fix: complete goal resume and comment input follow-ups (#3361)
This commit is contained in:
committed by
GitHub
parent
f3f844463b
commit
7c9fdd1ee5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user