diff --git a/apps/desktop/stories/workhub.stories.tsx b/apps/desktop/stories/workhub.stories.tsx index ba3f55f7cc0..59e5bbcdee7 100644 --- a/apps/desktop/stories/workhub.stories.tsx +++ b/apps/desktop/stories/workhub.stories.tsx @@ -723,14 +723,34 @@ export const NextPromptSuggestion: Story = { const offered = metrics(suggestion()!.querySelector('.maka-composer-next-prompt-text')!); const text = suggestion()!.textContent; expect(input).toHaveTextContent(''); - await userEvent.keyboard('{Tab}'); + expect(canvasElement.querySelector('.maka-composer-next-prompt-key')).toHaveTextContent('→ 或 Tab 接受建议'); + await userEvent.keyboard('{ArrowRight}'); await waitFor(() => expect(input).toHaveTextContent(text!)); expect(metrics(input)).toEqual(offered); + expect(document.activeElement).toBe(input); + const selection = window.getSelection()!; + expect(selection.isCollapsed).toBe(true); + const beforeCaret = document.createRange(); + beforeCaret.selectNodeContents(input); + beforeCaret.setEnd(selection.anchorNode!, selection.anchorOffset); + expect(beforeCaret.toString()).toBe(text); expect(writes.answer).toHaveBeenCalledTimes(1); await userEvent.keyboard('{Enter}'); await waitFor(() => expect(writes.answer).toHaveBeenCalledTimes(2)); expect(writes.answer.mock.calls[1][1].text).toBe(text); await waitFor(() => expect(suggestion()).not.toBeNull()); + await userEvent.keyboard('{Tab}'); + await waitFor(() => expect(input).toHaveTextContent(text!)); + expect(writes.answer).toHaveBeenCalledTimes(2); + // Undo uses the browser's native editing transaction, which the DOM shim + // cannot exercise. Dismissed/accepted suggestions do not reappear on Undo. + expect(document.execCommand('undo')).toBe(true); + await waitFor(() => expect(input).toHaveTextContent('')); + expect(suggestion()).toBeNull(); + expect(writes.answer).toHaveBeenCalledTimes(2); + await userEvent.type(input, '请检查并发回调。'); + await userEvent.keyboard('{Enter}'); + await waitFor(() => expect(suggestion()).not.toBeNull()); await userEvent.keyboard('{Escape}'); expect(suggestion()).toBeNull(); await userEvent.type(input, '请总结验证结果。'); diff --git a/packages/ui/src/__tests__/composer-shortcuts.test.tsx b/packages/ui/src/__tests__/composer-shortcuts.test.tsx index 711b5f82d17..5aef4543eb3 100644 --- a/packages/ui/src/__tests__/composer-shortcuts.test.tsx +++ b/packages/ui/src/__tests__/composer-shortcuts.test.tsx @@ -23,8 +23,10 @@ import { act, createRef } from 'react'; import { createRoot } from 'react-dom/client'; import { parseHTML } from 'linkedom'; +import type { SessionSummary } from '@maka/core/session'; import { Composer, type ComposerHandle, type ComposerSendMetadata } from '../composer.js'; import { LocaleProvider } from '../locale-context.js'; +import { ComposerPromptSuggestionProvider, type ComposerPromptSuggestionService } from '../prompt-suggestion.js'; const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); @@ -46,9 +48,10 @@ afterEach(async () => { else Reflect.deleteProperty(globalThis, 'navigator'); }); -async function harness(platform = 'MacIntel', streaming = true, overrides: Partial[0]> = {}) { +async function harness(platform = 'MacIntel', streaming = true, overrides: Partial[0]> = {}, suggestionService?: ComposerPromptSuggestionService) { const { document, window } = parseHTML('
'); window.getComputedStyle = () => ({ direction: 'ltr', writingMode: 'horizontal-tb', getPropertyValue: () => '' }) as unknown as CSSStyleDeclaration; + window.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) as unknown as MediaQueryList; window.getSelection = () => null; document.getSelection = () => null; const setAttribute = window.Element.prototype.setAttribute; @@ -72,15 +75,22 @@ async function harness(platform = 'MacIntel', streaming = true, overrides: Parti await act(() => root.unmount()); window.Element.prototype.setAttribute = setAttribute; }; - await act(() => root.render( - - { sends.push({ text, metadata }); }} onStop={() => undefined} {...overrides} /> - , - )); + let currentProps = { streaming, ...overrides }; + async function render(patch: Partial[0]> = {}) { + currentProps = { ...currentProps, ...patch }; + await act(async () => root.render( + + + { sends.push({ text, metadata }); }} onStop={() => undefined} {...currentProps} /> + + , + )); + } + await render(); const editor = document.querySelector('[contenteditable="true"]')!; assert.ok(editor); return { - sends, lineBreaks, editor, ref, + sends, lineBreaks, editor, ref, render, async draft(text = 'adjust the current task') { await act(() => ref.current!.setText(text)); }, async press(init: KeyboardEventInit = {}) { const event = new KeyEvent('keydown', { bubbles: true, cancelable: true, ...init }); @@ -179,3 +189,91 @@ test('steering supports staged context and coalesces repeated keys while admissi assert.deepEqual(calls, [{ followUpMode: 'steer' }]); await act(async () => finish(true)); }); + +const suggestedPrompt = 'Add callback tests and verify the result.'; +async function promptSuggestionHarness() { + const h = await harness('MacIntel', true, { + activeSession: { id: 'suggestion-session' } as SessionSummary, + }, { + enabled: true, + setEnabled() {}, + generate: async () => suggestedPrompt, + }); + // Native insertion and Undo are covered in Chromium; exercise the fallback here. + document.execCommand = () => false; + await h.render({ streaming: false }); + const offer = () => document.querySelector('.maka-composer-next-prompt'); + assert.equal(offer()?.textContent, suggestedPrompt); + return { ...h, offer }; +} + +for (const key of ['ArrowRight', 'Tab']) { + test(`${key} accepts the next prompt into the draft and waits for an explicit send`, async () => { + const h = await promptSuggestionHarness(); + const event = await h.press({ key }); + assert.equal(event.defaultPrevented, true); + assert.equal(h.ref.current!.getText(), suggestedPrompt); + assert.equal(h.offer(), null); + assert.deepEqual(h.sends, []); + await h.press(); + assert.deepEqual(h.sends, [{ text: suggestedPrompt, metadata: undefined }]); + }); + + test(`${key} keeps native behavior when the live draft changes before the suggestion render retires`, async () => { + const h = await promptSuggestionHarness(); + const draft = 'Keep the draft that just arrived'; + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }); + await act(() => { + // The imperative write updates the live ref immediately. React has not + // committed the render that removes the old offer when the key arrives. + h.ref.current!.setText(draft); + assert.ok(h.offer()); + h.editor.dispatchEvent(event); + }); + assert.equal(event.defaultPrevented, false); + assert.equal(h.ref.current!.getText(), draft); + assert.equal(h.offer(), null); + assert.deepEqual(h.sends, []); + }); +} + +for (const modifier of ['shiftKey', 'altKey', 'ctrlKey', 'metaKey'] as const) { + test(`${modifier}+Right Arrow retains its native behavior while a suggestion is visible`, async () => { + const h = await promptSuggestionHarness(); + const event = await h.press({ key: 'ArrowRight', [modifier]: true }); + assert.equal(event.defaultPrevented, false); + assert.equal(h.ref.current!.getText(), ''); + assert.ok(h.offer()); + assert.deepEqual(h.sends, []); + }); +} + +test('Right Arrow leaves an open trigger menu and IME keystrokes alone', async () => { + const h = await promptSuggestionHarness(); + h.editor.setAttribute('aria-expanded', 'true'); + await h.press({ key: 'ArrowRight' }); + assert.equal(h.ref.current!.getText(), ''); + assert.ok(h.offer()); + h.editor.setAttribute('aria-expanded', 'false'); + await h.press({ key: 'ArrowRight', isComposing: true }); + assert.equal(h.ref.current!.getText(), ''); + assert.ok(h.offer()); + assert.deepEqual(h.sends, []); +}); + +test('Right Arrow cannot restore an escaped suggestion or overwrite a started draft', async () => { + const h = await promptSuggestionHarness(); + await h.press({ key: 'Escape' }); + await h.press({ key: 'ArrowRight' }); + assert.equal(h.ref.current!.getText(), ''); + assert.equal(h.offer(), null); + await h.render({ streaming: true }); + await h.render({ streaming: false }); + assert.ok(h.offer()); + await h.draft('A different next step'); + const event = await h.press({ key: 'ArrowRight' }); + assert.equal(event.defaultPrevented, false); + assert.equal(h.ref.current!.getText(), 'A different next step'); + assert.equal(h.offer(), null); + assert.deepEqual(h.sends, []); +}); diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index d2c9b71f39a..69868bb189c 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -936,8 +936,8 @@ export const Composer = forwardRef< || props.pendingAttachments?.length || props.pendingQuotes?.length || props.pendingSessionReferences?.length), }); const suggestionLabel = copy.promptSuggestionLabel; - function acceptNextPrompt() { - if (!nextPrompt.text || compositionActiveRef.current || textPort.getValue().length) return; + function acceptNextPrompt(): boolean { + if (!nextPrompt.text || compositionActiveRef.current || textPort.getValue().length) return false; const value = nextPrompt.text; nextPrompt.dismiss(); focusInput(); @@ -947,6 +947,7 @@ export const Composer = forwardRef< saveCurrentDraft(value); } resetPromptHistoryNavigation(); + return true; } @@ -1501,8 +1502,10 @@ export const Composer = forwardRef< function onInputKeyDown(event: KeyboardEvent) { if (!event.defaultPrevented && nextPrompt.text && !compositionActiveRef.current && event.currentTarget.getAttribute('aria-expanded') !== 'true') { - if (event.key === 'Tab' && !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey) { - event.preventDefault(); acceptNextPrompt(); return; + if ((event.key === 'ArrowRight' || event.key === 'Tab') + && !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey) { + if (acceptNextPrompt()) event.preventDefault(); + return; } if (event.key === 'Escape') { event.preventDefault(); setDragActive(false); nextPrompt.dismiss(); return; } } @@ -2596,7 +2599,7 @@ export const Composer = forwardRef< }} /> - {nextPrompt.text ? : null} + {nextPrompt.text ? : null} {props.footerAccessory} )} diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index c06b816443a..1af9ce416ea 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -450,7 +450,7 @@ const CONVERSATION_COPY = { headlineWithLabel: (greeting, label) => `${greeting} ${label},今天想做点什么?`, headlineFallback: (greeting, tail) => `${greeting},${tail}。`, }, composer: { - promptSuggestionLabel: '下一步输入建议', promptSuggestionAccept: 'Tab 接受建议', promptSuggestionDescription: '将首条用户消息及最近六条可见消息(各最多 2,000 字符)发给当前模型;WorkHub 仅发最近消息。费用计入当前会话。不支持关闭思考的推理模型跳过。', + promptSuggestionLabel: '下一步输入建议', promptSuggestionAccept: '→ 或 Tab 接受建议', promptSuggestionDescription: '将首条用户消息及最近六条可见消息(各最多 2,000 字符)发给当前模型;WorkHub 仅发最近消息。费用计入当前会话。不支持关闭思考的推理模型跳过。', placeholder: '描述任务,@ 引用文件或会话,/ 选择技能…', textareaAriaLabel: '消息输入框', quoteCommentTitle: '引用注释', quoteCommentSave: '保存', quoteCommentCancel: '取消', selectedSkillsAriaLabel: '已选择的 Skill', removeSkillAriaLabel: (name) => `移除 Skill:${name}`, awaitingPermission: '等待你确认权限…', sending: '正在发送…', importing: '正在导入…', sendLabel: '发送', queuedMessagesAriaLabel: (count) => `${count} 条待发送消息`, @@ -574,7 +574,7 @@ const CONVERSATION_COPY = { headlineWithLabel: (greeting, label) => `${greeting} ${label},今天想做點什麼?`, headlineFallback: (greeting, tail) => `${greeting},${tail}。`, }, composer: { - promptSuggestionLabel: '下一步輸入建議', promptSuggestionAccept: 'Tab 接受建議', promptSuggestionDescription: '將首條使用者訊息及最近六條可見訊息(各最多 2,000 字元)傳給目前模型;WorkHub 僅傳最近訊息。費用計入目前對話。無法關閉思考的推理模型略過。', + promptSuggestionLabel: '下一步輸入建議', promptSuggestionAccept: '→ 或 Tab 接受建議', promptSuggestionDescription: '將首條使用者訊息及最近六條可見訊息(各最多 2,000 字元)傳給目前模型;WorkHub 僅傳最近訊息。費用計入目前對話。無法關閉思考的推理模型略過。', placeholder: '描述任務,@ 引用檔案,/ 選擇技能…', textareaAriaLabel: '訊息輸入框', quoteCommentTitle: '引用註解', quoteCommentSave: '儲存', quoteCommentCancel: '取消', selectedSkillsAriaLabel: '已選擇的 Skill', removeSkillAriaLabel: (name) => `移除 Skill:${name}`, awaitingPermission: '等待你確認權限…', sending: '正在傳送…', importing: '正在匯入…', sendLabel: '傳送', queuedMessagesAriaLabel: (count) => `${count} 條待發送訊息`, @@ -698,7 +698,7 @@ const CONVERSATION_COPY = { headlineWithLabel: (greeting, label) => `${greeting} ${label} — what shall we tackle today?`, headlineFallback: (greeting, tail) => `${greeting} — ${tail}.`, }, composer: { - promptSuggestionLabel: 'Next prompt suggestions', promptSuggestionAccept: 'Tab to accept', promptSuggestionDescription: 'Sends the first user message and six recent visible messages (up to 2,000 characters each) to this model; WorkHub sends recent messages only. Costs count toward this session. Reasoning models without an off setting are skipped.', + promptSuggestionLabel: 'Next prompt suggestions', promptSuggestionAccept: '→ or Tab to accept', promptSuggestionDescription: 'Sends the first user message and six recent visible messages (up to 2,000 characters each) to this model; WorkHub sends recent messages only. Costs count toward this session. Reasoning models without an off setting are skipped.', placeholder: 'Describe a task, @ to reference files or sessions, / for skills…', textareaAriaLabel: 'Message input', quoteCommentTitle: 'Quote annotation', quoteCommentSave: 'Save', quoteCommentCancel: 'Cancel', selectedSkillsAriaLabel: 'Selected Skills', removeSkillAriaLabel: (name) => `Remove Skill: ${name}`, awaitingPermission: 'Waiting for your permission decision…', sending: 'Sending…', importing: 'Importing…', sendLabel: 'Send', queuedMessagesAriaLabel: (count) => `${count} queued message${count === 1 ? '' : 's'}`,