Skip to content

Commit 3dbd075

Browse files
msfstefclaude
andauthored
feat(agents-mobile): pin sessions via long-press context menu (#4507)
## Intent The web sidebar lets users pin sessions so frequently-used ones stay at the top regardless of grouping; mobile had no equivalent, so important sessions sink into the date buckets. This ports the pinning feature to the mobile app, adapted for touch — and folds the web's row *hover info card* into the same surface, since mobile had no way to see session metadata from the list either. ## UX - **Long-press a root session row — or any search result** → bottom sheet with the entity info + a **Pin/Unpin** action. - Info is a field-for-field mirror of the web hover card (`SidebarRowInfo`): title, mono session id, `type · status · N subagents`, Runner (only when a runner is pinned), Sandbox (incl. the implicit "Local" default), Spawned (absolute), Last active (relative) — using the same shared formatters/helpers, so values match the web exactly. - **In-session kebab menu** also gets a **Pin/Unpin** item — the mirror of the desktop tile menu's Pin/Unpin (and the ⌘K "Pin current entity" action), so a session can be pinned from inside it. Together with search long-press this closes the "searched for a session, now want to pin it" gap. - **Pinned section** renders above the date/type/status groups; pinned roots are removed from the groups and from parent subtrees below (same de-dup as `SidebarTree`), ordered by recency like the web. - Pinned entities **respect the type/status visibility filters** (web parity — the pinned set derives from the filtered entities). - In tree mode pinning is **root-only** (web-sidebar parity); long-press on child subtree rows is a no-op. **Search results accept long-press at any depth** (desktop-tile-menu parity) — a pinned child hoists into the Pinned section and is filtered out of its parent's subtree. ### Deliberate divergences from the web (touch context) | Web | Mobile | Why | |---|---|---| | Hover reveals pin icon + info card | Long-press sheet combining both | no hover on touch | | Pinned section header is collapsible | Not collapsible | no mobile section is collapsible today | | ⌘K palette groups search results into Pinned/Sessions | Search results stay a flat list (no pinned section/indicator) | search targets a known session by name; can add later | | "Last active" has absolute-time tooltip | Relative only | no tooltip affordance | | Per-row pinned glyph (filled, rotated) | None — section placement is the indicator | rows stay clean | ## Implementation choices - **Storage**: per-device AsyncStorage array of entity urls (`electric-agents-mobile.pinned-entities`) — the mobile mirror of the web's `localStorage` model; not synced to the server. Module-level store like `sidebarPrefs.ts`/`savedServers.ts` (whole-set subscription — a pin toggle changes list composition, so per-url listener buckets à la `expandedTree.ts` would buy nothing), incl. the `dirty`-guarded hydration merge so a toggle during the startup window isn't clobbered. Unit-tested (vitest, gated AsyncStorage mock that genuinely exercises the pre-hydration path). - **Synced columns extended**: entities now sync `dispatch_policy` + `sandbox`, runners sync `sandbox_profiles` (zod defs mirrored from `ElectricAgentsProvider.tsx`) so the menu can resolve runner/sandbox labels. - **Shared-code reuse over reimplementation**: the menu reuses `formatTime` + `entityRuntime` helpers from `agents-server-ui`. The only shared-file change is type-level — `entityRuntime`'s *runner* params are loosened to a structural `Pick` subset (`RunnerLike`) since mobile's runner schema is narrower; entity params were left untouched (mobile's entity type is fully assignable). - **Tree-mode root gating by construction**: `onLongPressRoot` isn't forwarded into the `SessionTree` recursion, so child subtree rows can't get a menu; `pinnedSet` *is* forwarded so pinned entities are filtered out of subtrees before child-count/connector geometry is computed. Search rows wire the long-press directly (flat list, any depth) — safe because the subtree filtering above already handles pinned children. - The in-session menu reads/toggles the pin via the module store directly (no prop-drilling through `SessionScreen`), closing the sheet on toggle like the row menu does. - The subagent count in the info sheet is pinned-filtered, matching both the rendered subtree and the web hover card. - **Review follow-ups** (from the Claude review): the sheet re-resolves the long-pressed entity from the live query while open (snapshot fallback), so its header tracks status/title updates like the child count already did; the runners live query mounts only while the sheet has an entity instead of subscribing for the screen's lifetime; rows with a context menu expose an `accessibilityHint` so the long-press affordance is announced to screen readers. ## Testing - `pnpm --filter @electric-ax/agents-mobile typecheck && test` — 26 passed (7 new for the pin store) - `pnpm --filter @electric-ax/agents-server-ui typecheck && test` — 66 passed (type-only change) - No RN component-test harness in the package, so the UI wiring was verified by review (independent parity + scope reviews) — worth a manual pass in Expo before merge: pin/unpin round-trip, persistence across relaunch, child-row no-op in tree mode, pin from a search result, pin/unpin from the in-session menu, filter interaction. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5aa2d78 commit 3dbd075

13 files changed

Lines changed: 640 additions & 23 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@electric-ax/agents-mobile": patch
3+
"@electric-ax/agents-server-ui": patch
4+
---
5+
6+
Add session pinning to the mobile app: long-press a root session row (or any search result) to open a context sheet with the entity info (title, session id, type/status, subagents, runner, sandbox, spawned, last active) and a Pin/Unpin action; the in-session kebab menu also gets a Pin/Unpin item, mirroring the desktop tile menu. Pinned sessions surface in a Pinned section above the groups, persisted per-device in AsyncStorage — the mobile mirror of the web sidebar's pinning. Runner-param types in agents-server-ui's `entityRuntime` helpers are loosened to structural subsets so the mobile app can reuse them.

packages/agents-mobile/src/components/Icon.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export type IconName =
3939
| `cloud`
4040
| `user`
4141
| `users`
42+
| `pin`
4243

4344
const PATHS: Record<IconName, string> = {
4445
back: `M15 18l-6-6 6-6`,
@@ -64,6 +65,8 @@ const PATHS: Record<IconName, string> = {
6465
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`,
6566
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`,
6667
'arrow-up': `M12 19V5M5 12l7-7 7 7`,
68+
// Lucide `pin` — matches the desktop sidebar's pin toggle glyph.
69+
pin: `M12 17v5M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z`,
6770
square: `M7 7h10v10H7z`,
6871
github: `M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4M9 18c-4.51 2-5-2-7-2`,
6972
// Official Google "G" mark, rendered in a single fill colour. Google's

packages/agents-mobile/src/components/SessionMenu.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from './BottomSheet'
99
import { Icon } from './Icon'
1010
import { useDrillTransition } from './useDrillTransition'
11+
import { togglePin, usePinnedUrls } from '../lib/pinnedEntities'
1112
import { useTokens } from '../lib/ThemeProvider'
1213
import type { ElectricEntity, EntitySignal } from '../lib/agentsClient'
1314
import type { EmbedViewId } from '../lib/embedView'
@@ -101,7 +102,8 @@ const SIGNAL_OPTION_GROUPS: ReadonlyArray<
101102

102103
/**
103104
* Bottom-sheet "more" menu for the chat screen — exposes the view
104-
* toggle (chat / state explorer) plus a status header. Tapping a
105+
* toggle (chat / state explorer), a pin toggle (mirror of the
106+
* desktop tile menu's Pin/Unpin), plus a status header. Tapping a
105107
* row immediately switches the view and dismisses the sheet, so the
106108
* user can swap between modes in one tap-tap gesture (kebab → mode).
107109
*/
@@ -127,6 +129,8 @@ export function SessionMenu({
127129
signalDisabled?: boolean
128130
}): React.ReactElement {
129131
const tokens = useTokens()
132+
const pinnedUrls = usePinnedUrls()
133+
const pinned = entity !== null && pinnedUrls.includes(entity.url)
130134
const [signalMenuOpen, setSignalMenuOpen] = useState(false)
131135
const {
132136
style: drillPaneStyle,
@@ -318,6 +322,28 @@ export function SessionMenu({
318322
onPress={() => handlePick(`state-explorer`)}
319323
/>
320324
</BottomSheetSection>
325+
{entity && (
326+
<>
327+
<BottomSheetSeparator />
328+
<BottomSheetSection>
329+
<BottomSheetItem
330+
label={pinned ? `Unpin` : `Pin`}
331+
icon={
332+
<Icon
333+
name="pin"
334+
size={18}
335+
color={tokens.text2}
336+
strokeWidth={2}
337+
/>
338+
}
339+
onPress={() => {
340+
togglePin(entity.url)
341+
handleClose()
342+
}}
343+
/>
344+
</BottomSheetSection>
345+
</>
346+
)}
321347
{canOpenSignalMenu && (
322348
<>
323349
<BottomSheetSeparator />

packages/agents-mobile/src/components/SessionRow.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export const SessionRow = memo(function SessionRow({
5959
expanded = false,
6060
onToggleExpand,
6161
onPress,
62+
onLongPress,
6263
connector = null,
6364
currentPrincipalUrl = null,
6465
}: {
@@ -68,6 +69,7 @@ export const SessionRow = memo(function SessionRow({
6869
expanded?: boolean
6970
onToggleExpand?: () => void
7071
onPress: () => void
72+
onLongPress?: () => void
7173
connector?: SessionRowConnector | null
7274
currentPrincipalUrl?: string | null
7375
}): React.ReactElement {
@@ -93,7 +95,16 @@ export const SessionRow = memo(function SessionRow({
9395
/>
9496
) : null}
9597

96-
<Pressable style={[styles.rowMain, { paddingLeft }]} onPress={onPress}>
98+
<Pressable
99+
style={[styles.rowMain, { paddingLeft }]}
100+
onPress={onPress}
101+
onLongPress={onLongPress}
102+
// 350ms; RN's 500ms default feels unresponsive for a context menu.
103+
delayLongPress={350}
104+
accessibilityHint={
105+
onLongPress ? `Long press for session options` : undefined
106+
}
107+
>
97108
{({ pressed }) => (
98109
<>
99110
<View
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
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+
}

packages/agents-mobile/src/components/SessionTree.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ export const SessionTree = memo(function SessionTree({
3636
parentTrunkX,
3737
onSelectEntity,
3838
currentPrincipalUrl = null,
39+
onLongPressRoot,
40+
pinnedSet,
3941
}: {
4042
entity: ElectricEntity
4143
childrenByParent: Map<string, Array<ElectricEntity>>
@@ -49,9 +51,16 @@ export const SessionTree = memo(function SessionTree({
4951
parentTrunkX?: number
5052
onSelectEntity: (url: string) => void
5153
currentPrincipalUrl?: string | null
54+
/** Opens the row context menu; not forwarded to children (pinning is root-only). */
55+
onLongPressRoot?: (entity: ElectricEntity) => void
56+
/** Pinned entities render in the Pinned section, so filter them out of subtrees. */
57+
pinnedSet?: ReadonlySet<string>
5258
}): React.ReactElement {
5359
const expanded = useIsExpanded(entity.url)
54-
const children = childrenByParent.get(entity.url) ?? []
60+
const allChildren = childrenByParent.get(entity.url) ?? []
61+
const children = pinnedSet
62+
? allChildren.filter((c) => !pinnedSet.has(c.url))
63+
: allChildren
5564
const hasChildren = children.length > 0
5665

5766
// Trunk x for *this* row's children, computed once per render.
@@ -69,6 +78,9 @@ export const SessionTree = memo(function SessionTree({
6978
}
7079
onPress={() => onSelectEntity(entity.url)}
7180
currentPrincipalUrl={currentPrincipalUrl}
81+
onLongPress={
82+
onLongPressRoot ? () => onLongPressRoot(entity) : undefined
83+
}
7284
connector={
7385
parentTrunkX !== undefined
7486
? { trunkX: parentTrunkX, isLastSibling }
@@ -87,6 +99,7 @@ export const SessionTree = memo(function SessionTree({
8799
parentTrunkX={myTrunkX}
88100
onSelectEntity={onSelectEntity}
89101
currentPrincipalUrl={currentPrincipalUrl}
102+
pinnedSet={pinnedSet}
90103
/>
91104
))}
92105
</View>

0 commit comments

Comments
 (0)