feat(mobile): keep automation drafts across kills (#39683)

* feat(mobile): keep automation drafts across kills

* fix(mobile): drop a created automation's draft on affirm and restore saved fields under typed ones

* fix(mobile): stop saving an automation draft after it is dropped

* fix(mobile): reopen the automation editor with in-memory edits while the store can't be read

* refactor(mobile): one rule for which automation draft fields changed
This commit is contained in:
Kelvin Amoaba
2026-11-08 06:45:30 +00:00
committed by GitHub
parent 291d584903
commit 29781fc694
11 changed files with 346 additions and 30 deletions
+7 -4
View File
@@ -29,6 +29,7 @@ import {
validateAutomationDraft,
type AutomationDraft
} from './automation-draft'
import { useAutomationEditorDraft } from './use-automation-editor-draft'
import { automationErrorMessage } from './automation-error'
import { automationStyles as styles } from './automation-styles'
@@ -36,6 +37,8 @@ export function AutomationEditor(props: {
client: RpcClient
current?: MobileAutomation
initialDraft?: AutomationDraft
/** Where unsaved edits are kept; they come back when this editor opens again, even after a kill. */
draftKey: string
onDraftChange: (draft: AutomationDraft) => void
busy: boolean
connected: boolean
@@ -46,9 +49,9 @@ export function AutomationEditor(props: {
}) {
const t = useMobileTranslation()
useMobileColorScheme()
const [draft, setDraft] = useState(() => props.initialDraft ?? newAutomationDraft(props.current))
const original = useRef(newAutomationDraft(props.current))
const leave = useAutomationLeaveGuard(draft, original, props.leaveGuard)
const form = useAutomationEditorDraft(props.draftKey, props.current, props.initialDraft)
const { draft, setDraft, original } = form
const leave = useAutomationLeaveGuard(draft, original, props.leaveGuard, form.discard)
const selectTargetDraft = useAutomationTargetDrafts()
const initialized = useRef(false)
const [targets, setTargets] = useState<AutomationTargets | null>(null)
@@ -96,7 +99,7 @@ export function AutomationEditor(props: {
initial.workspaceMode = 'existing'
}
original.current = { ...initial }
setDraft(props.initialDraft ?? initial)
setDraft(props.initialDraft ?? form.withSavedEdits(initial))
}
})
.catch((failure: unknown) => {
@@ -4,7 +4,7 @@ import { ConfirmModal } from '../components/ConfirmModal'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { AutomationButton } from './AutomationControls'
import type { AutomationDraft } from './automation-draft'
import { automationDraftChanged, type AutomationDraft } from './automation-draft'
import { automationStyles as styles } from './automation-styles'
type EditorPosition = { step: number; editing: boolean; busy: boolean }
@@ -20,25 +20,17 @@ export function leaveThrough(slot: AutomationLeaveSlot | undefined, leave: () =>
}
}
// The *Edited flags record how a field changed, not anything the user would lose.
function draftChanged(original: AutomationDraft, draft: AutomationDraft): boolean {
const before = new Map(Object.entries(original))
return Object.entries(draft).some(
([key, value]) =>
key !== 'targetEdited' && key !== 'setupDecisionEdited' && value !== before.get(key)
)
}
/** Asks before a leave drops unsaved edits; fills `slot` while mounted so the tab and host switches ask too. */
export function useAutomationLeaveGuard(
draft: AutomationDraft,
original: RefObject<AutomationDraft>,
slot?: AutomationLeaveSlot
slot: AutomationLeaveSlot | undefined,
onDiscard: () => void
) {
const t = useMobileTranslation()
const [pendingLeave, setPendingLeave] = useState<(() => void) | null>(null)
const guard: AutomationLeaveGuard = (leave) => {
if (draftChanged(original.current, draft)) {
if (automationDraftChanged(original.current, draft)) {
setPendingLeave(() => leave)
} else {
leave()
@@ -64,7 +56,10 @@ export function useAutomationLeaveGuard(
confirmLabel={t('automations.mobile.discard', 'Discard')}
cancelLabel={t('automations.mobile.keepEditing', 'Keep editing')}
destructive
onConfirm={() => pendingLeave?.()}
onConfirm={() => {
onDiscard()
pendingLeave?.()
}}
onCancel={() => setPendingLeave(null)}
/>
)
@@ -0,0 +1,77 @@
import { act, type ReactTestInstance } from 'react-test-renderer'
import { expect, it, vi } from 'vitest'
import { publishPageDeviceStore } from '../mobile-web-shell/bridge/page-device-store'
import { AutomationField } from './AutomationControls'
import { resetAutomationEditorDraftsForTests } from './automation-editor-draft-row'
export function registerAutomationDraftPersistenceTests({
root,
press,
mount,
unmount
}: {
root: () => ReactTestInstance
press: (label: string) => Promise<void>
mount: () => Promise<unknown>
unmount: () => void
}): void {
it('brings unsaved edits back after the page reloads, and forgets them once discarded', async () => {
// The shell's draft table outlives the page; nothing else does.
const table = new Map<string, string>()
publishPageDeviceStore({
available: () => true,
read: async (_table, keys) => ({
status: 'ok',
values: keys.map((key) => table.get(key) ?? null)
}),
write: async (_table, ops) => {
for (const op of ops) {
if (op.value === null) {
table.delete(op.key)
} else {
table.set(op.key, op.value)
}
}
return { written: true }
}
})
const field = (label: string) =>
root()
.findAllByType(AutomationField)
.find((node) => node.props.label === label)!
async function reload(): Promise<void> {
unmount()
resetAutomationEditorDraftsForTests()
await mount()
await press('New automation')
}
try {
await mount()
await press('New automation')
vi.useFakeTimers()
await act(async () => {
field('Name').props.onChange('Nightly digest')
field('Instructions').props.onChange('Summarize merged PRs')
})
await act(async () => {
await vi.advanceTimersByTimeAsync(1000)
})
vi.useRealTimers()
await reload()
expect(field('Name').props.value).toBe('Nightly digest')
expect(field('Instructions').props.value).toBe('Summarize merged PRs')
await press('Cancel')
const prompt = root().find(
(node) => String(node.type) === 'ConfirmModal' && node.props.visible === true
)
await act(async () => prompt.props.onConfirm())
await reload()
expect(field('Name').props.value).toBe('')
expect(table.size).toBe(0)
} finally {
vi.useRealTimers()
publishPageDeviceStore(null)
}
})
}
@@ -1,6 +1,7 @@
import { registerAutomationPresentationTests } from './AutomationManager.presentation.spec'
import { registerAutomationHostReselectionTests } from './AutomationManager.host-reselection.spec'
import { registerAutomationRunHistoryTests } from './AutomationManager.run-history.spec'
import { registerAutomationDraftPersistenceTests } from './AutomationManager.draft-persistence.spec'
import { AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY } from '../../../src/shared/automation-runtime-capabilities'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { AutomationEditor } from './AutomationEditor'
@@ -8,6 +9,10 @@ import { automationUpdateParams } from './automation-draft'
import { createElement } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
automationEditorDraftKey,
resetAutomationEditorDraftsForTests
} from './automation-editor-draft-row'
import { AutomationManager } from './AutomationManager'
import { AutomationButton, AutomationChoice, AutomationField } from './AutomationControls'
import { AutomationRunDetails } from './AutomationRunDetails'
@@ -70,6 +75,8 @@ let tree: ReactTestRenderer | null = null
afterEach(() => {
act(() => tree?.unmount())
tree = null
// Unsaved edits outlive the editor for the page's life; each test is a fresh page.
resetAutomationEditorDraftsForTests()
})
function setTree(value: ReactTestRenderer): void {
tree = value
@@ -204,6 +211,12 @@ async function mount(
describe('mobile automation navigation and setup', () => {
registerAutomationRunHistoryTests({ root, setTree })
registerAutomationDraftPersistenceTests({
root,
press,
mount,
unmount: () => act(() => tree?.unmount())
})
registerAutomationPresentationTests({ inventory, root, press, setTree, mount })
registerAutomationHostReselectionTests({
inventory,
@@ -236,6 +249,7 @@ describe('mobile automation navigation and setup', () => {
capabilities: [],
onCancel: vi.fn(),
onDraftChange: vi.fn(),
draftKey: automationEditorDraftKey(),
onSave: save
})
)
@@ -337,6 +351,7 @@ describe('mobile automation navigation and setup', () => {
capabilities: [],
onCancel: vi.fn(),
onDraftChange: vi.fn(),
draftKey: automationEditorDraftKey(),
onSave: vi.fn()
})
)
@@ -414,6 +429,7 @@ describe('mobile automation navigation and setup', () => {
capabilities: [],
onCancel: vi.fn(),
onDraftChange: vi.fn(),
draftKey: automationEditorDraftKey(),
onSave: vi.fn()
}
await act(async () => {
@@ -570,6 +586,7 @@ describe('mobile automation navigation and setup', () => {
capabilities: [],
onCancel: vi.fn(),
onDraftChange: vi.fn(),
draftKey: automationEditorDraftKey(),
onSave
}
await act(async () => {
+7 -6
View File
@@ -28,6 +28,7 @@ import {
import { automationUpdateParams, type AutomationDraft } from './automation-draft'
import type { AutomationTargets } from './automation-target-inventory'
import { useAutomationCatalog } from './use-automation-catalog'
import { automationEditorDraftKey, dropAutomationEditorDraft } from './automation-editor-draft-row'
import { automationStyles as styles } from './automation-styles'
type AutomationView =
@@ -55,7 +56,6 @@ export function AutomationManager(props: {
const [view, setView] = useState<AutomationView>(startView)
const [tab, setTab] = useState<'automations' | 'runs'>('automations')
const [filter, setFilter] = useState<AutomationRunScope>(null)
const filterId = filter?.automationId ?? ''
const [outcome, setOutcome] = useState('all')
const [deleting, setDeleting] = useState<MobileAutomationRow | null>(null)
const createAttempt = useAutomationCreateAttempt(
@@ -67,7 +67,8 @@ export function AutomationManager(props: {
initialDraft ? { kind: 'editor', row: saved, initialDraft } : { kind: 'detail', row: saved }
)
},
catalog.isCurrentSnapshot
catalog.isCurrentSnapshot,
() => dropAutomationEditorDraft(props.executionHost)
)
const setupConsent = useAutomationSetupConsent(props.client, props.executionHost)
const disabled = !catalog.connected || catalog.busy
@@ -107,9 +108,7 @@ export function AutomationManager(props: {
setTab(next)
setView({ kind: 'list' })
}
function allAutomations() {
showList('automations')
}
const allAutomations = () => showList('automations')
function allRuns(automationId = '') {
selectFilter(automationId)
showList('runs')
@@ -240,6 +239,7 @@ export function AutomationManager(props: {
return
}
const id = saved.automation.id
dropAutomationEditorDraft(props.executionHost, id)
catalog.setRows((rows) => [...rows.filter((row) => row.automation.id !== id), saved])
setView({ kind: 'detail', row: saved })
}
@@ -286,6 +286,7 @@ export function AutomationManager(props: {
client={props.client}
current={view.row?.automation}
initialDraft={view.initialDraft}
draftKey={automationEditorDraftKey(props.executionHost, view.row?.automation.id)}
onDraftChange={createAttempt.observeDraft}
leaveGuard={props.leaveGuard}
capabilities={catalog.capabilities}
@@ -351,7 +352,7 @@ export function AutomationManager(props: {
<View style={styles.stack}>
<AutomationChoice
label={t('automations.mobile.automation', 'Automation')}
value={filterId}
value={filter?.automationId ?? ''}
options={[
{ value: '', label: t('automations.mobile.allAutomations', 'All automations') },
...catalog.rows.map((entry) => ({
@@ -59,6 +59,22 @@ export function newAutomationDraft(current?: MobileAutomation): AutomationDraft
}
}
/** The fields that differ from `original`; null when nothing the user would lose did (the *Edited flags only record how a field changed). */
export function automationDraftEdits(
original: AutomationDraft,
draft: AutomationDraft
): Partial<AutomationDraft> | null {
const before = new Map(Object.entries(original))
const edits = Object.entries(draft).filter(([key, value]) => value !== before.get(key))
return edits.some(([key]) => key !== 'targetEdited' && key !== 'setupDecisionEdited')
? Object.fromEntries(edits)
: null
}
export function automationDraftChanged(original: AutomationDraft, draft: AutomationDraft): boolean {
return automationDraftEdits(original, draft) !== null
}
// The host checks arguments for an agent or surface this build doesn't know.
function assertKnownExtraAgentArgs(draft: AutomationDraft): void {
const { agentId, surface } = draft
@@ -0,0 +1,143 @@
import * as z from 'zod'
import {
durableDraftRow,
durableDraftRowKey,
pageDraftRowBackend,
resetDurableDraftRowsForTests,
type DurableDraftRow
} from '../drafts/durable-draft-rows'
import type { AutomationDraft } from './automation-draft'
// Only the fields the user changed are kept, so a field edited elsewhere since then is not reverted.
export type AutomationDraftEdits = Partial<AutomationDraft>
const editsSchema = z
.object({
targetEdited: z.boolean(),
setupDecisionEdited: z.boolean(),
name: z.string(),
prompt: z.string(),
agentId: z.string().nullable(),
surface: z.string(),
setupId: z.string(),
workspaceId: z.string(),
workspaceMode: z.enum(['existing', 'new_per_run']),
rrule: z.string(),
timezone: z.string(),
baseBranch: z.string(),
setupDecision: z.enum(['inherit', 'run', 'skip']),
reuseSession: z.boolean(),
extraAgentArgs: z.string(),
precheckCommand: z.string(),
precheckTimeout: z.string(),
grace: z.string()
} satisfies Record<keyof AutomationDraft, z.ZodType>)
.partial()
type Slot = {
edits: AutomationDraftEdits | null
// A drop before the row was read: the stored copy is what was dropped, so it never comes back.
dropped: boolean
// Dropped since the editor opened: that editor's later typing is not saved (its automation may exist now).
closed: boolean
// The open editor, told whenever a read (the first or a retried one) brings saved edits.
onRestore: ((edits: AutomationDraftEdits) => void) | null
row: DurableDraftRow
}
const slots = new Map<string, Slot>()
/** One row per authority (the paired host, or a server) and automation, `new` for a create. */
export function automationEditorDraftKey(executionHost?: string, automationId?: string): string {
return durableDraftRowKey(
'automation',
'',
`${executionHost ?? 'paired'}:${automationId ?? 'new'}`
)
}
/** Lays saved edits over the form, except on fields the user has changed since it opened. */
export function laySavedEdits(
form: AutomationDraft,
original: AutomationDraft,
edits: AutomationDraftEdits
): AutomationDraft {
const before = new Map(Object.entries(original))
const now = new Map(Object.entries(form))
const untouched = Object.entries(edits).filter(([key]) => now.get(key) === before.get(key))
return untouched.length ? { ...form, ...Object.fromEntries(untouched) } : form
}
function slotFor(key: string): Slot {
let slot = slots.get(key)
if (!slot) {
const created: Slot = {
edits: null,
dropped: false,
closed: false,
onRestore: null,
row: durableDraftRow(key, {
backend: () => pageDraftRowBackend(key),
snapshot: () => created.edits,
restore: (saved) => {
const parsed = editsSchema.safeParse(saved)
if (created.dropped || !parsed.success || Object.keys(parsed.data).length === 0) {
return
}
// Fields typed before the read landed win over the saved copy.
created.edits = { ...parsed.data, ...created.edits }
created.onRestore?.(created.edits)
}
})
}
slot = created
slots.set(key, slot)
}
return slot
}
/** The page's copy: what was read, plus typing kept in memory while the store can't be read. */
export function readAutomationEditorDraft(key: string): AutomationDraftEdits | null {
return slotFor(key).edits
}
/** Opens the row for an editor: reads it if it hasn't been, and hands saved edits to `onRestore`. */
export function watchAutomationEditorDraft(
key: string,
onRestore: (edits: AutomationDraftEdits) => void
): () => void {
const slot = slotFor(key)
slot.closed = false
slot.onRestore = onRestore
void slot.row.load()
return () => {
if (slot.onRestore === onRestore) {
slot.onRestore = null
}
}
}
export function saveAutomationEditorDraft(key: string, edits: AutomationDraftEdits | null): void {
const slot = slotFor(key)
if (slot.closed) {
return
}
slot.edits = edits
slot.row.save(edits ? 'deferred' : 'immediate')
}
// Discarded edits, and edits the host now holds, must not come back.
export function discardAutomationEditorDraft(key: string): void {
const slot = slotFor(key)
slot.dropped ||= slot.row.state() === 'pending'
saveAutomationEditorDraft(key, null)
slot.closed = true
}
export function dropAutomationEditorDraft(executionHost?: string, automationId?: string): void {
discardAutomationEditorDraft(automationEditorDraftKey(executionHost, automationId))
}
export function resetAutomationEditorDraftsForTests(): void {
slots.clear()
resetDurableDraftRowsForTests()
}
@@ -25,7 +25,9 @@ export function useAutomationCreateAttempt(
rows: readonly MobileAutomationRow[],
creating: boolean,
onReconcile: (row: MobileAutomationRow, initialDraft?: AutomationDraft) => void,
isCurrentSnapshot: () => boolean
isCurrentSnapshot: () => boolean,
// The host holds the create, so the form's saved copy must not bring it back for a second one.
onCreated: () => void
) {
const attempt = useRef<SubmittedAutomationCreate | null>(null)
const latestDraft = useRef<AutomationDraft | null>(null)
@@ -60,8 +62,9 @@ export function useAutomationCreateAttempt(
}
submitted.affirmed = true
attempt.current = null
onCreated()
onReconcile(saved, Object.keys(changes).length ? { ...baseline, ...changes } : undefined)
}, [rows, creating, onReconcile, isCurrentSnapshot])
}, [rows, creating, onReconcile, isCurrentSnapshot, onCreated])
return {
observeDraft,
reset: () => {
@@ -140,10 +143,11 @@ export function useAutomationCreateAttempt(
}
try {
submitted.replyId = automationCreateWrite.interpret(response).automation.id
submitted.affirmed = true
} catch {
throw new Error(AUTOMATION_OUTCOME_UNKNOWN)
}
submitted.affirmed = true
onCreated()
}
}
}
@@ -0,0 +1,44 @@
import { useEffect, useRef, useState } from 'react'
import type { MobileAutomation } from './automation-reply-schema'
import { automationDraftEdits, newAutomationDraft, type AutomationDraft } from './automation-draft'
import {
discardAutomationEditorDraft,
laySavedEdits,
readAutomationEditorDraft,
saveAutomationEditorDraft,
watchAutomationEditorDraft
} from './automation-editor-draft-row'
/** The editor's form, with the user's unsaved edits kept in a draft row and laid back over it on reopen. */
export function useAutomationEditorDraft(
draftKey: string,
current: MobileAutomation | undefined,
initialDraft: AutomationDraft | undefined
) {
const [saved] = useState(() => (initialDraft ? null : readAutomationEditorDraft(draftKey)))
const restored = useRef(saved)
const [draft, setDraft] = useState(
() => initialDraft ?? { ...newAutomationDraft(current), ...saved }
)
const original = useRef(newAutomationDraft(current))
useEffect(() => {
return watchAutomationEditorDraft(draftKey, (edits) => {
restored.current = edits
setDraft((form) => laySavedEdits(form, original.current, edits))
})
}, [draftKey])
useEffect(() => {
saveAutomationEditorDraft(draftKey, automationDraftEdits(original.current, draft))
}, [draft, draftKey])
return {
draft,
setDraft,
original,
// The form rebuilt from the host's values, with the saved edits on top.
withSavedEdits: (form: AutomationDraft): AutomationDraft => ({ ...form, ...restored.current }),
discard: () => discardAutomationEditorDraft(draftKey)
}
}
@@ -235,7 +235,8 @@ describe('host-owned automation reads', () => {
catalog.rows,
true,
reconciled,
catalog.isCurrentSnapshot
catalog.isCurrentSnapshot,
vi.fn()
)
state = { catalog, attempt }
return null
@@ -315,7 +316,7 @@ describe('host-owned automation reads', () => {
const port = automationClient(() => ({ automation: savedAutomation() }))
let state: ReturnType<typeof useAutomationCreateAttempt> | null = null
function Probe() {
state = useAutomationCreateAttempt([], true, vi.fn(), () => true)
state = useAutomationCreateAttempt([], true, vi.fn(), () => true, vi.fn())
return null
}
await act(async () => {
@@ -368,7 +369,7 @@ describe('host-owned automation reads', () => {
const port = automationClient(() => ({}))
let state: ReturnType<typeof useAutomationCreateAttempt> | null = null
function Probe() {
state = useAutomationCreateAttempt([], true, vi.fn(), () => true)
state = useAutomationCreateAttempt([], true, vi.fn(), () => true, vi.fn())
return null
}
await act(async () => {
+16 -1
View File
@@ -1,4 +1,7 @@
import type { PageDeviceStore } from '../mobile-web-shell/bridge/page-device-store'
import {
readPageDeviceStore,
type PageDeviceStore
} from '../mobile-web-shell/bridge/page-device-store'
import {
decodeDeviceStoreValue,
encodeDeviceStoreValue
@@ -93,6 +96,18 @@ export function deviceStoreDraftBackend(
}
}
// An older shell grants no store; there the row lives only as long as the page.
const MEMORY_BACKEND: DurableDraftRowBackend = {
read: async () => ({ ok: true, value: null }),
write: async () => true
}
/** The device store when the shell grants it, else memory only, as before the store existed. */
export function pageDraftRowBackend(key: string): DurableDraftRowBackend {
const store = readPageDeviceStore()
return store?.available() ? deviceStoreDraftBackend(store, key) : MEMORY_BACKEND
}
function enqueue(row: Row, step: () => Promise<void>): Promise<void> {
row.chain = row.chain.then(step).catch((error: unknown) => {
console.warn('[draft-rows] a draft could not be saved; it is kept in memory', error)