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:
7
.changeset/fn-7657-github-import-state-retained.md
Normal file
7
.changeset/fn-7657-github-import-state-retained.md
Normal 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.
|
||||||
@@ -277,6 +277,8 @@ Use Import Tasks on desktop/tablet:
|
|||||||
5. Select the import action.
|
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.
|
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:
|
Use GitHub import on mobile:
|
||||||
|
|
||||||
1. Open the compact Header actions or bottom **More** sheet and select **Import from GitHub**.
|
1. Open the compact Header actions or bottom **More** sheet and select **Import from GitHub**.
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
|||||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||||
import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation";
|
import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation";
|
||||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||||
|
import { getGitHubImportState, saveGitHubImportState } from "../hooks/modalPersistence";
|
||||||
|
|
||||||
interface GitHubImportModalProps {
|
interface GitHubImportModalProps {
|
||||||
isOpen: boolean;
|
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
|
// 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);
|
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
|
// Build set of already imported URLs from existing tasks
|
||||||
const importedUrls = new Set<string>();
|
const importedUrls = new Set<string>();
|
||||||
for (const task of tasks) {
|
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(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
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("");
|
setOwner("");
|
||||||
setRepo("");
|
setRepo("");
|
||||||
setLabels("");
|
setLabels(persisted?.labels ?? "");
|
||||||
setProvider("github");
|
setProvider(persisted?.provider ?? "github");
|
||||||
setGitlabResource("project_issue");
|
setGitlabResource(persisted?.gitlabResource ?? "project_issue");
|
||||||
setGitlabProject("");
|
setGitlabProject(persisted?.gitlabProject ?? "");
|
||||||
setGitlabGroup("");
|
setGitlabGroup(persisted?.gitlabGroup ?? "");
|
||||||
setGitlabItems([]);
|
setGitlabItems([]);
|
||||||
setSelectedGitlabKey(null);
|
setSelectedGitlabKey(null);
|
||||||
setIssues([]);
|
setIssues([]);
|
||||||
setSelectedIssueNumber(null);
|
setSelectedIssueNumber(null);
|
||||||
setPulls([]);
|
setPulls([]);
|
||||||
setSelectedPullNumber(null);
|
setSelectedPullNumber(null);
|
||||||
setActiveTab("issues");
|
setActiveTab(persisted?.activeTab ?? "issues");
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsIssuesEmptyState(false);
|
setIsIssuesEmptyState(false);
|
||||||
setIsPullsEmptyState(false);
|
setIsPullsEmptyState(false);
|
||||||
@@ -466,6 +512,22 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
|||||||
setSelectedRemoteName("");
|
setSelectedRemoteName("");
|
||||||
autoLoadedRef.current = null;
|
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;
|
mountedRef.current = true;
|
||||||
const remoteLoadRequestId = remoteLoadRequestIdRef.current + 1;
|
const remoteLoadRequestId = remoteLoadRequestIdRef.current + 1;
|
||||||
remoteLoadRequestIdRef.current = remoteLoadRequestId;
|
remoteLoadRequestIdRef.current = remoteLoadRequestId;
|
||||||
@@ -487,21 +549,38 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
|||||||
setRemotes(fetchedRemotes);
|
setRemotes(fetchedRemotes);
|
||||||
setLoadingRemotes(false);
|
setLoadingRemotes(false);
|
||||||
|
|
||||||
const defaultRemote = fetchedRemotes.length === 1
|
// Hydrated remote/owner/repo take precedence when the named remote still exists in the freshly detected list.
|
||||||
? fetchedRemotes[0]
|
const hydratedRemoteName = persisted?.selectedRemoteName;
|
||||||
: fetchedRemotes.find((remote) => remote.name === "origin");
|
const hydratedRemote = hydratedRemoteName
|
||||||
|
? fetchedRemotes.find((remote) => remote.name === hydratedRemoteName)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (defaultRemote) {
|
if (hydratedRemote) {
|
||||||
setOwner(defaultRemote.owner);
|
setOwner(hydratedRemote.owner);
|
||||||
setRepo(defaultRemote.repo);
|
setRepo(hydratedRemote.repo);
|
||||||
setSelectedRemoteName(defaultRemote.name);
|
setSelectedRemoteName(hydratedRemote.name);
|
||||||
} else if (fetchedRemotes.length > 1) {
|
} else if (persisted?.owner && persisted?.repo) {
|
||||||
// Multiple remotes without origin: don't auto-select, user must choose.
|
// Persisted owner/repo survive even without a matching named remote (e.g. remote renamed/removed).
|
||||||
setOwner("");
|
setOwner(persisted.owner);
|
||||||
setRepo("");
|
setRepo(persisted.repo);
|
||||||
setSelectedRemoteName("");
|
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(() => {
|
.catch(() => {
|
||||||
if (!cancelled && mountedRef.current && remoteLoadRequestId === remoteLoadRequestIdRef.current) {
|
if (!cancelled && mountedRef.current && remoteLoadRequestId === remoteLoadRequestIdRef.current) {
|
||||||
@@ -554,6 +633,14 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
|||||||
if (fetchedIssues.length === 0) {
|
if (fetchedIssues.length === 0) {
|
||||||
setIsIssuesEmptyState(true);
|
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) {
|
} catch (err) {
|
||||||
setError(getErrorMessage(err) || t("git.failedToFetchIssues", "Failed to fetch issues"));
|
setError(getErrorMessage(err) || t("git.failedToFetchIssues", "Failed to fetch issues"));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -580,6 +667,14 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
|||||||
if (fetchedPulls.length === 0) {
|
if (fetchedPulls.length === 0) {
|
||||||
setIsPullsEmptyState(true);
|
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) {
|
} catch (err) {
|
||||||
setError(getErrorMessage(err) || t("git.failedToFetchPulls", "Failed to fetch pull requests"));
|
setError(getErrorMessage(err) || t("git.failedToFetchPulls", "Failed to fetch pull requests"));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -631,6 +726,14 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
|||||||
: await apiFetchGitLabMergeRequests(project, 30, labelArray.length > 0 ? labelArray : undefined);
|
: await apiFetchGitLabMergeRequests(project, 30, labelArray.length > 0 ? labelArray : undefined);
|
||||||
setGitlabItems(fetched);
|
setGitlabItems(fetched);
|
||||||
if (fetched.length === 0) setIsIssuesEmptyState(true);
|
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) {
|
} catch (err) {
|
||||||
setError(getErrorMessage(err) || t("git.failedToFetchGitlab", "Failed to fetch GitLab resources"));
|
setError(getErrorMessage(err) || t("git.failedToFetchGitlab", "Failed to fetch GitLab resources"));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -658,6 +761,65 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
|||||||
}
|
}
|
||||||
}, [selectedGitlabItem, gitlabEnabled, gitlabResource, gitlabProject, gitlabGroup, projectId, onImport, isMobile, mobileView, t]);
|
}, [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
|
// Auto-load data when owner and repo are set and valid
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (provider !== "github") return;
|
if (provider !== "github") return;
|
||||||
|
|||||||
@@ -154,8 +154,25 @@ describe("GitHubImportModal", () => {
|
|||||||
expect(activeRule).toContain("background: color-mix(in srgb, var(--todo) 12%, transparent);");
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
clearAllPersistedImportState();
|
||||||
vi.mocked(fetchGitRemotes).mockReset();
|
vi.mocked(fetchGitRemotes).mockReset();
|
||||||
vi.mocked(apiFetchGitHubIssues).mockReset();
|
vi.mocked(apiFetchGitHubIssues).mockReset();
|
||||||
vi.mocked(apiImportGitHubIssue).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/project
|
|||||||
export const STORED_PLANNING_KEY = "kb-planning-last-description";
|
export const STORED_PLANNING_KEY = "kb-planning-last-description";
|
||||||
export const STORED_SUBTASK_KEY = "kb-subtask-last-description";
|
export const STORED_SUBTASK_KEY = "kb-subtask-last-description";
|
||||||
export const STORED_MISSION_KEY = "kb-mission-last-goal";
|
export const STORED_MISSION_KEY = "kb-mission-last-goal";
|
||||||
|
export const STORED_GITHUB_IMPORT_KEY = "kb-dashboard-github-import-state";
|
||||||
|
|
||||||
// Planning persistence
|
// Planning persistence
|
||||||
|
|
||||||
@@ -46,3 +47,74 @@ export function getMissionGoal(projectId?: string): string {
|
|||||||
export function clearMissionGoal(projectId?: string): void {
|
export function clearMissionGoal(projectId?: string): void {
|
||||||
removeScopedItem(STORED_MISSION_KEY, projectId);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const PROJECT_STORAGE_KEYS: string[] = [
|
|||||||
"kb-dashboard-mailbox-sidebar-width",
|
"kb-dashboard-mailbox-sidebar-width",
|
||||||
"kb-dashboard-agents-sidebar-width",
|
"kb-dashboard-agents-sidebar-width",
|
||||||
"kb-dashboard-github-import-list-width",
|
"kb-dashboard-github-import-list-width",
|
||||||
|
"kb-dashboard-github-import-state",
|
||||||
"kb-quick-entry-text",
|
"kb-quick-entry-text",
|
||||||
"kb-inline-create-text",
|
"kb-inline-create-text",
|
||||||
"fn-agent-view",
|
"fn-agent-view",
|
||||||
|
|||||||
Reference in New Issue
Block a user