fix(dashboard): wider/resizable onboarding modal + GitHub CLI-aware messaging

- ModelOnboardingModal: native CSS resize with persisted size via
  useModalResizePersist; default ~1100px wide; modal scrolls inside
  the content area instead of clipping.
- Provider cards flex-wrap so the API-key form drops to a full-width
  row instead of being squished into a fixed side column. Connected
  badge no longer stretches the full body width.
- Step changes now reset content scrollTop so each page lands at the
  top.
- GitHub step: prominent GitHub mark via ProviderIcon; when gh CLI is
  authenticated the intro reads as 'you are all set', primary CTA
  becomes 'Continue with gh CLI auth', and a secondary
  'Connect OAuth (optional)' button is offered.
- CustomModelDropdown: highlight init runs once per open session, and
  filter changes reset highlight to top + scrollTop=0, fixing the
  scroll-fight when filtering models.
- TUI dashboard: at >=150 cols, Stats panel sits to the left of Logs;
  bottom row drops to Utilities + Settings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-27 20:05:45 -07:00
parent 4b9fd3dd64
commit f3d918997c
4 changed files with 249 additions and 74 deletions

View File

@@ -718,7 +718,11 @@ function StatusModeGrid({
const logsShare = Math.max(1, middleHeight - SYSTEM_HEIGHT - bottomShare); const logsShare = Math.max(1, middleHeight - SYSTEM_HEIGHT - bottomShare);
// LogsPanel chrome: border 2 + title 1 + filter 1 = 4. // LogsPanel chrome: border 2 + title 1 + filter 1 = 4.
const logsAvailableRows = Math.max(1, logsShare - 4); const logsAvailableRows = Math.max(1, logsShare - 4);
tuiDebug("StatusModeGrid", { cols, rows, middleHeight, logsShare, bottomShare, focused }); // On very wide terminals, lift Stats up next to Logs so the bottom row only
// holds Utilities + Settings. Logs gets the row budget that Stats vacated.
const wideLayout = cols >= 150;
const wideLogsAvailableRows = Math.max(1, logsShare + bottomShare - 4);
tuiDebug("StatusModeGrid", { cols, rows, middleHeight, logsShare, bottomShare, focused, wideLayout });
return ( return (
<Box flexDirection="column" flexGrow={1}> <Box flexDirection="column" flexGrow={1}>
@@ -732,6 +736,32 @@ function StatusModeGrid({
<Box height={4} flexShrink={0} overflow="hidden"> <Box height={4} flexShrink={0} overflow="hidden">
<SystemPanel state={state} isFocused={focused === "system"} /> <SystemPanel state={state} isFocused={focused === "system"} />
</Box> </Box>
{wideLayout ? (
<>
{/* Wide: Stats sits left of Logs and absorbs the bottom-row height. */}
<Box flexGrow={1} flexShrink={0} flexDirection="row" overflow="hidden">
<Box flexDirection="column" width="30%" flexShrink={0} overflow="hidden">
<StatsPanel state={state} isFocused={focused === "stats"} />
</Box>
<Box flexGrow={1} flexDirection="column" overflow="hidden">
<LogsPanel
state={state}
isFocused={focused === "logs"}
availableRows={wideLogsAvailableRows}
/>
</Box>
</Box>
<Box flexDirection="row" flexShrink={2} overflow="hidden">
<Box flexDirection="column" flexGrow={1} flexBasis={0} overflow="hidden">
<UtilitiesPanel state={state} isFocused={focused === "utilities"} />
</Box>
<Box flexDirection="column" flexGrow={1} flexBasis={0} overflow="hidden">
<SettingsPanel state={state} isFocused={focused === "settings"} />
</Box>
</Box>
</>
) : (
<>
{/* Logs: fills remaining vertical space. flexShrink=0 so System and {/* Logs: fills remaining vertical space. flexShrink=0 so System and
the bottom row collapse first — Logs keeps its space. */} the bottom row collapse first — Logs keeps its space. */}
<Box flexGrow={1} flexShrink={0} flexDirection="column" overflow="hidden"> <Box flexGrow={1} flexShrink={0} flexDirection="column" overflow="hidden">
@@ -754,6 +784,8 @@ function StatusModeGrid({
<SettingsPanel state={state} isFocused={focused === "settings"} /> <SettingsPanel state={state} isFocused={focused === "settings"} />
</Box> </Box>
</Box> </Box>
</>
)}
</Box> </Box>
<Box flexShrink={0}> <Box flexShrink={0}>
@@ -829,20 +861,27 @@ function StatusBar({ state, controller: _controller }: { state: DashboardState;
const uptimePart = systemInfo ? formatUptime(Date.now() - systemInfo.startTimeMs) : null; const uptimePart = systemInfo ? formatUptime(Date.now() - systemInfo.startTimeMs) : null;
const versionPart = systemInfo ? `${systemInfo.baseUrl} v${FUSION_VERSION}` : null; const versionPart = systemInfo ? `${systemInfo.baseUrl} v${FUSION_VERSION}` : null;
// Truncate both halves so the StatusBar always fits in a single row. // Both halves must collapse into a single row regardless of width.
// Without this, default wrap="wrap" lets long hotkey strings or URLs // The right side is rendered as a single Text (not a Box of separate
// wrap to 2+ rows, throwing the layout's row budget off by 1-2 rows // Texts joined with gap+flexShrink=0) so Yoga can truncate it when
// and pushing the header off the top of the alt-screen. // the natural width of hotkeys + version exceeds `cols`. With the
// previous structure, the row's natural width could exceed `cols`
// (~137 in the worst case: utilities hotkeys + localhost URL + uptime
// + version + ●), causing the row to wrap to 2 lines; height={1} +
// overflow=hidden then clipped the top line, making the hotkeys row
// disappear and only the version row show through.
const rightParts: string[] = [];
if (uptimePart) rightParts.push(uptimePart, "|");
if (versionPart) rightParts.push(versionPart);
const rightText = rightParts.join(" ");
return ( return (
<Box height={1} justifyContent="space-between" paddingX={1} flexShrink={0} overflow="hidden"> <Box height={1} justifyContent="space-between" paddingX={1} flexShrink={0} overflow="hidden">
<Text dimColor wrap="truncate-end">{hotkeys.join(" · ")}</Text> <Text dimColor wrap="truncate-end">{hotkeys.join(" · ")}</Text>
{versionPart && ( {rightText && (
<Box flexDirection="row" gap={1} flexShrink={0}> <Text wrap="truncate-end">
{uptimePart && <Text dimColor wrap="truncate-end">{uptimePart}</Text>} <Text dimColor>{rightText}</Text>
{uptimePart && <Text dimColor wrap="truncate-end">|</Text>} {hasUpdate && <Text color="yellow">{" ●"}</Text>}
<Text dimColor wrap="truncate-end">{versionPart}</Text> </Text>
{hasUpdate && <Text color="yellow" wrap="truncate-end"></Text>}
</Box>
)} )}
</Box> </Box>
); );

View File

@@ -283,16 +283,36 @@ export function CustomModelDropdown({
setPortalRoot(document.body); setPortalRoot(document.body);
}, []); }, []);
// Reset highlighted index when opening // Initialise the highlighted option on open. We only seed once per open
// session — re-seeding on every optionsList change (which fires on each
// keystroke into the filter) was snapping highlightedIndex back to the
// current model's position, and the scrollIntoView effect below then
// yanked the list back to that row, making filtering feel like the
// dropdown was "refreshing" or fighting the user's scroll.
const didInitHighlightRef = useRef(false);
useEffect(() => { useEffect(() => {
if (isOpen) { if (!isOpen) {
didInitHighlightRef.current = false;
return;
}
if (didInitHighlightRef.current) return;
if (optionsList.length === 0) return;
const selectableIndex = optionsList.findIndex( const selectableIndex = optionsList.findIndex(
(opt, idx) => idx >= (currentValueIndex >= 0 ? currentValueIndex : 0) && opt.type !== "provider" (opt, idx) => idx >= (currentValueIndex >= 0 ? currentValueIndex : 0) && opt.type !== "provider"
); );
setHighlightedIndex(selectableIndex >= 0 ? selectableIndex : 0); setHighlightedIndex(selectableIndex >= 0 ? selectableIndex : 0);
} didInitHighlightRef.current = true;
}, [isOpen, optionsList, currentValueIndex]); }, [isOpen, optionsList, currentValueIndex]);
// When the filter changes, reset to the first option and scroll the list
// back to the top instead of keeping a now-invalid highlight position.
useEffect(() => {
if (!isOpen) return;
if (!didInitHighlightRef.current) return;
setHighlightedIndex(0);
if (listRef.current) listRef.current.scrollTop = 0;
}, [localFilter, isOpen]);
// Focus search input and position dropdown when opening // Focus search input and position dropdown when opening
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;

View File

@@ -1,11 +1,24 @@
/* ===== Model Onboarding Modal ===== */ /* ===== Model Onboarding Modal ===== */
.model-onboarding-modal { .model-onboarding-modal {
max-width: 880px; /* Resizable via the native CSS grip. The user's chosen size is persisted
width: 90vw; by useModalResizePersist. Min/max keep the dimensions sane so a value
max-height: 85vh; saved on a 4K display still renders on a laptop. */
min-width: 640px;
min-height: 480px;
max-width: calc(100vw - 40px);
max-height: calc(100dvh - 40px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden;
resize: both;
}
.model-onboarding-modal:not([style*="width"]) {
width: min(1100px, calc(100vw - 40px));
}
.model-onboarding-modal:not([style*="height"]) {
height: min(85vh, calc(100dvh - 40px));
} }
.model-onboarding-header { .model-onboarding-header {
@@ -414,7 +427,10 @@
margin: 0 0 var(--space-md) 0; margin: 0 0 var(--space-md) 0;
} }
/* Provider cards in onboarding - enhanced card layout for AI setup step */ /* Provider cards in onboarding - enhanced card layout for AI setup step.
flex-wrap lets the API-key form drop to a full-width row beneath the
icon+body header instead of being crammed into a fixed side column,
which was the root of the squished layout at narrow modal widths. */
.onboarding-provider-card { .onboarding-provider-card {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -424,6 +440,8 @@
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--card); background: var(--card);
transition: background var(--transition-fast), border-color var(--transition-fast); transition: background var(--transition-fast), border-color var(--transition-fast);
flex-wrap: wrap;
min-width: 0;
} }
.onboarding-provider-card:hover { .onboarding-provider-card:hover {
@@ -442,11 +460,12 @@
} }
.onboarding-provider-card__body { .onboarding-provider-card__body {
flex: 1; flex: 1 1 240px;
min-width: 0; min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-xs); gap: var(--space-xs);
overflow-wrap: anywhere;
} }
.onboarding-provider-card__name { .onboarding-provider-card__name {
@@ -463,6 +482,13 @@
line-height: 1.4; line-height: 1.4;
} }
/* Status badge sits in a flex-column body — keep it sized to its text
instead of stretching the full row width. */
.onboarding-provider-card__body > .auth-status-badge,
.onboarding-provider-card__body > .auth-key-hint {
align-self: flex-start;
}
.onboarding-provider-card__actions { .onboarding-provider-card__actions {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
@@ -489,7 +515,11 @@
.onboarding-provider-card__actions--api-key { .onboarding-provider-card__actions--api-key {
align-items: stretch; align-items: stretch;
width: min(calc(var(--space-2xl) * 10), 100%); /* Force the API-key form onto its own full-width row beneath the
icon+body header so the input has space to breathe. */
flex: 1 0 100%;
width: 100%;
min-width: 0;
} }
.auth-provider-card--cli .auth-provider-info { .auth-provider-card--cli .auth-provider-info {
@@ -833,6 +863,34 @@
opacity: 0.5; opacity: 0.5;
} }
.model-onboarding-github-optional .optional-icon--github {
color: var(--text);
opacity: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* ProviderIcon caps at lg=24px — scale up so the GitHub mark reads as the
hero of this section. */
.model-onboarding-github-optional .optional-icon--github svg {
width: 64px;
height: 64px;
}
.model-onboarding-github-optional__actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--space-sm);
}
.model-onboarding-github-optional__actions .btn {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.model-onboarding-github-optional p { .model-onboarding-github-optional p {
color: var(--text-muted); color: var(--text-muted);
max-width: 320px; max-width: 320px;
@@ -1142,6 +1200,13 @@
gap: 6px; gap: 6px;
} }
/* When the footer holds only the "Get Started" button (final "complete"
step), push it to the right edge instead of hugging the left under
`justify-content: space-between`. */
.model-onboarding-footer > .btn-primary:only-child {
margin-inline-start: auto;
}
.onboarding-skip-step-link { .onboarding-skip-step-link {
background: none; background: none;
border: none; border: none;

View File

@@ -15,6 +15,7 @@ import {
createTask, createTask,
} from "../api"; } from "../api";
import type { ToastType } from "../hooks/useToast"; import type { ToastType } from "../hooks/useToast";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { CustomModelDropdown } from "./CustomModelDropdown"; import { CustomModelDropdown } from "./CustomModelDropdown";
import { ProviderIcon } from "./ProviderIcon"; import { ProviderIcon } from "./ProviderIcon";
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard"; import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
@@ -594,6 +595,7 @@ export function ModelOnboardingModal({
const [apiKeySuccess, setApiKeySuccess] = useState<Record<string, string | null>>({}); const [apiKeySuccess, setApiKeySuccess] = useState<Record<string, string | null>>({});
const apiKeySuccessTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({}); const apiKeySuccessTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
const onboardingContentRef = useRef<HTMLDivElement | null>(null); const onboardingContentRef = useRef<HTMLDivElement | null>(null);
const modalRef = useRef<HTMLDivElement | null>(null);
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [loginOutcomes, setLoginOutcomes] = useState<Record<string, LoginOutcome>>({}); const [loginOutcomes, setLoginOutcomes] = useState<Record<string, LoginOutcome>>({});
const [isGithubSkipped, setIsGithubSkipped] = useState<boolean>(() => { const [isGithubSkipped, setIsGithubSkipped] = useState<boolean>(() => {
@@ -606,6 +608,17 @@ export function ModelOnboardingModal({
const resumedFromStep = persistedState?.currentStep; const resumedFromStep = persistedState?.currentStep;
const isResumedFlow = !!persistedState && persistedState.currentStep !== "complete"; const isResumedFlow = !!persistedState && persistedState.currentStep !== "complete";
useModalResizePersist(modalRef, isOpen, "fusion:model-onboarding-modal-size");
// Scroll the content area to the top whenever the step changes so the user
// always lands at the start of the next page instead of mid-scroll from the
// previous one.
useEffect(() => {
const content = onboardingContentRef.current;
if (!content) return;
content.scrollTop = 0;
}, [step]);
// Initialize skippedProviders from persisted state // Initialize skippedProviders from persisted state
const [skippedProviders, setSkippedProviders] = useState<Record<string, boolean>>( const [skippedProviders, setSkippedProviders] = useState<Record<string, boolean>>(
() => { () => {
@@ -1743,7 +1756,7 @@ export function ModelOnboardingModal({
aria-modal="true" aria-modal="true"
aria-labelledby="onboarding-title" aria-labelledby="onboarding-title"
> >
<div className="modal model-onboarding-modal"> <div className="modal model-onboarding-modal" ref={modalRef}>
{/* Header */} {/* Header */}
<div className="model-onboarding-header"> <div className="model-onboarding-header">
<h2 id="onboarding-title" className="model-onboarding-title"> <h2 id="onboarding-title" className="model-onboarding-title">
@@ -2028,9 +2041,18 @@ export function ModelOnboardingModal({
{step === "github" && ( {step === "github" && (
<div className="model-onboarding-github"> <div className="model-onboarding-github">
{isGitHubReady ? (
<p className="model-onboarding-description">
{isGitHubReadyViaCli
? "GitHub CLI is already authenticated — issue imports and pull request tracking work right now. You're all set; no further action needed."
: "GitHub is connected — issue imports and pull request tracking are available. You're all set; no further action needed."}
</p>
) : (
<p className="model-onboarding-description"> <p className="model-onboarding-description">
Connecting GitHub unlocks issue imports and pull request tracking. You can skip this task creation works without it. Connecting GitHub unlocks issue imports and pull request tracking. You can skip this task creation works without it.
</p> </p>
)}
{!isGitHubReady && (
<div className="onboarding-feature-list"> <div className="onboarding-feature-list">
<ul> <ul>
<li className="onboarding-feature-list-heading"> <li className="onboarding-feature-list-heading">
@@ -2047,6 +2069,7 @@ export function ModelOnboardingModal({
<li className="onboarding-helper-text onboarding-feature-list-item--with-github">Link code changes to tasks</li> <li className="onboarding-helper-text onboarding-feature-list-item--with-github">Link code changes to tasks</li>
</ul> </ul>
</div> </div>
)}
{/* Skip-state banner: shown when AI setup was skipped */} {/* Skip-state banner: shown when AI setup was skipped */}
{aiSetupSkipped && ( {aiSetupSkipped && (
@@ -2067,11 +2090,13 @@ export function ModelOnboardingModal({
{!hasGithubProvider ? ( {!hasGithubProvider ? (
<div className="model-onboarding-github-optional"> <div className="model-onboarding-github-optional">
<GitPullRequest size={48} className="optional-icon" /> <div className="optional-icon optional-icon--github" aria-hidden="true">
<ProviderIcon provider="github" size="lg" />
</div>
{isGitHubReadyViaCli ? ( {isGitHubReadyViaCli ? (
<p> <p>
GitHub CLI is already authenticated, so imports and PR tracking work now. GitHub CLI is already authenticated, so imports and PR tracking work now.
OAuth integration in Settings Authentication is optional and only controls OAuth from the dashboard is optional and only controls
dashboard-managed connect/disconnect. dashboard-managed connect/disconnect.
</p> </p>
) : ( ) : (
@@ -2080,12 +2105,38 @@ export function ModelOnboardingModal({
or continue now and connect later. or continue now and connect later.
</p> </p>
)} )}
<div className="model-onboarding-github-optional__actions">
<button <button
className="btn btn-primary btn-sm" className="btn btn-primary btn-sm"
onClick={() => setStep("project-setup")} onClick={() => setStep("project-setup")}
> >
Continue without GitHub {isGitHubReadyViaCli
? "Continue with gh CLI auth →"
: "Continue without GitHub →"}
</button> </button>
{isGitHubReadyViaCli && (
authActionInProgress === "github" ? (
<button className="btn btn-sm" disabled>
<Loader2 size={14} className="onboarding-spinner" />
Waiting for OAuth login
</button>
) : (
<button
className="btn btn-sm"
onClick={() => handleLogin("github")}
>
<ProviderIcon provider="github" size="sm" />
Connect OAuth (optional)
</button>
)
)}
</div>
{isGitHubReadyViaCli && authActionInProgress === "github" && loginInstructions.github && (
<LoginInstructions
instructions={loginInstructions.github}
data-testid="onboarding-login-instructions-github"
/>
)}
</div> </div>
) : ( ) : (
<> <>