diff --git a/locales/en.json b/locales/en.json index f10ce9b93..ee47a165b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -380,6 +380,7 @@ "chat.mobileHome.localProjectSearchPlaceholder": "Search projects", "chat.mobileHome.machineSelector": "Machines", "chat.mobileHome.newChatAriaLabel": "New chat", + "chat.mobileHome.newChatInProjectAriaLabel": "New chat in {{projectLabel}}", "chat.mobileHome.onboarding.description": "Download the desktop app to get started.", "chat.mobileHome.onboarding.downloadButton": "Download Lody", "chat.mobileHome.onboarding.title": "Lody runs on your computer", @@ -402,10 +403,9 @@ "chat.mobileNewChat.planModeLabel": "Plan", "chat.mobileNewChat.projectLabel": "Project", "chat.mobileNewChat.repoLabel": "Repo", + "chat.mobileNewChat.roleMachineLocked": "Machine is fixed by the selected Role", "chat.mobileNewChat.title": "New chat", - "chat.mobileNewChat.workdirLocalLabel": "Local files", - "chat.mobileNewChat.workdirModeLabel": "Mode", - "chat.mobileNewChat.workdirWorktreeLabel": "Worktree", + "chat.mobileNewChat.workLabel": "Work", "chat.mobileProject.chatTab": "Chats", "chat.mobileProject.emptyConversations": "No matching chats", "chat.mobileProject.filesTab": "Files", @@ -600,16 +600,16 @@ "composer.pastedTextSummary": "{{charCount}} chars · {{lineCount}} lines", "composer.pastedTextTitle": "Pasted text", "composer.promptPlaceholder.base": "", - "composer.promptPlaceholder.commands": "Press '/' for commands.", - "composer.promptPlaceholder.commandsMentions": "Press '/' for commands, '@' for mentions.", - "composer.promptPlaceholder.commandsMentionsSkills": "Press '/' for commands, '@' for mentions, '$' for skills.", - "composer.promptPlaceholder.commandsSkills": "Press '/' for commands, '$' for skills.", - "composer.promptPlaceholder.mentions": "Press '@' for mentions.", - "composer.promptPlaceholder.mentionsSkills": "Press '@' for mentions, '$' for skills.", - "composer.promptPlaceholder.mobile": "Press '@' for mentions.", - "composer.promptPlaceholder.mobileMentionsSkills": "Press '@' for mentions, '$' for skills.", - "composer.promptPlaceholder.mobileSkills": "Press '$' for skills.", - "composer.promptPlaceholder.skills": "Press '$' for skills.", + "composer.promptPlaceholder.commands": "Press / for commands.", + "composer.promptPlaceholder.commandsMentions": "Press / for commands, @ for mentions.", + "composer.promptPlaceholder.commandsMentionsSkills": "Press / for commands, @ for mentions, $ for skills.", + "composer.promptPlaceholder.commandsSkills": "Press / for commands, $ for skills.", + "composer.promptPlaceholder.mentions": "Press @ for mentions.", + "composer.promptPlaceholder.mentionsSkills": "Press @ for mentions, $ for skills.", + "composer.promptPlaceholder.mobile": "Press @ for mentions.", + "composer.promptPlaceholder.mobileMentionsSkills": "Press @ for mentions, $ for skills.", + "composer.promptPlaceholder.mobileSkills": "Press $ for skills.", + "composer.promptPlaceholder.skills": "Press $ for skills.", "desktopGithubInstall.openButton": "Open Lody Desktop", "desktopGithubInstall.opening": "Opening Lody Desktop…", "desktopGithubInstall.title": "Continue in Lody Desktop", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 7f73faf65..980aa96ef 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -380,6 +380,7 @@ "chat.mobileHome.localProjectSearchPlaceholder": "搜索项目", "chat.mobileHome.machineSelector": "机器", "chat.mobileHome.newChatAriaLabel": "新建对话", + "chat.mobileHome.newChatInProjectAriaLabel": "在 {{projectLabel}} 中新建对话", "chat.mobileHome.onboarding.description": "下载桌面客户端并启动后即可开始。", "chat.mobileHome.onboarding.downloadButton": "下载 Lody", "chat.mobileHome.onboarding.title": "Lody 运行在你的电脑上", @@ -402,10 +403,9 @@ "chat.mobileNewChat.planModeLabel": "计划", "chat.mobileNewChat.projectLabel": "项目", "chat.mobileNewChat.repoLabel": "仓库", + "chat.mobileNewChat.roleMachineLocked": "所选 Role 已固定运行机器", "chat.mobileNewChat.title": "新建对话", - "chat.mobileNewChat.workdirLocalLabel": "本地文件", - "chat.mobileNewChat.workdirModeLabel": "模式", - "chat.mobileNewChat.workdirWorktreeLabel": "新工作树", + "chat.mobileNewChat.workLabel": "工作", "chat.mobileProject.chatTab": "对话", "chat.mobileProject.emptyConversations": "没有匹配的对话", "chat.mobileProject.filesTab": "文件", @@ -600,16 +600,16 @@ "composer.pastedTextSummary": "{{charCount}} 字符 · {{lineCount}} 行", "composer.pastedTextTitle": "已粘贴文本", "composer.promptPlaceholder.base": "", - "composer.promptPlaceholder.commands": "按 '/' 使用命令。", - "composer.promptPlaceholder.commandsMentions": "按 '/' 使用命令,'@' 添加提及。", - "composer.promptPlaceholder.commandsMentionsSkills": "按 '/' 使用命令,'@' 添加提及,'$' 使用技能。", - "composer.promptPlaceholder.commandsSkills": "按 '/' 使用命令,'$' 使用技能。", - "composer.promptPlaceholder.mentions": "按 '@' 添加提及。", - "composer.promptPlaceholder.mentionsSkills": "按 '@' 添加提及,'$' 使用技能。", - "composer.promptPlaceholder.mobile": "按 '@' 添加提及。", - "composer.promptPlaceholder.mobileMentionsSkills": "按 '@' 添加提及,'$' 使用技能。", - "composer.promptPlaceholder.mobileSkills": "按 '$' 使用技能。", - "composer.promptPlaceholder.skills": "按 '$' 使用技能。", + "composer.promptPlaceholder.commands": "按 / 使用命令。", + "composer.promptPlaceholder.commandsMentions": "按 / 使用命令,@ 添加提及。", + "composer.promptPlaceholder.commandsMentionsSkills": "按 / 使用命令,@ 添加提及,$ 使用技能。", + "composer.promptPlaceholder.commandsSkills": "按 / 使用命令,$ 使用技能。", + "composer.promptPlaceholder.mentions": "按 @ 添加提及。", + "composer.promptPlaceholder.mentionsSkills": "按 @ 添加提及,$ 使用技能。", + "composer.promptPlaceholder.mobile": "按 @ 添加提及。", + "composer.promptPlaceholder.mobileMentionsSkills": "按 @ 添加提及,$ 使用技能。", + "composer.promptPlaceholder.mobileSkills": "按 $ 使用技能。", + "composer.promptPlaceholder.skills": "按 $ 使用技能。", "desktopGithubInstall.openButton": "打开 Lody Desktop", "desktopGithubInstall.opening": "正在打开 Lody Desktop…", "desktopGithubInstall.title": "继续使用 Lody Desktop", diff --git a/packages/components/src/components/chat/AGENTS.md b/packages/components/src/components/chat/AGENTS.md index b83db9200..136c595d3 100644 --- a/packages/components/src/components/chat/AGENTS.md +++ b/packages/components/src/components/chat/AGENTS.md @@ -53,6 +53,11 @@ Index and rationale: [README.md](README.md). `onAttachmentAddClick`; selected files are split by MIME into the image and file draft hooks, exactly like paste and drop. +- Mobile New Chat shares the Local/GitHub selection model through a native header + picker with no project (Chat) first. Switching context preserves draft/run + config; selected Roles prevent machine changes. Machine, branch, and Worktree + join run config in the bottom rail; see `../mobile/AGENTS.md`. + ## Invariants - The chat-route URL declares the composer's selection and never carries one-shot diff --git a/packages/components/src/components/chat/chat-composer.tsx b/packages/components/src/components/chat/chat-composer.tsx index fac41a465..f4f1b51b3 100644 --- a/packages/components/src/components/chat/chat-composer.tsx +++ b/packages/components/src/components/chat/chat-composer.tsx @@ -99,6 +99,8 @@ export interface ChatComposerProps { title?: ReactNode; tone?: ChatComposerTone; variant?: ChatComposerVariant; + /** Fill a new-chat sheet with a borderless editor and a horizontally scrolling footer. */ + fillSheet?: boolean; mentionSource?: MentionProjectSource; availableCommands?: AcpCommandSummary[]; /** False while this retained composer is not the active command surface. */ @@ -118,7 +120,11 @@ export interface ChatComposerProps { promptPlaceholder?: string; promptDisabled?: boolean; promptRows?: number; + /** Override the compact one-row minimum used by mobile session composers. */ + mobileSessionPromptRows?: number; promptEnterKeyHint?: TextareaProps['enterKeyHint']; + /** Focus the prompt as soon as this composer is mounted. */ + promptAutoFocus?: boolean; promptRef?: Ref; pastedTextDrafts?: PastedTextDraft[]; onPastedTextDraftsChange?: (drafts: PastedTextDraft[]) => void; @@ -230,6 +236,7 @@ export function ChatComposer({ title, tone = 'light', variant = 'landing', + fillSheet = false, mentionSource, availableCommands, commandsEnabled = true, @@ -243,7 +250,9 @@ export function ChatComposer({ promptPlaceholder, promptDisabled = false, promptRows = 3, + mobileSessionPromptRows, promptEnterKeyHint, + promptAutoFocus = false, promptRef, pastedTextDrafts = [], onPastedTextDraftsChange, @@ -293,7 +302,7 @@ export function ChatComposer({ // Mobile session composer starts at a single line to save vertical space // (desktop keeps its 2-line default); it still auto-grows as the user types. const singleLineMobile = isMobile && variant === 'session'; - const effectivePromptRows = singleLineMobile ? 1 : promptRows; + const effectivePromptRows = singleLineMobile ? (mobileSessionPromptRows ?? 1) : promptRows; // Desktop-only ⌘L discovery hint in the empty composer. Requires a fine pointer // AND non-mobile layout so phone frames / narrow viewports never show a // keyboard shortcut that doesn't exist on touch. Hidden once focused or typing. @@ -646,6 +655,7 @@ export function ChatComposer({ className={cn( 'flex w-full flex-col', isDialog ? 'gap-3' : isLanding ? 'gap-12' : 'gap-2', + fillSheet && 'min-h-0 flex-1', className )} > @@ -656,7 +666,13 @@ export function ChatComposer({ ) : null} {!isDialog ? ( -
+
{/* Top selector (repo, branch) - shown outside and above the input box */} {topSelector ? (
@@ -664,7 +680,13 @@ export function ChatComposer({
) : null}
- {commentReferenceItems.length > 0 ? ( -
- {commentReferenceItems.map((item) => ( - - ))} -
- ) : null} - {visualAnnotationReferenceItems.length > 0 ? ( -
- {visualAnnotationReferenceItems.map((item) => ( - - ))} -
- ) : null} - {imageItems.length > 0 || fileItems.length > 0 ? ( - // Unified horizontal, scrollable attachment strip: image thumbnails - // and file cards share the same square-card shape and scroll - // sideways together (cards step up in size on mobile for touch). -
- {imageItems.map((image) => ( -
- +
+ +
+ {image.status === 'uploading' ? ( +
+ {image.progress}% +
) : null} {image.status === 'failed' ? ( -
+ ) : null} - -
-
- {image.status === 'uploading' ? ( -
- {image.progress}% -
- ) : null} - {image.status === 'failed' ? ( - - ) : null} -
- ))} - {fileItems.map((file) => ( -
- ( +
- {getFileTypeLabel(file.name)} - - - {file.name} - - {file.status === 'failed' ? ( - - ) : ( - - {file.status === 'preparing' - ? t('sessions.filePreparing', 'Preparing… {{progress}}%', { - progress: file.progress, - }) - : file.status === 'uploading' - ? `${file.progress}%` - : file.status === 'verifying' - ? t('sessions.fileVerifying', 'Verifying…') - : file.sizeLabel} + {getFileTypeLabel(file.name)} - )} - {file.status === 'preparing' || - file.status === 'uploading' || - file.status === 'verifying' ? ( -
-
-
- ) : null} -
- + {file.name} + + {file.status === 'failed' ? ( + + ) : ( + + {file.status === 'preparing' + ? t('sessions.filePreparing', 'Preparing… {{progress}}%', { + progress: file.progress, + }) + : file.status === 'uploading' + ? `${file.progress}%` + : file.status === 'verifying' + ? t('sessions.fileVerifying', 'Verifying…') + : file.sizeLabel} + + )} + {file.status === 'preparing' || + file.status === 'uploading' || + file.status === 'verifying' ? ( +
+
+
+ ) : null} +
+ +
-
- ))} -
- ) : null} - - + ))} +
+ ) : null} +
+
div]:h-full' : 'contents'}> + +
{showFocusHint ? (
) : null} -
+
button]:h-10 [&>button]:w-10' + )} + > {/* Single bottom-left "+" attachment entry (replaces the old image + paperclip icons). Hidden when neither add callback is provided. */} @@ -945,7 +984,14 @@ export function ChatComposer({ {/* Single row only: long model names must shrink/truncate inside the run-config face rather than wrapping config chips onto a second line (especially on mobile). */} -
+
{footerSelector ?? selector}
diff --git a/packages/components/src/components/chat/chat-landing-view.tsx b/packages/components/src/components/chat/chat-landing-view.tsx index cb8a914d0..4eb1d0509 100644 --- a/packages/components/src/components/chat/chat-landing-view.tsx +++ b/packages/components/src/components/chat/chat-landing-view.tsx @@ -436,6 +436,7 @@ export function ChatLandingView({ promptDisabled={submissionPending} promptRows={2} promptEnterKeyHint={promptEnterKeyHint} + promptAutoFocus pastedTextDrafts={submissionPending ? [] : pastedTextDrafts} onPastedTextDraftsChange={submissionPending ? undefined : onPastedTextDraftsChange} onMentionRangesChange={onMentionRangesChange} diff --git a/packages/components/src/components/chat/chat-landing.tsx b/packages/components/src/components/chat/chat-landing.tsx index 65111d598..8f9860094 100644 --- a/packages/components/src/components/chat/chat-landing.tsx +++ b/packages/components/src/components/chat/chat-landing.tsx @@ -49,6 +49,7 @@ import { Loader2, LockKeyhole, Monitor, + MessageSquare, PanelLeft, RefreshCw, X, @@ -225,7 +226,10 @@ import { useVisibleSessionMetas, } from '@/hooks/use-visible-session-metas'; import { useReportVisibleSessionsForEagerSync } from '@/hooks/use-report-visible-sessions-for-eager-sync'; -import { getLocalProjectVisibilityKey } from '@/lib/visible-local-project-index'; +import { + getLocalProjectVisibilityKey, + type VisibleLocalProjectEntry, +} from '@/lib/visible-local-project-index'; import { shouldShowSessionSharing } from '@/lib/session-sharing'; import { isNativeAppShell } from '@/lib/native-platform'; import { openExternalUrl } from '@/lib/native-browser'; @@ -245,11 +249,12 @@ import { splitImageAndFileAttachments } from '@/lib/file-drop'; import { canShowSubscriptionRateLimits } from '@/lib/session-usage'; import { canShowCodexResetForecast } from '@/lib/codex-reset-forecast'; import { createMachinePairing } from '@/lib/cli-api-key'; -import { ContextSwitch, type SessionContextType } from './context-switch'; +import type { SessionContextType } from './context-switch'; import { UNIFIED_PROJECT_OPTION_RENDER_LIMIT, UnifiedProjectSelectorView, buildUnifiedLocalProjectOptions, + compareUnifiedProjectOptions, type LocalProjectSelection, type UnifiedProjectSelection, } from './unified-project-selector'; @@ -312,15 +317,16 @@ import { } from '@/lib/local-project-rpc-file-provider'; import { GitHubRepoFileProvider } from '@/lib/github-repo-file-provider'; import type { FileWorkspaceProvider } from '@/lib/file-workspace-provider'; -import { Tabs, TabsList, TabsTrigger } from '@/ui/tabs'; -import { Folder as FolderIcon, GitBranch as GitBranchIcon } from 'lucide-react'; +import { GitBranch as GitBranchIcon } from 'lucide-react'; import { AddLocalProjectDialogContainer } from '@/components/local-projects/add-local-project-dialog-container'; import { - MobileInlinePicker, MobileInlinePickerCoordinator, MobileInlinePickerRowSlot, - type MobileInlinePickerOption, } from '@/components/mobile/mobile-inline-picker'; +import { + MobileNativeSelect, + type MobileNativeSelectOption, +} from '@/components/mobile/mobile-native-select'; import { buildChildSessionsByParent, buildSidebarOpenerRowResolver, @@ -710,6 +716,38 @@ function WorkspaceChatLanding({ archive toggle picks the archived list explicitly when needed (see `mobileHomeChats` / `mobileProjectConversations`). */ const visibleSessions = visibleActiveSessions; + const projectRecency = useMemo( + () => getChatLandingProjectRecency(visibleSessions), + [visibleSessions] + ); + const compareVisibleLocalProjects = useCallback( + (left: VisibleLocalProjectEntry, right: VisibleLocalProjectEntry) => { + if (isMobile) { + return compareChatLandingLocalProjectByRecency(left, right, projectRecency.byProject); + } + return compareUnifiedProjectOptions( + { + label: left.project.name, + value: left.key, + lastUsedAt: + projectRecency.byProject.get(left.key) ?? + left.project.lastOpenedAtMs ?? + left.project.createdAtMs ?? + undefined, + }, + { + label: right.project.name, + value: right.key, + lastUsedAt: + projectRecency.byProject.get(right.key) ?? + right.project.lastOpenedAtMs ?? + right.project.createdAtMs ?? + undefined, + } + ); + }, + [isMobile, projectRecency.byProject] + ); const liveSessionStatuses = useMemo(() => { const next = new Map(); for (const session of visibleAllActiveSessions) { @@ -934,24 +972,12 @@ function WorkspaceChatLanding({ return machineId && machines.has(machineId) ? machineId : null; }, [localProbeResult?.machineId, machines]); - const isOwnVisibleMachine = useCallback( - (machineId: MachineId) => { - if (visibleLocalMachineId === machineId) return true; - const access = accessByMachineId.get(machineId); - return Boolean(userId && access?.ownerUserId === userId); - }, - [accessByMachineId, userId, visibleLocalMachineId] - ); - const hasLocalProjects = visibleLocalProjectMap.size > 0; const localProjectCount = visibleLocalProjectMap.size; // ── Context type (Local Projects vs GitHub Worktrees) ── const [contextType, setContextType] = useState( - () => - preSelectedContext ?? - readChatLandingDefaults(workspaceId)?.contextType ?? - (isMobile ? 'github' : 'chat') + () => preSelectedContext ?? readChatLandingDefaults(workspaceId)?.contextType ?? 'local' ); const analyticsProjectKind = contextType === 'chat' ? null : contextType; @@ -1256,17 +1282,17 @@ function WorkspaceChatLanding({ ); const getFirstVisibleLocalProjectForMachine = useCallback( (machineId: MachineId): LocalProjectSelection | null => { - for (const entry of visibleLocalProjectMap.values()) { - if (entry.machineId === machineId) { - return { - machineId, + const entry = [...visibleLocalProjectMap.values()] + .filter((candidate) => candidate.machineId === machineId) + .sort(compareVisibleLocalProjects)[0]; + return entry + ? { + machineId: entry.machineId, localProjectId: entry.project.id, - }; - } - } - return null; + } + : null; }, - [visibleLocalProjectMap] + [compareVisibleLocalProjects, visibleLocalProjectMap] ); const handleSelectedLocalBranchChange = useCallback((nextBranch: string | null) => { const currentProject = selectedLocalProjectRef.current; @@ -1415,18 +1441,6 @@ function WorkspaceChatLanding({ [numberFormatter, prompt, setSessionState, t] ); - // Auto-focus textarea on mount (desktop only) - const mobileKeyboardRef = useRef(usesMobileKeyboardAction); - mobileKeyboardRef.current = usesMobileKeyboardAction; - useEffect(() => { - const id = requestAnimationFrame(() => { - if (!mobileKeyboardRef.current) { - promptTextareaRef.current?.focus(); - } - }); - return () => cancelAnimationFrame(id); - }, []); - // ── Apply pre-selection from search params ── const preSelectionKey = buildChatLandingPreSelectionKey({ context: preSelectedContext, @@ -1547,7 +1561,7 @@ function WorkspaceChatLanding({ if (localProjectAvailability !== 'unavailable') return; toast.error(t('sidebar.localProjects.forbidden', 'Local project is not available')); handleSelectedLocalProjectChange(null); - setContextType(hasGitHubRepos ? 'github' : 'chat'); + setContextType(hasLocalProjects ? 'local' : hasGitHubRepos ? 'github' : 'chat'); }, [ contextType, accessByMachineId, @@ -1560,6 +1574,7 @@ function WorkspaceChatLanding({ visibleLocalProjectAccess, visibleLocalProjectsLoading, isMetaRoomFirstSyncPending, + hasLocalProjects, hasGitHubRepos, t, handleSelectedLocalProjectChange, @@ -2151,36 +2166,17 @@ function WorkspaceChatLanding({ useEffect(() => { if (!defaultsReady) return; if (contextType !== 'local' || selectedLocalProject) return; - if (selectedMachineId) { - const machineProject = getFirstVisibleLocalProjectForMachine(selectedMachineId); - if (machineProject) { - handleSelectedLocalProjectChange(machineProject); - } - return; - } - // Bias the default to an own-machine project when one exists, falling - // back to the first shared project so teammates still get a selection. - let fallback: { machineId: MachineId; localProjectId: LocalProjectId } | null = null; - for (const entry of visibleLocalProjectMap.values()) { - const candidate = { - machineId: entry.machineId, - localProjectId: entry.project.id, - }; - if (isOwnVisibleMachine(entry.machineId)) { - handleSelectedLocalProjectChange(candidate); - return; - } - if (!fallback) fallback = candidate; - } - if (fallback) { - handleSelectedLocalProjectChange(fallback); + const firstProject = [...visibleLocalProjectMap.values()].sort(compareVisibleLocalProjects)[0]; + if (firstProject) { + handleSelectedLocalProjectChange({ + machineId: firstProject.machineId, + localProjectId: firstProject.project.id, + }); } }, [ contextType, defaultsReady, - getFirstVisibleLocalProjectForMachine, - isOwnVisibleMachine, - selectedMachineId, + compareVisibleLocalProjects, visibleLocalProjectMap, selectedLocalProject, handleSelectedLocalProjectChange, @@ -3368,11 +3364,6 @@ function WorkspaceChatLanding({ () => (selectedMachineId && isSelectedMachineValid ? selectedMachineId : localProjectMachineId), [localProjectMachineId, selectedMachineId, isSelectedMachineValid] ); - const localProjectSelectorMachineId = - contextType === 'local' ? (selectedMachineId ?? localProjectMachineId) : null; - const localProjectSelectorEmptyText = t( - getEmptyLocalProjectsMessageKey(Boolean(localProjectSelectorMachineId)) - ); const { showBranchSelector, isBranchDisabled, branchSelectorKey } = getChatLandingBranchSelectorState({ @@ -3469,12 +3460,9 @@ function WorkspaceChatLanding({
) : null; - const mobileSheetRecency = useMemo( - () => getChatLandingProjectRecency(visibleSessions), - [visibleSessions] - ); + const mobileSheetRecency = projectRecency; - const desktopProjectSelection = useMemo(() => { + const unifiedProjectSelection = useMemo(() => { if (contextType === 'local' && selectedLocalProject) { return { kind: 'local', ...selectedLocalProject }; } @@ -3483,7 +3471,7 @@ function WorkspaceChatLanding({ } return { kind: 'none' }; }, [contextType, selectedLocalProject, selectedRepo]); - const handleDesktopProjectChange = useCallback( + const handleUnifiedProjectChange = useCallback( (selection: UnifiedProjectSelection) => { if (selection.kind === 'none') { setContextType('chat'); @@ -3749,8 +3737,8 @@ function WorkspaceChatLanding({ onAddMachine={handleAddMachine} /> []>(() => { + const mobileSheetMachineOptions = useMemo[]>(() => { return mobileSheetOnlineMachines.map((m) => ({ value: m.id as MachineId, label: m.name, - searchText: m.name, - icon:
); } -/* Chat-list filter toggle — lives on the first group heading's trailing - edge (not next to search). Active tint when the pill bar is open; a - small primary dot marks applied filters while the bar is collapsed. */ +/* Chat-list filter toggle — lives inside the header search field because + search and filters both narrow the visible conversations. A small primary + dot marks applied filters while the pill bar is collapsed. */ function ChatListFilterToggle({ open, hasActiveFilters, @@ -775,13 +782,11 @@ function ChatListFilterToggle({ aria-label={ariaLabel} aria-pressed={open} className={cn( - 'relative inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border', - 'border-border/50 bg-muted text-muted-foreground', - 'dark:border-white/12 dark:bg-white/10', + 'relative -mr-2 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground', 'transition-colors active:scale-[0.97]', - 'hover:bg-muted/80 hover:text-foreground dark:hover:bg-white/14', + 'hover:bg-background/60 hover:text-foreground', 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/30', - open && 'border-primary/40 bg-primary/15 text-primary' + open && 'bg-primary/15 text-primary' )} >
@@ -1680,16 +1696,7 @@ export function MobileHomeScreen({ onPermanentDelete={showArchived ? onChatPermanentDelete : undefined} hasActiveFilters={hasActiveChatFilters} onClearFilters={onClearChatFilters} - firstGroupTrailing={ - chatFilterPills && chatFilterPills.length > 0 ? ( - setChatFiltersOpen((open) => !open)} - /> - ) : undefined - } + onNewChatInProject={onNewChatInProject} onPrivateHelp={() => setPrivateHelpOpen(true)} /> ) @@ -2208,7 +2215,7 @@ function ChatsFlatView({ onPermanentDelete, hasActiveFilters = false, onClearFilters, - firstGroupTrailing, + onNewChatInProject, onPrivateHelp, }: { chats: MobileConversationItem[]; @@ -2225,9 +2232,7 @@ function ChatsFlatView({ state to the "filters hid everything" copy + Clear filters button. */ hasActiveFilters?: boolean; onClearFilters?: () => void; - /** Filter chip mounted on the first group heading (or a trailing-only - row when the list is empty / flat with no heading). */ - firstGroupTrailing?: ReactNode; + onNewChatInProject?: (project: { kind: 'local' | 'github'; projectKey: string }) => void; onPrivateHelp: () => void; }) { const visible = useMemo(() => { @@ -2248,22 +2253,12 @@ function ChatsFlatView({ ) : undefined; - /* Empty states still need the filter chip so the user can open the - bar / clear filters without a group heading to host it. */ - const emptyTrailing = - firstGroupTrailing != null ? ( -
- {firstGroupTrailing} -
- ) : null; - if (chats.length === 0) { /* `chats` is already filtered by the caller, so an empty list with active filters means the filters hid everything (vs. a workspace with no conversations at all). */ return (
- {emptyTrailing} - {emptyTrailing}
); @@ -2302,7 +2296,6 @@ function ChatsFlatView({ /* Active list is flat — no "全部对话" section label. Only the archived surface keeps a heading so the mode is obvious. */ flatHeading={archived ? (labels.archivedChatsHeading ?? '归档对话') : undefined} - firstGroupTrailing={firstGroupTrailing} onSelect={onSelect} rowActions={{ onTogglePin, onArchive, onRestore }} archived={archived} @@ -2311,6 +2304,8 @@ function ChatsFlatView({ privateLabel={labels.privateLabel} privateHelpAriaLabel={labels.privateHelpAriaLabel} onPrivateHelp={onPrivateHelp} + onNewChatInProject={onNewChatInProject} + newChatInProjectAriaLabel={labels.newChatInProjectAriaLabel} /> ); diff --git a/packages/components/src/components/mobile/mobile-native-select.tsx b/packages/components/src/components/mobile/mobile-native-select.tsx new file mode 100644 index 000000000..7f4b0faa5 --- /dev/null +++ b/packages/components/src/components/mobile/mobile-native-select.tsx @@ -0,0 +1,109 @@ +import { ChevronsUpDown, Loader2 } from 'lucide-react'; +import { Fragment, type ReactNode } from 'react'; + +import { cn } from '@/lib/utils'; + +export type MobileNativeSelectOption = { + value: T; + label: string; + group?: string; + disabled?: boolean; +}; + +export type MobileNativeSelectProps = { + value: T | null | undefined; + onChange: (value: T) => void; + options: MobileNativeSelectOption[]; + triggerContent: ReactNode; + ariaLabel: string; + disabled?: boolean; + loading?: boolean; + loadingText?: ReactNode; + showIndicator?: boolean; + className?: string; +}; + +/** + * A visually branded trigger backed by a real HTML select. Touch platforms + * therefore present their system option picker instead of an app-owned menu. + */ +export function MobileNativeSelect({ + value, + onChange, + options, + triggerContent, + ariaLabel, + disabled = false, + loading = false, + loadingText, + showIndicator = true, + className, +}: MobileNativeSelectProps) { + const hasAlternative = options.some((option) => !option.disabled && option.value !== value); + const isInteractive = !disabled && !loading && hasAlternative; + const groupedOptions = options.reduce< + Array<{ label: string | null; options: MobileNativeSelectOption[] }> + >((groups, option) => { + const label = option.group ?? null; + const existing = groups.find((group) => group.label === label); + if (existing) { + existing.options.push(option); + } else { + groups.push({ label, options: [option] }); + } + return groups; + }, []); + + return ( +
+ + {loading ? ( + <> + + {isInteractive && showIndicator ? ( +
+ ); +} diff --git a/packages/components/src/components/mobile/mobile-new-chat-sheet.tsx b/packages/components/src/components/mobile/mobile-new-chat-sheet.tsx index bc15bed88..b08c4d8e5 100644 --- a/packages/components/src/components/mobile/mobile-new-chat-sheet.tsx +++ b/packages/components/src/components/mobile/mobile-new-chat-sheet.tsx @@ -1,27 +1,23 @@ -import { Fragment, useRef, type ComponentType, type CSSProperties, type ReactNode } from 'react'; -import { AnimatePresence, motion } from 'framer-motion'; -import { X } from 'lucide-react'; +import { Fragment, type ComponentType, type CSSProperties, type ReactNode } from 'react'; +import { ChevronLeft } from 'lucide-react'; import { Drawer, DrawerContent, DrawerDescription, DrawerTitle } from '@/ui/drawer'; -import { MobileInlinePickerRowSlot } from '@/components/mobile/mobile-inline-picker'; -import { useKeyboardAwareScrollIntoView } from '@/hooks/use-keyboard-aware-scroll-into-view'; +import { useKeyboardAwareSheet } from '@/hooks/use-keyboard-aware-scroll-into-view'; import { cn } from '@/lib/utils'; export type MobileNewChatSheetLabels = { title?: string; description?: string; closeAriaLabel?: string; + /** @deprecated Target rows no longer render separate labels. */ machineLabel?: string; + /** @deprecated Work / Chat is rendered inline with the machine. */ contextTypeLabel?: string; - /** Primary per-type row label — just the project / repo name now - (branch lives on its own row, see `branchLabel`). Parent picks - the wording (e.g. "项目" / "仓库"). */ + /** @deprecated The unified project control identifies itself. */ perTypeLabel?: string; - /** Branch row label, shown on the row below the project/repo when - a branch picker is wired. Defaults to "分支". */ + /** @deprecated Branch is rendered beside the project. */ branchLabel?: string; - /** Secondary per-type row label, used by local context for the worktree - mode pill. Optional — collapsed when `secondaryPerTypeNode` is null. */ + /** @deprecated Workdir mode is rendered without a separate label. */ secondaryPerTypeLabel?: string; }; @@ -32,26 +28,18 @@ export type MobileNewChatSheetProps = { export type MobileNewChatSheetContentProps = { labels?: MobileNewChatSheetLabels; - /** Row 1: machine pill / selector. */ + /** Optional machine selector in the bottom rail. */ machineNode: ReactNode; - /** Row 2: project type (3-icon) pill switcher. */ - contextTypeNode: ReactNode; - /** Row 3: the project (local) or repo (github) picker on its own - row — branch moved to its own row below so neither chip gets - truncated on narrow phones. Pass `null` to collapse (chat - context has no target). */ + /** @deprecated Context now belongs to the header picker; retained for existing hosts. */ + contextTypeNode?: ReactNode; + /** Unified context picker: no project (Chat), local projects, and GitHub repositories. */ perTypeNode?: ReactNode | null; - /** Row 4 (optional): branch picker, only shown when a project / - repo with branches is selected. Splitting it off from - `perTypeNode` was the user's call — easier to read at a glance - than two side-by-side chips. */ + /** Optional branch picker in the bottom rail. */ branchNode?: ReactNode | null; - /** Row 5 (local-only): workdir mode toggle (本地文件 / 新工作树). - Renders only when the caller hands a node — github and chat - contexts pass `null`. */ + /** Local-only Worktree toggle in the bottom rail. */ secondaryPerTypeNode?: ReactNode | null; - /** The composer itself — already wired with footer / bottomBar slots. */ - composer: ReactNode; + /** Render a sheet composer with these target controls in its footer rail. */ + composer: ReactNode | ((targetControls: ReactNode) => ReactNode); /** Optional cluster below the composer. Usually empty now that run config (agent/model/mode/…) lives in the composer footer via `MobileSessionRunConfig`; kept for hosts that still need a slot. */ @@ -70,60 +58,30 @@ export type MobileNewChatSheetContentProps = { scrollAreaStyle?: CSSProperties; }; -/** - * Bottom sheet that hosts the "new chat" composer flow on mobile home. - * - * Layout stacks top-to-bottom per the design comp: machine → project - * type → per-type selectors (optionally split into project+branch + - * worktree on local) → composer (footer holds the same - * `MobileSessionRunConfig` face as the in-session chat). Each row - * carries a short label (机器 / 类型 / 项目 / 模式) so the surface reads - * like a form rather than a tag cloud. - */ +/** Full-height writing surface; execution controls belong to its bottom rail. */ export function MobileNewChatSheet({ open, onOpenChange, ...contentProps }: MobileNewChatSheetProps) { const title = contentProps.labels?.title ?? '新建对话'; - const description = contentProps.labels?.description; - + const keyboard = useKeyboardAwareSheet(); return ( - {/* Adaptive height: only cap the maximum so short contexts (chat-only - with no per-type row) collapse to their natural height instead of - leaving a half-screen of empty white space below the composer. */} - {/* Shift the whole drawer up by the soft-keyboard height on iOS - Capacitor so the textarea + footer stay above the keyboard. The - `--native-keyboard-height` CSS var is `0px` on web and Android; - Android relies on native WebView resize instead (the main layout - uses this var globally, but vaul renders the drawer through a - portal so the root keyboard-padding utility doesn't reach it). - - iOS keyboard animates over ~250-350ms with a custom curve. - We match it via the inline `style.transition` (Tailwind's - `transition-[bottom]` was being overridden by Vaul's own - transition declaration) and bump duration to 320ms so the - lift lands roughly in sync with the keyboard finishing its - own slide-up. Vaul's open/close uses `translate-y` - (orthogonal property), so the two animations don't fight. */} {title} - {description ?? title} + + {contentProps.labels?.description ?? title} + onOpenChange(false)} - className={cn('max-h-full', contentProps.className)} + className={cn('min-h-0 flex-1', contentProps.className)} /> @@ -147,182 +105,61 @@ export function MobileNewChatSheetContent({ scrollAreaStyle, }: MobileNewChatSheetContentProps) { const title = labels.title ?? '新建对话'; - const description = labels.description; - const closeAriaLabel = labels.closeAriaLabel ?? 'Close'; - const machineLabel = labels.machineLabel ?? '机器'; - const contextTypeLabel = labels.contextTypeLabel ?? '类型'; - const perTypeLabel = labels.perTypeLabel ?? '项目'; - const branchLabel = labels.branchLabel ?? '分支'; - const secondaryPerTypeLabel = labels.secondaryPerTypeLabel ?? '模式'; - /* Keep the focused composer centered above the native keyboard so the - footer run-config control isn't left hidden. */ - const scrollRef = useRef(null); - useKeyboardAwareScrollIntoView(scrollRef); - + const keyboard = useKeyboardAwareSheet({ chromeHeight: '5rem', bodyPadding: '8px' }); + const targets = ( + <> + {[machineNode, branchNode, secondaryPerTypeNode, contextTypeNode].map((node, index) => + node ? ( +
+ {node} +
+ ) : null + )} + + ); return (
-
-

{title}

- {showCloseButton ? ( - - ) : null} -
-

- {description ?? title} -

- +
+ {showCloseButton ? ( + + ) : null} +
+ {perTypeNode ??

{title}

} +
+
+ {labels.description ? ( +

{labels.description}

+ ) : null}
); } - -/* Wrapper that animates a row's mount / unmount via height accordion. - `alwaysOn` skips the AnimatePresence-driven mount transition (for - rows that are always rendered like machine + type) but keeps the - `pt-1.5` rhythm so the spacing stays consistent with the rows that - do animate in. - - We bake `pt-1.5` into the wrapper instead of using parent `gap-1.5` - because gap doesn't animate with height — when a row mounts from - height 0, the gap above it pops in instantly while the row's body - grows, which looks jumpy. Putting the spacing inside the animated - container makes it grow with the row. */ -function AnimatedSheetRow({ - children, - alwaysOn = false, -}: { - children: ReactNode; - alwaysOn?: boolean; -}) { - if (alwaysOn) { - return
{children}
; - } - return ( - -
{children}
-
- ); -} - -/* Single labelled row. - Label sits left at fixed width; value column flexes to fill the rest - so chips render in the remaining width. The whole row is wrapped in a - `MobileInlinePickerRowSlot` so any picker dropped inside the value - column portals its expansion drawer into the slot rendered just - below the row card — full row width, escaping the chip's own column. - That gives the "list under the entire row" behavior the design comp - asks for even when the row holds two side-by-side chips (e.g. - project + branch). */ -function Row({ label, children }: { label: string; children: ReactNode }) { - return ( - -
- {/* Fixed-width label column so every row's value column starts - at the same x regardless of the label's intrinsic width. - Without this, "Machine" / "Type" / "Project" / "Branch" / - "Mode" each push the value chip to a different x in - English (Chinese labels are uniform 2 chars so it accidentally - lined up before). `w-16` (64px) fits the longest English - label at 0.72rem without wrapping. */} - - {label} - -
{children}
-
-
- ); -} diff --git a/packages/components/src/components/mobile/mobile-project-screen.tsx b/packages/components/src/components/mobile/mobile-project-screen.tsx index 9e5625288..8ac0b1aa5 100644 --- a/packages/components/src/components/mobile/mobile-project-screen.tsx +++ b/packages/components/src/components/mobile/mobile-project-screen.tsx @@ -776,6 +776,16 @@ export function MobileProjectScreen({ onBack={onBack} searchQuery={searchQuery} onSearchChange={setSearchQuery} + filterToggle={ + hasFilterPills ? ( + setFiltersOpen((open) => !open)} + /> + ) : undefined + } showArchived={showArchived} onShowArchivedToggle={onShowArchivedToggle} /> @@ -795,8 +805,8 @@ export function MobileProjectScreen({ /> )} - {/* Filter pill bar — toggled from the first group heading's - trailing chip (mirrors home Chat). Unmounted on Files / + {/* Filter pill bar — toggled from the search field's trailing + control (mirrors home Chat). Unmounted on Files / Settings so those tabs stay free of conversation chrome. */} {selectedProjectTab === 'chat' && filtersOpen && hasFilterPills && filterPills ? ( @@ -825,19 +835,7 @@ export function MobileProjectScreen({ > {selectedProjectTab === 'chat' ? ( visibleConversations.length === 0 ? ( - <> - {hasFilterPills ? ( -
- setFiltersOpen((open) => !open)} - /> -
- ) : null} - - + ) : ( setFiltersOpen((open) => !open)} - /> - ) : undefined - } selectedConversationId={selectedConversationId} onSelect={onConversationSelect} rowActions={{ @@ -978,8 +966,7 @@ function ProjectTabPlaceholder({ /* Chat-tab project header — one chrome row matching home: back | compact project mark | search (fills the blank) | archive. The full display name is no longer a centered title; the mark + - search placeholder keep context. Filter lives on the first group - heading (not here). */ + search placeholder keep context. Filter lives inside search. */ function ProjectChatTopBar({ project, displayName, @@ -987,6 +974,7 @@ function ProjectChatTopBar({ onBack, searchQuery, onSearchChange, + filterToggle, showArchived, onShowArchivedToggle, }: { @@ -996,6 +984,7 @@ function ProjectChatTopBar({ onBack: () => void; searchQuery: string; onSearchChange: (next: string) => void; + filterToggle?: ReactNode; showArchived: boolean; onShowArchivedToggle?: () => void; }) { @@ -1012,7 +1001,12 @@ function ProjectChatTopBar({ {displayName}
- +
{onShowArchivedToggle ? (
); } diff --git a/packages/components/src/components/sessions/session-detail.tsx b/packages/components/src/components/sessions/session-detail.tsx index b39b4ac2b..510d6f8ce 100644 --- a/packages/components/src/components/sessions/session-detail.tsx +++ b/packages/components/src/components/sessions/session-detail.tsx @@ -1765,6 +1765,23 @@ const SessionDetail = ({ }, [] ); + const activeSessionReadyForInputFocus = activeSession !== null; + + // Desktop conversation navigation is an intent to continue typing: focus the + // active composer after entering a Session or switching its conversation tab. + // Mobile deliberately does not do this for existing Sessions, where focus + // would raise the software keyboard merely from navigation. A pending child + // waits until its real chat surface arrives before taking focus. + useEffect(() => { + if (isMobile || !activeSessionReadyForInputFocus || activeTabIsPendingChild) { + return undefined; + } + + const frameId = requestAnimationFrame(() => { + chatRefsMap.current.get(activeTabSessionId)?.focusInput(); + }); + return () => cancelAnimationFrame(frameId); + }, [activeSessionReadyForInputFocus, activeTabIsPendingChild, activeTabSessionId, isMobile]); const handleInsertDroppedSessionMention = useCallback( (droppedSessionId: string) => { diff --git a/packages/components/src/hooks/use-chat-landing-defaults.ts b/packages/components/src/hooks/use-chat-landing-defaults.ts index 62d79f136..742f2c82c 100644 --- a/packages/components/src/hooks/use-chat-landing-defaults.ts +++ b/packages/components/src/hooks/use-chat-landing-defaults.ts @@ -100,9 +100,9 @@ export function useChatLandingDefaults({ if (workspaceId && restoredContextTypeWorkspaceId !== workspaceId) { setRestoredContextTypeWorkspaceId(workspaceId); if (shouldRestoreContextType) { - const storedContextType = readChatLandingDefaults(workspaceId)?.contextType; - if (storedContextType && storedContextType !== contextType) { - setContextType(storedContextType); + const preferredContextType = readChatLandingDefaults(workspaceId)?.contextType ?? 'local'; + if (preferredContextType !== contextType) { + setContextType(preferredContextType); } } } diff --git a/packages/components/src/stories/ChatLandingAttachments.stories.tsx b/packages/components/src/stories/ChatLandingAttachments.stories.tsx index 7fcbeb59e..169b0ba07 100644 --- a/packages/components/src/stories/ChatLandingAttachments.stories.tsx +++ b/packages/components/src/stories/ChatLandingAttachments.stories.tsx @@ -164,6 +164,7 @@ function MobileNewChatDemo() { const composer = ( } - autoResize - maxRows={6} + autoResize={false} /> ); @@ -204,9 +204,6 @@ function MobileNewChatDemo() { const contentProps: MobileNewChatSheetContentProps = { labels: { title: '新建对话', - machineLabel: '机器', - contextTypeLabel: '类型', - perTypeLabel: '仓库', }, coordinator: MobileInlinePickerCoordinator, machineNode: , @@ -221,7 +218,7 @@ function MobileNewChatDemo() { // a wide Storybook canvas. Mobile card sizing still keys off window width.
- +
); diff --git a/packages/components/src/stories/MobileChatList.stories.tsx b/packages/components/src/stories/MobileChatList.stories.tsx index 18b8d21ac..76475b861 100644 --- a/packages/components/src/stories/MobileChatList.stories.tsx +++ b/packages/components/src/stories/MobileChatList.stories.tsx @@ -362,6 +362,8 @@ function StoryShell({ setSelectedId(id); fn()(id); }} + onNewChatInProject={fn()} + newChatInProjectAriaLabel={(projectLabel) => `在 ${projectLabel} 中新建对话`} rowActions={actions} /* In archived mode, swipe delete + multi-select both route through this; remove the confirmed ids from the mock list. */ diff --git a/packages/components/src/stories/MobileHomeScreen.stories.tsx b/packages/components/src/stories/MobileHomeScreen.stories.tsx index 5883a9907..f4077de93 100644 --- a/packages/components/src/stories/MobileHomeScreen.stories.tsx +++ b/packages/components/src/stories/MobileHomeScreen.stories.tsx @@ -272,6 +272,7 @@ function MobileHomeScreenStory({ chatTab: 'Chat', settingsTab: '设置', newChatAriaLabel: '新建对话', + newChatInProjectAriaLabel: (projectLabel) => `在 ${projectLabel} 中新建对话`, recentProjectsHeading: '最近常用', allLocalProjectsHeading: '全部项目', allGitHubReposHeading: '全部仓库', @@ -291,6 +292,7 @@ function MobileHomeScreenStory({ onLocalProjectSelect={fn()} onGitHubRepositorySelect={fn()} onChatSelect={fn()} + onNewChatInProject={fn()} onSettingsOpen={fn()} onNewChat={fn()} onDownloadClient={fn()} diff --git a/packages/components/src/stories/MobileNewChatSheet.stories.tsx b/packages/components/src/stories/MobileNewChatSheet.stories.tsx index b28dc8900..9f1761227 100644 --- a/packages/components/src/stories/MobileNewChatSheet.stories.tsx +++ b/packages/components/src/stories/MobileNewChatSheet.stories.tsx @@ -1,15 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { useState } from 'react'; -import { - ArrowUp, - Folder, - GitBranch, - Github, - MessageCircle, - Monitor, - ShieldCheck, - Sparkles, -} from 'lucide-react'; +import { useEffect, useState, type ReactNode } from 'react'; +import { ChatComposer } from '@/components/chat/chat-composer'; +import { ArrowUp, Folder, GitBranch, Github, Monitor } from 'lucide-react'; import { MobileNewChatSheet } from '@/components/mobile/mobile-new-chat-sheet'; import { @@ -19,13 +11,12 @@ import { type MobileInlinePickerOption, } from '@/components/mobile/mobile-inline-picker'; import { - MobileModelPickerLabel, - mobileModelPickerTriggerClassName, -} from '@/components/mobile/mobile-session-composer-footer'; + MobileNativeSelect, + type MobileNativeSelectOption, +} from '@/components/mobile/mobile-native-select'; +import { WorktreeCheckboxPill } from '@/components/shared'; import { Button } from '@/ui/button'; -import { cn } from '@/lib/utils'; import type { AcpConfigOptionValue } from '@/components/shared/acp-selector-options'; -import { Tabs, TabsList, TabsTrigger } from '@/ui/tabs'; const meta = { title: 'Mobile/MobileNewChatSheet', @@ -37,48 +28,39 @@ const meta = { export default meta; type Story = StoryObj; -/* The story wires real `MobileInlinePicker`s for the row chips so the - drawer-from-behind animation, single-open coordination, and trigger - styling are screenshot-verifiable. Composer-internal chips (model / - thinking) still need a real `ChatComposer` to test fully; the mock - composer here renders just the picker triggers so we can see them - inline in the footer row. */ +/* The story wires native selects for the new-chat target rows and keeps + `MobileInlinePicker` for rich composer configuration rows. Composer-internal + chips still need a real `ChatComposer` to test fully; the mock composer here + renders just their picker triggers so we can see them in the footer row. */ -const machineOptions: MobileInlinePickerOption[] = [ - { value: 'zx-macbook', label: 'zx-macbook', icon: }, - { value: 'lab-m2', label: 'lab-m2', icon: }, +const machineOptions: MobileNativeSelectOption[] = [ + { value: 'zx-macbook', label: 'zx-macbook' }, + { value: 'lab-m2', label: 'lab-m2' }, ]; -const githubRepoOptions: MobileInlinePickerOption[] = [ +const githubRepoOptions: MobileNativeSelectOption[] = [ { value: 'loro-dev/lody', label: 'loro-dev/lody', - description: 'AI-native local-first coding companion', - icon: , }, { value: 'loro-dev/loro', label: 'loro-dev/loro', - description: 'High-performance CRDT framework', - icon: , }, ]; -const localProjectOptions: MobileInlinePickerOption[] = [ +const localProjectOptions: MobileNativeSelectOption[] = [ { value: 'zx-macbook:lody', label: 'lody', - description: '~/code/lody', - icon: , }, ]; -const branchOptions: MobileInlinePickerOption[] = [ - { value: 'main', label: 'main', icon: }, +const branchOptions: MobileNativeSelectOption[] = [ + { value: 'main', label: 'main' }, { value: 'feat/audit-mobile-coupling', label: 'feat/audit-mobile-coupling', - icon: , }, ]; @@ -93,91 +75,38 @@ const longModelOptions: MobileInlinePickerOption[] = [ ...modelOptions, ]; -const thinkingOptions: MobileInlinePickerOption[] = [ - { value: 'low', label: 'low' }, - { value: 'medium', label: 'medium' }, - { value: 'high', label: 'high' }, -]; - -const agentOptions: MobileInlinePickerOption[] = [ - { value: 'claude-code', label: 'Claude Code', icon: }, - { value: 'codex', label: 'Codex', icon: }, -]; - -const permissionOptions: MobileInlinePickerOption[] = [ - { - value: 'askPermission', - label: 'Ask permission', - icon: , - }, -]; - -function MockContextTypeNode({ +function MockWorkdirModeNode({ + contextType, value, onChange, }: { - value: 'local' | 'github' | 'chat'; - onChange: (next: 'local' | 'github' | 'chat') => void; + contextType: 'local' | 'github' | 'chat'; + value: 'local' | 'worktree'; + onChange: (next: 'local' | 'worktree') => void; }) { - const triggerClassName = cn( - 'flex-1 inline-flex items-center justify-start gap-1.5 rounded-md px-2 py-1 text-sm font-medium transition-all', - 'data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-xs', - 'text-muted-foreground' - ); - return ( - onChange(v as 'local' | 'github' | 'chat')} - className="w-full" - > - - - - Local - - - - GitHub - - - - Chat - - - - ); -} - -function MockWorkdirModeNode({ contextType }: { contextType: 'local' | 'github' | 'chat' }) { if (contextType !== 'local') return null; - const triggerClassName = - 'flex-1 inline-flex items-center justify-start gap-1.5 rounded-md px-2 py-1 text-sm font-medium transition-all'; return ( -
- - -
+ onChange(checked ? 'worktree' : 'local')} + className="h-7 bg-transparent px-1.5 hover:bg-hover/60" + /> ); } function MockComposer({ + initialPrompt = '', + targetControls, configOptionValues: _configOptionValues, onConfigOptionChange: _onConfigOptionChange, selectedModel, setSelectedModel, modelPickerOptions = modelOptions, selectedThinking, - setSelectedThinking, + setSelectedThinking: _setSelectedThinking, }: { + initialPrompt?: string; + targetControls?: ReactNode; configOptionValues: Record; onConfigOptionChange: (configId: string, value: AcpConfigOptionValue) => void; selectedModel: string; @@ -186,67 +115,69 @@ function MockComposer({ selectedThinking: string; setSelectedThinking: (v: string) => void; }) { + const [prompt, setPrompt] = useState(initialPrompt); return ( -
-