Skip to content

Commit 0b26edf

Browse files
msfstefclaude
andauthored
feat(agents-mobile): session sharing, access management & copy/share links (desktop parity) (#4564)
## Summary Brings session sharing to `agents-mobile`, closing the parity gap with the desktop `ShareEntityDialog` while using mobile-native UX paradigms. Mobile previously only *consumed* effective permissions to gate actions; it can now manage who has access to a session, copy the session id, and share a browser-openable session link. Part of the agents-mobile ↔ agents-desktop parity track (follows #4553, #4546). ## What's included ### 1. Share session screen (`ShareSessionScreen` + `app/session-share.tsx` modal route) Opened from the session kebab menu's **Share** entry. Single-column layout, top to bottom: - **Session title + copyable id** (tap id → copy, copy→check icon swap). - **Session link pill** — abbreviated web URL (scheme stripped, middle-ellipsized) with a share glyph; one tap opens the **native OS share sheet** (which includes Copy, so there is no separate in-app "Copy link"). - **People with access** — pinned, non-removable **Owner** row derived from `entity.created_by` (omitted when null), then one row per grantee (avatar/initials, name/email, role pill). Desktop doesn't show the owner; mobile adds it so the list is never confusingly empty. - **General access** — the workspace-wide *All users* grant (`subject_kind: principal_kind / user`) as its own section (Google-Drive style) rather than mixed into the people list like desktop. - **Add people** — search-first user list (synced `users` table), excluding self/owner/existing grantees. - **Role picker** — tapping any row opens a bottom sheet with **View / Chat / Manage** (same permission sets and eye/message/shield glyphs as desktop) plus a destructive **Remove access** (Alert-confirmed). Roles commit immediately per row — no desktop-style deferred Grant/Update button. ### 2. Copy session id The session menu's status header now shows the id (in place of the agent type label) with a tap-to-copy affordance; same treatment on the long-press row sheet's id line. Mirrors the desktop `EntityHeader` copy-id pattern (1.2s icon swap, via the new `useCopyFeedback` hook + `expo-clipboard`). ### 3. Session web links `sessionWebUrl(serverUrl, entityUrl)` builds `{serverUrl}/__agent_ui/#/entity/{id}`. ## Key decisions & why | Decision | Rationale | |---|---| | Grants come from REST `GET /_electric/entities/:type/:id/grants`, not sync | The synced `entity_effective_permissions` shape is scoped to the **current principal only** (`server-utils.ts` `buildCurrentPrincipalEntityEffectivePermissionsWhere`), so it cannot list other people's access. Same approach as desktop's `loadGrants()`. Refetch-after-mutation (no optimistic grant state) because the server owns grant ids needed for subsequent diffs. | | Web URL targets `/__agent_ui/` directly, not the server root | The root `302` redirect uses an absolute `location: /__agent_ui/`, which would drop a Cloud `/t/<service-id>/v1` tenant prefix. The session id stays **un-encoded** to match the web UI's hash splat route (`/entity/$`). | | Share screen reachable by **everyone** (no `manage` gate on the menu entry) | Link sharing must work for non-managers. The grant sections self-gate: the manage-protected endpoint returns 401/403 → the screen shows a manage-required message (this also gracefully handles revoking your own manage access mid-session). | | Single share affordance (link pill → native sheet), no in-app Copy link | The native sheet includes Copy; two buttons for the same URL was duplicative. The pill shows the URL being shared (Meet/Zoom pattern). In-app clipboard copy remains only for the session **id**. | | One **Share** menu item next to Pin | Earlier iteration had Copy id / Copy link / Share… / Share & access — collapsed after review feedback. | | `userDisplay()`/`initials()` extracted to `agents-server-ui/src/lib/userDisplay.ts` | Mobile deep-imports shared lib code (existing pattern: `principals`, `sharePermissions`, `entity-api`, `auth-fetch`); extraction avoids drift vs. duplicating. Internal lib file only — no public API change, desktop dialog updated to import it. | | Inbound links **out of scope** | Universal links (https → app) need associated-domain setup; planned follow-up. The web URL format was chosen so those links can later deep-link into the app once a domain is verified. | ## Architecture notes (for future sessions on this PR) - `src/lib/entityGrants.ts` — all grant logic, pure where possible: REST wrappers (`listEntityGrants`/`createEntityGrant`/`deleteEntityGrant`, errors throw `GrantsRequestError` carrying `status`), `diffGrantsForRole` (set-based create/delete diff, ignores non-share custom permissions, handles duplicate rows), `grantIdsForRemoval`, `buildShareAccessModel` (groups grants → all-users + per-user entries with roles, drops current user / system principals / role-less subjects), `setSubjectRole` / `removeSubjectAccess` orchestration. Unit-tested with the repo's hoisted `serverFetch` mock pattern. - `src/lib/sessionLinks.ts` — `sessionIdFromEntityUrl`, `sessionWebUrl` (pure, unit-tested incl. Cloud prefix + malformed-URL fallback). - Role semantics live in shared `agents-server-ui/src/lib/sharePermissions.ts`: View=[read,fork], Chat=[read,write,signal,fork,schedule,spawn], Manage=[manage,delete]; `roleFromGrants` / `rolePermissionsMatchGrants` reused. - `SessionMenu` takes an optional `onShare` prop threaded from `app/session.tsx` → `SessionScreen` (router push to `/session-share?entityUrl=…`). - `BottomSheetItem` always reserves a 22px icon slot — give items icons or they look indented (why the role options carry glyphs). - Search fields must mirror `SearchBar` metrics (fixed 36px height, zero-padding input) or the placeholder sits off-centre on Android. ## Testing - ✅ 22 new vitest tests (`sessionLinks.test.ts`, `entityGrants.test.ts`) — written test-first; full mobile suite 83/83, `agents-server-ui` 88/88, both packages typecheck clean. - ⬜ Manual QA needed (see below). ### Manual QA checklist - [ ] **Rebuild dev clients** — `expo-clipboard` is a new native module; old dev binaries won't load this JS (the one build-impacting change). - [ ] Open a copied/shared link in a browser against a **local** server and a **Cloud** server (verifies `/__agent_ui/#/entity/{id}` resolves under the `/t/<svc>/v1` prefix — the one assumption not verifiable statically). - [ ] iOS + Android: copy id from menu + long-press sheet; link pill opens native sheet; cancel is silent. - [ ] Manager flow: add user → change role View↔Chat↔Manage → remove → All-users grant add/change/remove; cross-check grants in the desktop ShareEntityDialog. - [ ] Non-manager: share screen shows link actions + manage-required message; no crash. - [ ] Self-revocation: remove the All-users *Manage* grant that granted you manage → screen degrades to access-denied state. ## Out of scope / follow-ups - Universal links / inbound https session links opening the app (needs associated domains + per-deployment config). - Haptic feedback on copy (would add `expo-haptics`). - Sharing for non-session entity types (no mobile detail surfaces exist for them yet). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 176bec8 commit 0b26edf

17 files changed

Lines changed: 1624 additions & 45 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@electric-ax/agents-mobile": patch
3+
"@electric-ax/agents-server-ui": patch
4+
---
5+
6+
Bring session sharing to mobile (desktop `ShareEntityDialog` parity, mobile-first UX):
7+
8+
- **Share session screen.** A modal route opened from the session menu's new **Share** entry. It exposes a link pill (abbreviated session web URL — one tap opens the native OS share sheet, which includes Copy), a "People with access" list with a pinned Owner row, a Google-Drive-style "General access" section for the workspace-wide *All users* grant, and a search-first "Add people" section. Roles (View / Chat / Manage, same permission sets and glyphs as desktop) commit per row through a bottom-sheet picker with a destructive *Remove access* action — no deferred Grant/Update button. The grant list comes from the manage-protected REST `GET /grants` endpoint (the synced effective-permissions shape is scoped to the current principal, so it can't list other people's access); non-managers still get the link actions and see a manage-required message below.
9+
- **Copy session id.** The session menu's status header and the long-press row sheet now render the id with a tap-to-copy affordance (copy→check icon swap, mirroring the desktop entity header), via a new `expo-clipboard` dependency.
10+
- **Session web links.** `sessionWebUrl()` builds `{serverUrl}/__agent_ui/#/entity/{id}` directly — targeting the web UI path rather than the server root, whose absolute-path redirect would drop a Cloud `/t/<service-id>/v1` tenant prefix.
11+
12+
The desktop dialog's `userDisplay()`/`initials()` helpers move into `agents-server-ui`'s `lib/userDisplay.ts` so mobile deep-imports them instead of duplicating. Grant-diffing, removal, and access-model grouping logic is ported into a pure, unit-tested `entityGrants` module. No server API changes.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Stack, useLocalSearchParams, useRouter } from 'expo-router'
2+
import { ShareSessionScreen } from '../src/screens/ShareSessionScreen'
3+
import { useAgentsRouteGuard } from '../src/lib/useAgentsRouteGuard'
4+
5+
export default function SessionShareRoute(): React.ReactElement | null {
6+
const params = useLocalSearchParams<{ entityUrl?: string }>()
7+
const router = useRouter()
8+
const guard = useAgentsRouteGuard()
9+
if (guard) return guard
10+
11+
const entityUrl = Array.isArray(params.entityUrl)
12+
? params.entityUrl[0]
13+
: (params.entityUrl ?? ``)
14+
15+
return (
16+
<>
17+
<Stack.Screen options={{ presentation: `modal` }} />
18+
<ShareSessionScreen
19+
entityUrl={entityUrl}
20+
onBack={() => {
21+
if (router.canGoBack()) router.back()
22+
else router.replace(`/`)
23+
}}
24+
/>
25+
</>
26+
)
27+
}

