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

This commit is contained in:
Fusion
2026-04-16 14:55:23 -07:00
committed by gsxdsm
parent b1fcb54eda
commit 78a7c0a829
6 changed files with 668 additions and 65 deletions

View File

@@ -0,0 +1,243 @@
# Interface Packages Audit — FN-1963
## Summary
Comprehensive read-only audit completed across `packages/cli`, `packages/tui`, `packages/desktop`, `packages/mobile`, and `packages/plugin-sdk`.
**Total findings: 22**
- **Critical:** 1
- **High:** 3
- **Medium:** 15
- **Low:** 3
The most severe issue is a **desktop API contract split** between preload and renderer codepaths that can disable native desktop integrations (window controls, IPC API transport, deep-link/update hooks). Secondary concerns cluster around lifecycle cleanup (`process.exit` in command helpers, non-disposed stores/listeners) and brittle API boundaries (direct source imports in plugin-sdk, string-based duplicate detection in extension imports).
## Critical Findings
### IF-001 — Desktop preload/renderer API contract drift breaks native bridge
- **Severity:** critical
- **Location:**
- `packages/desktop/src/preload.ts:6-43`
- `packages/desktop/src/renderer/hooks/useElectron.ts:15-19`
- `packages/desktop/src/renderer/components/TitleBar.tsx:18-23`
- `packages/desktop/src/renderer/api-electron.ts:141`
- `packages/desktop/src/ipc.ts:5-59` (no `api-request` handler)
- **Description:** Preload exposes `window.fusionAPI`, but renderer stack checks `window.electronAPI` and expects methods/channels (`windowControl`, `getPlatform`, `invoke("api-request")`, `installUpdate`) that are not exposed/handled.
- **Impact:** Desktop-only functionality silently degrades or no-ops (custom title bar controls, Electron transport path, update/deep-link hooks), causing inconsistent behavior between web vs desktop shell.
- **Suggested fix:** Define one canonical bridge contract and align all layers (preload typings, renderer hooks/components, IPC handler channels). Add integration tests that boot preload+renderer together and assert bridge method parity.
## High Findings
### IF-002 — Daemon token printed in cleartext at startup
- **Severity:** high
- **Location:** `packages/cli/src/commands/daemon.ts:456-463` (despite `maskToken` at `daemon.ts:108-115`)
- **Description:** Startup banner logs full daemon bearer token.
- **Impact:** Token exposure in terminal recordings/log aggregation/shell history can allow unauthorized API access.
- **Suggested fix:** Mask token by default (show prefix/suffix only), add `--show-token` explicit override if full token display is truly needed.
### IF-003 — TaskStore lifecycle leaks in settings/backup command paths
- **Severity:** high
- **Location:**
- `packages/cli/src/commands/settings-import.ts:28-119`
- `packages/cli/src/commands/settings-export.ts:22-70`
- `packages/cli/src/commands/backup.ts:27-54`
- **Description:** Commands construct `TaskStore` instances but never close them, and several branches terminate via `process.exit(...)`.
- **Impact:** In embedded/in-process usage (tests, extension-hosted command reuse), DB handles can leak and produce lock contention.
- **Suggested fix:** Wrap store usage in `try/finally` with `await store.close()`, and return structured results/errors to top-level runner instead of direct exits in helpers.
### IF-004 — Auto-updater listeners can be registered repeatedly
- **Severity:** high
- **Location:**
- `packages/desktop/src/main.ts:97` (setup at boot)
- `packages/desktop/src/ipc.ts:35-38` (setup again on each check)
- `packages/desktop/src/native.ts:132-156`
- **Description:** `setupAutoUpdater()` registers event handlers each call with no idempotency guard/removal.
- **Impact:** Duplicate notifications/events and potential listener leak warnings over long sessions.
- **Suggested fix:** Add one-time registration guard (module-level boolean or `autoUpdater.listenerCount(...)` check) and separate "trigger check" from "register listeners".
## Medium Findings
### IF-005 — `--port` parsing accepts missing/invalid values as `NaN`
- **Severity:** medium
- **Location:** `packages/cli/src/bin.ts:317-346`
- **Description:** `parseInt(args[pi + 1], 10)` is used without validating value presence/type.
- **Impact:** `fn dashboard --port` (no value) or invalid values can cascade into non-user-friendly runtime errors.
- **Suggested fix:** Use shared validated parser (`getFlagValueNumber`) and emit explicit usage errors when value missing/invalid.
### IF-006 — Async disposal callbacks in dashboard are not awaited
- **Severity:** medium
- **Location:** `packages/cli/src/commands/dashboard.ts:468-479` (with async callbacks added at `dashboard.ts:571-574`)
- **Description:** `disposeCallbacks` are typed/consumed as sync; async teardown work is fire-and-forget.
- **Impact:** Shutdown order becomes nondeterministic, and cleanup races can occur under signal handling.
- **Suggested fix:** Make `dispose` async and await each callback, or enforce sync-only callbacks.
### IF-007 — `runServe` repeatedly registers process listeners
- **Severity:** medium
- **Location:** `packages/cli/src/commands/serve.ts:146-188`
- **Description:** Diagnostics setup adds `beforeExit`/`exit`/`uncaughtExceptionMonitor`/`unhandledRejection` listeners each invocation without a registration guard.
- **Impact:** Duplicate logs and listener growth in programmatic multi-run scenarios.
- **Suggested fix:** Mirror dashboards `processDiagnosticsRegistered` guard pattern and unregister where appropriate.
### IF-008 — Extension store cache is cleared without disposing stores
- **Severity:** medium
- **Location:**
- `packages/cli/src/extension.ts:40-49`
- `packages/cli/src/extension.ts:1909-1916`
- **Description:** `storeCache.clear()` drops references but never closes cached `TaskStore` instances.
- **Impact:** Potential open DB handles across session lifecycles and stale connections after project switches.
- **Suggested fix:** Iterate cache values on shutdown and call `close()` before clearing.
### IF-009 — `fn_task_plan` monkey-patches global console and ignores `ctx.cwd`
- **Severity:** medium
- **Location:** `packages/cli/src/extension.ts:1000-1029`
- **Description:** Tool temporarily overrides global `console.log/error`; also calls `runTaskPlan(..., true)` without passing project context/cwd.
- **Impact:** Concurrent tool calls can interleave logs/race restore; planning may target wrong project when host cwd differs.
- **Suggested fix:** Avoid global console patching (return structured output from planner API) and thread explicit project/cwd into planner path.
### IF-010 — GitHub import duplicate detection is substring-based
- **Severity:** medium
- **Location:**
- `packages/cli/src/extension.ts:771`
- `packages/cli/src/extension.ts:848`
- **Description:** Existing import checks use `task.description.includes(sourceUrl)`.
- **Impact:** False positives/negatives with partial URL matches or edited descriptions can skip valid imports or duplicate tasks.
- **Suggested fix:** Parse canonical `Source:` metadata or persist source URL in a structured task field.
### IF-011 — `fn_skills_install` child process has no timeout/cancellation strategy
- **Severity:** medium
- **Location:** `packages/cli/src/extension.ts:1761-1784`
- **Description:** `spawn("npx", ...)` waits indefinitely for exit; no timeout or signal handling.
- **Impact:** Hung `npx` can stall tool execution indefinitely.
- **Suggested fix:** Add timeout with forced termination and user-facing timeout error.
### IF-012 — `/fn` command state is module-level and session-shared
- **Severity:** medium
- **Location:** `packages/cli/src/extension.ts:1820-1904`
- **Description:** `dashboardProcess`/`dashboardPort` are shared state for command handler instance.
- **Impact:** Multiple sessions in same runtime can interfere (status/start/stop collisions).
- **Suggested fix:** Scope process state per session/context or maintain keyed process registry.
### IF-013 — TUI terminal sizing clamps to minimum, masking real narrow widths
- **Severity:** medium
- **Location:** `packages/tui/src/utils/terminal.ts:65-69`
- **Description:** Width/height are forced to min (80x24), then used as effective dimensions.
- **Impact:** Very narrow terminals can render as if wider than reality, leading to overflow/wrapping artifacts.
- **Suggested fix:** Expose both actual and clamped dimensions; render logic should respect actual bounds and optionally warn/fallback when below minimum.
### IF-014 — TUI truncation is not display-width aware (Unicode/emoji)
- **Severity:** medium
- **Location:** `packages/tui/src/utils/truncate.ts:37-55`
- **Description:** Uses `string.length`/`slice` for terminal width.
- **Impact:** Wide characters and grapheme clusters misalign table columns and produce visual corruption.
- **Suggested fix:** Use wcwidth/grapheme-aware measurement/truncation utilities.
### IF-015 — Number-key routing is duplicated and not focus-guarded in router
- **Severity:** medium
- **Location:**
- `packages/tui/src/components/screen-router.tsx:114-123`
- `packages/tui/src/hooks/use-global-shortcuts.tsx:141-146`
- `packages/tui/src/index.tsx:65-67,82-85`
- **Description:** Screen switching is handled in both router and global shortcuts; router path ignores focus guard.
- **Impact:** Double state updates and accidental screen changes while typing in focused inputs.
- **Suggested fix:** Centralize screen-switch handling and enforce focus guard in a single input handler path.
### IF-016 — `useActivityLog` live conversion loses type fidelity and cancellation is ineffective
- **Severity:** medium
- **Location:** `packages/tui/src/hooks/use-activity-log.ts:76-86,98-110`
- **Description:** Live `agent:log` events are coerced to `type: "task:updated"`; async fetch still mutates state after effect cancellation.
- **Impact:** Type filtering becomes misleading for live updates; potential set-state-after-unmount warnings.
- **Suggested fix:** Preserve/log original event kind (or explicit mapping), and gate state updates by cancellation/version guard.
### IF-017 — Mobile `initializePlugins` lacks rollback on partial init failure
- **Severity:** medium
- **Location:** `packages/mobile/src/index.ts:61-104`
- **Description:** Managers initialize sequentially; if later init fails, earlier managers remain active.
- **Impact:** Partial startup can leave intervals/listeners running without returned references/cleanup coordination.
- **Suggested fix:** Add transactional init with rollback (`destroy`) for already-started managers on failure.
### IF-018 — Push manager `start()` is not idempotent
- **Severity:** medium
- **Location:** `packages/mobile/src/plugins/push-notifications.ts:82-86` + listener accumulation at `89-98`
- **Description:** Repeated `start()` calls re-register listeners without guard.
- **Impact:** Duplicate notification events and multiplied side effects.
- **Suggested fix:** Track started state and short-circuit or teardown before re-init.
### IF-019 — Deep-link PWA fallback misses initial hash and leaves encoded IDs in custom-scheme path parsing
- **Severity:** medium
- **Location:**
- `packages/mobile/src/plugins/deep-links.ts:91-118`
- `packages/mobile/src/plugins/deep-links.ts:184-192`
- **Description:** Handler listens to `hashchange` only (no initial hash consume); custom scheme segments are assigned without decoding.
- **Impact:** First-load deeplink can be dropped; encoded task/project IDs can propagate incorrectly.
- **Suggested fix:** Invoke bound hash handler once during initialize and decode path segments when constructing payload.
### IF-020 — Plugin SDK imports core source via relative paths (bypasses package boundary)
- **Severity:** medium
- **Location:**
- `packages/plugin-sdk/src/index.ts:57-61`
- `packages/plugin-sdk/src/index.test.ts:3-5`
- **Description:** SDK depends on `../../core/src/...` internals rather than stable package entrypoints.
- **Impact:** Core refactors can break SDK consumers; publish-time portability is brittle.
- **Suggested fix:** Re-export/import from public package exports (`@fusion/core`) and keep SDK isolated from internal source layout.
### IF-021 — Plugin SDK omits key plugin-store/loader public types
- **Severity:** medium
- **Location:**
- Missing from `packages/plugin-sdk/src/index.ts`
- Present in core at `packages/core/src/plugin-store.ts:18-36`, `packages/core/src/plugin-loader.ts:31-40`
- **Description:** `PluginStoreEvents`, `PluginRegistrationInput`, `PluginUpdateInput`, `PluginLoaderOptions` are not exposed by SDK.
- **Impact:** Plugin/tooling authors must import from core internals, reinforcing boundary violations.
- **Suggested fix:** Re-export these types in SDK index and add export-surface tests.
## Low Findings
### IF-022 — `definePlugin` helper loses specific subtype inference
- **Severity:** low
- **Location:** `packages/plugin-sdk/src/index.ts:99-100`
- **Description:** Signature is `FusionPlugin -> FusionPlugin` instead of generic identity `<T extends FusionPlugin>(plugin: T) => T`.
- **Impact:** Reduced literal-type preservation and weaker IntelliSense in advanced plugin authoring scenarios.
- **Suggested fix:** Make helper generic and add TS assertion tests for inferred literal preservation.
### IF-023 — macOS activate path doesnt restore existing hidden window
- **Severity:** low
- **Location:** `packages/desktop/src/main.ts:124-129` with hide-on-close at `main.ts:63-72`
- **Description:** `activate` only handles `mainWindow === null`; hidden-but-existing window is not shown.
- **Impact:** Dock re-activation can appear unresponsive after hide-to-tray behavior.
- **Suggested fix:** On activate, show/focus existing hidden window when present.
### IF-024 — Utility duplication across CLI commands increases drift risk
- **Severity:** low
- **Location:**
- `packages/cli/src/commands/dashboard.ts:27-47`
- `packages/cli/src/commands/serve.ts:51-71`
- `packages/cli/src/commands/daemon.ts:49-69`
- **Description:** `formatBytes`/`formatUptime` are copy-pasted across modules.
- **Impact:** Behavior drift and repeated maintenance effort.
- **Suggested fix:** Move shared formatters to a common utility module in CLI package.
## Package-by-Package Details
### CLI (`packages/cli`)
- IF-002, IF-003, IF-005, IF-006, IF-007, IF-024
- Additional consistency note: helper-level `process.exit(...)` usage is pervasive across command modules (`task.ts`, `mission.ts`, `project.ts`, `node.ts`, `settings-import.ts`, `settings-export.ts`, `backup.ts`), making command functions hard to reuse safely outside direct CLI execution.
### Pi Extension (`packages/cli/src/extension.ts`)
- IF-008, IF-009, IF-010, IF-011, IF-012
### TUI (`packages/tui`)
- IF-013, IF-014, IF-015, IF-016
### Desktop (`packages/desktop`)
- IF-001, IF-004, IF-023
### Mobile (`packages/mobile`)
- IF-017, IF-018, IF-019
### Plugin SDK (`packages/plugin-sdk`)
- IF-020, IF-021, IF-022
### Cross-Cutting
- **Import consistency:** IF-001 and IF-020 show boundary drift (desktop bridge naming divergence, plugin-sdk source-relative imports).
- **Shared type alignment:** desktop has parallel incompatible API types (`fusionAPI` vs `electronAPI` contracts), and plugin-sdk misses public core plugin lifecycle/store types (IF-001, IF-021).
- **Error handling patterns:** CLI/extension mix thrown errors, `process.exit`, and `{ isError: true }` result contracts, creating inconsistent caller behavior.
- **Resource cleanup:** recurring lifecycle issues across CLI commands, extension cache disposal, desktop updater listeners, and mobile manager startup rollback/idempotency.
- **Test coverage gaps:** plugin-sdk tests focus on runtime identity but do not assert SDK export completeness or boundary integrity (IF-021/IF-022). A single skipped CLI bundle asset test exists at `packages/cli/src/__tests__/bundle-output.test.ts:49`.

