diff --git a/.changeset/fix-dashboard-localstorage-quota-exhaustion.md b/.changeset/fix-dashboard-localstorage-quota-exhaustion.md new file mode 100644 index 0000000000..603307effa --- /dev/null +++ b/.changeset/fix-dashboard-localstorage-quota-exhaustion.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix dashboard localStorage quota exhaustion from stale SWR caches and add a Clear local data escape hatch. +category: fix +dev: Stale SWR hydration entries (per-chat-session/per-room message caches) were never garbage-collected; readCache now lazily deletes stale entries, a boot sweep prunes anything older than 24h, and Settings → General exposes a user-facing "Clear local data" button that preserves the auth token. diff --git a/.changeset/fn-7453-gitlab-enable-disclosure.md b/.changeset/fn-7453-gitlab-enable-disclosure.md new file mode 100644 index 0000000000..f24fc6e848 --- /dev/null +++ b/.changeset/fn-7453-gitlab-enable-disclosure.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a GitLab enable toggle and collapsible Settings controls. +category: feature +dev: Adds gitlabEnabled gating for GitLab API operations while preserving saved configuration. 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/.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/.changeset/fn-7456-ios-mobile-terminal-spacing.md b/.changeset/fn-7456-ios-mobile-terminal-spacing.md new file mode 100644 index 0000000000..e02e283399 --- /dev/null +++ b/.changeset/fn-7456-ios-mobile-terminal-spacing.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix iOS mobile terminal spacing when opening terminals with the keyboard already visible. +category: fix +dev: Seeds iOS keyboard-open viewport baselines for TerminalModal and SessionTerminal before xterm fit/resize. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 606a80a37b..960ba07377 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. @@ -966,6 +966,13 @@ Features: - **Ecosystem** shows active model breadth, per-model task activity, and real plugin activations for the selected range. Plugin activation counts come from project-scoped plugin/extension load events via `/api/command-center/plugin-activations`; if no activation rows exist in range, the metric renders unavailable (`—`) rather than fabricating zero. The tab still reuses the tokens analytics endpoint grouped by model, adds a task-share-by-model pie from `TokenAnalytics.groups`, and renders a tokens/tasks trend line when `TokenAnalytics.series` buckets are present; if series buckets are absent, no synthetic trend is shown. - **GitHub** shows local GitHub issue flow for the selected range: **Filed by Fusion** counts tasks with a persisted `githubTracking.issue`, **Fixed by Fusion** counts tasks imported from GitHub source issues (`sourceIssueProvider = "github"`) that are currently in `done`, using the persisted `sourceIssueClosedAt` / `TaskSourceIssue.closedAt` close time when the reconciler has observed it. Rows that predate the field or have not been observed closed fall back to task `updatedAt` as the documented completion-time approximation; Fusion never fabricates a close timestamp and this analytics path never calls GitHub, the `gh` CLI, or any external network source. To make historical fixed dates exact, use **Backfill exact close times** in the Fixed by Fusion card; the dashboard calls the project-scoped manual `POST /api/git/github/backfill-source-issue-closed-at` endpoint in `{ offset, limit }` batches until `hasMore` is false, then surfaces the accumulated `scanned`, `filled`, `skipped`, and `errors` counts. The endpoint fetches real GitHub `closed_at` values once, fills only missing `sourceIssueClosedAt` values, and never runs automatically or from analytics-time rendering. The area shows filed/fixed/net stat cards, a filed-vs-fixed pie, a filed/fixed recharts trend line, existing daily sparklines, a by-repository bar breakdown, and a **Resolved issues** detail list. Resolved rows include the Fusion task, repository, source issue number, optional issue link, resolved timestamp, and whether that timestamp is exact (`sourceIssueClosedAt`) or the documented `updatedAt` approximation; missing issue URLs render as plain text rather than empty anchors or click targets. The same resolved rows are available from the GitHub analytics payload as `resolved` and from the CSV export. + +### GitLab Settings disclosure and enable toggle + +GitLab settings are collapsed by default to keep Settings less noisy. Use **Settings → Project → General → GitLab Configuration** for project GitLab URL/API overrides, **Settings → Project → Merge → GitLab Authentication** for project token settings, and **Settings → Global General → GitLab Configuration** for global fallbacks. Each disclosure header includes **Enable GitLab integration** so operators can disable GitLab without expanding advanced fields. + +When `gitlabEnabled` is off, Fusion keeps saved GitLab URLs and tokens intact but disables outbound GitLab API work: Import Tasks GitLab fetch/import controls show an enable-in-Settings message, API/CLI/pi import paths reject before network calls, and lifecycle comments/close/reconcile/refresh paths skip with diagnostics. Existing imported-task GitLab metadata remains viewable. GitHub imports and GitHub settings are unchanged. GitLab Signals inbound webhooks are configured separately by `FUSION_SIGNAL_GITLAB_SECRET`; they are not governed by the outbound GitLab API enable toggle. + - **Signals** is backed by the project-scoped `/api/command-center/signals` endpoint, which aggregates real rows from the local `incidents` table. Verified external connectors (`POST /api/signals/gitlab`, `/webhook`, `/sentry`, `/datadog`, and `/pagerduty`) create triage tasks and also write/resolve incidents, so Signals shows total/open/resolved counts, MTTR when resolved incidents have enough timestamps, and source/severity/status breakdowns from connector traffic. GitLab supports GitLab.com and self-managed project/group issue and merge-request webhooks through the environment-only `FUSION_SIGNAL_GITLAB_SECRET` and `X-Gitlab-Token` header; no GitLab CLI or server-side link fetch is used. Signals adds an open-vs-resolved status pie from the same response. Signals has no per-day series today, so it intentionally does not render a line chart or fabricate a trend. The companion `/api/command-center/signals/connectors` endpoint returns only per-provider configured booleans, allowing the empty state to distinguish "no connector configured" from "connector configured, awaiting signals" without exposing secrets. - **System** is the canonical system-telemetry destination. It reads local telemetry from `GET /api/system-stats` and, when multiple registered nodes exist, shows a node selector that can proxy the same system-stats payload through `GET /api/nodes/:id/system-stats` for remote nodes. It renders live radial gauges for app CPU, host memory, and heap usage, keeps a small client-side rolling buffer for CPU/memory/heap trend sparklines, adds a recharts CPU/memory/heap line from that same rolling buffer, and adds a task-by-column pie alongside the existing tasks-by-column and agents-by-state bars. Host memory uses OS-available memory (Node `process.availableMemory()` when available, with a flagged `freemem` fallback) so macOS inactive/cache pages are not reported as used. The Vitest process count, manual kill confirmation, auto-kill toggle, threshold controls, and last-auto-kill timestamp moved here unchanged; the standalone System Stats modal and its desktop Header/mobile More affordances were removed. - **Mission Control** shows live active sessions/runs/nodes, current sessions and nodes, an animated live activity snapshot, and a live SDLC funnel; when idle it reports that live updates resume when work starts. No additional pie or line chart is rendered because the live SDLC funnel already visualizes the panel's only quantitative distribution (`snapshot.columns`), while sessions/nodes are live control lists rather than categorical analytics. Motion-heavy accents respect reduced-motion preferences. diff --git a/docs/gitlab-parity-inventory.md b/docs/gitlab-parity-inventory.md index 237d90a792..d5c0a0702e 100644 --- a/docs/gitlab-parity-inventory.md +++ b/docs/gitlab-parity-inventory.md @@ -106,6 +106,7 @@ Still deferred to later parity tasks: linked GitLab tracking issue creation/adop - **FN-7422 — GitLab core URL configuration:** implemented GitLab.com/self-managed instance and API URL settings/resolution. Preserve GitHub settings untouched. - **FN-7423 — GitLab access-token settings/auth contract:** implemented personal/project/group token settings, `GITLAB_TOKEN` fallback, `PRIVATE-TOKEN` auth metadata, and required scope documentation. Preserve GitHub settings untouched and do not add `glab`. - **FN-7424 — GitLab import runtime:** implemented dashboard/API fetch/import rows, Import Tasks provider controls, CLI `fn task import-gitlab`, and `fn_task_*_gitlab_*` browse/import extension tools for project issues, group issues, and merge requests. GitLab comments, close/reopen, linked tracking, Command Center, research/search, and star prompts remain deferred. +- **FN-7453 — GitLab enable/disclosure:** added dual-scoped `gitlabEnabled` (unset effectively enabled) plus Settings disclosures. Disabled outbound GitLab API operations reject/skip without deleting saved URL/token settings; existing GitLab task metadata and inbound Signals webhook handling remain separate. - **FN-7425 — GitLab tracking lifecycle:** implement provider-specific tracking issue creation/adoption, post-create hooks, Task Detail/List chips and controls, lifecycle notes, close/reopen/delete/unlink behavior, stale state, and batch status refresh. - **FN-7426 — Completion comments and auto-close:** implement `gitlabCommentOnDone`, `gitlabCommentTemplate`, `gitlabCloseSourceIssueOnDone`, source issue reconciliation, and exact closed-at backfill for project issues. Keep MR close/merge behavior out unless explicitly scoped. - **FN-7427 — Command Center GitLab analytics and signals:** add local-task-store-only GitLab analytics with CSV export and optional manual exact-time backfill. Add GitLab webhook/system-hook Signals connector only after the signed verification and normalization contract is defined. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 043145facb..6a3c23c6f7 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -92,6 +92,7 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio | `opencodeGoModelSync` | `boolean` | `true` | Sync opencode-go model catalog at startup via `opencode models opencode --refresh`, and re-run that refresh after saving an `opencode`/`opencode-go` API key in Dashboard Settings, normalizing discovered `opencode/...` IDs into the `opencode-go` provider surface used by `/api/models`. | | `updateCheckEnabled` | `boolean` | `true` | When enabled, Fusion performs a daily npm registry check for new `@runfusion/fusion` versions and shows update notices in CLI/dashboard. | | `githubTrackingDefaultRepo` | `string` | `undefined` | Global fallback issue-tracking repo (`owner/repo`) used when task-level tracking is enabled and no project/task override is set. In Settings UI this is a detected-remote dropdown with a Custom fallback for manual entry. This key is dual-scope: global saves go through `PUT /api/settings/global` (Settings → Global General). | +| `gitlabEnabled` | `boolean` | `undefined` (effective `true`) | Global fallback enable switch for outbound GitLab integrations. Undefined preserves existing behavior; explicit `false` disables GitLab API fetch/import/comment/close/reconcile/refresh operations while leaving saved URL/token settings intact. Projects can override this key. Dashboard location: **Settings → Global General → GitLab Configuration** disclosure. | | `gitlabInstanceUrl` | `string` | `undefined` (effective `https://gitlab.com`) | Global fallback GitLab web instance URL. Blank/unset defaults to GitLab.com. Values are trimmed and must be absolute `http://` or `https://` URLs without username/password userinfo; trailing slashes are normalized by `resolveGitlabConfig`. Projects can override this key. | | `gitlabApiBaseUrl` | `string` | `undefined` (effective `https://gitlab.com/api/v4`) | Global fallback GitLab REST API base URL. Blank/unset derives `/api/v4`, preserving self-managed path prefixes such as `https://example.com/gitlab` → `https://example.com/gitlab/api/v4`. Values are trimmed and must be absolute `http://` or `https://` URLs without userinfo. | | `gitlabAuthToken` | `string` | `undefined` | Global fallback GitLab access token used by later HTTP API integrations when the project does not set its own token. The dashboard renders this as a password input and never displays saved token values in helper text. The resolver trims whitespace and falls back to process `GITLAB_TOKEN` only when both project and global tokens are blank. | @@ -591,6 +592,7 @@ Default notes: | `githubTrackingEnabledByDefault` | `boolean` | `false` | Project-level default for enabling issue tracking on ordinary new tasks. When this is false, the Quick Entry GitHub toggle is disabled until tracking is enabled in Settings. Imported GitHub issues still follow this default unless `githubLinkImportedIssuesToTracking` is enabled. | | `githubLinkImportedIssuesToTracking` | `boolean` | `false` | Project-scoped, import-only option. When enabled, GitHub issue imports from the dashboard, CLI, and extension tools persist `githubTracking: { enabled: true }` so Fusion adopts the imported source issue as the tracking issue without turning tracking on for ordinary new tasks. Duplicate/skipped imports do not create tasks or tracking metadata. | | `githubTrackingDefaultRepo` | `string` | `undefined` | Project default issue-tracking repo (`owner/repo`) used before global fallback for tracked task creation (precedence: task override → project default → global default). In Settings UI this is a detected-remote dropdown with a Custom fallback for manual entry. This key is dual-scope: project saves go through `PUT /api/settings` (Settings → General → GitHub Tracking) while global saves go through `PUT /api/settings/global` (Settings → Global General). | +| `gitlabEnabled` | `boolean` | `undefined` (effective global fallback, then `true`) | Project GitLab integration enable switch. Explicit `false` disables outbound GitLab API imports, completion comments, close/reopen, source closed-at backfill, and tracking refresh side effects for this project without deleting saved URL/token fields. Dashboard location: **Settings → Project → General → GitLab Configuration** and **Settings → Project → Merge → GitLab Authentication** disclosure headers. | | `gitlabInstanceUrl` | `string` | `undefined` (effective global fallback, then `https://gitlab.com`) | Project GitLab web instance URL for GitLab.com or self-managed GitLab. Blank/unset inherits global `gitlabInstanceUrl` and then defaults to GitLab.com. Values are trimmed and must be absolute `http://` or `https://` URLs without username/password userinfo; trailing slashes are normalized by `resolveGitlabConfig`. Dashboard location: **Settings → Project → General → GitLab Configuration**. | | `gitlabApiBaseUrl` | `string` | `undefined` (effective global fallback, then `/api/v4`) | Optional project GitLab REST API base URL. Blank/unset inherits global `gitlabApiBaseUrl`; if still unset, Fusion derives `/api/v4`, preserving self-managed path prefixes. Override only for API gateways with a different absolute HTTP(S) base URL. | | `gitlabAuthToken` | `string` | `undefined` | Project GitLab access token for later GitLab import/tracking/comment/close HTTP API tasks. Project value takes precedence over global `gitlabAuthToken`, then process `GITLAB_TOKEN`. The dashboard renders the field as a password input and trims values on save; blank clears the project override. Dashboard location: **Settings → Project → Merge → GitLab Authentication**. | @@ -602,6 +604,8 @@ Default notes: | `githubAuthMode` | `"gh-cli" \| "token"` | `"gh-cli"` | Project GitHub auth strategy used by tracking lifecycle integration. `"gh-cli"` requires an installed/authenticated `gh` CLI. `"token"` requires a non-empty `githubAuthToken` (or `GITHUB_TOKEN` env fallback). Tracking lifecycle auth is strict per selected mode (no cross-fallback). | | `githubAuthToken` | `string` | `undefined` | Optional project PAT used when `githubAuthMode` is `"token"` (takes precedence over server startup token for tracking flows). | +GitLab enablement defaults effectively to on when `gitlabEnabled` is unset, so existing installations keep GitLab behavior until an operator turns the integration off. Project `gitlabEnabled` overrides global `gitlabEnabled`; if both are unset, GitLab API operations are active subject to token/URL validation. Disabling GitLab skips or rejects outbound network-backed operations but does not delete `gitlabInstanceUrl`, `gitlabApiBaseUrl`, `gitlabAuthToken`, or `gitlabAuthTokenType`; re-enabling resumes with the saved configuration. The Settings UI keeps these controls behind GitLab Configuration / GitLab Authentication disclosures, with the enable toggle in each disclosure header. + GitLab configuration examples: leave both URL fields blank for GitLab.com (`https://gitlab.com`, API `https://gitlab.com/api/v4`); set only `gitlabInstanceUrl=https://gitlab.example.com/gitlab` for a self-managed path-prefix install (API derives `https://gitlab.example.com/gitlab/api/v4`); set both URL fields when a self-managed API gateway differs from the web URL. GitLab auth uses access tokens over the GitLab REST API `PRIVATE-TOKEN` header; Fusion does not require or invoke `glab`. Supported token families are [personal access tokens](https://docs.gitlab.com/user/profile/personal_access_tokens/), [project access tokens](https://docs.gitlab.com/user/project/settings/project_access_tokens/), and [group access tokens](https://docs.gitlab.com/user/group/settings/group_access_tokens/). GitLab issue/MR import and tracking reads need `read_api` or `api`; posting notes/comments and closing/reopening issues or MRs need `api`. Project and group access tokens are constrained to their associated resource and role membership, so the configured token must cover the target project or group. Lifecycle actions use the configured API base URL for GitLab.com and self-managed instances, URL-encode project path identifiers, and skip unsupported targets such as terminal merged merge requests or group issues missing concrete project identity. Command Center signals, research/search providers, and star-prompt behavior remain deferred to later GitLab subtasks tracked from [GitLab Parity Inventory](./gitlab-parity-inventory.md). | `autoCreatePr` | `boolean` | `false` | Auto-create PRs for completed tasks. | diff --git a/docs/task-management.md b/docs/task-management.md index 9b6413e1fe..7393c3e715 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -674,11 +674,11 @@ Recovery/backfill guidance: ## GitHub Issue Import and PR Creation -GitLab instance/API URL and access-token configuration are available in Settings for GitLab.com and self-managed GitLab (`gitlabInstanceUrl`, optional `gitlabApiBaseUrl`, `gitlabAuthToken`, `gitlabAuthTokenType`). Fusion accepts personal, project, and group access tokens for GitLab HTTP API import tasks; read-only project issue, group issue, and merge request imports require `read_api` or `api`, while later write actions such as comments and auto-close require `api`. +GitLab enablement, instance/API URL, and access-token configuration are available in Settings for GitLab.com and self-managed GitLab (`gitlabEnabled`, `gitlabInstanceUrl`, optional `gitlabApiBaseUrl`, `gitlabAuthToken`, `gitlabAuthTokenType`). Fusion accepts personal, project, and group access tokens for GitLab HTTP API import tasks; read-only project issue, group issue, and merge request imports require `read_api` or `api`, while later write actions such as comments and auto-close require `api`. -GitLab imports are HTTP API only and do not require or invoke `glab`. Operators can import project issues, group issues, and project merge requests from the Import Tasks surface, CLI, or pi extension tools. Imported tasks are created in `triage`, include the GitLab body (or `(no description)`) plus `Source: `, and persist `source.sourceType: "gitlab_import"`, `source.sourceMetadata.provider: "gitlab"`, `resourceType` (`project_issue`, `group_issue`, or `merge_request`), instance/API URL, project/group identity, IID, and web URL. Group issue imports preserve the originating project identity from GitLab so duplicate detection is project-aware instead of group-path-only. Merge request imports use MR IID as the visible number and a namespaced external ID so they do not collide with issue imports. +GitLab imports are HTTP API only and do not require or invoke `glab`. They require effective `gitlabEnabled !== false`; when GitLab is disabled, dashboard/API/CLI/pi import fetches fail clearly before making GitLab network calls, while saved URL/token settings and existing linked GitLab metadata remain visible for re-enable. Operators can import project issues, group issues, and project merge requests from the Import Tasks surface, CLI, or pi extension tools. Imported tasks are created in `triage`, include the GitLab body (or `(no description)`) plus `Source: `, and persist `source.sourceType: "gitlab_import"`, `source.sourceMetadata.provider: "gitlab"`, `resourceType` (`project_issue`, `group_issue`, or `merge_request`), instance/API URL, project/group identity, IID, and web URL. Group issue imports preserve the originating project identity from GitLab so duplicate detection is project-aware instead of group-path-only. Merge request imports use MR IID as the visible number and a namespaced external ID so they do not collide with issue imports. -Duplicate detection checks existing non-archived task provenance and source URLs before creating another GitLab-imported task. GitLab linked tracking display, comments/notes, auto-close/reopen, Command Center signals/analytics, research/search support, and any GitLab-star prompt remain out of scope until the later GitLab parity tasks mapped in [GitLab Parity Inventory](./gitlab-parity-inventory.md). +Duplicate detection checks existing non-archived task provenance and source URLs before creating another GitLab-imported task. GitLab lifecycle side effects (completion comments, close/reopen, source closed-at backfill, and tracking refreshes) also require GitLab to be enabled; when disabled they skip network work with task-log diagnostics instead of clearing source metadata. GitLab linked tracking display, comments/notes, auto-close/reopen, Command Center signals/analytics, research/search support, and any GitLab-star prompt remain out of scope until the later GitLab parity tasks mapped in [GitLab Parity Inventory](./gitlab-parity-inventory.md). Linear issue import is available through the bundled **Linear Import** plugin, not the core GitHub/GitLab Import Tasks implementation. Operators enable the plugin from Plugin Manager, configure the plugin-owned Linear API key, then use the plugin dashboard view or plugin tools to browse and import issues. Imported Linear tasks are created in `triage`, include the Linear body (or `(no description)`) plus `Source: `, and persist `sourceIssue.provider: "linear"` plus `source.sourceMetadata.provider: "linear"` with stable issue id, identifier, URL, team, state, assignee, and timestamps where available. Duplicate detection checks existing non-archived Linear provenance by issue id, identifier, and source URL before task creation and reports the existing task id when a duplicate is found. diff --git a/packages/core/src/__tests__/gitlab-config.test.ts b/packages/core/src/__tests__/gitlab-config.test.ts index 7d92eab843..4ed45fe05c 100644 --- a/packages/core/src/__tests__/gitlab-config.test.ts +++ b/packages/core/src/__tests__/gitlab-config.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_INSTANCE_URL, resolveGitlabConfig, + resolveGitlabEnabled, } from "../gitlab-config.js"; /* @@ -12,6 +13,7 @@ These tests pin FN-7422 as configuration-only groundwork: GitLab.com is the blan describe("resolveGitlabConfig", () => { it("defaults to GitLab.com when no settings are configured", () => { expect(resolveGitlabConfig()).toEqual({ + enabled: true, instanceUrl: DEFAULT_GITLAB_INSTANCE_URL, apiBaseUrl: DEFAULT_GITLAB_API_BASE_URL, }); @@ -24,6 +26,7 @@ describe("resolveGitlabConfig", () => { project: { gitlabInstanceUrl: "https://project.example/gitlab", gitlabApiBaseUrl: "https://project.example/rest" }, }), ).toEqual({ + enabled: true, instanceUrl: "https://project.example/gitlab", apiBaseUrl: "https://project.example/rest", }); @@ -36,6 +39,7 @@ describe("resolveGitlabConfig", () => { project: { gitlabInstanceUrl: " ", gitlabApiBaseUrl: "" }, }), ).toEqual({ + enabled: true, instanceUrl: "https://global.example/gitlab", apiBaseUrl: "https://global.example/gitlab/api/v4", }); @@ -43,6 +47,7 @@ describe("resolveGitlabConfig", () => { it("derives the API base URL from a self-managed path prefix", () => { expect(resolveGitlabConfig({ project: { gitlabInstanceUrl: "https://example.com/gitlab/" } })).toEqual({ + enabled: true, instanceUrl: "https://example.com/gitlab", apiBaseUrl: "https://example.com/gitlab/api/v4", }); @@ -53,16 +58,26 @@ describe("resolveGitlabConfig", () => { resolveGitlabConfig({ project: { gitlabInstanceUrl: "https://gitlab.example", gitlabApiBaseUrl: "https://api.example/custom/v4/" }, }), - ).toEqual({ instanceUrl: "https://gitlab.example", apiBaseUrl: "https://api.example/custom/v4" }); + ).toEqual({ enabled: true, instanceUrl: "https://gitlab.example", apiBaseUrl: "https://api.example/custom/v4" }); }); it("treats blank strings as cleared defaults", () => { expect(resolveGitlabConfig({ project: { gitlabInstanceUrl: " ", gitlabApiBaseUrl: "\t" } })).toEqual({ + enabled: true, instanceUrl: DEFAULT_GITLAB_INSTANCE_URL, apiBaseUrl: DEFAULT_GITLAB_API_BASE_URL, }); }); + + it("resolves effective enabled state with project-over-global precedence", () => { + expect(resolveGitlabEnabled()).toBe(true); + expect(resolveGitlabEnabled({ global: { gitlabEnabled: false } })).toBe(false); + expect(resolveGitlabEnabled({ global: { gitlabEnabled: false }, project: { gitlabEnabled: true } })).toBe(true); + expect(resolveGitlabEnabled({ global: { gitlabEnabled: true }, project: { gitlabEnabled: false } })).toBe(false); + expect(resolveGitlabConfig({ global: { gitlabEnabled: false } })).toMatchObject({ enabled: false }); + }); + it.each([ ["GitLab instance URL", { project: { gitlabInstanceUrl: "ssh://gitlab.example" } }], ["GitLab instance URL", { project: { gitlabInstanceUrl: "https://user:pass@gitlab.example" } }], diff --git a/packages/core/src/__tests__/settings-parity.test.ts b/packages/core/src/__tests__/settings-parity.test.ts index 89b7c268d2..8531ad9f6b 100644 --- a/packages/core/src/__tests__/settings-parity.test.ts +++ b/packages/core/src/__tests__/settings-parity.test.ts @@ -81,16 +81,18 @@ describe("settings key parity", () => { expect(isProjectSettingsKey("agentMemoryInclusionMode")).toBe(false); }); - it("keeps GitLab URL and token configuration dual-scoped with blank defaults", () => { + it("keeps GitLab enablement, URL, and token configuration dual-scoped with blank defaults", () => { + expect(DEFAULT_GLOBAL_SETTINGS.gitlabEnabled).toBeUndefined(); expect(DEFAULT_GLOBAL_SETTINGS.gitlabInstanceUrl).toBeUndefined(); expect(DEFAULT_GLOBAL_SETTINGS.gitlabApiBaseUrl).toBeUndefined(); expect(DEFAULT_GLOBAL_SETTINGS.gitlabAuthToken).toBeUndefined(); expect(DEFAULT_GLOBAL_SETTINGS.gitlabAuthTokenType).toBeUndefined(); + expect(DEFAULT_PROJECT_SETTINGS.gitlabEnabled).toBeUndefined(); expect(DEFAULT_PROJECT_SETTINGS.gitlabInstanceUrl).toBeUndefined(); expect(DEFAULT_PROJECT_SETTINGS.gitlabApiBaseUrl).toBeUndefined(); expect(DEFAULT_PROJECT_SETTINGS.gitlabAuthToken).toBeUndefined(); expect(DEFAULT_PROJECT_SETTINGS.gitlabAuthTokenType).toBeUndefined(); - for (const key of ["gitlabInstanceUrl", "gitlabApiBaseUrl", "gitlabAuthToken", "gitlabAuthTokenType"] as const) { + for (const key of ["gitlabEnabled", "gitlabInstanceUrl", "gitlabApiBaseUrl", "gitlabAuthToken", "gitlabAuthTokenType"] as const) { expect(isGlobalSettingsKey(key)).toBe(true); expect(isProjectSettingsKey(key)).toBe(true); expect(PROJECT_SETTINGS_KEYS).toContain(key); @@ -423,16 +425,18 @@ describe("settings key parity", () => { // FNXC:SettingsScopeParity 2026-06-26-17:35: // mcpServers is intentionally dual-scoped (FN-7077, "inject configured MCP servers across // agent surfaces"): a global default applies to every project while a project override - // tailors the MCP server set per project. GitLab URL settings are dual-scoped (FN-7422) - // so operators can set an organization-wide self-managed instance while individual projects - // can override hosts or API prefixes; GitLab token settings are dual-scoped (FN-7423) - // so projects can override global fallback credentials without treating project/group tokens - // as globally authorized. Keep this allow-list in GLOBAL_SETTINGS_KEYS order. + // tailors the MCP server set per project. GitLab enablement/URL settings are dual-scoped + // (FN-7422/FN-7453) so operators can set an organization-wide default while individual + // projects can override active state, hosts, or API prefixes; GitLab token settings are + // dual-scoped (FN-7423) so projects can override global fallback credentials without + // treating project/group tokens as globally authorized. Keep this allow-list in + // GLOBAL_SETTINGS_KEYS order. expect(overlap).toEqual([ "testMode", "mergeRequestContractShadowEnabled", "taskTokenBudget", "githubTrackingDefaultRepo", + "gitlabEnabled", "gitlabInstanceUrl", "gitlabApiBaseUrl", "gitlabAuthToken", diff --git a/packages/core/src/gitlab-config.ts b/packages/core/src/gitlab-config.ts index 197aad7a43..34076edd9c 100644 --- a/packages/core/src/gitlab-config.ts +++ b/packages/core/src/gitlab-config.ts @@ -4,6 +4,7 @@ export const DEFAULT_GITLAB_INSTANCE_URL = "https://gitlab.com"; export const DEFAULT_GITLAB_API_BASE_URL = "https://gitlab.com/api/v4"; export interface GitlabConfigSettingsSource { + gitlabEnabled?: boolean; gitlabInstanceUrl?: string; gitlabApiBaseUrl?: string; } @@ -14,10 +15,21 @@ export interface ResolveGitlabConfigInput { } export interface ResolvedGitlabConfig { + enabled: boolean; instanceUrl: string; apiBaseUrl: string; } +export function resolveGitlabEnabled(input: ResolveGitlabConfigInput = {}): boolean { + /* + FNXC:GitLabEnablement 2026-07-02-00:00: + FN-7453 separates saved GitLab URL/token configuration from whether GitLab integrations are active. Undefined remains effectively enabled for backward compatibility; explicit project false overrides global true/undefined and short-circuits runtime network paths before URL or token validation. + */ + if (typeof input.project?.gitlabEnabled === "boolean") return input.project.gitlabEnabled; + if (typeof input.global?.gitlabEnabled === "boolean") return input.global.gitlabEnabled; + return true; +} + function readConfiguredString(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); @@ -63,6 +75,7 @@ function deriveApiBaseUrl(instanceUrl: string): string { * FN-7422 only establishes typed GitLab.com and self-managed URL configuration for later GitLab auth/import/tracking subtasks. Normalize and validate here before any future network client consumes these settings, preserving self-managed path prefixes while rejecting non-http(s) URLs and userinfo-bearing URLs. */ export function resolveGitlabConfig(input: ResolveGitlabConfigInput = {}): ResolvedGitlabConfig { + const enabled = resolveGitlabEnabled(input); const projectInstanceUrl = readConfiguredString(input.project?.gitlabInstanceUrl); const globalInstanceUrl = readConfiguredString(input.global?.gitlabInstanceUrl); const projectApiBaseUrl = readConfiguredString(input.project?.gitlabApiBaseUrl); @@ -75,5 +88,5 @@ export function resolveGitlabConfig(input: ResolveGitlabConfigInput = {}): Resol ? DEFAULT_GITLAB_API_BASE_URL : deriveApiBaseUrl(instanceUrl); - return { instanceUrl, apiBaseUrl }; + return { enabled, instanceUrl, apiBaseUrl }; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 30eea903a2..746ce443eb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,4 @@ -export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_INSTANCE_URL, resolveGitlabConfig, PLANNING_DEEPEN_CHECKPOINT_ID, PLANNING_DEEPEN_CHECKPOINT_QUESTION, PLANNING_DEEPEN_PROCEED_OPTION_ID, PLANNING_DEEPEN_PROCEED_RESPONSE_KEY, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, isMcpSecretRef } from "./types.js"; +export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_INSTANCE_URL, resolveGitlabConfig, resolveGitlabEnabled, PLANNING_DEEPEN_CHECKPOINT_ID, PLANNING_DEEPEN_CHECKPOINT_QUESTION, PLANNING_DEEPEN_PROCEED_OPTION_ID, PLANNING_DEEPEN_PROCEED_RESPONSE_KEY, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, isMcpSecretRef } from "./types.js"; export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, TaskGitLabTracking, TaskGitLabTrackedItem, GitLabTrackedItemKind, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType } from "./types.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js"; export { diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 230235b2a9..67b8f90a54 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -136,6 +136,7 @@ export const DEFAULT_GLOBAL_SETTINGS = { updateCheckFrequency: "daily", autoReloadOnVersionChange: true, githubTrackingDefaultRepo: undefined, + gitlabEnabled: undefined, gitlabInstanceUrl: undefined, gitlabApiBaseUrl: undefined, gitlabAuthToken: undefined, @@ -501,6 +502,7 @@ export const DEFAULT_PROJECT_SETTINGS = { githubTrackingEnabledByDefault: false, githubLinkImportedIssuesToTracking: false, githubTrackingDefaultRepo: undefined, + gitlabEnabled: undefined, gitlabInstanceUrl: undefined, gitlabApiBaseUrl: undefined, gitlabAuthToken: undefined, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e42c2659bc..1f4a9f645d 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -19,6 +19,7 @@ export { DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_INSTANCE_URL, resolveGitlabConfig, + resolveGitlabEnabled, } from "./gitlab-config.js"; export type { GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput } from "./gitlab-config.js"; export { validateMcpServerDefinitionDetailed, validateMcpServerDefinitionsDetailed } from "./settings-validation.js"; @@ -3206,6 +3207,8 @@ export interface GlobalSettings { /** Global fallback GitHub tracking repo in `owner/repo` format (FN-3868). * Used when a project has no githubTrackingDefaultRepo. */ githubTrackingDefaultRepo?: string; + /** Global GitLab integration enable flag. Undefined is effectively enabled for backward compatibility; projects can override this value. */ + gitlabEnabled?: boolean; /** Global fallback GitLab web instance URL. Defaults effectively to https://gitlab.com when unset. * Project gitlabInstanceUrl overrides this value. */ gitlabInstanceUrl?: string; @@ -4339,6 +4342,8 @@ export interface ProjectSettings { * FNXC:GitLabConfiguration 2026-07-02-00:00: * FN-7422 adds durable GitLab instance/API URL settings for GitLab.com and self-managed hosts. FN-7423 layers token settings onto the same project-over-global configuration contract without adding runtime GitLab imports or tracking. */ + /** Project GitLab integration enable flag. Undefined inherits global gitlabEnabled, then defaults effectively enabled for backward compatibility. */ + gitlabEnabled?: boolean; /** Project GitLab web instance URL. Falls back to global gitlabInstanceUrl, then https://gitlab.com. */ gitlabInstanceUrl?: string; /** Project GitLab REST API base URL. Falls back to global gitlabApiBaseUrl, then derives `/api/v4`. */ diff --git a/packages/dashboard/app/__tests__/settings-save-split.test.ts b/packages/dashboard/app/__tests__/settings-save-split.test.ts index a55bed4672..52b150783a 100644 --- a/packages/dashboard/app/__tests__/settings-save-split.test.ts +++ b/packages/dashboard/app/__tests__/settings-save-split.test.ts @@ -28,6 +28,8 @@ describe("scope anchors", () => { expect(isProjectSettingsKey("enabledBuiltinWorkflowIds")).toBe(true); expect(isProjectSettingsKey("githubLinkImportedIssuesToTracking")).toBe(true); expect(isGlobalSettingsKey("githubLinkImportedIssuesToTracking")).toBe(false); + expect(isGlobalSettingsKey("gitlabEnabled")).toBe(true); + expect(isProjectSettingsKey("gitlabEnabled")).toBe(true); expect(isGlobalSettingsKey("gitlabAuthToken")).toBe(true); expect(isProjectSettingsKey("gitlabAuthToken")).toBe(true); expect(isGlobalSettingsKey("gitlabAuthTokenType")).toBe(true); @@ -220,13 +222,14 @@ describe("splitSettingsSave", () => { expect(projectPatch).toEqual({ maxConcurrent: 7 }); }); - it("routes GitLab token settings to global settings only from global general", () => { + it("routes GitLab enable and token settings to global settings only from global general", () => { const initialScopedValues = { - global: { gitlabAuthToken: undefined, gitlabAuthTokenType: undefined }, - project: { gitlabAuthToken: "project-token", gitlabAuthTokenType: "project" }, + global: { gitlabEnabled: true, gitlabAuthToken: undefined, gitlabAuthTokenType: undefined }, + project: { gitlabEnabled: true, gitlabAuthToken: "project-token", gitlabAuthTokenType: "project" }, } as never; const payload: Record = { + gitlabEnabled: false, gitlabAuthToken: "global-token", gitlabAuthTokenType: "group", }; @@ -238,17 +241,18 @@ describe("splitSettingsSave", () => { activeSection: "global-general", }); - expect(globalPatch).toEqual({ gitlabAuthToken: "global-token", gitlabAuthTokenType: "group" }); + expect(globalPatch).toEqual({ gitlabEnabled: false, gitlabAuthToken: "global-token", gitlabAuthTokenType: "group" }); expect(projectPatch).toEqual({}); }); - it("routes GitLab token settings to project settings outside global general", () => { + it("routes GitLab enable and token settings to project settings outside global general", () => { const initialScopedValues = { - global: { gitlabAuthToken: "global-token", gitlabAuthTokenType: "group" }, - project: {}, + global: { gitlabEnabled: false, gitlabAuthToken: "global-token", gitlabAuthTokenType: "group" }, + project: { gitlabEnabled: true }, } as never; const payload: Record = { + gitlabEnabled: false, gitlabAuthToken: "project-token", gitlabAuthTokenType: "project", }; @@ -261,7 +265,7 @@ describe("splitSettingsSave", () => { }); expect(globalPatch).toEqual({}); - expect(projectPatch).toEqual({ gitlabAuthToken: "project-token", gitlabAuthTokenType: "project" }); + expect(projectPatch).toEqual({ gitlabEnabled: false, gitlabAuthToken: "project-token", gitlabAuthTokenType: "project" }); }); it("clears a project GitLab token with null-as-delete while preserving selected token type", () => { 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/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/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/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index 501ff59d7b..6a0cef26db 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -363,6 +363,26 @@ function isKeyboardFocusableElement(el: Element | null): boolean { * - Fallback: initial viewport height - vv.height - vv.offsetTop * Works on iOS Safari where window.innerHeight shrinks with the keyboard. */ +function getScreenViewportBaselineCandidate(viewportWidth: number, viewportHeight: number): number | null { + if (typeof window === "undefined" || !window.screen) return null; + const screenWidth = window.screen.width; + const screenHeight = window.screen.height; + if (!Number.isFinite(screenWidth) || !Number.isFinite(screenHeight) || screenWidth <= 0 || screenHeight <= 0) { + return null; + } + + const portraitLike = viewportHeight >= viewportWidth; + const candidate = portraitLike + ? Math.max(screenWidth, screenHeight) + : Math.min(screenWidth, screenHeight); + const gap = candidate - viewportHeight; + const minMeaningfulGap = portraitLike + ? Math.max(220, candidate * 0.25) + : Math.max(80, candidate * 0.25); + + return gap >= minMeaningfulGap ? candidate : null; +} + function getKeyboardOverlap(): number { if (typeof window === "undefined" || !window.visualViewport) return 0; const vv = window.visualViewport; @@ -384,6 +404,9 @@ function getKeyboardOverlap(): number { FNXC:Terminal 2026-06-30-11:42: Touch-primary short landscape and folded closed postures can be <=480px tall. A keyboard-closed width/posture sample must replace an unfolded baseline even at that height, while focused keyboard-open samples remain excluded so xterm does not clear overlap before the first correct folded fit. + + FNXC:Terminal 2026-07-02-18:12: + iOS Safari can deliver the very first terminal sample with the helper textarea focused, the soft keyboard already open, and both `innerHeight` and `documentElement.clientHeight` shrunk to the visual viewport. Seed that initial focused sample from the device screen only when the missing height is large enough to be a keyboard, so 10px/12px terminals publish --keyboard-overlap/--vv-height/--vv-width before any close/open, orientation, reconnect, or font reset side effect can repair spaced ASCII cells. */ if (!isKeyboardFocusableElement(document.activeElement) && hasSettledViewportPostureChange(viewportWidth)) { setInitialViewportBaseline(viewportHeight, viewportWidth); @@ -392,7 +415,13 @@ function getKeyboardOverlap(): number { // On iOS Safari, window.innerHeight shrinks to match visualViewport. // Detect keyboard by checking if visual viewport is shorter than initial // height by more than 80px (with a 30px noise filter). - const initialHeight = getInitialViewportHeight(viewportWidth, viewportHeight); + const screenBaselineCandidate = isKeyboardFocusableElement(document.activeElement) + ? getScreenViewportBaselineCandidate(viewportWidth, viewportHeight) + : null; + const initialHeight = Math.max( + getInitialViewportHeight(viewportWidth, screenBaselineCandidate ?? viewportHeight), + screenBaselineCandidate ?? 0, + ); const gap = initialHeight - vv.offsetTop - vv.height; // Minimum 30px gap required to filter noise (address bar, toolbar changes). // Threshold of 80px: only consider keyboard present when gap exceeds this. diff --git a/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx index 19eb676322..643eee4a14 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx @@ -22,6 +22,7 @@ import { createRoomFixture, ensureMatchMedia, installChatViewEnv, + mockFetchModels, } from "./ChatView.test-harness"; // Mock the hooks @@ -230,11 +231,20 @@ describe("ChatView mobile behavior", () => { } }); - it("mobile mode: collapses direct thread controls into the top ViewHeader row", async () => { + it("mobile mode: collapses direct thread controls into one far-left ViewHeader row", async () => { const restoreMatchMedia = mockMobileViewport(); try { setupMockChat({ - activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + activeSession: { + id: "session-001", + agentId: "__fn_agent__", + status: "active", + title: "Testing", + modelProvider: "minimax", + modelId: "m3", + createdAt: "2026-04-08T00:00:00.000Z", + updatedAt: "2026-04-08T00:00:00.000Z", + }, messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }], }); @@ -244,12 +254,21 @@ describe("ChatView mobile behavior", () => { expect(document.querySelector(".chat-view--mobile-direct-thread")).toBeInTheDocument(); const viewHeader = document.querySelector(".view-header") as HTMLElement; + const headerActions = viewHeader.querySelector(".view-header__actions") as HTMLElement; + const headerTitle = viewHeader.querySelector(".view-header__title") as HTMLElement; const backButton = screen.getByTestId("chat-back-btn"); const sessionTrigger = screen.getByTestId("chat-mobile-session-trigger"); const renderToggle = screen.getByTestId("chat-thread-render-toggle"); expect(viewHeader).toContainElement(backButton); expect(viewHeader).toContainElement(sessionTrigger); + expect(headerActions).toContainElement(backButton); + expect(headerActions).toContainElement(sessionTrigger); + expect(headerActions.firstElementChild).toBe(backButton); + expect(backButton.compareDocumentPosition(sessionTrigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(headerTitle).not.toContainElement(backButton); + expect(headerTitle.querySelector("svg")).toHaveAttribute("aria-hidden", "true"); + expect(sessionTrigger).toHaveTextContent("M3"); expect(viewHeader).not.toContainElement(renderToggle); expect(renderToggle).toHaveClass("chat-thread-header-render-toggle--floating"); expect(screen.getAllByTestId("chat-back-btn")).toHaveLength(1); @@ -384,6 +403,44 @@ describe("ChatView mobile behavior", () => { } }); + it("mobile mode: long duplicate session titles stay in the single-row switcher with Bot fallback", async () => { + const restoreMatchMedia = mockMobileViewport(); + const selectSession = vi.fn(); + const longTitle = "MiniMax M3 with an extraordinarily long duplicated conversation label that must truncate"; + try { + mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [], defaultProvider: null, defaultModelId: null }); + const duplicateSessions = [ + { id: "session-001", agentId: "agent-unresolved", status: "active" as const, title: longTitle, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + { id: "session-002", agentId: "agent-unresolved-2", status: "active" as const, title: longTitle, createdAt: "2026-04-07T00:00:00.000Z", updatedAt: "2026-04-07T00:00:00.000Z" }, + ]; + setupMockChat({ + sessions: duplicateSessions, + filteredSessions: duplicateSessions, + activeSession: duplicateSessions[0], + messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }], + selectSession, + }); + + await renderWithAct(); + + const headerActions = document.querySelector(".view-header__actions") as HTMLElement; + const backButton = screen.getByTestId("chat-back-btn"); + const trigger = screen.getByTestId("chat-mobile-session-trigger"); + expect(headerActions.firstElementChild).toBe(backButton); + expect(trigger).toHaveTextContent(longTitle); + expect(within(trigger).getByTestId("icon-bot")).toBeInTheDocument(); + expect(screen.getAllByTestId("chat-mobile-session-trigger")).toHaveLength(1); + + await userEvent.click(trigger); + const dropdown = screen.getByTestId("chat-mobile-session-dropdown"); + expect(within(dropdown).getAllByText(longTitle)).toHaveLength(2); + await userEvent.click(screen.getByTestId("chat-mobile-session-option-session-002")); + expect(selectSession).toHaveBeenCalledWith("session-002"); + } finally { + restoreMatchMedia.mockRestore(); + } + }); + it("mobile mode: ViewHeader session trigger opens quick session switcher and closes after selection", async () => { const restoreMatchMedia = mockMobileViewport(); const selectSession = vi.fn(); @@ -1921,14 +1978,27 @@ describe("ChatView mobile CSS contract", () => { expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-message\s*\{[^}]*max-width:\s*100%/); }); - it("mobile direct thread collapses header text and floats the render toggle with tokenized sizing", async () => { - const directTitleRule = css.match(/\.chat-view--mobile-direct-thread\s*>\s*\.view-header\s+\.view-header__title\s+span\s*\{([^}]*)\}/)?.[1] ?? ""; + it("mobile direct thread keeps a non-wrapping far-left header row with tokenized sizing", async () => { + const headerRule = css.match(/\.chat-view--mobile-direct-thread\s*>\s*\.view-header\s*\{([^}]*)\}/)?.[1] ?? ""; + const directTitleRule = css.match(/\.chat-view--mobile-direct-thread\s*>\s*\.view-header\s+\.view-header__title\s*\{([^}]*)\}/)?.[1] ?? ""; const actionsRule = css.match(/\.chat-view--mobile-direct-thread\s*>\s*\.view-header\s+\.view-header__actions\s*\{([^}]*)\}/)?.[1] ?? ""; + const menuRule = css.match(/\.chat-view--mobile-direct-thread\s+\.chat-mobile-session-menu\s*\{([^}]*)\}/)?.[1] ?? ""; + const triggerRule = css.match(/\.chat-mobile-session-trigger\s*\{([^}]*)\}/)?.[1] ?? ""; const floatingToggleRule = css.match(/\.chat-thread-header-render-toggle--floating\s*\{([^}]*)\}/)?.[1] ?? ""; + expect(headerRule).toContain("flex-wrap: nowrap"); + expect(directTitleRule).toContain("position: absolute"); expect(directTitleRule).toContain("inline-size: var(--btn-border-width)"); expect(directTitleRule).toContain("clip-path: inset(50%)"); - expect(actionsRule).toContain("flex: 1 1 auto"); + expect(actionsRule).toContain("width: 100%"); + expect(actionsRule).toContain("margin-left: 0"); + expect(actionsRule).toContain("flex-wrap: nowrap"); + expect(actionsRule).toContain("justify-content: flex-start"); + expect(menuRule).toContain("flex: 1 1 0"); + expect(menuRule).toContain("min-width: 0"); + expect(triggerRule).toContain("min-width: 0"); + expect(triggerRule).toContain("gap: var(--space-sm)"); + expect(triggerRule).not.toMatch(/#[0-9a-fA-F]{3,8}|rgb\(/); expect(floatingToggleRule).toContain("position: absolute"); expect(floatingToggleRule).toContain("right: var(--space-md)"); expect(floatingToggleRule).toContain("bottom: calc((var(--space-lg) * 2.5) + (var(--space-md) * 2) + var(--space-sm))"); 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__/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(); diff --git a/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx b/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx index 2caed22ca8..aab44461e5 100644 --- a/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx @@ -558,6 +558,48 @@ describe("SessionTerminal (mobile) — keyboard-open behavior", () => { input.remove(); }); + it("keeps initial iOS keyboard-open 12px metrics when layout height already shrank", async () => { + installMatchMedia(true); + const originalScreen = window.screen; + installVisualViewport({ innerHeight: 390, vvHeight: 390, vvWidth: 390 }); + Object.defineProperty(window, "innerWidth", { value: 390, writable: true, configurable: true }); + Object.defineProperty(document.documentElement, "clientHeight", { + value: 390, + configurable: true, + }); + Object.defineProperty(window, "screen", { + configurable: true, + value: { width: 390, height: 844 }, + }); + window.localStorage.setItem( + TERMINAL_PREFERENCES_KEY, + JSON.stringify({ ...DEFAULT_TERMINAL_PREFERENCES, fontSize: 12 }), + ); + const input = document.createElement("textarea"); + document.body.appendChild(input); + input.focus(); + + try { + const { ws } = await renderMobile(); + + await waitFor(() => { + const root = screen.getByTestId("cli-terminal-mobile-bar").closest(".cli-session-terminal"); + expect(root).toHaveClass("cli-session-terminal--mobile"); + expect(root).toHaveAttribute("data-keyboard-open", "true"); + const bar = screen.getByTestId("cli-terminal-mobile-bar"); + expect(bar.className).toContain("cli-session-terminal__mobile-bar--keyboard-open"); + expect(bar.style.bottom).toBe("454px"); + }); + expect(mockTerm.options.fontSize).toBe(12); + expectMeasurementSafeFontStack(mockTerm.options.fontFamily as string); + await waitFor(() => expect(mockFitAddon.fit).toHaveBeenCalled()); + expect(ws.sent.some((raw) => JSON.parse(raw).type === "resize")).toBe(true); + } finally { + input.remove(); + Object.defineProperty(window, "screen", { configurable: true, value: originalScreen }); + } + }); + it("keeps Android keyboard-open 10px metrics on visualViewport mobile width", async () => { installMatchMedia({ width: false, height: false }); installVisualViewport({ innerHeight: 700, vvHeight: 320, vvWidth: 390 }); 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/__tests__/TerminalModal.test.tsx b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx index f8bb0d7f80..5cc9df833a 100644 --- a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx @@ -5030,6 +5030,122 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", () } }); + it("fits initial iOS keyboard-open 12px terminal from the visible viewport before any repair event", async () => { + (window as any).ontouchstart = null; + window.localStorage.setItem(TERMINAL_PREFERENCES_KEY, JSON.stringify({ + ...DEFAULT_TERMINAL_PREFERENCES, + fontSize: 12, + })); + const originalScreen = window.screen; + const { listeners, mockVV } = simulateIOSSafari(true, 390); + Object.defineProperty(mockVV, "width", { value: 390, writable: true, configurable: true }); + Object.defineProperty(window, "innerWidth", { value: 390, writable: true, configurable: true }); + Object.defineProperty(document.documentElement, "clientWidth", { + value: 390, + configurable: true, + }); + Object.defineProperty(document.documentElement, "clientHeight", { + value: 390, + configurable: true, + }); + Object.defineProperty(window, "screen", { + configurable: true, + value: { width: 390, height: 844 }, + }); + const helperTextarea = document.createElement("textarea"); + document.body.appendChild(helperTextarea); + helperTextarea.focus(); + const onDataListeners: Array<(data: string) => void> = []; + const resizeForInitialIOSKeyboard = vi.fn(); + mockUseTerminal.mockReturnValue(createMockTerminalState({ + connectionStatus: "connected", + resize: resizeForInitialIOSKeyboard, + onData: vi.fn((cb: (data: string) => void) => { + onDataListeners.push(cb); + return vi.fn(); + }), + onScrollback: vi.fn((cb: (data: string) => void) => { + onDataListeners.push(cb); + return vi.fn(); + }), + })); + + try { + render(); + + await waitFor(() => expect(screen.getByTestId("terminal-font-size-value")).toHaveTextContent("12px")); + await waitFor(() => { + const modal = screen.getByTestId("terminal-modal"); + expect(modal).toHaveClass("terminal-modal--mobile"); + expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("454px"); + expect(modal.style.getPropertyValue("--vv-height")).toBe("390px"); + expect(modal.style.getPropertyValue("--vv-width")).toBe("390px"); + }); + await waitFor(() => expect(onDataListeners.length).toBeGreaterThan(0)); + + act(() => { + for (const cb of onDataListeners) { + cb("❯ test\r\n❯ ls\r\nAGENTS.md README.md package.json  main\r\n"); + } + for (const cb of listeners.resize) cb(); + }); + + await waitFor(() => expect(mockTerminalInstance.write).toHaveBeenCalledWith(expect.stringContaining("test"))); + await waitFor(() => expect(mockTerminalInstance.write).toHaveBeenCalledWith(expect.stringContaining("AGENTS.md"))); + await waitFor(() => expect(resizeForInitialIOSKeyboard).toHaveBeenCalledWith(80, 24)); + expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string); + expect(mockTerminalInstance.options.fontSize).toBe(12); + } finally { + helperTextarea.remove(); + Object.defineProperty(window, "screen", { configurable: true, value: originalScreen }); + window.localStorage.removeItem(TERMINAL_PREFERENCES_KEY); + } + }); + + it("fits initial iOS keyboard-open 10px terminal when layout height already shrank", async () => { + (window as any).ontouchstart = null; + window.localStorage.setItem(TERMINAL_FONT_SIZE_KEY, "10"); + const originalScreen = window.screen; + const { mockVV } = simulateIOSSafari(true, 390); + Object.defineProperty(mockVV, "width", { value: 390, writable: true, configurable: true }); + Object.defineProperty(window, "innerWidth", { value: 390, writable: true, configurable: true }); + Object.defineProperty(document.documentElement, "clientHeight", { + value: 390, + configurable: true, + }); + Object.defineProperty(window, "screen", { + configurable: true, + value: { width: 390, height: 844 }, + }); + const helperTextarea = document.createElement("textarea"); + document.body.appendChild(helperTextarea); + helperTextarea.focus(); + const resizeForInitialIOSSmallFont = vi.fn(); + mockUseTerminal.mockReturnValue(createMockTerminalState({ + connectionStatus: "connected", + resize: resizeForInitialIOSSmallFont, + })); + + try { + render(); + + await waitFor(() => expect(screen.getByTestId("terminal-font-size-value")).toHaveTextContent("10px")); + await waitFor(() => { + const modal = screen.getByTestId("terminal-modal"); + expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("454px"); + expect(modal.style.getPropertyValue("--vv-height")).toBe("390px"); + expect(modal.style.getPropertyValue("--vv-width")).toBe("390px"); + }); + await waitFor(() => expect(resizeForInitialIOSSmallFont).toHaveBeenCalledWith(80, 24)); + expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string); + expect(mockTerminalInstance.options.fontSize).toBe(10); + } finally { + helperTextarea.remove(); + Object.defineProperty(window, "screen", { configurable: true, value: originalScreen }); + window.localStorage.removeItem(TERMINAL_FONT_SIZE_KEY); + } + }); + it("fits Android keyboard-open 10px terminal to visual viewport width before any repair event", async () => { (window as any).ontouchstart = null; window.localStorage.setItem(TERMINAL_FONT_SIZE_KEY, "10"); 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..6f58a95248 100644 --- a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx @@ -4,6 +4,7 @@ import { ProjectDefaultWorkflowField } from "../../WorkflowSelector"; import { WorkflowIcon } from "../../WorkflowIcon"; import { TrackingRepoSelect, type TrackingRepoOption } from "../../TrackingRepoSelect"; import { fetchWorkflows } from "../../../api"; +import { clearAllLocalCache } from "../../../utils/swrCache"; import type { ToastType } from "../../../hooks/useToast"; import type { SectionBaseProps } from "./context"; import { useTranslation } from "react-i18next"; @@ -58,6 +59,22 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast }; }); }; + /* + FNXC:SettingsGeneral 2026-07-02-00:00: + User-facing escape hatch for localStorage quota exhaustion. The dashboard accumulates per-project + SWR hydration caches (chat sessions, rooms, tasks, board snapshots) whose stale entries linger + indefinitely. clearAllLocalCache wipes all Fusion-owned browser data (caches + UI prefs) while + preserving the auth token so the session survives the reload. Tasks and project settings live + server-side and are unaffected. + */ + const handleClearLocalData = () => { + const confirmed = window.confirm(t("settings.general.clearLocalDataConfirm", "Clear all cached data and UI preferences stored in this browser? This frees space used by stale chat, task, and board caches. Your tasks and project settings are safe (stored server-side). The dashboard will reload.")); + if (!confirmed) { + return; + } + clearAllLocalCache(); + window.location.reload(); + }; return (<> {scopeBanner}

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

@@ -283,18 +300,44 @@ 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. */} +
+ + {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.")} +
+
+
+ {/* + 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")}

- - 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.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.")} +
+ +
); } 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.")} +
+