fix(mobile): keep typed commit messages, restore failed shortcut removals, name icon buttons (#30142)

This commit is contained in:
Kelvin Amoaba
2026-10-18 20:05:28 +00:00
committed by GitHub
parent cf7423c8f4
commit b0a7d2eb48
26 changed files with 1024 additions and 144 deletions
+7 -1
View File
@@ -143,7 +143,13 @@ export default function PairConfirmScreen() {
return (
<View ref={setPairConfirmRootRef} style={[styles.container, containerPadding]}>
<Pressable style={styles.backButton} onPress={cancel}>
<Pressable
style={styles.backButton}
onPress={cancel}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Back"
>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
+14 -2
View File
@@ -218,7 +218,13 @@ export default function PairScanScreen() {
const canAskAgain = permission.canAskAgain !== false
return (
<View ref={setPairScanRootRef} style={[styles.container, containerPadding]}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<Pressable
style={styles.backButton}
onPress={() => router.back()}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Back"
>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<View style={styles.centered}>
@@ -261,7 +267,13 @@ export default function PairScanScreen() {
return (
<View ref={setPairScanRootRef} style={[styles.container, containerPadding]}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<Pressable
style={styles.backButton}
onPress={() => router.back()}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Back"
>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
+7 -1
View File
@@ -258,7 +258,13 @@ export default function TerminalSettingsScreen() {
return (
<GestureHandlerRootView style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<Pressable
style={styles.backButton}
onPress={() => router.back()}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Back"
>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Terminal</Text>
@@ -356,6 +356,9 @@ export function MobileAccountsScreen() {
style={styles.iconButton}
onPress={refresh}
disabled={!client || refreshing || connState !== 'connected'}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Refresh accounts"
>
{refreshing ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
+40 -12
View File
@@ -66,20 +66,49 @@ type Props = {
onManageShortcuts?: () => void
}
export async function loadCustomKeys(): Promise<CustomKey[]> {
try {
const raw = await AsyncStorage.getItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY)
return raw ? (JSON.parse(raw) as CustomKey[]) : []
} catch {
return []
}
// Throws on a failed read or parse, so a write never starts from an empty list it made up.
async function readStoredCustomKeys(): Promise<CustomKey[]> {
const raw = await AsyncStorage.getItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY)
return raw ? (JSON.parse(raw) as CustomKey[]) : []
}
export async function saveCustomKeys(keys: CustomKey[]): Promise<void> {
export function loadCustomKeys(): Promise<CustomKey[]> {
return readStoredCustomKeys().catch(() => [])
}
function writeCustomKeys(keys: CustomKey[]): Promise<void> {
// Through the one write path, which notes the mirror on an accepted write and on nothing else
// (ruling 35). There is no rollback here any more because there is nothing to undo: on the page
// a value over the cap rejects, and a rejected write never reached the map.
await persistMirrored(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, JSON.stringify(keys))
return persistMirrored(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, JSON.stringify(keys))
}
// Every write to the stored list takes a turn here, so a settings save, an add and a removal never
// land over one that started first, and an add or removal reads what the last write saved.
let customKeysWriteTurn: Promise<unknown> = Promise.resolve()
function inCustomKeysWriteTurn<T>(write: () => Promise<T>): Promise<T> {
const run = customKeysWriteTurn.then(write)
customKeysWriteTurn = run.catch(() => {})
return run
}
export function saveCustomKeys(keys: CustomKey[]): Promise<void> {
return inCustomKeysWriteTurn(() => writeCustomKeys(keys))
}
function updateStoredCustomKeys(change: (keys: CustomKey[]) => CustomKey[]): Promise<CustomKey[]> {
return inCustomKeysWriteTurn(async () => {
const updated = change(await readStoredCustomKeys())
await writeCustomKeys(updated)
return updated
})
}
// Like addKey: resolves with the stored list only once the store took it, so a refused write
// never drops a row from screen that the next load would bring back.
export function removeCustomKey(id: string): Promise<CustomKey[]> {
return updateStoredCustomKeys((keys) => keys.filter((key) => key.id !== id))
}
export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortcuts }: Props) {
@@ -107,9 +136,8 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
const addKey = useCallback(
async (key: Omit<CustomKey, 'id'>) => {
const existing = await loadCustomKeys()
const newKey: CustomKey = { ...key, id: `custom-${Date.now()}` }
const updated = [...existing, newKey]
let updated: CustomKey[]
// Caught here because both callers are `void addKey(...)`, which leaves a rejection nowhere
// to go. On the page this key is allowlisted and its write rejects for size — the contract
// `page-async-storage` states, and the one ruling 33.6 extends to a key `init` could not
@@ -117,7 +145,7 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
// handler, which reports a page fault and drops the generation for a key nobody could add.
// Every other allowlisted writer in this closure already catches its own save.
try {
await saveCustomKeys(updated)
updated = await updateStoredCustomKeys((keys) => [...keys, newKey])
} catch (error) {
// Neither reported nor closed: a drawer that dismissed itself and announced the key would
// put a row on the accessory bar that no store holds and the next load would not have.
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
AppState,
View,
@@ -14,6 +14,7 @@ import type Animated from 'react-native-reanimated'
import type { AnimatedRef, SharedValue } from 'react-native-reanimated'
import { CustomKeyModal, loadCustomKeys, saveCustomKeys, type CustomKey } from './CustomKeyModal'
import { DragReorderList } from './DragReorderList'
import { useSerializedStoredState } from '../storage/use-serialized-stored-state'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import {
TERMINAL_ACCESSORY_KEYS,
@@ -59,6 +60,11 @@ function ShortcutBarRow({
)
}
async function loadShortcutLayout(): Promise<TerminalAccessoryLayout> {
const { orderedBuiltInIds, visibleBuiltInIds } = await loadTerminalAccessoryLayout()
return { orderedBuiltInIds, visibleBuiltInIds }
}
type Props = {
scrollRef: AnimatedRef<Animated.ScrollView>
scrollOffsetY: SharedValue<number>
@@ -72,67 +78,24 @@ export function TerminalShortcutSettings({
scrollContentHeight,
onDragActiveChange
}: Props): React.JSX.Element {
const [customKeys, setCustomKeys] = useState<CustomKey[]>([])
const {
value: customKeys,
setValue: setCustomKeys,
persist: persistCustomKeys,
refresh: refreshCustomKeys,
adoptStored: adoptStoredCustomKeys
} = useSerializedStoredState<CustomKey[]>([], loadCustomKeys, saveCustomKeys)
const [showCustomKeyModal, setShowCustomKeyModal] = useState(false)
const [shortcutLayout, setShortcutLayout] = useState<TerminalAccessoryLayout>(
getDefaultTerminalAccessoryLayout
const {
value: shortcutLayout,
setValue: setShortcutLayout,
persist: persistLayout,
refresh: refreshShortcutLayout
} = useSerializedStoredState(
getDefaultTerminalAccessoryLayout,
loadShortcutLayout,
saveTerminalAccessoryLayout
)
const layoutWriteChainRef = useRef<Promise<void>>(Promise.resolve())
const layoutWriteSeqRef = useRef(0)
const pendingLayoutWritesRef = useRef(0)
const persistLayout = useCallback((next: TerminalAccessoryLayout) => {
layoutWriteSeqRef.current += 1
pendingLayoutWritesRef.current += 1
layoutWriteChainRef.current = layoutWriteChainRef.current
.catch(() => {})
.then(() => saveTerminalAccessoryLayout(next))
.catch(() => {})
.finally(() => {
pendingLayoutWritesRef.current -= 1
})
}, [])
const refreshShortcutLayout = useCallback(() => {
const refreshSeq = layoutWriteSeqRef.current
void loadTerminalAccessoryLayout().then((layout) => {
if (pendingLayoutWritesRef.current > 0 || refreshSeq !== layoutWriteSeqRef.current) {
return
}
setShortcutLayout({
orderedBuiltInIds: layout.orderedBuiltInIds,
visibleBuiltInIds: layout.visibleBuiltInIds
})
})
}, [])
const customKeysWriteChainRef = useRef<Promise<void>>(Promise.resolve())
const customKeysWriteSeqRef = useRef(0)
const pendingCustomKeysWritesRef = useRef(0)
// Why: same stale-snapshot guard as persistLayout — a focus/AppState refresh
// racing an in-flight save must not overwrite the optimistic state.
const persistCustomKeys = useCallback((next: CustomKey[]) => {
customKeysWriteSeqRef.current += 1
pendingCustomKeysWritesRef.current += 1
customKeysWriteChainRef.current = customKeysWriteChainRef.current
.catch(() => {})
.then(() => saveCustomKeys(next))
.catch(() => {})
.finally(() => {
pendingCustomKeysWritesRef.current -= 1
})
}, [])
const refreshCustomKeys = useCallback(() => {
const refreshSeq = customKeysWriteSeqRef.current
void loadCustomKeys().then((keys) => {
if (pendingCustomKeysWritesRef.current > 0 || refreshSeq !== customKeysWriteSeqRef.current) {
return
}
setCustomKeys(keys)
})
}, [])
const handleDeleteCustomKey = useCallback(
(key: CustomKey) => {
@@ -294,6 +257,9 @@ export function TerminalShortcutSettings({
pressed && styles.deleteButtonPressed
]}
onPress={() => handleDeleteCustomKey(key)}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={`Remove ${key.label}`}
>
<X size={16} color={colors.statusRed} />
</Pressable>
@@ -316,12 +282,7 @@ export function TerminalShortcutSettings({
<CustomKeyModal
visible={showCustomKeyModal}
onClose={() => setShowCustomKeyModal(false)}
onKeysChanged={(keys) => {
// Why: the modal already persisted this list; bumping the sequence
// discards refreshes that read storage before its save landed.
customKeysWriteSeqRef.current += 1
setCustomKeys(keys)
}}
onKeysChanged={adoptStoredCustomKeys}
/>
</>
)
@@ -4,19 +4,33 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const store = vi.hoisted(() => {
// Annotated rather than asserted: the literal alone narrows `raw` to `null`.
const held: { raw: string | null; refuse: boolean } = { raw: null, refuse: false }
const held: {
raw: string | null
refuse: boolean
failRead: boolean
writes: number
holdWrites: Promise<void> | null
} = { raw: null, refuse: false, failRead: false, writes: 0, holdWrites: null }
return held
})
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: async () => store.raw,
setItem: async () => {
getItem: async () => {
if (store.failRead) {
throw new Error('the store could not be read')
}
return store.raw
},
setItem: async (_key: string, value: string) => {
await store.holdWrites
store.writes += 1
if (store.refuse) {
throw new Error(
'Orca could not save orca:custom-accessory-keys: a stored value may be too large.'
)
}
store.raw = value
}
}
}))
@@ -52,7 +66,7 @@ vi.mock('react-native', () => ({
vi.mock('lucide-react-native', () => ({ ChevronLeft: hosts.View }))
vi.mock('./BottomDrawer', () => ({ BottomDrawer: hosts.View }))
import { CustomKeyModal } from './CustomKeyModal'
import { CustomKeyModal, removeCustomKey, saveCustomKeys } from './CustomKeyModal'
import { readMirroredStorage } from '../storage/mirrored-storage-keys'
const CUSTOM_KEYS = 'orca:custom-accessory-keys'
@@ -110,6 +124,9 @@ function addAShortcut(renderer: ReturnType<typeof create>): void {
beforeEach(() => {
store.raw = null
store.refuse = false
store.failRead = false
store.writes = 0
store.holdWrites = null
})
/**
@@ -174,3 +191,71 @@ describe('adding a custom key when the store refuses the write', () => {
expect(onClose).toHaveBeenCalledTimes(1)
})
})
describe('removing a custom key', () => {
const kept = { id: 'custom-1', label: 'Ctrl+C', bytes: '\u0003', enter: false }
const removed = { id: 'custom-2', label: 'Ctrl+D', bytes: '\u0004', enter: false }
it('rejects when the store refuses, so a caller never drops the row', async () => {
store.raw = JSON.stringify([kept, removed])
store.refuse = true
await expect(removeCustomKey(removed.id)).rejects.toThrow()
})
it('answers the stored list without the key once the store takes it', async () => {
store.raw = JSON.stringify([kept, removed])
await expect(removeCustomKey(removed.id)).resolves.toEqual([kept])
})
it('lets two removals in flight together both land', async () => {
const third = { id: 'custom-3', label: 'Ctrl+Z', bytes: '\u001a', enter: false }
store.raw = JSON.stringify([kept, removed, third])
await Promise.all([removeCustomKey(kept.id), removeCustomKey(removed.id)])
expect(JSON.parse(store.raw ?? '[]')).toEqual([third])
})
it('removes from the list a settings save in flight stored, not the list before it', async () => {
const third = { id: 'custom-3', label: 'Ctrl+Z', bytes: '\u001a', enter: false }
store.raw = JSON.stringify([kept, removed, third])
let releaseWrites: () => void = () => {}
store.holdWrites = new Promise((resolve) => {
releaseWrites = resolve
})
const reorder = saveCustomKeys([third, removed, kept])
const removal = removeCustomKey(removed.id)
releaseWrites()
await Promise.all([reorder, removal])
expect(JSON.parse(store.raw ?? '[]')).toEqual([third, kept])
})
it('writes nothing when the stored list cannot be read, so a read failure erases nothing', async () => {
store.raw = JSON.stringify([kept, removed])
store.failRead = true
await expect(removeCustomKey(removed.id)).rejects.toThrow()
expect(store.writes).toBe(0)
})
})
describe('adding a custom key when the stored list cannot be read', () => {
it('writes nothing and keeps the drawer open', async () => {
store.failRead = true
vi.spyOn(console, 'warn').mockImplementation(() => {})
const onKeysChanged = vi.fn()
const onClose = vi.fn()
let renderer: ReturnType<typeof create> | null = null
act(() => {
renderer = create(createElement(CustomKeyModal, { visible: true, onClose, onKeysChanged }))
})
if (renderer === null) {
throw new Error('the modal did not render')
}
addAShortcut(renderer)
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
expect(store.writes).toBe(0)
expect(onKeysChanged).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,163 @@
import { createElement, useEffect, type ReactNode } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { CustomKey } from './CustomKeyModal'
const CUSTOM_KEYS = 'orca:custom-accessory-keys'
const store = vi.hoisted(() => {
const held: {
values: Map<string, string>
holdWrites: Promise<void> | null
refuseWrites: boolean
} = { values: new Map(), holdWrites: null, refuseWrites: false }
return held
})
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: async (key: string) => store.values.get(key) ?? null,
setItem: async (key: string, value: string) => {
await store.holdWrites
if (store.refuseWrites) {
throw new Error('the store refused the write')
}
store.values.set(key, value)
}
}
}))
const hosts = vi.hoisted(() => {
const make = (name: string) => {
const Host = (props: { children?: ReactNode }): ReactNode => props.children ?? null
Host.displayName = name
return Host
}
return { View: make('View'), Text: make('Text'), Pressable: make('Pressable') }
})
vi.mock('react-native', () => ({
AppState: { addEventListener: () => ({ remove: () => {} }) },
View: hosts.View,
Text: hosts.Text,
Pressable: hosts.Pressable,
Switch: hosts.View,
StyleSheet: { create: <T,>(styles: T) => styles, absoluteFillObject: {} },
Platform: { OS: 'ios', select: (options: Record<string, unknown>) => options.ios }
}))
vi.mock('expo-router', () => ({
useFocusEffect: (effect: () => void) => useEffect(effect, [effect])
}))
vi.mock('lucide-react-native', () => ({
ChevronLeft: hosts.View,
ChevronRight: hosts.View,
X: hosts.View
}))
vi.mock('./BottomDrawer', () => ({ BottomDrawer: hosts.View }))
const lists = vi.hoisted(() => {
const held: { custom: { items: CustomKey[]; onReorder: (ids: string[]) => void } | null } = {
custom: null
}
return held
})
vi.mock('./DragReorderList', () => ({
DragReorderList: (props: {
items: { id: string }[]
renderRow: (item: { id: string }) => ReactNode
onReorder: (ids: string[]) => void
}): ReactNode => {
if (props.items.some((item) => item.id.startsWith('custom-'))) {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the custom-key list is the only one whose ids start with `custom-`.
lists.custom = props as unknown as { items: CustomKey[]; onReorder: (ids: string[]) => void }
}
return props.items.map((item) => props.renderRow(item))
}
}))
import { TerminalShortcutSettings } from './TerminalShortcutSettings'
const keys: CustomKey[] = ['a', 'b', 'c'].map((name) => ({
id: `custom-${name}`,
label: name,
bytes: name,
enter: false
}))
function stored(): string[] {
const raw = store.values.get(CUSTOM_KEYS)
const parsed: CustomKey[] = raw ? JSON.parse(raw) : []
return parsed.map((key) => key.id)
}
function shown(): string[] {
return lists.custom?.items.map((key) => key.id) ?? []
}
let renderer: ReactTestRenderer | null = null
async function mount(): Promise<void> {
await act(async () => {
renderer = create(
createElement(TerminalShortcutSettings, {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocked reorder list never reads the scroll bindings.
scrollRef: {} as never,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above.
scrollOffsetY: {} as never,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above.
scrollContentHeight: {} as never,
onDragActiveChange: () => {}
})
)
})
}
function pressRemove(label: string): void {
const button = renderer?.root.find(
(node) => node.type === hosts.Pressable && node.props.accessibilityLabel === `Remove ${label}`
)
act(() => button?.props.onPress())
}
async function settle(): Promise<void> {
await act(async () => {
for (let turn = 0; turn < 10; turn += 1) {
await Promise.resolve()
}
})
}
beforeEach(() => {
store.values = new Map([[CUSTOM_KEYS, JSON.stringify(keys)]])
store.holdWrites = null
store.refuseWrites = false
lists.custom = null
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
describe('custom shortcuts in terminal settings', () => {
it('keeps a key removed when the list is reordered before the removal is saved', async () => {
await mount()
let releaseWrites: () => void = () => {}
store.holdWrites = new Promise((resolve) => {
releaseWrites = resolve
})
pressRemove('b')
act(() => lists.custom?.onReorder(['custom-c', 'custom-a']))
releaseWrites()
await settle()
expect(shown()).toEqual(['custom-c', 'custom-a'])
expect(stored()).toEqual(['custom-c', 'custom-a'])
})
it('shows the key again when the store refuses its removal', async () => {
await mount()
store.refuseWrites = true
pressRemove('b')
expect(shown()).toEqual(['custom-a', 'custom-c'])
await settle()
expect(shown()).toEqual(['custom-a', 'custom-b', 'custom-c'])
expect(stored()).toEqual(['custom-a', 'custom-b', 'custom-c'])
})
})
+3
View File
@@ -15,6 +15,9 @@ export function MobileHomeTopBar({ onOpenSettings }: { onOpenSettings: () => voi
<Pressable
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
onPress={onOpenSettings}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Settings"
>
<Settings size={18} color={colors.textSecondary} />
</Pressable>
@@ -23,6 +23,7 @@ export function MobileNativeChatFloatingControls({
<Pressable
accessibilityRole="button"
accessibilityLabel={toolsExpanded ? 'Collapse tool runs' : 'Expand tool runs'}
accessibilityState={{ expanded: toolsExpanded }}
style={({ pressed }) => [styles.toolsPill, pressed && styles.pressed]}
onPress={onToggleTools}
>
@@ -4,14 +4,21 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SoftKeyboardState } from '../platform/keyboard-occlusion'
import type { RouteHandoff } from '../navigation/route-handoff'
import type { CustomKey } from '../components/CustomKeyModal'
const harness = vi.hoisted(() => {
const keyboard: SoftKeyboardState = { height: 0, visible: false }
const deleteCustomKey: { current: ((key: CustomKey) => Promise<void>) | null } = {
current: null
}
return {
keyboard,
deleteCustomKey,
notifyKeyboardVisibility: vi.fn<(visible: boolean) => void>(),
notifyTerminalFrameHeight: vi.fn<(height: number) => void>(),
setKeyboardHeight: vi.fn<Dispatch<SetStateAction<number>>>()
setKeyboardHeight: vi.fn<Dispatch<SetStateAction<number>>>(),
setCustomKeys: vi.fn<Dispatch<SetStateAction<CustomKey[]>>>(),
removeCustomKey: vi.fn<(id: string) => Promise<CustomKey[]>>()
}
})
@@ -26,7 +33,7 @@ vi.mock('../terminal/terminal-viewport-refit', () => ({
notifyTerminalFrameHeight: harness.notifyTerminalFrameHeight
})
}))
vi.mock('../components/CustomKeyModal', () => ({ saveCustomKeys: () => Promise.resolve() }))
vi.mock('../components/CustomKeyModal', () => ({ removeCustomKey: harness.removeCustomKey }))
vi.mock('../worktree/last-visited-worktree-repo', () => ({ writeLastVisitedWorktree: () => {} }))
import { useMobileSessionKeyboardState } from './use-mobile-session-keyboard-state'
@@ -49,7 +56,7 @@ const router: RouteHandoff = {
}
function Harness(): null {
useMobileSessionKeyboardState({
const { handleDeleteCustomKey } = useMobileSessionKeyboardState({
hostId: 'host-1',
worktreeId: 'wt-1',
router,
@@ -62,8 +69,7 @@ function Harness(): null {
tabStripOffsetRef: { current: 0 },
tabStripViewportWidthRef: { current: 0 },
tabStripContentWidthRef: { current: 0 },
customKeys: [],
setCustomKeys: () => {},
setCustomKeys: harness.setCustomKeys,
setShowCustomKeyModal: () => {},
setKeyboardHeight: harness.setKeyboardHeight,
// Read only by the viewport refit, which is mocked above; present because the scope names them.
@@ -78,6 +84,7 @@ function Harness(): null {
viewportMeasuredRef: { current: false },
viewportRef: { current: null }
})
harness.deleteCustomKey.current = handleDeleteCustomKey
return null
}
@@ -130,3 +137,32 @@ describe('what the session screen hears about the keyboard', () => {
expect(harness.setKeyboardHeight.mock.calls.every(([height]) => height === 0)).toBe(true)
})
})
describe('removing a custom shortcut from the session sheet', () => {
const key: CustomKey = { id: 'custom-1', label: 'Ctrl+C', bytes: '\u0003', enter: false }
beforeEach(() => {
harness.setCustomKeys.mockClear()
harness.removeCustomKey.mockReset()
})
it('keeps the key on screen when the store refuses the removal', async () => {
harness.removeCustomKey.mockRejectedValue(new Error('refused'))
vi.spyOn(console, 'warn').mockImplementation(() => {})
await mount()
await act(async () => {
await harness.deleteCustomKey.current?.(key)
})
expect(harness.setCustomKeys).not.toHaveBeenCalled()
})
it('shows the list the store took once the removal lands', async () => {
harness.removeCustomKey.mockResolvedValue([])
await mount()
await act(async () => {
await harness.deleteCustomKey.current?.(key)
})
expect(harness.removeCustomKey).toHaveBeenCalledWith('custom-1')
expect(harness.setCustomKeys).toHaveBeenCalledWith([])
})
})
@@ -1,7 +1,7 @@
import { useEffect, useCallback } from 'react'
import { useSoftKeyboard } from '../platform/keyboard-occlusion'
import { useTerminalViewportRefit } from '../terminal/terminal-viewport-refit'
import { saveCustomKeys, type CustomKey } from '../components/CustomKeyModal'
import { removeCustomKey, type CustomKey } from '../components/CustomKeyModal'
import { writeLastVisitedWorktree } from '../worktree/last-visited-worktree-repo'
import { resolveTabStripScrollOffset } from './tab-strip-scroll'
import type { MobileSessionLifecycleModel } from './use-mobile-session-lifecycle'
@@ -16,7 +16,6 @@ export type MobileSessionKeyboardScope = Pick<
| 'activeSessionTabId'
| 'clientRef'
| 'connState'
| 'customKeys'
| 'deviceTokenRef'
| 'hostId'
| 'router'
@@ -54,7 +53,6 @@ export function useMobileSessionKeyboardState(scope: MobileSessionKeyboardScope)
tabStripViewportWidthRef,
tabStripContentWidthRef,
tabLayoutsRef,
customKeys,
setCustomKeys,
setShowCustomKeyModal,
setKeyboardHeight,
@@ -135,14 +133,13 @@ export function useMobileSessionKeyboardState(scope: MobileSessionKeyboardScope)
}
}, [hostId, worktreeId])
const handleDeleteCustomKey = useCallback(
async (key: CustomKey) => {
const updated = customKeys.filter((k) => k.id !== key.id)
setCustomKeys(updated)
await saveCustomKeys(updated)
},
[customKeys]
)
const handleDeleteCustomKey = useCallback(async (key: CustomKey) => {
try {
setCustomKeys(await removeCustomKey(key.id))
} catch (error) {
console.warn('[custom-keys] the store would not take this removal', error)
}
}, [])
const handleManageShortcuts = useCallback(() => {
setShowCustomKeyModal(false)
@@ -365,3 +365,24 @@ describe('committed branch files during commit-message typing', () => {
expect(format).not.toHaveBeenCalled()
})
})
describe('AI commit message button', () => {
function generateButton() {
return renderer?.root.find(
(node) =>
String(node.type) === 'Pressable' &&
(node.props.accessibilityLabel === 'Generate commit message with AI' ||
node.props.accessibilityLabel === 'Cancel commit message generation')
)
}
it('matches desktop: a typed message blocks regenerating, and cancel stays available', () => {
render()
expect(generateButton()?.props.disabled).toBe(false)
patch({ commitMessage: 'my own message' })
expect(generateButton()?.props.disabled).toBe(true)
expect(generateButton()?.props.accessibilityHint).toBe('Clear the message to regenerate.')
patch({ generatingMessage: true })
expect(generateButton()?.props.disabled).toBe(false)
})
})
@@ -52,6 +52,9 @@ export function MobileSourceControlContent({ state }: Props) {
// Why: a capped listing can hide unstaged rows behind an all-staged prefix.
const canStageAll = stageablePaths.length > 0 || status?.didHitLimit === true
const shouldShowGenerateButton = stagedCount > 0 || generatingMessage
// Why: as on desktop, a typed message blocks regenerating; while generating the button cancels.
const generateBlockedByMessage = !generatingMessage && commitMessage.trim().length > 0
const generateDisabled = busyAction !== null || generateBlockedByMessage
const createPrHeroActive =
createPrAction.visible && !createPrAction.disabled && !createPrAction.pushFirst
const fileList = useMemo(
@@ -209,12 +212,16 @@ export function MobileSourceControlContent({ state }: Props) {
<Pressable
style={({ pressed }) => [
styles.generateButton,
busyAction !== null && styles.commitButtonDisabled,
generateDisabled && styles.commitButtonDisabled,
pressed && styles.commitButtonPressed
]}
// Why: commit-message AI belongs to the commit path; hiding it
// during Stage All keeps the quick action visually unambiguous.
disabled={busyAction !== null}
disabled={generateDisabled}
accessibilityState={{ disabled: generateDisabled }}
accessibilityHint={
generateBlockedByMessage ? 'Clear the message to regenerate.' : undefined
}
onPress={() =>
generatingMessage ? cancelGenerateCommitMessage() : void generateCommitMessage()
}
@@ -0,0 +1,123 @@
import { createElement, useRef, useState } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { MobileGenerateCommitMessageResult } from './mobile-commit-message-ai'
// Why: react-native is Flow source vitest will not parse; haptics reaches expo-haptics through it.
vi.mock('react-native', () => ({
Platform: { OS: 'ios', select: (options: Record<string, unknown>) => options.ios },
StyleSheet: { create: (value: unknown) => value, hairlineWidth: 1 }
}))
vi.mock('expo-router', () => ({
useRouter: () => ({
back: () => undefined,
push: () => undefined,
replace: () => undefined,
navigate: () => undefined,
dismissTo: () => undefined,
prefetch: () => undefined,
canGoBack: () => false,
setParams: () => undefined
})
}))
vi.mock('../platform/haptics', () => ({ triggerError: vi.fn(), triggerSuccess: vi.fn() }))
const ai = vi.hoisted(() => {
const held: {
resolve: ((result: MobileGenerateCommitMessageResult) => void) | null
cancel: Mock<() => void>
} = { resolve: null, cancel: vi.fn<() => void>() }
return held
})
vi.mock('./mobile-commit-message-ai', () => ({
requestMobileCommitMessage: () =>
new Promise<MobileGenerateCommitMessageResult>((resolve) => {
ai.resolve = resolve
}),
cancelMobileCommitMessage: async () => ai.cancel()
}))
import { useRouteHandoff } from '../navigation/route-handoff'
import { useMobileSourceControlRunners } from './use-mobile-source-control-runners'
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocked AI module and the injected senders are the only things that would reach the client.
const client = {} as RpcClient
type Runners = ReturnType<typeof useMobileSourceControlRunners>
const view: {
message: string
type: ((next: string) => void) | null
runners: Runners | null
} = { message: '', type: null, runners: null }
function Harness(): null {
const router = useRouteHandoff()
const [commitMessage, setCommitMessage] = useState('')
const [generatingMessage, setGeneratingMessage] = useState(false)
const mountedRef = useRef(true)
const busyActionRef = useRef<string | null>(null)
view.message = commitMessage
view.type = setCommitMessage
view.runners = useMobileSourceControlRunners({
client,
hostId: 'host-a',
worktreeId: 'wt-1',
status: null,
branchLabel: 'main',
commitMessage,
stagedEntries: [],
generatingMessage,
stageablePaths: [],
unstageablePaths: [],
router,
sendGitRequest: vi.fn(),
sendCommitRequest: vi.fn().mockResolvedValue({ success: true }),
runGitSyncSteps: vi.fn(),
loadStatus: vi.fn().mockResolvedValue(true),
mountedRef,
busyActionRef,
setBusyAction: vi.fn(),
setActionError: vi.fn(),
setCommitMessage,
setGeneratingMessage,
setShowActionSheet: vi.fn(),
setLocalBranches: vi.fn(),
setShowBranchPicker: vi.fn(),
setCreatedPrUrl: vi.fn(),
setCreatedPrWarning: vi.fn(),
recordCommitFailure: vi.fn()
})
return null
}
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
ai.resolve = null
ai.cancel.mockClear()
})
describe('committing while the AI is still writing a message', () => {
it('cancels the generation so its late reply does not refill the emptied box', async () => {
act(() => {
renderer = create(createElement(Harness))
})
let generating: Promise<void> | undefined
act(() => {
generating = view.runners?.generateCommitMessage()
})
act(() => view.type?.('fix: my own words'))
await act(async () => {
await view.runners?.commit()
})
expect(ai.cancel).toHaveBeenCalledTimes(1)
expect(view.message).toBe('')
await act(async () => {
ai.resolve?.({ success: true, message: 'feat: generated for the old diff' })
await generating
})
expect(view.message).toBe('')
})
})
@@ -0,0 +1,120 @@
import { createElement, useRef, useState } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { MobileGenerateCommitMessageResult } from './mobile-commit-message-ai'
vi.mock('../platform/haptics', () => ({ triggerError: vi.fn(), triggerSuccess: vi.fn() }))
const reply = vi.hoisted(() => {
const held: {
resolve: ((result: MobileGenerateCommitMessageResult) => void) | null
reject: ((error: Error) => void) | null
cancel: () => Promise<void>
} = { resolve: null, reject: null, cancel: async () => {} }
return held
})
vi.mock('./mobile-commit-message-ai', () => ({
requestMobileCommitMessage: () =>
new Promise<MobileGenerateCommitMessageResult>((resolve, reject) => {
reply.resolve = resolve
reply.reject = reject
}),
cancelMobileCommitMessage: () => reply.cancel()
}))
import { useMobileCommitMessageGeneration } from './use-mobile-commit-message-generation'
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocked request module never reaches the client.
const client = {} as RpcClient
const view: {
message: string
error: string | null
type: ((next: string) => void) | null
generate: (() => Promise<void>) | null
cancel: (() => void) | null
} = { message: '', error: null, type: null, generate: null, cancel: null }
function Harness(): null {
const [message, setMessage] = useState('')
const [generating, setGenerating] = useState(false)
const [error, setError] = useState<string | null>(null)
const mountedRef = useRef(true)
const busyActionRef = useRef<string | null>(null)
const { generateCommitMessage, cancelGenerateCommitMessage } = useMobileCommitMessageGeneration({
client,
worktreeId: 'wt-1',
generatingMessage: generating,
mountedRef,
busyActionRef,
setGeneratingMessage: setGenerating,
setCommitMessage: setMessage,
setActionError: setError
})
view.message = message
view.error = error
view.cancel = cancelGenerateCommitMessage
view.type = setMessage
view.generate = generateCommitMessage
return null
}
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
reply.resolve = null
reply.reject = null
reply.cancel = async () => {}
})
async function generateThen(typed: string | null): Promise<void> {
act(() => {
renderer = create(createElement(Harness))
})
let pending: Promise<void> | undefined
act(() => {
pending = view.generate?.()
})
if (typed !== null) {
act(() => view.type?.(typed))
}
await act(async () => {
reply.resolve?.({ success: true, message: 'feat: generated' })
await pending
})
}
describe('useMobileCommitMessageGeneration', () => {
it('keeps what the user typed while the agent ran, as desktop does', async () => {
await generateThen('fix: my own words')
expect(view.message).toBe('fix: my own words')
})
it('fills an empty box with the generated message', async () => {
await generateThen(null)
expect(view.message).toBe('feat: generated')
})
it('shows no error from a run that was stopped and then failed', async () => {
reply.cancel = () => Promise.reject(new Error('transport closed'))
const unhandled = vi.fn()
process.on('unhandledRejection', unhandled)
act(() => {
renderer = create(createElement(Harness))
})
let pending: Promise<void> | undefined
act(() => {
pending = view.generate?.()
})
act(() => view.cancel?.())
await act(async () => {
reply.reject?.(new Error('transport closed'))
await pending
await new Promise((resolve) => setTimeout(resolve, 0))
})
process.off('unhandledRejection', unhandled)
expect(view.error).toBeNull()
expect(unhandled).not.toHaveBeenCalled()
})
})
@@ -1,4 +1,10 @@
import { useCallback, type MutableRefObject } from 'react'
import {
useCallback,
useRef,
type Dispatch,
type MutableRefObject,
type SetStateAction
} from 'react'
import type { RpcClient } from '../transport/rpc-client'
import { triggerError, triggerSuccess } from '../platform/haptics'
import { cancelMobileCommitMessage, requestMobileCommitMessage } from './mobile-commit-message-ai'
@@ -10,7 +16,7 @@ type Params = {
mountedRef: MutableRefObject<boolean>
busyActionRef: MutableRefObject<string | null>
setGeneratingMessage: (next: boolean) => void
setCommitMessage: (next: string) => void
setCommitMessage: Dispatch<SetStateAction<string>>
setActionError: (next: string | null) => void
}
@@ -27,6 +33,7 @@ export function useMobileCommitMessageGeneration(params: Params) {
setCommitMessage,
setActionError
} = params
const runRef = useRef(0)
// AI-generate a commit message from the staged diff. Matches desktop: the
// button is always available; a missing model surfaces as a toast.
@@ -34,15 +41,18 @@ export function useMobileCommitMessageGeneration(params: Params) {
if (!client || generatingMessage || busyActionRef.current) {
return
}
runRef.current += 1
const run = runRef.current
setGeneratingMessage(true)
setActionError(null)
try {
const result = await requestMobileCommitMessage(client, worktreeId)
if (!mountedRef.current) {
if (!mountedRef.current || run !== runRef.current) {
return
}
if (result.success) {
setCommitMessage(result.message)
// Why: same rule as desktop — text typed while the agent ran wins over the generated message.
setCommitMessage((current) => (current.length > 0 ? current : result.message))
triggerSuccess()
} else if (!result.canceled) {
triggerError()
@@ -51,7 +61,7 @@ export function useMobileCommitMessageGeneration(params: Params) {
} catch (err) {
// Why: a transport drop rejects the RPC; without this the error haptic +
// message are skipped and the rejection escapes the void-called handler.
if (mountedRef.current) {
if (mountedRef.current && run === runRef.current) {
triggerError()
setActionError(err instanceof Error ? err.message : 'Failed to generate commit message')
}
@@ -72,10 +82,15 @@ export function useMobileCommitMessageGeneration(params: Params) {
])
const cancelGenerateCommitMessage = useCallback(() => {
if (client) {
void cancelMobileCommitMessage(client, worktreeId)
if (!client || !generatingMessage) {
return
}
}, [client, worktreeId])
// Why: like desktop's canceled record — a run the host still finishes must not fill the box.
runRef.current += 1
// Why: commit and Create PR call this without awaiting; a dropped transport must not surface
// as an unhandled rejection, and the run's own reply still clears the spinner.
cancelMobileCommitMessage(client, worktreeId).catch(() => {})
}, [client, generatingMessage, worktreeId])
return { generateCommitMessage, cancelGenerateCommitMessage }
}
@@ -31,6 +31,7 @@ type Params = {
mountedRef: MutableRefObject<boolean>
runGitWorkflow: RunGitWorkflow
loadStatus: LoadStatus
cancelGenerateCommitMessage: () => void
setActionError: (next: string | null) => void
setCommitMessage: (next: string) => void
setShowActionSheet: (next: boolean) => void
@@ -49,6 +50,7 @@ export function useMobileCreatePrRunner({
mountedRef,
runGitWorkflow,
loadStatus,
cancelGenerateCommitMessage,
setActionError,
setCommitMessage,
setShowActionSheet,
@@ -70,6 +72,8 @@ export function useMobileCreatePrRunner({
}
let progress: MobileHostedReviewCreateIntentProgress | null = null
const ran = await runGitWorkflow(pushFirst ? 'push-create-pr' : 'create-pr', async () => {
// Why: this run commits the box's message, so it supersedes a running generation too.
cancelGenerateCommitMessage()
created.current = await runMobileHostedReviewCreateIntent(client, worktreeId, {
branch,
title: branchLabel,
@@ -112,6 +116,7 @@ export function useMobileCreatePrRunner({
},
[
branchLabel,
cancelGenerateCommitMessage,
client,
commitMessage,
loadStatus,
@@ -19,6 +19,7 @@ type Params = {
stagedEntries: MobileCommitFailureRecovery['stagedEntries']
sendGitRequest: SendGitRequest
sendCommitRequest: (message: string) => Promise<unknown>
cancelGenerateCommitMessage: () => void
runGitSyncSteps: () => Promise<void>
runGitWorkflow: RunGitWorkflow
loadStatus: (options?: LoadStatusOptions) => Promise<boolean>
@@ -37,7 +38,8 @@ export function useMobileSourceControlCommitRunners(params: Params) {
commitMessage,
stagedEntries,
sendGitRequest,
sendCommitRequest,
sendCommitRequest: sendCommitRequestOnly,
cancelGenerateCommitMessage,
runGitSyncSteps,
runGitWorkflow,
loadStatus,
@@ -48,6 +50,15 @@ export function useMobileSourceControlCommitRunners(params: Params) {
setCommitMessage,
recordCommitFailure
} = params
// Why: as on desktop, committing the box's message cancels a running generation so its late
// reply cannot refill the box the commit empties.
const sendCommitRequest = useCallback(
(message: string) => {
cancelGenerateCommitMessage()
return sendCommitRequestOnly(message)
},
[cancelGenerateCommitMessage, sendCommitRequestOnly]
)
const commit = useCallback(async () => {
const message = commitMessage.trim()
@@ -1,4 +1,4 @@
import { useCallback, type MutableRefObject } from 'react'
import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
import type { RouteHandoff } from '../navigation/route-handoff'
import type { RpcClient } from '../transport/rpc-client'
import { triggerError, triggerSuccess } from '../platform/haptics'
@@ -38,7 +38,7 @@ type Params = {
busyActionRef: MutableRefObject<string | null>
setBusyAction: (next: string | null) => void
setActionError: (next: string | null) => void
setCommitMessage: (next: string) => void
setCommitMessage: Dispatch<SetStateAction<string>>
setGeneratingMessage: (next: boolean) => void
setShowActionSheet: (next: boolean) => void
setLocalBranches: (next: RuntimeGitLocalBranches | null) => void
@@ -179,22 +179,6 @@ export function useMobileSourceControlRunners(params: Params) {
await runGitAction('unstage-all', 'git.bulkUnstage', { filePaths: unstageablePaths })
}, [runGitAction, unstageablePaths])
const { commit, runCommitSequence, runCommitSyncSequence } = useMobileSourceControlCommitRunners({
commitMessage,
stagedEntries,
sendGitRequest,
sendCommitRequest,
runGitSyncSteps,
runGitWorkflow,
loadStatus,
mountedRef,
busyActionRef,
setBusyAction,
setActionError,
setCommitMessage,
recordCommitFailure
})
const { generateCommitMessage, cancelGenerateCommitMessage } = useMobileCommitMessageGeneration({
client,
worktreeId,
@@ -206,6 +190,23 @@ export function useMobileSourceControlRunners(params: Params) {
setActionError
})
const { commit, runCommitSequence, runCommitSyncSequence } = useMobileSourceControlCommitRunners({
commitMessage,
stagedEntries,
sendGitRequest,
sendCommitRequest,
cancelGenerateCommitMessage,
runGitSyncSteps,
runGitWorkflow,
loadStatus,
mountedRef,
busyActionRef,
setBusyAction,
setActionError,
setCommitMessage,
recordCommitFailure
})
const createPr = useMobileCreatePrRunner({
client,
worktreeId,
@@ -216,6 +217,7 @@ export function useMobileSourceControlRunners(params: Params) {
mountedRef,
runGitWorkflow,
loadStatus,
cancelGenerateCommitMessage,
setActionError,
setCommitMessage,
setShowActionSheet,
@@ -0,0 +1,69 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useSerializedStoredState } from './use-serialized-stored-state'
type Hook = ReturnType<typeof useSerializedStoredState<string>>
let latest: Hook | null = null
let renderer: ReactTestRenderer | null = null
function mount(load: () => Promise<string>, save: (value: string) => Promise<void>): void {
function Harness(): null {
latest = useSerializedStoredState('a', load, save)
return null
}
act(() => {
renderer = create(createElement(Harness))
})
}
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
latest = null
})
describe('useSerializedStoredState', () => {
it('shows the stored value again when a save is refused', async () => {
mount(
async () => 'a',
async () => {
throw new Error('refused')
}
)
await act(async () => {
latest?.setValue('b')
latest?.persist('b')
})
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(latest?.value).toBe('a')
})
it('leaves a later queued write in charge when an earlier save is refused', async () => {
const load = vi.fn(async () => 'a')
let landLater: () => void = () => {}
const save = vi.fn((value: string) =>
value === 'b'
? Promise.reject(new Error('refused'))
: new Promise<void>((resolve) => {
landLater = resolve
})
)
mount(load, save)
await act(async () => {
latest?.persist('b')
latest?.setValue('c')
latest?.persist('c')
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(load).not.toHaveBeenCalled()
await act(async () => {
landLater()
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(load).not.toHaveBeenCalled()
expect(latest?.value).toBe('c')
})
})
@@ -0,0 +1,58 @@
import { useCallback, useRef, useState } from 'react'
/**
* Screen state over a stored value, changed optimistically and saved one write at a time.
*
* A focus or AppState refresh that races an in-flight save must not overwrite the optimistic
* state, and a refused save must not leave the screen showing a change the store never took.
*/
export function useSerializedStoredState<T>(
initial: T | (() => T),
load: () => Promise<T>,
save: (value: T) => Promise<void>
) {
const [value, setValue] = useState(initial)
const writeChainRef = useRef<Promise<void>>(Promise.resolve())
const writeSeqRef = useRef(0)
const pendingWritesRef = useRef(0)
const refresh = useCallback(() => {
const refreshSeq = writeSeqRef.current
void load().then((stored) => {
if (pendingWritesRef.current > 0 || refreshSeq !== writeSeqRef.current) {
return
}
setValue(stored)
})
}, [load])
const persist = useCallback(
(next: T) => {
writeSeqRef.current += 1
pendingWritesRef.current += 1
writeChainRef.current = writeChainRef.current
.then(() => save(next))
.then(
() => false,
() => true
)
.then((refused) => {
pendingWritesRef.current -= 1
// Why: a refused write left the store as it was; show what it holds. A write still
// queued owns the screen, and a read started now could land after it and undo it.
if (refused && pendingWritesRef.current === 0) {
refresh()
}
})
},
[refresh, save]
)
/** A value another writer already stored; refreshes that read before it landed are dropped. */
const adoptStored = useCallback((stored: T) => {
writeSeqRef.current += 1
setValue(stored)
}, [])
return { value, setValue, persist, refresh, adoptStored }
}
@@ -14,6 +14,14 @@ import { styles } from './mobile-tasks-legacy-styles'
import { renderMobileTasksProviderControls } from './mobile-tasks-provider-controls'
import { renderMobileTasksSearchControl } from './mobile-tasks-search-control'
// Same wording as the desktop task page's icon buttons.
const REFRESH_LABELS = {
github: 'Refresh GitHub work',
gitlab: 'Refresh GitLab work items',
linear: 'Refresh Linear',
jira: 'Refresh Jira issues'
} as const
export function renderMobileTasksChrome(model: ConnectionPresentationModel) {
const { setTaskCopyFeedbackRootRef } = model
return (
@@ -72,6 +80,9 @@ export function renderMobileTasksStatusBar(model: ConnectionPresentationModel) {
<Pressable
style={styles.iconButton}
disabled={!taskUiReady || loading || refreshing || githubProjectLoading}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={REFRESH_LABELS[provider]}
onPress={() => {
if (!taskUiReady) {
return
@@ -89,6 +100,15 @@ export function renderMobileTasksStatusBar(model: ConnectionPresentationModel) {
<Pressable
style={styles.iconButton}
disabled={!taskUiReady}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={
provider === 'github'
? 'New GitHub issue'
: linearConnected
? 'New Linear issue'
: 'Connect Linear'
}
onPress={() => {
if (!taskUiReady) {
return
@@ -1,3 +1,4 @@
import { useCallback } from 'react'
import type { SourceControlPanelFoundation } from '../panel/use-panel-foundation'
import { useSourceControlCreatePrIntentCommitMessage } from '../review/use-create-pr-intent-commit-message'
import { useSourceControlConflictAbort } from '../sync/use-conflict-abort'
@@ -97,6 +98,19 @@ export function useSourceControlCommitFlows(foundation: SourceControlPanelFounda
updateCommitMessageGenerationRecord,
worktreePath
})
const { handleCommit: commitStagedChanges } = commitAction
const { handleCancelGenerate } = commitMessageGeneration
// Why: committing the box's own message makes a running generation moot; cancel it so its late
// reply cannot refill the box the commit empties. Create PR commits carry their own target.
const handleCommit = useCallback<typeof commitStagedChanges>(
(messageOverride, options) => {
if (!options?.target) {
handleCancelGenerate()
}
return commitStagedChanges(messageOverride, options)
},
[commitStagedChanges, handleCancelGenerate]
)
const createPrIntentCommitMessage = useSourceControlCreatePrIntentCommitMessage({
activeRepo,
generateInFlightRef,
@@ -115,7 +129,7 @@ export function useSourceControlCommitFlows(foundation: SourceControlPanelFounda
fastForwardBranch,
fetchBranch,
grouped,
handleCommit: commitAction.handleCommit,
handleCommit,
pullBranch,
pushBranch,
rebaseFromBase,
@@ -143,7 +157,7 @@ export function useSourceControlCommitFlows(foundation: SourceControlPanelFounda
})
return {
...commitAction,
handleCommit,
...commitMessageGeneration,
...createPrIntentCommitMessage,
...remoteActionRunner,
@@ -0,0 +1,111 @@
// @vitest-environment happy-dom
import { act, createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { useAppStore } from '@/store'
import type { CommitDraftsByWorktree } from './commit-drafts'
const host = vi.hoisted(() => {
const held: {
succeed: ((message: string) => void) | null
cancel: Mock<() => void>
} = { succeed: null, cancel: vi.fn<() => void>() }
return held
})
vi.mock('@/runtime/runtime-git-generation-client', () => ({
generateRuntimeCommitMessage: () =>
new Promise((resolve) => {
host.succeed = (message) => resolve({ success: true, message })
}),
cancelRuntimeGenerateCommitMessage: () => host.cancel()
}))
vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null }))
import { useSourceControlCommitMessageGeneration } from './use-commit-message-generation'
const WORKTREE_ID = 'repo-1::/repo'
const initialAppState = useAppStore.getInitialState()
let drafts: CommitDraftsByWorktree = {}
const generateInFlightRef: { current: Record<string, boolean> } = { current: {} }
let latest: ReturnType<typeof useSourceControlCommitMessageGeneration> | null = null
let root: Root | null = null
function Probe(): null {
const records = useAppStore((s) => s.commitMessageGenerationRecords)
const state = useAppStore.getState()
latest = useSourceControlCommitMessageGeneration({
activeRepo: null,
activeRepoTarget: { kind: 'local' },
activeWorktreeId: WORKTREE_ID,
allocateCommitMessageGenerationRequestId: state.allocateCommitMessageGenerationRequestId,
commitMessageGenerationRecords: records,
generateErrors: {},
generateInFlightByWorktree: {},
generateInFlightRef,
openCommitGenerationDialog: () => {},
resolvedCommitMessageAi: null,
setCommitMessageGenerationRecord: state.setCommitMessageGenerationRecord,
setGenerateErrors: () => {},
setGenerateInFlightByWorktree: () => {},
settings: null,
sourceControlAiActionsVisible: true,
updateCommitDrafts: (update) => {
drafts = typeof update === 'function' ? update(drafts) : update
},
updateCommitMessageGenerationRecord: state.updateCommitMessageGenerationRecord,
worktreePath: '/repo'
})
return null
}
async function startGenerating(): Promise<{ pending: Promise<void> | undefined }> {
await act(async () => {
root = createRoot(document.createElement('div'))
root.render(createElement(Probe))
})
let pending: Promise<void> | undefined
await act(async () => {
pending = latest?.handleGenerate({
sourceControlAiResolvedParams: { agentId: 'claude', model: 'sonnet' }
})
})
return { pending }
}
beforeEach(() => {
useAppStore.setState({ recordFeatureInteraction: async () => {} })
drafts = {}
generateInFlightRef.current = {}
})
afterEach(() => {
act(() => root?.unmount())
root = null
latest = null
host.succeed = null
host.cancel.mockClear()
useAppStore.setState(initialAppState, true)
})
describe('a commit message generation that was canceled', () => {
it('fills the empty box when nothing canceled the run', async () => {
const { pending } = await startGenerating()
await act(async () => {
host.succeed?.('feat: generated')
await pending
})
expect(drafts[WORKTREE_ID]).toBe('feat: generated')
})
it('drops the late reply after a cancel, so a commit that emptied the box stays empty', async () => {
const { pending } = await startGenerating()
act(() => latest?.handleCancelGenerate())
expect(host.cancel).toHaveBeenCalledTimes(1)
await act(async () => {
host.succeed?.('feat: generated for the old diff')
await pending
})
expect(drafts[WORKTREE_ID]).toBeUndefined()
})
})
@@ -169,20 +169,23 @@ export function useSourceControlCommitMessageGeneration({
return
}
updateCommitMessageGenerationRecord(activeCommitMessageGenerationKey, (record) =>
resolveCommitMessageGenerationSuccess({
record,
requestId,
message: result.message
})
)
const succeeded = resolveCommitMessageGenerationSuccess({
record:
useAppStore.getState().commitMessageGenerationRecords[activeCommitMessageGenerationKey],
requestId,
message: result.message
})
if (succeeded) {
setCommitMessageGenerationRecord(activeCommitMessageGenerationKey, succeeded)
}
// Why: race protection — drop the generated message if the user typed into the textarea while the agent ran, rather than overwrite their edits.
// A run canceled (by Stop or by a commit) may still succeed on the host; its message
// describes changes the box no longer holds.
updateCommitDrafts((prev) => {
const current = prev[activeWorktreeId]
if (current && current.length > 0) {
return prev
}
return writeCommitDraftForWorktree(prev, activeWorktreeId, result.message)
return !succeeded || (current && current.length > 0)
? prev
: writeCommitDraftForWorktree(prev, activeWorktreeId, result.message)
})
useAppStore.getState().recordFeatureInteraction('ai-commit-generation')
setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null }))