View File

@@ -1,5 +1,5 @@
import { useState, useCallback } from "react";
import { Plus, Pencil, Trash2, Check, X, GripVertical, Sparkles, Download, Copy, Loader, ChevronLeft, ArrowLeft } from "lucide-react";
import { useState, useCallback, useEffect, useRef } from "react";
import { Plus, Pencil, Trash2, Check, X, GripVertical, Sparkles, Download, Copy, Loader, ChevronLeft, ArrowLeft, ChevronUp } from "lucide-react";
import type { ToastType } from "../hooks/useToast";
import { useRoadmaps, type FeatureSuggestion, type MilestoneSuggestion, type SuggestionDraftPatch } from "../hooks/useRoadmaps";
import { useViewportMode } from "../hooks/useViewportMode";
@@ -1456,6 +1456,18 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
// Goal prompt state for milestone suggestion generation
const [goalPrompt, setGoalPrompt] = useState("");
// Mobile suggestion panel collapse state
const [showSuggestionPanel, setShowSuggestionPanel] = useState(false);
// Reset suggestion panel when roadmap changes on mobile
const prevRoadmapIdRef = useRef<string | null>(null);
useEffect(() => {
if (prevRoadmapIdRef.current !== null && prevRoadmapIdRef.current !== selectedRoadmapId) {
setShowSuggestionPanel(false);
}
prevRoadmapIdRef.current = selectedRoadmapId;
}, [selectedRoadmapId]);
// Inline edit states
const [roadmapEdit, setRoadmapEdit] = useState<InlineEditState>({
roadmapId: null,
@@ -2245,67 +2257,154 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
</div>
{/* Milestone Suggestions Section */}
<div className="roadmap-suggestion-section">
<div className="roadmap-suggestion-header">
<h3 className="roadmap-suggestion-title">Generate Milestone Ideas</h3>
</div>
<div className="roadmap-suggestion-form">
<textarea
className="roadmap-suggestion-input"
value={goalPrompt}
onChange={(e) => setGoalPrompt(e.target.value)}
placeholder="Describe your roadmap goal (e.g., 'Build a user authentication system with OAuth, profiles, and admin dashboard')"
rows={2}
disabled={isGeneratingSuggestions || !selectedRoadmapId}
data-testid="goal-prompt-input"
/>
<div className="roadmap-suggestion-actions">
<button
className="roadmap-suggestion-generate-btn"
onClick={handleGenerateSuggestions}
disabled={!goalPrompt.trim() || isGeneratingSuggestions || !selectedRoadmapId}
data-testid="generate-suggestions-btn"
>
{isGeneratingSuggestions ? "Generating..." : "Generate Milestones"}
</button>
{isMobile ? (
showSuggestionPanel ? (
<div className="roadmap-suggestion-section">
<div className="roadmap-suggestion-header">
<h3 className="roadmap-suggestion-title">Generate Milestone Ideas</h3>
<button
className="roadmap-suggestion-collapse-btn"
onClick={() => setShowSuggestionPanel(false)}
aria-label="Collapse suggestion panel"
data-testid="collapse-suggestion-panel-btn"
>
<ChevronUp size={16} />
</button>
</div>
<div className="roadmap-suggestion-form">
<textarea
className="roadmap-suggestion-input"
value={goalPrompt}
onChange={(e) => setGoalPrompt(e.target.value)}
placeholder="Describe your roadmap goal (e.g., 'Build a user authentication system with OAuth, profiles, and admin dashboard')"
rows={2}
disabled={isGeneratingSuggestions || !selectedRoadmapId}
data-testid="goal-prompt-input"
autoFocus
/>
<div className="roadmap-suggestion-actions">
<button
className="roadmap-suggestion-generate-btn"
onClick={handleGenerateSuggestions}
disabled={!goalPrompt.trim() || isGeneratingSuggestions || !selectedRoadmapId}
data-testid="generate-suggestions-btn"
>
{isGeneratingSuggestions ? "Generating..." : "Generate Milestones"}
</button>
{milestoneSuggestions.length > 0 && (
<>
<button
className="roadmap-suggestion-accept-all-btn"
onClick={handleAcceptAllSuggestions}
data-testid="accept-all-suggestions-btn"
>
Accept All ({milestoneSuggestions.length})
</button>
<button
className="roadmap-suggestion-clear-btn"
onClick={handleClearSuggestions}
title="Clear suggestions"
aria-label="Clear suggestions"
data-testid="clear-suggestions-btn"
>
<X size={14} />
</button>
</>
)}
</div>
</div>
{/* Suggestion Cards */}
{milestoneSuggestions.length > 0 && (
<>
<button
className="roadmap-suggestion-accept-all-btn"
onClick={handleAcceptAllSuggestions}
data-testid="accept-all-suggestions-btn"
>
Accept All ({milestoneSuggestions.length})
</button>
<button
className="roadmap-suggestion-clear-btn"
onClick={handleClearSuggestions}
title="Clear suggestions"
aria-label="Clear suggestions"
data-testid="clear-suggestions-btn"
>
<X size={14} />
</button>
</>
<div className="roadmap-suggestion-list">
{milestoneSuggestions.map((suggestion) => (
<MilestoneSuggestionCard
key={suggestion.id}
suggestion={suggestion}
onUpdateDraft={(patch) => updateMilestoneSuggestionDraft(suggestion.id, patch)}
onAccept={() => handleAcceptSuggestion(suggestion.id)}
testIdPrefix="suggestion"
/>
))}
</div>
)}
</div>
</div>
{/* Suggestion Cards */}
{milestoneSuggestions.length > 0 && (
<div className="roadmap-suggestion-list">
{milestoneSuggestions.map((suggestion) => (
<MilestoneSuggestionCard
key={suggestion.id}
suggestion={suggestion}
onUpdateDraft={(patch) => updateMilestoneSuggestionDraft(suggestion.id, patch)}
onAccept={() => handleAcceptSuggestion(suggestion.id)}
testIdPrefix="suggestion"
/>
))}
) : (
<div className="roadmap-suggestion-section">
<button
className="roadmap-suggestion-expand-btn"
onClick={() => setShowSuggestionPanel(true)}
disabled={!selectedRoadmapId}
data-testid="expand-suggestion-panel-btn"
>
<Sparkles size={16} />
Generate Milestone Ideas
</button>
</div>
)}
</div>
)
) : (
<div className="roadmap-suggestion-section">
<div className="roadmap-suggestion-header">
<h3 className="roadmap-suggestion-title">Generate Milestone Ideas</h3>
</div>
<div className="roadmap-suggestion-form">
<textarea
className="roadmap-suggestion-input"
value={goalPrompt}
onChange={(e) => setGoalPrompt(e.target.value)}
placeholder="Describe your roadmap goal (e.g., 'Build a user authentication system with OAuth, profiles, and admin dashboard')"
rows={2}
disabled={isGeneratingSuggestions || !selectedRoadmapId}
data-testid="goal-prompt-input"
/>
<div className="roadmap-suggestion-actions">
<button
className="roadmap-suggestion-generate-btn"
onClick={handleGenerateSuggestions}
disabled={!goalPrompt.trim() || isGeneratingSuggestions || !selectedRoadmapId}
data-testid="generate-suggestions-btn"
>
{isGeneratingSuggestions ? "Generating..." : "Generate Milestones"}
</button>
{milestoneSuggestions.length > 0 && (
<>
<button
className="roadmap-suggestion-accept-all-btn"
onClick={handleAcceptAllSuggestions}
data-testid="accept-all-suggestions-btn"
>
Accept All ({milestoneSuggestions.length})
</button>
<button
className="roadmap-suggestion-clear-btn"
onClick={handleClearSuggestions}
title="Clear suggestions"
aria-label="Clear suggestions"
data-testid="clear-suggestions-btn"
>
<X size={14} />
</button>
</>
)}
</div>
</div>
{/* Suggestion Cards */}
{milestoneSuggestions.length > 0 && (
<div className="roadmap-suggestion-list">
{milestoneSuggestions.map((suggestion) => (
<MilestoneSuggestionCard
key={suggestion.id}
suggestion={suggestion}
onUpdateDraft={(patch) => updateMilestoneSuggestionDraft(suggestion.id, patch)}
onAccept={() => handleAcceptSuggestion(suggestion.id)}
testIdPrefix="suggestion"
/>
))}
</div>
)}
</div>
)}
{/* Milestone lanes */}
<div className="roadmaps-view__milestone-lanes">

View File

@@ -46,6 +46,7 @@ vi.mock("lucide-react", () => ({
Loader: (props: unknown) => <span data-testid="loader-icon" {...props}>Loader</span>,
ArrowLeft: (props: unknown) => <span data-testid="arrow-left-icon" {...props}>ArrowLeft</span>,
ChevronLeft: (props: unknown) => <span data-testid="chevron-left-icon" {...props}>ChevronLeft</span>,
ChevronUp: (props: unknown) => <span data-testid="chevron-up-icon" {...props}>ChevronUp</span>,
}));
// Viewport mode mock helper
@@ -919,4 +920,180 @@ describe("RoadmapsView", () => {
expect(screen.getByText("Milestone 2")).toBeInTheDocument();
});
});
describe("Mobile suggestion panel collapse", () => {
beforeEach(() => {
mockViewport("mobile");
});
afterEach(() => {
vi.restoreAllMocks();
});
it("shows expand button instead of suggestion section on mobile", async () => {
render(<RoadmapsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByTestId("mobile-roadmap-item-RM-001")).toBeInTheDocument();
});
// Select roadmap
fireEvent.click(screen.getByTestId("mobile-roadmap-item-RM-001"));
// On mobile, should show the expand button instead of the goal prompt input
await waitFor(() => {
expect(screen.getByTestId("expand-suggestion-panel-btn")).toBeInTheDocument();
});
expect(screen.queryByTestId("goal-prompt-input")).not.toBeInTheDocument();
});
it("expands suggestion panel on mobile when button is clicked", async () => {
render(<RoadmapsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByTestId("mobile-roadmap-item-RM-001")).toBeInTheDocument();
});
// Select roadmap
fireEvent.click(screen.getByTestId("mobile-roadmap-item-RM-001"));
// Expand the panel
await waitFor(() => {
expect(screen.getByTestId("expand-suggestion-panel-btn")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("expand-suggestion-panel-btn"));
// Panel should now be visible
await waitFor(() => {
expect(screen.getByTestId("goal-prompt-input")).toBeInTheDocument();
});
expect(screen.queryByTestId("expand-suggestion-panel-btn")).not.toBeInTheDocument();
});
it("can collapse suggestion panel on mobile", async () => {
render(<RoadmapsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByTestId("mobile-roadmap-item-RM-001")).toBeInTheDocument();
});
// Select roadmap
fireEvent.click(screen.getByTestId("mobile-roadmap-item-RM-001"));
// Expand the panel
await waitFor(() => {
expect(screen.getByTestId("expand-suggestion-panel-btn")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("expand-suggestion-panel-btn"));
// Wait for panel to expand
await waitFor(() => {
expect(screen.getByTestId("goal-prompt-input")).toBeInTheDocument();
});
// Collapse the panel
fireEvent.click(screen.getByTestId("collapse-suggestion-panel-btn"));
// Panel should be hidden, expand button should be back
await waitFor(() => {
expect(screen.getByTestId("expand-suggestion-panel-btn")).toBeInTheDocument();
});
expect(screen.queryByTestId("goal-prompt-input")).not.toBeInTheDocument();
});
it("persists goal prompt and suggestions across collapse/expand on mobile", async () => {
// Mock milestone suggestion generation
(api.generateMilestoneSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
suggestions: [
{ title: "Persisted Milestone", description: "Persisted description" },
],
});
render(<RoadmapsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByTestId("mobile-roadmap-item-RM-001")).toBeInTheDocument();
});
// Select roadmap
fireEvent.click(screen.getByTestId("mobile-roadmap-item-RM-001"));
// Expand the panel
await waitFor(() => {
expect(screen.getByTestId("expand-suggestion-panel-btn")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("expand-suggestion-panel-btn"));
// Type into goal prompt
await waitFor(() => {
expect(screen.getByTestId("goal-prompt-input")).toBeInTheDocument();
});
const goalInput = screen.getByTestId("goal-prompt-input");
await userEvent.type(goalInput, "Build an app");
// Generate suggestions
fireEvent.click(screen.getByTestId("generate-suggestions-btn"));
// Wait for suggestions to appear
await waitFor(() => {
expect(screen.getByText("Persisted Milestone")).toBeInTheDocument();
});
// Collapse the panel
fireEvent.click(screen.getByTestId("collapse-suggestion-panel-btn"));
// Wait for expand button to appear
await waitFor(() => {
expect(screen.getByTestId("expand-suggestion-panel-btn")).toBeInTheDocument();
});
// Re-expand the panel
fireEvent.click(screen.getByTestId("expand-suggestion-panel-btn"));
// Goal prompt and suggestions should persist
await waitFor(() => {
expect(screen.getByTestId("goal-prompt-input")).toBeInTheDocument();
});
expect(screen.getByTestId("goal-prompt-input")).toHaveValue("Build an app");
expect(screen.getByText("Persisted Milestone")).toBeInTheDocument();
});
it("resets suggestion panel when switching roadmaps on mobile", async () => {
render(<RoadmapsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByTestId("mobile-roadmap-item-RM-001")).toBeInTheDocument();
});
// Select RM-001
fireEvent.click(screen.getByTestId("mobile-roadmap-item-RM-001"));
// Expand the panel
await waitFor(() => {
expect(screen.getByTestId("expand-suggestion-panel-btn")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("expand-suggestion-panel-btn"));
await waitFor(() => {
expect(screen.getByTestId("goal-prompt-input")).toBeInTheDocument();
});
// Go back to roadmap list
fireEvent.click(screen.getByTestId("mobile-back-btn"));
// Wait for list to appear
await waitFor(() => {
expect(screen.getByTestId("mobile-roadmap-item-RM-002")).toBeInTheDocument();
});
// Switch to RM-002
fireEvent.click(screen.getByTestId("mobile-roadmap-item-RM-002"));
// Panel should be collapsed (expand button visible)
await waitFor(() => {
expect(screen.getByTestId("expand-suggestion-panel-btn")).toBeInTheDocument();
});
expect(screen.queryByTestId("goal-prompt-input")).not.toBeInTheDocument();
});
});
});

