From 44205086d3ec1621090441da6a4d895d5d6f3c52 Mon Sep 17 00:00:00 2001 From: msfstef Date: Thu, 11 Jun 2026 15:44:36 +0300 Subject: [PATCH 1/2] feat(agents-mobile): session sharing, access management & copy/share links (desktop parity) Ports the desktop ShareEntityDialog feature set to mobile with mobile-native UX: a Share session modal (link pill -> native share sheet, people-with-access list, General access section, search-first add people, per-row role commits via bottom-sheet picker), tap-to-copy session ids in the session menu and long-press sheet, and a sessionWebUrl() helper that targets /__agent_ui/ directly so Cloud tenant prefixes survive. Grant CRUD goes through the manage-protected REST endpoints since the synced effective-permissions shape is self-scoped. Extracts userDisplay/initials from the desktop dialog into agents-server-ui lib for reuse. Co-Authored-By: Claude Fable 5 --- .changeset/agents-mobile-session-sharing.md | 12 + packages/agents-mobile/app/session-share.tsx | 27 + packages/agents-mobile/app/session.tsx | 5 + packages/agents-mobile/package.json | 1 + .../agents-mobile/src/components/Icon.tsx | 14 + .../src/components/SessionMenu.tsx | 76 +- .../src/components/SessionRowMenu.tsx | 31 +- .../src/components/useCopyFeedback.ts | 31 + .../src/lib/entityGrants.test.ts | 273 +++++++ .../agents-mobile/src/lib/entityGrants.ts | 247 ++++++ .../src/lib/sessionLinks.test.ts | 53 ++ .../agents-mobile/src/lib/sessionLinks.ts | 25 + .../src/screens/SessionScreen.tsx | 9 + .../src/screens/ShareSessionScreen.tsx | 743 ++++++++++++++++++ .../src/components/ShareEntityDialog.tsx | 29 +- .../agents-server-ui/src/lib/userDisplay.ts | 38 + pnpm-lock.yaml | 16 + 17 files changed, 1589 insertions(+), 41 deletions(-) create mode 100644 .changeset/agents-mobile-session-sharing.md create mode 100644 packages/agents-mobile/app/session-share.tsx create mode 100644 packages/agents-mobile/src/components/useCopyFeedback.ts create mode 100644 packages/agents-mobile/src/lib/entityGrants.test.ts create mode 100644 packages/agents-mobile/src/lib/entityGrants.ts create mode 100644 packages/agents-mobile/src/lib/sessionLinks.test.ts create mode 100644 packages/agents-mobile/src/lib/sessionLinks.ts create mode 100644 packages/agents-mobile/src/screens/ShareSessionScreen.tsx create mode 100644 packages/agents-server-ui/src/lib/userDisplay.ts diff --git a/.changeset/agents-mobile-session-sharing.md b/.changeset/agents-mobile-session-sharing.md new file mode 100644 index 0000000000..dc374bf794 --- /dev/null +++ b/.changeset/agents-mobile-session-sharing.md @@ -0,0 +1,12 @@ +--- +"@electric-ax/agents-mobile": patch +"@electric-ax/agents-server-ui": patch +--- + +Bring session sharing to mobile (desktop `ShareEntityDialog` parity, mobile-first UX): + +- **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. +- **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. +- **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//v1` tenant prefix. + +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. diff --git a/packages/agents-mobile/app/session-share.tsx b/packages/agents-mobile/app/session-share.tsx new file mode 100644 index 0000000000..7a7328866b --- /dev/null +++ b/packages/agents-mobile/app/session-share.tsx @@ -0,0 +1,27 @@ +import { Stack, useLocalSearchParams, useRouter } from 'expo-router' +import { ShareSessionScreen } from '../src/screens/ShareSessionScreen' +import { useAgentsRouteGuard } from '../src/lib/useAgentsRouteGuard' + +export default function SessionShareRoute(): React.ReactElement | null { + const params = useLocalSearchParams<{ entityUrl?: string }>() + const router = useRouter() + const guard = useAgentsRouteGuard() + if (guard) return guard + + const entityUrl = Array.isArray(params.entityUrl) + ? params.entityUrl[0] + : (params.entityUrl ?? ``) + + return ( + <> + + { + if (router.canGoBack()) router.back() + else router.replace(`/`) + }} + /> + + ) +} diff --git a/packages/agents-mobile/app/session.tsx b/packages/agents-mobile/app/session.tsx index ac39c9e4b9..f2cc587217 100644 --- a/packages/agents-mobile/app/session.tsx +++ b/packages/agents-mobile/app/session.tsx @@ -122,6 +122,9 @@ function SessionRouteInner({ params: { entityUrl: target, view: `chat` }, }) } + const openShare = (): void => { + router.push({ pathname: `/session-share`, params: { entityUrl } }) + } const setView = (next: EmbedViewId): void => { router.setParams({ view: next }) } @@ -176,12 +179,14 @@ function SessionRouteInner({ onComposerHeightChange={setChatComposerHeight} onSendMessage={() => setChatLogScrollSignal(Date.now())} onInlineQueuedMessagesChange={setInlineQueuedMessages} + onShare={openShare} /> ) : ( )} diff --git a/packages/agents-mobile/package.json b/packages/agents-mobile/package.json index 2f18cee9bc..ba8677c2c3 100644 --- a/packages/agents-mobile/package.json +++ b/packages/agents-mobile/package.json @@ -27,6 +27,7 @@ "@tanstack/react-db": "^0.1.85", "expo": "54.0.35", "expo-build-properties": "~1.0.10", + "expo-clipboard": "~8.0.8", "expo-constants": "~18.0.13", "expo-image-manipulator": "~14.0.8", "expo-image-picker": "~17.0.11", diff --git a/packages/agents-mobile/src/components/Icon.tsx b/packages/agents-mobile/src/components/Icon.tsx index 00f6c6787f..a25404e48e 100644 --- a/packages/agents-mobile/src/components/Icon.tsx +++ b/packages/agents-mobile/src/components/Icon.tsx @@ -42,6 +42,11 @@ export type IconName = | `pin` | `image` | `camera` + | `copy` + | `link` + | `share` + | `eye` + | `shield` const PATHS: Record = { back: `M15 18l-6-6 6-6`, @@ -73,6 +78,15 @@ const PATHS: Record = { // Lucide `image` / `camera` — image attachment affordances. 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`, 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`, + // Lucide `copy` / `link` / `share-2` — session link & share actions + // (`share-2` matches the desktop share dialog's trigger glyph). + copy: `M8 8h13v13H8zM4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2`, + 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`, + 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`, + // Lucide `eye` / `shield-check` — share role glyphs, matching the + // desktop share dialog's View/Manage segments. + 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`, + 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`, 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`, // Official Google "G" mark, rendered in a single fill colour. Google's // brand guidelines permit monochrome use in CTA contexts where the diff --git a/packages/agents-mobile/src/components/SessionMenu.tsx b/packages/agents-mobile/src/components/SessionMenu.tsx index 6d98d91dac..201dd55f0b 100644 --- a/packages/agents-mobile/src/components/SessionMenu.tsx +++ b/packages/agents-mobile/src/components/SessionMenu.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { Animated, StyleSheet, Text, View } from 'react-native' +import { Animated, Pressable, StyleSheet, Text, View } from 'react-native' import { BottomSheet, BottomSheetItem, @@ -7,9 +7,12 @@ import { BottomSheetSeparator, } from './BottomSheet' import { Icon } from './Icon' +import { useCopyFeedback } from './useCopyFeedback' import { useDrillTransition } from './useDrillTransition' import { togglePin, usePinnedUrls } from '../lib/pinnedEntities' +import { sessionIdFromEntityUrl } from '../lib/sessionLinks' import { useTokens } from '../lib/ThemeProvider' +import { monoFontFamily } from '../lib/theme' import type { ElectricEntity, EntitySignal } from '../lib/agentsClient' import type { EmbedViewId } from '../lib/embedView' @@ -102,8 +105,10 @@ const SIGNAL_OPTION_GROUPS: ReadonlyArray< /** * Bottom-sheet "more" menu for the chat screen — exposes the view - * toggle (chat / state explorer), a pin toggle (mirror of the - * desktop tile menu's Pin/Unpin), plus a status header. Tapping a + * toggle (chat / state explorer), a pin toggle (mirror of the desktop + * tile menu's Pin/Unpin), a Share entry that drills into the share & + * access screen, plus a status header with a copyable session id. + * Tapping a * row immediately switches the view and dismisses the sheet, so the * user can swap between modes in one tap-tap gesture (kebab → mode). */ @@ -116,6 +121,7 @@ export function SessionMenu({ signalError, onSignal, onStopImmediately, + onShare, signalDisabled = false, }: { open: boolean @@ -126,9 +132,12 @@ export function SessionMenu({ signalError?: string | null onSignal?: (signal: EntitySignal) => void onStopImmediately?: () => void + /** Opens the share & access screen. */ + onShare?: () => void signalDisabled?: boolean }): React.ReactElement { const tokens = useTokens() + const { copiedKey, copy } = useCopyFeedback() const pinnedUrls = usePinnedUrls() const pinned = entity !== null && pinnedUrls.includes(entity.url) const [signalMenuOpen, setSignalMenuOpen] = useState(false) @@ -267,15 +276,39 @@ export function SessionMenu({ {entity.status} - + copy(`id`, sessionIdFromEntityUrl(entity.url)) + } + hitSlop={8} style={{ - color: tokens.text3, - fontSize: 12, - textTransform: `lowercase`, + flexDirection: `row`, + alignItems: `center`, + gap: 6, + flexShrink: 1, }} > - {entity.type} - + + {sessionIdFromEntityUrl(entity.url)} + + + {signalError ? ( + {onShare && ( + + } + trailing={ + + } + onPress={() => { + handleClose() + onShare() + }} + /> + )} )} diff --git a/packages/agents-mobile/src/components/SessionRowMenu.tsx b/packages/agents-mobile/src/components/SessionRowMenu.tsx index 911d707d37..29f4f65030 100644 --- a/packages/agents-mobile/src/components/SessionRowMenu.tsx +++ b/packages/agents-mobile/src/components/SessionRowMenu.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { StyleSheet, Text, View } from 'react-native' +import { Pressable, StyleSheet, Text, View } from 'react-native' import { useLiveQuery } from '@tanstack/react-db' import { formatAbsoluteDateTime, @@ -18,8 +18,10 @@ import { BottomSheetSeparator, } from './BottomSheet' import { Icon } from './Icon' +import { useCopyFeedback } from './useCopyFeedback' import { useAgents } from '../lib/AgentsProvider' import { getEntityDisplayTitle, type ElectricEntity } from '../lib/agentsClient' +import { sessionIdFromEntityUrl } from '../lib/sessionLinks' import { useTokens } from '../lib/ThemeProvider' import { fontSize, monoFontFamily } from '../lib/theme' import type { Tokens } from '../lib/theme' @@ -89,6 +91,7 @@ function EntityInfo({ const tokens = useTokens() const styles = useMemo(() => createStyles(tokens), [tokens]) const { runnersCollection } = useAgents() + const { copiedKey, copy } = useCopyFeedback() // Resolve runner/sandbox labels via the shared pure helpers (the // web's `useEntityRuntimeInfo` reads its own provider context, so @@ -108,9 +111,21 @@ function EntityInfo({ {getEntityDisplayTitle(entity)} - - {entity.url.replace(/^\//, ``)} - + copy(`id`, sessionIdFromEntityUrl(entity.url))} + hitSlop={8} + style={styles.infoIdRow} + > + + {sessionIdFromEntityUrl(entity.url)} + + + {entity.type} · {entity.status} {childCount > 0 @@ -166,7 +181,15 @@ function createStyles(tokens: Tokens) { fontWeight: `500`, lineHeight: 20, }, + infoIdRow: { + alignSelf: `flex-start`, + flexDirection: `row`, + alignItems: `center`, + gap: 6, + maxWidth: `100%`, + }, infoId: { + flexShrink: 1, color: tokens.text3, fontSize: fontSize.sm, fontFamily: monoFontFamily, diff --git a/packages/agents-mobile/src/components/useCopyFeedback.ts b/packages/agents-mobile/src/components/useCopyFeedback.ts new file mode 100644 index 0000000000..790ea3bf79 --- /dev/null +++ b/packages/agents-mobile/src/components/useCopyFeedback.ts @@ -0,0 +1,31 @@ +import { useEffect, useRef, useState } from 'react' +import * as Clipboard from 'expo-clipboard' + +/** + * Clipboard copy with transient confirmation state — mirrors the + * desktop entity header's 1.2s copy→check icon swap. `copiedKey` + * names the row that last copied so a menu with several copy items + * only flips the one that was tapped. + */ +export function useCopyFeedback(): { + copiedKey: string | null + copy: (key: string, text: string) => void +} { + const [copiedKey, setCopiedKey] = useState(null) + const timer = useRef | null>(null) + + useEffect(() => { + return () => { + if (timer.current) clearTimeout(timer.current) + } + }, []) + + const copy = (key: string, text: string): void => { + void Clipboard.setStringAsync(text) + setCopiedKey(key) + if (timer.current) clearTimeout(timer.current) + timer.current = setTimeout(() => setCopiedKey(null), 1200) + } + + return { copiedKey, copy } +} diff --git a/packages/agents-mobile/src/lib/entityGrants.test.ts b/packages/agents-mobile/src/lib/entityGrants.test.ts new file mode 100644 index 0000000000..f873d27a91 --- /dev/null +++ b/packages/agents-mobile/src/lib/entityGrants.test.ts @@ -0,0 +1,273 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { EntityPermissionGrant } from './entityGrants' + +const mocks = vi.hoisted(() => ({ + serverFetch: vi.fn(), +})) + +vi.mock(`@electric-ax/agents-server-ui/src/lib/auth-fetch`, () => ({ + serverFetch: mocks.serverFetch, +})) + +function grant( + id: number, + permission: string, + subject: Partial = {} +): EntityPermissionGrant { + return { + id, + entity_url: `/horton/abc`, + permission, + subject_kind: `principal`, + subject_value: `/principal/user%3Aalice`, + created_at: `2026-01-01`, + updated_at: `2026-01-01`, + ...subject, + } +} + +function allUsersGrant(id: number, permission: string): EntityPermissionGrant { + return grant(id, permission, { + subject_kind: `principal_kind`, + subject_value: `user`, + }) +} + +describe(`diffGrantsForRole`, () => { + it(`creates the missing permissions when upgrading view to chat`, async () => { + const { diffGrantsForRole } = await import(`./entityGrants`) + const existing = [grant(1, `read`), grant(2, `fork`)] + expect(diffGrantsForRole(existing, `chat`)).toEqual({ + deleteIds: [], + createPermissions: [`write`, `signal`, `schedule`, `spawn`], + }) + }) + + it(`deletes the extra grants when downgrading chat to view`, async () => { + const { diffGrantsForRole } = await import(`./entityGrants`) + const existing = [ + grant(1, `read`), + grant(2, `write`), + grant(3, `signal`), + grant(4, `fork`), + grant(5, `schedule`), + grant(6, `spawn`), + ] + expect(diffGrantsForRole(existing, `view`)).toEqual({ + deleteIds: [2, 3, 5, 6], + createPermissions: [], + }) + }) + + it(`swaps the full permission set between disjoint roles`, async () => { + const { diffGrantsForRole } = await import(`./entityGrants`) + const existing = [grant(1, `manage`), grant(2, `delete`)] + expect(diffGrantsForRole(existing, `chat`)).toEqual({ + deleteIds: [1, 2], + createPermissions: [ + `read`, + `write`, + `signal`, + `fork`, + `schedule`, + `spawn`, + ], + }) + }) + + it(`keeps duplicate rows for retained permissions and deletes all copies of dropped ones`, async () => { + const { diffGrantsForRole } = await import(`./entityGrants`) + const existing = [ + grant(1, `read`), + grant(2, `read`), + grant(3, `write`), + grant(4, `write`), + ] + expect(diffGrantsForRole(existing, `view`)).toEqual({ + deleteIds: [3, 4], + createPermissions: [`fork`], + }) + }) + + it(`never touches non-share custom permissions`, async () => { + const { diffGrantsForRole } = await import(`./entityGrants`) + const existing = [grant(1, `read`), grant(2, `custom:observe`)] + expect(diffGrantsForRole(existing, `view`).deleteIds).toEqual([]) + }) +}) + +describe(`grantIdsForRemoval`, () => { + it(`returns every share-permission grant id and skips custom permissions`, async () => { + const { grantIdsForRemoval } = await import(`./entityGrants`) + const existing = [ + grant(1, `read`), + grant(2, `manage`), + grant(3, `custom:observe`), + ] + expect(grantIdsForRemoval(existing)).toEqual([1, 2]) + }) +}) + +describe(`buildShareAccessModel`, () => { + it(`groups user grants and derives their role`, async () => { + const { buildShareAccessModel } = await import(`./entityGrants`) + const model = buildShareAccessModel( + [grant(1, `read`), grant(2, `fork`)], + null + ) + expect(model.allUsers).toBeNull() + expect(model.users).toEqual([ + { + userId: `alice`, + role: `view`, + grants: [grant(1, `read`), grant(2, `fork`)], + }, + ]) + }) + + it(`surfaces an all-users entry separately from individual users`, async () => { + const { buildShareAccessModel } = await import(`./entityGrants`) + const model = buildShareAccessModel( + [ + allUsersGrant(1, `manage`), + allUsersGrant(2, `delete`), + grant(3, `read`), + ], + null + ) + expect(model.allUsers).toEqual({ + role: `manage`, + grants: [allUsersGrant(1, `manage`), allUsersGrant(2, `delete`)], + }) + expect(model.users.map((entry) => entry.userId)).toEqual([`alice`]) + }) + + it(`drops the current user, non-user principals and role-less subjects`, async () => { + const { buildShareAccessModel } = await import(`./entityGrants`) + const model = buildShareAccessModel( + [ + grant(1, `read`), + grant(2, `read`, { subject_value: `/principal/system%3Aframework` }), + grant(3, `fork`, { subject_value: `/principal/user%3Abob` }), + ], + `alice` + ) + // bob's only grant is `fork`, which maps to no role; alice is the + // current user; the system principal is not a user. + expect(model.users).toEqual([]) + }) +}) + +describe(`grant requests`, () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it(`lists grants from the manage-protected entity endpoint`, async () => { + const { listEntityGrants } = await import(`./entityGrants`) + mocks.serverFetch.mockResolvedValue( + new Response(JSON.stringify({ grants: [grant(1, `read`)] }), { + status: 200, + }) + ) + const grants = await listEntityGrants({ + baseUrl: `http://server`, + entityUrl: `/horton/abc`, + }) + expect(mocks.serverFetch).toHaveBeenCalledWith( + `http://server/_electric/entities/horton/abc/grants` + ) + expect(grants).toEqual([grant(1, `read`)]) + }) + + it(`creates a grant with the bare subject/permission body`, async () => { + const { createEntityGrant } = await import(`./entityGrants`) + mocks.serverFetch.mockResolvedValue(new Response(`{}`, { status: 201 })) + await createEntityGrant({ + baseUrl: `http://server`, + entityUrl: `/horton/abc`, + subject: { kind: `principal`, value: `/principal/user%3Abob` }, + permission: `read`, + }) + const [url, init] = mocks.serverFetch.mock.calls.at(-1)! + expect(url).toBe(`http://server/_electric/entities/horton/abc/grants`) + expect(JSON.parse((init as RequestInit).body as string)).toEqual({ + subject_kind: `principal`, + subject_value: `/principal/user%3Abob`, + permission: `read`, + }) + }) + + it(`deletes a grant by id`, async () => { + const { deleteEntityGrant } = await import(`./entityGrants`) + mocks.serverFetch.mockResolvedValue(new Response(null, { status: 204 })) + await deleteEntityGrant({ + baseUrl: `http://server`, + entityUrl: `/horton/abc`, + grantId: 7, + }) + const [url, init] = mocks.serverFetch.mock.calls.at(-1)! + expect(url).toBe(`http://server/_electric/entities/horton/abc/grants/7`) + expect((init as RequestInit).method).toBe(`DELETE`) + }) + + it(`throws a GrantsRequestError carrying the status and server message`, async () => { + const { GrantsRequestError, listEntityGrants } = await import( + `./entityGrants` + ) + mocks.serverFetch.mockResolvedValue( + new Response(JSON.stringify({ message: `manage required` }), { + status: 401, + }) + ) + const error = await listEntityGrants({ + baseUrl: `http://server`, + entityUrl: `/horton/abc`, + }).catch((err: unknown) => err) + expect(error).toBeInstanceOf(GrantsRequestError) + expect((error as InstanceType).status).toBe(401) + expect((error as Error).message).toBe(`manage required`) + }) +}) + +describe(`setSubjectRole`, () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.serverFetch.mockResolvedValue(new Response(`{}`, { status: 200 })) + }) + + it(`fans out the diff as parallel delete and create requests`, async () => { + const { setSubjectRole } = await import(`./entityGrants`) + await setSubjectRole({ + baseUrl: `http://server`, + entityUrl: `/horton/abc`, + subject: { kind: `principal`, value: `/principal/user%3Aalice` }, + role: `view`, + existingGrants: [grant(1, `read`), grant(2, `write`)], + }) + const calls = mocks.serverFetch.mock.calls.map(([url, init]) => [ + url, + (init as RequestInit | undefined)?.method ?? `GET`, + ]) + expect(calls).toContainEqual([ + `http://server/_electric/entities/horton/abc/grants/2`, + `DELETE`, + ]) + expect(calls).toContainEqual([ + `http://server/_electric/entities/horton/abc/grants`, + `POST`, + ]) + }) + + it(`is a no-op when the grants already match the role`, async () => { + const { setSubjectRole } = await import(`./entityGrants`) + await setSubjectRole({ + baseUrl: `http://server`, + entityUrl: `/horton/abc`, + subject: { kind: `principal`, value: `/principal/user%3Aalice` }, + role: `view`, + existingGrants: [grant(1, `read`), grant(2, `fork`)], + }) + expect(mocks.serverFetch).not.toHaveBeenCalled() + }) +}) diff --git a/packages/agents-mobile/src/lib/entityGrants.ts b/packages/agents-mobile/src/lib/entityGrants.ts new file mode 100644 index 0000000000..627adcd4f1 --- /dev/null +++ b/packages/agents-mobile/src/lib/entityGrants.ts @@ -0,0 +1,247 @@ +import { serverFetch } from '@electric-ax/agents-server-ui/src/lib/auth-fetch' +import { entityApiUrl } from '@electric-ax/agents-server-ui/src/lib/entity-api' +import { + SHARE_PERMISSIONS, + SHARE_ROLE_PERMISSIONS, + roleFromGrants, + rolePermissionsMatchGrants, +} from '@electric-ax/agents-server-ui/src/lib/sharePermissions' +import { userIdFromPrincipal } from '@electric-ax/agents-server-ui/src/lib/principals' +import type { + SharePermission, + ShareRole, +} from '@electric-ax/agents-server-ui/src/lib/sharePermissions' + +export type EntityPermissionGrant = { + id: number + entity_url: string + permission: SharePermission | string + subject_kind: `principal` | `principal_kind` | string + subject_value: string + propagation?: `self` | `descendants` | string + copy_to_children?: boolean + created_by?: string + expires_at?: string + created_at: string + updated_at: string +} + +export type ShareSubject = { + kind: `principal` | `principal_kind` + value: string +} + +export const ALL_USERS_SUBJECT: ShareSubject = { + kind: `principal_kind`, + value: `user`, +} + +export type ShareAccessEntry = { + role: ShareRole + grants: Array +} + +export type ShareAccessModel = { + allUsers: ShareAccessEntry | null + users: Array +} + +export class GrantsRequestError extends Error { + status: number + + constructor(message: string, status: number) { + super(message) + this.name = `GrantsRequestError` + this.status = status + } +} + +export async function listEntityGrants({ + baseUrl, + entityUrl, +}: { + baseUrl: string + entityUrl: string +}): Promise> { + const res = await serverFetch(entityApiUrl(baseUrl, entityUrl, `/grants`)) + await assertOk(res, `Load grants`) + const data = (await res.json()) as { + grants?: Array + } + return Array.isArray(data.grants) ? data.grants : [] +} + +export async function createEntityGrant({ + baseUrl, + entityUrl, + subject, + permission, +}: { + baseUrl: string + entityUrl: string + subject: ShareSubject + permission: SharePermission +}): Promise { + const res = await serverFetch(entityApiUrl(baseUrl, entityUrl, `/grants`), { + method: `POST`, + headers: { 'content-type': `application/json` }, + body: JSON.stringify({ + subject_kind: subject.kind, + subject_value: subject.value, + permission, + }), + }) + await assertOk(res, `Create grant`) +} + +export async function deleteEntityGrant({ + baseUrl, + entityUrl, + grantId, +}: { + baseUrl: string + entityUrl: string + grantId: number +}): Promise { + const res = await serverFetch( + entityApiUrl(baseUrl, entityUrl, `/grants/${grantId}`), + { method: `DELETE` } + ) + await assertOk(res, `Delete grant`) +} + +export function diffGrantsForRole( + existingGrants: ReadonlyArray, + role: ShareRole +): { deleteIds: Array; createPermissions: Array } { + const existing = existingGrants.filter((grant) => + SHARE_PERMISSIONS.has(grant.permission) + ) + const desired = new Set(SHARE_ROLE_PERMISSIONS[role]) + const existingPermissions = new Set(existing.map((grant) => grant.permission)) + return { + deleteIds: existing + .filter((grant) => !desired.has(grant.permission)) + .map((grant) => grant.id), + createPermissions: SHARE_ROLE_PERMISSIONS[role].filter( + (permission) => !existingPermissions.has(permission) + ), + } +} + +export function grantIdsForRemoval( + existingGrants: ReadonlyArray +): Array { + return existingGrants + .filter((grant) => SHARE_PERMISSIONS.has(grant.permission)) + .map((grant) => grant.id) +} + +export function buildShareAccessModel( + grants: ReadonlyArray, + currentUserId: string | null +): ShareAccessModel { + const allUsersGrants: Array = [] + const grantsByUserId = new Map>() + for (const grant of grants) { + if (isAllUsersGrant(grant)) { + allUsersGrants.push(grant) + continue + } + if (grant.subject_kind !== `principal`) continue + const userId = userIdFromPrincipal(grant.subject_value) + if (!userId || userId === currentUserId) continue + const existing = grantsByUserId.get(userId) + if (existing) existing.push(grant) + else grantsByUserId.set(userId, [grant]) + } + + const allUsersRole = roleFromGrants(allUsersGrants) + const users: ShareAccessModel[`users`] = [] + for (const [userId, userGrants] of grantsByUserId) { + const role = roleFromGrants(userGrants) + if (role) users.push({ userId, role, grants: userGrants }) + } + + return { + allUsers: allUsersRole + ? { role: allUsersRole, grants: allUsersGrants } + : null, + users, + } +} + +export async function setSubjectRole({ + baseUrl, + entityUrl, + subject, + role, + existingGrants, +}: { + baseUrl: string + entityUrl: string + subject: ShareSubject + role: ShareRole + existingGrants: ReadonlyArray +}): Promise { + if (rolePermissionsMatchGrants(role, existingGrants)) return + const { deleteIds, createPermissions } = diffGrantsForRole( + existingGrants, + role + ) + await Promise.all([ + ...deleteIds.map((grantId) => + deleteEntityGrant({ baseUrl, entityUrl, grantId }) + ), + ...createPermissions.map((permission) => + createEntityGrant({ baseUrl, entityUrl, subject, permission }) + ), + ]) +} + +export async function removeSubjectAccess({ + baseUrl, + entityUrl, + existingGrants, +}: { + baseUrl: string + entityUrl: string + existingGrants: ReadonlyArray +}): Promise { + await Promise.all( + grantIdsForRemoval(existingGrants).map((grantId) => + deleteEntityGrant({ baseUrl, entityUrl, grantId }) + ) + ) +} + +function isAllUsersGrant(grant: EntityPermissionGrant): boolean { + return ( + grant.subject_kind === ALL_USERS_SUBJECT.kind && + grant.subject_value === ALL_USERS_SUBJECT.value + ) +} + +async function assertOk(res: Response, action: string): Promise { + if (res.ok) return + const text = await res.text().catch(() => ``) + throw new GrantsRequestError( + parseErrorResponse(text) ?? `${action} failed (${res.status})`, + res.status + ) +} + +function parseErrorResponse(text: string): string | null { + if (!text) return null + try { + const data = JSON.parse(text) as { + error?: { message?: unknown } + message?: unknown + } + if (typeof data.error?.message === `string`) return data.error.message + if (typeof data.message === `string`) return data.message + } catch { + return text + } + return text +} diff --git a/packages/agents-mobile/src/lib/sessionLinks.test.ts b/packages/agents-mobile/src/lib/sessionLinks.test.ts new file mode 100644 index 0000000000..9cdf4950e8 --- /dev/null +++ b/packages/agents-mobile/src/lib/sessionLinks.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { sessionIdFromEntityUrl, sessionWebUrl } from './sessionLinks' + +describe(`sessionIdFromEntityUrl`, () => { + it(`strips leading slashes from the entity url`, () => { + expect(sessionIdFromEntityUrl(`/horton/abc`)).toBe(`horton/abc`) + expect(sessionIdFromEntityUrl(`//horton/abc`)).toBe(`horton/abc`) + expect(sessionIdFromEntityUrl(`horton/abc`)).toBe(`horton/abc`) + }) +}) + +describe(`sessionWebUrl`, () => { + it(`builds a web UI link on a bare server origin`, () => { + expect(sessionWebUrl(`https://host.example`, `/horton/abc`)).toBe( + `https://host.example/__agent_ui/#/entity/horton/abc` + ) + }) + + it(`normalizes a trailing slash on the server url`, () => { + expect(sessionWebUrl(`https://host.example/`, `/horton/abc`)).toBe( + `https://host.example/__agent_ui/#/entity/horton/abc` + ) + }) + + it(`preserves a Cloud tenant path prefix`, () => { + expect( + sessionWebUrl( + `https://agents.electric-sql.cloud/t/svc-123/v1`, + `/horton/abc` + ) + ).toBe( + `https://agents.electric-sql.cloud/t/svc-123/v1/__agent_ui/#/entity/horton/abc` + ) + }) + + it(`drops query and hash from the server url`, () => { + expect( + sessionWebUrl(`https://host.example/base/?stale=1#old`, `/horton/abc`) + ).toBe(`https://host.example/base/__agent_ui/#/entity/horton/abc`) + }) + + it(`keeps nested session ids un-encoded for the hash splat route`, () => { + expect(sessionWebUrl(`https://host.example`, `/agent/foo/bar`)).toBe( + `https://host.example/__agent_ui/#/entity/agent/foo/bar` + ) + }) + + it(`falls back to string concatenation for unparseable server urls`, () => { + expect(sessionWebUrl(`not a url`, `/horton/abc`)).toBe( + `not a url/__agent_ui/#/entity/horton/abc` + ) + }) +}) diff --git a/packages/agents-mobile/src/lib/sessionLinks.ts b/packages/agents-mobile/src/lib/sessionLinks.ts new file mode 100644 index 0000000000..95783a04e7 --- /dev/null +++ b/packages/agents-mobile/src/lib/sessionLinks.ts @@ -0,0 +1,25 @@ +const WEB_UI_PATH = `__agent_ui` + +export function sessionIdFromEntityUrl(entityUrl: string): string { + return entityUrl.replace(/^\/+/, ``) +} + +/** + * Browser-openable link to a session in the server's bundled web UI. + * Targets `/__agent_ui/` directly rather than the server root: the root + * 302 redirect uses an absolute path, which would drop a Cloud tenant + * prefix like `/t//v1`. The session id stays un-encoded to + * match the web UI's hash splat route (`/entity/$`). + */ +export function sessionWebUrl(serverUrl: string, entityUrl: string): string { + const id = sessionIdFromEntityUrl(entityUrl) + let base: string + try { + const parsed = new URL(serverUrl) + const prefix = parsed.pathname.replace(/\/+$/, ``) + base = `${parsed.origin}${prefix}` + } catch { + base = serverUrl.replace(/\/+$/, ``) + } + return `${base}/${WEB_UI_PATH}/#/entity/${id}` +} diff --git a/packages/agents-mobile/src/screens/SessionScreen.tsx b/packages/agents-mobile/src/screens/SessionScreen.tsx index 3f86b03ca5..ada314c3eb 100644 --- a/packages/agents-mobile/src/screens/SessionScreen.tsx +++ b/packages/agents-mobile/src/screens/SessionScreen.tsx @@ -82,6 +82,7 @@ export function ChatSessionScreen({ onComposerHeightChange, onSendMessage, onInlineQueuedMessagesChange, + onShare, }: { entityUrl: string onBack: () => void @@ -93,6 +94,7 @@ export function ChatSessionScreen({ onInlineQueuedMessagesChange?: ( messages: Array ) => void + onShare?: () => void }): React.ReactElement { return ( ) } @@ -113,10 +116,12 @@ export function StateInspectorSessionScreen({ entityUrl, onBack, onSetView, + onShare, }: { entityUrl: string onBack: () => void onSetView: (view: EmbedViewId) => void + onShare?: () => void }): React.ReactElement { return ( ) } @@ -147,6 +153,7 @@ export function SessionScreen({ onComposerHeightChange, onSendMessage, onInlineQueuedMessagesChange, + onShare, }: { entityUrl: string view: EmbedViewId @@ -159,6 +166,7 @@ export function SessionScreen({ onInlineQueuedMessagesChange?: ( messages: Array ) => void + onShare?: () => void }): React.ReactElement { const { entitiesCollection, serverUrl, signalEntity } = useAgents() const tokens = useTokens() @@ -433,6 +441,7 @@ export function SessionScreen({ signalError={signalError} onSignal={sendMenuSignal} onStopImmediately={() => void stopImmediately()} + onShare={onShare} signalDisabled={!canSignal} /> diff --git a/packages/agents-mobile/src/screens/ShareSessionScreen.tsx b/packages/agents-mobile/src/screens/ShareSessionScreen.tsx new file mode 100644 index 0000000000..c1b647d037 --- /dev/null +++ b/packages/agents-mobile/src/screens/ShareSessionScreen.tsx @@ -0,0 +1,743 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + ActivityIndicator, + Alert, + Image, + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + Share, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native' +import { useLiveQuery } from '@tanstack/react-db' +import { eq } from '@tanstack/db' +import { + userIdFromPrincipal, + userPrincipalUrl, +} from '@electric-ax/agents-server-ui/src/lib/principals' +import { userDisplay } from '@electric-ax/agents-server-ui/src/lib/userDisplay' +import { + BottomSheet, + BottomSheetItem, + BottomSheetSection, + BottomSheetSeparator, +} from '../components/BottomSheet' +import { Header, HeaderBackButton } from '../components/Header' +import { Icon, type IconName } from '../components/Icon' +import { Screen } from '../components/Screen' +import { useCopyFeedback } from '../components/useCopyFeedback' +import { useAgents } from '../lib/AgentsProvider' +import { getEntityDisplayTitle } from '../lib/agentsClient' +import { + ALL_USERS_SUBJECT, + GrantsRequestError, + buildShareAccessModel, + listEntityGrants, + removeSubjectAccess, + setSubjectRole, + type EntityPermissionGrant, + type ShareSubject, +} from '../lib/entityGrants' +import { sessionIdFromEntityUrl, sessionWebUrl } from '../lib/sessionLinks' +import { useCurrentPrincipal } from '../lib/useCurrentPrincipal' +import { useTokens } from '../lib/ThemeProvider' +import { fontSize, monoFontFamily, radii, spacing } from '../lib/theme' +import type { ShareRole } from '@electric-ax/agents-server-ui/src/lib/sharePermissions' +import type { ElectricUser } from '../lib/agentsClient' +import type { Tokens } from '../lib/theme' + +const ALL_USERS_KEY = `__all_users__` + +// Same role glyphs as the desktop dialog's View/Chat/Manage segments. +const ROLE_OPTIONS: ReadonlyArray<{ + id: ShareRole + label: string + description: string + icon: IconName +}> = [ + { id: `view`, label: `View`, description: `Read-only access`, icon: `eye` }, + { + id: `chat`, + label: `Chat`, + description: `Interact and send messages`, + icon: `chat`, + }, + { + id: `manage`, + label: `Manage`, + description: `Full control, incl. sharing`, + icon: `shield`, + }, +] + +const ROLE_LABELS: Record = { + view: `View`, + chat: `Chat`, + manage: `Manage`, +} + +/** Subject the role sheet is acting on — a user, or the all-users kind. */ +type ShareTarget = { + key: string + label: string + subject: ShareSubject + role: ShareRole | null + grants: Array +} + +/** + * Mobile counterpart of the desktop `ShareEntityDialog`, restructured + * for one column: link sharing (copy / native share sheet), people + * with access, workspace-wide "General access", then a search-first + * add-people section. Link actions work for anyone with the session + * open; the grant sections self-gate on the manage-protected REST + * endpoint. Roles commit per row through a bottom-sheet picker + * instead of the desktop's deferred Grant button. + */ +export function ShareSessionScreen({ + entityUrl, + onBack, +}: { + entityUrl: string + onBack: () => void +}): React.ReactElement { + const { serverUrl, entitiesCollection, usersCollection } = useAgents() + const tokens = useTokens() + const styles = useMemo(() => createStyles(tokens), [tokens]) + const { userId: currentUserId } = useCurrentPrincipal() + const { copiedKey, copy } = useCopyFeedback() + + const [grants, setGrants] = useState>([]) + const [loadedOnce, setLoadedOnce] = useState(false) + const [accessDenied, setAccessDenied] = useState(false) + const [error, setError] = useState(null) + const [savingKey, setSavingKey] = useState(null) + const [roleTarget, setRoleTarget] = useState(null) + const [query, setQuery] = useState(``) + + const { data: entities = [] } = useLiveQuery( + (q) => + q + .from({ entity: entitiesCollection }) + .where(({ entity }) => eq(entity.url, entityUrl)), + [entitiesCollection, entityUrl] + ) + const entity = entities[0] ?? null + + const { data: users = [] } = useLiveQuery( + (q) => q.from({ user: usersCollection }), + [usersCollection] + ) + const usersById = useMemo( + () => new Map(users.map((user) => [user.id, user])), + [users] + ) + + const loadGrants = useCallback(async () => { + try { + const loaded = await listEntityGrants({ baseUrl: serverUrl, entityUrl }) + setGrants(loaded) + setAccessDenied(false) + } catch (err) { + // Losing manage (incl. revoking your own access) is an expected + // state, not an error to surface. + if ( + err instanceof GrantsRequestError && + (err.status === 401 || err.status === 403) + ) { + setAccessDenied(true) + setError(null) + } else { + setError(err instanceof Error ? err.message : String(err)) + } + } finally { + setLoadedOnce(true) + } + }, [serverUrl, entityUrl]) + + useEffect(() => { + void loadGrants() + }, [loadGrants]) + + const model = useMemo( + () => buildShareAccessModel(grants, currentUserId), + [grants, currentUserId] + ) + const accessRows = useMemo( + () => + model.users + .map((entry) => ({ + ...entry, + display: userDisplay(usersById.get(entry.userId), entry.userId), + user: usersById.get(entry.userId), + })) + .sort((a, b) => a.display.primary.localeCompare(b.display.primary)), + [model.users, usersById] + ) + + const ownerUserId = userIdFromPrincipal(entity?.created_by) + const ownerDisplay = entity?.created_by + ? userDisplay( + ownerUserId ? usersById.get(ownerUserId) : undefined, + ownerUserId ?? entity.created_by + ) + : null + + // Add-people candidates: everyone except yourself, the owner and + // people who already have access (their role is edited in place). + const grantedUserIds = useMemo( + () => new Set(model.users.map((entry) => entry.userId)), + [model.users] + ) + const addableUsers = useMemo(() => { + const needle = query.trim().toLowerCase() + return users + .filter( + (user) => + user.id !== currentUserId && + user.id !== ownerUserId && + !grantedUserIds.has(user.id) + ) + .filter( + (user) => !needle || userSearchText(user).toLowerCase().includes(needle) + ) + .sort((a, b) => userSearchText(a).localeCompare(userSearchText(b))) + }, [users, currentUserId, ownerUserId, grantedUserIds, query]) + + const userTarget = (entry: { + userId: string + role: ShareRole | null + grants: Array + }): ShareTarget => ({ + key: entry.userId, + label: userDisplay(usersById.get(entry.userId), entry.userId).primary, + subject: { kind: `principal`, value: userPrincipalUrl(entry.userId) }, + role: entry.role, + grants: entry.grants, + }) + const allUsersTarget: ShareTarget = { + key: ALL_USERS_KEY, + label: `All users`, + subject: ALL_USERS_SUBJECT, + role: model.allUsers?.role ?? null, + grants: model.allUsers?.grants ?? [], + } + + const shareLink = async (): Promise => { + const url = sessionWebUrl(serverUrl, entityUrl) + const title = entity + ? getEntityDisplayTitle(entity) + : sessionIdFromEntityUrl(entityUrl) + try { + // iOS shares the `url` field; Android only reads `message`. + await Share.share( + Platform.OS === `ios` ? { url, title } : { message: url, title } + ) + } catch { + // Best-effort: cancellation rejects on some platforms. + } + } + + const applyRole = async ( + target: ShareTarget, + role: ShareRole + ): Promise => { + setRoleTarget(null) + if (target.role === role) return + setSavingKey(target.key) + setError(null) + try { + await setSubjectRole({ + baseUrl: serverUrl, + entityUrl, + subject: target.subject, + role, + existingGrants: target.grants, + }) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + // The REST grant list is the source of truth for grant ids, so + // refetch rather than patching state (desktop parity). + await loadGrants() + setSavingKey(null) + } + } + + const removeAccess = (target: ShareTarget): void => { + setRoleTarget(null) + Alert.alert( + `Remove access`, + `Remove ${target.label}'s access to this session?`, + [ + { text: `Cancel`, style: `cancel` }, + { + text: `Remove`, + style: `destructive`, + onPress: () => { + void (async () => { + setSavingKey(target.key) + setError(null) + try { + await removeSubjectAccess({ + baseUrl: serverUrl, + entityUrl, + existingGrants: target.grants, + }) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + await loadGrants() + setSavingKey(null) + } + })() + }, + }, + ] + ) + } + + return ( + +
} + title="Share session" + /> + + + + + {entity && ( + + {getEntityDisplayTitle(entity)} + + )} + copy(`id`, sessionIdFromEntityUrl(entityUrl))} + hitSlop={6} + > + + {sessionIdFromEntityUrl(entityUrl)} + + + + + + {/* Link pill (Meet/Zoom-style): the abbreviated URL is the + content, the trailing glyph the action — one tap opens + the native share sheet, which includes Copy. */} + Session link + void shareLink()}> + + + + + {sessionWebUrl(serverUrl, entityUrl).replace(/^https?:\/\//, ``)} + + + + + {error && ( + + {error} + + )} + + {accessDenied ? ( + + You need manage access to view or change who has access to this + session. + + ) : !loadedOnce ? ( + + ) : ( + <> + People with access + + {ownerDisplay && ( + + )} + {accessRows.map((entry) => ( + setRoleTarget(userTarget(entry))} + /> + ))} + {!ownerDisplay && accessRows.length === 0 && ( + Only the owner has access. + )} + + + General access + + setRoleTarget(allUsersTarget)} + /> + + + Add people + + + + + + {addableUsers.map((user) => ( + + setRoleTarget( + userTarget({ userId: user.id, role: null, grants: [] }) + ) + } + /> + ))} + {addableUsers.length === 0 && ( + + {users.length === 0 + ? `No users available on this server.` + : `No matching users.`} + + )} + + + )} + + + + setRoleTarget(null)} + title={roleTarget?.label} + > + + {ROLE_OPTIONS.map((option) => ( + + } + active={roleTarget?.role === option.id} + onPress={() => { + if (roleTarget) void applyRole(roleTarget, option.id) + }} + /> + ))} + + {roleTarget?.role && ( + <> + + + + } + onPress={() => { + if (roleTarget) removeAccess(roleTarget) + }} + /> + + + )} + + + ) +} + +function AccessRow({ + tokens, + display, + user, + iconName, + rolePill, + saving, + onPress, +}: { + tokens: Tokens + display: { primary: string; secondary: string; initials: string } + user?: ElectricUser + iconName?: `users` + rolePill?: string + saving?: boolean + onPress?: () => void +}): React.ReactElement { + const styles = useMemo(() => createStyles(tokens), [tokens]) + const row = ( + <> + {iconName ? ( + + + + ) : user?.avatar_url ? ( + + ) : ( + + {display.initials} + + )} + + + {display.primary} + + + {display.secondary} + + + {saving ? ( + + ) : ( + <> + {rolePill && {rolePill}} + {onPress && ( + + )} + + )} + + ) + + if (!onPress) return {row} + return ( + + {row} + + ) +} + +function userSearchText(user: ElectricUser): string { + return [user.display_name, user.email, user.id].filter(Boolean).join(` `) +} + +function createStyles(tokens: Tokens) { + return StyleSheet.create({ + keyboard: { + flex: 1, + }, + content: { + padding: spacing.lg, + paddingBottom: spacing.xxl, + gap: spacing.md, + }, + intro: { + gap: 2, + }, + introTitle: { + color: tokens.text1, + fontSize: fontSize.lg, + fontWeight: `500`, + }, + introIdRow: { + alignSelf: `flex-start`, + flexDirection: `row`, + alignItems: `center`, + gap: spacing.xs, + maxWidth: `100%`, + }, + introId: { + flexShrink: 1, + color: tokens.text3, + fontSize: fontSize.sm, + fontFamily: monoFontFamily, + }, + actionIcon: { + width: 32, + alignItems: `center`, + justifyContent: `center`, + }, + linkText: { + flex: 1, + color: tokens.text2, + fontSize: fontSize.sm, + fontFamily: monoFontFamily, + }, + sectionLabel: { + marginTop: spacing.sm, + color: tokens.text3, + fontSize: fontSize.xs, + fontWeight: `500`, + letterSpacing: 0.6, + textTransform: `uppercase`, + }, + list: { + gap: spacing.xs, + }, + row: { + flexDirection: `row`, + alignItems: `center`, + gap: spacing.md, + borderWidth: 1, + borderColor: tokens.border1, + borderRadius: radii.md, + backgroundColor: tokens.surface, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + }, + avatar: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: tokens.accentA2, + alignItems: `center`, + justifyContent: `center`, + }, + avatarInitials: { + color: tokens.accent11, + fontSize: fontSize.xs, + fontWeight: `600`, + }, + rowText: { + flex: 1, + gap: 1, + }, + rowPrimary: { + color: tokens.text1, + fontSize: fontSize.base, + fontWeight: `500`, + }, + rowSecondary: { + color: tokens.text3, + fontSize: fontSize.sm, + }, + rolePill: { + color: tokens.text2, + fontSize: fontSize.xs, + fontWeight: `500`, + borderWidth: 1, + borderColor: tokens.border1, + borderRadius: radii.sm, + paddingHorizontal: spacing.sm, + paddingVertical: 2, + overflow: `hidden`, + }, + // Field metrics mirror `SearchBar` — fixed height with a + // zero-padding input keeps the placeholder vertically centred on + // both platforms (Android adds intrinsic vertical padding). + searchWrap: { + flexDirection: `row`, + alignItems: `center`, + gap: spacing.xs, + height: 36, + borderWidth: 1, + borderColor: tokens.border1, + borderRadius: radii.md, + backgroundColor: tokens.surface, + paddingHorizontal: 10, + }, + searchInput: { + flex: 1, + minWidth: 0, + color: tokens.text1, + fontSize: fontSize.lg, + paddingVertical: 0, + }, + errorRow: { + borderRadius: radii.sm, + backgroundColor: tokens.redA2, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + }, + errorText: { + color: tokens.red11, + fontSize: fontSize.sm, + }, + empty: { + color: tokens.text3, + fontSize: fontSize.sm, + }, + }) +} diff --git a/packages/agents-server-ui/src/components/ShareEntityDialog.tsx b/packages/agents-server-ui/src/components/ShareEntityDialog.tsx index 5759826bfc..a8d29eff1d 100644 --- a/packages/agents-server-ui/src/components/ShareEntityDialog.tsx +++ b/packages/agents-server-ui/src/components/ShareEntityDialog.tsx @@ -23,6 +23,7 @@ import { useServerConnection } from '../hooks/useServerConnection' import { useCurrentPrincipal } from '../hooks/useCurrentPrincipal' import { useElectricAgents } from '../lib/ElectricAgentsProvider' import { userIdFromPrincipal, userPrincipalUrl } from '../lib/principals' +import { userDisplay } from '../lib/userDisplay' import { Button, Dialog, Icon, IconButton, Input, Text, Tooltip } from '../ui' import type { ElectricEntity, @@ -738,31 +739,3 @@ function isAllUsersGrant(grant: EntityPermissionGrant): boolean { function userSearchText(user: ElectricUser): string { return [user.display_name, user.email, user.id].filter(Boolean).join(` `) } - -function userDisplay( - user: ElectricUser | undefined, - fallbackId: string -): { primary: string; secondary: string; initials: string } { - const primary = user?.display_name || user?.email || fallbackId - const secondary = - user?.display_name && user.email - ? user.email - : user?.display_name || user?.email - ? fallbackId - : `user:${fallbackId}` - return { - primary, - secondary, - initials: initials(primary || fallbackId), - } -} - -function initials(value: string): string { - const parts = value - .replace(/@.*/, ``) - .split(/[\s._-]+/) - .filter(Boolean) - const letters = - parts.length >= 2 ? `${parts[0]![0]}${parts[1]![0]}` : value.slice(0, 2) - return letters.toUpperCase() -} diff --git a/packages/agents-server-ui/src/lib/userDisplay.ts b/packages/agents-server-ui/src/lib/userDisplay.ts new file mode 100644 index 0000000000..c042294b3b --- /dev/null +++ b/packages/agents-server-ui/src/lib/userDisplay.ts @@ -0,0 +1,38 @@ +export type UserDisplayInfo = { + display_name?: string | null + email?: string | null +} + +export type UserDisplay = { + primary: string + secondary: string + initials: string +} + +export function userDisplay( + user: UserDisplayInfo | undefined, + fallbackId: string +): UserDisplay { + const primary = user?.display_name || user?.email || fallbackId + const secondary = + user?.display_name && user.email + ? user.email + : user?.display_name || user?.email + ? fallbackId + : `user:${fallbackId}` + return { + primary, + secondary, + initials: initials(primary || fallbackId), + } +} + +export function initials(value: string): string { + const parts = value + .replace(/@.*/, ``) + .split(/[\s._-]+/) + .filter(Boolean) + const letters = + parts.length >= 2 ? `${parts[0]![0]}${parts[1]![0]}` : value.slice(0, 2) + return letters.toUpperCase() +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7bf901c090..d7960e1b00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1728,6 +1728,9 @@ importers: expo-build-properties: specifier: ~1.0.10 version: 1.0.10(expo@54.0.35) + expo-clipboard: + specifier: ~8.0.8 + version: 8.0.8(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-constants: specifier: ~18.0.13 version: 18.0.13(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) @@ -14142,6 +14145,13 @@ packages: peerDependencies: expo: '*' + expo-clipboard@8.0.8: + resolution: {integrity: sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-constants@17.1.7: resolution: {integrity: sha512-byBjGsJ6T6FrLlhOBxw4EaiMXrZEn/MlUYIj/JAd+FS7ll5X/S4qVRbIimSJtdW47hXMq0zxPfJX6njtA56hHA==} peerDependencies: @@ -37007,6 +37017,12 @@ snapshots: expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) semver: 7.7.4 + expo-clipboard@8.0.8(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + expo-constants@17.1.7(expo@53.0.20(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0)))(react-native-webview@13.16.1(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0)): dependencies: '@expo/config': 11.0.13 From 017f8cec2f4451a7aa770337a850fccb85af76e2 Mon Sep 17 00:00:00 2001 From: msfstef Date: Thu, 11 Jun 2026 16:45:03 +0300 Subject: [PATCH 2/2] fix(agents-mobile): address share-screen review findings - Diff re-added users against their actual grants (exposed via ShareAccessModel.grantsByUserId) so granting a role to a user with a partial, role-less grant set no longer creates duplicate grant rows. - Use the exact rolePermissionsMatchGrants check in applyRole instead of the coarse role compare, so re-selecting a role normalizes partial grant sets (desktop Update parity). - Guard applyRole/removeAccess while a save is in flight to prevent cross-row savingKey clobbering and racing grant refetches. - Filter the owner out of the access rows (already pinned as a dedicated row) and reword the all-users remove confirmation. - Dedupe userSearchText into agents-server-ui's userDisplay lib, un-export the internal initials helper, and trim unused optional fields from the mobile grant type. - Minor polish: hitSlop consistency on the copy-id pill, accessibility label on the share-link pill. Co-Authored-By: Claude Fable 5 --- .../src/lib/entityGrants.test.ts | 10 +++++ .../agents-mobile/src/lib/entityGrants.ts | 8 ++-- .../src/screens/ShareSessionScreen.tsx | 45 ++++++++++++++----- .../src/components/ShareEntityDialog.tsx | 6 +-- .../agents-server-ui/src/lib/userDisplay.ts | 6 ++- 5 files changed, 53 insertions(+), 22 deletions(-) diff --git a/packages/agents-mobile/src/lib/entityGrants.test.ts b/packages/agents-mobile/src/lib/entityGrants.test.ts index f873d27a91..332debb9c4 100644 --- a/packages/agents-mobile/src/lib/entityGrants.test.ts +++ b/packages/agents-mobile/src/lib/entityGrants.test.ts @@ -156,6 +156,16 @@ describe(`buildShareAccessModel`, () => { // current user; the system principal is not a user. expect(model.users).toEqual([]) }) + + it(`exposes raw grants for role-less users so re-adding diffs against them`, async () => { + const { buildShareAccessModel } = await import(`./entityGrants`) + const forkOnly = grant(1, `fork`, { + subject_value: `/principal/user%3Abob`, + }) + const model = buildShareAccessModel([forkOnly], null) + expect(model.users).toEqual([]) + expect(model.grantsByUserId.get(`bob`)).toEqual([forkOnly]) + }) }) describe(`grant requests`, () => { diff --git a/packages/agents-mobile/src/lib/entityGrants.ts b/packages/agents-mobile/src/lib/entityGrants.ts index 627adcd4f1..74cd06714a 100644 --- a/packages/agents-mobile/src/lib/entityGrants.ts +++ b/packages/agents-mobile/src/lib/entityGrants.ts @@ -18,10 +18,6 @@ export type EntityPermissionGrant = { permission: SharePermission | string subject_kind: `principal` | `principal_kind` | string subject_value: string - propagation?: `self` | `descendants` | string - copy_to_children?: boolean - created_by?: string - expires_at?: string created_at: string updated_at: string } @@ -44,6 +40,9 @@ export type ShareAccessEntry = { export type ShareAccessModel = { allUsers: ShareAccessEntry | null users: Array + /** All per-user grants, including ones that map to no role — diff + * against these when (re-)granting so partial sets aren't duplicated. */ + grantsByUserId: Map> } export class GrantsRequestError extends Error { @@ -168,6 +167,7 @@ export function buildShareAccessModel( ? { role: allUsersRole, grants: allUsersGrants } : null, users, + grantsByUserId, } } diff --git a/packages/agents-mobile/src/screens/ShareSessionScreen.tsx b/packages/agents-mobile/src/screens/ShareSessionScreen.tsx index c1b647d037..04f3960f5c 100644 --- a/packages/agents-mobile/src/screens/ShareSessionScreen.tsx +++ b/packages/agents-mobile/src/screens/ShareSessionScreen.tsx @@ -20,7 +20,11 @@ import { userIdFromPrincipal, userPrincipalUrl, } from '@electric-ax/agents-server-ui/src/lib/principals' -import { userDisplay } from '@electric-ax/agents-server-ui/src/lib/userDisplay' +import { rolePermissionsMatchGrants } from '@electric-ax/agents-server-ui/src/lib/sharePermissions' +import { + userDisplay, + userSearchText, +} from '@electric-ax/agents-server-ui/src/lib/userDisplay' import { BottomSheet, BottomSheetItem, @@ -168,19 +172,22 @@ export function ShareSessionScreen({ () => buildShareAccessModel(grants, currentUserId), [grants, currentUserId] ) + const ownerUserId = userIdFromPrincipal(entity?.created_by) + // The owner is pinned above as a dedicated row, so an explicit grant + // for them must not render a second entry. const accessRows = useMemo( () => model.users + .filter((entry) => entry.userId !== ownerUserId) .map((entry) => ({ ...entry, display: userDisplay(usersById.get(entry.userId), entry.userId), user: usersById.get(entry.userId), })) .sort((a, b) => a.display.primary.localeCompare(b.display.primary)), - [model.users, usersById] + [model.users, usersById, ownerUserId] ) - const ownerUserId = userIdFromPrincipal(entity?.created_by) const ownerDisplay = entity?.created_by ? userDisplay( ownerUserId ? usersById.get(ownerUserId) : undefined, @@ -248,7 +255,10 @@ export function ShareSessionScreen({ role: ShareRole ): Promise => { setRoleTarget(null) - if (target.role === role) return + if (savingKey !== null) return + // Exact permission-set check (not the coarse role) so re-selecting + // a role normalizes partial grant sets, matching desktop's Update. + if (rolePermissionsMatchGrants(role, target.grants)) return setSavingKey(target.key) setError(null) try { @@ -271,9 +281,12 @@ export function ShareSessionScreen({ const removeAccess = (target: ShareTarget): void => { setRoleTarget(null) + if (savingKey !== null) return Alert.alert( `Remove access`, - `Remove ${target.label}'s access to this session?`, + target.key === ALL_USERS_KEY + ? `Remove access for all users to this session?` + : `Remove ${target.label}'s access to this session?`, [ { text: `Cancel`, style: `cancel` }, { @@ -327,7 +340,7 @@ export function ShareSessionScreen({ copy(`id`, sessionIdFromEntityUrl(entityUrl))} - hitSlop={6} + hitSlop={8} > {sessionIdFromEntityUrl(entityUrl)} @@ -345,7 +358,12 @@ export function ShareSessionScreen({ content, the trailing glyph the action — one tap opens the native share sheet, which includes Copy. */} Session link - void shareLink()}> + void shareLink()} + accessibilityRole="button" + accessibilityLabel="Share session link" + > setRoleTarget( - userTarget({ userId: user.id, role: null, grants: [] }) + userTarget({ + userId: user.id, + role: null, + // Role-less users keep grants the model drops + // (e.g. a lone `fork`); diff against them so + // granting a role doesn't duplicate rows. + grants: model.grantsByUserId.get(user.id) ?? [], + }) ) } /> @@ -599,10 +624,6 @@ function AccessRow({ ) } -function userSearchText(user: ElectricUser): string { - return [user.display_name, user.email, user.id].filter(Boolean).join(` `) -} - function createStyles(tokens: Tokens) { return StyleSheet.create({ keyboard: { diff --git a/packages/agents-server-ui/src/components/ShareEntityDialog.tsx b/packages/agents-server-ui/src/components/ShareEntityDialog.tsx index a8d29eff1d..f42a18dc01 100644 --- a/packages/agents-server-ui/src/components/ShareEntityDialog.tsx +++ b/packages/agents-server-ui/src/components/ShareEntityDialog.tsx @@ -23,7 +23,7 @@ import { useServerConnection } from '../hooks/useServerConnection' import { useCurrentPrincipal } from '../hooks/useCurrentPrincipal' import { useElectricAgents } from '../lib/ElectricAgentsProvider' import { userIdFromPrincipal, userPrincipalUrl } from '../lib/principals' -import { userDisplay } from '../lib/userDisplay' +import { userDisplay, userSearchText } from '../lib/userDisplay' import { Button, Dialog, Icon, IconButton, Input, Text, Tooltip } from '../ui' import type { ElectricEntity, @@ -735,7 +735,3 @@ function isAllUsersGrant(grant: EntityPermissionGrant): boolean { grant.subject_value === ALL_USERS_SUBJECT_VALUE ) } - -function userSearchText(user: ElectricUser): string { - return [user.display_name, user.email, user.id].filter(Boolean).join(` `) -} diff --git a/packages/agents-server-ui/src/lib/userDisplay.ts b/packages/agents-server-ui/src/lib/userDisplay.ts index c042294b3b..a42648f8bd 100644 --- a/packages/agents-server-ui/src/lib/userDisplay.ts +++ b/packages/agents-server-ui/src/lib/userDisplay.ts @@ -27,7 +27,11 @@ export function userDisplay( } } -export function initials(value: string): string { +export function userSearchText(user: UserDisplayInfo & { id: string }): string { + return [user.display_name, user.email, user.id].filter(Boolean).join(` `) +} + +function initials(value: string): string { const parts = value .replace(/@.*/, ``) .split(/[\s._-]+/)