From 78ebeafcec11e9e04060940d95de55ff546c2002 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 2 Jul 2026 18:06:00 -0700 Subject: [PATCH 1/6] FN-7454: show Linear Import in Plugin Manager Register Linear Import as a built-in plugin so users can install and manage it from Plugin Manager. - Add fusion-plugin-linear-import to the built-in plugin catalog. - Extend Plugin Manager registry and rendering coverage for the Linear Import entry. - Add a patch changeset documenting the operator-visible plugin visibility fix. Files changed: .changeset/fn-7454-linear-plugin-visibility.md | 7 ++ .../dashboard/app/components/PluginManager.tsx | 11 +++ .../__tests__/PluginManager.registry.test.tsx | 72 ++++++++++++++++++- .../components/__tests__/PluginManager.test.tsx | 80 ++++++++++++++++++++++ 4 files changed, 168 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-7454 Fusion-Task-Lineage: 1b54adb4-78cc-47cc-ad7a-d675de64fd49 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7454-linear-plugin-visibility.md | 7 ++ .../app/components/PluginManager.tsx | 11 +++ .../__tests__/PluginManager.registry.test.tsx | 72 ++++++++++++++++- .../__tests__/PluginManager.test.tsx | 80 +++++++++++++++++++ 4 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 .changeset/fn-7454-linear-plugin-visibility.md diff --git a/.changeset/fn-7454-linear-plugin-visibility.md b/.changeset/fn-7454-linear-plugin-visibility.md new file mode 100644 index 0000000000..38b7690719 --- /dev/null +++ b/.changeset/fn-7454-linear-plugin-visibility.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Show the bundled Linear Import plugin in Plugin Manager and dashboard plugin surfaces. +category: fix +dev: Keeps fusion-plugin-linear-import registered across the built-in Plugin Manager catalog while reusing existing registry, dashboard view, and bundled packaging paths. diff --git a/packages/dashboard/app/components/PluginManager.tsx b/packages/dashboard/app/components/PluginManager.tsx index 0bf4448334..5061d0fffa 100644 --- a/packages/dashboard/app/components/PluginManager.tsx +++ b/packages/dashboard/app/components/PluginManager.tsx @@ -176,6 +176,17 @@ export const BUILTIN_PLUGINS: BuiltinPlugin[] = [ category: "integration", path: "./plugins/fusion-plugin-compound-engineering", }, + /* + * FNXC:PluginManager 2026-07-02-17:56: + * FN-7454 keeps Linear Import in the built-in catalog because FN-7443 shipped the plugin package, registry entry, and dashboard view, but users still could not install or manage it from Plugin Manager without this bundled-plugin registration. + */ + { + id: "fusion-plugin-linear-import", + name: "Linear Import", + description: "Browse Linear issues and import selected issues as Fusion triage tasks through plugin-owned settings.", + category: "integration", + path: "./plugins/fusion-plugin-linear-import", + }, { id: BUILTIN_AGENT_BROWSER_PLUGIN_ID, name: "Agent Browser", diff --git a/packages/dashboard/app/components/__tests__/PluginManager.registry.test.tsx b/packages/dashboard/app/components/__tests__/PluginManager.registry.test.tsx index 8757cc0d4e..0622d8efc0 100644 --- a/packages/dashboard/app/components/__tests__/PluginManager.registry.test.tsx +++ b/packages/dashboard/app/components/__tests__/PluginManager.registry.test.tsx @@ -97,8 +97,8 @@ function stubEventSource() { vi.stubGlobal("EventSource", MockEventSource); } -async function renderRegistry(entries: RegistryPluginEntry[] = registryEntries) { - vi.mocked(fetchPlugins).mockResolvedValue([installedPlugin]); +async function renderRegistry(entries: RegistryPluginEntry[] = registryEntries, installed: PluginInstallation[] = [installedPlugin]) { + vi.mocked(fetchPlugins).mockResolvedValue(installed); vi.mocked(fetchPluginRegistry).mockResolvedValue(entries); render(); await act(async () => { @@ -151,6 +151,74 @@ describe("PluginManager registry browsing", () => { expect(within(comingSoon).getByText("Coming Soon")).toBeInTheDocument(); }); + it.each([ + { + state: "not-installed" as const, + entry: { + id: "fusion-plugin-linear-import", + name: "Linear Import", + description: "Browse Linear issues and import selected issues as Fusion triage tasks through plugin-owned settings.", + version: "0.1.0", + author: "Fusion", + category: "integration" as const, + path: "./plugins/fusion-plugin-linear-import", + tags: ["linear", "import", "issues", "dashboard"], + installed: false, + canInstall: true, + }, + installed: [] as PluginInstallation[], + action: "Install", + }, + { + state: "installed-error" as const, + entry: { + id: "fusion-plugin-linear-import", + name: "Linear Import", + description: "Browse Linear issues and import selected issues as Fusion triage tasks through plugin-owned settings.", + version: "0.1.0", + author: "Fusion", + category: "integration" as const, + path: "./plugins/fusion-plugin-linear-import", + tags: ["linear", "import", "issues", "dashboard"], + installed: true, + installedVersion: "0.1.0", + state: "error" as const, + canInstall: true, + }, + installed: [{ ...installedPlugin, id: "fusion-plugin-linear-import", name: "Linear Import", state: "error" as const, enabled: true, error: "Linear plugin failed" }], + action: "Manage", + }, + { + state: "installed-started" as const, + entry: { + id: "fusion-plugin-linear-import", + name: "Linear Import", + description: "Browse Linear issues and import selected issues as Fusion triage tasks through plugin-owned settings.", + version: "0.1.0", + author: "Fusion", + category: "integration" as const, + path: "./plugins/fusion-plugin-linear-import", + tags: ["linear", "import", "issues", "dashboard"], + installed: true, + installedVersion: "0.1.0", + state: "started" as const, + canInstall: true, + }, + installed: [{ ...installedPlugin, id: "fusion-plugin-linear-import", name: "Linear Import", state: "started" as const, enabled: true }], + action: "Manage", + }, + ])("shows Linear Import registry action for $state", async ({ entry, installed, action }) => { + await renderRegistry([entry], installed); + + const section = screen.getByRole("region", { name: "Browse Registry" }); + const linear = within(section).getByText("Linear Import").closest(".plugin-registry-item") as HTMLElement; + expect(linear).toBeInTheDocument(); + expect(within(linear).getByText("Browse Linear issues and import selected issues as Fusion triage tasks through plugin-owned settings.")).toBeInTheDocument(); + expect(within(linear).getByText("integration")).toBeInTheDocument(); + expect(within(linear).getByRole("button", { name: action })).toBeInTheDocument(); + expect(within(section).getAllByText("Linear Import")).toHaveLength(1); + }); + it("installs registry plugins with their manifest path and refreshes installed plugins", async () => { await renderRegistry(); expect(fetchPlugins).toHaveBeenCalledTimes(1); diff --git a/packages/dashboard/app/components/__tests__/PluginManager.test.tsx b/packages/dashboard/app/components/__tests__/PluginManager.test.tsx index b7d8d6bd5d..2d274398d2 100644 --- a/packages/dashboard/app/components/__tests__/PluginManager.test.tsx +++ b/packages/dashboard/app/components/__tests__/PluginManager.test.tsx @@ -113,6 +113,7 @@ vi.mock("../../hooks/useConfirm", () => ({ import { AGENT_BROWSER_SETTINGS_SCHEMA, BUILTIN_AGENT_BROWSER_PLUGIN_ID, + BUILTIN_PLUGINS, PluginManager, STATE_COLORS, } from "../PluginManager"; @@ -131,6 +132,15 @@ import { } from "../../api"; const addToast = vi.fn(); +const LINEAR_PLUGIN_ID = "fusion-plugin-linear-import"; + +function getBuiltInPluginCard(name: string): HTMLElement { + const card = screen.getAllByText(name) + .map((node) => node.closest(".plugin-builtins-item")) + .find((node): node is HTMLElement => node instanceof HTMLElement); + expect(card).toBeTruthy(); + return card; +} function expectEventsUrl(url: string, projectId?: string) { const parsed = new URL(url, "http://localhost"); @@ -271,9 +281,79 @@ describe("PluginManager", () => { expect(screen.getByText("Reports")).toBeTruthy(); expect(screen.getByText("WhatsApp Chat")).toBeTruthy(); expect(screen.getByText("CLI Printing Press")).toBeTruthy(); + expect(screen.getByText("Linear Import")).toBeTruthy(); expect(screen.getByText(/Pairs to WhatsApp Web \(multi-device\) with QR or pairing code/i)).toBeTruthy(); }); + it("keeps Linear Import in the bundled plugin catalog", () => { + expect(BUILTIN_PLUGINS.find((plugin) => plugin.id === LINEAR_PLUGIN_ID)).toMatchObject({ + id: LINEAR_PLUGIN_ID, + name: "Linear Import", + category: "integration", + path: "./plugins/fusion-plugin-linear-import", + description: "Browse Linear issues and import selected issues as Fusion triage tasks through plugin-owned settings.", + }); + }); + + it("installs Linear Import from the built-in section when not installed", async () => { + vi.mocked(fetchPlugins).mockResolvedValue([]); + render(); + + await waitFor(() => { + expect(fetchPlugins).toHaveBeenCalled(); + }); + + const linearCard = getBuiltInPluginCard("Linear Import"); + expect(within(linearCard).getByText("Not installed")).toBeTruthy(); + const installButton = within(linearCard).getByRole("button", { name: /Install Linear Import/i }); + await userEvent.click(installButton); + + await waitFor(() => { + expect(installPlugin).toHaveBeenCalledWith({ path: "./plugins/fusion-plugin-linear-import" }, undefined); + expect(addToast).toHaveBeenCalledWith("Linear Import installed globally", "success"); + }); + }); + + it.each([ + { state: "installed" as const, enabled: false, statusLabel: "installed" }, + { state: "started" as const, enabled: true, statusLabel: "started" }, + { state: "error" as const, enabled: true, statusLabel: "error" }, + ])("shows Manage for installed Linear Import in $state state", async ({ state, enabled, statusLabel }) => { + vi.mocked(fetchPlugins).mockResolvedValue([ + { + ...mockPlugins[0], + id: LINEAR_PLUGIN_ID, + name: "Linear Import", + description: "Browse Linear issues and import selected issues as Fusion triage tasks through plugin-owned settings.", + state, + enabled, + error: state === "error" ? "Linear plugin failed to start" : undefined, + settingsSchema: {}, + }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getAllByText("Linear Import").length).toBeGreaterThanOrEqual(2); + }); + + const linearCard = getBuiltInPluginCard("Linear Import"); + expect(within(linearCard).getByText("Installed")).toBeTruthy(); + expect(within(linearCard).queryByText("Built-in metadata only")).toBeNull(); + expect(within(linearCard).getAllByText("integration").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText(statusLabel).length).toBeGreaterThanOrEqual(1); + + const manageButton = within(linearCard).getByRole("button", { name: /^Manage$/i }); + expect(manageButton).not.toBeDisabled(); + await userEvent.click(manageButton); + + await waitFor(() => { + expect(fetchPluginSettings).toHaveBeenCalledWith(LINEAR_PLUGIN_ID, undefined); + }); + expect(screen.getByTestId("plugin-manager-detail")).toBeTruthy(); + }); + it("renders built-in agent browser metadata-only entry when uninstalled", async () => { render(); From 1430a42dd8df6c863af7ce0e85edc603206323dd Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 2 Jul 2026 18:11:49 -0700 Subject: [PATCH 2/6] FN-7455: fix mobile direct chat header alignment Keep the mobile direct-chat header on a single left-aligned row without losing accessible context. - Hide the direct-chat ViewHeader title shell from layout while preserving the accessible Chat heading. - Keep the back button first and the active session switcher beside it on a non-wrapping mobile row. - Extend mobile chat coverage for long duplicate session titles, Bot fallback labels, and the CSS layout contract. - Document the updated mobile Chat header behavior and add a patch changeset. Files changed: .changeset/fn-7455-mobile-chat-header-layout.md | 7 ++ docs/dashboard-guide.md | 2 +- packages/dashboard/app/components/ChatView.css | 17 +++-- packages/dashboard/app/components/ChatView.tsx | 4 +- .../components/__tests__/ChatView.mobile.test.tsx | 80 ++++++++++++++++++++-- 5 files changed, 98 insertions(+), 12 deletions(-) Fusion-Task-Id: FN-7455 Fusion-Task-Lineage: fe14b542-ff6a-437b-ad89-8011c2b3c299 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7455-mobile-chat-header-layout.md | 7 ++ docs/dashboard-guide.md | 2 +- .../dashboard/app/components/ChatView.css | 17 +++- .../dashboard/app/components/ChatView.tsx | 4 +- .../__tests__/ChatView.mobile.test.tsx | 80 +++++++++++++++++-- 5 files changed, 98 insertions(+), 12 deletions(-) create mode 100644 .changeset/fn-7455-mobile-chat-header-layout.md diff --git a/.changeset/fn-7455-mobile-chat-header-layout.md b/.changeset/fn-7455-mobile-chat-header-layout.md new file mode 100644 index 0000000000..a5259eaa0b --- /dev/null +++ b/.changeset/fn-7455-mobile-chat-header-layout.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix the mobile Chat header so back navigation and session selection stay on one row. +category: fix +dev: Keeps the direct-chat mobile header collapsed while preserving desktop and room-chat layouts. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 606a80a37b..abcba69e77 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -411,7 +411,7 @@ Chat view provides project-scoped conversations with agents. - If you queue follow-up user messages while the assistant is still streaming, Chat persists them per session, stacks each queued preview above the input box with one shared divider, and restores/sends them one at a time in FIFO order once each active response finishes if you leave and return. - Chat message lists now track near-bottom scroll state: while you are reading older messages, live streaming/new replies do not force-scroll; a **Latest** jump control appears until you return to the tail. - On mobile direct-chat threads, entering a thread and restoring Chat after tab/page visibility returns re-anchors to the newest message (`scrollTop = scrollHeight`) so the view always opens at the live tail. -- On mobile direct-chat threads, the top Chat header collapses into one compact row: the back button and active conversation dropdown live beside the Chat icon, while the visible “Chat” title is hidden to preserve transcript space. Tapping the active conversation opens a lightweight dropdown so you can switch to another direct session or start a New Chat without backing out to the sidebar list first; long conversation titles stay readable in the dropdown via wrapped option text and taller touch-friendly rows. +- On mobile direct-chat threads, the top Chat header collapses into one compact row: the back button is the far-left visible control and the active conversation dropdown stays beside it, while the visible Chat icon/title shell is hidden to preserve transcript space. Tapping the active conversation opens a lightweight dropdown so you can switch to another direct session or start a New Chat without backing out to the sidebar list first; long conversation titles stay readable in the dropdown via wrapped option text and taller touch-friendly rows. - On mobile direct-chat threads, the single thread-wide Markdown/plain eye toggle floats above the transcript/composer area instead of occupying a second header row; desktop/tablet keeps the toggle in the thread header. - Direct chat sessions can be renamed from the sidebar row edit button, the desktop conversation context menu, and the mobile session switcher; blank rename submissions clear the custom title so the default session label is shown again. diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index 36a01af426..9217463b54 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -769,10 +769,14 @@ Mobile chat session switching needs a dedicated rename tap target beside each se } /* -FNXC:ChatHeader 2026-07-02-00:00: -Mobile direct-chat detail uses ViewHeader as the only visible header row. Keep the Chat icon/title relationship available to assistive tech while visually collapsing only the text label, and let the moved session switcher consume the action row width without duplicating the old thread-header controls. +FNXC:ChatHeader 2026-07-02-17:26: +Mobile direct-chat detail uses ViewHeader as the only visible header row. The back arrow must be the far-left visible/focusable control and the session selector must stay on that same non-wrapping row, so hide the entire Chat title/icon shell from layout while retaining the accessible heading and make the actions cluster own the row from the left edge. */ -.chat-view--mobile-direct-thread > .view-header .view-header__title span { +.chat-view--mobile-direct-thread > .view-header { + flex-wrap: nowrap; +} + +.chat-view--mobile-direct-thread > .view-header .view-header__title { position: absolute; inline-size: var(--btn-border-width); block-size: var(--btn-border-width); @@ -784,6 +788,10 @@ Mobile direct-chat detail uses ViewHeader as the only visible header row. Keep t .chat-view--mobile-direct-thread > .view-header .view-header__actions { flex: 1 1 auto; + width: 100%; + min-width: 0; + margin-left: 0; + flex-wrap: nowrap; justify-content: flex-start; } @@ -792,7 +800,8 @@ Mobile direct-chat detail uses ViewHeader as the only visible header row. Keep t } .chat-view--mobile-direct-thread .chat-mobile-session-menu { - flex: 1 1 auto; + flex: 1 1 0; + min-width: 0; } @media (max-width: 768px), (max-height: 480px) { diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 530f137545..8ba0e85de9 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -2685,8 +2685,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout {showMobileDirectThreadHeaderControls ? ( <> {/* - FNXC:ChatHeader 2026-07-02-00:00: - Mobile direct-thread view has a single top row: move back navigation and the active conversation switcher into ViewHeader so the transcript gains the height formerly consumed by a second thread header. The ViewHeader still owns the accessible Chat title; CSS only hides its visible text in this direct-thread mobile state. + FNXC:ChatHeader 2026-07-02-17:26: + Mobile direct-thread view has a single top row: back navigation must be the first visible/focusable control at the far-left edge and the active conversation switcher must stay beside it. The ViewHeader still owns the accessible Chat title; ChatView-scoped CSS hides the entire title/icon shell only in this direct-thread mobile state so it cannot reserve left-edge layout space. */} - + {provider === "github" ? ( <> @@ -1625,23 +1645,24 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
{(["project_issue", "group_issue", "merge_request"] as GitLabResourceTab[]).map((resource) => ( - ))}
{gitlabResource !== "group_issue" ? ( - setGitlabProject(event.target.value)} placeholder={t("git.gitlabProjectPlaceholder", "group/subgroup/project or numeric ID")} aria-label={t("git.gitlabProjectLabel", "GitLab project path or ID")} disabled={loading || importing} /> + setGitlabProject(event.target.value)} placeholder={t("git.gitlabProjectPlaceholder", "group/subgroup/project or numeric ID")} aria-label={t("git.gitlabProjectLabel", "GitLab project path or ID")} disabled={loading || importing || !gitlabEnabled} /> ) : ( - setGitlabGroup(event.target.value)} placeholder={t("git.gitlabGroupPlaceholder", "group/subgroup or numeric ID")} aria-label={t("git.gitlabGroupLabel", "GitLab group path or ID")} disabled={loading || importing} /> + setGitlabGroup(event.target.value)} placeholder={t("git.gitlabGroupPlaceholder", "group/subgroup or numeric ID")} aria-label={t("git.gitlabGroupLabel", "GitLab group path or ID")} disabled={loading || importing || !gitlabEnabled} /> )} - setLabels(event.target.value)} placeholder={t("git.filterByLabelsPlaceholder", "Filter: bug,enhancement…")} aria-label={t("git.filterGitLabByLabels", "Filter GitLab resources by labels")} disabled={loading || importing} /> -
+ {!gitlabEnabled &&
{t("git.gitlabDisabledHeading", "GitLab integration disabled")}{t("git.gitlabDisabledHint", "Enable GitLab integration in Settings to fetch or import GitLab resources. Saved GitLab URLs and tokens remain configured.")}
} {error &&
{t("git.gitlabError", "GitLab import unavailable")}{error}
} {gitlabItems.length === 0 && !loading && !error ?
{t("git.gitlabNoResources", "No GitLab resources loaded")}{t("git.gitlabLoadHint", "Enter a project or group and load resources from the configured GitLab instance.")}
: null}
@@ -1663,7 +1684,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,

{selectedGitlabItem.resourceKind === "merge_request" ? "!" : "#"}{selectedGitlabItem.iid} {selectedGitlabItem.title}

{selectedGitlabItem.state}{t("git.openSource", "Open source")}
- +
) :
{t("git.gitlabNoSelection", "No GitLab resource selected")}{t("git.gitlabNoSelectionHint", "Choose a resource from the list to preview it.")}
}
@@ -1687,7 +1708,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, onClick={provider === "gitlab" ? handleImportGitLab : handleImport} disabled={ provider === "gitlab" - ? selectedGitlabItem === null || importing || (selectedGitlabItem ? importedUrls.has(selectedGitlabItem.webUrl) : false) + ? !gitlabEnabled || selectedGitlabItem === null || importing || (selectedGitlabItem ? importedUrls.has(selectedGitlabItem.webUrl) : false) : (activeTab === "issues" ? selectedIssueNumber === null : selectedPullNumber === null) || importing } > diff --git a/packages/dashboard/app/components/SettingsModal.css b/packages/dashboard/app/components/SettingsModal.css index 5295ecd776..b67d1f9f90 100644 --- a/packages/dashboard/app/components/SettingsModal.css +++ b/packages/dashboard/app/components/SettingsModal.css @@ -1208,6 +1208,83 @@ Settings section headings should preserve hierarchy through spacing and type onl content: "▾"; } +/* +FNXC:GitLabSettings 2026-07-02-00:00: +FN-7453 keeps GitLab's enable switch visible while hiding noisy URL/token fields behind native details disclosure. The layout uses token spacing and native summary semantics so collapsed and disabled states do not leave empty icon-button shells or focusable hidden fields. +*/ +.settings-gitlab-disclosure { + display: flex; + flex-direction: column; + gap: var(--space-sm); + margin-top: var(--space-md); + padding: var(--space-md); + border: var(--btn-border-width) solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-secondary); +} + +.settings-gitlab-disclosure > summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + cursor: pointer; + list-style: none; + color: var(--text-primary); +} + +.settings-gitlab-disclosure > summary::-webkit-details-marker { + display: none; +} + +.settings-gitlab-disclosure > summary::before { + content: "▸"; + color: var(--text-muted); +} + +.settings-gitlab-disclosure[open] > summary::before { + content: "▾"; +} + +.settings-gitlab-disclosure__title { + flex: 1 1 auto; + font-weight: var(--font-weight-semibold); + min-width: 0; +} + +.settings-gitlab-disclosure__toggle { + flex: 0 0 auto; +} + +.settings-gitlab-disclosure__body { + display: flex; + flex-direction: column; + gap: var(--space-sm); + margin-top: var(--space-sm); + min-width: 0; +} + +.settings-gitlab-disclosure__body > .form-group { + margin-top: 0; + padding: 0; +} + +@media (max-width: 768px) { + .settings-gitlab-disclosure { + padding: var(--space-sm); + } + + .settings-gitlab-disclosure > summary { + align-items: flex-start; + flex-wrap: wrap; + gap: var(--space-sm); + } + + .settings-gitlab-disclosure__toggle { + flex-basis: 100%; + } +} + .remote-cf-advanced-details { margin-top: var(--space-xs); } diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index e9d62c04f8..5c1c58bd68 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -402,6 +402,7 @@ type PluginsSubsectionId = "fusion-plugins" | "pi-extensions"; /** Local form state extends Settings with a worktreeInitCommand override and lets tokenCap carry null (delete semantic). */ type SettingsFormState = Settings & { worktreeInitCommand?: string; tokenCap?: number | null }; +type GlobalGitlabSettings = Pick; interface SettingsModalProps { onClose: () => void; @@ -775,6 +776,7 @@ export function SettingsModal({ // Track scoped settings for inheritance detection (fetched alongside merged settings) // This stores the raw { global, project } structure from the API const [scopedSettings, setScopedSettings] = useState<{ global: GlobalSettings; project: Partial } | null>(null); + const [globalGitlabSettings, setGlobalGitlabSettings] = useState(null); // Track initial scoped values for null-as-delete semantics on project overrides const [initialScopedValues, setInitialScopedValues] = useState<{ global: GlobalSettings; project: Partial } | null>(null); // Find the first non-group-header section for visibility fallback handling @@ -1022,6 +1024,13 @@ export function SettingsModal({ setForm(normalizedSettings); setInitialValues(normalizedSettings); // Store initial values to detect explicit clears setScopedSettings(scoped); + setGlobalGitlabSettings({ + gitlabEnabled: scoped.global.gitlabEnabled, + gitlabInstanceUrl: scoped.global.gitlabInstanceUrl, + gitlabApiBaseUrl: scoped.global.gitlabApiBaseUrl, + gitlabAuthToken: scoped.global.gitlabAuthToken, + gitlabAuthTokenType: scoped.global.gitlabAuthTokenType, + }); setInitialScopedValues({ ...scoped, project: { @@ -2474,6 +2483,11 @@ export function SettingsModal({ setIsSaving(true); try { const normalizedWorktreeCopyFiles = normalizeWorktreeCopyFilesForSave(form.worktreeCopyFiles); + /* + FNXC:GitLabEnablement 2026-07-02-00:00: + The Global General section must edit raw global GitLab settings, not the merged project-effective form. Otherwise a project override can silently overwrite the global GitLab default on a no-op save. + */ + const gitlabFormForSave = activeSection === "global-general" && globalGitlabSettings ? globalGitlabSettings : form; const payload = { ...form, worktreeInitCommand: form.worktreeInitCommand?.trim() || undefined, @@ -2486,10 +2500,11 @@ export function SettingsModal({ maxAutoMergeRetries: resolveMaxAutoMergeRetriesForSettingsForm(form), taskPrefix: form.taskPrefix?.trim() || undefined, githubTrackingDefaultRepo: form.githubTrackingDefaultRepo?.trim() || undefined, - gitlabInstanceUrl: form.gitlabInstanceUrl?.trim() || undefined, - gitlabApiBaseUrl: form.gitlabApiBaseUrl?.trim() || undefined, - gitlabAuthToken: form.gitlabAuthToken?.trim() || undefined, - gitlabAuthTokenType: form.gitlabAuthTokenType ?? "personal", + gitlabEnabled: gitlabFormForSave.gitlabEnabled, + gitlabInstanceUrl: gitlabFormForSave.gitlabInstanceUrl?.trim() || undefined, + gitlabApiBaseUrl: gitlabFormForSave.gitlabApiBaseUrl?.trim() || undefined, + gitlabAuthToken: gitlabFormForSave.gitlabAuthToken?.trim() || undefined, + gitlabAuthTokenType: gitlabFormForSave.gitlabAuthTokenType ?? "personal", githubAuthToken: form.githubAuthToken?.trim() || undefined, prTitlePromptInstructions: form.prTitlePromptInstructions?.trim() || undefined, prDescriptionPromptInstructions: form.prDescriptionPromptInstructions?.trim() || undefined, @@ -2551,7 +2566,7 @@ export function SettingsModal({ } finally { setIsSaving(false); } - }, [form, globalMaxConcurrent, prefixError, presetDraft, initialValues, initialScopedValues, onClose, addToast, projectId, activeSection, isSaving, t]); + }, [form, globalGitlabSettings, globalMaxConcurrent, prefixError, presetDraft, initialValues, initialScopedValues, onClose, addToast, projectId, activeSection, isSaving, t]); const handleSaveMemory = useCallback(async () => { try { @@ -2784,6 +2799,15 @@ export function SettingsModal({ scopeBanner={renderScopeBanner()} form={form} setForm={setForm} + globalSettings={globalGitlabSettings} + onGlobalGitlabSettingsChange={(patch) => setGlobalGitlabSettings((current) => ({ + gitlabEnabled: current?.gitlabEnabled, + gitlabInstanceUrl: current?.gitlabInstanceUrl, + gitlabApiBaseUrl: current?.gitlabApiBaseUrl, + gitlabAuthToken: current?.gitlabAuthToken, + gitlabAuthTokenType: current?.gitlabAuthTokenType, + ...patch, + }))} globalTrackingRepoOptions={globalTrackingRepoOptions} globalTrackingRepoLoading={globalTrackingRepoLoading} globalTrackingRepoError={globalTrackingRepoError} diff --git a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx index aaf9d6c3c7..62a7425754 100644 --- a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx @@ -15,6 +15,7 @@ import { apiImportGitLabProjectIssue, apiImportGitLabGroupIssue, apiImportGitLabMergeRequest, + fetchSettings, fetchGitRemotes, } from "../../api"; import type { Task } from "@fusion/core"; @@ -40,6 +41,7 @@ vi.mock("../../api", async (importOriginal) => { apiImportGitLabProjectIssue: vi.fn(), apiImportGitLabGroupIssue: vi.fn(), apiImportGitLabMergeRequest: vi.fn(), + fetchSettings: vi.fn(), fetchGitRemotes: vi.fn(), }; }); @@ -168,6 +170,8 @@ describe("GitHubImportModal", () => { vi.mocked(apiImportGitLabProjectIssue).mockReset(); vi.mocked(apiImportGitLabGroupIssue).mockReset(); vi.mocked(apiImportGitLabMergeRequest).mockReset(); + vi.mocked(fetchSettings).mockReset(); + vi.mocked(fetchSettings).mockResolvedValue({ gitlabEnabled: true } as never); // Set default mock for apiFetchGitHubIssues to return empty array (prevents undefined issues state) vi.mocked(apiFetchGitHubIssues).mockResolvedValue([]); vi.mocked(apiFetchGitHubPulls).mockResolvedValue([]); @@ -214,6 +218,21 @@ describe("GitHubImportModal", () => { expect(onImport).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-099" })); }); + it("shows disabled GitLab import controls without fetching when GitLab is off", async () => { + vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]); + vi.mocked(fetchSettings).mockResolvedValueOnce({ gitlabEnabled: false } as never); + + render(); + fireEvent.click(await screen.findByRole("button", { name: "GitLab" })); + + expect(await screen.findByTestId("gitlab-import-disabled")).toHaveTextContent("GitLab integration disabled"); + expect(screen.getByRole("button", { name: /Load/ })).toBeDisabled(); + expect(screen.getByLabelText("GitLab project path or ID")).toBeDisabled(); + expect(screen.getByRole("tab", { name: "Group issues" })).toBeDisabled(); + expect(apiFetchGitLabProjectIssues).not.toHaveBeenCalled(); + expect(apiImportGitLabProjectIssue).not.toHaveBeenCalled(); + }); + it("fetches group issues and merge requests without GitHub-only copy", async () => { vi.mocked(fetchGitRemotes).mockResolvedValue([]); vi.mocked(apiFetchGitLabGroupIssues).mockResolvedValueOnce([ diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx index ddef913b3b..8c42fa2e76 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx @@ -700,6 +700,46 @@ describe("SettingsModal", () => { } }); + it("renders and saves global GitLab enabled from scoped global values when project overrides differ", async () => { + mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, gitlabEnabled: true }); + mockFetchSettingsByScope.mockResolvedValueOnce({ + global: { ...defaultSettings, gitlabEnabled: false, gitlabInstanceUrl: "https://global.gitlab.test" }, + project: { gitlabEnabled: true }, + }); + + renderModal({ initialSection: "global-general" }); + await waitForSettingsModalReady(); + + const enableToggle = screen.getByLabelText("Enable GitLab integration") as HTMLInputElement; + expect(enableToggle).not.toBeChecked(); + expect(screen.getByLabelText("Global GitLab instance URL")).toBeDisabled(); + + await settingsModalUser.click(screen.getByRole("button", { name: "Save" })); + + expect(mockUpdateGlobalSettings).not.toHaveBeenCalledWith(expect.objectContaining({ gitlabEnabled: true })); + }); + + it("saves an explicit global GitLab enable edit without using the project override", async () => { + mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, gitlabEnabled: true }); + mockFetchSettingsByScope.mockResolvedValueOnce({ + global: { ...defaultSettings, gitlabEnabled: false }, + project: { gitlabEnabled: true }, + }); + + renderModal({ initialSection: "global-general" }); + await waitForSettingsModalReady(); + + await settingsModalUser.click(screen.getByLabelText("Enable GitLab integration")); + await settingsModalUser.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(expect.objectContaining({ gitlabEnabled: true })); + }); + if (mockUpdateSettings.mock.calls.length > 0) { + expect(mockUpdateSettings.mock.calls[0]?.[0]).not.toHaveProperty("gitlabEnabled"); + } + }); + it("shows global tracking repo error hint and keeps custom entry when lookups fail", async () => { mockFetchProjects.mockRejectedValueOnce(new Error("no projects")); @@ -997,6 +1037,13 @@ describe("SettingsModal", () => { renderModal({ initialSection: "general" }); await waitForSettingsModalReady(); + const disclosure = screen.getByTestId("project-gitlab-configuration-disclosure"); + expect(disclosure).not.toHaveAttribute("open"); + const enableToggle = screen.getByLabelText("Enable GitLab integration") as HTMLInputElement; + expect(enableToggle.checked).toBe(true); + await settingsModalUser.click(within(disclosure).getByText("GitLab Configuration")); + expect(disclosure).toHaveAttribute("open"); + expect(screen.getByRole("heading", { name: "GitLab Configuration" })).toBeInTheDocument(); expect(screen.getByText(/Blank uses GitLab.com or the global default/i)).toBeInTheDocument(); expect(screen.getByText(/Blank derives \/api\/v4/i)).toBeInTheDocument(); @@ -1019,6 +1066,37 @@ describe("SettingsModal", () => { } }); + it("saves project GitLab disabled state without clearing stored URLs", async () => { + mockFetchSettings.mockResolvedValueOnce({ + ...defaultSettings, + gitlabEnabled: true, + gitlabInstanceUrl: "https://gitlab.example.com/gitlab", + gitlabApiBaseUrl: "https://gitlab.example.com/gitlab/api/v4", + }); + mockFetchSettingsByScope.mockResolvedValueOnce({ + global: defaultSettings, + project: { + gitlabEnabled: true, + gitlabInstanceUrl: "https://gitlab.example.com/gitlab", + gitlabApiBaseUrl: "https://gitlab.example.com/gitlab/api/v4", + }, + }); + + renderModal({ initialSection: "general" }); + await waitForSettingsModalReady(); + + await settingsModalUser.click(screen.getByLabelText("Enable GitLab integration")); + await settingsModalUser.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(mockUpdateSettings).toHaveBeenCalled(); + }); + + expect(mockUpdateSettings.mock.calls[0][0]).toMatchObject({ gitlabEnabled: false }); + expect(mockUpdateSettings.mock.calls[0][0]).not.toHaveProperty("gitlabInstanceUrl"); + expect(mockUpdateSettings.mock.calls[0][0]).not.toHaveProperty("gitlabApiBaseUrl"); + }); + it("clears GitLab URL project overrides back to defaults", async () => { mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx index d7797ef43b..ab401fbf4a 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import { EditorView } from "@codemirror/view"; import path from "path"; import { SettingsModal } from "../SettingsModal"; @@ -1342,6 +1342,11 @@ describe("SettingsModal", () => { renderModal({ initialSection: "merge" }); await waitForSettingsModalReady(); + const disclosure = screen.getByTestId("project-gitlab-authentication-disclosure"); + expect(disclosure).not.toHaveAttribute("open"); + await settingsModalUser.click(within(disclosure).getByText("GitLab Authentication")); + expect(disclosure).toHaveAttribute("open"); + expect(screen.getByRole("heading", { name: "GitLab Authentication" })).toBeInTheDocument(); const tokenInput = screen.getByLabelText("GitLab access token") as HTMLInputElement; expect(tokenInput.type).toBe("password"); diff --git a/packages/dashboard/app/components/settings/save-split.ts b/packages/dashboard/app/components/settings/save-split.ts index 7b30f177e9..4693bb25ed 100644 --- a/packages/dashboard/app/components/settings/save-split.ts +++ b/packages/dashboard/app/components/settings/save-split.ts @@ -73,6 +73,7 @@ const GLOBAL_SECTION_KEYS: Record> = { experimental: new Set(["experimentalFeatures"]), "global-general": new Set([ "githubTrackingDefaultRepo", + "gitlabEnabled", "gitlabInstanceUrl", "gitlabApiBaseUrl", "gitlabAuthToken", @@ -323,7 +324,7 @@ export function splitSettingsSave({ if (key === "githubTrackingDefaultRepo" && activeSection !== "global-general") { continue; } - if ((key === "gitlabInstanceUrl" || key === "gitlabApiBaseUrl" || key === "gitlabAuthToken" || key === "gitlabAuthTokenType") && activeSection !== "global-general") { + if ((key === "gitlabEnabled" || key === "gitlabInstanceUrl" || key === "gitlabApiBaseUrl" || key === "gitlabAuthToken" || key === "gitlabAuthTokenType") && activeSection !== "global-general") { continue; } if (key === "mcpServers" && activeSection !== "global-mcp") { @@ -382,7 +383,7 @@ export function splitSettingsSave({ if (key === "githubTokenConfigured" || key === "prAuthAvailable") continue; // server-only if (key === "customProviders") continue; // persisted via dedicated routes, not save-split (see global branch above) if (key === "githubTrackingDefaultRepo" && activeSection === "global-general") continue; - if ((key === "gitlabInstanceUrl" || key === "gitlabApiBaseUrl" || key === "gitlabAuthToken" || key === "gitlabAuthTokenType") && activeSection === "global-general") continue; + if ((key === "gitlabEnabled" || key === "gitlabInstanceUrl" || key === "gitlabApiBaseUrl" || key === "gitlabAuthToken" || key === "gitlabAuthTokenType") && activeSection === "global-general") continue; if (key === "mcpServers" && activeSection === "global-mcp") continue; if (!isProjectSettingsKey(key)) continue; diff --git a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx index 5bec8a76f5..bc07ca3dc9 100644 --- a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx @@ -283,19 +283,31 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast

{t("settings.general.gitLabConfiguration", "GitLab Configuration")}

{/* - FNXC:GitLabConfiguration 2026-07-02-00:00: - FN-7422 exposes only project GitLab web/API URL configuration for GitLab.com and self-managed instances. Token auth, imports, tracking, comments, auto-close, Command Center signals, research providers, and star prompts are intentionally deferred to later GitLab subtasks. + FNXC:GitLabEnablement 2026-07-02-00:00: + FN-7453 keeps saved GitLab URL settings separate from the active integration switch. The disclosure is collapsed by default to reduce Settings noise; the summary toggle remains reachable without expanding advanced self-managed URL fields. */} -
- - setForm((f) => ({ ...f, gitlabInstanceUrl: e.target.value || undefined }))}/> - {t("settings.general.gitLabInstanceUrlHint", "Blank uses GitLab.com or the global default. Set an absolute http:// or https:// URL for self-managed GitLab, such as https://gitlab.example.com/gitlab.")} -
-
- - setForm((f) => ({ ...f, gitlabApiBaseUrl: e.target.value || undefined }))}/> - {t("settings.general.gitLabApiBaseUrlHint", "Blank derives /api/v4. Override only when a self-managed GitLab API is served from a different absolute http:// or https:// URL.")} -
+
+ + {t("settings.general.gitLabConfiguration", "GitLab Configuration")} + + + {form.gitlabEnabled === false ? t("settings.general.gitLabDisabledHint", "GitLab API imports, comments, close/reopen, and refresh operations are disabled. Saved URLs and tokens remain stored for re-enable.") : t("settings.general.gitLabEnabledHint", "Configure GitLab.com or self-managed GitLab URLs. Blank values inherit global fallbacks and then GitLab.com.")} +
+
+ + setForm((f) => ({ ...f, gitlabInstanceUrl: e.target.value || undefined }))}/> + {t("settings.general.gitLabInstanceUrlHint", "Blank uses GitLab.com or the global default. Set an absolute http:// or https:// URL for self-managed GitLab, such as https://gitlab.example.com/gitlab.")} +
+
+ + setForm((f) => ({ ...f, gitlabApiBaseUrl: e.target.value || undefined }))}/> + {t("settings.general.gitLabApiBaseUrlHint", "Blank derives /api/v4. Override only when a self-managed GitLab API is served from a different absolute http:// or https:// URL.")} +
+
+
); } export default GeneralSection; diff --git a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx index d5b8418a45..ffe6ac8700 100644 --- a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import type { GlobalSettings } from "@fusion/core"; import { resolvePersistAgentThinkingLog } from "@fusion/core"; import { TrackingRepoSelect, type TrackingRepoOption } from "../../TrackingRepoSelect"; import { CliBinaryPanel } from "../../CliBinaryPanel"; @@ -6,12 +7,15 @@ import type { SectionBaseProps } from "./context"; import { useTranslation } from "react-i18next"; export interface GlobalGeneralSectionProps extends SectionBaseProps { scopeBanner: ReactNode; + globalSettings: Pick | null; + onGlobalGitlabSettingsChange: (patch: Partial>) => void; globalTrackingRepoOptions: TrackingRepoOption[]; globalTrackingRepoLoading: boolean; globalTrackingRepoError: string | null; } -export function GlobalGeneralSection({ scopeBanner, form, setForm, globalTrackingRepoOptions, globalTrackingRepoLoading, globalTrackingRepoError, }: GlobalGeneralSectionProps) { +export function GlobalGeneralSection({ scopeBanner, form, setForm, globalSettings, onGlobalGitlabSettingsChange, globalTrackingRepoOptions, globalTrackingRepoLoading, globalTrackingRepoError, }: GlobalGeneralSectionProps) { const { t } = useTranslation("app"); + const globalGitlab = globalSettings ?? form; return (<> {scopeBanner}

{t("settings.globalGeneral.general", "General")}

@@ -21,35 +25,44 @@ export function GlobalGeneralSection({ scopeBanner, form, setForm, globalTrackin {t("settings.globalGeneral.projectsInheritThisValueWhenTheyDoNot", "Projects inherit this value when they do not set a project default tracking repo.")} {/* - FNXC:GitLabConfiguration 2026-07-02-00:00: - Global GitLab URL settings are fallbacks for projects that do not set their own self-managed GitLab instance/API URLs. - - FNXC:GitLabAuthentication 2026-07-02-00:00: - Global GitLab token settings are secret-safe fallbacks for projects without their own token override. They do not make project/group access tokens globally authorized; resource membership still applies to future GitLab runtime tasks. + FNXC:GitLabEnablement 2026-07-02-00:00: + FN-7453 adds a global GitLab enable fallback that can disable outbound GitLab HTTP API operations without deleting saved self-managed URL or token settings. Projects can override the enabled state when they need GitLab active while the global fallback is off. */} -
- - setForm((f) => ({ ...f, gitlabInstanceUrl: e.target.value || undefined }))}/> - {t("settings.globalGeneral.gitLabInstanceUrlHint", "Blank defaults to GitLab.com. Projects inherit this self-managed GitLab URL unless they set their own project value.")} -
-
- - setForm((f) => ({ ...f, gitlabApiBaseUrl: e.target.value || undefined }))}/> - {t("settings.globalGeneral.gitLabApiBaseUrlHint", "Blank derives /api/v4. Override only for self-managed GitLab API gateways that use a different absolute http:// or https:// URL.")} -
-
- - -
-
- - setForm((f) => ({ ...f, gitlabAuthToken: e.target.value || undefined }))}/> - {t("settings.globalGeneral.gitLabAuthTokenHint", "Projects inherit this fallback only when they do not set a project GitLab token. Read-only operations need read_api or api; future write actions need api; project/group tokens remain limited by resource membership.")} -
+
+ + {t("settings.globalGeneral.gitLabConfiguration", "GitLab Configuration")} + + + {globalGitlab.gitlabEnabled === false ? t("settings.globalGeneral.gitLabDisabledHint", "GitLab API operations are disabled by global default. Saved URL and token fallbacks remain stored for re-enable.") : t("settings.globalGeneral.gitLabEnabledHint", "Global GitLab URL and token fallbacks apply to projects that do not set their own values.")} +
+
+ + onGlobalGitlabSettingsChange({ gitlabInstanceUrl: e.target.value || undefined })}/> + {t("settings.globalGeneral.gitLabInstanceUrlHint", "Blank defaults to GitLab.com. Projects inherit this self-managed GitLab URL unless they set their own project value.")} +
+
+ + onGlobalGitlabSettingsChange({ gitlabApiBaseUrl: e.target.value || undefined })}/> + {t("settings.globalGeneral.gitLabApiBaseUrlHint", "Blank derives /api/v4. Override only for self-managed GitLab API gateways that use a different absolute http:// or https:// URL.")} +
+
+ + +
+
+ + onGlobalGitlabSettingsChange({ gitlabAuthToken: e.target.value || undefined })}/> + {t("settings.globalGeneral.gitLabAuthTokenHint", "Projects inherit this fallback only when they do not set a project GitLab token. Read-only operations need read_api or api; write actions need api; project/group tokens remain limited by resource membership.")} +
+
+
)}

{t("settings.merge.gitLabAuthentication", "GitLab Authentication")}

{/** - * FNXC:GitLabAuthentication 2026-07-02-00:00: - * FN-7423 exposes project GitLab token configuration only as secret-safe password input plus a personal/project/group token-type label. Later GitLab import/tracking/comment/close runtime tasks consume PRIVATE-TOKEN auth and must enforce read_api/api scope requirements documented here and in user docs. + * FNXC:GitLabEnablement 2026-07-02-00:00: + * FN-7453 makes project GitLab auth controls collapsible and governed by the same project-scoped enable switch as URL settings. Disabling GitLab preserves saved tokens but blocks outbound API side effects before auth validation. */} -
- - -
-
- - setForm((f) => ({ ...f, gitlabAuthToken: e.target.value || undefined }))}/> - {t("settings.merge.gitLabAuthTokenHint", "Read-only GitLab operations need read_api or api. Future write actions such as comments and auto-close need api. Project and group tokens are limited to their associated resource and role membership.")} -
-
- {t("settings.merge.moreDetails", "More details")} - {t("settings.merge.gitLabAuthDetails", "Fusion uses GitLab REST API token authentication with the PRIVATE-TOKEN header. Leave the token blank to clear the project override and fall back to a configured global GitLab token or GITLAB_TOKEN where available.")} +
+ + {t("settings.merge.gitLabAuthentication", "GitLab Authentication")} + + + {form.gitlabEnabled === false ? t("settings.merge.gitLabDisabledHint", "GitLab comments, close/reopen, import fetches, and refresh operations are disabled. Saved tokens remain stored for re-enable.") : t("settings.merge.gitLabAuthDetails", "Fusion uses GitLab REST API token authentication with the PRIVATE-TOKEN header. Leave the token blank to clear the project override and fall back to a configured global GitLab token or GITLAB_TOKEN where available.")} +
+
+ + +
+
+ + setForm((f) => ({ ...f, gitlabAuthToken: e.target.value || undefined }))}/> + {t("settings.merge.gitLabAuthTokenHint", "Read-only GitLab operations need read_api or api. Future write actions such as comments and auto-close need api. Project and group tokens are limited to their associated resource and role membership.")} +
+
+ {/* + FNXC:SettingsGeneral 2026-07-02-00:00: + "Clear local data" panel — the user-facing escape hatch when the dashboard runs out of + browser localStorage quota. Frees stale SWR hydration caches (chat sessions, rooms, tasks, + board snapshots) plus UI prefs. The auth token is preserved so the reload keeps the session. + */} +

