Skip to content
Open
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
22 changes: 21 additions & 1 deletion apps/desktop/stories/workhub.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, '请总结验证结果。');
Expand Down
112 changes: 105 additions & 7 deletions packages/ui/src/__tests__/composer-shortcuts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -46,9 +48,10 @@ afterEach(async () => {
else Reflect.deleteProperty(globalThis, 'navigator');
});

async function harness(platform = 'MacIntel', streaming = true, overrides: Partial<Parameters<typeof Composer>[0]> = {}) {
async function harness(platform = 'MacIntel', streaming = true, overrides: Partial<Parameters<typeof Composer>[0]> = {}, suggestionService?: ComposerPromptSuggestionService) {
const { document, window } = parseHTML('<div id="root"></div>');
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;
Expand All @@ -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(
<LocaleProvider locale="en">
<Composer ref={ref} streaming={streaming} onSend={(text, metadata) => { sends.push({ text, metadata }); }} onStop={() => undefined} {...overrides} />
</LocaleProvider>,
));
let currentProps = { streaming, ...overrides };
async function render(patch: Partial<Parameters<typeof Composer>[0]> = {}) {
currentProps = { ...currentProps, ...patch };
await act(async () => root.render(
<LocaleProvider locale="en">
<ComposerPromptSuggestionProvider service={suggestionService}>
<Composer ref={ref} onSend={(text, metadata) => { sends.push({ text, metadata }); }} onStop={() => undefined} {...currentProps} />
</ComposerPromptSuggestionProvider>
</LocaleProvider>,
));
}
await render();
const editor = document.querySelector<HTMLElement>('[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 });
Expand Down Expand Up @@ -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, []);
});
13 changes: 8 additions & 5 deletions packages/ui/src/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -947,6 +947,7 @@ export const Composer = forwardRef<
saveCurrentDraft(value);
}
resetPromptHistoryNavigation();
return true;
}


Expand Down Expand Up @@ -1501,8 +1502,10 @@ export const Composer = forwardRef<
function onInputKeyDown(event: KeyboardEvent<HTMLDivElement>) {
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')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 (optional, also applies to the existing Tab path): preventDefault() runs before acceptNextPrompt() re-checks textPort.getValue().length and composition. If those guards bail, the arrow is swallowed with no effect. Having acceptNextPrompt() return whether it accepted, and calling preventDefault() only in that case, would keep native caret movement as the fallback. I did not reproduce this; it is a narrow DOM/state race at most.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 7ab620e.

acceptNextPrompt() now returns whether it accepted the suggestion, and the shortcut handler calls preventDefault() only on success. If the live draft has already changed, Right Arrow and Tab keep their native behavior.

Added deterministic component regressions for both keys: update the live draft through the production imperative handle and dispatch the shortcut before React commits the render that retires the offer. Both tests fail on the previous code because the event is consumed, and pass with this fix while preserving the arriving draft and sending nothing.

Validation: 723 shared UI tests passed; the built WorkHub Storybook scenario passed in light/dark themes at 720px and 1280px, including acceptance, caret placement, Undo, and explicit send. Full build, lint, formatting, typecheck, and Knip for Desktop/UI also passed.

For the earlier CI failure, I checked both the PR job and the main job: both fail Check packaged artifact budget on node_modules/marked@18.0.11. This PR changes neither dependencies nor the packaging ledger; that upstream failure remains separate from this fix.

Posted by OpenAI Codex on behalf of MasamiYui.

&& !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey) {
if (acceptNextPrompt()) event.preventDefault();
return;
}
if (event.key === 'Escape') { event.preventDefault(); setDragActive(false); nextPrompt.dismiss(); return; }
}
Expand Down Expand Up @@ -2596,7 +2599,7 @@ export const Composer = forwardRef<
}}
/>
</MakaClientSessionScope>
{nextPrompt.text ? <kbd className="maka-composer-next-prompt-key" aria-hidden="true">Tab</kbd> : null}
{nextPrompt.text ? <kbd className="maka-composer-next-prompt-key" aria-hidden="true">{copy.promptSuggestionAccept}</kbd> : null}
{props.footerAccessory}
</div>
)}
Expand Down
6 changes: 3 additions & 3 deletions packages/ui/src/conversation-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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} 条待发送消息`,
Expand Down Expand Up @@ -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} 條待發送訊息`,
Expand Down Expand Up @@ -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'}`,
Expand Down
Loading