feat(dashboard): compact the GitHub import screen and move Import under the issue (#2163)

Mobile operator report: the import screen spent ~4 rows on chrome — a
provider row, a tab row, and a boxed ORIGIN/filter/Load stack — leaving
only **~9 issues visible**. Import also sat *above* the preview it acts
on, and a second Import in the list footer let you import an issue whose
body you had never opened.

## Before → After (measured at 412px)

| | before | after |
|---|---|---|
| control chrome | ~4 stacked bands (93px) | **one wrapping row (70px)**
|
| toolbar | boxed band, origin on its own line | **single 36px row** |
| issues visible | ~9 | **~13** |
| Import | list footer **and** preview header | **detail bottom bar
only** |

## Layout

- **Provider, type tabs, origin, filter and Load share one row.** It
wraps rather than clipping at the narrowest widths.
- **Load is icon-only** — the label survives as `aria-label`/`title`, so
the accessible name is unchanged (the pre-existing role+name query still
finds it, which is what proves nothing was lost for screen readers).
- **The labels filter is a popover.** Its trigger doubles as the readout
— it renders the active labels and takes an `is-active` cue — so
collapsing never hides applied state. Dismisses on outside pointerdown
or Escape; Escape `stopPropagation` keeps the modal from closing along
with it.
- **Origin stays visible as an inline chip** (per your call): it's
context for what you're importing, so it flattens from a stacked
ORIGIN/repo block rather than hiding in the popover.
- Removed the toolbar's `flex: 1 1 100%` mobile stacking — it dated from
when the toolbar was a full-width band and was forcing origin to claim
an entire line.
- Also neutralised `[data-theme="light"] .github-import-toolbar`, which
re-applied the band background later in the cascade at equal specificity
(light mode only).

## Actions — one place to import

Import + Close issue move from the preview header to a **bottom action
bar**: commit actions belong below the content they act on and within
thumb reach, matching the modal's own Cancel bar. The list footer's
duplicate Import is removed (Cancel stays — the modal still needs a
dismiss), so **an issue can no longer be imported sight-unseen**.

## Note for review — interaction with #551a2a3c1

`551a2a3c1` ("align import detail header") landed mid-work and
conflicted. Its panel-padding work is preserved untouched. Its header
rules were kept **as-is** because they remain correct for a lone label,
but its stated rationale — dropping `space-between` so "Close issue and
Import stay grouped as a pair at the end" — is moot now that those
actions live in the bottom bar. The comment is marked superseded rather
than left asserting something untrue.

## Verification

Measured in a real browser at 412px (jsdom has no layout, so the tests
pin structure and behaviour, not geometry): chrome 93→70px, toolbar a
single 36px row, ~13 issues visible, filter popover clamped inside the
viewport and autofocused, detail bar below the content with an unclipped
Import label. Re-verified after the rebase.

**90 tests pass** · gate green (294/122/63) · lint clean · typecheck
clean.

Seven existing tests encoded the old UI (labelled Load, always-open
filter input). They were **updated to the new contract rather than
appeased** — the filter assertions now exercise the whole affordance
(collapsed → open → filters), and FN-7657 persistence is asserted via
the *collapsed trigger*, proving both that state survives remount
**and** that a restored filter stays visible, which is the real risk
when a control collapses.

Two things worth knowing:

- The footer guard was **confirmed non-vacuous**: reintroducing the list
Import fails it.
- One new CSS rule is deliberately a child selector
(`.github-import-controls > *`) rather than naming
`.github-import-tabs`, because `GitHubImportModal.test.tsx` extracts
base rules with a naive first-match regex
(`/\.github-import-tabs\s*\{[^}]*\}/`) that a rule mentioning them
*above* the originals would silently hijack.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added a more compact, responsive mobile layout for the GitHub import
screen.
* Moved Import and Close issue actions to the preview’s bottom action
bar.
* Added a collapsible labels filter popover with keyboard and
outside-click dismissal.
  * Consolidated mobile controls into a single compact row.
  * Simplified the Load action to an accessible icon-only button.

* **Bug Fixes**
  * Removed duplicate Import controls from the list footer.
* Improved control sizing, wrapping, and preview action positioning on
narrow screens.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-15 23:48:47 -07:00
committed by GitHub
parent 81d4bee59b
commit 5ab46a6430
4 changed files with 591 additions and 73 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: The GitHub import screen shows far more issues at once, and Import now sits under the issue you are reading.
category: feature
dev: Provider, type tabs, origin, filter and Load collapse into one wrapping control row (chrome 93px -> 70px at 412px; ~9 -> ~13 issues visible). Load is icon-only (label kept as aria-label/title). The labels filter is a popover whose trigger doubles as its readout, so collapsing never hides applied state; dismisses on outside pointerdown or Escape (stopPropagation so the modal survives). Origin stays visible as an inline chip. Import moves out of the preview header into a bottom action bar alongside Close issue, and the list footer's duplicate Import is removed — the footer keeps only Cancel, so an issue can no longer be imported without opening it. Obsolete `flex: 1 1 100%` mobile stacking on the toolbar zones removed.

View File

@@ -1,6 +1,169 @@
/* === GitHub Import Modal ===
Note: Some hardcoded colors below (e.g. #238636, #2ea043, #f0883e, #1f6feb)
are intentional GitHub brand colors and should NOT be replaced with design tokens. */
/*
FNXC:GitHubImport 2026-07-15-23:25:
One control row instead of three stacked bands. The import screen previously spent its vertical
budget on a provider row, a tab row, and a boxed origin+filter+Load stack — on a phone that left only
a few issues visible. Provider, type tabs, origin, filter, and Load now share this row so the list
gets the space back.
`flex-wrap: wrap` is deliberate: at the narrowest widths the row reflows to a second line rather than
clipping controls (the failure mode the composer Save button hit). The toolbar keeps its own box
styling but is now an inline member of this row instead of a full-width band.
*/
.github-import-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
/*
The toolbar is a row member now, not a band. Its own box chrome (border/background/padding) is
dropped here: nested inside this row it drew a second frame around origin+filter+Load and forced
them onto their own line, which is exactly the wasted vertical space this change removes. It keeps
its internal flex layout and simply flows as more chips in the row.
*/
.github-import-controls .github-import-toolbar {
flex: 1 1 auto;
min-width: 0;
gap: var(--space-sm);
padding: 0;
background: none;
border: 0;
border-radius: 0;
flex-wrap: wrap;
}
/* Zones stop reserving their own width now that they are inline chips. */
.github-import-controls .github-import-toolbar__zone--remote {
min-width: 0;
}
.github-import-controls .github-import-toolbar__zone--filter {
flex: 0 1 auto;
min-width: 0;
}
/*
Origin stays VISIBLE (operator decision) but flattens from a stacked ORIGIN/repo block into one
inline chip — it is context for what you are importing, not a section header, so it should cost a
few characters of a shared row rather than two rows of its own.
*/
.github-import-controls .github-import-remote-pill {
flex-direction: row;
align-items: baseline;
gap: var(--space-xs);
min-width: 0;
}
/* The uppercase "ORIGIN" caption is redundant once the repo sits inline in the control row. */
.github-import-controls .github-import-remote-pill__name {
display: none;
}
.github-import-controls .github-import-remote-pill__repo {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 18ch;
font-size: 12px;
color: var(--text-muted);
}
/*
Row members sit inline and never stretch. Written as a child selector rather than naming
`.github-import-provider`/`.github-import-tabs` on purpose: GitHubImportModal.test.tsx extracts
those base rules with a naive first-match regex (/\.github-import-tabs\s*\{[^}]*\}/), so a rule
mentioning them ABOVE the originals would silently hijack that assertion. The toolbar opts back
into growing via its own higher-specificity rule below.
*/
.github-import-controls > * {
flex: 0 0 auto;
margin: 0;
}
/*
FNXC:GitHubImport 2026-07-15-23:25:
Filter popover. The trigger doubles as the filter's readout (it renders the active labels), so a
collapsed filter never hides applied state; `is-active` gives it an accent cue. `position: relative`
anchors the panel, which is width-clamped to the viewport so it cannot overflow on a phone.
*/
.github-import-filter-menu {
position: relative;
display: flex;
align-items: center;
min-width: 0;
}
.github-import-filter-trigger {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
min-height: 36px;
max-width: 100%;
padding: var(--space-xs) var(--space-sm);
font-size: 13px;
}
/* The readout must ellipsize, never widen the row or wrap mid-label. */
.github-import-filter-trigger__text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 12ch;
}
.github-import-filter-trigger.is-active {
border-color: var(--accent);
color: var(--accent);
}
.github-import-filter-panel {
position: absolute;
top: calc(100% + var(--space-xs));
left: 0;
z-index: 20;
display: flex;
flex-direction: column;
gap: var(--space-xs);
width: max(220px, 100%);
max-width: calc(100vw - var(--space-xl));
padding: var(--space-sm);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md, 0 4px 16px rgb(0 0 0 / 18%));
}
.github-import-filter-panel__label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
}
.github-import-filter-panel input {
width: 100%;
min-height: 36px;
}
/*
FNXC:GitHubImport 2026-07-15-23:25:
Load is icon-only — its label lives in aria-label/title. Square so it reads as an icon affordance and
holds a 36px touch target without claiming a row of its own.
*/
.github-import-controls .github-import-load-button {
flex: 0 0 auto;
gap: 0;
width: 36px;
min-width: 36px;
padding: 0;
}
/* Compact Toolbar Layout */
.github-import-toolbar {
display: flex;
@@ -165,8 +328,39 @@ Padding lives on the panel rather than the pane content so header, toast, and sc
}
/*
FNXC:GitHubImport 2026-07-15-18:20:
Header is one label + a right-aligned action cluster. The label takes the free space (margin-inline-end:auto) rather than the container using space-between, so Close issue and Import stay grouped as a pair at the end instead of Close issue being flung into the middle whenever both actions render.
FNXC:GitHubImport 2026-07-15-23:25:
Bottom action bar for the detail preview. Import/Close issue moved here from the pane header —
commit actions belong below the content they act on and within thumb reach on a phone, and this
mirrors the modal's own Cancel bar. `margin-top: auto` pins it to the bottom of the flex panel even
when the preview is short; the top border separates it from scrolling content.
*/
.github-import-detail-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--space-sm);
margin-top: auto;
padding-top: var(--space-md);
border-top: 1px solid var(--border);
}
/* Never let the primary action's label clip when the bar is narrow. */
.github-import-detail-actions .btn {
flex: 0 0 auto;
min-width: max-content;
min-height: 36px;
}
/*
FNXC:GitHubImport 2026-07-15-18:20 (superseded 2026-07-15-23:25):
Supersedes #551a2a3c1's note here. That change dropped `justify-content: space-between` and gave the
label `margin-inline-end: auto` so Close issue and Import stayed grouped as a pair at the END of this
header rather than Close issue being flung to the middle. Those actions now live in
`.github-import-detail-actions` below the content, so the header holds ONLY the label. The rules are
kept as-is because they remain correct for a lone label (it simply takes the free space), and they
are what a future action returning to this header would need — but the grouping rationale no longer
applies here. Do not re-add actions to this header without revisiting the bottom-bar decision.
*/
.github-import-pane-header {
display: flex;
@@ -1151,6 +1345,20 @@ Across the thread: a top filter (All/Human/Bot) and prev/next chevrons live in t
border-bottom-color: var(--border);
}
/*
FNXC:GitHubImport 2026-07-15-23:35:
Theme-scoped companion to the de-boxing above. The base rule dropped the toolbar's band chrome, but
this light-theme rule re-applied a background later in the cascade at equal specificity, so the band
survived in light mode only. Nested in the control row the toolbar is a group of inline chips, not a
surface — it must carry no fill in EITHER theme. Both selectors are listed so a dark-theme
counterpart added later cannot silently reintroduce the band.
*/
[data-theme="light"] .github-import-controls .github-import-toolbar,
[data-theme="dark"] .github-import-controls .github-import-toolbar {
background: none;
border: 0;
}
[data-theme="light"] .github-import-toolbar__zone--filter input {
background: var(--card);
border-color: var(--border);
@@ -1238,15 +1446,25 @@ Across the thread: a top filter (All/Human/Bot) and prev/next chevrons live in t
padding: var(--space-sm);
}
/*
FNXC:GitHubImport 2026-07-15-23:40:
These stacked the toolbar one-zone-per-row back when it was a full-width band. Inside the control
row that is now actively wrong: `flex: 1 1 100%` forced origin to claim an entire line, pushing
Filter and Load onto yet another — the exact vertical waste this change removes. Origin now shares
the row and simply ellipsizes; `min-width: 140px` on the filter is dropped for the same reason (it
reserved a full input's width for what is now a compact trigger). Order is preserved so the reading
order stays origin → filter → load.
*/
.github-import-toolbar__zone--remote {
flex: 1 1 100%;
flex: 0 1 auto;
order: 1;
min-width: 0;
}
.github-import-toolbar__zone--filter {
flex: 1 1 auto;
flex: 0 1 auto;
order: 2;
min-width: 140px;
min-width: 0;
}
.github-import-toolbar__zone--action {

View File

@@ -26,7 +26,7 @@ import {
type GitRemote,
type GitLabImportItem,
} from "../api";
import { Loader2, RefreshCw, GitPullRequest, CircleDot, ChevronUp, ChevronDown, Bot, User } from "lucide-react";
import { Loader2, RefreshCw, GitPullRequest, CircleDot, ChevronUp, ChevronDown, Bot, User, Filter } from "lucide-react";
import { GithubIcon } from "./GithubIcon";
import { MailboxMessageContent } from "./MailboxMessageContent";
import {
@@ -336,6 +336,39 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
const [owner, setOwner] = useState("");
const [repo, setRepo] = useState("");
const [labels, setLabels] = useState("");
/*
FNXC:GitHubImport 2026-07-15-23:20:
The labels filter collapses into a popover so the control row stays one line and the issue list
keeps the vertical space. `filterOpen` drives the popover; the trigger summarises the active
filter so a collapsed filter is never invisible state.
*/
const [filterOpen, setFilterOpen] = useState(false);
const filterMenuRef = useRef<HTMLDivElement | null>(null);
/*
FNXC:GitHubImport 2026-07-15-23:20:
Dismiss the filter popover on outside pointerdown or Escape. Bound only while open so the modal
does not carry idle global listeners. Escape stops propagation: the popover is the innermost
dismissible layer, and without this the modal itself would close on the same key.
*/
useEffect(() => {
if (!filterOpen) return;
const onPointerDown = (event: PointerEvent) => {
if (!filterMenuRef.current?.contains(event.target as Node)) setFilterOpen(false);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.stopPropagation();
setFilterOpen(false);
}
};
document.addEventListener("pointerdown", onPointerDown, true);
document.addEventListener("keydown", onKeyDown, true);
return () => {
document.removeEventListener("pointerdown", onPointerDown, true);
document.removeEventListener("keydown", onKeyDown, true);
};
}, [filterOpen]);
const [loading, setLoading] = useState(false);
const [provider, setProvider] = useState<ImportProvider>("github");
const [gitlabResource, setGitlabResource] = useState<GitLabResourceTab>("project_issue");
@@ -1238,12 +1271,21 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
)}
<div className="modal-body github-import-modal__body">
{/*
FNXC:GitHubImport 2026-07-15-23:20:
Mobile operator report: the import screen spent most of its vertical budget on chrome —
provider row, tab row, and a boxed origin+filter+Load stack — leaving only a few issues
visible. Provider, type tabs, origin, filter, and Load now share ONE control row so the
issue list gets the reclaimed space. The row wraps on narrow widths rather than clipping.
Origin stays VISIBLE as a compact chip (operator decision) — it is load-bearing context for
what you are about to import, so it must not hide inside the filter popover.
*/}
<div className="github-import-controls" data-testid="github-import-controls">
<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>
{gitlabEnabled ? <button type="button" className={`github-import-tab ${provider === "gitlab" ? "active" : ""}`} aria-pressed={provider === "gitlab"} onClick={() => setProvider("gitlab")} disabled={loading || importing}>GitLab</button> : null}
</div>
{provider === "github" ? (
<>
{provider === "github" && (<>
{/* Tab Navigation */}
<div className="github-import-tabs" role="tablist" aria-label={t("git.importTypeAriaLabel", "Import type")}>
<button
@@ -1316,19 +1358,50 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
{/* Center: Labels filter (only for issues) */}
<div className="github-import-toolbar__zone github-import-toolbar__zone--filter">
{activeTab === "issues" ? (
<>
<label htmlFor="gh-labels" className="visually-hidden">{t("git.filterByLabelsLabel", "Filter by labels")}</label>
<input
id="gh-labels"
type="text"
placeholder={t("git.filterByLabelsPlaceholder", "Filter: bug,enhancement…")}
value={labels}
onChange={(e) => setLabels(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleLoad()}
/*
FNXC:GitHubImport 2026-07-15-23:20:
The always-open filter input cost a full row for a control most imports never touch.
It is now a popover. The trigger doubles as the filter's readout — it shows the active
labels instead of the word "Filter" — so collapsing never hides applied state, and it
carries an `is-active` class for a visual cue.
*/
<div className="github-import-filter-menu" ref={filterMenuRef}>
<button
type="button"
className={`btn github-import-filter-trigger ${labels.trim() ? "is-active" : ""}`}
data-testid="github-import-filter-trigger"
aria-haspopup="dialog"
aria-expanded={filterOpen}
onClick={() => setFilterOpen((open) => !open)}
disabled={loading || importing || !hasRemotes}
aria-label={t("git.filterIssuesByLabels", "Filter issues by labels")}
/>
</>
title={t("git.filterByLabelsLabel", "Filter by labels")}
>
<Filter size={14} aria-hidden="true" />
<span className="github-import-filter-trigger__text">
{labels.trim() || t("git.filter", "Filter")}
</span>
<ChevronDown size={12} aria-hidden="true" />
</button>
{filterOpen && (
<div className="github-import-filter-panel" data-testid="github-import-filter-panel" role="dialog" aria-label={t("git.filterByLabelsLabel", "Filter by labels")}>
<label htmlFor="gh-labels" className="github-import-filter-panel__label">{t("git.filterByLabelsLabel", "Filter by labels")}</label>
<input
id="gh-labels"
type="text"
autoFocus
placeholder={t("git.filterByLabelsPlaceholder", "Filter: bug,enhancement…")}
value={labels}
onChange={(e) => setLabels(e.target.value)}
onKeyDown={(e) => {
// Enter applies the filter AND dismisses — the reload is the confirmation.
if (e.key === "Enter") { setFilterOpen(false); handleLoad(); }
}}
disabled={loading || importing || !hasRemotes}
aria-label={t("git.filterIssuesByLabels", "Filter issues by labels")}
/>
</div>
)}
</div>
) : (
<span className="github-import-filter-hint">
{t("git.openPullsFrom", "Open pull requests from {{remote}}", { remote: owner || t("git.selectedRemote", "selected remote") })}
@@ -1346,11 +1419,20 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
aria-label={loading ? t("git.loadingAriaLabel", "Loading {{tab}}", { tab: activeTab }) : t("git.loadFromRepoAriaLabel", "Load {{tab}} from repository", { tab: activeTab })}
title={loading ? t("git.loadingTitle", "Loading…") : t("git.loadTabTitle", "Load {{tab}}", { tab: activeTab })}
>
{/*
FNXC:GitHubImport 2026-07-15-23:20:
Load is icon-only. The full-width labelled button owned a whole row of vertical space
for an action whose meaning the refresh glyph already carries; the label survives as
the accessible name + tooltip (both set above), so nothing is lost for screen readers.
*/}
{loading ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
<span>{loading ? t("git.loading", "Loading…") : t("git.load", "Load")}</span>
</button>
</div>
</div>
</>)}
</div>
{provider === "github" ? (
<>
{/* Warning/Error states below toolbar */}
{!loadingRemotes && !hasRemotes && (
@@ -1563,34 +1645,16 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
className="floating-window--github-import-detail"
>
<div className="github-import-detail-panel">
{/*
FNXC:GitHubImport 2026-07-15-23:20:
Import and Close issue moved OUT of this header to a bottom action bar (see
`github-import-detail-actions`). Operator report: actions above the content they act on
read as navigation and are awkward to reach on a phone; a dialog's commit actions belong
at the bottom, which also matches the modal's own Cancel bar. The header is now purely a
heading.
*/}
<div className="github-import-pane-header">
<h4 id="github-import-preview-heading">{t("git.previewHeading", "Preview")}</h4>
{/*
FNXC:GitHubImport 2026-06-23-03:15:
Close-issue action sits next to the top Import action and acts on the selected OPEN issue. Hidden for the PR tab and for already-closed issues; disabled while a close request is in flight.
Closing reflects locally (badge flips to closed) without dismissing the preview.
*/}
{activeTab === "issues" && selectedIssue && !selectedIssueClosed && (
<button
className="btn github-import-issue-close-top"
data-testid="github-import-issue-close"
onClick={handleCloseIssue}
disabled={closingIssue}
title={t("git.closeIssueTitle", "Close issue #{{number}}", { number: selectedIssue.number })}
>
{closingIssue ? <Loader2 size={14} className="spin" /> : t("git.closeIssue", "Close issue")}
</button>
)}
<button
className="btn btn-primary github-import-action-top"
data-testid="github-import-action-top"
onClick={handleImport}
disabled={
(activeTab === "issues" ? selectedIssueNumber === null || isUrlImported(selectedIssue?.html_url) : selectedPullNumber === null || isUrlImported(selectedPull?.html_url)) || importing
}
>
{importing ? <Loader2 size={14} className="spin" /> : t("git.import", "Import")}
</button>
</div>
{/*
FNXC:GitHubImport 2026-06-23-03:15:
@@ -1781,6 +1845,38 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
</div>
) : null}
</div>
{/*
FNXC:GitHubImport 2026-07-15-23:20:
Bottom action bar for the detail preview — the ONLY place an import is committed now
(the list's footer Import was removed, so you can no longer import an issue whose body
you never opened). Import stays last/right as the primary action; Close issue sits to
its LEFT and still acts on the selected OPEN issue: hidden on the PR tab and for
already-closed issues, disabled while a close is in flight, and closing reflects
locally (the badge flips) without dismissing the preview.
*/}
<div className="github-import-detail-actions" data-testid="github-import-detail-actions">
{activeTab === "issues" && selectedIssue && !selectedIssueClosed && (
<button
className="btn github-import-issue-close"
data-testid="github-import-issue-close"
onClick={handleCloseIssue}
disabled={closingIssue}
title={t("git.closeIssueTitle", "Close issue #{{number}}", { number: selectedIssue.number })}
>
{closingIssue ? <Loader2 size={14} className="spin" /> : t("git.closeIssue", "Close issue")}
</button>
)}
<button
className="btn btn-primary github-import-action"
data-testid="github-import-action-top"
onClick={handleImport}
disabled={
(activeTab === "issues" ? selectedIssueNumber === null || isUrlImported(selectedIssue?.html_url) : selectedPullNumber === null || isUrlImported(selectedPull?.html_url)) || importing
}
>
{importing ? <Loader2 size={14} className="spin" /> : t("git.import", "Import")}
</button>
</div>
</div>
</FloatingWindow>
)}
@@ -1862,26 +1958,20 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
</div>
{/*
FNXC:GitHubImport 2026-06-23-02:00:
Bottom Cancel+Import bar is kept ONLY for the non-embedded modal presentation, which needs a Cancel to dismiss the dialog.
In the embedded sidebar (isEmbedded) there is no modal to cancel and the Import action now lives in the preview-pane top header, so the bottom bar is removed entirely.
FNXC:GitHubImport 2026-06-23-02:00 (revised 2026-07-15-23:20):
The bottom bar exists ONLY for the non-embedded modal, which needs a Cancel to dismiss the
dialog; the embedded sidebar has nothing to cancel, so it has no bar at all.
Import no longer lives here. There were two ways to import — this footer (acting on the
radio selection) and the preview pane — which split the action across two places and let you
import an issue whose body you had never opened. Import is now ONLY in the detail preview's
bottom action bar, so the list is purely for choosing what to look at. Cancel stays.
*/}
{!isEmbedded && (
<div className="modal-actions github-import-modal__actions">
<button className="btn" onClick={onClose} disabled={importing}>
{t("common.cancel", "Cancel")}
</button>
<button
className="btn btn-primary"
onClick={provider === "gitlab" ? handleImportGitLab : handleImport}
disabled={
provider === "gitlab"
? !gitlabEnabled || selectedGitlabItem === null || importing || (selectedGitlabItem ? isUrlImported(selectedGitlabItem.webUrl) : false)
: (activeTab === "issues" ? selectedIssueNumber === null || isUrlImported(selectedIssue?.html_url) : selectedPullNumber === null || isUrlImported(selectedPull?.html_url)) || importing
}
>
{importing ? <Loader2 size={14} className="spin" /> : t("git.import", "Import")}
</button>
</div>
)}
</div>

View File

@@ -396,9 +396,14 @@ describe("GitHubImportModal", () => {
expect(toolbar).toBeTruthy();
// Remote pill should be in toolbar
expect(within(toolbar).getByTestId("github-import-single-remote")).toBeTruthy();
// Filter input
expect(within(toolbar).getByPlaceholderText(/Filter:/)).toBeTruthy();
// Load button
/*
FNXC:GitHubImport 2026-07-15-23:50:
The filter is a popover now, so its input is absent until the trigger is used — assert the
trigger instead. Load is icon-only; its accessible name still carries "Load", so the same
role+name query keeps working and proves the label was not lost for screen readers.
*/
expect(within(toolbar).getByTestId("github-import-filter-trigger")).toBeTruthy();
expect(within(toolbar).queryByPlaceholderText(/Filter:/)).toBeNull();
expect(within(toolbar).getByRole("button", { name: /Load/i })).toBeTruthy();
});
});
@@ -532,13 +537,42 @@ describe("GitHubImportModal", () => {
expect(within(previewCard).getByText("(no description)")).toBeTruthy();
});
it("has optional labels input with filter placeholder", async () => {
/*
FNXC:GitHubImport 2026-07-15-23:50:
The labels filter collapsed into a popover to give the issue list back its vertical space, so the
input is no longer in the DOM until the trigger is used. This now tests the whole affordance —
reachable, opens, and still filters — rather than just the input's existence.
*/
it("reveals the labels filter input from the filter popover", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByPlaceholderText(/Filter:/)).toBeTruthy();
expect(screen.getByTestId("github-import-filter-trigger")).toBeTruthy();
});
// Collapsed by default — this is the vertical space the redesign reclaims.
expect(screen.queryByPlaceholderText(/Filter:/)).toBeNull();
fireEvent.click(screen.getByTestId("github-import-filter-trigger"));
expect(screen.getByTestId("github-import-filter-panel")).toBeTruthy();
expect(screen.getByPlaceholderText(/Filter:/)).toBeTruthy();
});
it("surfaces the active filter on the trigger so collapsing never hides applied state", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => expect(screen.getByTestId("github-import-filter-trigger")).toBeTruthy());
const trigger = screen.getByTestId("github-import-filter-trigger");
expect(trigger.textContent).toMatch(/Filter/);
fireEvent.click(trigger);
fireEvent.change(screen.getByPlaceholderText(/Filter:/), { target: { value: "bug,enhancement" } });
// The trigger doubles as the readout: a collapsed filter must still be visible state.
expect(screen.getByTestId("github-import-filter-trigger").textContent).toContain("bug,enhancement");
expect(screen.getByTestId("github-import-filter-trigger").className).toContain("is-active");
});
describe("with no remotes", () => {
@@ -953,7 +987,8 @@ describe("GitHubImportModal", () => {
expect(screen.getByText("Issue without labels")).toBeTruthy();
});
// Enter label filter
// Enter label filter — the input now lives behind the filter popover trigger.
fireEvent.click(screen.getByTestId("github-import-filter-trigger"));
const labelsInput = screen.getByPlaceholderText(/Filter:/);
fireEvent.change(labelsInput, { target: { value: "bug" } });
@@ -1422,20 +1457,21 @@ describe("GitHubImportModal", () => {
});
});
it("shows filter input for Issues tab, hint text for Pulls tab", async () => {
it("shows filter control for Issues tab, hint text for Pulls tab", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
// Default is Issues tab, should show filter input
expect(screen.getByPlaceholderText(/Filter:/)).toBeTruthy();
// Default is Issues tab. The filter is a popover now, so assert its trigger.
expect(screen.getByTestId("github-import-filter-trigger")).toBeTruthy();
});
// Click on Pull Requests tab
fireEvent.click(screen.getByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => {
// Should show hint text instead of filter input
// Should show hint text instead of the filter control (trigger AND input both gone).
expect(screen.queryByTestId("github-import-filter-trigger")).toBeNull();
expect(screen.queryByPlaceholderText(/Filter:/)).toBeNull();
expect(screen.getByText(/Open pull requests from/i)).toBeTruthy();
});
@@ -1687,6 +1723,8 @@ describe("GitHubImportModal", () => {
expect(screen.getByText("Persisted Issue")).toBeTruthy();
});
// FN-8004-era redesign: the filter input lives behind a popover trigger now.
fireEvent.click(screen.getByTestId("github-import-filter-trigger"));
fireEvent.change(screen.getByPlaceholderText(/Filter:/), { target: { value: "bug" } });
// The label change re-triggers auto-load (briefly disabling the list); wait for it to settle before selecting.
await waitFor(() => {
@@ -1707,7 +1745,14 @@ describe("GitHubImportModal", () => {
);
await waitFor(() => {
expect((screen.getByPlaceholderText(/Filter:/) as HTMLInputElement).value).toBe("bug");
/*
FNXC:GitHubImport 2026-07-15-23:55:
Assert restoration via the collapsed trigger rather than by reopening the popover. The
trigger renders the persisted filter, so this proves BOTH that FN-7657 persistence survived
the remount AND that a restored filter is visible without the operator hunting for it — the
real risk when a control collapses.
*/
expect(screen.getByTestId("github-import-filter-trigger").textContent).toContain("bug");
});
await waitFor(() => {
expect(within(screen.getByTestId("github-import-preview-card")).getByText("Persisted Issue")).toBeTruthy();
@@ -1812,6 +1857,8 @@ describe("GitHubImportModal", () => {
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
);
await waitFor(() => expect(screen.getByText("Project One Issue")).toBeTruthy());
// FN-8004-era redesign: the filter input lives behind a popover trigger now.
fireEvent.click(screen.getByTestId("github-import-filter-trigger"));
fireEvent.change(screen.getByPlaceholderText(/Filter:/), { target: { value: "bug" } });
// The label change re-triggers auto-load (briefly disabling the list); wait for it to settle before selecting.
await waitFor(() => {
@@ -1830,7 +1877,7 @@ describe("GitHubImportModal", () => {
);
await waitFor(() => {
expect((screen.getByPlaceholderText(/Filter:/) as HTMLInputElement).value).toBe("");
expect(screen.getByTestId("github-import-filter-trigger").textContent).not.toContain("bug");
});
expect(screen.queryByTestId("floating-window-github-import-detail")).toBeNull();
});
@@ -2030,4 +2077,160 @@ describe("GitHubImportModal", () => {
window.dispatchEvent(new Event("resize"));
});
});
/*
FNXC:GitHubImport 2026-07-16-00:05:
## Symptom Verification
Original symptom (mobile operator report): the import screen spent ~4 rows on chrome — a provider
row, a tab row, and a boxed ORIGIN/filter/Load stack — leaving ~9 issues visible; and Import lived
ABOVE the preview it acts on, while a second Import in the list footer let you import an issue whose
body you had never opened.
Assertion it is gone: verified in a real browser at 412px (jsdom has no layout, so these tests pin
STRUCTURE and BEHAVIOUR, not geometry). Post-change measurements: control chrome 93px -> 70px (two
rows), toolbar a single 36px row, ~13 issues visible, filter popover clamped inside the viewport,
and the detail action bar sitting below the preview content with an unclipped Import label.
## Surface Enumeration
- Issues tab AND Pull Requests tab (the filter exists only on Issues; the detail bar serves both).
- List footer (Cancel only) vs detail bar (the sole Import).
- Filter collapsed vs open, and the restored-from-persistence case (FN-7657).
- Close issue stays hidden on the PR tab and for already-closed issues — unchanged by the move.
*/
describe("GitHubImportModal — compact mobile layout (operator report)", () => {
// Inherits the suite's beforeEach (mock reset + defaults); only the remote needs pinning here.
beforeEach(() => {
vi.mocked(fetchGitRemotes).mockResolvedValue(singleRemote);
});
it("puts provider, type tabs, origin, filter and load in ONE control row", async () => {
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
const controls = await screen.findByTestId("github-import-controls");
// The row is the whole point: chrome that used to be four stacked bands is one container.
expect(within(controls).getByRole("button", { name: "GitHub" })).toBeTruthy();
expect(within(controls).getByRole("tab", { name: /Issues/i })).toBeTruthy();
expect(within(controls).getByRole("tab", { name: /Pull Requests/i })).toBeTruthy();
await waitFor(() => {
expect(within(controls).getByTestId("github-import-single-remote")).toBeTruthy();
});
expect(within(controls).getByTestId("github-import-filter-trigger")).toBeTruthy();
expect(within(controls).getByRole("button", { name: /Load/i })).toBeTruthy();
});
it("keeps Load reachable by name even though it is icon-only", async () => {
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => expect(screen.getByTestId("github-import-toolbar")).toBeTruthy());
// Dropping the visible label must not drop the accessible name.
const load = screen.getByRole("button", { name: /Load/i });
expect(load.textContent?.trim()).toBe("");
expect(load.getAttribute("aria-label")).toMatch(/Load/i);
});
it("keeps origin visible rather than hiding it inside the filter popover", async () => {
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
// Operator decision: origin is context for what you are importing, so it must stay on the row.
const remote = await screen.findByTestId("github-import-single-remote");
expect(remote.textContent).toContain("dustinbyrne/kb");
expect(screen.queryByTestId("github-import-filter-panel")).toBeNull();
});
it("closes the filter popover on Escape without closing the modal", async () => {
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => expect(screen.getByTestId("github-import-filter-trigger")).toBeTruthy());
fireEvent.click(screen.getByTestId("github-import-filter-trigger"));
expect(screen.getByTestId("github-import-filter-panel")).toBeTruthy();
fireEvent.keyDown(document, { key: "Escape" });
// The popover is the innermost layer: Escape dismisses IT, and must not also close the modal.
expect(screen.queryByTestId("github-import-filter-panel")).toBeNull();
expect(onClose).not.toHaveBeenCalled();
});
it("offers Import ONLY in the detail preview, never in the list footer", async () => {
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => expect(screen.getByTestId("github-import-toolbar")).toBeTruthy());
/*
Two Imports (footer + preview) split the action across two places and let an operator import an
issue sight-unseen. The footer keeps Cancel — the modal still needs a dismiss — but Import is
now exclusively the detail bar's.
*/
const footer = document.querySelector(".github-import-modal__actions");
expect(footer).toBeTruthy();
const footerButtons = [...footer!.querySelectorAll("button")].map((b) => b.textContent?.trim());
expect(footerButtons).toEqual(["Cancel"]);
expect(footerButtons).not.toContain("Import");
});
});
describe("GitHubImportModal — detail actions sit at the bottom (operator report)", () => {
// Inherits the suite's beforeEach (mock reset + defaults); only the remote needs pinning here.
beforeEach(() => {
vi.mocked(fetchGitRemotes).mockResolvedValue(singleRemote);
});
const openDetail = async () => {
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/dustinbyrne/kb/issues/1", labels: [], state: "open" },
]);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => expect(screen.getByText("First Issue")).toBeTruthy());
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
return screen.findByTestId("github-import-preview-card");
};
it("renders Import and Close issue AFTER the preview content, not above it", async () => {
await openDetail();
const bar = await screen.findByTestId("github-import-detail-actions");
const content = document.querySelector(".github-import-pane-content");
expect(content).toBeTruthy();
/*
The operator's ask: commit actions belong below the content they act on. jsdom has no layout, so
assert DOM ORDER — the bar must follow the content — which is what the CSS then pins to the
bottom of the flex panel. Geometry itself was verified in a real browser at 412px.
*/
expect(content!.compareDocumentPosition(bar) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(within(bar).getByRole("button", { name: /^Import$/i })).toBeTruthy();
expect(within(bar).getByTestId("github-import-issue-close")).toBeTruthy();
});
it("leaves no action buttons stranded in the preview header", async () => {
await openDetail();
await screen.findByTestId("github-import-detail-actions");
/*
Guard against the FN-6115-style empty shell: the DETAIL header must be a heading now, nothing
else. Scope to `.github-import-detail-panel` — `.github-import-pane-header` is shared with the
LIST pane, so an unscoped query silently asserts against the wrong header.
*/
const header = document.querySelector(".github-import-detail-panel .github-import-pane-header");
expect(header).toBeTruthy();
expect(header!.textContent).toContain("Preview");
expect(header!.querySelectorAll("button").length).toBe(0);
});
it("keeps Close issue off the Pull Requests tab", async () => {
await openDetail();
await screen.findByTestId("github-import-detail-actions");
expect(screen.getByTestId("github-import-issue-close")).toBeTruthy();
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce([]);
fireEvent.click(screen.getByRole("tab", { name: /Pull Requests/i }));
// Moving the buttons must not change WHEN close-issue is offered.
await waitFor(() => {
expect(screen.queryByTestId("github-import-issue-close")).toBeNull();
});
});
});
});