fix: complete goal resume and comment input follow-ups (#3361)

This commit is contained in:
Bohdan Triapitsyn
2026-09-05 10:53:34 +03:00
committed by GitHub
parent f3f844463b
commit 7c9fdd1ee5
9 changed files with 172 additions and 62 deletions
@@ -1,3 +1,4 @@
import { isIMECompositionEvent } from '@/lib/ime';
import React from 'react';
import { toast } from '@/components/ui';
@@ -272,7 +273,7 @@ const WebviewBrowser: React.FC<BrowserPaneProps> = ({ initialUrl, directory, tab
React.useEffect(() => {
if (!isAnnotating) return;
const handler = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
if (isIMECompositionEvent(event) || event.key !== 'Escape') return;
event.preventDefault();
event.stopImmediatePropagation();
setIsAnnotating(false);
@@ -1,61 +1,84 @@
import React from 'react';
import { describe, expect, mock, test } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import React, { act } from 'react';
import { Window } from 'happy-dom';
import { describe, expect, test } from 'bun:test';
import { createRoot } from 'react-dom/client';
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
import { SyncProvider } from '@/sync/sync-context';
import { ThemeSystemProvider } from '@/contexts/ThemeSystemContext';
import { I18nProvider } from '@/lib/i18n';
import { getDefaultTheme } from '@/lib/theme/themes';
mock.module('@/components/chat/SessionGoalRow', () => ({
SessionGoalRow: () => null,
}));
mock.module('@/components/chat/SessionSuggestionChip', () => ({
SessionSuggestionChip: () => null,
}));
mock.module('./ComposerAttachmentControls', () => ({
ComposerAttachmentControls: () => null,
}));
import { MobilePillComposer } from './MobilePillComposer';
const { MobilePillComposer } = await import('./MobilePillComposer');
const renderPill = (options: { hasContent: boolean; newSessionDraftOpen: boolean; canAbort?: boolean }) => renderToStaticMarkup(
<I18nProvider>
<MobilePillComposer
message={options.hasContent ? 'Draft message' : ''}
sessionId={options.newSessionDraftOpen ? null : 'session-1'}
newSessionDraftOpen={options.newSessionDraftOpen}
hasContent={options.hasContent}
isVSCode={false}
canAbort={options.canAbort ?? false}
footerIconButtonClass="icon-button"
iconSizeClass="icon-size"
sendIconSizeClass="send-icon-size"
stopIconSizeClass="stop-icon-size"
theme={getDefaultTheme(false)}
onExpand={() => {}}
onApplySuggestion={() => {}}
onPrimaryAction={() => {}}
onNewSession={() => {}}
onPickLocalFiles={() => {}}
onOpenIssuePicker={() => {}}
onOpenPrPicker={() => {}}
onOpenAttachSheet={() => {}}
onStartDictation={() => {}}
onAbort={() => {}}
/>
</I18nProvider>,
);
const renderPill = async (options: { hasContent: boolean; newSessionDraftOpen: boolean; canAbort?: boolean }) => {
const win = new Window({ url: 'http://localhost' });
const values = { window: win, document: win.document, navigator: win.navigator, localStorage: win.localStorage, IS_REACT_ACT_ENVIRONMENT: true };
const previous = new Map(Object.keys(values).map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
for (const [key, value] of Object.entries(values)) Object.defineProperty(globalThis, key, { configurable: true, value });
const container = document.createElement('div');
const root = createRoot(container);
let primaryActions = 0;
try {
await act(async () => root.render(
<SyncProvider directory="/fixture" sdk={createOpencodeClient({ baseUrl: "http://opencode.test", fetch: async () => new Response("[]", { headers: { "content-type": "application/json" } }) })}>
<ThemeSystemProvider>
<I18nProvider>
<MobilePillComposer
directory="/fixture"
message={options.hasContent ? 'Draft message' : ''}
sessionId={options.newSessionDraftOpen ? null : 'session-1'}
newSessionDraftOpen={options.newSessionDraftOpen}
hasContent={options.hasContent}
isVSCode={false}
canAbort={options.canAbort ?? false}
footerIconButtonClass="icon-button"
iconSizeClass="icon-size"
sendIconSizeClass="send-icon-size"
stopIconSizeClass="stop-icon-size"
theme={getDefaultTheme(false)}
onExpand={() => {}}
onApplySuggestion={() => {}}
onPrimaryAction={() => { primaryActions += 1; }}
onNewSession={() => {}}
onPickLocalFiles={() => {}}
onOpenIssuePicker={() => {}}
onOpenPrPicker={() => {}}
onOpenAttachSheet={() => {}}
onStartDictation={() => {}}
onAbort={() => {}}
/>
</I18nProvider>
</ThemeSystemProvider>
</SyncProvider>));
const send = container.querySelector<HTMLButtonElement>('[aria-label="Send message"]');
if (options.hasContent) {
expect(send).not.toBeNull();
await act(async () => { send?.click(); });
expect(primaryActions).toBe(1);
}
return container.innerHTML;
} finally {
await act(async () => root.unmount());
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
await win.happyDOM.close();
}
};
describe('MobilePillComposer', () => {
test('uses the inline action to send content while the session is idle', () => {
const markup = renderPill({ hasContent: true, newSessionDraftOpen: false });
test('uses the inline action to send content while the session is idle', async () => {
const markup = await renderPill({ hasContent: true, newSessionDraftOpen: false });
expect(markup).toContain('aria-label="Send message"');
expect(markup).toContain('aria-label="New chat"');
expect(markup.indexOf('aria-label="Send message"')).toBeLessThan(markup.indexOf('aria-label="New chat"'));
});
test('uses the trailing action to send content while the session is running', () => {
const markup = renderPill({ hasContent: true, newSessionDraftOpen: false, canAbort: true });
test('uses the trailing action to send content while the session is running', async () => {
const markup = await renderPill({ hasContent: true, newSessionDraftOpen: false, canAbort: true });
expect(markup).toContain('aria-label="Stop generating"');
expect(markup).toContain('aria-label="Send message"');
@@ -63,29 +86,29 @@ describe('MobilePillComposer', () => {
expect(markup.indexOf('aria-label="Stop generating"')).toBeLessThan(markup.indexOf('aria-label="Send message"'));
});
test('uses the inline send action for content in a new-session draft', () => {
const markup = renderPill({ hasContent: true, newSessionDraftOpen: true });
test('uses the inline send action for content in a new-session draft', async () => {
const markup = await renderPill({ hasContent: true, newSessionDraftOpen: true });
expect(markup).toContain('aria-label="Send message"');
expect(markup).toContain('w-0 opacity-0 overflow-hidden');
});
test('keeps the new-session action for an empty existing session', () => {
const markup = renderPill({ hasContent: false, newSessionDraftOpen: false });
test('keeps the new-session action for an empty existing session', async () => {
const markup = await renderPill({ hasContent: false, newSessionDraftOpen: false });
expect(markup).toContain('aria-label="New chat"');
expect(markup).not.toContain('aria-label="Send message"');
});
test('keeps the trailing action collapsed for an empty new-session draft', () => {
const markup = renderPill({ hasContent: false, newSessionDraftOpen: true });
test('keeps the trailing action collapsed for an empty new-session draft', async () => {
const markup = await renderPill({ hasContent: false, newSessionDraftOpen: true });
expect(markup).toContain('w-0 opacity-0 overflow-hidden');
expect(markup).not.toContain('aria-label="Send message"');
});
test('keeps abort and new-session actions while a session runs without content', () => {
const markup = renderPill({ hasContent: false, newSessionDraftOpen: false, canAbort: true });
test('keeps abort and new-session actions while a session runs without content', async () => {
const markup = await renderPill({ hasContent: false, newSessionDraftOpen: false, canAbort: true });
expect(markup).toContain('aria-label="Stop generating"');
expect(markup).toContain('aria-label="New chat"');
@@ -11,6 +11,7 @@
* is running, abort keeps that slot and the outer new-session action sends.
*/
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/icon/Icon';
import { StopIcon } from '@/components/icons/StopIcon';
import { SessionGoalRow } from '@/components/chat/SessionGoalRow';
@@ -165,15 +166,17 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
<StopIcon className={cn(stopIconSizeClass)} />
</button>
) : canPrimaryAction ? (
<button
<Button
type="button"
className={cn(footerIconButtonClass, 'text-primary hover:text-primary')}
variant="ghost"
size="icon"
className="text-primary hover:text-primary"
onClick={onPrimaryAction}
title={t('chat.chatInput.actions.sendMessageAria')}
aria-label={t('chat.chatInput.actions.sendMessageAria')}
>
<Icon name="send-plane-2" className={cn(sendIconSizeClass)} />
</button>
</Button>
) : null}
</div>
{/* While running, Send moves outside because Abort owns the pill's