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:
5
.changeset/fn-6881-stash-recovery-git-manager.md
Normal file
5
.changeset/fn-6881-stash-recovery-git-manager.md
Normal 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.
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||

|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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") }]
|
||||
: []),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user