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: () => {