feat(FN-1971): Step 1 - Add streaming variant of generateFeatureSuggestions

- Extract generateFeatureSuggestionsCore() that accepts onText/onThinking callbacks
- Refactor generateFeatureSuggestions() to use core with no-op callbacks
- Add generateFeatureSuggestionsStream() that uses core with SSE callbacks
- Add SseCallbacks interface for streaming output types
This commit is contained in:
Fusion
2026-04-18 10:24:59 -07:00
committed by gsxdsm
parent b612d0abb5
commit 0ffcc5e251
8 changed files with 117 additions and 27 deletions

View File

@@ -193,6 +193,7 @@ function AppInner() {
experimentalFeatures,
insightsEnabled,
roadmapEnabled,
memoryEnabled,
toggleAutoMerge,
toggleGlobalPause,
toggleEnginePause,
@@ -216,7 +217,10 @@ function AppInner() {
if (taskView === "agents" && !agentsEnabled) {
handleChangeTaskView("board");
}
}, [taskView, insightsEnabled, roadmapEnabled, experimentalFeatures, handleChangeTaskView, agentsEnabled]);
if (taskView === "memory" && !memoryEnabled) {
handleChangeTaskView("board");
}
}, [taskView, insightsEnabled, roadmapEnabled, experimentalFeatures, handleChangeTaskView, agentsEnabled, memoryEnabled]);
// Auto-close nodes overlay if feature flag is toggled off while overlay is open
useEffect(() => {
@@ -617,7 +621,7 @@ function AppInner() {
}
}}
isRemote={isRemote}
experimentalFeatures={{ insights: insightsEnabled, roadmap: roadmapEnabled }}
experimentalFeatures={{ insights: insightsEnabled, roadmap: roadmapEnabled, memoryView: memoryEnabled }}
/>
{viewMode === "project" && currentProject && !nodesOpen && taskView !== "missions" && !modalManager.isPlanningOpen && (
<SessionNotificationBanner
@@ -680,7 +684,7 @@ function AppInner() {
projectId={currentProject?.id}
showSkillsTab={skillsEnabled}
showAgentsTab={agentsEnabled}
experimentalFeatures={{ insights: insightsEnabled, roadmap: roadmapEnabled }}
experimentalFeatures={{ insights: insightsEnabled, roadmap: roadmapEnabled, memoryView: memoryEnabled }}
/>
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && (
<QuickChatFAB

View File

@@ -209,7 +209,7 @@ export interface HeaderProps {
/** Whether the current view is a remote node */
isRemote?: boolean;
/** Experimental feature flags controlling visibility of nav items. */
experimentalFeatures?: { insights?: boolean; roadmap?: boolean };
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean };
}
export function Header({
@@ -851,18 +851,20 @@ export function Header({
<span>Skills</span>
</button>
)}
<button
className={`view-toggle-overflow-item${view === "memory" ? " active" : ""}`}
onClick={() => {
onChangeView("memory");
setIsViewOverflowOpen(false);
}}
role="menuitem"
data-testid="view-toggle-memory"
>
<Brain size={14} />
<span>Memory</span>
</button>
{experimentalFeatures?.memoryView && (
<button
className={`view-toggle-overflow-item${view === "memory" ? " active" : ""}`}
onClick={() => {
onChangeView("memory");
setIsViewOverflowOpen(false);
}}
role="menuitem"
data-testid="view-toggle-memory"
>
<Brain size={14} />
<span>Memory</span>
</button>
)}
</div>
)}
</div>

View File

@@ -62,7 +62,7 @@ export interface MobileNavBarProps {
/** When true, shows the Agents mobile tab. Hidden by default (experimental feature). */
showAgentsTab?: boolean;
/** Experimental feature flags controlling visibility of nav items. */
experimentalFeatures?: { insights?: boolean; roadmap?: boolean };
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean };
}
function GitHubLogo({ size = 20 }: { size?: number }) {
@@ -540,15 +540,17 @@ export function MobileNavBar({
</button>
)}
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-memory"
onClick={() => handleMoreAction(() => onChangeView("memory"))}
>
<Brain />
<span>Memory</span>
</button>
{experimentalFeatures?.memoryView && (
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-memory"
onClick={() => handleMoreAction(() => onChangeView("memory"))}
>
<Brain />
<span>Memory</span>
</button>
)}
<div className="mobile-more-separator" />

View File

@@ -84,6 +84,7 @@ const AUTO_ARCHIVE_DEFAULT_AFTER_DAYS = 2;
const KNOWN_EXPERIMENTAL_FEATURES: Record<string, string> = {
insights: "Insights",
roadmap: "Roadmaps",
memoryView: "Memory",
};
export type SectionId = SettingsSection["id"];

View File

@@ -15,7 +15,7 @@ const defaultSettings: Settings = {
worktreeInitCommand: "",
testCommand: "",
buildCommand: "",
experimentalFeatures: { insights: true, roadmap: true, skillsView: true, agentsView: true },
experimentalFeatures: { insights: true, roadmap: true, skillsView: true, agentsView: true, memoryView: true },
};
vi.mock("../../api", async (importOriginal) => {
@@ -1348,6 +1348,49 @@ describe("App view switching", () => {
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
expect(screen.queryByTestId("view-overflow-insights")).toBeNull();
});
it("does not render memory view button when memoryView experimental feature is disabled", async () => {
// Override fetchSettings to return memoryView as disabled
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
experimentalFeatures: { memoryView: false },
});
render(<App />);
// Wait for the header to render
await waitFor(() => {
expect(screen.getByTitle("Board view")).toBeTruthy();
});
// Open the overflow menu - Memory item should not be rendered
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
expect(screen.queryByTestId("view-toggle-memory")).toBeNull();
});
it("redirects to board when memoryView experimental feature is disabled and taskView is memory", async () => {
// Set localStorage to memory view but memoryView is disabled
localStorage.setItem(taskViewStorageKey(), "memory");
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
experimentalFeatures: { memoryView: false },
});
render(<App />);
// Wait for the app to settle
await waitFor(() => {
expect(fetchSettings).toHaveBeenCalled();
});
// Should redirect to board view since memory is disabled
await waitFor(() => {
expect(document.querySelector(".board")).toBeTruthy();
});
// Cleanup
localStorage.removeItem(taskViewStorageKey());
});
});
describe("App GitHub import", () => {

View File

@@ -495,6 +495,28 @@ describe("Header", () => {
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
expect(screen.queryByTestId("view-overflow-skills")).toBeNull();
});
it("does not render memory overflow item when memoryView is not enabled", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} experimentalFeatures={{}} />);
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
expect(screen.queryByTestId("view-toggle-memory")).toBeNull();
});
it("renders memory overflow item when memoryView is enabled", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} experimentalFeatures={{ memoryView: true }} />);
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
expect(screen.getByTestId("view-toggle-memory")).toBeDefined();
});
it("calls onChangeView with 'memory' when Memory overflow item is clicked", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} experimentalFeatures={{ memoryView: true }} />);
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
fireEvent.click(screen.getByTestId("view-toggle-memory"));
expect(onChangeView).toHaveBeenCalledWith("memory");
});
});
// ── Search Visibility by View ─────────────────────────────────────

