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
17 changes: 14 additions & 3 deletions e2e/hitl/auto-approve.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ async function makeInterruptStream(
callCount: { n: number },
page: import('@playwright/test').Page,
): Promise<void> {
await page.route('**/api/proxy/agent/threads/*', (route) => {
const path = new URL(route.request().url()).pathname;
const isThreadRecord = /\/threads\/(?!search$)[^/]+$/.test(path);
if (route.request().method() === 'GET' && isThreadRecord) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'interrupted' }),
});
}
return route.fallback();
});
await page.route('**/api/proxy/agent/v1/stream', (route) => {
callCount.n++;
if (callCount.n === 1) {
Expand Down Expand Up @@ -78,8 +90,7 @@ test.describe('HITL — auto-approve', () => {
const chat = new ChatPage(page);
await chat.expectChatRoute();

// The response should complete without the interrupt banner ever appearing
await chat.waitForAIResponse(15_000);
await expect(page.getByText('Done automatically.')).toBeVisible({ timeout: 15_000 });
await expect(page.getByText('Action Required')).not.toBeVisible();
await expect(page.getByText('Input Required')).not.toBeVisible();
expect(callCount.n).toBeGreaterThanOrEqual(2);
Expand Down Expand Up @@ -129,7 +140,7 @@ test.describe('HITL — auto-approve', () => {
const chat = new ChatPage(page);
await chat.expectChatRoute();

await chat.waitForAIResponse(15_000);
await expect(page.getByText('Done automatically.')).toBeVisible({ timeout: 15_000 });
await expect(page.getByText('Action Required')).not.toBeVisible();
expect(callCount.n).toBeGreaterThanOrEqual(2);
},
Expand Down
58 changes: 58 additions & 0 deletions src/frontend/components/FeedbackButtons.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

import { FeedbackCommentBox, type FeedbackState } from './FeedbackButtons';

function commentState(comment: string): FeedbackState {
return {
existingFeedback: null,
isSubmitting: false,
showCommentInput: true,
comment,
disabled: false,
handleThumbsUp: vi.fn(),
handleThumbsDown: vi.fn(),
handleCommentSubmit: vi.fn(),
setComment: vi.fn(),
cancelComment: vi.fn(),
};
}

describe('FeedbackCommentBox', () => {
it('keeps Send disabled until the comment has text', () => {
const { rerender } = render(<FeedbackCommentBox fb={commentState('')} />);
const send = screen.getByRole('button', { name: 'Send' });
const comment = screen.getByRole('textbox', { name: 'What went wrong?' });

expect(comment).toBeRequired();
expect(send).toBeDisabled();

rerender(<FeedbackCommentBox fb={commentState(' ')} />);
expect(screen.getByRole('button', { name: 'Send' })).toBeDisabled();

rerender(<FeedbackCommentBox fb={commentState('The table was wrong')} />);
expect(screen.getByRole('button', { name: 'Send' })).toBeEnabled();
});

it('does not send a blank comment with Enter', () => {
const fb = commentState(' ');
render(<FeedbackCommentBox fb={fb} />);

fireEvent.keyDown(screen.getByRole('textbox', { name: 'What went wrong?' }), {
key: 'Enter',
});

expect(fb.handleCommentSubmit).not.toHaveBeenCalled();
});

it('sends when Enter is pressed on a real comment', () => {
const fb = commentState('The citation was wrong');
render(<FeedbackCommentBox fb={fb} />);

fireEvent.keyDown(screen.getByRole('textbox', { name: 'What went wrong?' }), {
key: 'Enter',
});

expect(fb.handleCommentSubmit).toHaveBeenCalledOnce();
});
});
19 changes: 14 additions & 5 deletions src/frontend/components/FeedbackButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
}

/** Hook that owns all feedback state and side effects */
export function useFeedback({

Check warning on line 33 in src/frontend/components/FeedbackButtons.tsx

View workflow job for this annotation

GitHub Actions / lint

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
messageId,
traceId,
chatId,
Expand Down Expand Up @@ -101,7 +101,9 @@
}, [locked, isSubmitting]);

const handleCommentSubmit = useCallback(() => {
void doSendFeedback('down', comment.trim() || undefined);
const text = comment.trim();
if (!text) return;
void doSendFeedback('down', text);
}, [doSendFeedback, comment]);

const cancelComment = useCallback(() => {
Expand Down Expand Up @@ -164,10 +166,13 @@
export function FeedbackCommentBox({ fb }: { fb: FeedbackState }) {
if (!fb.showCommentInput || !!fb.existingFeedback) return null;

const commentText = fb.comment.trim();
const canSend = commentText.length > 0 && !fb.isSubmitting;

const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
fb.handleCommentSubmit();
if (canSend) fb.handleCommentSubmit();
} else if (e.key === 'Escape') {
fb.cancelComment();
}
Expand All @@ -180,7 +185,10 @@
value={fb.comment}
onChange={(e) => fb.setComment(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="What went wrong? (optional)"
placeholder="What went wrong?"
aria-label="What went wrong?"
aria-required="true"
required
className="w-full resize-none border-0 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
autoFocus
rows={1}
Expand All @@ -195,8 +203,9 @@
</button>
<button
type="button"
className="rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-40"
disabled={fb.isSubmitting}
className="rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground hover:bg-primary/90 transition-colors disabled:pointer-events-none disabled:opacity-40"
disabled={!canSend}
aria-disabled={!canSend}
onClick={fb.handleCommentSubmit}
>
{fb.isSubmitting ? 'Sending...' : 'Send'}
Expand Down
14 changes: 14 additions & 0 deletions src/frontend/hooks/useStreamingAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -985,6 +985,20 @@ export function useStreamingAPI(threadId: string) {
dispatch(resolveAllPendingToolCalls({ chatId: threadId }));
}
lastSuccessfulConnectionRef.current = Date.now();
if (streamEndedWithInterruptRef.current) {
dispatch(updateStreamingState({
chatId: threadId,
state: {
isLoading: false,
isConnected: false,
isReconnecting: false,
reconnectAttempt: 0,
streamDroppedMidResponse: false,
},
}));
finish('success');
return;
}
const gotFullResponse = messagesRef.current.length > initialMessageCount + 1;
if (gotFullResponse && recoveryIntervalRef.current) {
clearInterval(recoveryIntervalRef.current);
Expand Down
2 changes: 1 addition & 1 deletion src/frontend/redux/slices/chats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ describe('chats slice — appendMessageToChat', () => {

describe('chats slice — subagent transcript', () => {
function withSubagentCard() {
let s = chatsReducer(initialState(), addChat(makeChat('c1')));
const s = chatsReducer(initialState(), addChat(makeChat('c1')));
const card: TestMessage = {
type: 'ai',
content: 'Asking the analyst.',
Expand Down
Loading