{t("settings.general.browserData", "Browser Data")}

+
+ + {t("settings.general.clearLocalDataHint", "Remove cached board snapshots, chat threads, and UI preferences stored in this browser. Frees space when the dashboard runs low on browser storage. Your tasks and project settings are stored server-side and are not affected.")} +
+ +
+
); } export default GeneralSection; diff --git a/packages/dashboard/app/utils/__tests__/swrCache.test.ts b/packages/dashboard/app/utils/__tests__/swrCache.test.ts index 8dea187510..bad1d4db4b 100644 --- a/packages/dashboard/app/utils/__tests__/swrCache.test.ts +++ b/packages/dashboard/app/utils/__tests__/swrCache.test.ts @@ -5,7 +5,9 @@ import { SWR_DEFAULT_MAX_AGE_MS, SWR_LONG_MAX_AGE_MS, SWR_TASKS_MAX_AGE_MS, + clearAllLocalCache, clearCache, + pruneStaleCacheEntries, readCache, writeCache, } from "../swrCache"; @@ -35,15 +37,31 @@ describe("swrCache", () => { expect(raw.data).toEqual(payload); }); - it("respects maxAgeMs for enveloped payloads", () => { + it("respects maxAgeMs for enveloped payloads and lazily deletes stale entries", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); writeCache("ttl", { value: "fresh" }); vi.setSystemTime(new Date("2026-01-01T00:00:02.000Z")); + // A stale read returns null AND removes the entry so it stops consuming quota. expect(readCache<{ value: string }>("ttl", { maxAgeMs: 1_000 })).toBeNull(); - expect(readCache<{ value: string }>("ttl")).toEqual({ value: "fresh" }); + expect(localStorage.getItem("ttl")).toBeNull(); + // A subsequent read without maxAgeMs also misses because the entry was lazily GC'd. + expect(readCache<{ value: string }>("ttl")).toBeNull(); + + vi.useRealTimers(); + }); + + it("does not lazily delete fresh enveloped entries", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + + writeCache("fresh-ttl", { value: "ok" }); + vi.setSystemTime(new Date("2026-01-01T00:00:00.500Z")); + + expect(readCache<{ value: string }>("fresh-ttl", { maxAgeMs: 1_000 })).toEqual({ value: "ok" }); + expect(localStorage.getItem("fresh-ttl")).not.toBeNull(); vi.useRealTimers(); }); @@ -139,4 +157,71 @@ describe("swrCache", () => { expect(() => writeCache("quota", { ok: true })).not.toThrow(); }); + it("pruneStaleCacheEntries removes SWR entries older than 24h but keeps fresh ones", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + + // Stale: written 25h ago. + writeCache(`${SWR_CACHE_KEYS.TASKS_PREFIX}old`, [{ id: "1" }]); + + vi.setSystemTime(new Date("2026-01-02T01:00:00.000Z")); // +25h + // Fresh: written now. + writeCache(SWR_CACHE_KEYS.PROJECTS, [{ id: "p" }]); + + const removed = pruneStaleCacheEntries(); + + expect(removed).toBe(1); + expect(localStorage.getItem(`${SWR_CACHE_KEYS.TASKS_PREFIX}old`)).toBeNull(); + expect(localStorage.getItem(SWR_CACHE_KEYS.PROJECTS)).not.toBeNull(); + + vi.useRealTimers(); + }); + + it("pruneStaleCacheEntries ignores non-cache keys, malformed JSON, and envelope-less payloads", () => { + // Non-SWR key (scoped pref) — never touched by the sweep. + localStorage.setItem("kb:proj1:kb-dashboard-task-view", "board"); + // Malformed JSON under a cache prefix — left alone (caught, not crashed). + localStorage.setItem(`${SWR_CACHE_KEYS.TASKS_PREFIX}bad`, "{not json"); + // Cache key without a savedAt envelope — left alone. + localStorage.setItem(SWR_CACHE_KEYS.MODELS, JSON.stringify([{ id: "x" }])); + + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z")); + writeCache(SWR_CACHE_KEYS.AGENTS, [{ id: "a" }]); + vi.setSystemTime(new Date("2026-01-02T00:00:00.000Z")); + + const removed = pruneStaleCacheEntries(); + + expect(removed).toBe(1); + expect(localStorage.getItem(SWR_CACHE_KEYS.AGENTS)).toBeNull(); + expect(localStorage.getItem("kb:proj1:kb-dashboard-task-view")).toBe("board"); + expect(localStorage.getItem(`${SWR_CACHE_KEYS.TASKS_PREFIX}bad`)).toBe("{not json"); + expect(localStorage.getItem(SWR_CACHE_KEYS.MODELS)).not.toBeNull(); + + vi.useRealTimers(); + }); + + it("clearAllLocalCache removes Fusion-owned keys but preserves the auth token", () => { + localStorage.setItem("fn.authToken", "secret-token"); + localStorage.setItem(`${SWR_CACHE_KEYS.TASKS_PREFIX}p1`, "[]"); + localStorage.setItem("kb:proj1:kb-dashboard-task-view", "board"); + localStorage.setItem("kb-dashboard-theme-mode", "dark"); + localStorage.setItem("fn-agent-log-markdown", "true"); + localStorage.setItem("fusion:right-dock-pinned", "true"); + localStorage.setItem("fusion-insight-model", "openai/gpt-4o"); + // Hypothetical non-Fusion key — left alone. + localStorage.setItem("other-app:data", "keep-me"); + + const removed = clearAllLocalCache(); + + expect(removed).toBe(6); + expect(localStorage.getItem("fn.authToken")).toBe("secret-token"); + expect(localStorage.getItem("other-app:data")).toBe("keep-me"); + expect(localStorage.getItem(`${SWR_CACHE_KEYS.TASKS_PREFIX}p1`)).toBeNull(); + expect(localStorage.getItem("kb:proj1:kb-dashboard-task-view")).toBeNull(); + expect(localStorage.getItem("kb-dashboard-theme-mode")).toBeNull(); + expect(localStorage.getItem("fn-agent-log-markdown")).toBeNull(); + expect(localStorage.getItem("fusion:right-dock-pinned")).toBeNull(); + expect(localStorage.getItem("fusion-insight-model")).toBeNull(); + }); }); diff --git a/packages/dashboard/app/utils/swrCache.ts b/packages/dashboard/app/utils/swrCache.ts index 45d6edf779..8bbb1d5a24 100644 --- a/packages/dashboard/app/utils/swrCache.ts +++ b/packages/dashboard/app/utils/swrCache.ts @@ -95,6 +95,18 @@ export function readCache(key: string, options?: { maxAgeMs?: number }): T | if (typeof maxAgeMs === "number") { const ageMs = Date.now() - envelope.savedAt; if (ageMs > maxAgeMs) { + /* + FNXC:SwrCache 2026-07-02-00:00: + Lazy GC: drop the stale entry so it stops consuming localStorage quota. A stale + entry is already treated as a miss by every reader (they re-fetch and overwrite), + so deleting it on read is behavior-preserving. This prevents per-session and + per-room message caches from accumulating when a reader revisits a stale key. + */ + try { + storage.removeItem(key); + } catch { + // Ignore storage errors — the stale read still returns null. + } return null; } } @@ -156,3 +168,111 @@ export function clearCache(prefix: string): void { // Ignore storage errors. } } + +/** + * FNXC:SwrCache 2026-07-02-00:00: + * Boot-time sweep that removes every SWR hydration entry older than SWR_LONG_MAX_AGE_MS (24h). + * Since 24h is the longest TTL any consumer passes to readCache, a pruned entry was already + * treated as a miss by every reader — this frees quota without changing hydration behavior. + * The main target is per-session / per-room message caches from abandoned conversations that + * are never read again (and therefore never hit readCache's lazy GC). Called once from the + * DashboardLoader mount so it runs before hydration hooks read their caches. + * + * Returns the number of entries removed for diagnostics. + */ +export function pruneStaleCacheEntries(): number { + const storage = getLocalStorage(); + if (!storage) { + return 0; + } + + let removed = 0; + try { + const staleKeys: string[] = []; + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (typeof key !== "string" || !key.startsWith("kb-dashboard-")) { + continue; + } + const raw = storage.getItem(key); + if (raw === null) { + continue; + } + try { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || !("savedAt" in parsed)) { + continue; + } + const savedAt = parsed.savedAt; + if (typeof savedAt !== "number" || Number.isNaN(savedAt)) { + continue; + } + if (Date.now() - savedAt > SWR_LONG_MAX_AGE_MS) { + staleKeys.push(key); + } + } catch { + // Malformed JSON — leave it; readCache/clearCache handle their own parsing. + } + } + + for (const key of staleKeys) { + storage.removeItem(key); + removed += 1; + } + } catch { + // Ignore storage errors. + } + + return removed; +} + +/** + * FNXC:SwrCache 2026-07-02-00:00: + * User-facing "Clear local data" helper: removes all Fusion-owned browser data — SWR + * hydration caches plus per-project scoped preferences and global UI preferences — while + * preserving the dashboard auth token so a reload keeps the session usable. Wired to + * Settings → General "Clear local data" as the escape hatch for quota exhaustion. Callers + * should reload the page after this so React state re-hydrates from a clean slate. + * + * Returns the number of keys removed for diagnostics. + */ +export const LOCAL_CACHE_PRESERVE_KEYS: Readonly> = { "fn.authToken": true }; + +function isFusionOwnedKey(key: string): boolean { + return ( + key.startsWith("kb-") || + key.startsWith("kb:") || + key.startsWith("fn-agent-log-") || + key.startsWith("fusion") + ); +} + +export function clearAllLocalCache(): number { + const storage = getLocalStorage(); + if (!storage) { + return 0; + } + + let removed = 0; + try { + const keys: string[] = []; + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (typeof key === "string") { + keys.push(key); + } + } + + for (const key of keys) { + if (key in LOCAL_CACHE_PRESERVE_KEYS || !isFusionOwnedKey(key)) { + continue; + } + storage.removeItem(key); + removed += 1; + } + } catch { + // Ignore storage errors. + } + + return removed; +} diff --git a/packages/dashboard/vitest.setup.ts b/packages/dashboard/vitest.setup.ts index c2b1471981..f1594ac2cc 100644 --- a/packages/dashboard/vitest.setup.ts +++ b/packages/dashboard/vitest.setup.ts @@ -121,6 +121,10 @@ if (typeof window !== "undefined") { clear: () => { Object.keys(localStorageMock).forEach((key) => delete localStorageMock[key]); }, + get length() { + return Object.keys(localStorageMock).length; + }, + key: (index: number) => Object.keys(localStorageMock)[index] ?? null, }, writable: true, });