Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- **RovoDev (BBY)**: Fixed `ROVODEV_REBRAND_JCA` env var handling so the "Jira Coding Agent" rebrand works correctly in webviews.
- **Notifications**: Fixed `atlassianNotificationNotifier` to correctly flush all promise levels, resolving a test reliability issue.
- **RovoDev**: Fixed the prompt toolbar so only one dropdown (Add, Preferences, or model selector) can be open at a time - opening one now closes any other that was open, instead of stacking a dropdown over a dropdown.
- **RovoDev (BBY)**: Fixed frequent live preview failures (`HTTP 409` on `/v3/stream_chat`) caused by triggering a live preview while the agent was already streaming. The Live Preview button is now hidden as soon as it is clicked (and while the agent is running), and a backstop guard prevents a live preview request from being sent when a response is already in progress. When a live preview attempt fails or ends without starting a preview, the button is automatically restored so it can be clicked again to retry (the button itself is the retry affordance, so live-preview errors no longer show a non-functional "Try again").

### Improvements

Expand Down
104 changes: 104 additions & 0 deletions src/rovo-dev/rovoDevChatProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ describe('RovoDevChatProvider', () => {
// Mock API client
mockApiClient = {
chat: jest.fn(),
createLivePreview: jest.fn(),
cancel: jest.fn(),
replay: jest.fn(),
resumeToolCall: jest.fn(),
Expand Down Expand Up @@ -295,6 +296,109 @@ describe('RovoDevChatProvider', () => {
});
});

describe('executeLivePreview', () => {
it('should throw error if API client is not initialized', async () => {
const providerWithoutClient = new RovoDevChatProvider(false, mockTelemetryProvider);
providerWithoutClient.setWebview(mockWebview);

await expect(providerWithoutClient.executeLivePreview()).rejects.toThrow(/failed to initialize/);
expect(mockApiClient.createLivePreview).not.toHaveBeenCalled();
});

it('should call createLivePreview when the agent is idle', async () => {
await chatProvider.setReady(mockApiClient);

const mockReadableStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('data: {"event_kind": "close"}\n\n'));
controller.close();
},
});
mockApiClient.createLivePreview.mockResolvedValue({ body: mockReadableStream } as Response);

await chatProvider.executeLivePreview();

expect(mockApiClient.createLivePreview).toHaveBeenCalledTimes(1);
});

it('should NOT call createLivePreview when the agent is already running (avoids HTTP 409)', async () => {
await chatProvider.setReady(mockApiClient);

// Simulate an in-flight stream by marking the agent as running.
(chatProvider as any)._abortController = new AbortController();
expect(chatProvider.isAgentRunning).toBe(true);

await chatProvider.executeLivePreview();

// The guard must short-circuit before hitting the backend so the
// second concurrent stream is never requested.
expect(mockApiClient.createLivePreview).not.toHaveBeenCalled();

// The button (hidden optimistically on click) should be restored so it
// reappears once the in-flight response finishes and the user can retry.
expect(mockWebview.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: RovoDevProviderMessageType.ShowLivePreviewButton,
show: true,
}),
);
});

it('should restore the button when the attempt ends without starting a preview', async () => {
await chatProvider.setReady(mockApiClient);

// A stream that finishes cleanly but never emits a `configure_live_preview`
// tool-call — i.e. the preview did not actually start.
const mockReadableStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('data: {"event_kind": "close"}\n\n'));
controller.close();
},
});
mockApiClient.createLivePreview.mockResolvedValue({ body: mockReadableStream } as Response);

await chatProvider.executeLivePreview();

expect(mockWebview.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: RovoDevProviderMessageType.ShowLivePreviewButton,
show: true,
}),
);
});

it('should NOT restore the button when the preview actually starts', async () => {
await chatProvider.setReady(mockApiClient);

// A stream that emits a `configure_live_preview` tool-call — the preview
// started successfully, so the button must stay hidden.
const toolCallData = JSON.stringify({
tool_name: 'configure_live_preview',
tool_call_id: 'tc-1',
args: '{}',
});
const mockReadableStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(`event: tool-call\ndata: ${toolCallData}\n\n`));
controller.enqueue(new TextEncoder().encode('event: close\ndata: {}\n\n'));
controller.close();
},
});
mockApiClient.createLivePreview.mockResolvedValue({ body: mockReadableStream } as Response);

await chatProvider.executeLivePreview();

// The tool-call handler hides the button (show:false); the completion
// path must NOT subsequently re-show it (show:true).
expect(mockWebview.postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({
type: RovoDevProviderMessageType.ShowLivePreviewButton,
show: true,
}),
);
});
});

