Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/agents-mobile-session-sharing.md
Original file line number Diff line number Diff line change
@@ -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/<service-id>/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.
27 changes: 27 additions & 0 deletions packages/agents-mobile/app/session-share.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Stack.Screen options={{ presentation: `modal` }} />
<ShareSessionScreen
entityUrl={entityUrl}
onBack={() => {
if (router.canGoBack()) router.back()
else router.replace(`/`)
}}
/>
</>
)
}
5 changes: 5 additions & 0 deletions packages/agents-mobile/app/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
Expand Down Expand Up @@ -176,12 +179,14 @@ function SessionRouteInner({
onComposerHeightChange={setChatComposerHeight}
onSendMessage={() => setChatLogScrollSignal(Date.now())}
onInlineQueuedMessagesChange={setInlineQueuedMessages}
onShare={openShare}
/>
) : (
<StateInspectorSessionScreen
entityUrl={entityUrl}
onBack={goBack}
onSetView={setView}
onShare={openShare}
/>
)}
</View>
Expand Down
1 change: 1 addition & 0 deletions packages/agents-mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions packages/agents-mobile/src/components/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ export type IconName =
| `pin`
| `image`
| `camera`
| `copy`
| `link`
| `share`
| `eye`
| `shield`

const PATHS: Record<IconName, string> = {
back: `M15 18l-6-6 6-6`,
Expand Down Expand Up @@ -73,6 +78,15 @@ const PATHS: Record<IconName, string> = {
// 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
Expand Down
76 changes: 67 additions & 9 deletions packages/agents-mobile/src/components/SessionMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
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,
BottomSheetSection,
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'

Expand Down Expand Up @@ -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).
*/
Expand All @@ -116,6 +121,7 @@ export function SessionMenu({
signalError,
onSignal,
onStopImmediately,
onShare,
signalDisabled = false,
}: {
open: boolean
Expand All @@ -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)
Expand Down Expand Up @@ -267,15 +276,39 @@ export function SessionMenu({
{entity.status}
</Text>
<View style={{ flex: 1 }} />
<Text
{/* Tappable session id — mirrors the desktop
entity header's copy-id affordance (icon swaps
to a check while the copy feedback is active). */}
<Pressable
onPress={() =>
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}
</Text>
<Text
numberOfLines={1}
style={{
flexShrink: 1,
color: tokens.text3,
fontSize: 12,
fontFamily: monoFontFamily,
}}
>
{sessionIdFromEntityUrl(entity.url)}
</Text>
<Icon
name={copiedKey === `id` ? `check` : `copy`}
size={14}
color={tokens.text3}
strokeWidth={2}
/>
</Pressable>
</View>
{signalError ? (
<Text
Expand Down Expand Up @@ -341,6 +374,31 @@ export function SessionMenu({
handleClose()
}}
/>
{onShare && (
<BottomSheetItem
label="Share"
icon={
<Icon
name="share"
size={18}
color={tokens.text2}
strokeWidth={2}
/>
}
trailing={
<Icon
name="chevron-right"
size={16}
color={tokens.text3}
strokeWidth={2}
/>
}
onPress={() => {
handleClose()
onShare()
}}
/>
)}
</BottomSheetSection>
</>
)}
Expand Down
31 changes: 27 additions & 4 deletions packages/agents-mobile/src/components/SessionRowMenu.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -108,9 +111,21 @@ function EntityInfo({
<Text style={styles.infoTitle} numberOfLines={2}>
{getEntityDisplayTitle(entity)}
</Text>
<Text style={styles.infoId} numberOfLines={1}>
{entity.url.replace(/^\//, ``)}
</Text>
<Pressable
onPress={() => copy(`id`, sessionIdFromEntityUrl(entity.url))}
hitSlop={8}
style={styles.infoIdRow}
>
<Text style={styles.infoId} numberOfLines={1}>
{sessionIdFromEntityUrl(entity.url)}
</Text>
<Icon
name={copiedKey === `id` ? `check` : `copy`}
size={14}
color={tokens.text3}
strokeWidth={2}
/>
</Pressable>
<Text style={styles.infoMeta} numberOfLines={1}>
{entity.type} · {entity.status}
{childCount > 0
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions packages/agents-mobile/src/components/useCopyFeedback.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>(null)
const timer = useRef<ReturnType<typeof setTimeout> | 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 }
}
Loading
Loading