Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/components/AskUserPanel/AskUserPanel.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
20 changes: 20 additions & 0 deletions src/components/AskUserPanel/AskUserPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
20 changes: 20 additions & 0 deletions src/components/AskUserPanel/AskUserPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement | null>(null);
const previousPendingIdRef = useRef<string | null>(null);

Expand All @@ -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
Expand Down Expand Up @@ -169,8 +173,22 @@ const AskUserPanel = ({ sessionId }: AskUserPanelProps) => {
>
<header className={styles.header}>
<span className={styles.chip}>AGENT IS ASKING</span>
<button
type="button"
className={styles.collapseBtn}
onClick={() => setCollapsed((c) => !c)}
aria-expanded={!collapsed}
aria-label={collapsed ? 'Expand question' : 'Collapse question'}
title={collapsed ? 'Expand' : 'Collapse'}
>
{collapsed ? '▸' : '▾'}
</button>
</header>

{collapsed ? (
<div className={styles.collapsedSummary}>{pending.question}</div>
) : (
<div className={styles.body}>
<div className={styles.question}>{pending.question}</div>

{pending.extraContext && (
Expand Down Expand Up @@ -269,6 +287,8 @@ const AskUserPanel = ({ sessionId }: AskUserPanelProps) => {
{submitting ? 'Sending…' : 'Send answer'}
</button>
</div>
</div>
)}
</section>
);
};
Expand Down
45 changes: 45 additions & 0 deletions src/components/InlineApprovalCard.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
20 changes: 20 additions & 0 deletions src/components/InlineApprovalCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<InlineApprovalCard workspaceId="ws-1" />);
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();
});
});
29 changes: 29 additions & 0 deletions src/components/InlineApprovalCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ const InlineApprovalCard = ({ workspaceId }: InlineApprovalCardProps) => {
const [error, setError] = useState<string | null>(null);
const firstCardRef = useRef<HTMLElement | null>(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<Set<string>>(new Set());

// Subscribe to backend approval-request events AND seed from the
// backend's pending list on mount. The seed catches requests that
Expand Down Expand Up @@ -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));
}, []);
Expand Down Expand Up @@ -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 (
<article
key={req.requestId}
Expand All @@ -306,7 +319,21 @@ const InlineApprovalCard = ({ workspaceId }: InlineApprovalCardProps) => {
: 'An agent wants to run:'}
</span>
</div>
<button
type="button"
className={styles.collapseBtn}
onClick={() => toggleCollapsed(req.requestId)}
aria-expanded={!isCollapsed}
aria-label={isCollapsed ? 'Expand request' : 'Collapse request'}
title={isCollapsed ? 'Expand' : 'Collapse'}
>
{isCollapsed ? '▸' : '▾'}
</button>
</header>
{isCollapsed ? (
<pre className={`${styles.command} ${styles.collapsedSummary}`}>{req.command}</pre>
) : (
<div className={styles.cardBody}>
<pre className={styles.command}>{req.command}</pre>
<section className={styles.segments}>
<p className={styles.segmentsLabel}>
Expand Down Expand Up @@ -420,6 +447,8 @@ const InlineApprovalCard = ({ workspaceId }: InlineApprovalCardProps) => {
</button>
</footer>
)}
</div>
)}
</article>
);
})}
Expand Down
45 changes: 45 additions & 0 deletions src/components/InlinePathGrantCard.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
29 changes: 29 additions & 0 deletions src/components/InlinePathGrantCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ const InlinePathGrantCard = ({ workspaceId }: InlinePathGrantCardProps) => {
const [error, setError] = useState<string | null>(null);
const firstCardRef = useRef<HTMLElement | null>(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<Set<string>>(new Set());

useEffect(() => {
// Switching workspaces reuses this component instance — the Workspace
Expand Down Expand Up @@ -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));
}, []);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -289,8 +302,22 @@ const InlinePathGrantCard = ({ workspaceId }: InlinePathGrantCardProps) => {
: 'An agent wants to extend its filesystem grants:'}
</span>
</div>
<button
type="button"
className={styles.collapseBtn}
onClick={() => toggleCollapsed(req.requestId)}
aria-expanded={!isCollapsed}
aria-label={isCollapsed ? 'Expand request' : 'Collapse request'}
title={isCollapsed ? 'Expand' : 'Collapse'}
>
{isCollapsed ? '▸' : '▾'}
</button>
</header>

{isCollapsed ? (
<div className={styles.collapsedSummary}>{card.path}</div>
) : (
<div className={styles.cardBody}>
<div className={styles.reasonBlock}>
<span className={styles.reasonLabel}>Reason from agent:</span>
<div className={styles.reasonText}>{req.reason || '(no reason given)'}</div>
Expand Down Expand Up @@ -410,6 +437,8 @@ const InlinePathGrantCard = ({ workspaceId }: InlinePathGrantCardProps) => {
{error && submittingId === null && (
<div className={styles.error}>{error}</div>
)}
</div>
)}
</article>
);
})}
Expand Down
Loading