packages/agents-mobile/app/session.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ function SessionRouteInner({
122122
params: { entityUrl: target, view: `chat` },
123123
})
124124
}
125+
const openShare = (): void => {
126+
router.push({ pathname: `/session-share`, params: { entityUrl } })
127+
}
125128
const setView = (next: EmbedViewId): void => {
126129
router.setParams({ view: next })
127130
}
@@ -176,12 +179,14 @@ function SessionRouteInner({
176179
onComposerHeightChange={setChatComposerHeight}
177180
onSendMessage={() => setChatLogScrollSignal(Date.now())}
178181
onInlineQueuedMessagesChange={setInlineQueuedMessages}
182+
onShare={openShare}
179183
/>
180184
) : (
181185
<StateInspectorSessionScreen
182186
entityUrl={entityUrl}
183187
onBack={goBack}
184188
onSetView={setView}
189+
onShare={openShare}
185190
/>
186191
)}
187192
</View>

packages/agents-mobile/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"@tanstack/react-db": "^0.1.85",
2828
"expo": "54.0.35",
2929
"expo-build-properties": "~1.0.10",
30+
"expo-clipboard": "~8.0.8",
3031
"expo-constants": "~18.0.13",
3132
"expo-image-manipulator": "~14.0.8",
3233
"expo-image-picker": "~17.0.11",

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ export type IconName =
4242
| `pin`
4343
| `image`
4444
| `camera`
45+
| `copy`
46+
| `link`
47+
| `share`
48+
| `eye`
49+
| `shield`
4550

