From 5a75fe6f1df855c020b9dde12505c6772950d69e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 6 Oct 2026 03:04:41 +0800 Subject: [PATCH] feat(desktop): rebuild the H5 phone and tablet shell around touch Replace the drawer-based mobile layout with a single navigation stack. Home is the session list grouped by status (waiting for you, working, then by day) with the new-task composer docked under it; a session, Settings and subagent or team-member pages push on top, and the system back gesture moves up one level. Touch tablets keep the list beside the page. - Composer: one row (+, model, send); permission mode, context usage and the capability menu move into a + bottom sheet. - Approvals: a waiting permission, question or plan takes the composer's place; the transcript keeps a one-line marker. Denying can carry a reason. - Messages: press and hold for copy, select text, edit and branch instead of an always-visible icon row. - Tool runs fold into one card per run and open the full timeline in a sheet; the turn change card opens a file's diff full screen. - Activity: a pill in the session bar opens the activity sheet instead of auto-opening over the chat. - Settings: one grouped list with the connection, provider, phone-editable preferences and the computer-only settings shown read-only. - Server: GET /api/sessions/live-status reports every running or waiting session so the list can sort sessions the phone never opened. --- desktop/src/api/sessions.ts | 9 + .../activity/SessionActivityPanel.test.tsx | 13 + .../activity/SessionActivityPanel.tsx | 179 +++---- .../components/chat/ActivityGroup.test.tsx | 63 +++ desktop/src/components/chat/ActivityGroup.tsx | 105 ++++- .../src/components/chat/AskUserQuestion.tsx | 11 + .../src/components/chat/AssistantMessage.tsx | 20 +- .../src/components/chat/ChatInput.test.tsx | 74 +++ desktop/src/components/chat/ChatInput.tsx | 123 ++++- .../chat/ComposerCapabilityMenu.tsx | 27 +- .../components/chat/ComposerSuggestionRow.tsx | 6 +- .../components/chat/ContextUsageIndicator.tsx | 27 +- .../chat/CurrentTurnChangeCard.test.tsx | 46 ++ .../components/chat/CurrentTurnChangeCard.tsx | 25 +- desktop/src/components/chat/MessageList.tsx | 13 + .../chat/MobileApprovalDock.test.tsx | 182 ++++++++ .../components/chat/MobileApprovalDock.tsx | 210 +++++++++ .../components/chat/MobileComposerSheet.tsx | 95 ++++ .../components/chat/MobileTurnDiffSheet.tsx | 115 +++++ .../components/chat/PendingDecisionMarker.tsx | 24 + .../src/components/chat/PermissionDialog.tsx | 118 +---- desktop/src/components/chat/ToolCallGroup.tsx | 8 + desktop/src/components/chat/UserMessage.tsx | 17 +- .../components/chat/permissionPresentation.ts | 99 ++++ .../chat/useMessageActionMenu.test.tsx | 150 ++++++ .../components/chat/useMessageActionMenu.tsx | 166 +++++++ .../controls/PermissionModeSelector.test.tsx | 31 +- .../controls/PermissionModeSelector.tsx | 41 +- .../controls/permissionModeState.ts | 34 ++ .../src/components/layout/AppShell.test.tsx | 156 ++----- desktop/src/components/layout/AppShell.tsx | 185 ++------ .../src/components/layout/ContentRouter.tsx | 12 +- .../src/components/layout/Sidebar.test.tsx | 118 +---- desktop/src/components/layout/Sidebar.tsx | 438 +++--------------- .../src/components/layout/SidebarTaskList.tsx | 7 +- .../layout/sidebarProjectPreferenceStorage.ts | 179 +++++++ .../components/mobile/MobileActivityPill.tsx | 49 ++ .../mobile/MobileSessionBrowser.tsx | 251 ++++++++++ .../components/mobile/MobileSessionList.tsx | 289 ++++++++++++ .../components/mobile/MobileShell.test.tsx | 255 ++++++++++ desktop/src/components/mobile/MobileShell.tsx | 164 +++++++ .../src/components/mobile/MobileTopBar.tsx | 57 +++ .../src/components/mobile/mobileNavigation.ts | 146 ++++++ .../mobile/mobileSessionGroups.test.ts | 85 ++++ .../components/mobile/mobileSessionGroups.ts | 72 +++ .../mobile/mobileSessionStatus.test.ts | 79 ++++ .../components/mobile/mobileSessionStatus.ts | 71 +++ .../components/mobile/mobileShellLayout.ts | 51 ++ .../mobile/useLiveSessionActivity.test.tsx | 65 +++ .../mobile/useLiveSessionActivity.ts | 53 +++ .../src/components/ui/MobileBottomSheet.tsx | 9 +- desktop/src/hooks/useLongPress.test.tsx | 127 +++++ desktop/src/hooks/useLongPress.ts | 103 ++++ desktop/src/hooks/useMobileViewport.test.tsx | 13 + desktop/src/hooks/useMobileViewport.ts | 9 +- .../src/hooks/useSessionListAutoRefresh.ts | 73 +++ desktop/src/i18n/locales/en.ts | 47 ++ desktop/src/i18n/locales/jp.ts | 47 ++ desktop/src/i18n/locales/kr.ts | 47 ++ desktop/src/i18n/locales/zh-TW.ts | 47 ++ desktop/src/i18n/locales/zh.ts | 47 ++ desktop/src/lib/formatRelativeTime.ts | 24 + desktop/src/lib/touchH5.test.ts | 12 +- desktop/src/pages/ActiveSession.test.tsx | 75 +++ desktop/src/pages/ActiveSession.tsx | 18 +- desktop/src/pages/EmptySession.test.tsx | 24 + desktop/src/pages/EmptySession.tsx | 128 +++-- desktop/src/pages/SettingsVoiceTab.test.tsx | 8 +- desktop/src/pages/SubagentRunPage.test.tsx | 2 + desktop/src/pages/SubagentRunPage.tsx | 4 +- .../src/pages/settings/H5Settings.test.tsx | 35 +- desktop/src/pages/settings/H5Settings.tsx | 159 ++++++- desktop/src/stores/activityPanelStore.ts | 27 ++ desktop/src/theme/globals.css | 56 --- .../__tests__/conversation-status.test.ts | 68 +++ src/server/api/sessions.ts | 12 + src/server/services/conversationService.ts | 5 + src/server/ws/handler.ts | 29 ++ 78 files changed, 4961 insertions(+), 1107 deletions(-) create mode 100644 desktop/src/components/chat/MobileApprovalDock.test.tsx create mode 100644 desktop/src/components/chat/MobileApprovalDock.tsx create mode 100644 desktop/src/components/chat/MobileComposerSheet.tsx create mode 100644 desktop/src/components/chat/MobileTurnDiffSheet.tsx create mode 100644 desktop/src/components/chat/PendingDecisionMarker.tsx create mode 100644 desktop/src/components/chat/permissionPresentation.ts create mode 100644 desktop/src/components/chat/useMessageActionMenu.test.tsx create mode 100644 desktop/src/components/chat/useMessageActionMenu.tsx create mode 100644 desktop/src/components/controls/permissionModeState.ts create mode 100644 desktop/src/components/layout/sidebarProjectPreferenceStorage.ts create mode 100644 desktop/src/components/mobile/MobileActivityPill.tsx create mode 100644 desktop/src/components/mobile/MobileSessionBrowser.tsx create mode 100644 desktop/src/components/mobile/MobileSessionList.tsx create mode 100644 desktop/src/components/mobile/MobileShell.test.tsx create mode 100644 desktop/src/components/mobile/MobileShell.tsx create mode 100644 desktop/src/components/mobile/MobileTopBar.tsx create mode 100644 desktop/src/components/mobile/mobileNavigation.ts create mode 100644 desktop/src/components/mobile/mobileSessionGroups.test.ts create mode 100644 desktop/src/components/mobile/mobileSessionGroups.ts create mode 100644 desktop/src/components/mobile/mobileSessionStatus.test.ts create mode 100644 desktop/src/components/mobile/mobileSessionStatus.ts create mode 100644 desktop/src/components/mobile/mobileShellLayout.ts create mode 100644 desktop/src/components/mobile/useLiveSessionActivity.test.tsx create mode 100644 desktop/src/components/mobile/useLiveSessionActivity.ts create mode 100644 desktop/src/hooks/useLongPress.test.tsx create mode 100644 desktop/src/hooks/useLongPress.ts create mode 100644 desktop/src/hooks/useSessionListAutoRefresh.ts create mode 100644 desktop/src/lib/formatRelativeTime.ts diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index 7b4cb617..ee38243d 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -30,6 +30,11 @@ export type SessionChatStatusResponse = { state: 'idle' | 'thinking' | 'compacting' | 'tool_executing' activityState: PetSessionRuntimeStatus } +/** One entry per session that is working or waiting right now; idle sessions are absent. */ +export type LiveSessionActivity = { + id: string + activityState: 'running' | 'waiting' +} type MessagesResponse = { messages: MessageEntry[] taskNotifications?: AgentTaskNotification[] @@ -525,6 +530,10 @@ export const sessionsApi = { return api.get(`/api/sessions/${sessionId}/summary`, options) }, + getLiveStatus(signal?: AbortSignal) { + return api.get<{ sessions: LiveSessionActivity[] }>('/api/sessions/live-status', { signal }) + }, + getChatStatus(sessionId: string, signal?: AbortSignal) { return api.get(`/api/sessions/${sessionId}/chat/status`, { signal }) }, diff --git a/desktop/src/components/activity/SessionActivityPanel.test.tsx b/desktop/src/components/activity/SessionActivityPanel.test.tsx index 9c45fc99..104f5628 100644 --- a/desktop/src/components/activity/SessionActivityPanel.test.tsx +++ b/desktop/src/components/activity/SessionActivityPanel.test.tsx @@ -716,6 +716,19 @@ describe('SessionActivityPanel', () => { expect(onClose).not.toHaveBeenCalled() }) + it('opens as a bottom sheet on the phone, which a tap inside does not close', () => { + const onClose = vi.fn() + render() + + const sheet = screen.getByTestId('session-activity-sheet') + expect(screen.queryByTestId('session-activity-panel')).not.toBeInTheDocument() + fireEvent.pointerDown(sheet) + expect(onClose).not.toHaveBeenCalled() + + fireEvent.keyDown(document, { key: 'Escape' }) + expect(onClose).toHaveBeenCalledTimes(1) + }) + it('renders as a rail without closing on outside pointerdown', () => { const onClose = vi.fn() render( diff --git a/desktop/src/components/activity/SessionActivityPanel.tsx b/desktop/src/components/activity/SessionActivityPanel.tsx index 1443b405..2e0e7780 100644 --- a/desktop/src/components/activity/SessionActivityPanel.tsx +++ b/desktop/src/components/activity/SessionActivityPanel.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Check, ChevronRight, Circle, FileText, LoaderCircle, Square, Terminal, Users, X, Zap } from 'lucide-react' import { Badge, StatusDot, type Tone } from '@/components/ui/Badge' import { Button } from '@/components/ui/Button' +import { MobileBottomSheet } from '@/components/ui/MobileBottomSheet' import { IconButton } from '@/components/ui/IconButton' import { Progress } from '@/components/ui/Progress' import { useDismissable } from '@/hooks/useDismissable' @@ -22,7 +23,11 @@ export type OpenSubagentPayload = { teamStartedAt?: number } -type SessionActivityPanelPlacement = 'overlay' | 'rail' +/** + * `overlay` floats over the session's top right, `rail` docks beside it, and + * `sheet` is the phone's bottom sheet, opened from the top bar's activity pill. + */ +type SessionActivityPanelPlacement = 'overlay' | 'rail' | 'sheet' type TranslationFn = ReturnType @@ -656,7 +661,8 @@ export function SessionActivityPanel({ ) useDismissable({ - open, + // A sheet closes itself (scrim, close button, Escape). + open: open && placement !== 'sheet', refs: [panelRef], onDismiss: onClose, isExempt: isDismissExempt, @@ -676,6 +682,98 @@ export function SessionActivityPanel({ } }, [model.sections.backgroundTasks.rows, open, selectedBackgroundTaskId]) + const sections = visibleSections.map((section, index) => { + const sectionTitle = getSectionTitle(section.id, t) + const taskProgress = section.id === 'tasks' ? getTaskProgress(section.rows) : null + + return ( +
0 ? 'border-t border-[var(--color-border)] pt-3' : undefined} + > +
+
+

+ {sectionTitle} +

+ {section.rows.length > 0 ? ( + {section.rows.length} + ) : null} +
+ {taskProgress ? ( + + {/* `Progress` is `w-full`; the wrapper is what makes it the + 52px rail the design calls for, since the two width + utilities would otherwise resolve by stylesheet order. */} + + {/* Named for what it measures, not the section: reusing the + section title made screen readers announce "任务, 50%". */} + + + {taskProgress.completed}/{taskProgress.total} + + ) : null} + {section.id === 'backgroundTasks' && finishedBackgroundTaskKeys.length > 0 && onClearFinishedBackgroundTasks ? ( + + ) : null} +
+
+ {section.rows.map((row) => ( +
+ { + setSelectedBackgroundTaskId((current) => ( + current === backgroundRow.id ? null : backgroundRow.id + )) + }} + selected={section.id === 'backgroundTasks' && selectedBackgroundTaskId === row.id} + /> + {section.id === 'backgroundTasks' && selectedBackgroundTaskId === row.id ? ( + + ) : null} +
+ ))} +
+
+ ) + }) + + if (placement === 'sheet') { + return ( + + {sections} + + ) + } + if (!open) return null // Both placements are out-of-flow overlays pinned to the top right of the // session area. `rail` used to be an in-flow flex sibling, which is why the @@ -730,82 +828,7 @@ export function SessionActivityPanel({ data-testid="session-activity-scroll" className={`min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain px-4 pb-4 pt-0.5 ${ACTIVITY_SCROLLBAR_CLASS}`} > - {visibleSections.map((section, index) => { - const sectionTitle = getSectionTitle(section.id, t) - const taskProgress = section.id === 'tasks' ? getTaskProgress(section.rows) : null - - return ( -
0 ? 'border-t border-[var(--color-border)] pt-3' : undefined} - > -
-
-

