|
| 1 | +import { useMemo } from 'react' |
| 2 | +import { StyleSheet, Text, View } from 'react-native' |
| 3 | +import { useLiveQuery } from '@tanstack/react-db' |
| 4 | +import { |
| 5 | + formatAbsoluteDateTime, |
| 6 | + formatRelativeTime, |
| 7 | +} from '@electric-ax/agents-server-ui/src/lib/formatTime' |
| 8 | +import { |
| 9 | + getEntityRunnerId, |
| 10 | + resolveEffectiveSandbox, |
| 11 | + resolveRunner, |
| 12 | + runnerDisplayLabel, |
| 13 | +} from '@electric-ax/agents-server-ui/src/lib/entityRuntime' |
| 14 | +import { |
| 15 | + BottomSheet, |
| 16 | + BottomSheetItem, |
| 17 | + BottomSheetSection, |
| 18 | + BottomSheetSeparator, |
| 19 | +} from './BottomSheet' |
| 20 | +import { Icon } from './Icon' |
| 21 | +import { useAgents } from '../lib/AgentsProvider' |
| 22 | +import { getEntityDisplayTitle, type ElectricEntity } from '../lib/agentsClient' |
| 23 | +import { useTokens } from '../lib/ThemeProvider' |
| 24 | +import { fontSize, monoFontFamily } from '../lib/theme' |
| 25 | +import type { Tokens } from '../lib/theme' |
| 26 | + |
| 27 | +/** |
| 28 | + * Long-press context menu for a session row on the home screen. |
| 29 | + * Mobile counterpart of two web-sidebar hover affordances at once: |
| 30 | + * the row info popout (`SidebarRowInfo` — same fields, same |
| 31 | + * formatting helpers) and the pin toggle. In tree mode only root |
| 32 | + * rows open it (the caller gates it, matching the web sidebar); |
| 33 | + * search hits open it at any depth, like the desktop tile menu — |
| 34 | + * a pinned child hoists into the Pinned section. |
| 35 | + */ |
| 36 | +export function SessionRowMenu({ |
| 37 | + open, |
| 38 | + onClose, |
| 39 | + entity, |
| 40 | + childCount, |
| 41 | + pinned, |
| 42 | + onTogglePin, |
| 43 | +}: { |
| 44 | + open: boolean |
| 45 | + onClose: () => void |
| 46 | + entity: ElectricEntity | null |
| 47 | + childCount: number |
| 48 | + pinned: boolean |
| 49 | + onTogglePin: () => void |
| 50 | +}): React.ReactElement { |
| 51 | + const tokens = useTokens() |
| 52 | + |
| 53 | + return ( |
| 54 | + <BottomSheet open={open} onClose={onClose}> |
| 55 | + {entity && ( |
| 56 | + <> |
| 57 | + <EntityInfo entity={entity} childCount={childCount} /> |
| 58 | + <BottomSheetSeparator /> |
| 59 | + </> |
| 60 | + )} |
| 61 | + <BottomSheetSection> |
| 62 | + <BottomSheetItem |
| 63 | + label={pinned ? `Unpin` : `Pin`} |
| 64 | + icon={ |
| 65 | + <Icon name="pin" size={18} color={tokens.text2} strokeWidth={2} /> |
| 66 | + } |
| 67 | + onPress={() => { |
| 68 | + onTogglePin() |
| 69 | + onClose() |
| 70 | + }} |
| 71 | + /> |
| 72 | + </BottomSheetSection> |
| 73 | + </BottomSheet> |
| 74 | + ) |
| 75 | +} |
| 76 | + |
| 77 | +/** |
| 78 | + * Info header — field-for-field mirror of the web hover card. Split |
| 79 | + * out so the runners live query only subscribes while the sheet has |
| 80 | + * an entity (it stays mounted, closed, for the screen's lifetime). |
| 81 | + */ |
| 82 | +function EntityInfo({ |
| 83 | + entity, |
| 84 | + childCount, |
| 85 | +}: { |
| 86 | + entity: ElectricEntity |
| 87 | + childCount: number |
| 88 | +}): React.ReactElement { |
| 89 | + const tokens = useTokens() |
| 90 | + const styles = useMemo(() => createStyles(tokens), [tokens]) |
| 91 | + const { runnersCollection } = useAgents() |
| 92 | + |
| 93 | + // Resolve runner/sandbox labels via the shared pure helpers (the |
| 94 | + // web's `useEntityRuntimeInfo` reads its own provider context, so |
| 95 | + // mobile queries its runners collection directly). |
| 96 | + const { data: runners = [] } = useLiveQuery( |
| 97 | + (q) => q.from({ r: runnersCollection }), |
| 98 | + [runnersCollection] |
| 99 | + ) |
| 100 | + |
| 101 | + const runnerId = getEntityRunnerId(entity) |
| 102 | + const runner = resolveRunner(runners, runnerId) |
| 103 | + const runnerLabel = runnerId ? runnerDisplayLabel(runner, runnerId) : null |
| 104 | + const sandboxLabel = resolveEffectiveSandbox(runners, entity, runner).label |
| 105 | + |
| 106 | + return ( |
| 107 | + <View style={styles.info}> |
| 108 | + <Text style={styles.infoTitle} numberOfLines={2}> |
| 109 | + {getEntityDisplayTitle(entity)} |
| 110 | + </Text> |
| 111 | + <Text style={styles.infoId} numberOfLines={1}> |
| 112 | + {entity.url.replace(/^\//, ``)} |
| 113 | + </Text> |
| 114 | + <Text style={styles.infoMeta} numberOfLines={1}> |
| 115 | + {entity.type} · {entity.status} |
| 116 | + {childCount > 0 |
| 117 | + ? ` · ${childCount} subagent${childCount === 1 ? `` : `s`}` |
| 118 | + : ``} |
| 119 | + </Text> |
| 120 | + <View style={styles.infoRows}> |
| 121 | + {runnerLabel && <InfoRow label="Runner" value={runnerLabel} />} |
| 122 | + {sandboxLabel && <InfoRow label="Sandbox" value={sandboxLabel} />} |
| 123 | + <InfoRow |
| 124 | + label="Spawned" |
| 125 | + value={formatAbsoluteDateTime(entity.created_at)} |
| 126 | + /> |
| 127 | + <InfoRow |
| 128 | + label="Last active" |
| 129 | + value={formatRelativeTime(entity.updated_at)} |
| 130 | + /> |
| 131 | + </View> |
| 132 | + </View> |
| 133 | + ) |
| 134 | +} |
| 135 | + |
| 136 | +function InfoRow({ |
| 137 | + label, |
| 138 | + value, |
| 139 | +}: { |
| 140 | + label: string |
| 141 | + value: string |
| 142 | +}): React.ReactElement { |
| 143 | + const tokens = useTokens() |
| 144 | + const styles = useMemo(() => createStyles(tokens), [tokens]) |
| 145 | + return ( |
| 146 | + <View style={styles.infoRow}> |
| 147 | + <Text style={styles.infoRowLabel}>{label}</Text> |
| 148 | + <Text style={styles.infoRowValue} numberOfLines={1}> |
| 149 | + {value} |
| 150 | + </Text> |
| 151 | + </View> |
| 152 | + ) |
| 153 | +} |
| 154 | + |
| 155 | +function createStyles(tokens: Tokens) { |
| 156 | + return StyleSheet.create({ |
| 157 | + info: { |
| 158 | + paddingHorizontal: 12, |
| 159 | + paddingTop: 6, |
| 160 | + paddingBottom: 8, |
| 161 | + gap: 2, |
| 162 | + }, |
| 163 | + infoTitle: { |
| 164 | + color: tokens.text1, |
| 165 | + fontSize: fontSize.base, |
| 166 | + fontWeight: `500`, |
| 167 | + lineHeight: 20, |
| 168 | + }, |
| 169 | + infoId: { |
| 170 | + color: tokens.text3, |
| 171 | + fontSize: fontSize.sm, |
| 172 | + fontFamily: monoFontFamily, |
| 173 | + }, |
| 174 | + infoMeta: { |
| 175 | + color: tokens.text3, |
| 176 | + fontSize: fontSize.sm, |
| 177 | + }, |
| 178 | + infoRows: { |
| 179 | + marginTop: 8, |
| 180 | + gap: 4, |
| 181 | + }, |
| 182 | + infoRow: { |
| 183 | + flexDirection: `row`, |
| 184 | + alignItems: `baseline`, |
| 185 | + gap: 12, |
| 186 | + }, |
| 187 | + infoRowLabel: { |
| 188 | + width: 80, |
| 189 | + flexShrink: 0, |
| 190 | + color: tokens.text3, |
| 191 | + fontSize: fontSize.sm, |
| 192 | + }, |
| 193 | + infoRowValue: { |
| 194 | + flex: 1, |
| 195 | + color: tokens.text2, |
| 196 | + fontSize: fontSize.sm, |
| 197 | + }, |
| 198 | + }) |
| 199 | +} |
0 commit comments