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
35 changes: 24 additions & 11 deletions src/mobile-web/src/components/ChatComposerBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ interface ChatComposerBarProps {
pendingImages: PendingImage[];
promptSuggestion?: string | null;
onAcceptPromptSuggestion?: (suggestion: string) => void;
onSendPromptSuggestion?: (suggestion: string) => void;
remoteUnavailable: boolean;
streaming: boolean;
}
Expand All @@ -61,6 +62,7 @@ export default function ChatComposerBar({
pendingImages,
promptSuggestion,
onAcceptPromptSuggestion,
onSendPromptSuggestion,
remoteUnavailable,
streaming,
}: ChatComposerBarProps) {
Expand All @@ -74,17 +76,28 @@ export default function ChatComposerBar({
<div className={`chat-page__input-wrap ${expanded ? 'is-expanded' : ''}`} ref={containerRef}>
{queueContent}
{visibleSuggestion ? (
<MobileButton
appearance="secondary"
aria-label={t('chat.promptSuggestionAccept', { suggestion: visibleSuggestion })}
className="chat-page__prompt-suggestion"
data-testid="chat-prompt-suggestion"
onClick={() => onAcceptPromptSuggestion?.(visibleSuggestion)}
size="sm"
trailing={<LucideCornerDownLeft width="14" height="14" aria-hidden="true" />}
>
{visibleSuggestion}
</MobileButton>
<div className="chat-page__prompt-suggestion">
<MobileButton
appearance="secondary"
aria-label={t('chat.promptSuggestionAccept', { suggestion: visibleSuggestion })}
className="chat-page__prompt-suggestion-accept"
data-testid="chat-prompt-suggestion"
onClick={() => onAcceptPromptSuggestion?.(visibleSuggestion)}
size="sm"
trailing={<LucideCornerDownLeft width="14" height="14" aria-hidden="true" />}
>
{visibleSuggestion}
</MobileButton>
<MobileIconButton
appearance="surface"
aria-label={t('chat.promptSuggestionSend', { suggestion: visibleSuggestion })}
className="chat-page__prompt-suggestion-send"
data-testid="chat-prompt-suggestion-send"
icon={<LucideArrowUp width="14" height="14" aria-hidden="true" />}
onClick={() => (onSendPromptSuggestion ?? onAcceptPromptSuggestion)?.(visibleSuggestion)}
size="sm"
/>
</div>
) : null}
<MobileComposer
aria-label={t('chat.collapsedInputPlaceholder')}
Expand Down
3 changes: 3 additions & 0 deletions src/mobile-web/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ export const messages: Record<MobileLanguage, MessageTree> = {
collapsedInputPlaceholder: 'Ask OpenBitFun',
collapsedStreamingPlaceholder: 'Replying...',
promptSuggestionAccept: 'Use suggestion: {suggestion}',
promptSuggestionSend: 'Send suggestion: {suggestion}',
messageQueued: 'Message queued; it will run after the current step',
imageAnalyzingPlaceholder: 'Sending images...',
imageAttachmentFallback: '(see attached images)',
Expand Down Expand Up @@ -627,6 +628,7 @@ export const messages: Record<MobileLanguage, MessageTree> = {
collapsedInputPlaceholder: '向 OpenBitFun 提问',
collapsedStreamingPlaceholder: '正在回复...',
promptSuggestionAccept: '采纳建议:{suggestion}',
promptSuggestionSend: '直接发送建议:{suggestion}',
messageQueued: '已加入队列,将在当前步骤结束后处理',
imageAnalyzingPlaceholder: '正在发送图片...',
imageAttachmentFallback: '(见附带图片)',
Expand Down Expand Up @@ -995,6 +997,7 @@ export const messages: Record<MobileLanguage, MessageTree> = {
collapsedInputPlaceholder: '向 OpenBitFun 提問',
collapsedStreamingPlaceholder: '正在回覆...',
promptSuggestionAccept: '採納建議:{suggestion}',
promptSuggestionSend: '直接傳送建議:{suggestion}',
messageQueued: '已加入隊列,將在當前步驟結束後處理',
imageAnalyzingPlaceholder: '正在傳送圖片...',
imageAttachmentFallback: '(見附帶圖片)',
Expand Down
24 changes: 17 additions & 7 deletions src/mobile-web/src/pages/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -991,10 +991,15 @@ const ChatPage: React.FC<ChatPageProps> = ({
return () => clearInterval(tid);
}, [isStreaming]);

const handleSend = useCallback(async () => {
const handleSend = useCallback(async (overrideText?: string) => {
if (useMobileStore.getState().connectionHealth === 'unreachable') return;
const text = input.trim();
const imgs = pendingImages;
// `onClick` passes the click event here; only a string means "submit this
// text instead of the current draft", which is how a suggested prompt is
// sent in one tap.
const explicitText = typeof overrideText === 'string' ? overrideText : null;
const text = (explicitText ?? input).trim();
// Attachments belong to the draft, which a suggestion leaves untouched.
const imgs = explicitText === null ? pendingImages : [];
if ((!text && imgs.length === 0) || imageAnalyzing || sendInFlightRef.current) return;
const targetEpoch = captureChatTargetEpoch();
if (targetEpoch === null) return;
Expand Down Expand Up @@ -1037,11 +1042,15 @@ const ChatPage: React.FC<ChatPageProps> = ({
if (!isChatTargetCurrent(targetEpoch)) return;
// Keep the draft until the host acknowledges it. A failed request must
// remain retryable, and an acknowledgement must not erase newer typing.
const draftUnchanged = inputRef.current?.value === input;
setInput(current => current === input ? '' : current);
// A suggestion sent in one tap never touched the draft, so it must leave
// whatever the user had typed alone.
setPromptSuggestion(null);
if (explicitText === null) {
const draftUnchanged = inputRef.current?.value === input;
setInput(current => current === input ? '' : current);
if (!wasStreaming && draftUnchanged) setInputExpanded(false);
}
setPendingImages(current => current.filter(image => !imgs.includes(image)));
if (!wasStreaming && draftUnchanged) setInputExpanded(false);
streamRef.current?.nudge();
if (hostQueue?.getSnapshot().snapshot?.receipt?.status === 'queued') {
setInfoToast(t('chat.messageQueued'));
Expand Down Expand Up @@ -1338,10 +1347,11 @@ const ChatPage: React.FC<ChatPageProps> = ({
onCompositionStart={handleCompositionStart}
onKeyDown={handleKeyDown}
onRemoveImage={removeImage}
onSend={handleSend}
onSend={() => void handleSend()}
pendingImages={pendingImages}
promptSuggestion={promptSuggestion}
onAcceptPromptSuggestion={acceptPromptSuggestion}
onSendPromptSuggestion={suggestion => void handleSend(suggestion)}
streaming={isStreaming}
/>

Expand Down
14 changes: 14 additions & 0 deletions src/mobile-web/src/styles/components/chat-input.scss
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@

.chat-page__prompt-suggestion {
align-self: flex-start;
display: flex;
align-items: center;
gap: 6px;
max-width: 100%;
min-width: 0;

Expand All @@ -95,6 +98,17 @@
}
}

// The bubble accepts the suggestion into the editor; the trailing control
// submits it directly so a suggestion can be sent with a single tap.
.chat-page__prompt-suggestion-accept {
flex: 0 1 auto;
min-width: 0;
}

.chat-page__prompt-suggestion-send {
flex: 0 0 auto;
}

// ── Model and reasoning controls ─────────────────────────────────────────

.chat-model-selector {
Expand Down
23 changes: 19 additions & 4 deletions src/mobile-web/tests/fixtures/assistant-session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import type { RelayHttpClient } from '../../src/services/RelayHttpClient';
import { queueStorage, type QueueItem, type QueueOutboxRecord } from '../../../shared/dialog-queue/HostDialogQueue';

/** Real sidebar, chat, manager and durable outbox; only host IO is simulated. */
export async function mountAssistantSessionFixture({ legacyWorkspace = false, queueSupported = true, legacyPending = false, selectedProject = false } = {}) {
export async function mountAssistantSessionFixture({ legacyWorkspace = false, queueSupported = true, legacyPending = false, selectedProject = false, openChat = false, holdSubmissions = false } = {}) {
let streamHandlers: Parameters<RemoteSessionManager['subscribeSessionStream']>[1] | undefined;
let settleSubmission: ((error?: string) => void) | undefined;
const account = 'assistant-mode-account', device = 'assistant-mode-host', sessionId = 'assistant-session';
const capabilities = ['host_stream_v1', ...(queueSupported ? ['dialog_queue_v1'] : []),
...(legacyWorkspace ? [] : ['workspace_id_references_v1', 'harness_profiles_v1'])];
Expand All @@ -32,7 +34,7 @@ export async function mountAssistantSessionFixture({ legacyWorkspace = false, qu
const projectSessions: SessionInfo[] = projects.map((workspace) => ({ session_id: `${workspace.name}-session`,
name: `${workspace.name} session`, agent_type: 'agentic', workspace_id: workspace.workspace_id,
workspace_path: workspace.path, workspace_name: workspace.name, created_at: '1', updated_at: '1', message_count: 0 }));
const calls: Array<Record<string, any>> = [], sessions: SessionInfo[] = legacyPending ? [session] : [];
const calls: Array<Record<string, any>> = [], sessions: SessionInfo[] = legacyPending || openChat ? [session] : [];
const receipts = new Map<string, QueueItem>();
const scope = JSON.stringify([account, device, sessionId]);
const pending: QueueOutboxRecord = { scope, key: JSON.stringify([scope, 'before-upgrade-turn']),
Expand All @@ -48,6 +50,7 @@ export async function mountAssistantSessionFixture({ legacyWorkspace = false, qu
isControlTargetCurrent: (target: { deviceId: string; epoch: number }) => target.deviceId === device && target.epoch === 0,
onControlTargetChange: () => () => {}, onDeviceDirectoryChanged: () => () => {},
subscribeHostStream: async (_id: string, handlers: Parameters<RemoteSessionManager['subscribeSessionStream']>[1]) => {
streamHandlers = handlers;
handlers.onHistoryState?.({ hasMore: false, oldestSeq: null, cursor: 0, truncated: false });
handlers.onCaughtUp?.();
return { close() {}, wake() {}, loadOlder: async () => {} };
Expand Down Expand Up @@ -78,6 +81,9 @@ export async function mountAssistantSessionFixture({ legacyWorkspace = false, qu
const request = command.request;
let receipt = request.action === 'get' ? receipts.get(request.turnId) ?? null : null;
if (request.action === 'submit') {
if (holdSubmissions) await new Promise<void>((resolve, reject) => {
settleSubmission = error => error ? reject(new Error(error)) : resolve();
});
const message = request.message;
if (!['Claw', 'Cowork', 'agentic', 'minimal', 'Ultra', 'Creative', 'plugin::claw'].includes(message.agentType)) {
throw new Error(`Unknown session mode: ${message.agentType}`);
Expand Down Expand Up @@ -105,7 +111,7 @@ export async function mountAssistantSessionFixture({ legacyWorkspace = false, qu
document.body.replaceChildren(element);
const root = createRoot(element);
function Fixture() {
const [selected, setSelected] = useState<{ id: string; agentType?: string; workspace?: SessionWorkspaceContext } | null>(legacyPending ? { id: sessionId, agentType: 'Claw' } : null);
const [selected, setSelected] = useState<{ id: string; agentType?: string; workspace?: SessionWorkspaceContext } | null>(legacyPending || openChat ? { id: sessionId, agentType: 'Claw' } : null);
return <ThemeProvider><I18nProvider>{selected
? <ChatPage sessionMgr={manager} sessionId={selected.id} sessionName="New Session" agentType={selected.agentType}
workspace={selected.workspace}
Expand All @@ -116,5 +122,14 @@ export async function mountAssistantSessionFixture({ legacyWorkspace = false, qu
}</I18nProvider></ThemeProvider>;
}
root.render(<Fixture />);
return { calls, manager, pending, receipts, sessionId, dispose: () => root.unmount() };
return { calls, manager, pending, receipts, sessionId, dispose: () => root.unmount(),
ready: () => !!streamHandlers,
event: (event: Parameters<NonNullable<typeof streamHandlers>['onEvent']>[0]) => streamHandlers?.onEvent(event),
completeSubmission(error?: string) {
if (!settleSubmission) throw new Error('No submission is waiting for acknowledgement');
const settle = settleSubmission;
settleSubmission = undefined;
settle(error);
},
};
}
2 changes: 2 additions & 0 deletions src/mobile-web/tests/fixtures/prompt-suggestion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ export function mountPromptSuggestionFixture() {
promptSuggestion={suggestion} onAcceptPromptSuggestion={value => {
calls.push(value); setInput(value); setExpanded(true);
requestAnimationFrame(() => inputRef.current?.focus());
}} onSendPromptSuggestion={value => {
calls.push(`send:${value}`); setSuggestion(null);
}} />
</div></I18nProvider></ThemeProvider>;
}
Expand Down
Loading
Loading