View File

@@ -33327,6 +33327,55 @@ html .column.drag-over * {
color: var(--text-primary);
}
/* === Mobile Suggestion Panel Expand/Collapse === */
.roadmap-suggestion-expand-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-md) var(--space-lg);
color: var(--text-primary);
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
transition: background var(--transition-fast), color var(--transition-fast);
}
.roadmap-suggestion-expand-btn:hover:not(:disabled) {
background: var(--card-hover);
}
.roadmap-suggestion-expand-btn:active:not(:disabled) {
transform: scale(0.98);
}
.roadmap-suggestion-expand-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.roadmap-suggestion-collapse-btn {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: var(--space-xs);
border-radius: var(--radius-sm);
transition: color var(--transition-fast);
}
.roadmap-suggestion-collapse-btn:hover {
color: var(--text-primary);
}
/* Mobile responsive */
@media (max-width: 768px) {
/* Allow vertical scrolling on mobile for roadmap list and detail views */
@@ -33571,6 +33620,10 @@ html .column.drag-over * {
margin: var(--space-md);
}
.roadmap-suggestion-expand-btn {
margin: var(--space-md);
}
.roadmap-suggestion-actions {
flex-wrap: wrap;
}

View File

@@ -735,7 +735,7 @@ export function createMissionRouter(
missionStore.updateMission(mission.id, { interviewState: "completed" as InterviewState });
// Create milestones, slices, and features with verification in dedicated fields.
// For each feature, auto-generate a linked contract assertion.
// Auto-generate contract assertions for milestone, slice, and feature levels.
for (const milestoneData of (summary.milestones ?? [])) {
// Use dedicated verification field instead of concatenating into description
const milestone = missionStore.addMilestone(mission.id, {
@@ -744,6 +744,16 @@ export function createMissionRouter(
verification: milestoneData.verification,
});
// Milestone-level assertion remains on the milestone even when it has no slices.
missionStore.addContractAssertion(milestone.id, {
title: `Milestone: ${milestoneData.title}`,
assertion:
milestoneData.verification
|| milestoneData.description
|| `Verify milestone completion: ${milestoneData.title}`,
status: "pending",
});
for (const sliceData of (milestoneData.slices ?? [])) {
// Use dedicated verification field instead of concatenating into description
const slice = missionStore.addSlice(milestone.id, {
@@ -752,6 +762,16 @@ export function createMissionRouter(
verification: sliceData.verification,
});
// Slice-level assertion for explicit verification criteria.
missionStore.addContractAssertion(milestone.id, {
title: `Slice: ${sliceData.title}`,
assertion:
sliceData.verification
|| sliceData.description
|| `Verify slice completion: ${sliceData.title}`,
status: "pending",
});
for (const featureData of (sliceData.features ?? [])) {
const feature = missionStore.addFeature(slice.id, {
title: featureData.title,
@@ -759,8 +779,7 @@ export function createMissionRouter(
acceptanceCriteria: featureData.acceptanceCriteria,
});
// Auto-generate a contract assertion for this feature
// Assertion text source priority: acceptanceCriteria -> description -> fallback
// Feature assertion text source priority: acceptanceCriteria -> description -> fallback
const assertionText = featureData.acceptanceCriteria
|| featureData.description
|| `Verify implementation of: ${featureData.title}`;

View File

@@ -9450,20 +9450,32 @@ describe("POST /api/ai/summarize-title", () => {
});
it("accepts optional provider and modelId parameters", async () => {
const fusionCore = await import("@fusion/core");
const summarizeTitleSpy = vi
.spyOn(fusionCore, "summarizeTitle")
.mockResolvedValueOnce("Generated title");
const description = "x".repeat(300);
const res = await REQUEST(
buildApp(),
"POST",
"/api/ai/summarize-title",
JSON.stringify({
description: "x".repeat(300),
description,
provider: "google",
modelId: "gemini-2.5-pro",
}),
{ "Content-Type": "application/json" },
);
// Either 200 (success) or 503 (AI service unavailable) is acceptable
expect([200, 503]).toContain(res.status);
expect(res.status).toBe(200);
expect(res.body).toEqual({ title: "Generated title" });
expect(summarizeTitleSpy).toHaveBeenCalledWith(
description,
"/test/project",
"google",
"gemini-2.5-pro",
);
});
});