FN-7657: persist GitHub issue import modal state across close/reopen

Retains the GitHub Import Tasks modal's provider/tab/filter/selection state so returning to Import Tasks doesn't reset the user's in-progress import setup.

- GitHubImportModal now persists provider, active tab, label filter, remote, and issue selection per project via a new modalPersistence hook, restoring them on remount instead of always defaulting.
- Added packages/dashboard/app/hooks/modalPersistence.ts to encapsulate the persisted-state read/write logic backed by projectStorage.
- projectStorage.ts gains the `kb-dashboard-github-import-state` storage key.
- Falls back to the existing default-remote auto-detect behavior when no persisted state exists.
- Added extensive test coverage in GitHubImportModal.test.tsx for the new persistence behavior.
- Updated docs/dashboard-guide.md to describe the retained state.
- Added a patch changeset for @runfusion/fusion.

Files changed:
 .changeset/fn-7657-github-import-state-retained.md |   7 +
 docs/dashboard-guide.md                            |   2 +
 .../dashboard/app/components/GitHubImportModal.tsx | 204 +++++++++++++--
 .../__tests__/GitHubImportModal.test.tsx           | 282 +++++++++++++++++++++
 packages/dashboard/app/hooks/modalPersistence.ts   |  72 ++++++
 packages/dashboard/app/utils/projectStorage.ts     |   1 +
 6 files changed, 547 insertions(+), 21 deletions(-)

Fusion-Task-Id: FN-7657
Fusion-Task-Lineage: c8086340-368c-4efd-a12a-4cddeeb0aa26
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-08 00:14:52 -07:00
parent e29fea38e0
commit 511bcaf56d
6 changed files with 546 additions and 20 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Retain GitHub issue import state when leaving and returning to Import Tasks.
category: fix
dev: Persists GitHubImportModal provider/tab/label filter/remote/selection per project via projectStorage (`kb-dashboard-github-import-state`) and hydrates on remount; falls back to the existing default-remote auto-detect when nothing is persisted.

View File

@@ -277,6 +277,8 @@ Use Import Tasks on desktop/tablet:
5. Select the import action.
Expected outcome: Fusion creates a task (or review task for a pull request) on the board and preserves GitHub provenance/tracking metadata. After a successful issue import, the issue selection clears and the view returns to the main issue list/no-selection preview so completed issue actions do not leave stale buttons active.
Leaving and returning to **Import Tasks** (for example switching to Board and back) restores the prior context for the current project — provider (GitHub/GitLab), active Issues/PRs tab, label filter, selected repository/remote, GitLab project/group inputs, and the previously selected issue/PR — instead of resetting to defaults. The restored selection re-validates against the freshly reloaded list; a selection that no longer exists (e.g. the issue was closed upstream) clears gracefully rather than showing a stuck or empty preview. First-time opens with no prior state keep the existing default-remote auto-detect behavior. State is scoped per project and does not leak across projects.
Use GitHub import on mobile:
1. Open the compact Header actions or bottom **More** sheet and select **Import from GitHub**.

View File