- {sectionTitle} -

- {section.rows.length > 0 ? ( - {section.rows.length} - ) : null} -
- {taskProgress ? ( - - {/* `Progress` is `w-full`; the wrapper is what makes it the - 52px rail the design calls for, since the two width - utilities would otherwise resolve by stylesheet order. */} - - {/* Named for what it measures, not the section: reusing the - section title made screen readers announce "任务, 50%". */} - - - {taskProgress.completed}/{taskProgress.total} - - ) : null} - {section.id === 'backgroundTasks' && finishedBackgroundTaskKeys.length > 0 && onClearFinishedBackgroundTasks ? ( - - ) : null} -
-
- {section.rows.map((row) => ( -
- { - setSelectedBackgroundTaskId((current) => ( - current === backgroundRow.id ? null : backgroundRow.id - )) - }} - selected={section.id === 'backgroundTasks' && selectedBackgroundTaskId === row.id} - /> - {section.id === 'backgroundTasks' && selectedBackgroundTaskId === row.id ? ( - - ) : null} -
- ))} -
-
- ) - })} + {sections} ) diff --git a/desktop/src/components/chat/ActivityGroup.test.tsx b/desktop/src/components/chat/ActivityGroup.test.tsx index 4fa27a0b..70101d34 100644 --- a/desktop/src/components/chat/ActivityGroup.test.tsx +++ b/desktop/src/components/chat/ActivityGroup.test.tsx @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest' import { fireEvent, render, screen, within } from '@testing-library/react' +import '@testing-library/jest-dom' import { ActivityGroup } from './ActivityGroup' import { buildActivitySegments, formatActivitySummary, type ActivityStep } from './activityGroupModel' import { useSettingsStore } from '../../stores/settingsStore' @@ -419,3 +420,65 @@ describe('buildActivitySegments', () => { expect(formatActivitySummary(buildActivitySegments(steps, t), t)).toBe(`${t('toolGroup.searchedOne')}, ${t('toolGroup.readMany', { count: 2 })}`) }) }) + +describe('ActivityGroup on a phone', () => { + beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) + }) + + const read = toolCall({ id: 'use-read', toolUseId: 'read-1', toolName: 'Read', input: { file_path: '/repo/src/todo.ts' }, timestamp: 1_000 }) + const bash = toolCall({ id: 'use-bash', toolUseId: 'bash-1', toolName: 'Bash', input: { command: 'bun test' }, timestamp: 2_000 }) + const steps: ActivityStep[] = [{ kind: 'tool', toolCall: read }, { kind: 'tool', toolCall: bash }] + + it('folds a finished run into one card and opens the whole timeline in a sheet', () => { + render( + , + ) + + const card = screen.getByTestId('activity-group') + expect(card).toHaveAttribute('data-presentation', 'sheet') + expect(card).toHaveTextContent(formatActivitySummary(buildActivitySegments(steps, t), t)) + // Nothing unfolds in place: the transcript keeps its place on a small screen. + expect(within(card).queryByText('bun test')).toBeNull() + expect(screen.queryByTestId('activity-live-step')).toBeNull() + + fireEvent.click(within(card).getByRole('button')) + const sheet = screen.getByTestId('activity-timeline-sheet') + expect(within(sheet).getByText('bun test')).toBeTruthy() + }) + + it('shows the step under way while the run is live', () => { + render( + , + ) + + expect(screen.getByTestId('activity-group')).toHaveAttribute('data-running', 'true') + expect(within(screen.getByTestId('activity-live-step')).getByText('bun test')).toBeTruthy() + }) + + it('gives even a single call the card, so every run reads the same', () => { + render( + , + ) + expect(screen.getByTestId('activity-group')).toHaveAttribute('data-presentation', 'sheet') + }) +}) diff --git a/desktop/src/components/chat/ActivityGroup.tsx b/desktop/src/components/chat/ActivityGroup.tsx index 9af3dd43..1371584a 100644 --- a/desktop/src/components/chat/ActivityGroup.tsx +++ b/desktop/src/components/chat/ActivityGroup.tsx @@ -1,7 +1,8 @@ import { memo, useEffect, useMemo, useState, type ReactNode } from 'react' import { getDisclosure, setDisclosure } from '../../lib/disclosureMemory' import { toolResultImagesFor, type ToolResultImageExtraction } from '@/lib/toolResultContent' -import { ChevronRight, CirclePause, CircleX, LoaderCircle } from 'lucide-react' +import { Check, ChevronRight, CirclePause, CircleX, LoaderCircle } from 'lucide-react' +import { MobileBottomSheet } from '@/components/ui/MobileBottomSheet' import { ToolCallBlock, formatDuration } from './ToolCallBlock' import { ThinkingBlock } from './ThinkingBlock' import { ToolResultImages } from './ToolResultImages' @@ -44,6 +45,13 @@ type Props = { revealToolUseId?: string /** Calls whose permission prompt is waiting on the user. */ awaitingToolUseIds?: ReadonlySet + /** + * `inline` opens the run in place. `sheet` (the phone) keeps every run one + * card: the counted summary, plus the step under way while it runs; a tap + * opens the whole timeline in a sheet. A run unfolding in place on a phone + * pushed the conversation off the screen a step at a time. + */ + presentation?: 'inline' | 'sheet' } const MAX_HEADER_ICONS = 3 @@ -98,8 +106,10 @@ export const ActivityGroup = memo(function ActivityGroup({ disclosureKey, revealToolUseId, awaitingToolUseIds, + presentation = 'inline', }: Props) { const t = useTranslation() + const [sheetOpen, setSheetOpen] = useState(false) /** null = follow the run's own state; set = the reader decided. */ const [pinnedCollapsedLocal, setPinnedCollapsedLocal] = useState(null) const pinnedCollapsed = disclosureKey @@ -146,7 +156,7 @@ export const ActivityGroup = memo(function ActivityGroup({ } const soleToolCall = steps.length === 1 && steps[0]?.kind === 'tool' ? steps[0].toolCall : null - if (soleToolCall) { + if (soleToolCall && presentation === 'inline') { return (
segments.findIndex((other) => other.icon === segment.icon) === index) .slice(0, MAX_HEADER_ICONS) + if (presentation === 'sheet') { + const lastToolStep = [...steps].reverse().find((step) => step.kind === 'tool') + const liveToolCall = showsProgress && lastToolStep?.kind === 'tool' ? lastToolStep.toolCall : null + const timeline = ( + + {steps.map((step) => step.kind === 'thinking' ? ( + + ) : ( + + ))} + + ) + return ( +
+ + {liveToolCall ? ( +
+ + + +
+ ) : null} + {!liveToolCall ? : null} + setSheetOpen(false)} + title={summaryText} + closeLabel={t('common.close')} + testId="activity-timeline-sheet" + tall + contentClassName="px-3 py-2" + > + {timeline} + +
+ ) + } + return (
{ @@ -100,6 +107,7 @@ export function AskUserQuestion({ input, result, supersededByUserMessage, + markerWhenPending = false, }: Props) { const { respondToPermission } = useChatStore() const activeTabId = useTabStore((s) => s.activeTabId) @@ -212,6 +220,9 @@ export function AskUserQuestion({ // direction. With the early return above the useMemo calls, that transition threw // "Rendered fewer/more hooks than expected" and took the whole message list down. if (questions.length === 0) return null + if (markerWhenPending && pendingRequest) { + return + } const safeActiveTab = Math.min(activeTab, questions.length - 1) const activeQuestion = questions[safeActiveTab] diff --git a/desktop/src/components/chat/AssistantMessage.tsx b/desktop/src/components/chat/AssistantMessage.tsx index 5d456802..f3e720f9 100644 --- a/desktop/src/components/chat/AssistantMessage.tsx +++ b/desktop/src/components/chat/AssistantMessage.tsx @@ -6,6 +6,7 @@ import { buildOpenWithMenuItemsForHref } from '../../lib/openWithMenuItems' import { fileRefFromElement } from '../../lib/markdownAutolink' import type { OpenWithItem } from '../../lib/openWithItems' import { MessageActionBar, type MessageBranchAction } from './MessageActionBar' +import { useMessageActionMenu } from './useMessageActionMenu' import { TurnCompletionStamp } from './TurnCompletionStamp' import type { TurnCompletion } from '../../lib/turnCompletion' import { ImageGalleryModal } from './ImageGalleryModal' @@ -165,25 +166,33 @@ export const AssistantMessage = memo(function AssistantMessage({ [t, workDir], ) + const showTurnCompletion = !isStreaming && Boolean(turnCompletion) + // On a phone any finished reply can be held for copy and select; branching + // stays with the reply that closes a turn, as on the desktop bar. + const actionMenu = useMessageActionMenu({ + copyText: isStreaming ? undefined : content, + branchAction: showTurnCompletion ? branchAction : undefined, + }) + if (!content.trim()) return null const documentLayout = shouldUseDocumentLayout(content) - const showTurnCompletion = !isStreaming && Boolean(turnCompletion) return (
} /> )} + {actionMenu.sheet}
) diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index e9d5774b..f583c57a 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -2808,6 +2808,80 @@ describe('ChatInput file mentions', () => { expect(fileSearchMenu).not.toHaveTextContent('Navigate') }) + it('moves the low-frequency controls off the phone toolbar into the + sheet', async () => { + viewportMocks.isMobile = true + + render() + await waitFor(() => { + expect(mocks.getGitInfo).toHaveBeenCalledWith(sessionId) + }) + + // Context usage stays mounted to keep tracking, but takes no toolbar slot. + expect(screen.getByTestId('context-usage-indicator')).not.toBeVisible() + + fireEvent.click(screen.getByRole('button', { name: 'Open composer tools' })) + const sheet = screen.getByTestId('mobile-composer-sheet') + expect(within(sheet).getByRole('button', { name: /Execution Permissions/ })).toHaveTextContent('Ask permissions') + // The phone sheet must not grab focus: that would throw the keyboard up + // over the list that was just opened. + expect(within(sheet).getByRole('combobox')).not.toHaveFocus() + + fireEvent.click(within(sheet).getByRole('button', { name: /^Context/ })) + + // One sheet at a time: the + sheet closes before the breakdown opens. + expect(screen.queryByTestId('mobile-composer-sheet')).not.toBeInTheDocument() + expect(await screen.findByTestId('context-usage-sheet')).toBeInTheDocument() + }) + + it('hands the phone composer slot to the approval bar while a request waits, and takes it back', async () => { + viewportMocks.isMobile = true + render() + await waitFor(() => { + expect(mocks.getGitInfo).toHaveBeenCalledWith(sessionId) + }) + const request = { requestId: 'perm-1', toolName: 'Bash', toolUseId: 'toolu-1', input: { command: 'bun run build' } } + + act(() => { + useChatStore.setState((state) => ({ + sessions: { + ...state.sessions, + [sessionId]: { ...state.sessions[sessionId]!, pendingPermission: request, pendingPermissions: { [request.requestId]: request } }, + }, + })) + }) + expect(screen.getByTestId('mobile-approval-dock')).toHaveTextContent('bun run build') + expect(screen.queryByTestId('chat-input-panel')).not.toBeInTheDocument() + + act(() => { + useChatStore.setState((state) => ({ + sessions: { + ...state.sessions, + [sessionId]: { ...state.sessions[sessionId]!, pendingPermission: null, pendingPermissions: {} }, + }, + })) + }) + expect(screen.queryByTestId('mobile-approval-dock')).not.toBeInTheDocument() + expect(screen.getByTestId('chat-input-panel')).toBeInTheDocument() + }) + + it('leaves the desktop composer alone while a request waits, since the card is in the transcript', async () => { + render() + await waitFor(() => { + expect(mocks.getGitInfo).toHaveBeenCalledWith(sessionId) + }) + const request = { requestId: 'perm-1', toolName: 'Bash', toolUseId: 'toolu-1', input: { command: 'bun run build' } } + act(() => { + useChatStore.setState((state) => ({ + sessions: { + ...state.sessions, + [sessionId]: { ...state.sessions[sessionId]!, pendingPermission: request, pendingPermissions: { [request.requestId]: request } }, + }, + })) + }) + expect(screen.queryByTestId('mobile-approval-dock')).not.toBeInTheDocument() + expect(screen.getByTestId('chat-input-panel')).toBeInTheDocument() + }) + it('keeps the active-session toolbar in flow so multiline caret cannot render behind controls', async () => { render() diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 60591830..efc54b25 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -5,13 +5,14 @@ import { getSessionReferences } from '@/lib/composerMentions' import { normalizeSessionReferences } from '@/lib/sessionReferences' import { isComposerReferenceVisible, isComposerSlashCommandVisible } from '@/lib/composerCapabilityVisibility' import { useState, useRef, useEffect, useCallback, useMemo, useId } from 'react' -import { ArrowUp, CornerDownRight, Pencil, Plus, Square, Trash2 } from 'lucide-react' +import { ArrowUp, CornerDownRight, Cpu, Gauge, Pencil, Plus, ShieldCheck, Square, Trash2 } from 'lucide-react' import { useDismissable } from '@/hooks/useDismissable' import { Button } from '@/components/ui/Button' import { IconButton } from '@/components/ui/IconButton' import { useTranslation } from '../../i18n' import { hasPendingAskUserQuestion, + listPendingPermissions, useChatStore, type RepositoryLaunchDraftState, } from '../../stores/chatStore' @@ -30,7 +31,8 @@ import { } from '../../stores/workspaceChatContextStore' import { sessionsApi, type SessionGitInfo } from '../../api/sessions' import { agentsApi } from '../../api/agents' -import { PermissionModeSelector } from '../controls/PermissionModeSelector' +import { PermissionModeSelector, type PermissionModeSelectorHandle } from '../controls/PermissionModeSelector' +import { PERMISSION_MODE_LABEL_KEYS, useResolvedPermissionMode } from '../controls/permissionModeState' import { ModelSelector, type ModelSelectorHandle } from '../controls/ModelSelector' import type { AttachmentRef } from '../../types/chat' import { AttachmentGallery } from './AttachmentGallery' @@ -40,12 +42,14 @@ import { RepositoryLaunchControls } from '@/components/chat/RepositoryLaunchCont import { ComposerReferenceMenu, type ComposerReferenceMenuHandle } from './ComposerReferenceMenu' import { ComposerReferenceDetail } from './ComposerReferenceDetail' import { ComposerCapabilityMenu } from './ComposerCapabilityMenu' +import { MobileComposerSheet, type MobileComposerSetting } from './MobileComposerSheet' +import { MobileApprovalDock } from './MobileApprovalDock' import { useCapabilityMenu } from './useCapabilityMenu' import { composerReferencesApi, mentionProviderId } from '@/api/composerReferences' import type { ComposerReferenceCandidate } from '@/types/composerReference' import { LocalSlashCommandPanel, type LocalSlashCommandName } from './LocalSlashCommandPanel' import { getSlashCommandOptionId, SlashCommandMenu } from './SlashCommandMenu' -import { ContextUsageIndicator } from './ContextUsageIndicator' +import { ContextUsageIndicator, type ContextUsageIndicatorHandle } from './ContextUsageIndicator' import { appendAgentSlashCommands, buildAgentSlashCommands, @@ -74,6 +78,7 @@ import { findMentionRanges, insertMentionIntoText, type ComposerMention, + type NewComposerMention, } from '../../lib/composerMentions' import type { PermissionMode } from '../../types/settings' import { getSessionWorkspaceState, getSessionSeedWorkDir } from '../../lib/sessionWorkspace' @@ -181,6 +186,8 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis const panelRef = useRef(null) const fileInputRef = useRef(null) const modelSelectorRef = useRef(null) + const permissionSelectorRef = useRef(null) + const contextUsageRef = useRef(null) const plusMenuRef = useRef(null) const slashMenuRef = useRef(null) const fileSearchRef = useRef(null) @@ -300,6 +307,7 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis const isMemberSession = !!memberInfo || activeTabType === 'subagent' const isActive = chatState !== 'idle' + const resolvedPermissionMode = useResolvedPermissionMode(activeTabId ?? undefined) const hasRunningSubagents = hasRunningSubagentTasks(sessionState?.backgroundAgentTasks) // Approved team processes are tracked by their plan, not background-agent // notifications. Keep Stop available after the review card is dismissed. @@ -633,7 +641,9 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis }, [activeSession?.workDir, activeSession?.projectRoot, activeSession?.workspaceState, activeTabId, gitInfo?.workDir, showLaunchControls]) useDismissable({ - open: plusMenuOpen, + // The phone's + opens a sheet in a portal, which closes itself; an outside + // press listener here would read every tap inside it as "outside". + open: plusMenuOpen && !isMobileComposer, refs: [plusMenuRef], onDismiss: () => setPlusMenuOpen(false), }) @@ -1227,6 +1237,52 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis // The "+" capability menu. The hook owns data loading and navigation // actions; these handlers are only the composer-local edits (mention badge, // slash text, prompt seed) plus the surfaces this composer already opens. + const insertSelectedFileMention = (mention: NewComposerMention) => { + const cursorPos = composerRef.current?.getSelectionOffsets().start ?? inputRef.current.length + const inserted = insertMentionIntoText(inputRef.current, mentionsRef.current, cursorPos, cursorPos, mention) + setComposerInput(inserted.text, inserted.mentions) + requestAnimationFrame(() => { + composerRef.current?.focus() + composerRef.current?.setSelectionOffsets(inserted.cursorPos) + }) + } + + // The phone's + sheet carries the controls that left its toolbar. A row + // closes the sheet first and opens the control's own sheet a frame later, + // so the two never stack. + const openFromComposerSheet = (open: () => void) => { + setPlusMenuOpen(false) + requestAnimationFrame(open) + } + const mobileComposerSettings: MobileComposerSetting[] = isMobileComposer && !isMemberSession + ? [ + { + key: 'permission', + icon: