diff --git a/.changeset/mobile-agent-signal-controls.md b/.changeset/mobile-agent-signal-controls.md new file mode 100644 index 0000000000..9fefeb6e6a --- /dev/null +++ b/.changeset/mobile-agent-signal-controls.md @@ -0,0 +1,6 @@ +--- +'@electric-ax/agents-mobile': patch +'@electric-ax/agents-server-ui': patch +--- + +Add mobile agent signal controls. The mobile chat composer now shows a stop control while a run is active, the session menu exposes all entity signal types in a child menu, and the embedded chat timeline accounts for the native composer/drawer inset with aligned message widths and bottom fade masking. diff --git a/packages/agents-mobile/app/session.tsx b/packages/agents-mobile/app/session.tsx index cd0ada55aa..de975f29a2 100644 --- a/packages/agents-mobile/app/session.tsx +++ b/packages/agents-mobile/app/session.tsx @@ -33,6 +33,7 @@ type SessionDomEmbedProps = { theme: `light` | `dark` scrollToBottomSignal?: number inlineQueuedMessages?: Array + bottomInset?: number onRequestOpenEntity: (entityUrl: string) => Promise style?: StyleProp matchContents?: boolean @@ -83,9 +84,9 @@ export default function SessionRoute(): React.ReactElement { () => ({ top: embedTop, width: windowDimensions.width, - height: Math.max(0, windowDimensions.height - embedTop - composerInset), + height: Math.max(0, windowDimensions.height - embedTop), }), - [composerInset, embedTop, windowDimensions.height, windowDimensions.width] + [embedTop, windowDimensions.height, windowDimensions.width] ) const embedSize = useMemo( () => ({ @@ -130,6 +131,7 @@ export default function SessionRoute(): React.ReactElement { theme={scheme} scrollToBottomSignal={chatLogScrollSignal} inlineQueuedMessages={inlineQueuedMessages} + bottomInset={composerInset} serverHeaders={serverHeaders} onRequestOpenEntity={async (target) => openSession(target)} dom={domOptions(styles, embedSize, tokens.bg)} diff --git a/packages/agents-mobile/src/components/Icon.tsx b/packages/agents-mobile/src/components/Icon.tsx index 79a15d106a..d04b116118 100644 --- a/packages/agents-mobile/src/components/Icon.tsx +++ b/packages/agents-mobile/src/components/Icon.tsx @@ -31,7 +31,9 @@ export type IconName = | `swap` | `chat` | `database` + | `radio` | `arrow-up` + | `square` const PATHS: Record = { back: `M15 18l-6-6 6-6`, @@ -52,7 +54,9 @@ const PATHS: Record = { swap: `M7 4l-3 3 3 3M4 7h13M17 14l3 3-3 3M20 17H7`, chat: `M4 4h16v12H8l-4 4Z`, database: `M5 5c0-1.1 3.1-2 7-2s7 .9 7 2v14c0 1.1-3.1 2-7 2s-7-.9-7-2V5ZM5 12c0 1.1 3.1 2 7 2s7-.9 7-2`, + radio: `M4.9 19.1a10 10 0 0 1 0-14.2M7.8 16.2a6 6 0 0 1 0-8.4M10.6 13.4a2 2 0 0 1 0-2.8M14 12h.01M16.2 7.8a6 6 0 0 1 0 8.4M19.1 4.9a10 10 0 0 1 0 14.2`, 'arrow-up': `M12 19V5M5 12l7-7 7 7`, + square: `M7 7h10v10H7z`, } export function Icon({ @@ -76,6 +80,14 @@ export function Icon({ ) } + if (name === `square`) { + return ( + + + + ) + } + return ( = { running: `blue`, idle: `green`, + paused: `amber`, + stopping: `amber`, spawning: `amber`, stopped: `gray`, + killed: `gray`, } +const SIGNAL_OPTION_GROUPS: ReadonlyArray< + ReadonlyArray<{ + id: string + shortName: string + description: string + signal?: EntitySignal + composite?: `stop-immediately` + code: string + destructive?: boolean + }> +> = [ + [ + { + id: `interrupt`, + signal: `SIGINT`, + shortName: `Interrupt`, + description: `Abort active run and continue`, + code: `SIGINT`, + }, + { + id: `stop-immediately`, + composite: `stop-immediately`, + shortName: `Stop immediately`, + description: `Abort active run and pause`, + code: `SIGSTOP+SIGINT`, + }, + ], + [ + { + id: `stop`, + signal: `SIGSTOP`, + shortName: `Stop`, + description: `Pause after current run`, + code: `SIGSTOP`, + }, + { + id: `reload`, + signal: `SIGHUP`, + shortName: `Reload`, + description: `Reload after current run`, + code: `SIGHUP`, + }, + { + id: `resume`, + signal: `SIGCONT`, + shortName: `Resume`, + description: `Resume paused work`, + code: `SIGCONT`, + }, + { + id: `custom`, + signal: `SIGUSR`, + shortName: `Custom`, + description: `Deliver to signal handler`, + code: `SIGUSR`, + }, + ], + [ + { + id: `terminate`, + signal: `SIGTERM`, + shortName: `Terminate`, + description: `Gracefully stop permanently`, + code: `SIGTERM`, + destructive: true, + }, + { + id: `kill`, + signal: `SIGKILL`, + shortName: `Kill`, + description: `Immediately kill permanently`, + code: `SIGKILL`, + destructive: true, + }, + ], +] + /** * Bottom-sheet "more" menu for the chat screen — exposes the view * toggle (chat / state explorer) plus a status header. Tapping a @@ -29,14 +110,23 @@ export function SessionMenu({ entity, view, onSetView, + signalError, + onSignal, + onStopImmediately, }: { open: boolean onClose: () => void entity: ElectricEntity | null view: EmbedViewId onSetView: (view: EmbedViewId) => void + signalError?: string | null + onSignal?: (signal: EntitySignal) => void + onStopImmediately?: () => void }): React.ReactElement { const tokens = useTokens() + const [signalMenuOpen, setSignalMenuOpen] = useState(false) + const [drillDirection, setDrillDirection] = useState(1) + const drillProgress = useRef(new Animated.Value(1)).current const dotKey = entity ? (STATUS_DOT_COLORS[entity.status] ?? `gray`) : `gray` const dotColor = @@ -52,77 +142,230 @@ export function SessionMenu({ onSetView(next) onClose() } + const handleClose = (): void => { + setSignalMenuOpen(false) + drillProgress.setValue(1) + onClose() + } + const transitionToMenu = (nextSignalMenuOpen: boolean): void => { + setDrillDirection(nextSignalMenuOpen ? 1 : -1) + drillProgress.setValue(0) + setSignalMenuOpen(nextSignalMenuOpen) + Animated.timing(drillProgress, { + toValue: 1, + duration: 180, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }).start() + } + const canSignal = + entity !== null && + entity.status !== `stopped` && + entity.status !== `killed` && + Boolean(onSignal) + const handleSignalOption = ( + option: (typeof SIGNAL_OPTION_GROUPS)[number][number] + ): void => { + if (option.composite === `stop-immediately`) { + onStopImmediately?.() + } else if (option.signal) { + onSignal?.(option.signal) + } + handleClose() + } + + useEffect(() => { + if (!open) { + setSignalMenuOpen(false) + drillProgress.setValue(1) + } + }, [drillProgress, open]) + + const drillPaneStyle = { + opacity: drillProgress, + transform: [ + { + translateX: drillProgress.interpolate({ + inputRange: [0, 1], + outputRange: [drillDirection * 28, 0], + }), + }, + ], + } return ( - - {entity && ( - <> - - - + + {signalMenuOpen ? ( + <> + + + } + onPress={() => transitionToMenu(false)} /> - - {entity.status} - - - - {entity.type} - - - - - - )} + + + + {SIGNAL_OPTION_GROUPS.map((group, groupIndex) => ( + + {groupIndex > 0 && } + {group.map((option) => ( + + } + destructive={option.destructive} + onPress={() => handleSignalOption(option)} + /> + ))} + + ))} + + + ) : ( + <> + {entity && ( + <> + + + + + {entity.status} + + + + {entity.type} + + + {signalError ? ( + + {signalError} + + ) : null} + + + + )} - - - } - active={view === `chat`} - onPress={() => handlePick(`chat`)} - /> - - } - active={view === `state-explorer`} - onPress={() => handlePick(`state-explorer`)} - /> - + + + } + active={view === `chat`} + onPress={() => handlePick(`chat`)} + /> + + } + active={view === `state-explorer`} + onPress={() => handlePick(`state-explorer`)} + /> + + {canSignal && ( + <> + + + + } + trailing={ + + } + onPress={() => transitionToMenu(true)} + /> + + + )} + + )} + ) } + +const styles = StyleSheet.create({ + drillPane: { + overflow: `hidden`, + }, +}) diff --git a/packages/agents-mobile/src/lib/AgentsProvider.tsx b/packages/agents-mobile/src/lib/AgentsProvider.tsx index c253523056..26a000e648 100644 --- a/packages/agents-mobile/src/lib/AgentsProvider.tsx +++ b/packages/agents-mobile/src/lib/AgentsProvider.tsx @@ -3,8 +3,10 @@ import { createEntitiesCollection, createEntityTypesCollection, createRunnersCollection, + signalEntity, type EntitiesCollection, type EntityTypesCollection, + type EntitySignal, type RunnersCollection, } from './agentsClient' @@ -13,6 +15,12 @@ type AgentsContextValue = { entitiesCollection: EntitiesCollection entityTypesCollection: EntityTypesCollection runnersCollection: RunnersCollection + signalEntity: (input: { + entityUrl: string + signal: EntitySignal + reason?: string + payload?: unknown + }) => Promise } const AgentsContext = createContext(null) @@ -30,6 +38,7 @@ export function AgentsProvider({ entitiesCollection: createEntitiesCollection(serverUrl), entityTypesCollection: createEntityTypesCollection(serverUrl), runnersCollection: createRunnersCollection(serverUrl), + signalEntity: (input) => signalEntity({ baseUrl: serverUrl, ...input }), } }, [serverUrl]) diff --git a/packages/agents-mobile/src/lib/agentsClient.ts b/packages/agents-mobile/src/lib/agentsClient.ts index c4cb91521d..2f414ad7f2 100644 --- a/packages/agents-mobile/src/lib/agentsClient.ts +++ b/packages/agents-mobile/src/lib/agentsClient.ts @@ -4,13 +4,31 @@ import { appendPathToUrl } from '@electric-ax/agents-runtime/client' import { serverFetch } from '@electric-ax/agents-server-ui/src/lib/auth-fetch' import { z } from 'zod' -export type EntityStatus = `spawning` | `running` | `idle` | `stopped` +export type EntityStatus = + | `spawning` + | `running` + | `idle` + | `paused` + | `stopping` + | `stopped` + | `killed` +export type EntitySignal = + | `SIGINT` + | `SIGHUP` + | `SIGTERM` + | `SIGKILL` + | `SIGSTOP` + | `SIGCONT` + | `SIGUSR` const ENTITY_STATUSES: [EntityStatus, ...Array] = [ `spawning`, `running`, `idle`, + `paused`, + `stopping`, `stopped`, + `killed`, ] export const entitySchema = z.object({ @@ -204,6 +222,39 @@ export async function spawnEntity({ return entityUrl } +export async function signalEntity({ + baseUrl, + entityUrl, + signal, + reason, + payload, +}: { + baseUrl: string + entityUrl: string + signal: EntitySignal + reason?: string + payload?: unknown +}): Promise { + const body: Record = { signal } + if (reason !== undefined) body.reason = reason + if (payload !== undefined) body.payload = payload + + const res = await serverFetch( + appendPathToUrl( + baseUrl, + `/_electric/entities${entityUrl.startsWith(`/`) ? entityUrl : `/${entityUrl}`}/signal` + ), + { + method: `POST`, + headers: { 'content-type': `application/json` }, + body: JSON.stringify(body), + } + ) + if (!res.ok) { + throw new Error(await responseMessage(res, `Signal failed`)) + } +} + export function getEntityDisplayTitle(entity: ElectricEntity): string { const tagTitle = entity.tags.title if (typeof tagTitle === `string` && tagTitle.length > 0) return tagTitle diff --git a/packages/agents-mobile/src/screens/SessionScreen.tsx b/packages/agents-mobile/src/screens/SessionScreen.tsx index c137efbd56..cd7c26af16 100644 --- a/packages/agents-mobile/src/screens/SessionScreen.tsx +++ b/packages/agents-mobile/src/screens/SessionScreen.tsx @@ -38,7 +38,7 @@ import { useTokens } from '../lib/ThemeProvider' import { fontSize, lineHeight, radii, spacing } from '../lib/theme' import type { Tokens } from '../lib/theme' import type { EmbedViewId } from '../lib/embedView' -import type { ElectricEntity } from '../lib/agentsClient' +import type { ElectricEntity, EntitySignal } from '../lib/agentsClient' export const CHAT_COMPOSER_BASE_HEIGHT = 76 export const CHAT_COMPOSER_OVERLAP = 20 @@ -139,13 +139,15 @@ export function SessionScreen({ messages: Array ) => void }): React.ReactElement { - const { entitiesCollection, serverUrl } = useAgents() + const { entitiesCollection, serverUrl, signalEntity } = useAgents() const tokens = useTokens() const styles = useMemo(() => createStyles(tokens), [tokens]) const [menuOpen, setMenuOpen] = useState(false) const [inlineQueuedMessages, setInlineQueuedMessages] = useState< Map >(() => new Map()) + const [stopPending, setStopPending] = useState(false) + const [signalError, setSignalError] = useState(null) const inlineQueuedKeysRef = useRef(new Set()) const inlineTimeoutsRef = useRef( new Map>() @@ -161,10 +163,8 @@ export function SessionScreen({ const entity = matches.at(0) ?? null const streamEntityUrl = view === `chat` && entity?.status !== `spawning` ? entityUrl : null - const { timelineRows, pendingInbox, db } = useEntityTimeline( - serverUrl, - streamEntityUrl - ) + const { timelineRows, pendingInbox, db, generationActive } = + useEntityTimeline(serverUrl, streamEntityUrl) const manifests = useMemo( () => timelineRows @@ -276,6 +276,67 @@ export function SessionScreen({ onInlineQueuedMessagesChange?.(Array.from(inlineQueuedMessages.values())) }, [inlineQueuedMessages, onInlineQueuedMessagesChange]) + useEffect(() => { + if (!generationActive) setStopPending(false) + }, [generationActive]) + + useEffect(() => { + setStopPending(false) + setSignalError(null) + }, [entityUrl]) + + const sendSignal = useCallback( + async ( + signal: EntitySignal, + reason: string, + opts: { stopPending?: boolean } = {} + ): Promise => { + if (!entity) return + if (opts.stopPending) setStopPending(true) + setSignalError(null) + try { + await signalEntity({ entityUrl, signal, reason }) + } catch (err) { + if (opts.stopPending) setStopPending(false) + setSignalError(err instanceof Error ? err.message : String(err)) + } + }, + [entity, entityUrl, signalEntity] + ) + + const stopGeneration = useCallback((): void => { + if (!generationActive || stopPending) return + void sendSignal(`SIGINT`, `Stopped from mobile chat UI`, { + stopPending: true, + }) + }, [generationActive, sendSignal, stopPending]) + + const stopImmediately = useCallback(async (): Promise => { + if (!entity) return + setSignalError(null) + try { + await signalEntity({ + entityUrl, + signal: `SIGSTOP`, + reason: `Stopped immediately from mobile session menu`, + }) + await signalEntity({ + entityUrl, + signal: `SIGINT`, + reason: `Interrupted current run for immediate stop`, + }) + } catch (err) { + setSignalError(err instanceof Error ? err.message : String(err)) + } + }, [entity, entityUrl, signalEntity]) + + const sendMenuSignal = useCallback( + (signal: EntitySignal): void => { + void sendSignal(signal, `Sent from mobile session menu`) + }, + [sendSignal] + ) + const title = entity ? getEntityDisplayTitle(entity) : decodeURIComponent(entityUrl.replace(/^\//, ``)) @@ -309,17 +370,25 @@ export function SessionScreen({ onOptimisticQueuedMessage={rememberInlineQueuedMessage} onOpenEntity={onOpenEntity} onOpenStateSource={onOpenStateSource} + generationActive={generationActive} + stopPending={stopPending} + onStop={stopGeneration} disabled={ - !db || entity?.status === `stopped` || entity?.status === `spawning` + !db || + entity?.status === `stopped` || + entity?.status === `killed` || + entity?.status === `spawning` } placeholder={ entity?.status === `stopped` ? `Entity stopped` - : entity?.status === `spawning` - ? `Starting...` - : !db - ? `Connecting...` - : `Send a message...` + : entity?.status === `killed` + ? `Entity killed` + : entity?.status === `spawning` + ? `Starting...` + : !db + ? `Connecting...` + : `Send a message...` } /> )} @@ -330,6 +399,9 @@ export function SessionScreen({ entity={entity} view={view} onSetView={onSetView} + signalError={signalError} + onSignal={sendMenuSignal} + onStopImmediately={() => void stopImmediately()} /> ) @@ -347,6 +419,9 @@ function NativeMessageComposer({ onOptimisticQueuedMessage, onOpenEntity, onOpenStateSource, + generationActive, + stopPending, + onStop, disabled, placeholder, }: { @@ -361,6 +436,9 @@ function NativeMessageComposer({ onOptimisticQueuedMessage?: (message: OptimisticInboxMessage) => void onOpenEntity?: (entityUrl: string) => void onOpenStateSource?: (sourceId: string) => void + generationActive: boolean + stopPending: boolean + onStop: () => void disabled: boolean placeholder: string }): React.ReactElement { @@ -403,6 +481,9 @@ function NativeMessageComposer({ if (!db) return null return createSteerInboxMessageAction({ db, baseUrl: serverUrl, entityUrl }) }, [db, serverUrl, entityUrl]) + const showStop = + generationActive && text.length === 0 && !disabled && !editingMessage + const canStop = showStop && !stopPending const canSend = text.length > 0 && !disabled && !sending const setMeasuredInputHeight = (height: number): void => { const nextHeight = Math.min( @@ -479,6 +560,14 @@ function NativeMessageComposer({ finishPersistedAction(tx.isPersisted.promise) } + const handleComposerAction = (): void => { + if (showStop) { + if (canStop) onStop() + return + } + send() + } + const startEditing = useCallback( (message: PendingInboxMessage): void => { const queuedText = readTextPayload(message.payload) @@ -601,24 +690,26 @@ function NativeMessageComposer({ returnKeyType="default" /> [ styles.sendButton, - canSend ? styles.sendButtonActive : null, - pressed && canSend ? styles.sendButtonPressed : null, + canSend || showStop ? styles.sendButtonActive : null, + showStop ? styles.stopButton : null, + showStop && stopPending ? styles.stopButtonPending : null, + pressed && (canSend || showStop) ? styles.sendButtonPressed : null, ]} > - {sending ? ( + {sending && !showStop ? ( ) : ( )} @@ -1324,7 +1415,6 @@ function createComposerStyles(tokens: Tokens) { marginTop: -CHAT_COMPOSER_OVERLAP, paddingHorizontal: spacing.lg, paddingTop: 0, - backgroundColor: tokens.bg, zIndex: 10, }, composer: { @@ -1370,6 +1460,16 @@ function createComposerStyles(tokens: Tokens) { sendButtonActive: { backgroundColor: tokens.accent9, }, + stopButton: { + width: 24, + height: 24, + borderRadius: 12, + margin: 5, + backgroundColor: tokens.accent9, + }, + stopButtonPending: { + opacity: 0.72, + }, sendButtonPressed: { opacity: 0.8, }, diff --git a/packages/agents-server-ui/src/components/EntityTimeline.tsx b/packages/agents-server-ui/src/components/EntityTimeline.tsx index e44128169d..8fdc1df8af 100644 --- a/packages/agents-server-ui/src/components/EntityTimeline.tsx +++ b/packages/agents-server-ui/src/components/EntityTimeline.tsx @@ -1327,10 +1327,13 @@ export function EntityTimeline({ -
+
{spawnTime ? ( diff --git a/packages/agents-server-ui/src/components/UserMessage.tsx b/packages/agents-server-ui/src/components/UserMessage.tsx index e7ce15e59a..8844c76dcd 100644 --- a/packages/agents-server-ui/src/components/UserMessage.tsx +++ b/packages/agents-server-ui/src/components/UserMessage.tsx @@ -24,7 +24,11 @@ export const UserMessage = memo(function UserMessage({ const sender = formatSender(section.from) return ( - + + bottomInset?: number // Forwarded across the Expo-DOM boundary so the embed's auth-fetch // module instance (separate from the native side) can inject the // Cloud `Authorization` + `x-electric-service` headers on every @@ -163,6 +170,7 @@ function EmbedSurface({ state }: { state: EmbedState }): ReactElement { serverUrl={state.serverUrl} scrollToBottomSignal={state.scrollToBottomSignal} inlineQueuedMessages={state.inlineQueuedMessages} + bottomInset={state.bottomInset} /> ) } @@ -173,12 +181,14 @@ function EntityHost({ serverUrl, scrollToBottomSignal, inlineQueuedMessages, + bottomInset, }: { entityUrl: string view: EmbedView serverUrl: string scrollToBottomSignal?: number inlineQueuedMessages?: Array + bottomInset?: number }): ReactElement { const { entitiesCollection } = useElectricAgents() const { data: matches = [], isLoading } = useLiveQuery( @@ -215,8 +225,11 @@ function EntityHost({ ) } if (view === `chat-log`) { + const style = { + '--mobile-chat-bottom-inset': `${Math.max(0, bottomInset ?? 0)}px`, + } as CSSProperties return ( -
+