@@ -36,6 +36,7 @@ import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation";
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
import { getGitHubImportState, saveGitHubImportState } from "../hooks/modalPersistence";
interface GitHubImportModalProps {
isOpen: boolean;
@@ -421,6 +422,26 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
// Track which owner/repo we've already auto-loaded to prevent duplicate loads
const autoLoadedRef = useRef<{ owner: string; repo: string; labels: string; tab: TabType } | null>(null);
/*
FNXC:GitHubImport 2026-07-07-00:00:
Restored selections (issue/pull/GitLab) must not be applied until the reloaded list actually contains them, otherwise a
stale selection would either render a dead preview or (worse) silently point at the wrong item. `handleLoad`/
`handleLoadPulls`/`handleLoadGitLab` all clear the selection at the START of a fetch (existing behavior for user-triggered
reloads), so the hydrated-on-mount selection is parked here and only re-applied once the corresponding fetch resolves AND
the item is still present in the reloaded list; otherwise it is silently dropped (graceful degrade, no stuck/empty preview).
*/
const pendingRestoreSelectionRef = useRef<{ issueNumber: number | null; pullNumber: number | null; gitlabKey: string | null }>({
issueNumber: null,
pullNumber: null,
gitlabKey: null,
});
// Set true during mount hydration when the persisted provider is GitLab with enough input to load; consumed by a
// one-shot effect below once gitlabProject/gitlabGroup state actually reflects the hydrated values.
const needsGitlabAutoLoadRef = useRef(false);
// Gates the persist-on-change effect until the mount hydration effect has applied its (possibly restored) values to
// state, so the FIRST commit's still-default state is never written over a real persisted value.
const [readyToPersistImportState, setReadyToPersistImportState] = useState(false);
// Build set of already imported URLs from existing tasks
const importedUrls = new Set<string>();
for (const task of tasks) {
@@ -440,23 +461,48 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
}
}
// Reset state when modal opens and fetch remotes
/*
FNXC:GitHubImport 2026-07-07-00:00:
Retain-state-on-exit-and-return (FN-7657): the embedded Import Tasks view fully unmounts on navigation away and remounts
fresh on return, so this "reset on open" effect must HYDRATE persisted per-project state instead of hard-resetting it when
a prior value exists, and fall back to the exact previous reset/default-remote-auto-detect behavior when nothing is
persisted (first-time opens keep their existing UX unchanged). Only the cheap, restorable fields are hydrated here
(provider/tab/labels/remote/owner/repo/GitLab inputs/selection) — fetched issue/pull/GitLab lists, loading flags, and
detail caches are ALWAYS reset and re-derived via the existing auto-load, never persisted.
Decision: the modal presentation (`AppModals.tsx`, mobile overflow path) shares this same effect/state and therefore
ALSO restores persisted state on open rather than starting fresh — keeping the two presentations coherent, since both
read/write the same per-project storage key and a user may open either one first. Modal open/close otherwise behaves
exactly as before (Cancel/close still unmount-discards in-memory-only state such as the fetched lists/loading flags).
*/
useEffect(() => {
if (isOpen) {
setReadyToPersistImportState(false);
const persisted = getGitHubImportState(projectId);
/*
FNXC:GitHubImport 2026-07-07-00:00:
owner/repo/selectedRemoteName are intentionally NOT hydrated synchronously here (unlike the other fields) — doing so
would populate them before the remote-detection fetch below resolves, which flips the auto-load effect on
immediately (synchronously, within the same mount commit) instead of after the async remote fetch as before. That
earlier timing can disable the tab buttons (loading=true) before a user's next interaction lands. They are applied
instead inside the fetchGitRemotes().then() below, preserving the original async timing while still taking
precedence over the default-remote auto-detect once remotes are known.
*/
setOwner("");
setRepo("");
setLabels("");
setProvider("github");
setGitlabResource("project_issue");
setGitlabProject("");
setGitlabGroup("");
setLabels(persisted?.labels ?? "");
setProvider(persisted?.provider ?? "github");
setGitlabResource(persisted?.gitlabResource ?? "project_issue");
setGitlabProject(persisted?.gitlabProject ?? "");
setGitlabGroup(persisted?.gitlabGroup ?? "");
setGitlabItems([]);
setSelectedGitlabKey(null);
setIssues([]);
setSelectedIssueNumber(null);
setPulls([]);
setSelectedPullNumber(null);
setActiveTab("issues");
setActiveTab(persisted?.activeTab ?? "issues");
setError(null);
setIsIssuesEmptyState(false);
setIsPullsEmptyState(false);
@@ -466,6 +512,22 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
setSelectedRemoteName("");
autoLoadedRef.current = null;
// Stash the restored selections; they are re-applied once the corresponding reload resolves and confirms the item
// is still present (see handleLoad/handleLoadPulls/handleLoadGitLab), never applied blindly.
pendingRestoreSelectionRef.current = {
issueNumber: persisted?.selectedIssueNumber ?? null,
pullNumber: persisted?.selectedPullNumber ?? null,
gitlabKey: persisted?.selectedGitlabKey ?? null,
};
needsGitlabAutoLoadRef.current =
persisted?.provider === "gitlab" &&
(persisted.gitlabResource === "group_issue" ? Boolean(persisted.gitlabGroup) : Boolean(persisted.gitlabProject));
// Applying the hydrated (possibly-empty) values above completes the synchronous portion of hydration; flipping this
// now lets the persist-on-change effect start writing from the NEXT render, which already reflects these values —
// never the pre-hydration defaults from this render's initial mount.
setReadyToPersistImportState(true);
mountedRef.current = true;
const remoteLoadRequestId = remoteLoadRequestIdRef.current + 1;
remoteLoadRequestIdRef.current = remoteLoadRequestId;
@@ -487,21 +549,38 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
setRemotes(fetchedRemotes);
setLoadingRemotes(false);
const defaultRemote = fetchedRemotes.length === 1
? fetchedRemotes[0]
: fetchedRemotes.find((remote) => remote.name === "origin");
// Hydrated remote/owner/repo take precedence when the named remote still exists in the freshly detected list.
const hydratedRemoteName = persisted?.selectedRemoteName;
const hydratedRemote = hydratedRemoteName
? fetchedRemotes.find((remote) => remote.name === hydratedRemoteName)
: undefined;
if (defaultRemote) {
setOwner(defaultRemote.owner);
setRepo(defaultRemote.repo);
setSelectedRemoteName(defaultRemote.name);
} else if (fetchedRemotes.length > 1) {
// Multiple remotes without origin: don't auto-select, user must choose.
setOwner("");
setRepo("");
setSelectedRemoteName("");
if (hydratedRemote) {
setOwner(hydratedRemote.owner);
setRepo(hydratedRemote.repo);
setSelectedRemoteName(hydratedRemote.name);
} else if (persisted?.owner && persisted?.repo) {
// Persisted owner/repo survive even without a matching named remote (e.g. remote renamed/removed).
setOwner(persisted.owner);
setRepo(persisted.repo);
setSelectedRemoteName(persisted.selectedRemoteName ?? "");
} else {
const defaultRemote = fetchedRemotes.length === 1
? fetchedRemotes[0]
: fetchedRemotes.find((remote) => remote.name === "origin");
if (defaultRemote) {
setOwner(defaultRemote.owner);
setRepo(defaultRemote.repo);
setSelectedRemoteName(defaultRemote.name);
} else if (fetchedRemotes.length > 1) {
// Multiple remotes without origin: don't auto-select, user must choose.
setOwner("");
setRepo("");
setSelectedRemoteName("");
}
// If no remotes, owner/repo remain empty
}
// If no remotes, owner/repo remain empty
})
.catch(() => {
if (!cancelled && mountedRef.current && remoteLoadRequestId === remoteLoadRequestIdRef.current) {
@@ -554,6 +633,14 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
if (fetchedIssues.length === 0) {
setIsIssuesEmptyState(true);
}
// FNXC:GitHubImport 2026-07-07-00:00: Re-apply a hydrated-on-mount selection only if it survived the reload; otherwise drop it silently (already null from the reset above).
const restoreIssueNumber = pendingRestoreSelectionRef.current.issueNumber;
if (restoreIssueNumber !== null) {
pendingRestoreSelectionRef.current.issueNumber = null;
if (fetchedIssues.some((issue) => issue.number === restoreIssueNumber)) {
setSelectedIssueNumber(restoreIssueNumber);
}
}
} catch (err) {
setError(getErrorMessage(err) || t("git.failedToFetchIssues", "Failed to fetch issues"));
} finally {
@@ -580,6 +667,14 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
if (fetchedPulls.length === 0) {
setIsPullsEmptyState(true);
}
// FNXC:GitHubImport 2026-07-07-00:00: Re-apply a hydrated-on-mount selection only if it survived the reload; otherwise drop it silently (already null from the reset above).
const restorePullNumber = pendingRestoreSelectionRef.current.pullNumber;
if (restorePullNumber !== null) {
pendingRestoreSelectionRef.current.pullNumber = null;
if (fetchedPulls.some((pull) => pull.number === restorePullNumber)) {
setSelectedPullNumber(restorePullNumber);
}
}
} catch (err) {
setError(getErrorMessage(err) || t("git.failedToFetchPulls", "Failed to fetch pull requests"));
} finally {
@@ -631,6 +726,14 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
: await apiFetchGitLabMergeRequests(project, 30, labelArray.length > 0 ? labelArray : undefined);
setGitlabItems(fetched);
if (fetched.length === 0) setIsIssuesEmptyState(true);
// FNXC:GitHubImport 2026-07-07-00:00: Re-apply a hydrated-on-mount GitLab selection only if it survived the reload; otherwise drop it silently (already null from the reset above).
const restoreGitlabKey = pendingRestoreSelectionRef.current.gitlabKey;
if (restoreGitlabKey !== null) {
pendingRestoreSelectionRef.current.gitlabKey = null;
if (fetched.some((item) => `${item.resourceKind}:${item.projectId ?? item.projectPath ?? ""}:${item.iid}` === restoreGitlabKey)) {
setSelectedGitlabKey(restoreGitlabKey);
}
}
} catch (err) {
setError(getErrorMessage(err) || t("git.failedToFetchGitlab", "Failed to fetch GitLab resources"));
} finally {
@@ -658,6 +761,65 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
}
}, [selectedGitlabItem, gitlabEnabled, gitlabResource, gitlabProject, gitlabGroup, projectId, onImport, isMobile, mobileView, t]);
/*
FNXC:GitHubImport 2026-07-07-00:00:
Mirrors the GitHub auto-load effect below for GitLab: on mount hydration with a persisted GitLab provider + enough input
(project for project_issue/merge_request, group for group_issue), trigger exactly one auto-load so the restored
selection has a list to be validated/re-applied against (see handleLoadGitLab). One-shot via the ref flag so manual
reloads/tab switches never re-trigger this.
*/
useEffect(() => {
if (!needsGitlabAutoLoadRef.current) return;
if (provider !== "gitlab") return;
const ready = gitlabResource === "group_issue" ? Boolean(gitlabGroup.trim()) : Boolean(gitlabProject.trim());
if (!ready) return;
needsGitlabAutoLoadRef.current = false;
handleLoadGitLab();
}, [provider, gitlabResource, gitlabProject, gitlabGroup, handleLoadGitLab]);
/*
FNXC:GitHubImport 2026-07-07-00:00:
Persist the cheap/restorable import-state fields per-project whenever any of them change, so leaving and returning to
the embedded view resumes the user's prior context. Gated on readyToPersistImportState so the FIRST commit after mount
(still holding pre-hydration defaults) never overwrites a real persisted value; the mount-hydration effect above flips
this flag only after applying the (possibly restored) values to state.
*/
useEffect(() => {
if (!readyToPersistImportState) return;
saveGitHubImportState(
{
provider,
activeTab,
labels,
selectedRemoteName,
owner,
repo,
gitlabResource,
gitlabProject,
gitlabGroup,
selectedIssueNumber,
selectedPullNumber,
selectedGitlabKey,
},
projectId,
);
}, [
readyToPersistImportState,
provider,
activeTab,
labels,
selectedRemoteName,
owner,
repo,
gitlabResource,
gitlabProject,
gitlabGroup,
selectedIssueNumber,
selectedPullNumber,
selectedGitlabKey,
projectId,
]);
// Auto-load data when owner and repo are set and valid
useEffect(() => {
if (provider !== "github") return;

View File

@@ -154,8 +154,25 @@ describe("GitHubImportModal", () => {
expect(activeRule).toContain("background: color-mix(in srgb, var(--todo) 12%, transparent);");
});
/*
* FNXC:GitHubImport 2026-07-07-00:00:
* FN-7657 introduced per-project persistence for the import view (provider/tab/labels/remote/selection) under
* `kb-dashboard-github-import-state` (unscoped when no projectId is passed, `kb:{projectId}:...` otherwise). Most
* pre-existing tests in this file render without a projectId and therefore share the SAME unscoped storage key, so
* that key (and the projectIds exercised anywhere in this file) must be cleared before EVERY test or state written
* by one test would leak into the next test's initial render.
*/
const GITHUB_IMPORT_STATE_KEY = "kb-dashboard-github-import-state";
const clearAllPersistedImportState = () => {
window.localStorage.removeItem(GITHUB_IMPORT_STATE_KEY);
for (const projectId of ["project-1", "project-2", "project-a", "project-b"]) {
window.localStorage.removeItem(`kb:${projectId}:${GITHUB_IMPORT_STATE_KEY}`);
}
};
beforeEach(() => {
vi.clearAllMocks();
clearAllPersistedImportState();
vi.mocked(fetchGitRemotes).mockReset();
vi.mocked(apiFetchGitHubIssues).mockReset();
vi.mocked(apiImportGitHubIssue).mockReset();
@@ -2232,4 +2249,269 @@ describe("GitHubImportModal", () => {
});
});
});
/*
* FNXC:GitHubImport 2026-07-07-00:00:
* FN-7657 symptom verification. The embedded Import Tasks view fully unmounts on navigation away (e.g. to Board) and
* remounts fresh on return; before this fix every one of these fields reset to defaults on remount. These tests
* unmount + remount a fresh instance with the SAME projectId to simulate exactly that, and assert restoration.
*/
describe("import state retention on exit and return (FN-7657)", () => {
const GITHUB_IMPORT_STATE_KEY = "kb-dashboard-github-import-state";
const originalInnerWidth = window.innerWidth;
const clearImportState = (projectId?: string) => {
const key = projectId ? `kb:${projectId}:${GITHUB_IMPORT_STATE_KEY}` : GITHUB_IMPORT_STATE_KEY;
window.localStorage.removeItem(key);
};
beforeEach(() => {
clearImportState("project-1");
clearImportState("project-2");
clearImportState(undefined);
});
afterEach(() => {
clearImportState("project-1");
clearImportState("project-2");
clearImportState(undefined);
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: originalInnerWidth,
});
window.dispatchEvent(new Event("resize"));
});
it("restores the active tab, label filter, and selected issue after unmount and remount for the same project", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValue(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValue([
{ number: 1, title: "Persisted Issue", body: "Body", html_url: "https://github.com/dustinbyrne/kb/issues/1", labels: [] },
]);
const first = render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
);
await waitFor(() => {
expect(screen.getByText("Persisted Issue")).toBeTruthy();
});
fireEvent.change(screen.getByPlaceholderText(/Filter:/), { target: { value: "bug" } });
// The label change re-triggers auto-load (briefly disabling the list); wait for it to settle before selecting.
await waitFor(() => {
expect(screen.getByRole("radio", { name: /Select issue #1/i })).not.toBeDisabled();
});
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
await waitFor(() => {
expect(within(screen.getByTestId("github-import-preview-card")).getByText("Persisted Issue")).toBeTruthy();
});
// Simulate navigating away from the embedded view (component fully unmounts).
first.unmount();
// Simulate returning to the view: a brand-new instance mounts for the same project.
render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
);
await waitFor(() => {
expect((screen.getByPlaceholderText(/Filter:/) as HTMLInputElement).value).toBe("bug");
});
await waitFor(() => {
expect(within(screen.getByTestId("github-import-preview-card")).getByText("Persisted Issue")).toBeTruthy();
});
});
it("restores the Pull Requests tab and selected PR after unmount and remount", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValue(singleRemote);
vi.mocked(apiFetchGitHubPulls).mockResolvedValue(mockPulls);
const first = render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
);
fireEvent.click(await screen.findByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => {
expect(screen.getByText("Test PR")).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio", { name: /Select pull request #1/i }));
await waitFor(() => {
expect(within(screen.getByTestId("github-import-preview-card")).getByText("Test PR")).toBeTruthy();
});
first.unmount();
render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
);
// Restored straight to the Pull Requests tab, with the prior PR selection re-applied.
await waitFor(() => {
expect(screen.getByRole("tab", { name: /Pull Requests/i })).toHaveAttribute("aria-selected", "true");
});
await waitFor(() => {
expect(within(screen.getByTestId("github-import-preview-card")).getByText("Test PR")).toBeTruthy();
});
});
it("restores GitLab provider, resource inputs, and selection after unmount and remount", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValue([]);
vi.mocked(apiFetchGitLabProjectIssues).mockResolvedValue([
{ resourceKind: "project_issue", id: 1, iid: 2, projectId: 3, projectPath: "group/project", title: "GitLab bug", description: "Body", webUrl: "https://gitlab.example.com/group/project/-/issues/2", state: "opened", labels: ["bug"] },
]);
const first = render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
);
fireEvent.click(await screen.findByRole("button", { name: "GitLab" }));
fireEvent.change(screen.getByLabelText("GitLab project path or ID"), { target: { value: "group/project" } });
fireEvent.click(screen.getByRole("button", { name: /Load/ }));
fireEvent.click(await screen.findByText(/#2 GitLab bug/));
await waitFor(() => {
expect(screen.getByTestId("gitlab-import-preview-card")).toBeTruthy();
});
first.unmount();
render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
);
// Provider tab and GitLab project input are restored immediately from persisted state.
await waitFor(() => {
expect(screen.getByRole("button", { name: "GitLab" })).toHaveAttribute("aria-pressed", "true");
});
await waitFor(() => {
expect((screen.getByLabelText("GitLab project path or ID") as HTMLInputElement).value).toBe("group/project");
});
// The hydrated-on-mount auto-load re-fetches the list and re-applies the restored selection.
await waitFor(() => {
expect(screen.getByTestId("gitlab-import-preview-card")).toBeTruthy();
});
});
it("keeps the existing default remote auto-detect behavior when no state has ever been persisted", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([
{ number: 9, title: "Fresh Issue", body: "", html_url: "https://github.com/dustinbyrne/kb/issues/9", labels: [] },
]);
render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
);
// No persisted state exists for this project: the single detected remote is still auto-selected and its issues load.
await waitFor(() => {
expect(screen.getByTestId("github-import-single-remote")).toBeTruthy();
expect(apiFetchGitHubIssues).toHaveBeenCalledWith("dustinbyrne", "kb", 30, undefined);
expect(screen.getByText("Fresh Issue")).toBeTruthy();
});
});
it("does not leak persisted state across different projects", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValue(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValue([
{ number: 1, title: "Project One Issue", body: "", html_url: "https://github.com/dustinbyrne/kb/issues/1", labels: [] },
]);
const first = render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
);
await waitFor(() => expect(screen.getByText("Project One Issue")).toBeTruthy());
fireEvent.change(screen.getByPlaceholderText(/Filter:/), { target: { value: "bug" } });
// The label change re-triggers auto-load (briefly disabling the list); wait for it to settle before selecting.
await waitFor(() => {
expect(screen.getByRole("radio", { name: /Select issue #1/i })).not.toBeDisabled();
});
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
await waitFor(() => {
expect(within(screen.getByTestId("github-import-preview-card")).getByText("Project One Issue")).toBeTruthy();
});
first.unmount();
// (project-1's own selection is verified above; now assert isolation for project-2.)
// A different project must NOT see project-1's persisted filter/selection.
render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-2" presentation="embedded" />,
);
await waitFor(() => {
expect((screen.getByPlaceholderText(/Filter:/) as HTMLInputElement).value).toBe("");
});
expect(screen.getByTestId("github-import-preview-empty")).toBeTruthy();
});
it("clears gracefully when a persisted selection is no longer present in the reloaded list", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValue(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([
{ number: 1, title: "Will Vanish", body: "", html_url: "https://github.com/dustinbyrne/kb/issues/1", labels: [] },
]);
const first = render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
);
await waitFor(() => expect(screen.getByText("Will Vanish")).toBeTruthy());
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
await waitFor(() => {
expect(within(screen.getByTestId("github-import-preview-card")).getByText("Will Vanish")).toBeTruthy();
});
first.unmount();
// On return, the reloaded list no longer contains issue #1 (e.g. closed/merged/deleted upstream).
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([
{ number: 2, title: "Still Here", body: "", html_url: "https://github.com/dustinbyrne/kb/issues/2", labels: [] },
]);
render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
);
// No crash and no stuck preview: the stale selection is dropped and the list/empty-preview state renders cleanly.
await waitFor(() => {
expect(screen.getByText("Still Here")).toBeTruthy();
});
expect(screen.getByTestId("github-import-preview-empty")).toBeTruthy();
});
it("does not strand a restored selection on an empty mobile preview pane after remount", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 480,
});
window.dispatchEvent(new Event("resize"));
vi.mocked(fetchGitRemotes).mockResolvedValue(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValue([
{ number: 1, title: "Mobile Persisted Issue", body: "", html_url: "https://github.com/dustinbyrne/kb/issues/1", labels: [] },
]);
const first = render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
);
await waitFor(() => expect(screen.getByText("Mobile Persisted Issue")).toBeTruthy());
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
first.unmount();
render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
);
// A fresh mount always starts on the list pane (never the preview pane), so a restored selection can never strand
// the user staring at a bare/empty preview — the list (with the restored selection re-applied) is visible instead.
await waitFor(() => {
const listPane = screen.getByTestId("github-import-list-pane");
expect(listPane.classList.contains("active")).toBe(true);
expect(screen.getByTestId("github-import-preview-pane").classList.contains("active")).toBe(false);
});
await waitFor(() => {
const radio = screen.getByRole("radio", { name: /Select issue #1/i }) as HTMLInputElement;
expect(radio.checked).toBe(true);
});
});
});
});

