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 3031d05a68
commit c94e9be31c
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);
// LogsPanel chrome: border 2 + title 1 + filter 1 = 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 (
<Box flexDirection="column" flexGrow={1}>
@@ -732,28 +736,56 @@ function StatusModeGrid({
<Box height={4} flexShrink={0} overflow="hidden">
<SystemPanel state={state} isFocused={focused === "system"} />
</Box>
{/* Logs: fills remaining vertical space. flexShrink=0 so System and
the bottom row collapse first — Logs keeps its space. */}
<Box flexGrow={1} flexShrink={0} flexDirection="column" overflow="hidden">
<LogsPanel
state={state}
isFocused={focused === "logs"}
availableRows={logsAvailableRows}
/>
</Box>
{/* Bottom row: Stats + Utilities + Settings, equal-width. flexShrink=2
so when terminal height is small they collapse before Logs does. */}
<Box flexDirection="row" flexShrink={2} overflow="hidden">
<Box flexDirection="column" flexGrow={1} flexBasis={0} overflow="hidden">
<StatsPanel state={state} isFocused={focused === "stats"} />
</Box>
<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>
{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
the bottom row collapse first — Logs keeps its space. */}
<Box flexGrow={1} flexShrink={0} flexDirection="column" overflow="hidden">
<LogsPanel
state={state}
isFocused={focused === "logs"}
availableRows={logsAvailableRows}
/>
</Box>
{/* Bottom row: Stats + Utilities + Settings, equal-width. flexShrink=2
so when terminal height is small they collapse before Logs does. */}
<Box flexDirection="row" flexShrink={2} overflow="hidden">
<Box flexDirection="column" flexGrow={1} flexBasis={0} overflow="hidden">
<StatsPanel state={state} isFocused={focused === "stats"} />
</Box>
<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>
</>
)}
</Box>
<Box flexShrink={0}>
@@ -829,20 +861,27 @@ function StatusBar({ state, controller: _controller }: { state: DashboardState;
const uptimePart = systemInfo ? formatUptime(Date.now() - systemInfo.startTimeMs) : null;
const versionPart = systemInfo ? `${systemInfo.baseUrl} v${FUSION_VERSION}` : null;
// Truncate both halves so the StatusBar always fits in a single row.
// Without this, default wrap="wrap" lets long hotkey strings or URLs
// wrap to 2+ rows, throwing the layout's row budget off by 1-2 rows
// and pushing the header off the top of the alt-screen.
// Both halves must collapse into a single row regardless of width.
// The right side is rendered as a single Text (not a Box of separate
// Texts joined with gap+flexShrink=0) so Yoga can truncate it when
// 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 (
<Box height={1} justifyContent="space-between" paddingX={1} flexShrink={0} overflow="hidden">
<Text dimColor wrap="truncate-end">{hotkeys.join(" · ")}</Text>
{versionPart && (
<Box flexDirection="row" gap={1} flexShrink={0}>
{uptimePart && <Text dimColor wrap="truncate-end">{uptimePart}</Text>}
{uptimePart && <Text dimColor wrap="truncate-end">|</Text>}
<Text dimColor wrap="truncate-end">{versionPart}</Text>
{hasUpdate && <Text color="yellow" wrap="truncate-end"></Text>}
</Box>
{rightText && (
<Text wrap="truncate-end">
<Text dimColor>{rightText}</Text>
{hasUpdate && <Text color="yellow">{" ●"}</Text>}
</Text>
)}
</Box>
);