FN-6881: move stash recovery into Git Manager

Move stash recovery into the Git Manager while retiring the standalone dashboard route.

- Add a Recovery section to Git Manager that hosts the existing StashRecoveryView.
- Remove stash recovery from top-level, overflow, mobile, and lazy-loaded view registries.
- Preserve orphaned-stash counts on Git Manager entry points and migrate saved stash-recovery routes back to Board.
- Update dashboard docs, lazy-load inventory, tests, and add a published package changeset.

Files changed:
 .changeset/fn-6881-stash-recovery-git-manager.md   |  5 +++
 AGENTS.md                                          |  3 +-
 docs/dashboard-guide.md                            |  9 ++--
 packages/dashboard/app/App.tsx                     | 13 ------
 .../app/__tests__/lazy-loaded-views-docs.test.ts   | 11 ++---
 .../dashboard/app/components/GitManagerModal.tsx   | 19 +++++++-
 packages/dashboard/app/components/Header.css       |  9 ++++
 packages/dashboard/app/components/Header.tsx       | 22 +++-------
 .../dashboard/app/components/LeftSidebarNav.tsx    |  4 --
 packages/dashboard/app/components/MobileNavBar.tsx | 16 +------
 .../components/__tests__/GitManagerModal.test.tsx  | 51 ++++++++++++++++++++++
 .../app/components/__tests__/Header.test.tsx       | 21 +++++++++
 .../components/__tests__/LeftSidebarNav.test.tsx   | 10 ++---
 .../app/components/__tests__/MobileNavBar.test.tsx | 10 +++++
 .../app/components/__tests__/RightDock.test.tsx    |  4 +-
 .../__tests__/overflowViewRegistry.test.tsx        |  3 +-
 .../app/components/overflowViewRegistry.tsx        | 10 -----
 packages/dashboard/app/hooks/__tests__/useViewState.test.ts       | 28 ++++++++++++
 packages/dashboard/app/hooks/useViewState.ts       | 19 +++++++-
 packages/dashboard/src/view-chunk-manifest.ts      |  1 -
 20 files changed, 184 insertions(+), 84 deletions(-)

Fusion-Task-Id: FN-6881

Fusion-Task-Lineage: 467d34ec-8a6a-46db-8eb8-cb0216c031da
This commit is contained in:
gsxdsm
2026-06-21 20:28:33 -07:00
parent 15d427bc11
commit 4fec139927
20 changed files with 184 additions and 84 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Move Stash Recovery into the Git Manager Recovery tab and remove the standalone top-level Stash Recovery view from dashboard navigation.

View File

@@ -221,7 +221,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme
### Lazy-Loaded Heavy Views
These 21 views are lazy-loaded via `React.lazy()` with `<Suspense fallback={null}>`.
These 20 views are lazy-loaded via `React.lazy()` with `<Suspense fallback={null}>`.
Keep this AGENTS inventory in sync with App lazy imports, AppModals lazy modal imports (`SettingsModal`, `WorkflowNodeEditor`, `SetupWizardModal`), and `packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts`.
- `AgentsView`
@@ -237,7 +237,6 @@ Keep this AGENTS inventory in sync with App lazy imports, AppModals lazy modal i
- `EvalsView`
- `TodoView`
- `GoalsView`
- `StashRecoveryView`
- `PullRequestView`
- `SetupWizardModal`
- `SettingsModal`

View File

@@ -394,6 +394,7 @@ Features:
- One-click **Sync** action in Remotes (`git pull --rebase` followed by push; it stops and surfaces an error instead of pushing when the pull conflicts or fails)
- Remote editing controls
- Stash inspection (view stat + patch) before apply/pop/drop actions
- Stash Recovery tab for orphaned merger-autostashes; orphan counts appear on Git Manager entry points instead of a standalone Stash Recovery view
- Remotes tab keeps "Recent commits on {remote}" in sync immediately after successful push/pull actions
![Git manager](./screenshots/git-manager.png)
@@ -765,13 +766,13 @@ Features:
For module-level behavior and API surfaces, see [Dev Server modules](./dev-server-modules.md).
## Stash Recovery View
## Stash Recovery in Git Manager
Stash Recovery view helps recover orphaned merger autostashes (`fusion-merger-autostash:*`) left behind when merge restore could not fully complete.
Stash Recovery helps recover orphaned merger autostashes (`fusion-merger-autostash:*`) left behind when merge restore could not fully complete. It now lives as the **Recovery** tab in **Git Manager**; the former standalone top-level Stash Recovery view is removed from desktop and mobile navigation.
Navigation:
- Desktop: **Header → More views → Stash Recovery**
- Mobile: **More** sheet → **Stash Recovery**
- Desktop: **Git Manager → Recovery**
- Mobile: **More** sheet → **Git Manager → Recovery**
Features:
- Lists orphaned stash entries grouped by source task ID (or **Unknown source** when unavailable)

