Files
orca/mobile/src/session/MobileSessionSheets.tsx
T
Brennan Benson 64e544a442 feat(mobile): Resume in New CLI from a phone chat tab (#29783)
* feat(mobile): Resume in New CLI from a phone chat tab

A chat tab on the phone had only Close in its long-press menu, so a chat
that would not load left no way to reach its conversation. The phone now
offers Resume in New CLI, like the desktop tab menu: the chat's host
prepares the conversation and the phone opens a copy in a new terminal tab.

The resume and fork startup builders move to src/shared so the desktop and
the phone build the same command.

* test(mobile): type the fake terminal create instead of casting its calls

* fix(mobile): pass the host's fork source as is; match the quoted args in the test

* test(mobile): give the fake host client the untyped mock the other client fakes use

* style: format the shared resume builder and phone fork files

* Fix mobile chat CLI fork creation on owning host

* Fix mobile chat fork lint and split create owner helpers

* Keep chat fork beside phone anchor across tab groups

* Combine mobile terminal runtime type imports

* Repair mobile create CI contracts after chat fork extraction

* Classify chat fork preflight refusals and preserve mobile create recordings

* Keep route-owned create cleanup quiet after unmount

* Preserve typed runtime repair refusal in chat fork preflight

* Preserve checked store through mobile chat fork preflight

* Register mobile chat fork runtime fixture before test setup

* Remove unused mobile create recording value

* style(mobile): format the chat fork create files; move fork reply messages into the fork feedback module

Formatting pushed the create hook past its line limit, so the fork-only reply message, partial-success warning and host capability check now live beside the other fork messages. Same order and copy as before.

* Keep quick command mutation aligned with formatted request

* Keep mobile chat CLI forks in terminal view

* Pass mobile chat resolution its existing controller arguments

* Match the mobile create fixture mock layout

* Keep mobile chat CLI forks open when placement is unavailable

* Brace placement contract fixture checks

* test(mobile): keep the prompt controller mock under its line limit after the main merge

* Expose pane admission in the runtime test fixture type

* Complete the chat fork contract controller fixture
2026-10-16 18:10:24 -07:00

401 lines
14 KiB
TypeScript

import { Platform } from 'react-native'
import { useClipboardWriter } from '../platform/clipboard'
import { Copy, FileText, Globe, RefreshCw, SquareTerminal } from 'lucide-react-native'
import { MobileSessionHeaderMoreActionsSheet } from './MobileSessionHeaderMoreActionsSheet'
import { QuickCommandsSheet } from './QuickCommandsSheet'
import { triggerSuccess, triggerError } from '../platform/haptics'
import { ActionSheetModal } from '../components/ActionSheetModal'
import { TextInputModal } from '../components/TextInputModal'
import { ConfirmModal } from '../components/ConfirmModal'
import { CustomKeyModal } from '../components/CustomKeyModal'
import { MobileDictationSetupSheet } from '../components/MobileDictationSetupSheet'
import { useWorkspaceOnPairedHost } from '../navigation/workspace-execution-host'
import { MobileBrowserTabActionSheet } from './MobileBrowserTabActionSheet'
import { getMobileTerminalActionSheetActions } from './mobile-terminal-action-sheet-actions'
import { getMobileNativeChatCliForkActions } from './mobile-native-chat-cli-fork'
import {
getRepoIdFromMobileWorktreeId,
isTerminalPhoneDisplayMode
} from './mobile-session-route-helpers'
import type { MobileSessionController } from './use-mobile-session-controller'
export function MobileSessionSheets({ controller }: { controller: MobileSessionController }) {
const {
worktreeId,
isFolderWorkspaceRoute,
isFloatingWorkspaceRoute,
client,
worktreeName,
sessionTabs,
pendingDiffNotesDelivery,
setPendingDiffNotesDelivery,
showCreateTabDrawer,
setShowCreateTabDrawer,
showQuickCommands,
setShowQuickCommands,
setShowCreateBrowserModal,
showCreateBrowserModal,
showHeaderMoreActions,
setShowHeaderMoreActions,
actionTarget,
setActionTarget,
markdownActionTarget,
setMarkdownActionTarget,
fileActionTarget,
setFileActionTarget,
browserActionTarget,
setBrowserActionTarget,
agentSessionActionTarget,
setAgentSessionActionTarget,
discardMarkdownTarget,
setDiscardMarkdownTarget,
leaveDrafts,
setLeaveDrafts,
setRenameTarget,
renameTarget,
setCustomKeys,
showCustomKeyModal,
setShowCustomKeyModal,
deleteKeyTarget,
setDeleteKeyTarget,
terminalModes,
showDictationSetup,
setShowDictationSetup,
browserScreencastSupported,
quickCommandsSupported,
quickCommandAgentPromptMaxLength,
showToast,
nativeChatTranscriptIsLocalReadable,
nativeChatController,
toggleTabChatView,
toggleDisplayMode,
readFileTab,
leaveSession,
discardMarkdownLocalContent,
confirmDiscardMarkdown,
handleDeleteCustomKey,
handleManageShortcuts,
handleClearTerminal,
handleCreateTerminal,
launchQuickCommand,
handleCreateMarkdownNote,
handleCreateBrowser,
handleBrowserNavigationCommand,
handleRenameTerminal,
handleCloseTerminal,
handleCloseSessionTab,
bulkCloseActions,
closeWithBulkActions,
createTabAgentActions,
sendDiffNotesAgentActions,
handlePanelTap,
openAgentSessionHistory,
showAgentSessionHistoryAction,
showChecksAction
} = controller
const clipboard = useClipboardWriter()
const onPairedHost = useWorkspaceOnPairedHost()
return (
<>
<MobileSessionHeaderMoreActionsSheet
visible={showHeaderMoreActions}
showAgentSessionHistory={showAgentSessionHistoryAction}
showChecks={showChecksAction}
onOpenAgentSessionHistory={openAgentSessionHistory}
onOpenChecks={() => handlePanelTap('pr')}
onClose={() => setShowHeaderMoreActions(false)}
/>
<QuickCommandsSheet
visible={showQuickCommands && quickCommandsSupported === true}
onClose={() => setShowQuickCommands(false)}
client={client}
repoId={
isFolderWorkspaceRoute || isFloatingWorkspaceRoute
? null
: getRepoIdFromMobileWorktreeId(worktreeId) || null
}
repoName={worktreeName || null}
agentPromptMaxLength={quickCommandAgentPromptMaxLength}
onLaunch={launchQuickCommand}
/>
<ActionSheetModal
visible={showCreateTabDrawer}
title="New Tab"
actions={[
...createTabAgentActions,
{
label: 'Terminal',
icon: SquareTerminal,
onPress: () => {
setShowCreateTabDrawer(false)
void handleCreateTerminal()
}
},
// Why: browser/markdown creation resolve a real worktree on the host
// (browser.tabCreate, files.createFile); the floating sentinel is
// terminal-only over RPC, so those options hide there.
...(isFloatingWorkspaceRoute
? []
: [
{
label: 'Browser',
icon: Globe,
closeBeforePress: true,
onPress: () => {
if (browserScreencastSupported !== true) {
showToast('Desktop update required for mobile browser streaming', 1600)
return
}
setShowCreateBrowserModal(true)
}
},
...(onPairedHost
? [
{
label: 'Markdown Note',
icon: FileText,
onPress: () => {
setShowCreateTabDrawer(false)
void handleCreateMarkdownNote()
}
}
]
: [])
])
]}
onClose={() => setShowCreateTabDrawer(false)}
/>
<ActionSheetModal
visible={pendingDiffNotesDelivery !== null}
title="Send Review Notes"
message="Choose an agent session for the current notes."
actions={[
...sendDiffNotesAgentActions,
{
label: 'Copy Notes',
icon: Copy,
onPress: () => {
const delivery = pendingDiffNotesDelivery
setPendingDiffNotesDelivery(null)
if (!delivery) {
return
}
void clipboard
.writeText(delivery.prompt)
.then(() => {
triggerSuccess()
showToast('Notes copied')
})
.catch(() => {
triggerError()
showToast("Couldn't copy notes", 1500)
})
}
}
]}
onClose={() => setPendingDiffNotesDelivery(null)}
/>
<ActionSheetModal
visible={actionTarget != null}
title={actionTarget?.title || 'Terminal'}
actions={getMobileTerminalActionSheetActions({
target: actionTarget,
tabs: sessionTabs.filter((tab) => tab.type === 'terminal'),
isTabChatView: nativeChatController.isTabChatView,
nativeChatTranscriptIsLocalReadable,
onDismiss: () => setActionTarget(null),
onToggleChat: toggleTabChatView,
isPhoneMode: (handle) => isTerminalPhoneDisplayMode(handle, terminalModes),
onToggleDisplayMode: (handle) => void toggleDisplayMode(handle),
onRename: setRenameTarget,
onClear: (target) => void handleClearTerminal(target),
onClose: (target) => void handleCloseTerminal(target),
onCloseSessionTab: (tab) => void handleCloseSessionTab(tab),
bulkCloseActions
})}
onClose={() => setActionTarget(null)}
/>
<ActionSheetModal
visible={markdownActionTarget != null}
title={markdownActionTarget?.title || 'Markdown'}
actions={[
{
label: 'Refresh',
icon: RefreshCw,
// Why: dirty refresh opens ConfirmModal; wait for this sheet's native
// Modal to unmount first (same dual-Modal race as tab Rename, #10331).
closeBeforePress: true,
onPress: () => {
const target = markdownActionTarget
if (target) {
discardMarkdownLocalContent(target)
}
}
},
{
label: 'Copy Path',
icon: FileText,
onPress: () => {
const target = markdownActionTarget
setMarkdownActionTarget(null)
if (target) {
void clipboard
.writeText(target.relativePath || target.filePath)
.then(() => showToast('Path copied'))
.catch(() => {
triggerError()
showToast("Couldn't copy path", 1500)
})
}
}
},
...closeWithBulkActions(markdownActionTarget, () => setMarkdownActionTarget(null))
]}
onClose={() => setMarkdownActionTarget(null)}
/>
<ActionSheetModal
visible={fileActionTarget != null}
title={fileActionTarget?.title || 'File'}
actions={[
{
label: 'Refresh',
icon: RefreshCw,
onPress: () => {
const target = fileActionTarget
setFileActionTarget(null)
if (target) {
void readFileTab(target)
}
}
},
...closeWithBulkActions(fileActionTarget, () => setFileActionTarget(null))
]}
onClose={() => setFileActionTarget(null)}
/>
<MobileBrowserTabActionSheet
target={browserActionTarget}
onClose={() => setBrowserActionTarget(null)}
onNavigate={handleBrowserNavigationCommand}
onCloseTab={handleCloseSessionTab}
bulkCloseActions={bulkCloseActions}
/>
<ActionSheetModal
visible={agentSessionActionTarget != null}
title={agentSessionActionTarget?.title || 'Chat'}
actions={[
...getMobileNativeChatCliForkActions({
controller,
tab: agentSessionActionTarget,
workspaceOnPairedHost: onPairedHost,
onDismiss: () => setAgentSessionActionTarget(null)
}),
...closeWithBulkActions(agentSessionActionTarget, () => setAgentSessionActionTarget(null))
]}
onClose={() => setAgentSessionActionTarget(null)}
/>
<ActionSheetModal
visible={leaveDrafts != null}
title="Unsaved markdown changes"
message="Copy or discard phone drafts before leaving."
actions={[
{
label: 'Copy All & Leave',
icon: FileText,
onPress: () => {
const drafts = leaveDrafts ?? []
const combined = drafts
.map((draft) => `# ${draft.title}\n\n${draft.content}`)
.join('\n\n---\n\n')
void clipboard
.writeText(combined)
.then(() => {
setLeaveDrafts(null)
leaveSession()
})
.catch(() => {
triggerError()
showToast("Couldn't copy drafts", 1500)
})
}
},
{
label: 'Discard & Leave',
destructive: true,
onPress: () => {
setLeaveDrafts(null)
leaveSession()
}
}
]}
onClose={() => setLeaveDrafts(null)}
/>
<ConfirmModal
visible={discardMarkdownTarget != null}
title="Discard Changes"
message="Replace the phone draft with the latest desktop file?"
confirmLabel="Discard"
destructive
onConfirm={confirmDiscardMarkdown}
onCancel={() => setDiscardMarkdownTarget(null)}
/>
<TextInputModal
visible={renameTarget != null}
title="Rename Terminal"
defaultValue={renameTarget?.title || 'Terminal'}
placeholder="Terminal name"
onSubmit={(value) => void handleRenameTerminal(value)}
onCancel={() => setRenameTarget(null)}
/>
<TextInputModal
visible={showCreateBrowserModal}
title="New Browser"
message="Enter a URL, or leave blank for a new tab."
defaultValue=""
placeholder="https://example.com"
submitLabel="Open"
allowEmpty
selectTextOnFocus
keyboardType={Platform.OS === 'ios' ? 'url' : 'default'}
onSubmit={(value) => {
void handleCreateBrowser(value).then((created) => {
if (created) {
setShowCreateBrowserModal(false)
}
})
}}
onCancel={() => setShowCreateBrowserModal(false)}
/>
<CustomKeyModal
visible={showCustomKeyModal}
onClose={() => setShowCustomKeyModal(false)}
onKeysChanged={setCustomKeys}
onManageShortcuts={handleManageShortcuts}
/>
<MobileDictationSetupSheet
visible={showDictationSetup}
client={client}
onClose={() => setShowDictationSetup(false)}
onReady={() => setShowDictationSetup(false)}
/>
<ActionSheetModal
visible={deleteKeyTarget != null}
title={deleteKeyTarget?.label ?? 'Shortcut'}
message="Remove this custom shortcut?"
actions={[
{
label: 'Remove',
destructive: true,
onPress: () => {
if (deleteKeyTarget) {
void handleDeleteCustomKey(deleteKeyTarget)
}
setDeleteKeyTarget(null)
}
}
]}
onClose={() => setDeleteKeyTarget(null)}
/>
</>
)
}