feat: show draft preset chips under the welcome message on mobile/vscode
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.
This commit is contained in:
@@ -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<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
return (
|
||||
<div className="relative flex h-full flex-col bg-background transform-gpu">
|
||||
{useCompactDraftLayout && !isDesktopExpandedInput ? (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center">
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center">
|
||||
<h1 className="text-balance text-3xl font-normal tracking-tight text-foreground">
|
||||
{renderDraftTitle(
|
||||
draftProjectLabel
|
||||
@@ -820,6 +822,12 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
draftProjectLabel,
|
||||
)}
|
||||
</h1>
|
||||
{chatSurfaceMode !== 'mini-chat' ? (
|
||||
<DraftPresetChips
|
||||
onSubmit={(text) => useInputStore.getState().requestPresetSubmit(text)}
|
||||
className="mt-8 max-w-md"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
|
||||
@@ -52,7 +52,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { GitHubIssuePickerDialog } from '@/components/session/GitHubIssuePickerDialog';
|
||||
import { GitHubPrPickerDialog } from '@/components/session/GitHubPrPickerDialog';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { IconName } from "@/components/icon/icons";
|
||||
import { DraftPresetChips } from './DraftPresetChips';
|
||||
import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
@@ -67,7 +67,6 @@ import { buildSessionTargetOptions } from '@/sync/session-worktree-contract';
|
||||
import { usePermissionStore } from '@/stores/permissionStore';
|
||||
import { extractGitChangedFiles } from './changedFiles';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { I18nKey } from '@/lib/i18n';
|
||||
import { fetchResponseStyleInstruction } from '@/lib/responseStyle';
|
||||
import { wrapSystemReminder } from '@/lib/systemReminder';
|
||||
import { getSyncMessages } from '@/sync/sync-refs';
|
||||
@@ -88,25 +87,6 @@ import {
|
||||
} from './attachmentCitations';
|
||||
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
|
||||
|
||||
// Starter presets shown under the composer on the desktop draft welcome screen.
|
||||
// `promptKey` presets send a plain natural-language prompt; `command` presets
|
||||
// send a built-in slash command (e.g. /workspace-review) through the normal submit path.
|
||||
type DraftPreset = {
|
||||
id: string;
|
||||
icon: IconName;
|
||||
labelKey: I18nKey;
|
||||
promptKey?: I18nKey;
|
||||
command?: string;
|
||||
};
|
||||
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' },
|
||||
];
|
||||
|
||||
const MAX_VISIBLE_TEXTAREA_LINES = 8;
|
||||
const EMPTY_QUEUE: QueuedMessage[] = [];
|
||||
const EMPTY_MESSAGES: Message[] = [];
|
||||
@@ -1021,6 +1001,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<HTMLTextAreaElement>) => {
|
||||
// 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<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
</div>
|
||||
</div>
|
||||
{newSessionDraftOpen && !isDesktopExpanded && !isMobile && !isVSCode && !isMiniChatSurface ? (
|
||||
<div className="chat-input-column mt-4 flex flex-wrap justify-center gap-2">
|
||||
{DRAFT_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const text = preset.command ?? (preset.promptKey ? t(preset.promptKey) : '');
|
||||
if (text) submitPresetPrompt(text);
|
||||
}}
|
||||
className="group inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-[var(--interactive-hover)] hover:text-foreground"
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
||||
borderColor: currentTheme?.colors?.interactive?.border,
|
||||
}}
|
||||
>
|
||||
<Icon name={preset.icon} className="h-3.5 w-3.5 shrink-0 opacity-70 transition-opacity group-hover:opacity-100" />
|
||||
<span>{t(preset.labelKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<DraftPresetChips onSubmit={submitPresetPrompt} className="chat-input-column mt-4" />
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
|
||||
@@ -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<DraftPresetChipsProps> = ({ onSubmit, className }) => {
|
||||
const { t } = useI18n();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-wrap items-center justify-center gap-2', className)}>
|
||||
{DRAFT_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const text = resolveDraftPresetText(preset, t);
|
||||
if (text) onSubmit(text);
|
||||
}}
|
||||
className="group inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-[var(--interactive-hover)] hover:text-foreground"
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
||||
borderColor: currentTheme?.colors?.interactive?.border,
|
||||
}}
|
||||
>
|
||||
<Icon name={preset.icon} className="h-3.5 w-3.5 shrink-0 opacity-70 transition-opacity group-hover:opacity-100" />
|
||||
<span>{t(preset.labelKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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) : '');
|
||||
@@ -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<void>
|
||||
@@ -110,6 +118,7 @@ export const useInputStore = create<InputState>()((set, get) => ({
|
||||
pendingInputText: null,
|
||||
pendingInputMode: "replace",
|
||||
pendingSyntheticParts: null,
|
||||
pendingPresetSubmit: null,
|
||||
attachedFiles: [],
|
||||
activeEditorFile: null,
|
||||
|
||||
@@ -123,6 +132,15 @@ export const useInputStore = create<InputState>()((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: () => {
|
||||
|
||||
Reference in New Issue
Block a user