From 57d36f6c1b81a395a098b82e67d608ba9b349f94 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 29 May 2026 13:31:07 +0300 Subject: [PATCH] feat: show draft preset chips under the welcome message on mobile/vscode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On narrow surfaces (mobile, vscode) the composer sits at the bottom and only the welcome message is centered, so the preset chips had nowhere sensible to live and were desktop-only. Render them under the centered welcome message there instead. Extract the shared preset list (draftPresets.ts) and chip row (DraftPresetChips) so both layouts reuse them. Since the command-aware submit lives in ChatInput, ChatContainer triggers it through a new input-store channel (requestPresetSubmit / consumePendingPresetSubmit) that ChatInput consumes. Mini-chat stays excluded — too small for the row. --- .../ui/src/components/chat/ChatContainer.tsx | 10 +++- packages/ui/src/components/chat/ChatInput.tsx | 53 +++++-------------- .../src/components/chat/DraftPresetChips.tsx | 47 ++++++++++++++++ .../ui/src/components/chat/draftPresets.ts | 29 ++++++++++ packages/ui/src/sync/input-store.ts | 18 +++++++ 5 files changed, 115 insertions(+), 42 deletions(-) create mode 100644 packages/ui/src/components/chat/DraftPresetChips.tsx create mode 100644 packages/ui/src/components/chat/draftPresets.ts diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 63869dc2..429515f9 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -2,6 +2,8 @@ import React from 'react'; import type { Message, Part, Session } from '@opencode-ai/sdk/v2'; import { ChatInput } from './ChatInput'; +import { DraftPresetChips } from './DraftPresetChips'; +import { useInputStore } from '@/sync/input-store'; import { useUIStore } from '@/stores/useUIStore'; import { Skeleton } from '@/components/ui/skeleton'; import ChatEmptyState from './ChatEmptyState'; @@ -811,7 +813,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr return (
{useCompactDraftLayout && !isDesktopExpandedInput ? ( -
+

{renderDraftTitle( draftProjectLabel @@ -820,6 +822,12 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr draftProjectLabel, )}

+ {chatSurfaceMode !== 'mini-chat' ? ( + useInputStore.getState().requestPresetSubmit(text)} + className="mt-8 max-w-md" + /> + ) : null}
) : null}
= ({ onOpenSettings, scrollTo const clearAttachedFiles = useInputStore((s) => s.clearAttachedFiles); const saveSessionAgentSelection = useSelectionStore((s) => s.saveSessionAgentSelection); const consumePendingInputText = useInputStore((s) => s.consumePendingInputText); + const pendingPresetSubmit = useInputStore((s) => s.pendingPresetSubmit); const setPendingInputText = useInputStore((s) => s.setPendingInputText); const pendingInputText = useInputStore((s) => s.pendingInputText); const consumePendingSyntheticParts = useInputStore((s) => s.consumePendingSyntheticParts); @@ -2197,6 +2178,15 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo void handleSubmitRef.current(); }, []); + // Preset chips rendered outside this component (e.g. under the welcome + // message on narrow surfaces) request a submit via the input store; consume + // it here so it routes through the same command-aware submit path. + React.useEffect(() => { + if (pendingPresetSubmit == null) return; + const text = useInputStore.getState().consumePendingPresetSubmit(); + if (text) submitPresetPrompt(text); + }, [pendingPresetSubmit, submitPresetPrompt]); + const handleKeyDown = (e: React.KeyboardEvent) => { // Early return during IME composition to prevent interference with autocomplete. // Uses keyCode === 229 fallback for WebKit where compositionend fires before keydown. @@ -4545,26 +4535,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
{newSessionDraftOpen && !isDesktopExpanded && !isMobile && !isVSCode && !isMiniChatSurface ? ( -
- {DRAFT_PRESETS.map((preset) => ( - - ))} -
+ ) : null} diff --git a/packages/ui/src/components/chat/DraftPresetChips.tsx b/packages/ui/src/components/chat/DraftPresetChips.tsx new file mode 100644 index 00000000..6388e3a7 --- /dev/null +++ b/packages/ui/src/components/chat/DraftPresetChips.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Icon } from "@/components/icon/Icon"; +import { useI18n } from '@/lib/i18n'; +import { useThemeSystem } from '@/contexts/useThemeSystem'; +import { cn } from '@/lib/utils'; +import { DRAFT_PRESETS, resolveDraftPresetText } from './draftPresets'; + +type DraftPresetChipsProps = { + /** Called with the resolved text (command or prompt) when a chip is clicked. */ + onSubmit: (text: string) => void; + /** Extra classes for the wrapper (e.g. width/spacing per surface). */ + className?: string; +}; + +/** + * The row of starter preset chips shown on the draft welcome screen. + * Rendering is shared between the desktop layout (under the composer) and the + * narrow/compact layout (under the centered welcome message); the surface owns + * how clicking a chip is submitted via `onSubmit`. + */ +export const DraftPresetChips: React.FC = ({ onSubmit, className }) => { + const { t } = useI18n(); + const { currentTheme } = useThemeSystem(); + + return ( +
+ {DRAFT_PRESETS.map((preset) => ( + + ))} +
+ ); +}; diff --git a/packages/ui/src/components/chat/draftPresets.ts b/packages/ui/src/components/chat/draftPresets.ts new file mode 100644 index 00000000..c5a19469 --- /dev/null +++ b/packages/ui/src/components/chat/draftPresets.ts @@ -0,0 +1,29 @@ +import type { IconName } from "@/components/icon/icons"; +import type { I18nKey } from "@/lib/i18n"; + +// Starter presets shown on the draft welcome screen — under the composer on +// desktop, and under the centered welcome message on narrow surfaces +// (mobile/vscode). `command` presets send a built-in slash command through the +// normal submit path; `promptKey` presets send a plain natural-language prompt. +export type DraftPreset = { + id: string; + icon: IconName; + labelKey: I18nKey; + promptKey?: I18nKey; + command?: string; +}; + +export const DRAFT_PRESETS: readonly DraftPreset[] = [ + { id: 'explore', icon: 'compass-3', labelKey: 'chat.draftPresets.explore.label', command: '/explore' }, + { id: 'catchup', icon: 'history', labelKey: 'chat.draftPresets.catchup.label', command: '/catch-up' }, + { id: 'weigh', icon: 'scales-3', labelKey: 'chat.draftPresets.weigh.label', command: '/weigh' }, + { id: 'plan', icon: 'survey', labelKey: 'chat.draftPresets.plan.label', command: '/plan-feature' }, + { id: 'debug', icon: 'bug', labelKey: 'chat.draftPresets.debug.label', command: '/debug' }, + { id: 'review', icon: 'search-eye', labelKey: 'chat.draftPresets.review.label', command: '/workspace-review' }, +]; + +// Resolve the text a preset submits: a slash command, or a translated prompt. +export const resolveDraftPresetText = ( + preset: DraftPreset, + t: (key: I18nKey) => string, +): string => preset.command ?? (preset.promptKey ? t(preset.promptKey) : ''); diff --git a/packages/ui/src/sync/input-store.ts b/packages/ui/src/sync/input-store.ts index a5e81a8e..52933de6 100644 --- a/packages/ui/src/sync/input-store.ts +++ b/packages/ui/src/sync/input-store.ts @@ -88,11 +88,19 @@ export type InputState = { pendingInputText: string | null pendingInputMode: "replace" | "append" | "append-inline" pendingSyntheticParts: SyntheticContextPart[] | null + /** + * Text a draft preset chip asked to submit immediately. Set by surfaces that + * render the chips outside ChatInput (e.g. under the welcome message on + * narrow layouts); consumed by ChatInput, which owns the command-aware submit. + */ + pendingPresetSubmit: string | null attachedFiles: AttachedFile[] activeEditorFile: VSCodeActiveEditorFile | null setPendingInputText: (text: string | null, mode?: "replace" | "append" | "append-inline") => void consumePendingInputText: () => { text: string; mode: "replace" | "append" | "append-inline" } | null + requestPresetSubmit: (text: string) => void + consumePendingPresetSubmit: () => string | null setPendingSyntheticParts: (parts: SyntheticContextPart[] | null) => void consumePendingSyntheticParts: () => SyntheticContextPart[] | null addAttachedFile: (file: File) => Promise @@ -110,6 +118,7 @@ export const useInputStore = create()((set, get) => ({ pendingInputText: null, pendingInputMode: "replace", pendingSyntheticParts: null, + pendingPresetSubmit: null, attachedFiles: [], activeEditorFile: null, @@ -123,6 +132,15 @@ export const useInputStore = create()((set, get) => ({ return { text: pendingInputText, mode: pendingInputMode } }, + requestPresetSubmit: (text) => set({ pendingPresetSubmit: text }), + + consumePendingPresetSubmit: () => { + const { pendingPresetSubmit } = get() + if (pendingPresetSubmit === null) return null + set({ pendingPresetSubmit: null }) + return pendingPresetSubmit + }, + setPendingSyntheticParts: (parts) => set({ pendingSyntheticParts: parts }), consumePendingSyntheticParts: () => {