diff --git a/src/components/AskUserPanel/AskUserPanel.module.css b/src/components/AskUserPanel/AskUserPanel.module.css index 59aded2..175fce0 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 399cb59..d785466 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 348b356..7297857 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 2fdacac..3be69bc 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 d4c791d..ab8e2b1 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 70c5800..0703b57 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 0ae5472..20cb66b 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 302bdb6..a88431d 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}
)} +
+ )} ); })}