diff --git a/apps/desktop/src/renderer/components/ui/DetailDialog.tsx b/apps/desktop/src/renderer/components/ui/DetailDialog.tsx new file mode 100644 index 0000000..fb73bc7 --- /dev/null +++ b/apps/desktop/src/renderer/components/ui/DetailDialog.tsx @@ -0,0 +1,124 @@ +// A modal for a selected item's detail (an MCP server, a skill): a fixed header with the +// title and a close button, and a body that scrolls on its own. Opening it from a long list +// keeps the detail in view instead of appending it below the list. +// +// Escape and a backdrop click close it. Callers that stack another dialog on top should +// unmount this one meanwhile (its focus trap would otherwise hold Tab). + +import { useEffect, type CSSProperties, type ReactNode } from 'react'; +import { elevation, fontSize, radius, space, token } from '../../themes/tokens'; +import { hoverBackground } from '../../lib/hover'; +import { noDragRegion, useModalFocus } from './modal'; + +export function DetailDialog({ + labelId, + title, + closeLabel, + onClose, + children, +}: { + /** id of the heading inside `title`, for aria-labelledby. */ + labelId: string; + title: ReactNode; + closeLabel: string; + onClose: () => void; + children: ReactNode; +}) { + const panelRef = useModalFocus(); + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent): void => { + if (e.key === 'Escape' && !e.defaultPrevented) onClose(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [onClose]); + + return ( +
+
e.stopPropagation()} + > +
+
{title}
+ +
+
{children}
+
+
+ ); +} + +const overlayStyle: CSSProperties = { + position: 'fixed', + ...noDragRegion, + inset: 0, + background: token('overlay'), + display: 'grid', + placeItems: 'center', + padding: space['2xl'], + zIndex: 20, +}; + +const panelStyle: CSSProperties = { + width: 'min(760px, 100%)', + maxHeight: '100%', + boxSizing: 'border-box', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + background: token('bg'), + color: token('text'), + border: `1px solid ${token('border')}`, + borderRadius: radius.xl, + boxShadow: elevation('modal'), + outline: 'none', +}; + +const headerStyle: CSSProperties = { + display: 'flex', + alignItems: 'flex-start', + gap: space.md, + flexShrink: 0, + padding: `${space.xl}px ${space.xl}px ${space.md}px`, + borderBottom: `1px solid ${token('border')}`, +}; + +const bodyStyle: CSSProperties = { + flex: 1, + minHeight: 0, + overflowY: 'auto', + overscrollBehavior: 'contain', + padding: `${space.lg}px ${space.xl}px ${space.xl}px`, +}; + +const closeButtonStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + width: 28, + height: 28, + padding: 0, + borderRadius: radius.sm, + border: 'none', + background: 'transparent', + color: token('textMuted'), + cursor: 'pointer', + fontSize: fontSize.xl, + lineHeight: 1, +}; diff --git a/apps/desktop/src/renderer/features/mcp/McpHome.tsx b/apps/desktop/src/renderer/features/mcp/McpHome.tsx index 51c0ac3..5672833 100644 --- a/apps/desktop/src/renderer/features/mcp/McpHome.tsx +++ b/apps/desktop/src/renderer/features/mcp/McpHome.tsx @@ -13,6 +13,7 @@ import { useCallback, useEffect, useState, type CSSProperties, type FormEvent } from 'react'; import { disabledOpacity, elevation, fontSize, radius, space, token } from '../../themes/tokens'; +import { DetailDialog } from '../../components/ui/DetailDialog'; import { hoverBackground } from '../../lib/hover'; import { useT, type TranslateFn } from '../../i18n'; import { ScrollTabScreen } from '../../components/TabScreenShell'; @@ -209,9 +210,11 @@ export function McpHome() { } /> - {selectedRow && ( + {/* Hidden while the edit form is open: one modal at a time. */} + {selectedRow && !editing && ( (confirm ? setConfirm(null) : setSelected(null))} agents={columns} busy={busy} confirm={confirm} @@ -460,6 +463,7 @@ function CellChip({ info, cell, t }: { info: McpAgentInfo; cell: McpMatrixRow['a function DetailPanel({ row, + onClose, agents, busy, confirm, @@ -478,6 +482,7 @@ function DetailPanel({ onGateway, }: { row: McpMatrixRow; + onClose: () => void; agents: McpAgentInfo[]; busy: boolean; confirm: Confirm | null; @@ -503,11 +508,17 @@ function DetailPanel({ const copyCount = (lib ? 1 : 0) + agents.filter((a) => row.agents[a.agentId]?.entry).length; return ( -
-
-

{row.name}

- {lib && {t(`mcp.transport.${lib.transport}`)}} -
+ +

{row.name}

+ {lib && {t(`mcp.transport.${lib.transport}`)}} + + } + > {lib ? ( <>
{mcpSummary(lib)}
@@ -644,7 +655,7 @@ function DetailPanel({ onClick={() => setConfirm({ text: copyCount <= 1 ? t('mcp.confirm.deleteAgentLast') : t('mcp.confirm.deleteAgent'), - confirmLabel: t('mcp.actions.delete'), + confirmLabel: t('mcp.actions.deleteAgent'), run: () => onDeleteAgent(a.agentId), }) } @@ -667,7 +678,7 @@ function DetailPanel({ } /> )} -
+ ); } @@ -1279,6 +1290,12 @@ const confirmBarStyle: CSSProperties = { padding: '10px 14px', marginBottom: space.lg, borderRadius: radius.md, + // Pinned to the top of the dialog body so it stays in view when it was opened from a + // row further down. + position: 'sticky', + top: 0, + zIndex: 1, + background: token('bg'), border: `1px solid ${token('danger')}`, }; diff --git a/apps/desktop/src/renderer/features/skills/SkillsHome.tsx b/apps/desktop/src/renderer/features/skills/SkillsHome.tsx index 42290bd..03768b3 100644 --- a/apps/desktop/src/renderer/features/skills/SkillsHome.tsx +++ b/apps/desktop/src/renderer/features/skills/SkillsHome.tsx @@ -12,6 +12,7 @@ import { useEffect, useMemo, useState } from 'react'; import { disabledOpacity, fontSize, radius, space, token } from '../../themes/tokens'; +import { DetailDialog } from '../../components/ui/DetailDialog'; import { hoverBackground } from '../../lib/hover'; import { useI18n, type Locale, type TranslateFn } from '../../i18n'; import { useSkills } from '../../hooks/useSkills'; @@ -252,6 +253,7 @@ export function SkillsHome() { {selectedRow && ( (confirm ? setConfirm(null) : setSelected(null))} agents={availableAgents} agentName={agentName} busy={busy} @@ -399,6 +401,7 @@ function Matrix({ function DetailPanel({ row, + onClose, agents, agentName, busy, @@ -418,6 +421,7 @@ function DetailPanel({ locale, }: { row: SkillMatrixRow; + onClose: () => void; agents: { id: AgentId; name: string }[]; agentName: (id: AgentId) => string; busy: boolean; @@ -446,16 +450,22 @@ function DetailPanel({ (hasCentral ? 1 : 0) + agents.filter((a) => row.agents[a.id]?.entry != null).length; return ( -
-
-

{row.name}

- {hasCentral && row.central && ( - - {numberFormat.format(row.central.fileCount)} {t('skills.meta.files')} ·{' '} - {formatBytes(row.central.totalBytes)} - - )} -
+ +

{row.name}

+ {hasCentral && row.central && ( + + {numberFormat.format(row.central.fileCount)} {t('skills.meta.files')} ·{' '} + {formatBytes(row.central.totalBytes)} + + )} + + } + >

{description || t('skills.detail.noDescription')}

@@ -616,7 +626,7 @@ function DetailPanel({ copyCount <= 1 ? t('skills.confirm.deleteAgentLast') : t('skills.confirm.deleteAgent'), - confirmLabel: t('skills.actions.delete'), + confirmLabel: t('skills.actions.deleteAgent'), run: async () => onDeleteAgent(a.id), }) } @@ -644,7 +654,7 @@ function DetailPanel({ t={t} /> )} -
+ ); } @@ -1117,14 +1127,6 @@ const bodyCellStyle = { whiteSpace: 'nowrap', } as const; -const detailStyle = { - marginTop: 24, - padding: space['2xl'], - border: `1px solid ${token('border')}`, - borderRadius: radius.lg, - background: token('surface'), -} as const; - const confirmBarStyle = { display: 'flex', alignItems: 'center', @@ -1132,6 +1134,12 @@ const confirmBarStyle = { padding: '10px 14px', marginBottom: 16, borderRadius: radius.md, + // Pinned to the top of the dialog body so it stays in view when it was opened from a + // row further down. + position: 'sticky', + top: 0, + zIndex: 1, + background: token('bg'), border: `1px solid ${token('danger')}`, } as const;