View File

@@ -241,6 +241,18 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-more-item-insights")).toBeDefined();
});
it("does not show memory in more sheet when memoryView is not enabled", () => {
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{}} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
expect(screen.queryByTestId("mobile-more-item-memory")).toBeNull();
});
it("shows memory in more sheet when memoryView is enabled", () => {
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{ memoryView: true }} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
expect(screen.getByTestId("mobile-more-item-memory")).toBeDefined();
});
it("insights item in more sheet calls onChangeView with 'insights'", () => {
const props = createDefaultProps();
const { container } = render(<MobileNavBar {...props} experimentalFeatures={{ insights: true }} />);

View File

@@ -16,6 +16,7 @@ export interface UseAppSettingsResult {
experimentalFeatures: Record<string, boolean>;
insightsEnabled: boolean;
roadmapEnabled: boolean;
memoryEnabled: boolean;
toggleAutoMerge: () => Promise<void>;
toggleGlobalPause: () => Promise<void>;
toggleEnginePause: () => Promise<void>;
@@ -39,6 +40,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
const [experimentalFeatures, setExperimentalFeatures] = useState<Record<string, boolean>>({});
const [insightsEnabled, setInsightsEnabled] = useState(false);
const [roadmapEnabled, setRoadmapEnabled] = useState(false);
const [memoryEnabled, setMemoryEnabled] = useState(false);
/**
* Fetches config and settings from the backend and updates local state.
@@ -67,6 +69,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
const features = settings.experimentalFeatures ?? {};
setInsightsEnabled(features.insights === true);
setRoadmapEnabled(features.roadmap === true);
setMemoryEnabled(features.memoryView === true);
}
}, [projectId]);
@@ -130,6 +133,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
experimentalFeatures,
insightsEnabled,
roadmapEnabled,
memoryEnabled,
toggleAutoMerge,
toggleGlobalPause,
toggleEnginePause,