4651
const PATHS: Record<IconName, string> = {
4752
back: `M15 18l-6-6 6-6`,
@@ -73,6 +78,15 @@ const PATHS: Record<IconName, string> = {
7378
// Lucide `image` / `camera` — image attachment affordances.
7479
image: `M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2ZM8.5 11a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM21 15l-5-5L5 21`,
7580
camera: `M9 4 7.5 6H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-2.5L15 4ZM12 17a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z`,
81+
// Lucide `copy` / `link` / `share-2` — session link & share actions
82+
// (`share-2` matches the desktop share dialog's trigger glyph).
83+
copy: `M8 8h13v13H8zM4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2`,
84+
link: `M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71`,
85+
share: `M18 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM6 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM18 22a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98`,
86+
// Lucide `eye` / `shield-check` — share role glyphs, matching the
87+
// desktop share dialog's View/Manage segments.
88+
eye: `M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7ZM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z`,
89+
shield: `M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1 1 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1ZM9 12l2 2 4-4`,
7690
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`,
7791
// Official Google "G" mark, rendered in a single fill colour. Google's
7892
// brand guidelines permit monochrome use in CTA contexts where the

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

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import { useEffect, useState } from 'react'
2-
import { Animated, StyleSheet, Text, View } from 'react-native'
2+
import { Animated, Pressable, StyleSheet, Text, View } from 'react-native'
33
import {
44
BottomSheet,
55
BottomSheetItem,
66
BottomSheetSection,
77
BottomSheetSeparator,
88
} from './BottomSheet'
99
import { Icon } from './Icon'
10+
import { useCopyFeedback } from './useCopyFeedback'
1011
import { useDrillTransition } from './useDrillTransition'
1112
import { togglePin, usePinnedUrls } from '../lib/pinnedEntities'
13+
import { sessionIdFromEntityUrl } from '../lib/sessionLinks'
1214
import { useTokens } from '../lib/ThemeProvider'
15+
import { monoFontFamily } from '../lib/theme'
1316
import type { ElectricEntity, EntitySignal } from '../lib/agentsClient'
1417
import type { EmbedViewId } from '../lib/embedView'
1518

@@ -102,8 +105,10 @@ const SIGNAL_OPTION_GROUPS: ReadonlyArray<
102105

103106
/**
104107
* Bottom-sheet "more" menu for the chat screen — exposes the view
105-
* toggle (chat / state explorer), a pin toggle (mirror of the
106-
* desktop tile menu's Pin/Unpin), plus a status header. Tapping a
108+
* toggle (chat / state explorer), a pin toggle (mirror of the desktop
109+
* tile menu's Pin/Unpin), a Share entry that drills into the share &
110+
* access screen, plus a status header with a copyable session id.
111+
* Tapping a
107112
* row immediately switches the view and dismisses the sheet, so the
108113
* user can swap between modes in one tap-tap gesture (kebab → mode).
109114
*/
@@ -116,6 +121,7 @@ export function SessionMenu({
116121
signalError,
117122
onSignal,
118123
onStopImmediately,
124+
onShare,
119125
signalDisabled = false,
120126
}: {
121127
open: boolean
@@ -126,9 +132,12 @@ export function SessionMenu({
126132
signalError?: string | null
127133
onSignal?: (signal: EntitySignal) => void
128134
onStopImmediately?: () => void
135+
/** Opens the share & access screen. */
136+
onShare?: () => void
129137
signalDisabled?: boolean
130138
}): React.ReactElement {
131139
const tokens = useTokens()
140+
const { copiedKey, copy } = useCopyFeedback()
132141
const pinnedUrls = usePinnedUrls()
133142
const pinned = entity !== null && pinnedUrls.includes(entity.url)
134143
const [signalMenuOpen, setSignalMenuOpen] = useState(false)
@@ -267,15 +276,39 @@ export function SessionMenu({
267276
{entity.status}
268277
</Text>
269278
<View style={{ flex: 1 }} />
270-
<Text
279+
{/* Tappable session id — mirrors the desktop
280+
entity header's copy-id affordance (icon swaps
281+
to a check while the copy feedback is active). */}
282+
<Pressable
283+
onPress={() =>
284+
copy(`id`, sessionIdFromEntityUrl(entity.url))
285+
}
286+
hitSlop={8}
271287
style={{
272-
color: tokens.text3,
273-
fontSize: 12,
274-
textTransform: `lowercase`,
288+
flexDirection: `row`,
289+
alignItems: `center`,
290+
gap: 6,
291+
flexShrink: 1,
275292
}}
276293
>
277-
{entity.type}
278-
</Text>
294+
<Text
295+
numberOfLines={1}
296+
style={{
297+
flexShrink: 1,
298+
color: tokens.text3,
299+
fontSize: 12,
300+
fontFamily: monoFontFamily,
301+
}}
302+
>
303+
{sessionIdFromEntityUrl(entity.url)}
304+
</Text>
305+
<Icon
306+
name={copiedKey === `id` ? `check` : `copy`}
307+
size={14}
308+
color={tokens.text3}
309+
strokeWidth={2}
310+
/>
311+
</Pressable>
279312
</View>
280313
{signalError ? (
281314
<Text
@@ -341,6 +374,31 @@ export function SessionMenu({
341374
handleClose()
342375
}}
343376
/>
377+
{onShare && (
378+
<BottomSheetItem
379+
label="Share"
380+
icon={
381+
<Icon
382+
name="share"
383+
size={18}
384+
color={tokens.text2}
385+
strokeWidth={2}
386+
/>
387+
}
388+
trailing={
389+
<Icon
390+
name="chevron-right"
391+
size={16}
392+
color={tokens.text3}
393+
strokeWidth={2}
394+
/>
395+
}
396+
onPress={() => {
397+
handleClose()
398+
onShare()
399+
}}
400+
/>
401+
)}
344402
</BottomSheetSection>
345403
</>
346404
)}

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

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useMemo } from 'react'
2-
import { StyleSheet, Text, View } from 'react-native'
2+
import { Pressable, StyleSheet, Text, View } from 'react-native'
33
import { useLiveQuery } from '@tanstack/react-db'
44
import {
55
formatAbsoluteDateTime,
@@ -18,8 +18,10 @@ import {
1818
BottomSheetSeparator,
1919
} from './BottomSheet'
2020
import { Icon } from './Icon'
21+
import { useCopyFeedback } from './useCopyFeedback'
2122
import { useAgents } from '../lib/AgentsProvider'
2223
import { getEntityDisplayTitle, type ElectricEntity } from '../lib/agentsClient'
24+
import { sessionIdFromEntityUrl } from '../lib/sessionLinks'
2325
import { useTokens } from '../lib/ThemeProvider'
2426
import { fontSize, monoFontFamily } from '../lib/theme'
2527
import type { Tokens } from '../lib/theme'
@@ -89,6 +91,7 @@ function EntityInfo({
8991
const tokens = useTokens()
9092
const styles = useMemo(() => createStyles(tokens), [tokens])
9193
const { runnersCollection } = useAgents()
94+
const { copiedKey, copy } = useCopyFeedback()
9295

9396
// Resolve runner/sandbox labels via the shared pure helpers (the
9497
// web's `useEntityRuntimeInfo` reads its own provider context, so
@@ -108,9 +111,21 @@ function EntityInfo({
108111
<Text style={styles.infoTitle} numberOfLines={2}>
109112
{getEntityDisplayTitle(entity)}
110113
</Text>
111-
<Text style={styles.infoId} numberOfLines={1}>
112-
{entity.url.replace(/^\//, ``)}
113-
</Text>
114+
<Pressable
115+
onPress={() => copy(`id`, sessionIdFromEntityUrl(entity.url))}
116+
hitSlop={8}
117+
style={styles.infoIdRow}
118+
>
119+
<Text style={styles.infoId} numberOfLines={1}>
120+
{sessionIdFromEntityUrl(entity.url)}
121+
</Text>
122+
<Icon
123+
name={copiedKey === `id` ? `check` : `copy`}
124+
size={14}
125+
color={tokens.text3}
126+
strokeWidth={2}
127+
/>
128+
</Pressable>
114129
<Text style={styles.infoMeta} numberOfLines={1}>
115130
{entity.type} · {entity.status}
116131
{childCount > 0
@@ -166,7 +181,15 @@ function createStyles(tokens: Tokens) {
166181
fontWeight: `500`,
167182
lineHeight: 20,
168183
},
184+
infoIdRow: {
185+
alignSelf: `flex-start`,
186+
flexDirection: `row`,
187+
alignItems: `center`,
188+
gap: 6,
189+
maxWidth: `100%`,
190+
},
169191
infoId: {
192+
flexShrink: 1,
170193
color: tokens.text3,
171194
fontSize: fontSize.sm,
172195
fontFamily: monoFontFamily,
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { useEffect, useRef, useState } from 'react'
2+
import * as Clipboard from 'expo-clipboard'
3+
4+
/**
5+
* Clipboard copy with transient confirmation state — mirrors the
6+
* desktop entity header's 1.2s copy→check icon swap. `copiedKey`
7+
* names the row that last copied so a menu with several copy items
8+
* only flips the one that was tapped.
9+
*/
10+
export function useCopyFeedback(): {
11+
copiedKey: string | null
12+
copy: (key: string, text: string) => void
13+
} {
14+
const [copiedKey, setCopiedKey] = useState<string | null>(null)
15+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
16+
17+
useEffect(() => {
18+
return () => {
19+
if (timer.current) clearTimeout(timer.current)
20+
}
21+
}, [])
22+
23+
const copy = (key: string, text: string): void => {
24+
void Clipboard.setStringAsync(text)
25+
setCopiedKey(key)
26+
if (timer.current) clearTimeout(timer.current)
27+
timer.current = setTimeout(() => setCopiedKey(null), 1200)
28+
}
29+
30+
return { copiedKey, copy }
31+
}

0 commit comments

Comments
 (0)