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
|
### 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`.
|
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`
|
- `AgentsView`
|
||||||
@@ -237,7 +237,6 @@ Keep this AGENTS inventory in sync with App lazy imports, AppModals lazy modal i
|
|||||||
- `EvalsView`
|
- `EvalsView`
|
||||||
- `TodoView`
|
- `TodoView`
|
||||||
- `GoalsView`
|
- `GoalsView`
|
||||||
- `StashRecoveryView`
|
|
||||||
- `PullRequestView`
|
- `PullRequestView`
|
||||||
- `SetupWizardModal`
|
- `SetupWizardModal`
|
||||||
- `SettingsModal`
|
- `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)
|
- 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
|
- Remote editing controls
|
||||||
- Stash inspection (view stat + patch) before apply/pop/drop actions
|
- 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
|
- 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).
|
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:
|
Navigation:
|
||||||
- Desktop: **Header → More views → Stash Recovery**
|
- Desktop: **Git Manager → Recovery**
|
||||||
- Mobile: **More** sheet → **Stash Recovery**
|
- Mobile: **More** sheet → **Git Manager → Recovery**
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
- Lists orphaned stash entries grouped by source task ID (or **Unknown source** when unavailable)
|
- 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 DevServerView = lazy(() => import("./components/DevServerView").then((m) => ({ default: m.DevServerView })));
|
||||||
const TodoView = lazy(() => import("./components/TodoView").then((m) => ({ default: m.TodoView })));
|
const TodoView = lazy(() => import("./components/TodoView").then((m) => ({ default: m.TodoView })));
|
||||||
const GoalsView = lazy(() => import("./components/GoalsView").then((m) => ({ default: m.GoalsView })));
|
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 })));
|
const PullRequestView = lazy(() => import("./components/PullRequestView").then((m) => ({ default: m.PullRequestView })));
|
||||||
|
|
||||||
// Warm lazy chunks during browser idle so first navigation to each view is
|
// 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/DevServerView");
|
||||||
void import("./components/TodoView");
|
void import("./components/TodoView");
|
||||||
void import("./components/GoalsView");
|
void import("./components/GoalsView");
|
||||||
void import("./components/StashRecoveryView");
|
|
||||||
void import("./components/PullRequestView");
|
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") {
|
if (taskView === "pull-requests") {
|
||||||
return (
|
return (
|
||||||
<PageErrorBoundary>
|
<PageErrorBoundary>
|
||||||
@@ -2162,7 +2150,6 @@ function AppInner() {
|
|||||||
mailboxUnreadCount={mailboxUnreadCount}
|
mailboxUnreadCount={mailboxUnreadCount}
|
||||||
mailboxPendingApprovalCount={mailboxPendingApprovalCount}
|
mailboxPendingApprovalCount={mailboxPendingApprovalCount}
|
||||||
chatHasUnreadResponse={chatHasUnreadResponse}
|
chatHasUnreadResponse={chatHasUnreadResponse}
|
||||||
stashOrphanCount={stashOrphanCount}
|
|
||||||
experimentalFeatures={{
|
experimentalFeatures={{
|
||||||
insights: insightsEnabled,
|
insights: insightsEnabled,
|
||||||
memoryView: memoryEnabled,
|
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:
|
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.
|
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 { describe, expect, it } from "vitest";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
@@ -30,7 +33,6 @@ const EXPECTED_DOCUMENTED_VIEWS = new Set([
|
|||||||
"EvalsView",
|
"EvalsView",
|
||||||
"TodoView",
|
"TodoView",
|
||||||
"GoalsView",
|
"GoalsView",
|
||||||
"StashRecoveryView",
|
|
||||||
"PullRequestView",
|
"PullRequestView",
|
||||||
"SetupWizardModal",
|
"SetupWizardModal",
|
||||||
"SettingsModal",
|
"SettingsModal",
|
||||||
@@ -54,7 +56,6 @@ const EXPECTED_APP_LEVEL_VIEWS = new Set([
|
|||||||
"DevServerView",
|
"DevServerView",
|
||||||
"TodoView",
|
"TodoView",
|
||||||
"GoalsView",
|
"GoalsView",
|
||||||
"StashRecoveryView",
|
|
||||||
"PullRequestView",
|
"PullRequestView",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -99,7 +100,7 @@ function extractAppModalsLazyViews(appModalsSource: string): Set<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("AGENTS lazy-loaded views inventory", () => {
|
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 agentsDoc = readFileSync(resolve(__dirname, "../../../../AGENTS.md"), "utf-8");
|
||||||
const appSource = readFileSync(resolve(__dirname, "../App.tsx"), "utf-8");
|
const appSource = readFileSync(resolve(__dirname, "../App.tsx"), "utf-8");
|
||||||
const appModalsSource = readFileSync(resolve(__dirname, "../components/AppModals.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 section = extractLazyLoadedSection(agentsDoc);
|
||||||
const countMatch = section.match(/These\s+(\d+)\s+views\s+are lazy-loaded/);
|
const countMatch = section.match(/These\s+(\d+)\s+views\s+are lazy-loaded/);
|
||||||
expect(countMatch).toBeTruthy();
|
expect(countMatch).toBeTruthy();
|
||||||
expect(Number(countMatch?.[1])).toBe(21);
|
expect(Number(countMatch?.[1])).toBe(20);
|
||||||
|
|
||||||
const documentedViews = extractBacktickedNamesFromBullets(section);
|
const documentedViews = extractBacktickedNamesFromBullets(section);
|
||||||
expect(new Set(documentedViews)).toEqual(EXPECTED_DOCUMENTED_VIEWS);
|
expect(new Set(documentedViews)).toEqual(EXPECTED_DOCUMENTED_VIEWS);
|
||||||
expect(documentedViews).toHaveLength(21);
|
expect(documentedViews).toHaveLength(20);
|
||||||
|
|
||||||
expect(section).toContain("`ResearchView`");
|
expect(section).toContain("`ResearchView`");
|
||||||
expect(section).toContain("`TodoView`");
|
expect(section).toContain("`TodoView`");
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import {
|
|||||||
fetchRemoteCommits,
|
fetchRemoteCommits,
|
||||||
fetchBranchCommits,
|
fetchBranchCommits,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
|
import { StashRecoveryView } from "./StashRecoveryView";
|
||||||
import {
|
import {
|
||||||
GitBranch as GitBranchIcon,
|
GitBranch as GitBranchIcon,
|
||||||
GitCommit as GitCommitIcon,
|
GitCommit as GitCommitIcon,
|
||||||
@@ -91,11 +92,12 @@ import {
|
|||||||
Send,
|
Send,
|
||||||
Pencil,
|
Pencil,
|
||||||
Info,
|
Info,
|
||||||
|
History,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
// ── Types & Constants ─────────────────────────────────────────────
|
// ── 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 }> }[] = [
|
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: "branches", label: "Branches", icon: GitBranchIcon },
|
||||||
{ id: "worktrees", label: "Worktrees", icon: HardDrive },
|
{ id: "worktrees", label: "Worktrees", icon: HardDrive },
|
||||||
{ id: "stashes", label: "Stashes", icon: Archive },
|
{ 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 },
|
{ id: "remotes", label: "Remotes", icon: GitMerge },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -333,6 +340,10 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
|||||||
stashDiffRequestIdRef.current += 1;
|
stashDiffRequestIdRef.current += 1;
|
||||||
break;
|
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": {
|
case "remotes": {
|
||||||
const remoteStatus = await fetchGitStatus(projectId, { extended: true });
|
const remoteStatus = await fetchGitStatus(projectId, { extended: true });
|
||||||
setStatus(remoteStatus);
|
setStatus(remoteStatus);
|
||||||
@@ -938,6 +949,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
|||||||
branches: t("git.sectionBranches", "Branches"),
|
branches: t("git.sectionBranches", "Branches"),
|
||||||
worktrees: t("git.sectionWorktrees", "Worktrees"),
|
worktrees: t("git.sectionWorktrees", "Worktrees"),
|
||||||
stashes: t("git.sectionStashes", "Stashes"),
|
stashes: t("git.sectionStashes", "Stashes"),
|
||||||
|
recovery: t("git.sectionRecovery", "Recovery"),
|
||||||
remotes: t("git.sectionRemotes", "Remotes"),
|
remotes: t("git.sectionRemotes", "Remotes"),
|
||||||
}[section.id] ?? section.label;
|
}[section.id] ?? section.label;
|
||||||
return (
|
return (
|
||||||
@@ -1076,6 +1088,11 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Recovery Panel ── */}
|
||||||
|
{activeSection === "recovery" && !loading && (
|
||||||
|
<StashRecoveryView />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Remotes Panel ── */}
|
{/* ── Remotes Panel ── */}
|
||||||
{activeSection === "remotes" && !loading && (
|
{activeSection === "remotes" && !loading && (
|
||||||
<RemotesPanel
|
<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);
|
border-color: var(--todo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-git-manager-btn {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-badge {
|
.btn-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -2px;
|
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);
|
background: var(--card);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-overflow-item--with-badge .btn-badge {
|
||||||
|
position: static;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-overflow-item svg {
|
.mobile-overflow-item svg {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
@@ -1108,7 +1108,7 @@ export function Header({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
ref={viewOverflowTriggerRef}
|
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={() => {
|
onClick={() => {
|
||||||
if (shouldRouteMoreViewsToRightDock) {
|
if (shouldRouteMoreViewsToRightDock) {
|
||||||
setIsViewOverflowOpen(false);
|
setIsViewOverflowOpen(false);
|
||||||
@@ -1165,20 +1165,6 @@ export function Header({
|
|||||||
<span>{t("header.goalsView", "Goals")}</span>
|
<span>{t("header.goalsView", "Goals")}</span>
|
||||||
</button>
|
</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 && (
|
{experimentalFeatures?.researchView && (
|
||||||
<button
|
<button
|
||||||
className={`view-toggle-overflow-item${view === "research" ? " active" : ""}`}
|
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) */}
|
{/* Git Manager button - desktop only (moved to overflow on mobile/tablet) */}
|
||||||
{!isCompact && onOpenGitManager && (
|
{!isCompact && onOpenGitManager && (
|
||||||
<button
|
<button
|
||||||
className="btn-icon"
|
className="btn-icon header-git-manager-btn"
|
||||||
onClick={onOpenGitManager}
|
onClick={onOpenGitManager}
|
||||||
title={t("header.gitManager", "Git Manager")}
|
title={t("header.gitManager", "Git Manager")}
|
||||||
data-testid="git-manager-btn"
|
data-testid="git-manager-btn"
|
||||||
>
|
>
|
||||||
<GitBranch size={16} />
|
<GitBranch size={16} />
|
||||||
|
{stashOrphanCount > 0 ? <span className="btn-badge">{stashOrphanCount}</span> : null}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1600,13 +1587,14 @@ export function Header({
|
|||||||
{/* Git Manager - in overflow on mobile */}
|
{/* Git Manager - in overflow on mobile */}
|
||||||
{onOpenGitManager && (
|
{onOpenGitManager && (
|
||||||
<button
|
<button
|
||||||
className="mobile-overflow-item"
|
className="mobile-overflow-item mobile-overflow-item--with-badge"
|
||||||
onClick={() => handleOverflowAction(onOpenGitManager)}
|
onClick={() => handleOverflowAction(onOpenGitManager)}
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid="overflow-git-btn"
|
data-testid="overflow-git-btn"
|
||||||
>
|
>
|
||||||
<GitBranch size={16} />
|
<GitBranch size={16} />
|
||||||
<span>{t("header.gitManager", "Git Manager")}</span>
|
<span>{t("header.gitManager", "Git Manager")}</span>
|
||||||
|
{stashOrphanCount > 0 ? <span className="btn-badge">{stashOrphanCount}</span> : null}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!isDesktopShell && (
|
{!isDesktopShell && (
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
FileText,
|
FileText,
|
||||||
Gauge,
|
Gauge,
|
||||||
History,
|
|
||||||
Lightbulb,
|
Lightbulb,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List,
|
List,
|
||||||
@@ -108,7 +107,6 @@ export interface LeftSidebarNavProps {
|
|||||||
mailboxUnreadCount?: number;
|
mailboxUnreadCount?: number;
|
||||||
mailboxPendingApprovalCount?: number;
|
mailboxPendingApprovalCount?: number;
|
||||||
chatHasUnreadResponse?: boolean;
|
chatHasUnreadResponse?: boolean;
|
||||||
stashOrphanCount?: number;
|
|
||||||
experimentalFeatures?: LeftSidebarExperimentalFeatures;
|
experimentalFeatures?: LeftSidebarExperimentalFeatures;
|
||||||
pluginDashboardViews?: PluginDashboardViewEntry[];
|
pluginDashboardViews?: PluginDashboardViewEntry[];
|
||||||
showAgentsTab?: boolean;
|
showAgentsTab?: boolean;
|
||||||
@@ -156,7 +154,6 @@ export function LeftSidebarNav({
|
|||||||
mailboxUnreadCount = 0,
|
mailboxUnreadCount = 0,
|
||||||
mailboxPendingApprovalCount = 0,
|
mailboxPendingApprovalCount = 0,
|
||||||
chatHasUnreadResponse = false,
|
chatHasUnreadResponse = false,
|
||||||
stashOrphanCount = 0,
|
|
||||||
experimentalFeatures,
|
experimentalFeatures,
|
||||||
pluginDashboardViews = [],
|
pluginDashboardViews = [],
|
||||||
showAgentsTab = false,
|
showAgentsTab = false,
|
||||||
@@ -355,7 +352,6 @@ export function LeftSidebarNav({
|
|||||||
...(experimentalFeatures?.goalsView
|
...(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: "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
|
...(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") }]
|
? [{ 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,
|
Gauge,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Grid3X3,
|
Grid3X3,
|
||||||
History,
|
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Lightbulb,
|
Lightbulb,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -298,7 +297,6 @@ export function MobileNavBar({
|
|||||||
|| (view === "todos" && todoViewEnabled)
|
|| (view === "todos" && todoViewEnabled)
|
||||||
|| (view === "skills" && !showSkillsTopLevel)
|
|| (view === "skills" && !showSkillsTopLevel)
|
||||||
|| view === "graph"
|
|| view === "graph"
|
||||||
|| view === "stash-recovery"
|
|
||||||
|| (isPluginViewId(view) && !topLevelPrimaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view));
|
|| (isPluginViewId(view) && !topLevelPrimaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -502,6 +500,7 @@ export function MobileNavBar({
|
|||||||
>
|
>
|
||||||
<GitBranch />
|
<GitBranch />
|
||||||
<span>{t("nav.gitManager", "Git Manager")}</span>
|
<span>{t("nav.gitManager", "Git Manager")}</span>
|
||||||
|
{stashOrphanCount > 0 ? <span className="mobile-more-item-badge">{formatCount(stashOrphanCount)}</span> : null}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="mobile-more-split-row">
|
<div className="mobile-more-split-row">
|
||||||
@@ -709,19 +708,6 @@ export function MobileNavBar({
|
|||||||
</button>
|
</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 && (
|
{experimentalFeatures?.researchView && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -311,10 +311,61 @@ describe("GitManagerModal", () => {
|
|||||||
expect(screen.getByRole("tab", { name: /branches/i })).toBeInTheDocument();
|
expect(screen.getByRole("tab", { name: /branches/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("tab", { name: /worktrees/i })).toBeInTheDocument();
|
expect(screen.getByRole("tab", { name: /worktrees/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("tab", { name: /stashes/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();
|
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 ─────────────────────────────────────
|
// ── Keyboard Navigation ─────────────────────────────────────
|
||||||
|
|
||||||
it("closes on Escape key", async () => {
|
it("closes on Escape key", async () => {
|
||||||
|
|||||||
@@ -133,6 +133,21 @@ describe("Header", () => {
|
|||||||
expect(onOpenGitHubImport).toHaveBeenCalled();
|
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", () => {
|
describe("view toggle", () => {
|
||||||
it("does not render view toggle when onChangeView is not provided", () => {
|
it("does not render view toggle when onChangeView is not provided", () => {
|
||||||
renderHeader();
|
renderHeader();
|
||||||
@@ -256,6 +271,12 @@ describe("Header", () => {
|
|||||||
expect(screen.getByTestId("view-overflow-todos")).toBeInTheDocument();
|
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) => {
|
it.each(["desktop", "tablet"] as const)("routes More views to the right dock panel toggle on %s", (tier) => {
|
||||||
const onToggleRightDock = vi.fn();
|
const onToggleRightDock = vi.fn();
|
||||||
const { rerender } = renderHeader({
|
const { rerender } = renderHeader({
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ function renderSidebar(overrides: Partial<ComponentProps<typeof LeftSidebarNav>>
|
|||||||
mailboxUnreadCount: 3,
|
mailboxUnreadCount: 3,
|
||||||
mailboxPendingApprovalCount: 1,
|
mailboxPendingApprovalCount: 1,
|
||||||
chatHasUnreadResponse: true,
|
chatHasUnreadResponse: true,
|
||||||
stashOrphanCount: 2,
|
|
||||||
experimentalFeatures: {
|
experimentalFeatures: {
|
||||||
insights: true,
|
insights: true,
|
||||||
memoryView: true,
|
memoryView: true,
|
||||||
@@ -218,7 +217,6 @@ describe("LeftSidebarNav", () => {
|
|||||||
"sidebar-nav-mailbox",
|
"sidebar-nav-mailbox",
|
||||||
"sidebar-nav-evals",
|
"sidebar-nav-evals",
|
||||||
"sidebar-nav-goals",
|
"sidebar-nav-goals",
|
||||||
"sidebar-nav-stash-recovery",
|
|
||||||
"sidebar-nav-research",
|
"sidebar-nav-research",
|
||||||
"sidebar-nav-insights",
|
"sidebar-nav-insights",
|
||||||
"sidebar-nav-skills",
|
"sidebar-nav-skills",
|
||||||
@@ -233,6 +231,7 @@ describe("LeftSidebarNav", () => {
|
|||||||
|
|
||||||
expect(screen.getByTestId("sidebar-nav-documents")).toHaveTextContent("Artifacts");
|
expect(screen.getByTestId("sidebar-nav-documents")).toHaveTextContent("Artifacts");
|
||||||
expect(screen.getByTestId("sidebar-nav-planning")).toHaveTextContent("Planning");
|
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 primaryNav = screen.getByRole("navigation", { name: "Primary navigation" });
|
||||||
const primaryButtons = within(primaryNav).getAllByRole("button");
|
const primaryButtons = within(primaryNav).getAllByRole("button");
|
||||||
@@ -285,7 +284,7 @@ describe("LeftSidebarNav", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByTestId("sidebar-nav-board")).toBeDefined();
|
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-agents")).toBeNull();
|
||||||
expect(screen.queryByTestId("sidebar-nav-research")).toBeNull();
|
expect(screen.queryByTestId("sidebar-nav-research")).toBeNull();
|
||||||
expect(screen.queryByTestId("sidebar-nav-insights")).toBeNull();
|
expect(screen.queryByTestId("sidebar-nav-insights")).toBeNull();
|
||||||
@@ -312,14 +311,13 @@ describe("LeftSidebarNav", () => {
|
|||||||
expect(screen.queryByRole("button", { name: /view$/i })).toBeNull();
|
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();
|
renderSidebar();
|
||||||
|
|
||||||
const mailboxBadge = screen.getByTestId("sidebar-nav-mailbox").querySelector(".left-sidebar-nav__badge");
|
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(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", () => {
|
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-nav-tab-mailbox")).toBeDefined();
|
||||||
expect(screen.getByTestId("mobile-more-item-activity")).toBeDefined();
|
expect(screen.getByTestId("mobile-more-item-activity")).toBeDefined();
|
||||||
expect(screen.getByTestId("mobile-more-item-git")).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-terminal")).toBeDefined();
|
||||||
expect(screen.getByTestId("mobile-more-item-files")).toBeDefined();
|
expect(screen.getByTestId("mobile-more-item-files")).toBeDefined();
|
||||||
expect(screen.getByTestId("mobile-more-item-planning")).toBeDefined();
|
expect(screen.getByTestId("mobile-more-item-planning")).toBeDefined();
|
||||||
@@ -604,6 +605,15 @@ describe("MobileNavBar", () => {
|
|||||||
expect(screen.getByTestId("mobile-more-item-settings")).toBeDefined();
|
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", () => {
|
it("does not show legacy roadmaps in more sheet", () => {
|
||||||
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{}} />);
|
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{}} />);
|
||||||
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
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("../DevServerView", () => ({ DevServerView: () => <div data-testid="mock-devserver-view" /> }));
|
||||||
vi.mock("../TodoView", () => ({ TodoView: () => <div data-testid="mock-todos-view" /> }));
|
vi.mock("../TodoView", () => ({ TodoView: () => <div data-testid="mock-todos-view" /> }));
|
||||||
vi.mock("../GoalsView", () => ({ GoalsView: () => <div data-testid="mock-goals-view" /> }));
|
vi.mock("../GoalsView", () => ({ GoalsView: () => <div data-testid="mock-goals-view" /> }));
|
||||||
vi.mock("../StashRecoveryView", () => ({ StashRecoveryView: () => <div data-testid="mock-stash-recovery-view" /> }));
|
|
||||||
|
|
||||||
const renderProps = {
|
const renderProps = {
|
||||||
addToast: vi.fn(),
|
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("aria-label", "Artifacts");
|
||||||
expect(screen.getByTestId("right-dock-tab-documents")).toHaveAttribute("title", "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-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-research")).toBeNull();
|
||||||
expect(screen.queryByTestId("right-dock-tab-insights")).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-skills", "mock-skills-view"],
|
||||||
["right-dock-tab-memory", "mock-memory-view"],
|
["right-dock-tab-memory", "mock-memory-view"],
|
||||||
["right-dock-tab-secrets", "mock-secrets-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-evals", "mock-evals-view"],
|
||||||
["right-dock-tab-goals", "mock-goals-view"],
|
["right-dock-tab-goals", "mock-goals-view"],
|
||||||
["right-dock-tab-todos", "mock-todos-view"],
|
["right-dock-tab-todos", "mock-todos-view"],
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ describe("overflowViewRegistry", () => {
|
|||||||
todosEnabled: false,
|
todosEnabled: false,
|
||||||
}).map((entry) => entry.key);
|
}).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({
|
const enabled = getVisibleOverflowViewEntries({
|
||||||
experimentalFeatures: {
|
experimentalFeatures: {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
CheckSquare,
|
CheckSquare,
|
||||||
FileText,
|
FileText,
|
||||||
Folder,
|
Folder,
|
||||||
History,
|
|
||||||
Lock,
|
Lock,
|
||||||
Monitor,
|
Monitor,
|
||||||
Search,
|
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 DevServerView = lazy(() => import("./DevServerView").then((m) => ({ default: m.DevServerView })));
|
||||||
const TodoView = lazy(() => import("./TodoView").then((m) => ({ default: m.TodoView })));
|
const TodoView = lazy(() => import("./TodoView").then((m) => ({ default: m.TodoView })));
|
||||||
const GoalsView = lazy(() => import("./GoalsView").then((m) => ({ default: m.GoalsView })));
|
const GoalsView = lazy(() => import("./GoalsView").then((m) => ({ default: m.GoalsView })));
|
||||||
const StashRecoveryView = lazy(() => import("./StashRecoveryView").then((m) => ({ default: m.StashRecoveryView })));
|
|
||||||
|
|
||||||
export type OverflowViewKey =
|
export type OverflowViewKey =
|
||||||
| "files"
|
| "files"
|
||||||
@@ -44,7 +42,6 @@ export type OverflowViewKey =
|
|||||||
| "skills"
|
| "skills"
|
||||||
| "memory"
|
| "memory"
|
||||||
| "secrets"
|
| "secrets"
|
||||||
| "stash-recovery"
|
|
||||||
| "evals"
|
| "evals"
|
||||||
| "goalsView"
|
| "goalsView"
|
||||||
| "todos"
|
| "todos"
|
||||||
@@ -217,13 +214,6 @@ export const STATIC_OVERFLOW_VIEW_ENTRIES: readonly OverflowViewEntry[] = [
|
|||||||
testId: "right-dock-tab-secrets",
|
testId: "right-dock-tab-secrets",
|
||||||
render: (props) => wrapOverflowView(<SecretsView addToast={props.addToast} />),
|
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",
|
key: "evals",
|
||||||
label: "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 () => {
|
it("migrates legacy roadmaps state to plugin view when registered", async () => {
|
||||||
vi.spyOn(pluginViewRegistry, "isPluginViewRegistered").mockReturnValue(true);
|
vi.spyOn(pluginViewRegistry, "isPluginViewRegistered").mockReturnValue(true);
|
||||||
localStorage.setItem("kb-dashboard-task-view", "roadmaps");
|
localStorage.setItem("kb-dashboard-task-view", "roadmaps");
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
|||||||
import { getPluginViewId, isPluginViewId, isPluginViewRegistered } from "../plugins/pluginViewRegistry";
|
import { getPluginViewId, isPluginViewId, isPluginViewRegistered } from "../plugins/pluginViewRegistry";
|
||||||
|
|
||||||
export type ViewMode = "overview" | "project";
|
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 PluginTaskView = `plugin:${string}:${string}`;
|
||||||
export type TaskView = BuiltInTaskView | PluginTaskView;
|
export type TaskView = BuiltInTaskView | PluginTaskView;
|
||||||
|
|
||||||
@@ -39,7 +39,6 @@ const BUILT_IN_TASK_VIEWS: readonly BuiltInTaskView[] = [
|
|||||||
"secrets",
|
"secrets",
|
||||||
"devserver",
|
"devserver",
|
||||||
"dev-server",
|
"dev-server",
|
||||||
"stash-recovery",
|
|
||||||
"pull-requests",
|
"pull-requests",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -72,6 +71,14 @@ function migrateLegacyReliabilityView(value: string | null): TaskView | null {
|
|||||||
return value === "reliability" ? "command-center" : 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 {
|
interface UseViewStateOptions {
|
||||||
projectsLoading: boolean;
|
projectsLoading: boolean;
|
||||||
projectsError: string | null;
|
projectsError: string | null;
|
||||||
@@ -118,6 +125,8 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult {
|
|||||||
const saved = getScopedItem("kb-dashboard-task-view");
|
const saved = getScopedItem("kb-dashboard-task-view");
|
||||||
const legacyReliabilityView = migrateLegacyReliabilityView(saved);
|
const legacyReliabilityView = migrateLegacyReliabilityView(saved);
|
||||||
if (legacyReliabilityView) return legacyReliabilityView;
|
if (legacyReliabilityView) return legacyReliabilityView;
|
||||||
|
const retiredStashRecoveryView = migrateRetiredStashRecoveryView(saved);
|
||||||
|
if (retiredStashRecoveryView) return retiredStashRecoveryView;
|
||||||
if (saved === "roadmaps") return migrateLegacyRoadmapsView(saved);
|
if (saved === "roadmaps") return migrateLegacyRoadmapsView(saved);
|
||||||
if (isTaskView(saved)) return saved;
|
if (isTaskView(saved)) return saved;
|
||||||
return "board";
|
return "board";
|
||||||
@@ -131,8 +140,11 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = getScopedItem("kb-dashboard-task-view", currentProject?.id);
|
const saved = getScopedItem("kb-dashboard-task-view", currentProject?.id);
|
||||||
const legacyReliabilityView = migrateLegacyReliabilityView(saved);
|
const legacyReliabilityView = migrateLegacyReliabilityView(saved);
|
||||||
|
const retiredStashRecoveryView = migrateRetiredStashRecoveryView(saved);
|
||||||
if (legacyReliabilityView) {
|
if (legacyReliabilityView) {
|
||||||
setTaskView(legacyReliabilityView);
|
setTaskView(legacyReliabilityView);
|
||||||
|
} else if (retiredStashRecoveryView) {
|
||||||
|
setTaskView(retiredStashRecoveryView);
|
||||||
} else if (saved === "roadmaps") {
|
} else if (saved === "roadmaps") {
|
||||||
setTaskView(migrateLegacyRoadmapsView(saved));
|
setTaskView(migrateLegacyRoadmapsView(saved));
|
||||||
} else if (isTaskView(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 viewParam = new URLSearchParams(window.location.search).get("view");
|
||||||
const legacyReliabilityView = migrateLegacyReliabilityView(viewParam);
|
const legacyReliabilityView = migrateLegacyReliabilityView(viewParam);
|
||||||
|
const retiredStashRecoveryView = migrateRetiredStashRecoveryView(viewParam);
|
||||||
if (legacyReliabilityView) {
|
if (legacyReliabilityView) {
|
||||||
setTaskView(legacyReliabilityView);
|
setTaskView(legacyReliabilityView);
|
||||||
|
} else if (retiredStashRecoveryView) {
|
||||||
|
setTaskView(retiredStashRecoveryView);
|
||||||
} else if (viewParam && isTaskView(viewParam)) {
|
} else if (viewParam && isTaskView(viewParam)) {
|
||||||
setTaskView(normalizeTaskView(viewParam));
|
setTaskView(normalizeTaskView(viewParam));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ export const VIEW_SOURCE_MAP: Record<TaskViewId, string> = {
|
|||||||
"command-center": "components/command-center/CommandCenter.tsx",
|
"command-center": "components/command-center/CommandCenter.tsx",
|
||||||
"dev-server": "components/DevServerView.tsx",
|
"dev-server": "components/DevServerView.tsx",
|
||||||
goalsView: "components/GoalsView.tsx",
|
goalsView: "components/GoalsView.tsx",
|
||||||
"stash-recovery": "components/StashRecoveryView.tsx",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ManifestCacheEntry = {
|
type ManifestCacheEntry = {
|
||||||
|
|||||||
Reference in New Issue
Block a user