From b7dace5b9517a85613962f07a000a9cfde37fa26 Mon Sep 17 00:00:00 2001 From: juan <2930882+juacker@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:40:45 +0200 Subject: [PATCH] feat(cards): cap height + collapsible inline approval/ask/path-grant cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline ask_user / permission / path-grant cards rendered in-flow at the end of the conversation with no max-height and no overflow, so a long prompt or several stacked cards grew unbounded and — with auto-scroll to bottom — swallowed the viewport, hiding the conversation (the F9 report). Keep them embedded in flow (the right model: the decision needs its conversation context) and bound them instead: - Each card body is capped at min(60vh, 600px) and scrolls internally past the cap, so it can never fully cover the conversation. Action buttons live inside and are reachable by scrolling within the card. - A collapse toggle in each card header squishes the body to a one-line summary (the command / the path / the question) so the user can peek at the conversation behind a pending card, then expand to act. Default expanded; AskUserPanel resets to expanded per new request. All frontend, no backend change. Adds collapse regression tests for the AskUserPanel and InlineApprovalCard. --- .../AskUserPanel/AskUserPanel.module.css | 44 ++++++++++++++++++ .../AskUserPanel/AskUserPanel.test.tsx | 20 +++++++++ src/components/AskUserPanel/AskUserPanel.tsx | 20 +++++++++ src/components/InlineApprovalCard.module.css | 45 +++++++++++++++++++ src/components/InlineApprovalCard.test.tsx | 20 +++++++++ src/components/InlineApprovalCard.tsx | 29 ++++++++++++ src/components/InlinePathGrantCard.module.css | 45 +++++++++++++++++++ src/components/InlinePathGrantCard.tsx | 29 ++++++++++++ 8 files changed, 252 insertions(+) diff --git a/src/components/AskUserPanel/AskUserPanel.module.css b/src/components/AskUserPanel/AskUserPanel.module.css index 59aded29..175fce0c 100644 --- a/src/components/AskUserPanel/AskUserPanel.module.css +++ b/src/components/AskUserPanel/AskUserPanel.module.css @@ -168,3 +168,47 @@ border-radius: var(--radius-sm); padding: 6px 10px; } + +.collapseBtn { + margin-left: auto; + align-self: center; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: 1px solid var(--color-border-light); + border-radius: var(--radius-xs); + background: var(--color-bg-elevated); + color: var(--color-text-secondary); + cursor: pointer; + font-size: 11px; + line-height: 1; + transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; +} + +.collapseBtn:hover { + background: var(--color-bg-hover); + color: var(--color-text-primary); + border-color: var(--color-border-strong); +} + +/* Cap the body so a long question + options can never swallow the + conversation; it scrolls internally past the cap. */ +.body { + display: flex; + flex-direction: column; + gap: 12px; + max-height: min(60vh, 600px); + overflow-y: auto; +} + +.collapsedSummary { + font-size: 13px; + color: var(--color-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/src/components/AskUserPanel/AskUserPanel.test.tsx b/src/components/AskUserPanel/AskUserPanel.test.tsx index 399cb59c..d7854664 100644 --- a/src/components/AskUserPanel/AskUserPanel.test.tsx +++ b/src/components/AskUserPanel/AskUserPanel.test.tsx @@ -319,3 +319,23 @@ describe('AskUserPanel — snapshot poll race regression', () => { expect(screen.getByText('Which option do you want?')).toBeInTheDocument(); }); }); + +describe('AskUserPanel — collapse', () => { + it('collapses the body to a one-line summary and expands again', async () => { + const user = userEvent.setup(); + mountWithPending(askUserRequest()); + + // Expanded by default: the options are visible. + expect(screen.getByText('Option A')).toBeInTheDocument(); + + // Collapse hides the body (options/textarea) but keeps the question + // visible as a summary, so the conversation behind it is reachable. + await user.click(screen.getByRole('button', { name: /collapse question/i })); + expect(screen.queryByText('Option A')).toBeNull(); + expect(screen.getByText('Which option do you want?')).toBeInTheDocument(); + + // Expand restores the full body. + await user.click(screen.getByRole('button', { name: /expand question/i })); + expect(screen.getByText('Option A')).toBeInTheDocument(); + }); +}); diff --git a/src/components/AskUserPanel/AskUserPanel.tsx b/src/components/AskUserPanel/AskUserPanel.tsx index 348b3566..72978574 100644 --- a/src/components/AskUserPanel/AskUserPanel.tsx +++ b/src/components/AskUserPanel/AskUserPanel.tsx @@ -36,6 +36,9 @@ const AskUserPanel = ({ sessionId }: AskUserPanelProps) => { const [otherText, setOtherText] = useState(''); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(''); + // Collapse the body to a one-line summary so a long question can't swallow + // the conversation; the user expands to answer. Resets per new request. + const [collapsed, setCollapsed] = useState(false); const containerRef = useRef(null); const previousPendingIdRef = useRef(null); @@ -51,6 +54,7 @@ const AskUserPanel = ({ sessionId }: AskUserPanelProps) => { setOtherText(''); setError(''); setSubmitting(false); + setCollapsed(false); }, [pending?.pendingId]); // Focus the panel when a request appears so keyboard users land @@ -169,8 +173,22 @@ const AskUserPanel = ({ sessionId }: AskUserPanelProps) => { >
AGENT IS ASKING +
+ {collapsed ? ( +
{pending.question}
+ ) : ( +
{pending.question}
{pending.extraContext && ( @@ -269,6 +287,8 @@ const AskUserPanel = ({ sessionId }: AskUserPanelProps) => { {submitting ? 'Sending…' : 'Send answer'}
+ + )} ); }; diff --git a/src/components/InlineApprovalCard.module.css b/src/components/InlineApprovalCard.module.css index 2fdacaca..3be69bc9 100644 --- a/src/components/InlineApprovalCard.module.css +++ b/src/components/InlineApprovalCard.module.css @@ -221,3 +221,48 @@ background: var(--color-critical-bg, rgba(220, 38, 38, 0.04)); } +.collapseBtn { + margin-left: auto; + align-self: flex-start; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: 1px solid var(--color-border-light); + border-radius: var(--radius-xs); + background: var(--color-bg-elevated); + color: var(--color-text-secondary); + cursor: pointer; + font-size: 11px; + line-height: 1; + transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; +} + +.collapseBtn:hover { + background: var(--color-bg-hover); + color: var(--color-text-primary); + border-color: var(--color-border-strong); +} + +/* Cap the body so a tall (or stacked) request can't swallow the + conversation; it scrolls internally past the cap. Buttons live inside, + reachable by scrolling within the card. */ +.cardBody { + display: flex; + flex-direction: column; + gap: 10px; + max-height: min(60vh, 600px); + overflow-y: auto; +} + +/* Collapsed one-line summary (applied alongside .command); must override + .command's pre-wrap, so it is declared after .command in source order. */ +.collapsedSummary { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + word-break: normal; +} diff --git a/src/components/InlineApprovalCard.test.tsx b/src/components/InlineApprovalCard.test.tsx index d4c791d6..ab8e2b1e 100644 --- a/src/components/InlineApprovalCard.test.tsx +++ b/src/components/InlineApprovalCard.test.tsx @@ -120,4 +120,24 @@ describe('InlineApprovalCard', () => { await waitFor(() => expect(screen.queryByText('rg --files')).toBeNull()); }); + + it('collapses the card body and expands it again', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => expect(listenHandlers['permissions://request']).toBeTruthy()); + fireRequest(SINGLE_SEGMENT_REQUEST); + await screen.findByText('rg --files'); + + // Expanded by default: the decision buttons are present. + expect(screen.getByRole('button', { name: /allow once/i })).toBeInTheDocument(); + + // Collapse hides the body (buttons) but keeps the command summary. + await user.click(screen.getByRole('button', { name: /collapse request/i })); + expect(screen.queryByRole('button', { name: /allow once/i })).toBeNull(); + expect(screen.getByText('rg --files')).toBeInTheDocument(); + + // Expand restores the decision buttons. + await user.click(screen.getByRole('button', { name: /expand request/i })); + expect(screen.getByRole('button', { name: /allow once/i })).toBeInTheDocument(); + }); }); diff --git a/src/components/InlineApprovalCard.tsx b/src/components/InlineApprovalCard.tsx index 70c58007..0703b57e 100644 --- a/src/components/InlineApprovalCard.tsx +++ b/src/components/InlineApprovalCard.tsx @@ -63,6 +63,9 @@ const InlineApprovalCard = ({ workspaceId }: InlineApprovalCardProps) => { const [error, setError] = useState(null); const firstCardRef = useRef(null); const previousCountRef = useRef(0); + // Per-card collapse: squish a card to a one-line summary so a tall (or + // stacked) request can't swallow the conversation. Default expanded. + const [collapsedIds, setCollapsedIds] = useState>(new Set()); // Subscribe to backend approval-request events AND seed from the // backend's pending list on mount. The seed catches requests that @@ -182,6 +185,15 @@ const InlineApprovalCard = ({ workspaceId }: InlineApprovalCardProps) => { } }, [requests.length]); + const toggleCollapsed = useCallback((requestId: string) => { + setCollapsedIds((current) => { + const next = new Set(current); + if (next.has(requestId)) next.delete(requestId); + else next.add(requestId); + return next; + }); + }, []); + const dismissRequest = useCallback((requestId: string) => { setRequests((current) => current.filter((q) => q.requestId !== requestId)); }, []); @@ -290,6 +302,7 @@ const InlineApprovalCard = ({ workspaceId }: InlineApprovalCardProps) => { {requests.map((req, cardIndex) => { const cardState = perCardState[req.requestId] || {}; const isSubmitting = submittingId === req.requestId; + const isCollapsed = collapsedIds.has(req.requestId); return (
{ : 'An agent wants to run:'} + + {isCollapsed ? ( +
{req.command}
+ ) : ( +
{req.command}

@@ -420,6 +447,8 @@ const InlineApprovalCard = ({ workspaceId }: InlineApprovalCardProps) => { )} +

+ )}
); })} diff --git a/src/components/InlinePathGrantCard.module.css b/src/components/InlinePathGrantCard.module.css index 0ae5472f..20cb66b6 100644 --- a/src/components/InlinePathGrantCard.module.css +++ b/src/components/InlinePathGrantCard.module.css @@ -250,3 +250,48 @@ color: var(--color-critical-dark, #B91C1C); font-size: 12px; } + +.collapseBtn { + margin-left: auto; + align-self: flex-start; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: 1px solid var(--color-border-light); + border-radius: var(--radius-xs); + background: var(--color-bg-elevated); + color: var(--color-text-secondary); + cursor: pointer; + font-size: 11px; + line-height: 1; + transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; +} + +.collapseBtn:hover { + background: var(--color-bg-hover); + color: var(--color-text-primary); + border-color: var(--color-border-strong); +} + +/* Cap the body so a tall (or stacked) request can't swallow the + conversation; it scrolls internally past the cap. */ +.cardBody { + display: flex; + flex-direction: column; + gap: 10px; + max-height: min(60vh, 600px); + overflow-y: auto; +} + +.collapsedSummary { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace; + font-size: 12px; + color: var(--color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/src/components/InlinePathGrantCard.tsx b/src/components/InlinePathGrantCard.tsx index 302bdb6c..a88431d7 100644 --- a/src/components/InlinePathGrantCard.tsx +++ b/src/components/InlinePathGrantCard.tsx @@ -102,6 +102,9 @@ const InlinePathGrantCard = ({ workspaceId }: InlinePathGrantCardProps) => { const [error, setError] = useState(null); const firstCardRef = useRef(null); const previousCountRef = useRef(0); + // Per-card collapse: squish a card to a one-line summary so a tall (or + // stacked) request can't swallow the conversation. Default expanded. + const [collapsedIds, setCollapsedIds] = useState>(new Set()); useEffect(() => { // Switching workspaces reuses this component instance — the Workspace @@ -206,6 +209,15 @@ const InlinePathGrantCard = ({ workspaceId }: InlinePathGrantCardProps) => { } }, [requests.length]); + const toggleCollapsed = useCallback((requestId: string) => { + setCollapsedIds((current) => { + const next = new Set(current); + if (next.has(requestId)) next.delete(requestId); + else next.add(requestId); + return next; + }); + }, []); + const dismissRequest = useCallback((requestId: string) => { setRequests((current) => current.filter((q) => q.requestId !== requestId)); }, []); @@ -255,6 +267,7 @@ const InlinePathGrantCard = ({ workspaceId }: InlinePathGrantCardProps) => { access: req.requestedAccess, }; const isSubmitting = submittingId === req.requestId; + const isCollapsed = collapsedIds.has(req.requestId); // Validation: edited path must be the original or a descendant // (component-wise prefix). Edited access must be no stronger @@ -289,8 +302,22 @@ const InlinePathGrantCard = ({ workspaceId }: InlinePathGrantCardProps) => { : 'An agent wants to extend its filesystem grants:'} + + {isCollapsed ? ( +
{card.path}
+ ) : ( +
Reason from agent:
{req.reason || '(no reason given)'}
@@ -410,6 +437,8 @@ const InlinePathGrantCard = ({ workspaceId }: InlinePathGrantCardProps) => { {error && submittingId === null && (
{error}
)} +
+ )} ); })}