View File

@@ -122,7 +122,6 @@ const CommandCenter = lazy(() => import("./components/command-center/CommandCent
const DevServerView = lazy(() => import("./components/DevServerView").then((m) => ({ default: m.DevServerView })));
const TodoView = lazy(() => import("./components/TodoView").then((m) => ({ default: m.TodoView })));
const GoalsView = lazy(() => import("./components/GoalsView").then((m) => ({ default: m.GoalsView })));
const StashRecoveryView = lazy(() => import("./components/StashRecoveryView").then((m) => ({ default: m.StashRecoveryView })));
const PullRequestView = lazy(() => import("./components/PullRequestView").then((m) => ({ default: m.PullRequestView })));
// Warm lazy chunks during browser idle so first navigation to each view is
@@ -151,7 +150,6 @@ function prefetchLazyViews() {
void import("./components/DevServerView");
void import("./components/TodoView");
void import("./components/GoalsView");
void import("./components/StashRecoveryView");
void import("./components/PullRequestView");
});
}
@@ -1667,16 +1665,6 @@ function AppInner() {
);
}
if (taskView === "stash-recovery") {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<StashRecoveryView />
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "pull-requests") {
return (
<PageErrorBoundary>
@@ -2162,7 +2150,6 @@ function AppInner() {
mailboxUnreadCount={mailboxUnreadCount}
mailboxPendingApprovalCount={mailboxPendingApprovalCount}
chatHasUnreadResponse={chatHasUnreadResponse}
stashOrphanCount={stashOrphanCount}
experimentalFeatures={{
insights: insightsEnabled,
memoryView: memoryEnabled,

View File

@@ -11,6 +11,9 @@ FN-6702 removes ReliabilityView from the App-level lazy inventory because Reliab
FNXC:CommandCenter 2026-06-19-00:00:
FN-6717 removes NodesView from the App-level lazy inventory because Nodes now mounts inside the lazy CommandCenter chunk.
FNXC:GitManager 2026-06-21-00:00:
FN-6881 removes StashRecoveryView from the App-level lazy inventory because Stash Recovery now mounts through the lazy GitManagerModal chunk.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
@@ -30,7 +33,6 @@ const EXPECTED_DOCUMENTED_VIEWS = new Set([
"EvalsView",
"TodoView",
"GoalsView",
"StashRecoveryView",
"PullRequestView",
"SetupWizardModal",
"SettingsModal",
@@ -54,7 +56,6 @@ const EXPECTED_APP_LEVEL_VIEWS = new Set([
"DevServerView",
"TodoView",
"GoalsView",
"StashRecoveryView",
"PullRequestView",
]);
@@ -99,7 +100,7 @@ function extractAppModalsLazyViews(appModalsSource: string): Set<string> {
}
describe("AGENTS lazy-loaded views inventory", () => {
it("documents the App-level and AppModals lazy views accurately and keeps the curated 21-view list in sync", () => {
it("documents the App-level and AppModals lazy views accurately and keeps the curated 20-view list in sync", () => {
const agentsDoc = readFileSync(resolve(__dirname, "../../../../AGENTS.md"), "utf-8");
const appSource = readFileSync(resolve(__dirname, "../App.tsx"), "utf-8");
const appModalsSource = readFileSync(resolve(__dirname, "../components/AppModals.tsx"), "utf-8");
@@ -107,11 +108,11 @@ describe("AGENTS lazy-loaded views inventory", () => {
const section = extractLazyLoadedSection(agentsDoc);
const countMatch = section.match(/These\s+(\d+)\s+views\s+are lazy-loaded/);
expect(countMatch).toBeTruthy();
expect(Number(countMatch?.[1])).toBe(21);
expect(Number(countMatch?.[1])).toBe(20);
const documentedViews = extractBacktickedNamesFromBullets(section);
expect(new Set(documentedViews)).toEqual(EXPECTED_DOCUMENTED_VIEWS);
expect(documentedViews).toHaveLength(21);
expect(documentedViews).toHaveLength(20);
expect(section).toContain("`ResearchView`");
expect(section).toContain("`TodoView`");

View File

@@ -58,6 +58,7 @@ import {
fetchRemoteCommits,
fetchBranchCommits,
} from "../api";
import { StashRecoveryView } from "./StashRecoveryView";
import {
GitBranch as GitBranchIcon,
GitCommit as GitCommitIcon,
@@ -91,11 +92,12 @@ import {
Send,
Pencil,
Info,
History,
} from "lucide-react";
// ── Types & Constants ─────────────────────────────────────────────
type SectionId = "status" | "changes" | "commits" | "branches" | "worktrees" | "stashes" | "remotes";
type SectionId = "status" | "changes" | "commits" | "branches" | "worktrees" | "stashes" | "recovery" | "remotes";
const SECTIONS: { id: SectionId; label: string; icon: React.ComponentType<{ size?: number }> }[] = [
@@ -105,6 +107,11 @@ const SECTIONS: { id: SectionId; label: string; icon: React.ComponentType<{ size
{ id: "branches", label: "Branches", icon: GitBranchIcon },
{ id: "worktrees", label: "Worktrees", icon: HardDrive },
{ id: "stashes", label: "Stashes", icon: Archive },
/*
FNXC:GitManager 2026-06-21-00:00:
FN-6881 re-homes orphaned-autostash Stash Recovery from a standalone top-level view into a Git Manager section so users have one canonical recovery destination while the /stash-recovery API remains unchanged.
*/
{ id: "recovery", label: "Recovery", icon: History },
{ id: "remotes", label: "Remotes", icon: GitMerge },
];
@@ -333,6 +340,10 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
stashDiffRequestIdRef.current += 1;
break;
}
case "recovery": {
// StashRecoveryView self-fetches /stash-recovery/orphans; this branch exists so selecting Recovery clears the modal loading state without issuing an unrelated git status request.
break;
}
case "remotes": {
const remoteStatus = await fetchGitStatus(projectId, { extended: true });
setStatus(remoteStatus);
@@ -938,6 +949,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
branches: t("git.sectionBranches", "Branches"),
worktrees: t("git.sectionWorktrees", "Worktrees"),
stashes: t("git.sectionStashes", "Stashes"),
recovery: t("git.sectionRecovery", "Recovery"),
remotes: t("git.sectionRemotes", "Remotes"),
}[section.id] ?? section.label;
return (
@@ -1076,6 +1088,11 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
/>
)}
{/* ── Recovery Panel ── */}
{activeSection === "recovery" && !loading && (
<StashRecoveryView />
)}
{/* ── Remotes Panel ── */}
{activeSection === "remotes" && !loading && (
<RemotesPanel

View File

@@ -326,6 +326,10 @@ non-notched devices, so this is a no-op there. Pair with viewport-fit=cover (ind
border-color: var(--todo);
}
.header-git-manager-btn {
position: relative;
}
.btn-badge {
position: absolute;
top: -2px;
@@ -811,6 +815,11 @@ non-notched devices, so this is a no-op there. Pair with viewport-fit=cover (ind
background: var(--card);
}
.mobile-overflow-item--with-badge .btn-badge {
position: static;
margin-left: auto;
}
.mobile-overflow-item svg {
color: var(--text-muted);
flex-shrink: 0;

View File

@@ -1108,7 +1108,7 @@ export function Header({
<>
<button
ref={viewOverflowTriggerRef}
className={`view-toggle-btn${!shouldRouteMoreViewsToRightDock && (["research", "skills", "insights", "memory", "secrets", "dev-server", "devserver", "graph", "stash-recovery", "todos"].includes(view) || (isTablet && view === "documents") || (experimentalFeatures?.evalsView && view === "evals") || (experimentalFeatures?.goalsView && view === "goalsView") || isPluginViewId(view)) ? " active" : ""}`}
className={`view-toggle-btn${!shouldRouteMoreViewsToRightDock && (["research", "skills", "insights", "memory", "secrets", "dev-server", "devserver", "graph", "todos"].includes(view) || (isTablet && view === "documents") || (experimentalFeatures?.evalsView && view === "evals") || (experimentalFeatures?.goalsView && view === "goalsView") || isPluginViewId(view)) ? " active" : ""}`}
onClick={() => {
if (shouldRouteMoreViewsToRightDock) {
setIsViewOverflowOpen(false);
@@ -1165,20 +1165,6 @@ export function Header({
<span>{t("header.goalsView", "Goals")}</span>
</button>
)}
<button
className={`view-toggle-overflow-item${view === "stash-recovery" ? " active" : ""}`}
onClick={() => {
onChangeView("stash-recovery");
setIsViewOverflowOpen(false);
}}
role="menuitem"
data-testid="view-overflow-stash-recovery"
>
<History size={14} />
<span>{t("header.stashRecoveryView", "Stash Recovery")}</span>
{stashOrphanCount > 0 ? <span className="btn-badge">{stashOrphanCount}</span> : null}
</button>
{experimentalFeatures?.researchView && (
<button
className={`view-toggle-overflow-item${view === "research" ? " active" : ""}`}
@@ -1477,12 +1463,13 @@ export function Header({
{/* Git Manager button - desktop only (moved to overflow on mobile/tablet) */}
{!isCompact && onOpenGitManager && (
<button
className="btn-icon"
className="btn-icon header-git-manager-btn"
onClick={onOpenGitManager}
title={t("header.gitManager", "Git Manager")}
data-testid="git-manager-btn"
>
<GitBranch size={16} />
{stashOrphanCount > 0 ? <span className="btn-badge">{stashOrphanCount}</span> : null}
</button>
)}
@@ -1600,13 +1587,14 @@ export function Header({
{/* Git Manager - in overflow on mobile */}
{onOpenGitManager && (
<button
className="mobile-overflow-item"
className="mobile-overflow-item mobile-overflow-item--with-badge"
onClick={() => handleOverflowAction(onOpenGitManager)}
role="menuitem"
data-testid="overflow-git-btn"
>
<GitBranch size={16} />
<span>{t("header.gitManager", "Git Manager")}</span>
{stashOrphanCount > 0 ? <span className="btn-badge">{stashOrphanCount}</span> : null}
</button>
)}
{!isDesktopShell && (

View File

@@ -13,7 +13,6 @@ import {
ChevronRight,
FileText,
Gauge,
History,
Lightbulb,
LayoutGrid,
List,
@@ -108,7 +107,6 @@ export interface LeftSidebarNavProps {
mailboxUnreadCount?: number;
mailboxPendingApprovalCount?: number;
chatHasUnreadResponse?: boolean;
stashOrphanCount?: number;
experimentalFeatures?: LeftSidebarExperimentalFeatures;
pluginDashboardViews?: PluginDashboardViewEntry[];
showAgentsTab?: boolean;
@@ -156,7 +154,6 @@ export function LeftSidebarNav({
mailboxUnreadCount = 0,
mailboxPendingApprovalCount = 0,
chatHasUnreadResponse = false,
stashOrphanCount = 0,
experimentalFeatures,
pluginDashboardViews = [],
showAgentsTab = false,
@@ -355,7 +352,6 @@ export function LeftSidebarNav({
...(experimentalFeatures?.goalsView
? [{ id: "goals", label: t("header.goalsView", "Goals"), view: "goalsView" as TaskView, isActive: view === "goalsView", icon: Target, testId: "sidebar-nav-goals", onSelect: () => onChangeView("goalsView") }]
: []),
{ id: "stash-recovery", label: t("header.stashRecoveryView", "Stash Recovery"), view: "stash-recovery", isActive: view === "stash-recovery", icon: History, testId: "sidebar-nav-stash-recovery", badge: stashOrphanCount > 0 ? stashOrphanCount : undefined, onSelect: () => onChangeView("stash-recovery") },
...(experimentalFeatures?.researchView
? [{ id: "research", label: t("header.researchView", "Research"), view: "research" as TaskView, isActive: view === "research", icon: Search, testId: "sidebar-nav-research", onSelect: () => onChangeView("research") }]
: []),

View File

@@ -13,7 +13,6 @@ import {
Gauge,
GitBranch,
Grid3X3,
History,
LayoutGrid,
Lightbulb,
Loader2,
@@ -298,7 +297,6 @@ export function MobileNavBar({
|| (view === "todos" && todoViewEnabled)
|| (view === "skills" && !showSkillsTopLevel)
|| view === "graph"
|| view === "stash-recovery"
|| (isPluginViewId(view) && !topLevelPrimaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view));
return (
@@ -502,6 +500,7 @@ export function MobileNavBar({
>
<GitBranch />
<span>{t("nav.gitManager", "Git Manager")}</span>
{stashOrphanCount > 0 ? <span className="mobile-more-item-badge">{formatCount(stashOrphanCount)}</span> : null}
</button>
<div className="mobile-more-split-row">
@@ -709,19 +708,6 @@ export function MobileNavBar({
</button>
)}
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-stash-recovery"
onClick={() => handleMoreAction(() => onChangeView("stash-recovery"))}
>
<History />
<span>{t("nav.stashRecovery", "Stash Recovery")}</span>
{stashOrphanCount > 0 ? <span className="mobile-more-item-badge">{formatCount(stashOrphanCount)}</span> : null}
</button>
{experimentalFeatures?.researchView && (
<button
type="button"

View File

@@ -311,10 +311,61 @@ describe("GitManagerModal", () => {
expect(screen.getByRole("tab", { name: /branches/i })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /worktrees/i })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /stashes/i })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /recovery/i })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /remotes/i })).toBeInTheDocument();
});
});
it("renders Stash Recovery inside the Recovery section", async () => {
(api as any).mockImplementation((path: string) => {
if (path === "/stash-recovery/orphans") {
return Promise.resolve({
records: [
{
sha: "abcdef1234567890",
sourceTaskId: "FN-100",
createdAt: "2026-06-21T00:00:00Z",
classification: "unknown",
changedPaths: ["src/file.ts"],
},
],
});
}
return Promise.resolve({ events: [] });
});
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(await screen.findByRole("tab", { name: /recovery/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: "Stash Recovery" })).toBeInTheDocument();
expect(screen.getByText("1 orphans")).toBeInTheDocument();
expect(screen.getByText("FN-100")).toBeInTheDocument();
});
});
it("renders the empty Stash Recovery state inside the Recovery section", async () => {
(api as any).mockImplementation((path: string) => {
if (path === "/stash-recovery/orphans") {
return Promise.resolve({ records: [] });
}
return Promise.resolve({ events: [] });
});
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(await screen.findByRole("tab", { name: /recovery/i }));
await waitFor(() => {
expect(screen.getByText("No orphaned merger autostashes found.")).toBeInTheDocument();
});
});
// ── Keyboard Navigation ─────────────────────────────────────
it("closes on Escape key", async () => {

View File

@@ -133,6 +133,21 @@ describe("Header", () => {
expect(onOpenGitHubImport).toHaveBeenCalled();
});
it("shows the stash orphan badge on the desktop Git Manager button", () => {
renderHeader({ onOpenGitManager: noop, stashOrphanCount: 5 }, "desktop");
const button = screen.getByTestId("git-manager-btn");
expect(button).toHaveTextContent("5");
expect(button.querySelector(".btn-badge")?.textContent).toBe("5");
});
it("shows the stash orphan badge on the compact Git Manager overflow item", () => {
renderHeader({ onOpenGitManager: noop, stashOrphanCount: 6 }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
const item = screen.getByTestId("overflow-git-btn");
expect(item).toHaveTextContent("Git Manager");
expect(item.querySelector(".btn-badge")?.textContent).toBe("6");
});
describe("view toggle", () => {
it("does not render view toggle when onChangeView is not provided", () => {
renderHeader();
@@ -256,6 +271,12 @@ describe("Header", () => {
expect(screen.getByTestId("view-overflow-todos")).toBeInTheDocument();
});
it("does not render the retired Stash Recovery view overflow item", () => {
renderHeader({ onChangeView: noop, todosEnabled: true, stashOrphanCount: 4 });
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
expect(screen.queryByTestId("view-overflow-stash-recovery")).toBeNull();
});
it.each(["desktop", "tablet"] as const)("routes More views to the right dock panel toggle on %s", (tier) => {
const onToggleRightDock = vi.fn();
const { rerender } = renderHeader({

View File

@@ -105,7 +105,6 @@ function renderSidebar(overrides: Partial<ComponentProps<typeof LeftSidebarNav>>
mailboxUnreadCount: 3,
mailboxPendingApprovalCount: 1,
chatHasUnreadResponse: true,
stashOrphanCount: 2,
experimentalFeatures: {
insights: true,
memoryView: true,
@@ -218,7 +217,6 @@ describe("LeftSidebarNav", () => {
"sidebar-nav-mailbox",
"sidebar-nav-evals",
"sidebar-nav-goals",
"sidebar-nav-stash-recovery",
"sidebar-nav-research",
"sidebar-nav-insights",
"sidebar-nav-skills",
@@ -233,6 +231,7 @@ describe("LeftSidebarNav", () => {
expect(screen.getByTestId("sidebar-nav-documents")).toHaveTextContent("Artifacts");
expect(screen.getByTestId("sidebar-nav-planning")).toHaveTextContent("Planning");
expect(screen.queryByTestId("sidebar-nav-stash-recovery")).toBeNull();
const primaryNav = screen.getByRole("navigation", { name: "Primary navigation" });
const primaryButtons = within(primaryNav).getAllByRole("button");
@@ -285,7 +284,7 @@ describe("LeftSidebarNav", () => {
});
expect(screen.getByTestId("sidebar-nav-board")).toBeDefined();
expect(screen.getByTestId("sidebar-nav-stash-recovery")).toBeDefined();
expect(screen.queryByTestId("sidebar-nav-stash-recovery")).toBeNull();
expect(screen.queryByTestId("sidebar-nav-agents")).toBeNull();
expect(screen.queryByTestId("sidebar-nav-research")).toBeNull();
expect(screen.queryByTestId("sidebar-nav-insights")).toBeNull();
@@ -312,14 +311,13 @@ describe("LeftSidebarNav", () => {
expect(screen.queryByRole("button", { name: /view$/i })).toBeNull();
});
it("renders mailbox and stash badges", () => {
it("renders mailbox badges without the removed stash recovery destination", () => {
renderSidebar();
const mailboxBadge = screen.getByTestId("sidebar-nav-mailbox").querySelector(".left-sidebar-nav__badge");
const stashBadge = screen.getByTestId("sidebar-nav-stash-recovery").querySelector(".left-sidebar-nav__badge");
expect(mailboxBadge?.textContent).toBe("3");
expect(stashBadge?.textContent).toBe("2");
expect(screen.queryByTestId("sidebar-nav-stash-recovery")).toBeNull();
});
it("renders zero plugin views and at least one primary and overflow plugin view", () => {

View File

@@ -589,6 +589,7 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-nav-tab-mailbox")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-activity")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-git")).toBeDefined();
expect(screen.queryByTestId("mobile-more-item-stash-recovery")).toBeNull();
expect(screen.getByTestId("mobile-more-item-terminal")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-files")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-planning")).toBeDefined();
@@ -604,6 +605,15 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-more-item-settings")).toBeDefined();
});
it("shows the stash orphan badge on the Git Manager item instead of a Stash Recovery item", () => {
render(<MobileNavBar {...createDefaultProps()} stashOrphanCount={8} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
const gitItem = screen.getByTestId("mobile-more-item-git");
expect(gitItem.querySelector(".mobile-more-item-badge")?.textContent).toBe("8");
expect(screen.queryByTestId("mobile-more-item-stash-recovery")).toBeNull();
});
it("does not show legacy roadmaps in more sheet", () => {
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{}} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));

View File

@@ -21,7 +21,6 @@ vi.mock("../SecretsView", () => ({ SecretsView: () => <div data-testid="mock-sec
vi.mock("../DevServerView", () => ({ DevServerView: () => <div data-testid="mock-devserver-view" /> }));
vi.mock("../TodoView", () => ({ TodoView: () => <div data-testid="mock-todos-view" /> }));
vi.mock("../GoalsView", () => ({ GoalsView: () => <div data-testid="mock-goals-view" /> }));
vi.mock("../StashRecoveryView", () => ({ StashRecoveryView: () => <div data-testid="mock-stash-recovery-view" /> }));
const renderProps = {
addToast: vi.fn(),
@@ -80,7 +79,7 @@ describe("RightDock", () => {
expect(screen.getByTestId("right-dock-tab-documents")).toHaveAttribute("aria-label", "Artifacts");
expect(screen.getByTestId("right-dock-tab-documents")).toHaveAttribute("title", "Artifacts");
expect(screen.getByTestId("right-dock-tab-secrets")).toBeInTheDocument();
expect(screen.getByTestId("right-dock-tab-stash-recovery")).toBeInTheDocument();
expect(screen.queryByTestId("right-dock-tab-stash-recovery")).toBeNull();
expect(screen.queryByTestId("right-dock-tab-research")).toBeNull();
expect(screen.queryByTestId("right-dock-tab-insights")).toBeNull();
});
@@ -140,7 +139,6 @@ describe("RightDock", () => {
["right-dock-tab-skills", "mock-skills-view"],
["right-dock-tab-memory", "mock-memory-view"],
["right-dock-tab-secrets", "mock-secrets-view"],
["right-dock-tab-stash-recovery", "mock-stash-recovery-view"],
["right-dock-tab-evals", "mock-evals-view"],
["right-dock-tab-goals", "mock-goals-view"],
["right-dock-tab-todos", "mock-todos-view"],

View File

@@ -25,7 +25,8 @@ describe("overflowViewRegistry", () => {
todosEnabled: false,
}).map((entry) => entry.key);
expect(disabled).toEqual(["files", "documents", "secrets", "stash-recovery"]);
expect(disabled).toEqual(["files", "documents", "secrets"]);
expect(disabled).not.toContain("stash-recovery");
const enabled = getVisibleOverflowViewEntries({
experimentalFeatures: {

View File

@@ -4,7 +4,6 @@ import {
CheckSquare,
FileText,
Folder,
History,
Lock,
Monitor,
Search,
@@ -34,7 +33,6 @@ const SecretsView = lazy(() => import("./SecretsView").then((m) => ({ default: m
const DevServerView = lazy(() => import("./DevServerView").then((m) => ({ default: m.DevServerView })));
const TodoView = lazy(() => import("./TodoView").then((m) => ({ default: m.TodoView })));
const GoalsView = lazy(() => import("./GoalsView").then((m) => ({ default: m.GoalsView })));
const StashRecoveryView = lazy(() => import("./StashRecoveryView").then((m) => ({ default: m.StashRecoveryView })));
export type OverflowViewKey =
| "files"
@@ -44,7 +42,6 @@ export type OverflowViewKey =
| "skills"
| "memory"
| "secrets"
| "stash-recovery"
| "evals"
| "goalsView"
| "todos"
@@ -217,13 +214,6 @@ export const STATIC_OVERFLOW_VIEW_ENTRIES: readonly OverflowViewEntry[] = [
testId: "right-dock-tab-secrets",
render: (props) => wrapOverflowView(<SecretsView addToast={props.addToast} />),
},
{
key: "stash-recovery",
label: "Stash Recovery",
icon: History,
testId: "right-dock-tab-stash-recovery",
render: () => wrapOverflowView(<StashRecoveryView />),
},
{
key: "evals",
label: "Evals",

View File

@@ -93,6 +93,34 @@ describe("useViewState", () => {
}
});
it("migrates retired stash recovery taskView from localStorage to board", async () => {
localStorage.setItem("kb-dashboard-task-view", "stash-recovery");
const { result } = renderHook(() => useViewState(createOptions()));
await waitFor(() => {
expect(result.current.taskView).toBe("board");
});
expect(localStorage.getItem("kb-dashboard-task-view")).toBe("board");
});
it("migrates retired stash recovery URL param to board", async () => {
const originalUrl = `${window.location.pathname}${window.location.search}`;
localStorage.setItem("kb-dashboard-task-view", "list");
window.history.replaceState({}, "", "?view=stash-recovery");
try {
const { result } = renderHook(() => useViewState(createOptions()));
await waitFor(() => {
expect(result.current.taskView).toBe("board");
});
expect(localStorage.getItem("kb-dashboard-task-view")).toBe("board");
} finally {
window.history.replaceState({}, "", originalUrl || "/");
}
});
it("migrates legacy roadmaps state to plugin view when registered", async () => {
vi.spyOn(pluginViewRegistry, "isPluginViewRegistered").mockReturnValue(true);
localStorage.setItem("kb-dashboard-task-view", "roadmaps");

View File

@@ -5,7 +5,7 @@ import { getScopedItem, setScopedItem } from "../utils/projectStorage";
import { getPluginViewId, isPluginViewId, isPluginViewRegistered } from "../plugins/pluginViewRegistry";
export type ViewMode = "overview" | "project";
export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "goalsView" | "todos" | "planning" | "skills" | "mailbox" | "insights" | "memory" | "command-center" | "secrets" | "devserver" | "dev-server" | "stash-recovery" | "pull-requests";
export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "goalsView" | "todos" | "planning" | "skills" | "mailbox" | "insights" | "memory" | "command-center" | "secrets" | "devserver" | "dev-server" | "pull-requests";
export type PluginTaskView = `plugin:${string}:${string}`;
export type TaskView = BuiltInTaskView | PluginTaskView;
@@ -39,7 +39,6 @@ const BUILT_IN_TASK_VIEWS: readonly BuiltInTaskView[] = [
"secrets",
"devserver",
"dev-server",
"stash-recovery",
"pull-requests",
];
@@ -72,6 +71,14 @@ function migrateLegacyReliabilityView(value: string | null): TaskView | null {
return value === "reliability" ? "command-center" : null;
}
/*
FNXC:ViewState 2026-06-21-00:00:
FN-6881 removed the standalone Stash Recovery task view after moving recovery into Git Manager. Persisted or linked `stash-recovery` values must land on Board instead of restoring an orphaned route.
*/
function migrateRetiredStashRecoveryView(value: string | null): TaskView | null {
return value === "stash-recovery" ? "board" : null;
}
interface UseViewStateOptions {
projectsLoading: boolean;
projectsError: string | null;
@@ -118,6 +125,8 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult {
const saved = getScopedItem("kb-dashboard-task-view");
const legacyReliabilityView = migrateLegacyReliabilityView(saved);
if (legacyReliabilityView) return legacyReliabilityView;
const retiredStashRecoveryView = migrateRetiredStashRecoveryView(saved);
if (retiredStashRecoveryView) return retiredStashRecoveryView;
if (saved === "roadmaps") return migrateLegacyRoadmapsView(saved);
if (isTaskView(saved)) return saved;
return "board";
@@ -131,8 +140,11 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult {
useEffect(() => {
const saved = getScopedItem("kb-dashboard-task-view", currentProject?.id);
const legacyReliabilityView = migrateLegacyReliabilityView(saved);
const retiredStashRecoveryView = migrateRetiredStashRecoveryView(saved);
if (legacyReliabilityView) {
setTaskView(legacyReliabilityView);
} else if (retiredStashRecoveryView) {
setTaskView(retiredStashRecoveryView);
} else if (saved === "roadmaps") {
setTaskView(migrateLegacyRoadmapsView(saved));
} else if (isTaskView(saved)) {
@@ -160,8 +172,11 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult {
const viewParam = new URLSearchParams(window.location.search).get("view");
const legacyReliabilityView = migrateLegacyReliabilityView(viewParam);
const retiredStashRecoveryView = migrateRetiredStashRecoveryView(viewParam);
if (legacyReliabilityView) {
setTaskView(legacyReliabilityView);
} else if (retiredStashRecoveryView) {
setTaskView(retiredStashRecoveryView);
} else if (viewParam && isTaskView(viewParam)) {
setTaskView(normalizeTaskView(viewParam));
}

View File

@@ -37,7 +37,6 @@ export const VIEW_SOURCE_MAP: Record<TaskViewId, string> = {
"command-center": "components/command-center/CommandCenter.tsx",
"dev-server": "components/DevServerView.tsx",
goalsView: "components/GoalsView.tsx",
"stash-recovery": "components/StashRecoveryView.tsx",
};
type ManifestCacheEntry = {