View File

@@ -4,6 +4,7 @@ import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/project
export const STORED_PLANNING_KEY = "kb-planning-last-description";
export const STORED_SUBTASK_KEY = "kb-subtask-last-description";
export const STORED_MISSION_KEY = "kb-mission-last-goal";
export const STORED_GITHUB_IMPORT_KEY = "kb-dashboard-github-import-state";
// Planning persistence
@@ -46,3 +47,74 @@ export function getMissionGoal(projectId?: string): string {
export function clearMissionGoal(projectId?: string): void {
removeScopedItem(STORED_MISSION_KEY, projectId);
}
// GitHub/GitLab import persistence
/*
FNXC:GitHubImport 2026-07-07-00:00:
The embedded Import Tasks view (`GitHubImportModal` rendered with `presentation="embedded"`, constant `isOpen={true}`) fully
unmounts when the user navigates to another main-content view and remounts from scratch on return, so its "reset state on
open" effect previously wiped provider/tab/filter/remote/selection every time. Persist ONLY the cheap, restorable fields
listed below (never the fetched issues/pulls/gitlab lists, loading flags, or detail caches — those re-derive via the
existing auto-load) per-project so returning to the view resumes where the user left off. First-time opens with no
persisted value must keep the existing default-remote auto-detect behavior untouched.
*/
export interface GitHubImportPersistedState {
provider: "github" | "gitlab";
activeTab: "issues" | "pulls";
labels: string;
selectedRemoteName: string;
owner: string;
repo: string;
gitlabResource: "project_issue" | "group_issue" | "merge_request";
gitlabProject: string;
gitlabGroup: string;
selectedIssueNumber: number | null;
selectedPullNumber: number | null;
selectedGitlabKey: string | null;
}
export function saveGitHubImportState(state: GitHubImportPersistedState, projectId?: string): void {
try {
setScopedItem(STORED_GITHUB_IMPORT_KEY, JSON.stringify(state), projectId);
} catch {
// Best-effort persistence; ignore storage failures (e.g. quota, disabled storage).
}
}
/**
* Reads and defensively re-shapes the persisted GitHub/GitLab import state.
* Returns null when nothing is stored, or when the stored value is corrupt/not an object, so callers can fall back
* to the existing reset/default-remote-auto-detect behavior exactly as before. Each field is individually validated
* and defaulted so a partially-corrupt or schema-drifted blob still yields a usable (if partial) restore.
*/
export function getGitHubImportState(projectId?: string): GitHubImportPersistedState | null {
try {
const raw = getScopedItem(STORED_GITHUB_IMPORT_KEY, projectId);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") return null;
const p = parsed as Record<string, unknown>;
return {
provider: p.provider === "gitlab" ? "gitlab" : "github",
activeTab: p.activeTab === "pulls" ? "pulls" : "issues",
labels: typeof p.labels === "string" ? p.labels : "",
selectedRemoteName: typeof p.selectedRemoteName === "string" ? p.selectedRemoteName : "",
owner: typeof p.owner === "string" ? p.owner : "",
repo: typeof p.repo === "string" ? p.repo : "",
gitlabResource:
p.gitlabResource === "group_issue" || p.gitlabResource === "merge_request" ? p.gitlabResource : "project_issue",
gitlabProject: typeof p.gitlabProject === "string" ? p.gitlabProject : "",
gitlabGroup: typeof p.gitlabGroup === "string" ? p.gitlabGroup : "",
selectedIssueNumber: typeof p.selectedIssueNumber === "number" ? p.selectedIssueNumber : null,
selectedPullNumber: typeof p.selectedPullNumber === "number" ? p.selectedPullNumber : null,
selectedGitlabKey: typeof p.selectedGitlabKey === "string" ? p.selectedGitlabKey : null,
};
} catch {
return null;
}
}
export function clearGitHubImportState(projectId?: string): void {
removeScopedItem(STORED_GITHUB_IMPORT_KEY, projectId);
}

View File

@@ -19,6 +19,7 @@ export const PROJECT_STORAGE_KEYS: string[] = [
"kb-dashboard-mailbox-sidebar-width",
"kb-dashboard-agents-sidebar-width",
"kb-dashboard-github-import-list-width",
"kb-dashboard-github-import-state",
"kb-quick-entry-text",
"kb-inline-create-text",
"fn-agent-view",