Skip to content

Commit de0b6d3

Browse files
authored
[AXON-1265] Add 'Allow all' and 'Enable YOLO mode' for mutiple tool requests (#1132)
* AXON-1265: add 'enableYolo' and 'allowAll' options * Merge remote-tracking branch 'origin' into AXON-1265-when-multiple-tool-permissions-are-requested-show merge * AXON-1265 add dropdown button component * Merge remote-tracking branch 'origin' into AXON-1265-when-multiple-tool-permissions-are-requested-show merge with main * AXON-1265: add unit test * AXON-1265: update styles * simplify allow all logic * Merge remote-tracking branch 'origin' into AXON-1265-when-multiple-tool-permissions-are-requested-show
1 parent e9e56c6 commit de0b6d3

File tree

10 files changed

+228
-23
lines changed

10 files changed

+228
-23
lines changed

src/react/atlascode/rovo-dev/RovoDev.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ body {
33
background-color: var(--vscode-sideBar-background) !important;
44
}
55

6+
.atlaskit-portal-container {
7+
--ds-background-neutral-subtle: var(--vscode-dropdown-background);
8+
--ds-background-neutral-subtle-hovered: var(--vscode-list-hoverBackground);
9+
}
10+
611
.rovoDevChat * {
712
scrollbar-width: auto;
813
scrollbar-color: var(--vscode-scrollbarSlider-background) transparent;

src/react/atlascode/rovo-dev/messaging/ChatStream.tsx

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { MinimalIssue } from '@atlassianlabs/jira-pi-common-models';
22
import * as React from 'react';
3-
import { ToolPermissionChoice } from 'src/rovo-dev/rovoDevApiClientInterfaces';
4-
import { State } from 'src/rovo-dev/rovoDevTypes';
3+
import { State, ToolPermissionDialogChoice } from 'src/rovo-dev/rovoDevTypes';
54
import { RovoDevProviderMessage, RovoDevProviderMessageType } from 'src/rovo-dev/rovoDevWebviewProviderMessages';
65
import { ConnectionTimeout } from 'src/util/time';
76

@@ -19,6 +18,7 @@ import { ToolCallItem } from '../tools/ToolCallItem';
1918
import { ToolReturnParsedItem } from '../tools/ToolReturnItem';
2019
import { DialogMessage, parseToolReturnMessage, PullRequestMessage, Response, scrollToEnd } from '../utils';
2120
import { ChatMessageItem } from './ChatMessageItem';
21+
import { DropdownButton } from './dropdown-button/DropdownButton';
2222
import { MessageDrawer } from './MessageDrawer';
2323

2424
interface ChatStreamProps {
@@ -48,7 +48,7 @@ interface ChatStreamProps {
4848
setPromptText: (context: string) => void;
4949
jiraWorkItems: MinimalIssue<DetailedSiteInfo>[] | undefined;
5050
onJiraItemClick: (issue: MinimalIssue<DetailedSiteInfo>) => void;
51-
onToolPermissionChoice: (toolCallId: string, choice: ToolPermissionChoice) => void;
51+
onToolPermissionChoice: (toolCallId: string, choice: ToolPermissionDialogChoice | 'enableYolo') => void;
5252
}
5353

5454
export const ChatStream: React.FC<ChatStreamProps> = ({
@@ -313,15 +313,36 @@ export const ChatStream: React.FC<ChatStreamProps> = ({
313313
</div>
314314
)}
315315

316-
{!isChatHistoryDisabled &&
317-
modalDialogs.map((dialog) => (
318-
<DialogMessageItem
319-
msg={dialog}
320-
isRetryAfterErrorButtonEnabled={renderProps.isRetryAfterErrorButtonEnabled}
321-
retryAfterError={renderProps.retryPromptAfterError}
322-
onToolPermissionChoice={onToolPermissionChoice}
323-
/>
324-
))}
316+
{!isChatHistoryDisabled && (
317+
<div>
318+
{modalDialogs.map((dialog) => (
319+
<DialogMessageItem
320+
msg={dialog}
321+
isRetryAfterErrorButtonEnabled={renderProps.isRetryAfterErrorButtonEnabled}
322+
retryAfterError={renderProps.retryPromptAfterError}
323+
onToolPermissionChoice={onToolPermissionChoice}
324+
/>
325+
))}
326+
{modalDialogs.length > 1 && modalDialogs.every((d) => d.type === 'toolPermissionRequest') && (
327+
<DropdownButton
328+
buttonItem={{
329+
label: 'Allow all',
330+
onSelect: () => onToolPermissionChoice(modalDialogs[0].toolCallId, 'allowAll'),
331+
}}
332+
items={[
333+
{
334+
label: 'Allow all',
335+
onSelect: () => onToolPermissionChoice(modalDialogs[0].toolCallId, 'allowAll'),
336+
},
337+
{
338+
label: 'Enable YOLO mode',
339+
onSelect: () => onToolPermissionChoice(modalDialogs[0].toolCallId, 'enableYolo'),
340+
},
341+
]}
342+
/>
343+
)}
344+
</div>
345+
)}
325346

326347
{!isChatHistoryDisabled && currentState.state === 'WaitingForPrompt' && (
327348
<FollowUpActionFooter>
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
.dropdown-button-container {
2+
width: 100%;
3+
display: flex;
4+
justify-content: flex-end;
5+
flex-direction: row;
6+
align-items: center;
7+
margin-bottom: 8px;
8+
}
9+
10+
.dropdown-button {
11+
display: flex;
12+
flex-direction: row;
13+
align-items: center;
14+
gap: 0;
15+
border: 1px solid var(--vscode-editorWidget-border);
16+
border-radius: 4px;
17+
background: var(--vscode-button-background);
18+
}
19+
20+
.dropdown-button-trigger {
21+
display: flex;
22+
background: inherit;
23+
border: none;
24+
padding: 6px 8px;
25+
color: var(--vscode-button-foreground);
26+
}
27+
.dropdown-button-trigger:hover {
28+
background: var(--vscode-button-hoverBackground);
29+
}
30+
31+
.dropdown-button-separator {
32+
width: 1px;
33+
height: var(--vscode-font-size);
34+
background-color: var(--vscode-button-foreground);
35+
}
36+
37+
.dropdown-item-container {
38+
background-color: var(--vscode-dropdown-background);
39+
border: 1px solid var(--vscode-editorWidget-border);
40+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { fireEvent, render, screen } from '@testing-library/react';
2+
import React from 'react';
3+
4+
import { DropdownButton } from './DropdownButton';
5+
6+
describe('DropdownButton', () => {
7+
const mockButtonItem = {
8+
label: 'Main Action',
9+
onSelect: jest.fn(),
10+
};
11+
12+
const mockItems = [
13+
{ label: 'Item 1', onSelect: jest.fn() },
14+
{ label: 'Item 2', onSelect: jest.fn() },
15+
];
16+
17+
beforeEach(() => {
18+
jest.clearAllMocks();
19+
});
20+
21+
it('renders button item label', () => {
22+
render(<DropdownButton buttonItem={mockButtonItem} />);
23+
expect(screen.getByText('Main Action')).toBeTruthy();
24+
});
25+
26+
it('calls buttonItem onSelect when main button is clicked', () => {
27+
render(<DropdownButton buttonItem={mockButtonItem} />);
28+
fireEvent.click(screen.getByText('Main Action'));
29+
expect(mockButtonItem.onSelect).toHaveBeenCalledTimes(1);
30+
});
31+
32+
it('does not render dropdown when items are not provided', () => {
33+
render(<DropdownButton buttonItem={mockButtonItem} />);
34+
expect(screen.queryByRole('button', { name: /chevron/i })).not.toBeTruthy();
35+
});
36+
37+
it('renders dropdown when items are provided', () => {
38+
render(<DropdownButton buttonItem={mockButtonItem} items={mockItems} />);
39+
expect(screen.getByRole('button', { name: 'More actions' })).toBeTruthy();
40+
});
41+
42+
it('renders dropdown items when dropdown is opened', () => {
43+
render(<DropdownButton buttonItem={mockButtonItem} items={mockItems} />);
44+
const dropdownTrigger = screen.getAllByRole('button')[1];
45+
fireEvent.click(dropdownTrigger);
46+
expect(screen.getByText('Item 1')).toBeTruthy();
47+
expect(screen.getByText('Item 2')).toBeTruthy();
48+
});
49+
50+
it('calls item onSelect when dropdown item is clicked', () => {
51+
render(<DropdownButton buttonItem={mockButtonItem} items={mockItems} />);
52+
const dropdownTrigger = screen.getAllByRole('button')[1];
53+
fireEvent.click(dropdownTrigger);
54+
fireEvent.click(screen.getByText('Item 1'));
55+
expect(mockItems[0].onSelect).toHaveBeenCalledTimes(1);
56+
});
57+
58+
it('renders with empty items array', () => {
59+
render(<DropdownButton buttonItem={mockButtonItem} items={[]} />);
60+
expect(screen.getByText('Main Action')).toBeTruthy();
61+
expect(screen.queryByRole('button', { name: /chevron/i })).not.toBeTruthy();
62+
});
63+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import './DropdownButton.css';
2+
3+
import DropdownMenu, { DropdownItem } from '@atlaskit/dropdown-menu';
4+
import React from 'react';
5+
6+
interface DropdownButtonItem {
7+
label: string;
8+
onSelect: () => void;
9+
}
10+
interface DropdownButtonProps {
11+
buttonItem: DropdownButtonItem;
12+
items?: DropdownButtonItem[];
13+
}
14+
15+
export const DropdownButton: React.FC<DropdownButtonProps> = ({ buttonItem, items }) => {
16+
return (
17+
<div className="dropdown-button-container">
18+
<div className="dropdown-button">
19+
<button
20+
aria-label="Main action"
21+
className="dropdown-button-trigger"
22+
style={{ borderRadius: '4px 0 0 4px' }}
23+
onClick={buttonItem.onSelect}
24+
>
25+
{buttonItem.label}
26+
</button>
27+
{items && items.length > 0 && (
28+
<>
29+
<div className="dropdown-button-separator" />
30+
<DropdownMenu
31+
trigger={({ triggerRef, isSelected, testId, ...providedProps }) => (
32+
<button
33+
aria-label="More actions"
34+
className="dropdown-button-trigger"
35+
style={{ padding: '6px 4px', borderRadius: '0 4px 4px 0' }}
36+
type="button"
37+
{...providedProps}
38+
ref={triggerRef}
39+
>
40+
<i className="codicon codicon-chevron-down" />
41+
</button>
42+
)}
43+
spacing="compact"
44+
>
45+
<div className="dropdown-item-container">
46+
{items.map((item, index) => (
47+
<DropdownItem key={index} onClick={item.onSelect}>
48+
<span style={{ color: 'var(--vscode-editor-foreground)' }}>{item.label}</span>
49+
</DropdownItem>
50+
))}
51+
</div>
52+
</DropdownMenu>
53+
</>
54+
)}
55+
</div>
56+
</div>
57+
);
58+
};

src/react/atlascode/rovo-dev/rovoDevView.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@ import { detectLanguage } from '@speed-highlight/core/detect';
88
import { useCallback, useState } from 'react';
99
import * as React from 'react';
1010
import { RovoDevToolReturnResponse } from 'src/rovo-dev/responseParserInterfaces';
11-
import { ToolPermissionChoice } from 'src/rovo-dev/rovoDevApiClientInterfaces';
12-
import { RovoDevContextItem, State } from 'src/rovo-dev/rovoDevTypes';
11+
import { RovoDevContextItem, State, ToolPermissionDialogChoice } from 'src/rovo-dev/rovoDevTypes';
1312
import { v4 } from 'uuid';
1413

1514
import { DetailedSiteInfo } from '../../../atlclients/authInfo';
@@ -670,11 +669,23 @@ const RovoDevView: React.FC = () => {
670669
}, []);
671670

672671
const onToolPermissionChoice = useCallback(
673-
(toolCallId: string, choice: ToolPermissionChoice) => {
672+
(toolCallId: string, choice: ToolPermissionDialogChoice | 'enableYolo') => {
674673
// remove the dialog after the choice is submitted
675-
setModalDialogs((prev) =>
676-
prev.filter((x) => x.type !== 'toolPermissionRequest' || x.toolCallId !== toolCallId),
677-
);
674+
if (choice === 'enableYolo') {
675+
setIsYoloModeToggled(true);
676+
setModalDialogs([]);
677+
postMessage({
678+
type: RovoDevViewResponseType.YoloModeToggled,
679+
value: true,
680+
});
681+
return;
682+
} else {
683+
setModalDialogs((prev) =>
684+
choice === 'allowAll'
685+
? []
686+
: prev.filter((x) => x.type !== 'toolPermissionRequest' || x.toolCallId !== toolCallId),
687+
);
688+
}
678689

679690
postMessage({
680691
type: RovoDevViewResponseType.ToolPermissionChoiceSubmit,

src/react/atlascode/rovo-dev/rovoDevViewMessages.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { ReducerAction } from '@atlassianlabs/guipi-core-controller';
2-
import { ToolPermissionChoice } from 'src/rovo-dev/rovoDevApiClientInterfaces';
3-
import { RovoDevPrompt } from 'src/rovo-dev/rovoDevTypes';
2+
import { RovoDevPrompt, ToolPermissionDialogChoice } from 'src/rovo-dev/rovoDevTypes';
43

54
import { FeedbackType } from './feedback-form/FeedbackForm';
65

@@ -70,6 +69,6 @@ export type RovoDevViewResponse =
7069
| ReducerAction<RovoDevViewResponseType.CheckFileExists, { filePath: string; requestId: string }>
7170
| ReducerAction<
7271
RovoDevViewResponseType.ToolPermissionChoiceSubmit,
73-
{ choice: ToolPermissionChoice; toolCallId: string }
72+
{ choice: ToolPermissionDialogChoice; toolCallId: string }
7473
>
7574
| ReducerAction<RovoDevViewResponseType.YoloModeToggled, { value: boolean }>;

src/rovo-dev/rovoDevChatProvider.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export class RovoDevChatProvider {
5050
public set yoloMode(value: boolean) {
5151
this._yoloMode = value;
5252
if (value) {
53-
this.signalYoloModeEngaged();
53+
this.signalToolRequestAllowAll();
5454
}
5555
}
5656

@@ -523,7 +523,7 @@ export class RovoDevChatProvider {
523523
}
524524
}
525525

526-
private async signalYoloModeEngaged() {
526+
public async signalToolRequestAllowAll() {
527527
if (this._pendingToolConfirmationLeft > 0) {
528528
for (const key in this._pendingToolConfirmation) {
529529
if (this._pendingToolConfirmation[key] === 'undecided') {

src/rovo-dev/rovoDevTypes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ToolPermissionChoice } from './rovoDevApiClientInterfaces';
12
import { RovoDevEntitlementCheckFailedDetail } from './rovoDevWebviewProviderMessages';
23

34
export type RovoDevContextFileInfo = {
@@ -88,3 +89,5 @@ export interface BasicState {
8889
}
8990

9091
export type State = BasicState | InitializingState | DisabledState;
92+
93+
export type ToolPermissionDialogChoice = ToolPermissionChoice | 'allowAll';

src/rovo-dev/rovoDevWebviewProvider.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,12 @@ export class RovoDevWebviewProvider extends Disposable implements WebviewViewPro
435435
break;
436436

437437
case RovoDevViewResponseType.ToolPermissionChoiceSubmit:
438+
if (e.choice === 'allowAll') {
439+
await this._chatProvider.signalToolRequestAllowAll();
440+
break;
441+
}
438442
await this._chatProvider.signalToolRequestChoiceSubmit(e.toolCallId, e.choice);
443+
439444
break;
440445

441446
case RovoDevViewResponseType.YoloModeToggled:

0 commit comments

Comments
 (0)