describe('executeReplay', () => {
it('should throw error if API client is not initialized', async () => {
const providerWithoutClient = new RovoDevChatProvider(false, mockTelemetryProvider);
Expand Down
49 changes: 42 additions & 7 deletions src/rovo-dev/rovoDevChatProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ export class RovoDevChatProvider {
private _replayInProgress = false;
private _lastMessageSentTime: number | undefined;

// true while a live-preview stream is being processed (drives non-retriable
// errors and restoring the button when the attempt doesn't start a preview)
private _livePreviewInProgress = false;
// set when a `configure_live_preview` tool-call is seen, i.e. the preview started
private _livePreviewStarted = false;

private get isDebugPanelEnabled() {
return this.extensionApi.config.isDebugPanelEnabled();
}
Expand Down Expand Up @@ -656,6 +662,7 @@ export class RovoDevChatProvider {
message: response,
});
if (response.tool_name === 'configure_live_preview' && sourceApi !== 'replay') {
this._livePreviewStarted = true;
webview
.postMessage({
type: RovoDevProviderMessageType.ShowLivePreviewButton,
Expand Down Expand Up @@ -1102,8 +1109,10 @@ export class RovoDevChatProvider {
...(httpStatus !== undefined ? { httpStatus } : {}),
});
}
// the error is retriable only when it happens during the streaming of a 'chat' response
await this.processError(error, { isRetriable: sourceApi === 'chat' });
// retriable only for a 'chat' response; live-preview retries via the restored button
await this.processError(error, {
isRetriable: sourceApi === 'chat' && !this._livePreviewInProgress,
});
}
} else {
if (sourceApi === 'chat') {
Expand Down Expand Up @@ -1261,9 +1270,22 @@ export class RovoDevChatProvider {
return;
}

// Put the chat into "listen" mode (GeneratingResponse) without echoing a synthetic
// user-message bubble — the live-preview click is implicitly a prompt, but we don't
// want to display fake user text for it.
// Backstop against the HTTP 409 "agent busy" race: `/v3/live-preview` starts a
// `stream_chat`, which the backend rejects if one is already in flight.
if (this.isAgentRunning) {
Logger.info('Ignoring live preview request: the agent is already running');
// Restore the button (hidden optimistically on click) without touching the
// running response's state, so it reappears once the response finishes.
await this._webView.postMessage({
type: RovoDevProviderMessageType.ShowLivePreviewButton,
show: true,
});
return;
}

this._livePreviewInProgress = true;
this._livePreviewStarted = false;

this.beginNewPrompt();
await this.signalPromptSent({ text: 'Start a live preview for this project', context: [] }, true);

Expand All @@ -1274,8 +1296,21 @@ export class RovoDevChatProvider {
return this.processResponse('chat', response);
};

await this.executeStreamingApiWithErrorHandling('chat', fetchOp);
this._abortController = null;
try {
await this.executeStreamingApiWithErrorHandling('chat', fetchOp);
} finally {
this._abortController = null;

// if the preview never started, restore the button so the user can retry
if (!this._livePreviewStarted && this._webView) {
await this._webView.postMessage({
type: RovoDevProviderMessageType.ShowLivePreviewButton,
show: true,
});
}

this._livePreviewInProgress = false;
}
}

private preparePayloadForChatRequest(prompt: RovoDevPrompt): RovoDevChatRequest {
Expand Down
17 changes: 15 additions & 2 deletions src/rovo-dev/ui/live-preview/LivePreviewButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,28 @@ interface LivePreviewButtonProps {
messagingApi: ReturnType<
typeof useMessagingApi<RovoDevViewResponse, RovoDevProviderMessage, RovoDevProviderMessage>
>;
// preferred handler for starting a live preview; the parent guards re-clicks and
// hides the button. falls back to posting the message directly when omitted.
onCreateLivePreview?: () => void;
}

export const LivePreviewButton: React.FC<LivePreviewButtonProps> = ({ messagingApi: { postMessage } }) => {
export const LivePreviewButton: React.FC<LivePreviewButtonProps> = ({
messagingApi: { postMessage },
onCreateLivePreview,
}) => {
const [isLoading, setIsLoading] = React.useState(false);

const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
if (isLoading) {
return;
}
setIsLoading(true);
postMessage({ type: RovoDevViewResponseType.CreateLivePreview });
if (onCreateLivePreview) {
onCreateLivePreview();
} else {
postMessage({ type: RovoDevViewResponseType.CreateLivePreview });
}
};

return (
Expand Down
4 changes: 3 additions & 1 deletion src/rovo-dev/ui/messaging/ChatStream.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ interface ChatStreamProps {
credentialHints?: CredentialHint[];
onGeneratePlanClick?: (planId: string, proceed: boolean) => void;
showLivePreviewButton?: boolean;
onCreateLivePreview?: () => void;
isAtlassianUser?: boolean;
}

Expand All @@ -75,6 +76,7 @@ export const ChatStream: React.FC<ChatStreamProps> = ({
credentialHints,
onGeneratePlanClick,
showLivePreviewButton,
onCreateLivePreview,
isAtlassianUser,
}) => {
const chatEndRef = React.useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -283,7 +285,7 @@ export const ChatStream: React.FC<ChatStreamProps> = ({

{showActionFooter && (
<FollowUpActionFooter>
<LivePreviewButton messagingApi={messagingApi} />
<LivePreviewButton messagingApi={messagingApi} onCreateLivePreview={onCreateLivePreview} />
</FollowUpActionFooter>
)}
<div id="sentinel" ref={sentinelRef} style={{ height: '10px', width: '100%', pointerEvents: 'none' }} />
Expand Down
16 changes: 16 additions & 0 deletions src/rovo-dev/ui/rovoDevView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,21 @@ const RovoDevView: React.FC = () => {
});
}, [postMessage]);

const onCreateLivePreview = useCallback((): void => {
// only start when idle, and hide the button immediately to avoid a
// double-click race that triggers an HTTP 409 ("agent busy")
if (currentState.state !== 'WaitingForPrompt') {
return;
Comment thread
dchiew-atl marked this conversation as resolved.
Outdated
}

setShowLivePreviewButton(false);
setCurrentState({ state: 'GeneratingResponse' });

postMessage({
type: RovoDevViewResponseType.CreateLivePreview,
});
}, [currentState.state, postMessage]);

const cancelResponse = useCallback((): void => {
if (currentState.state === 'CancellingResponse') {
return;
Expand Down Expand Up @@ -1107,6 +1122,7 @@ const RovoDevView: React.FC = () => {
credentialHints={credentialHints}
onGeneratePlanClick={(e: string, proceed: boolean) => handleExitPlanMode(proceed, e)}
showLivePreviewButton={showLivePreviewButton}
onCreateLivePreview={onCreateLivePreview}
/>
{!hidePromptBox && (
<div className="input-section-container">
Expand Down
Loading