fix(dashboard): garbage-collect stale SWR caches and add Clear local data button (#1877)
## Problem Users reported the dashboard running out of browser `localStorage` space. Root cause: stale SWR hydration entries were **never garbage-collected**. - `readCache` returned `null` for stale entries but left the bytes on disk. - No sweep existed for abandoned caches — per-chat-session and per-chat-room message caches (up to 500 KB each, capped by `DEFAULT_MAX_BYTES`) from old conversations accumulated indefinitely. - No user-facing escape hatch to clear browser data. With a typical ~5 MB origin quota, ~10 full chat-session caches could exhaust storage. ## Fix — three layers **1. Lazy GC in `readCache`** (`swrCache.ts`) When a reader passes `maxAgeMs` and the entry is stale, `readCache` now deletes the key instead of just returning `null`. Behavior-preserving — every reader already treats a stale entry as a miss and re-fetches — so the only observable change is freed quota on active paths. **2. Boot sweep `pruneStaleCacheEntries()`** (`swrCache.ts` → `DashboardLoader.tsx`) Called once from `DashboardLoader` on mount, before hydration hooks read their caches. Iterates all `kb-dashboard-*` keys and removes any envelope older than 24 h (`SWR_LONG_MAX_AGE_MS`). Since 24 h is the longest TTL any consumer uses, a pruned entry was already a miss for every reader. This catches the real space hogs: abandoned per-session/per-room message caches that are never read again. **3. Settings → General "Clear local data"** (`GeneralSection.tsx`) A new **Browser Data** panel with a button that confirms, then calls `clearAllLocalCache()` — wipes all Fusion-owned browser data (`kb-*`, `kb:*`, `fn-agent-log-*`, `fusion*`) while **preserving `fn.authToken`** so the session survives the reload. Tasks and project settings live server-side and are unaffected. ## Supporting fix The test environment's `localStorage` mock (`vitest.setup.ts`) was incomplete — it lacked `length` and `key()`, so any iteration-based storage code silently no-op'd in tests. Added both to make it a faithful `Storage` implementation. ## Verification | Check | Result | |---|---| | swrCache tests | 14/14 ✅ | | DashboardLoader tests | 6/6 ✅ | | settings-sections tests | 15/15 ✅ | | auth + useCurrentProject + useChat (regression) | 148/148 ✅ | | Typecheck (`tsc --noEmit`) | clean ✅ | | Lint (ESLint, 5 changed files) | clean ✅ | | Changeset format (`pnpm check:changesets`) | valid ✅ | ## Files - `packages/dashboard/app/utils/swrCache.ts` — lazy GC + `pruneStaleCacheEntries` + `clearAllLocalCache` - `packages/dashboard/app/components/DashboardLoader.tsx` — boot sweep call - `packages/dashboard/app/components/settings/sections/GeneralSection.tsx` — Clear local data button - `packages/dashboard/app/utils/__tests__/swrCache.test.ts` — updated + new tests - `packages/dashboard/vitest.setup.ts` — complete the localStorage mock - `.changeset/fix-dashboard-localstorage-quota-exhaustion.md` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a GitLab integration enable/disable toggle in Settings, with collapsible GitLab sections and preserved saved credentials for re-enable. * Added a “Clear local data” option in Settings to remove cached browser data while keeping sign-in state. * Added bundled support for Linear Import in the plugin manager. * **Bug Fixes** * Prevented dashboard storage quota issues by trimming stale cached data automatically. * Improved mobile chat header and iOS terminal behavior for better spacing and keyboard handling. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -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.
|
||||
7
.changeset/fn-7453-gitlab-enable-disclosure.md
Normal file
7
.changeset/fn-7453-gitlab-enable-disclosure.md
Normal file
@@ -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.
|
||||
7
.changeset/fn-7454-linear-plugin-visibility.md
Normal file
7
.changeset/fn-7454-linear-plugin-visibility.md
Normal file
@@ -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.
|
||||
7
.changeset/fn-7455-mobile-chat-header-layout.md
Normal file
7
.changeset/fn-7455-mobile-chat-header-layout.md
Normal file
@@ -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.
|
||||
7
.changeset/fn-7456-ios-mobile-terminal-spacing.md
Normal file
7
.changeset/fn-7456-ios-mobile-terminal-spacing.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
<!-- FNXC:ChatViewDocs 2026-07-01-00:00: Task-detail planner chats are intentionally hidden from the common Direct feed by default after issue #1850; Settings keeps an opt-in for operators who want populated task-planner sessions restored without adding a mandatory Tasks tab. -->
|
||||
@@ -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.
|
||||
<!-- FNXC:CommandCenter 2026-06-21-07:07: FN-6722 requires the GitHub area to expose a resolved-issue detail list from local task-store analytics only, with exact close timestamps flagged when reconciliation populated `sourceIssueClosedAt` and approximation called out otherwise. -->
|
||||
- **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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 `<instance>/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 `<instance>/api/v4`) | Optional project GitLab REST API base URL. Blank/unset inherits global `gitlabApiBaseUrl`; if still unset, Fusion derives `<instance>/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. |
|
||||
|
||||
@@ -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: <web_url>`, 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: <web_url>`, 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: <url>`, 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.
|
||||
|
||||
|
||||
@@ -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" } }],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 `<instance>/api/v4`. */
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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", () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
*/}
|
||||
<button className="btn-icon chat-back-btn" onClick={handleBack} data-testid="chat-back-btn" aria-label={t("chat.backToConversations", "Back to conversations")}>
|
||||
<ChevronLeft size={16} />
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { consumeVersionUpdateFlag } from "../versionCheck";
|
||||
import { SWR_CACHE_KEYS, clearCache } from "../utils/swrCache";
|
||||
import { SWR_CACHE_KEYS, clearCache, pruneStaleCacheEntries } from "../utils/swrCache";
|
||||
import "./DashboardLoader.css";
|
||||
|
||||
export type DashboardLoaderStage = "projects" | "project" | "tasks" | "ready";
|
||||
@@ -44,6 +44,14 @@ function getStepState(stepId: LoaderStep["id"], stage: DashboardLoaderStage): "d
|
||||
export function DashboardLoader({ stage }: DashboardLoaderProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [isVersionUpdate] = useState(() => {
|
||||
/*
|
||||
FNXC:SwrCache 2026-07-02-00:00:
|
||||
Prune stale SWR hydration entries once on boot, before any hydration hook reads its cache.
|
||||
Removes per-session/per-room caches older than 24h (abandoned conversations) that readCache's
|
||||
lazy GC never reaches because nobody reads them again. This is the fix for localStorage quota
|
||||
exhaustion reported by users with many projects and chat sessions.
|
||||
*/
|
||||
pruneStaleCacheEntries();
|
||||
const versionUpdated = consumeVersionUpdateFlag();
|
||||
if (versionUpdated) {
|
||||
clearCache(SWR_CACHE_KEYS.TASKS_PREFIX);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
apiImportGitLabProjectIssue,
|
||||
apiImportGitLabGroupIssue,
|
||||
apiImportGitLabMergeRequest,
|
||||
fetchSettings,
|
||||
fetchGitRemotes,
|
||||
type GitHubIssue,
|
||||
type GitHubPull,
|
||||
@@ -328,6 +329,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
const [gitlabGroup, setGitlabGroup] = useState("");
|
||||
const [gitlabItems, setGitlabItems] = useState<GitLabImportItem[]>([]);
|
||||
const [selectedGitlabKey, setSelectedGitlabKey] = useState<string | null>(null);
|
||||
const [gitlabEnabled, setGitlabEnabled] = useState(true);
|
||||
|
||||
// Tab state
|
||||
const [activeTab, setActiveTab] = useState<TabType>("issues");
|
||||
@@ -585,9 +587,27 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
}
|
||||
}, [owner, repo]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!isOpen) return () => { cancelled = true; };
|
||||
fetchSettings(projectId, { forceFresh: true })
|
||||
.then((settings) => {
|
||||
if (!cancelled) setGitlabEnabled(settings.gitlabEnabled !== false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setGitlabEnabled(true);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [isOpen, projectId]);
|
||||
|
||||
const selectedGitlabItem = gitlabItems.find((item) => `${item.resourceKind}:${item.projectId ?? item.projectPath ?? ""}:${item.iid}` === selectedGitlabKey) ?? null;
|
||||
|
||||
const handleLoadGitLab = useCallback(async () => {
|
||||
if (!gitlabEnabled) {
|
||||
setError(t("git.gitlabDisabled", "GitLab integration is disabled in Settings. Enable it to fetch or import GitLab resources; saved configuration is preserved."));
|
||||
return;
|
||||
}
|
||||
const project = gitlabProject.trim();
|
||||
const group = gitlabGroup.trim();
|
||||
if ((gitlabResource === "project_issue" || gitlabResource === "merge_request") && !project) {
|
||||
@@ -616,10 +636,10 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [gitlabProject, gitlabGroup, gitlabResource, labels, t]);
|
||||
}, [gitlabEnabled, gitlabProject, gitlabGroup, gitlabResource, labels, t]);
|
||||
|
||||
const handleImportGitLab = useCallback(async () => {
|
||||
if (!selectedGitlabItem) return;
|
||||
if (!selectedGitlabItem || !gitlabEnabled) return;
|
||||
setImporting(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -636,7 +656,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}, [selectedGitlabItem, gitlabResource, gitlabProject, gitlabGroup, projectId, onImport, isMobile, mobileView, t]);
|
||||
}, [selectedGitlabItem, gitlabEnabled, gitlabResource, gitlabProject, gitlabGroup, projectId, onImport, isMobile, mobileView, t]);
|
||||
|
||||
// Auto-load data when owner and repo are set and valid
|
||||
useEffect(() => {
|
||||
@@ -1085,7 +1105,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
<div className="modal-body github-import-modal__body">
|
||||
<div className="github-import-provider" role="group" aria-label={t("git.providerAriaLabel", "Import provider")}>
|
||||
<button type="button" className={`github-import-tab ${provider === "github" ? "active" : ""}`} aria-pressed={provider === "github"} onClick={() => setProvider("github")} disabled={loading || importing}>GitHub</button>
|
||||
<button type="button" className={`github-import-tab ${provider === "gitlab" ? "active" : ""}`} aria-pressed={provider === "gitlab"} onClick={() => setProvider("gitlab")} disabled={loading || importing}>GitLab</button>
|
||||
<button type="button" className={`github-import-tab ${provider === "gitlab" ? "active" : ""}`} aria-pressed={provider === "gitlab"} onClick={() => setProvider("gitlab")} disabled={loading || importing} title={gitlabEnabled ? undefined : t("git.gitlabDisabledTabTitle", "GitLab integration is disabled in Settings")}>GitLab</button>
|
||||
</div>
|
||||
{provider === "github" ? (
|
||||
<>
|
||||
@@ -1625,23 +1645,24 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
<div className="github-import-gitlab" data-testid="gitlab-import-panel">
|
||||
<div className="github-import-tabs" role="tablist" aria-label={t("git.gitlabResourceAriaLabel", "GitLab resource type")}>
|
||||
{(["project_issue", "group_issue", "merge_request"] as GitLabResourceTab[]).map((resource) => (
|
||||
<button key={resource} type="button" role="tab" aria-selected={gitlabResource === resource} className={`github-import-tab ${gitlabResource === resource ? "active" : ""}`} onClick={() => { setGitlabResource(resource); setGitlabItems([]); setSelectedGitlabKey(null); }} disabled={loading || importing}>
|
||||
<button key={resource} type="button" role="tab" aria-selected={gitlabResource === resource} className={`github-import-tab ${gitlabResource === resource ? "active" : ""}`} onClick={() => { setGitlabResource(resource); setGitlabItems([]); setSelectedGitlabKey(null); }} disabled={loading || importing || !gitlabEnabled}>
|
||||
{resource === "project_issue" ? t("git.gitlabProjectIssues", "Project issues") : resource === "group_issue" ? t("git.gitlabGroupIssues", "Group issues") : t("git.gitlabMergeRequests", "Merge requests")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="github-import-toolbar" role="toolbar" aria-label={t("git.gitlabToolbarAriaLabel", "GitLab import controls")}>
|
||||
{gitlabResource !== "group_issue" ? (
|
||||
<input className="input" value={gitlabProject} onChange={(event) => 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} />
|
||||
<input className="input" value={gitlabProject} onChange={(event) => 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} />
|
||||
) : (
|
||||
<input className="input" value={gitlabGroup} onChange={(event) => 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} />
|
||||
<input className="input" value={gitlabGroup} onChange={(event) => 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} />
|
||||
)}
|
||||
<input className="input" value={labels} onChange={(event) => setLabels(event.target.value)} placeholder={t("git.filterByLabelsPlaceholder", "Filter: bug,enhancement…")} aria-label={t("git.filterGitLabByLabels", "Filter GitLab resources by labels")} disabled={loading || importing} />
|
||||
<button type="button" className="btn btn-primary" onClick={handleLoadGitLab} disabled={loading || importing || (gitlabResource === "group_issue" ? !gitlabGroup.trim() : !gitlabProject.trim())}>
|
||||
<input className="input" value={labels} onChange={(event) => 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} />
|
||||
<button type="button" className="btn btn-primary" onClick={handleLoadGitLab} disabled={!gitlabEnabled || loading || importing || (gitlabResource === "group_issue" ? !gitlabGroup.trim() : !gitlabProject.trim())}>
|
||||
{loading ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
|
||||
{t("git.load", "Load")}
|
||||
</button>
|
||||
</div>
|
||||
{!gitlabEnabled && <div className="github-import-state github-import-state--idle" data-testid="gitlab-import-disabled"><strong>{t("git.gitlabDisabledHeading", "GitLab integration disabled")}</strong><span>{t("git.gitlabDisabledHint", "Enable GitLab integration in Settings to fetch or import GitLab resources. Saved GitLab URLs and tokens remain configured.")}</span></div>}
|
||||
{error && <div className="github-import-state github-import-state--error" data-testid="gitlab-import-error"><strong>{t("git.gitlabError", "GitLab import unavailable")}</strong><span>{error}</span></div>}
|
||||
{gitlabItems.length === 0 && !loading && !error ? <div className="github-import-state github-import-state--idle" data-testid="gitlab-import-empty"><strong>{t("git.gitlabNoResources", "No GitLab resources loaded")}</strong><span>{t("git.gitlabLoadHint", "Enter a project or group and load resources from the configured GitLab instance.")}</span></div> : null}
|
||||
<div className="github-import-gitlab__workspace">
|
||||
@@ -1663,7 +1684,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
<h4>{selectedGitlabItem.resourceKind === "merge_request" ? "!" : "#"}{selectedGitlabItem.iid} {selectedGitlabItem.title}</h4>
|
||||
<div className="preview-meta-row"><span className={`preview-state-badge preview-state-badge--${selectedGitlabItem.state}`}>{selectedGitlabItem.state}</span><a href={selectedGitlabItem.webUrl} target="_blank" rel="noopener noreferrer">{t("git.openSource", "Open source")}</a></div>
|
||||
<MailboxMessageContent className="preview-body preview-body--markdown" content={selectedGitlabItem.description?.trim() || t("git.noDescription", "(no description)")} testId="gitlab-import-preview-body" />
|
||||
<button type="button" className="btn btn-primary" onClick={handleImportGitLab} disabled={importing || importedUrls.has(selectedGitlabItem.webUrl)}>{importing ? <Loader2 size={14} className="spin" /> : t("git.import", "Import")}</button>
|
||||
<button type="button" className="btn btn-primary" onClick={handleImportGitLab} disabled={!gitlabEnabled || importing || importedUrls.has(selectedGitlabItem.webUrl)}>{importing ? <Loader2 size={14} className="spin" /> : t("git.import", "Import")}</button>
|
||||
</div>
|
||||
) : <div className="github-import-state github-import-state--idle" data-testid="gitlab-import-preview-empty"><strong>{t("git.gitlabNoSelection", "No GitLab resource selected")}</strong><span>{t("git.gitlabNoSelectionHint", "Choose a resource from the list to preview it.")}</span></div>}
|
||||
</div>
|
||||
@@ -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
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<GlobalSettings, "gitlabEnabled" | "gitlabInstanceUrl" | "gitlabApiBaseUrl" | "gitlabAuthToken" | "gitlabAuthTokenType">;
|
||||
|
||||
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<Settings> } | null>(null);
|
||||
const [globalGitlabSettings, setGlobalGitlabSettings] = useState<GlobalGitlabSettings | null>(null);
|
||||
// Track initial scoped values for null-as-delete semantics on project overrides
|
||||
const [initialScopedValues, setInitialScopedValues] = useState<{ global: GlobalSettings; project: Partial<Settings> } | 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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
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))");
|
||||
|
||||
@@ -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(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
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([
|
||||
|
||||
@@ -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(<PluginManager addToast={addToast} />);
|
||||
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);
|
||||
|
||||
@@ -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(<PluginManager addToast={addToast} />);
|
||||
|
||||
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(<PluginManager addToast={addToast} />);
|
||||
|
||||
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(<PluginManager addToast={addToast} />);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 <instance>\/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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
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");
|
||||
|
||||
@@ -73,6 +73,7 @@ const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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}
|
||||
<h4 className="settings-section-heading">{t("settings.general.general", "General")}</h4>
|
||||
@@ -283,18 +300,44 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast
|
||||
</div>
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.general.gitLabConfiguration", "GitLab Configuration")}</h4>
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<details className="settings-gitlab-disclosure" data-testid="project-gitlab-configuration-disclosure">
|
||||
<summary>
|
||||
<span className="settings-gitlab-disclosure__title">{t("settings.general.gitLabConfiguration", "GitLab Configuration")}</span>
|
||||
<label className="checkbox-label settings-gitlab-disclosure__toggle" htmlFor="gitlabEnabled" onClick={(event) => event.stopPropagation()}>
|
||||
<input id="gitlabEnabled" type="checkbox" checked={form.gitlabEnabled !== false} onChange={(e) => setForm((f) => ({ ...f, gitlabEnabled: e.target.checked }))}/>
|
||||
{t("settings.general.enableGitLabIntegration", "Enable GitLab integration")}
|
||||
</label>
|
||||
</summary>
|
||||
<small className="settings-description">{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.")}</small>
|
||||
<div className="settings-gitlab-disclosure__body" aria-disabled={form.gitlabEnabled === false}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="gitlabInstanceUrl">{t("settings.general.gitLabInstanceUrl", "GitLab instance URL")}</label>
|
||||
<input id="gitlabInstanceUrl" className="input" type="url" placeholder="https://gitlab.com" value={form.gitlabInstanceUrl ?? ""} disabled={form.gitlabEnabled === false} onChange={(e) => setForm((f) => ({ ...f, gitlabInstanceUrl: e.target.value || undefined }))}/>
|
||||
<small>{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.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="gitlabApiBaseUrl">{t("settings.general.gitLabApiBaseUrlOptional", "GitLab API base URL (optional / advanced)")}</label>
|
||||
<input id="gitlabApiBaseUrl" className="input" type="url" placeholder="https://gitlab.com/api/v4" value={form.gitlabApiBaseUrl ?? ""} disabled={form.gitlabEnabled === false} onChange={(e) => setForm((f) => ({ ...f, gitlabApiBaseUrl: e.target.value || undefined }))}/>
|
||||
<small>{t("settings.general.gitLabApiBaseUrlHint", "Blank derives <instance>/api/v4. Override only when a self-managed GitLab API is served from a different absolute http:// or https:// URL.")}</small>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.general.browserData", "Browser Data")}</h4>
|
||||
<div className="form-group">
|
||||
<label htmlFor="gitlabInstanceUrl">{t("settings.general.gitLabInstanceUrl", "GitLab instance URL")}</label>
|
||||
<input id="gitlabInstanceUrl" className="input" type="url" placeholder="https://gitlab.com" value={form.gitlabInstanceUrl ?? ""} onChange={(e) => setForm((f) => ({ ...f, gitlabInstanceUrl: e.target.value || undefined }))}/>
|
||||
<small>{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.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="gitlabApiBaseUrl">{t("settings.general.gitLabApiBaseUrlOptional", "GitLab API base URL (optional / advanced)")}</label>
|
||||
<input id="gitlabApiBaseUrl" className="input" type="url" placeholder="https://gitlab.com/api/v4" value={form.gitlabApiBaseUrl ?? ""} onChange={(e) => setForm((f) => ({ ...f, gitlabApiBaseUrl: e.target.value || undefined }))}/>
|
||||
<small>{t("settings.general.gitLabApiBaseUrlHint", "Blank derives <instance>/api/v4. Override only when a self-managed GitLab API is served from a different absolute http:// or https:// URL.")}</small>
|
||||
<label>{t("settings.general.clearLocalData", "Clear local data")}</label>
|
||||
<small>{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.")}</small>
|
||||
<div style={{ marginTop: "var(--space-sm)" }}>
|
||||
<button type="button" className="btn btn-sm" onClick={handleClearLocalData}>{t("settings.general.clearLocalDataButton", "Clear local data")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</>);
|
||||
}
|
||||
|
||||
@@ -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<GlobalSettings, "gitlabEnabled" | "gitlabInstanceUrl" | "gitlabApiBaseUrl" | "gitlabAuthToken" | "gitlabAuthTokenType"> | null;
|
||||
onGlobalGitlabSettingsChange: (patch: Partial<Pick<GlobalSettings, "gitlabEnabled" | "gitlabInstanceUrl" | "gitlabApiBaseUrl" | "gitlabAuthToken" | "gitlabAuthTokenType">>) => 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}
|
||||
<h4 className="settings-section-heading">{t("settings.globalGeneral.general", "General")}</h4>
|
||||
@@ -21,35 +25,44 @@ export function GlobalGeneralSection({ scopeBanner, form, setForm, globalTrackin
|
||||
<small>{t("settings.globalGeneral.projectsInheritThisValueWhenTheyDoNot", "Projects inherit this value when they do not set a project default tracking repo.")}</small>
|
||||
</div>
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<div className="form-group">
|
||||
<label htmlFor="globalGitlabInstanceUrl">{t("settings.globalGeneral.gitLabInstanceUrl", "Global GitLab instance URL")}</label>
|
||||
<input id="globalGitlabInstanceUrl" className="input" type="url" placeholder="https://gitlab.com" value={form.gitlabInstanceUrl ?? ""} onChange={(e) => setForm((f) => ({ ...f, gitlabInstanceUrl: e.target.value || undefined }))}/>
|
||||
<small>{t("settings.globalGeneral.gitLabInstanceUrlHint", "Blank defaults to GitLab.com. Projects inherit this self-managed GitLab URL unless they set their own project value.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="globalGitlabApiBaseUrl">{t("settings.globalGeneral.gitLabApiBaseUrlOptional", "Global GitLab API base URL (optional / advanced)")}</label>
|
||||
<input id="globalGitlabApiBaseUrl" className="input" type="url" placeholder="https://gitlab.com/api/v4" value={form.gitlabApiBaseUrl ?? ""} onChange={(e) => setForm((f) => ({ ...f, gitlabApiBaseUrl: e.target.value || undefined }))}/>
|
||||
<small>{t("settings.globalGeneral.gitLabApiBaseUrlHint", "Blank derives <instance>/api/v4. Override only for self-managed GitLab API gateways that use a different absolute http:// or https:// URL.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="globalGitlabAuthTokenType">{t("settings.globalGeneral.gitLabTokenType", "Global GitLab token type")}</label>
|
||||
<select id="globalGitlabAuthTokenType" className="select" value={form.gitlabAuthTokenType ?? "personal"} onChange={(e) => setForm((f) => ({ ...f, gitlabAuthTokenType: e.target.value as "personal" | "project" | "group" }))}>
|
||||
<option value="personal">{t("settings.globalGeneral.gitLabPersonalAccessToken", "Personal access token")}</option>
|
||||
<option value="project">{t("settings.globalGeneral.gitLabProjectAccessToken", "Project access token")}</option>
|
||||
<option value="group">{t("settings.globalGeneral.gitLabGroupAccessToken", "Group access token")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="globalGitlabAuthToken">{t("settings.globalGeneral.gitLabAccessToken", "Global GitLab access token")}</label>
|
||||
<input id="globalGitlabAuthToken" className="input" type="password" autoComplete="off" value={form.gitlabAuthToken ?? ""} onChange={(e) => setForm((f) => ({ ...f, gitlabAuthToken: e.target.value || undefined }))}/>
|
||||
<small className="settings-description">{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.")}</small>
|
||||
</div>
|
||||
<details className="settings-gitlab-disclosure" data-testid="global-gitlab-configuration-disclosure">
|
||||
<summary>
|
||||
<span className="settings-gitlab-disclosure__title">{t("settings.globalGeneral.gitLabConfiguration", "GitLab Configuration")}</span>
|
||||
<label className="checkbox-label settings-gitlab-disclosure__toggle" htmlFor="globalGitlabEnabled" onClick={(event) => event.stopPropagation()}>
|
||||
<input id="globalGitlabEnabled" type="checkbox" checked={globalGitlab.gitlabEnabled !== false} onChange={(e) => onGlobalGitlabSettingsChange({ gitlabEnabled: e.target.checked })}/>
|
||||
{t("settings.globalGeneral.enableGitLabIntegration", "Enable GitLab integration")}
|
||||
</label>
|
||||
</summary>
|
||||
<small className="settings-description">{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.")}</small>
|
||||
<div className="settings-gitlab-disclosure__body" aria-disabled={globalGitlab.gitlabEnabled === false}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="globalGitlabInstanceUrl">{t("settings.globalGeneral.gitLabInstanceUrl", "Global GitLab instance URL")}</label>
|
||||
<input id="globalGitlabInstanceUrl" className="input" type="url" placeholder="https://gitlab.com" value={globalGitlab.gitlabInstanceUrl ?? ""} disabled={globalGitlab.gitlabEnabled === false} onChange={(e) => onGlobalGitlabSettingsChange({ gitlabInstanceUrl: e.target.value || undefined })}/>
|
||||
<small>{t("settings.globalGeneral.gitLabInstanceUrlHint", "Blank defaults to GitLab.com. Projects inherit this self-managed GitLab URL unless they set their own project value.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="globalGitlabApiBaseUrl">{t("settings.globalGeneral.gitLabApiBaseUrlOptional", "Global GitLab API base URL (optional / advanced)")}</label>
|
||||
<input id="globalGitlabApiBaseUrl" className="input" type="url" placeholder="https://gitlab.com/api/v4" value={globalGitlab.gitlabApiBaseUrl ?? ""} disabled={globalGitlab.gitlabEnabled === false} onChange={(e) => onGlobalGitlabSettingsChange({ gitlabApiBaseUrl: e.target.value || undefined })}/>
|
||||
<small>{t("settings.globalGeneral.gitLabApiBaseUrlHint", "Blank derives <instance>/api/v4. Override only for self-managed GitLab API gateways that use a different absolute http:// or https:// URL.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="globalGitlabAuthTokenType">{t("settings.globalGeneral.gitLabTokenType", "Global GitLab token type")}</label>
|
||||
<select id="globalGitlabAuthTokenType" className="select" value={globalGitlab.gitlabAuthTokenType ?? "personal"} disabled={globalGitlab.gitlabEnabled === false} onChange={(e) => onGlobalGitlabSettingsChange({ gitlabAuthTokenType: e.target.value as "personal" | "project" | "group" })}>
|
||||
<option value="personal">{t("settings.globalGeneral.gitLabPersonalAccessToken", "Personal access token")}</option>
|
||||
<option value="project">{t("settings.globalGeneral.gitLabProjectAccessToken", "Project access token")}</option>
|
||||
<option value="group">{t("settings.globalGeneral.gitLabGroupAccessToken", "Group access token")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="globalGitlabAuthToken">{t("settings.globalGeneral.gitLabAccessToken", "Global GitLab access token")}</label>
|
||||
<input id="globalGitlabAuthToken" className="input" type="password" autoComplete="off" value={globalGitlab.gitlabAuthToken ?? ""} disabled={globalGitlab.gitlabEnabled === false} onChange={(e) => onGlobalGitlabSettingsChange({ gitlabAuthToken: e.target.value || undefined })}/>
|
||||
<small className="settings-description">{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.")}</small>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<CliBinaryPanel />
|
||||
<div className="form-group">
|
||||
<label htmlFor="dismissModalsOnOutsideClick" className="checkbox-label">
|
||||
|
||||
@@ -296,25 +296,33 @@ export function MergeSection({ scopeBanner, form, setForm, integrationBranchOpti
|
||||
</div>)}
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.merge.gitLabAuthentication", "GitLab Authentication")}</h4>
|
||||
{/**
|
||||
* 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.
|
||||
*/}
|
||||
<div className="form-group">
|
||||
<label htmlFor="gitlabAuthTokenType">{t("settings.merge.gitLabTokenType", "GitLab token type")}</label>
|
||||
<select id="gitlabAuthTokenType" className="select" value={form.gitlabAuthTokenType ?? "personal"} onChange={(e) => setForm((f) => ({ ...f, gitlabAuthTokenType: e.target.value as "personal" | "project" | "group" }))}>
|
||||
<option value="personal">{t("settings.merge.gitLabPersonalAccessToken", "Personal access token")}</option>
|
||||
<option value="project">{t("settings.merge.gitLabProjectAccessToken", "Project access token")}</option>
|
||||
<option value="group">{t("settings.merge.gitLabGroupAccessToken", "Group access token")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="gitlabAuthToken">{t("settings.merge.gitLabAccessToken", "GitLab access token")}</label>
|
||||
<input id="gitlabAuthToken" type="password" className="input" autoComplete="off" value={form.gitlabAuthToken ?? ""} onChange={(e) => setForm((f) => ({ ...f, gitlabAuthToken: e.target.value || undefined }))}/>
|
||||
<small className="settings-description">{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.")}</small>
|
||||
</div>
|
||||
<details className="settings-option-details">
|
||||
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
|
||||
<small>{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.")}</small>
|
||||
<details className="settings-gitlab-disclosure" data-testid="project-gitlab-authentication-disclosure">
|
||||
<summary>
|
||||
<span className="settings-gitlab-disclosure__title">{t("settings.merge.gitLabAuthentication", "GitLab Authentication")}</span>
|
||||
<label className="checkbox-label settings-gitlab-disclosure__toggle" htmlFor="mergeGitlabEnabled" onClick={(event) => event.stopPropagation()}>
|
||||
<input id="mergeGitlabEnabled" type="checkbox" checked={form.gitlabEnabled !== false} onChange={(e) => setForm((f) => ({ ...f, gitlabEnabled: e.target.checked }))}/>
|
||||
{t("settings.merge.enableGitLabIntegration", "Enable GitLab integration")}
|
||||
</label>
|
||||
</summary>
|
||||
<small className="settings-description">{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.")}</small>
|
||||
<div className="settings-gitlab-disclosure__body" aria-disabled={form.gitlabEnabled === false}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="gitlabAuthTokenType">{t("settings.merge.gitLabTokenType", "GitLab token type")}</label>
|
||||
<select id="gitlabAuthTokenType" className="select" value={form.gitlabAuthTokenType ?? "personal"} disabled={form.gitlabEnabled === false} onChange={(e) => setForm((f) => ({ ...f, gitlabAuthTokenType: e.target.value as "personal" | "project" | "group" }))}>
|
||||
<option value="personal">{t("settings.merge.gitLabPersonalAccessToken", "Personal access token")}</option>
|
||||
<option value="project">{t("settings.merge.gitLabProjectAccessToken", "Project access token")}</option>
|
||||
<option value="group">{t("settings.merge.gitLabGroupAccessToken", "Group access token")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="gitlabAuthToken">{t("settings.merge.gitLabAccessToken", "GitLab access token")}</label>
|
||||
<input id="gitlabAuthToken" type="password" className="input" autoComplete="off" value={form.gitlabAuthToken ?? ""} disabled={form.gitlabEnabled === false} onChange={(e) => setForm((f) => ({ ...f, gitlabAuthToken: e.target.value || undefined }))}/>
|
||||
<small className="settings-description">{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.")}</small>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<div className="form-group">
|
||||
<label htmlFor="includeTaskIdInCommit" className="checkbox-label">
|
||||
|
||||
@@ -105,6 +105,28 @@ function isCollapsedRestoreViewportSample(baselineHeight: number): boolean {
|
||||
return window.visualViewport.height >= baselineHeight - IOS_VIEWPORT_SHRINK_MIN_PX;
|
||||
}
|
||||
|
||||
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 getKeyboardMetrics(
|
||||
previousMetrics: KeyboardMetrics = CLOSED_KEYBOARD_METRICS,
|
||||
{ bypassImpossibleSampleHold = false }: { bypassImpossibleSampleHold?: boolean } = {},
|
||||
@@ -159,7 +181,14 @@ function getKeyboardMetrics(
|
||||
// iOS fallback (window.innerHeight shrinks with keyboard). Same focused
|
||||
// requirement as above — the dismissal animation otherwise leaves the
|
||||
// gap > the open-threshold for the duration of the slide.
|
||||
const baselineHeight = getBaselineViewportHeight();
|
||||
/*
|
||||
FNXC:Terminal 2026-07-02-18:18:
|
||||
SessionTerminal uses this shared hook, so it has the same initial iOS keyboard-open failure mode as TerminalModal: the first focused sample can have `innerHeight`, `clientHeight`, and `visualViewport.height` already shrunk. Use a guarded screen-derived baseline only when the missing height is large enough to be a real keyboard, keeping the mobile input bar and xterm resize bridge correct at 10px/12px before later viewport events can repair spacing.
|
||||
*/
|
||||
const screenBaselineCandidate = focused
|
||||
? getScreenViewportBaselineCandidate(getCurrentViewportWidth(), vv.height)
|
||||
: null;
|
||||
const baselineHeight = Math.max(getBaselineViewportHeight(), screenBaselineCandidate ?? 0);
|
||||
const gap = Math.max(0, baselineHeight - vv.offsetTop - vv.height);
|
||||
|
||||
if (gap >= IOS_FALLBACK_MIN_GAP_PX && focused) {
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
SWR_DEFAULT_MAX_AGE_MS,
|
||||
SWR_LONG_MAX_AGE_MS,
|
||||
SWR_TASKS_MAX_AGE_MS,
|
||||
clearAllLocalCache,
|
||||
clearCache,
|
||||
pruneStaleCacheEntries,
|
||||
readCache,
|
||||
writeCache,
|
||||
} from "../swrCache";
|
||||
@@ -35,15 +37,31 @@ describe("swrCache", () => {
|
||||
expect(raw.data).toEqual(payload);
|
||||
});
|
||||
|
||||
it("respects maxAgeMs for enveloped payloads", () => {
|
||||
it("respects maxAgeMs for enveloped payloads and lazily deletes stale entries", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
|
||||
writeCache("ttl", { value: "fresh" });
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:02.000Z"));
|
||||
|
||||
// A stale read returns null AND removes the entry so it stops consuming quota.
|
||||
expect(readCache<{ value: string }>("ttl", { maxAgeMs: 1_000 })).toBeNull();
|
||||
expect(readCache<{ value: string }>("ttl")).toEqual({ value: "fresh" });
|
||||
expect(localStorage.getItem("ttl")).toBeNull();
|
||||
// A subsequent read without maxAgeMs also misses because the entry was lazily GC'd.
|
||||
expect(readCache<{ value: string }>("ttl")).toBeNull();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not lazily delete fresh enveloped entries", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
|
||||
writeCache("fresh-ttl", { value: "ok" });
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.500Z"));
|
||||
|
||||
expect(readCache<{ value: string }>("fresh-ttl", { maxAgeMs: 1_000 })).toEqual({ value: "ok" });
|
||||
expect(localStorage.getItem("fresh-ttl")).not.toBeNull();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -139,4 +157,71 @@ describe("swrCache", () => {
|
||||
|
||||
expect(() => writeCache("quota", { ok: true })).not.toThrow();
|
||||
});
|
||||
it("pruneStaleCacheEntries removes SWR entries older than 24h but keeps fresh ones", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
|
||||
// Stale: written 25h ago.
|
||||
writeCache(`${SWR_CACHE_KEYS.TASKS_PREFIX}old`, [{ id: "1" }]);
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-02T01:00:00.000Z")); // +25h
|
||||
// Fresh: written now.
|
||||
writeCache(SWR_CACHE_KEYS.PROJECTS, [{ id: "p" }]);
|
||||
|
||||
const removed = pruneStaleCacheEntries();
|
||||
|
||||
expect(removed).toBe(1);
|
||||
expect(localStorage.getItem(`${SWR_CACHE_KEYS.TASKS_PREFIX}old`)).toBeNull();
|
||||
expect(localStorage.getItem(SWR_CACHE_KEYS.PROJECTS)).not.toBeNull();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("pruneStaleCacheEntries ignores non-cache keys, malformed JSON, and envelope-less payloads", () => {
|
||||
// Non-SWR key (scoped pref) — never touched by the sweep.
|
||||
localStorage.setItem("kb:proj1:kb-dashboard-task-view", "board");
|
||||
// Malformed JSON under a cache prefix — left alone (caught, not crashed).
|
||||
localStorage.setItem(`${SWR_CACHE_KEYS.TASKS_PREFIX}bad`, "{not json");
|
||||
// Cache key without a savedAt envelope — left alone.
|
||||
localStorage.setItem(SWR_CACHE_KEYS.MODELS, JSON.stringify([{ id: "x" }]));
|
||||
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z"));
|
||||
writeCache(SWR_CACHE_KEYS.AGENTS, [{ id: "a" }]);
|
||||
vi.setSystemTime(new Date("2026-01-02T00:00:00.000Z"));
|
||||
|
||||
const removed = pruneStaleCacheEntries();
|
||||
|
||||
expect(removed).toBe(1);
|
||||
expect(localStorage.getItem(SWR_CACHE_KEYS.AGENTS)).toBeNull();
|
||||
expect(localStorage.getItem("kb:proj1:kb-dashboard-task-view")).toBe("board");
|
||||
expect(localStorage.getItem(`${SWR_CACHE_KEYS.TASKS_PREFIX}bad`)).toBe("{not json");
|
||||
expect(localStorage.getItem(SWR_CACHE_KEYS.MODELS)).not.toBeNull();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("clearAllLocalCache removes Fusion-owned keys but preserves the auth token", () => {
|
||||
localStorage.setItem("fn.authToken", "secret-token");
|
||||
localStorage.setItem(`${SWR_CACHE_KEYS.TASKS_PREFIX}p1`, "[]");
|
||||
localStorage.setItem("kb:proj1:kb-dashboard-task-view", "board");
|
||||
localStorage.setItem("kb-dashboard-theme-mode", "dark");
|
||||
localStorage.setItem("fn-agent-log-markdown", "true");
|
||||
localStorage.setItem("fusion:right-dock-pinned", "true");
|
||||
localStorage.setItem("fusion-insight-model", "openai/gpt-4o");
|
||||
// Hypothetical non-Fusion key — left alone.
|
||||
localStorage.setItem("other-app:data", "keep-me");
|
||||
|
||||
const removed = clearAllLocalCache();
|
||||
|
||||
expect(removed).toBe(6);
|
||||
expect(localStorage.getItem("fn.authToken")).toBe("secret-token");
|
||||
expect(localStorage.getItem("other-app:data")).toBe("keep-me");
|
||||
expect(localStorage.getItem(`${SWR_CACHE_KEYS.TASKS_PREFIX}p1`)).toBeNull();
|
||||
expect(localStorage.getItem("kb:proj1:kb-dashboard-task-view")).toBeNull();
|
||||
expect(localStorage.getItem("kb-dashboard-theme-mode")).toBeNull();
|
||||
expect(localStorage.getItem("fn-agent-log-markdown")).toBeNull();
|
||||
expect(localStorage.getItem("fusion:right-dock-pinned")).toBeNull();
|
||||
expect(localStorage.getItem("fusion-insight-model")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,6 +95,18 @@ export function readCache<T>(key: string, options?: { maxAgeMs?: number }): T |
|
||||
if (typeof maxAgeMs === "number") {
|
||||
const ageMs = Date.now() - envelope.savedAt;
|
||||
if (ageMs > maxAgeMs) {
|
||||
/*
|
||||
FNXC:SwrCache 2026-07-02-00:00:
|
||||
Lazy GC: drop the stale entry so it stops consuming localStorage quota. A stale
|
||||
entry is already treated as a miss by every reader (they re-fetch and overwrite),
|
||||
so deleting it on read is behavior-preserving. This prevents per-session and
|
||||
per-room message caches from accumulating when a reader revisits a stale key.
|
||||
*/
|
||||
try {
|
||||
storage.removeItem(key);
|
||||
} catch {
|
||||
// Ignore storage errors — the stale read still returns null.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -156,3 +168,111 @@ export function clearCache(prefix: string): void {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:SwrCache 2026-07-02-00:00:
|
||||
* Boot-time sweep that removes every SWR hydration entry older than SWR_LONG_MAX_AGE_MS (24h).
|
||||
* Since 24h is the longest TTL any consumer passes to readCache, a pruned entry was already
|
||||
* treated as a miss by every reader — this frees quota without changing hydration behavior.
|
||||
* The main target is per-session / per-room message caches from abandoned conversations that
|
||||
* are never read again (and therefore never hit readCache's lazy GC). Called once from the
|
||||
* DashboardLoader mount so it runs before hydration hooks read their caches.
|
||||
*
|
||||
* Returns the number of entries removed for diagnostics.
|
||||
*/
|
||||
export function pruneStaleCacheEntries(): number {
|
||||
const storage = getLocalStorage();
|
||||
if (!storage) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let removed = 0;
|
||||
try {
|
||||
const staleKeys: string[] = [];
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
const key = storage.key(index);
|
||||
if (typeof key !== "string" || !key.startsWith("kb-dashboard-")) {
|
||||
continue;
|
||||
}
|
||||
const raw = storage.getItem(key);
|
||||
if (raw === null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object" || !("savedAt" in parsed)) {
|
||||
continue;
|
||||
}
|
||||
const savedAt = parsed.savedAt;
|
||||
if (typeof savedAt !== "number" || Number.isNaN(savedAt)) {
|
||||
continue;
|
||||
}
|
||||
if (Date.now() - savedAt > SWR_LONG_MAX_AGE_MS) {
|
||||
staleKeys.push(key);
|
||||
}
|
||||
} catch {
|
||||
// Malformed JSON — leave it; readCache/clearCache handle their own parsing.
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of staleKeys) {
|
||||
storage.removeItem(key);
|
||||
removed += 1;
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:SwrCache 2026-07-02-00:00:
|
||||
* User-facing "Clear local data" helper: removes all Fusion-owned browser data — SWR
|
||||
* hydration caches plus per-project scoped preferences and global UI preferences — while
|
||||
* preserving the dashboard auth token so a reload keeps the session usable. Wired to
|
||||
* Settings → General "Clear local data" as the escape hatch for quota exhaustion. Callers
|
||||
* should reload the page after this so React state re-hydrates from a clean slate.
|
||||
*
|
||||
* Returns the number of keys removed for diagnostics.
|
||||
*/
|
||||
export const LOCAL_CACHE_PRESERVE_KEYS: Readonly<Record<string, true>> = { "fn.authToken": true };
|
||||
|
||||
function isFusionOwnedKey(key: string): boolean {
|
||||
return (
|
||||
key.startsWith("kb-") ||
|
||||
key.startsWith("kb:") ||
|
||||
key.startsWith("fn-agent-log-") ||
|
||||
key.startsWith("fusion")
|
||||
);
|
||||
}
|
||||
|
||||
export function clearAllLocalCache(): number {
|
||||
const storage = getLocalStorage();
|
||||
if (!storage) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let removed = 0;
|
||||
try {
|
||||
const keys: string[] = [];
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
const key = storage.key(index);
|
||||
if (typeof key === "string") {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
if (key in LOCAL_CACHE_PRESERVE_KEYS || !isFusionOwnedKey(key)) {
|
||||
continue;
|
||||
}
|
||||
storage.removeItem(key);
|
||||
removed += 1;
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
@@ -61,6 +61,26 @@ describe("resolveGitlabAuth", () => {
|
||||
expect(JSON.stringify(resolution)).not.toMatch(/glab|cli/i);
|
||||
});
|
||||
|
||||
|
||||
it("returns disabled before validating URL or token settings", () => {
|
||||
expect(
|
||||
resolveGitlabAuth({
|
||||
projectSettings: { gitlabEnabled: false, gitlabInstanceUrl: "not-a-url", gitlabAuthTokenType: "deploy" },
|
||||
globalSettings: { gitlabEnabled: true, gitlabAuthToken: "global-token" },
|
||||
env: { GITLAB_TOKEN: "env-token" },
|
||||
}),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: "disabled",
|
||||
message: "GitLab integration is disabled in Settings.",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses project enabled state before global enabled fallback", () => {
|
||||
expect(resolveGitlabAuth({ projectSettings: { gitlabEnabled: true, gitlabAuthToken: "token" }, globalSettings: { gitlabEnabled: false }, env: {} })).toMatchObject({ ok: true });
|
||||
expect(resolveGitlabAuth({ projectSettings: {}, globalSettings: { gitlabEnabled: false, gitlabAuthToken: "token" }, env: {} })).toMatchObject({ ok: false, reason: "disabled" });
|
||||
});
|
||||
|
||||
it("returns token_missing when configured settings and GITLAB_TOKEN are blank", () => {
|
||||
expect(
|
||||
resolveGitlabAuth({
|
||||
|
||||
@@ -99,6 +99,17 @@ describe("GitLab import routes", () => {
|
||||
expect(store.createTask.mock.calls[1][0].sourceIssue).toMatchObject({ provider: "gitlab", issueNumber: 5, url: "https://gitlab.example.com/g/p/-/merge_requests/5" });
|
||||
});
|
||||
|
||||
|
||||
it("returns a disabled error without fetching GitLab when integration is off", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(jsonResponse([]));
|
||||
const { app, store } = buildApp(fetchImpl);
|
||||
store.getSettings.mockResolvedValueOnce({ gitlabEnabled: false, gitlabInstanceUrl: "not-a-url", gitlabAuthToken: "" });
|
||||
const response = await request(app, "POST", "/api/gitlab/project/issues/fetch", JSON.stringify({ project: "g/p" }), { "Content-Type": "application/json" });
|
||||
expect(response.status).toBe(400);
|
||||
expect(JSON.stringify(response.body)).toContain("GitLab integration is disabled");
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes self-managed settings and returns auth/config errors without token leakage", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(jsonResponse([]));
|
||||
const { app, store } = buildApp(fetchImpl);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { resolveGitlabConfig, type GlobalSettings, type ProjectSettings } from "@fusion/core";
|
||||
import { resolveGitlabConfig, resolveGitlabEnabled, type GlobalSettings, type ProjectSettings } from "@fusion/core";
|
||||
import type { GitlabAuthTokenType } from "@fusion/core";
|
||||
|
||||
export const GITLAB_AUTH_HEADER_NAME = "PRIVATE-TOKEN" as const;
|
||||
export const GITLAB_AUTH_TOKEN_TYPES = ["personal", "project", "group"] as const satisfies readonly GitlabAuthTokenType[];
|
||||
|
||||
export interface GitlabAuthSettingsSource {
|
||||
gitlabEnabled?: boolean;
|
||||
gitlabInstanceUrl?: string;
|
||||
gitlabApiBaseUrl?: string;
|
||||
gitlabAuthToken?: string;
|
||||
@@ -23,12 +24,12 @@ export type GitlabAuthResolution =
|
||||
| { ok: true; auth: ResolvedGitlabAuth }
|
||||
| {
|
||||
ok: false;
|
||||
reason: "token_missing" | "invalid_token_type" | "invalid_config";
|
||||
reason: "disabled" | "token_missing" | "invalid_token_type" | "invalid_config";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export interface ResolveGitlabAuthDeps {
|
||||
projectSettings?: GitlabAuthSettingsSource | Pick<ProjectSettings, "gitlabInstanceUrl" | "gitlabApiBaseUrl" | "gitlabAuthToken" | "gitlabAuthTokenType"> | null;
|
||||
projectSettings?: GitlabAuthSettingsSource | Pick<ProjectSettings, "gitlabEnabled" | "gitlabInstanceUrl" | "gitlabApiBaseUrl" | "gitlabAuthToken" | "gitlabAuthTokenType"> | null;
|
||||
globalSettings?: GitlabAuthSettingsSource | Partial<GlobalSettings> | Record<string, unknown> | null;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
@@ -65,6 +66,10 @@ function firstConfiguredTokenType(...values: unknown[]): GitlabAuthTokenType | u
|
||||
* FN-7423 resolves personal, project, and group GitLab access tokens for future HTTP API integrations without invoking `glab` or any GitLab CLI. GitLab REST auth uses the PRIVATE-TOKEN header; read-only features require read_api or api, while future write/comment/close features require api and token resource membership.
|
||||
*/
|
||||
export function resolveGitlabAuth(deps: ResolveGitlabAuthDeps = {}): GitlabAuthResolution {
|
||||
if (!resolveGitlabEnabled({ project: deps.projectSettings ?? undefined, global: deps.globalSettings as Partial<GlobalSettings> | undefined })) {
|
||||
return { ok: false, reason: "disabled", message: "GitLab integration is disabled in Settings." };
|
||||
}
|
||||
|
||||
let config: ReturnType<typeof resolveGitlabConfig>;
|
||||
try {
|
||||
config = resolveGitlabConfig({
|
||||
|
||||
@@ -121,6 +121,10 @@ if (typeof window !== "undefined") {
|
||||
clear: () => {
|
||||
Object.keys(localStorageMock).forEach((key) => delete localStorageMock[key]);
|
||||
},
|
||||
get length() {
|
||||
return Object.keys(localStorageMock).length;
|
||||
},
|
||||
key: (index: number) => Object.keys(localStorageMock)[index] ?? null,
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
@@ -1428,6 +1428,17 @@ describe("PluginRunner", () => {
|
||||
});
|
||||
|
||||
describe("task lifecycle hooks", () => {
|
||||
/*
|
||||
FNXC:PluginRunnerTests 2026-07-02-17:20:
|
||||
PluginRunner lifecycle handlers intentionally fire-and-forget their hook dispatch, so tests must wait for the mocked invokeHook promise chain to settle before asserting.
|
||||
Use a bounded microtask flush instead of real wall-clock sleeps because FN-5048 forbids deterministic test settling through setTimeout delays when no production timer behavior is under test.
|
||||
*/
|
||||
const flushMicrotasks = async (turns = 4): Promise<void> => {
|
||||
for (let turn = 0; turn < turns; turn += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
};
|
||||
|
||||
it("should invoke onTaskCreated when task:created event fires", async () => {
|
||||
mockPluginLoader.invokeHook = vi.fn();
|
||||
await pluginRunner.init();
|
||||
@@ -1443,8 +1454,7 @@ describe("PluginRunner", () => {
|
||||
createdHandler(mockTask);
|
||||
}
|
||||
|
||||
// Give async handler time to execute
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(mockPluginLoader.invokeHook).toHaveBeenCalledWith(
|
||||
"onTaskCreated",
|
||||
@@ -1467,8 +1477,7 @@ describe("PluginRunner", () => {
|
||||
movedHandler({ task: mockTask, from: "todo", to: "in-progress" });
|
||||
}
|
||||
|
||||
// Give async handler time to execute
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(mockPluginLoader.invokeHook).toHaveBeenCalledWith(
|
||||
"onTaskMoved",
|
||||
@@ -1505,8 +1514,7 @@ describe("PluginRunner", () => {
|
||||
movedHandler({ task: mockTask, from: "in-progress", to: "done" });
|
||||
}
|
||||
|
||||
// Give async handler time to execute
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(mockPluginLoader.invokeHook).toHaveBeenCalledWith(
|
||||
"onTaskCompleted",
|
||||
@@ -1538,8 +1546,7 @@ describe("PluginRunner", () => {
|
||||
movedHandler({ task: mockTask, from: "todo", to: "in-progress" });
|
||||
}
|
||||
|
||||
// Give async handler time to execute
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(mockPluginLoader.invokeHook).not.toHaveBeenCalledWith(
|
||||
"onTaskCompleted",
|
||||
|
||||
Reference in New Issue
Block a user