From 6914a26c7193a70b2d6688cf056b82cd4d24a0a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 26 Jul 2026 10:05:56 +0200 Subject: [PATCH 01/11] feat(mobile): accept shared text, links and files from the OS share sheet --- apps/mobile/app.config.ts | 19 ++ apps/mobile/package.json | 1 + apps/mobile/plugins/withExpoShareIntent.js | 88 ++++++++ apps/mobile/src/app/+native-intent.tsx | 15 ++ apps/mobile/src/app/_layout.tsx | 117 +++++++++- .../src/lib/pending-share-navigation.test.ts | 109 +++++++++ .../src/lib/pending-share-navigation.ts | 47 ++++ apps/mobile/src/lib/share-payload.test.ts | 212 ++++++++++++++++++ apps/mobile/src/lib/share-payload.ts | 128 +++++++++++ pnpm-lock.yaml | 39 +++- pnpm-workspace.yaml | 9 + 11 files changed, 773 insertions(+), 11 deletions(-) create mode 100644 apps/mobile/plugins/withExpoShareIntent.js create mode 100644 apps/mobile/src/app/+native-intent.tsx create mode 100644 apps/mobile/src/lib/pending-share-navigation.test.ts create mode 100644 apps/mobile/src/lib/pending-share-navigation.ts create mode 100644 apps/mobile/src/lib/share-payload.test.ts create mode 100644 apps/mobile/src/lib/share-payload.ts diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 6fc2668992..7b41403a8f 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -149,6 +149,25 @@ const config: ExpoConfig = { }, ], ['react-native-appsflyer', { shouldUsePurchaseConnector: true }], + // Local wrapper: pnpm isolation + Kilo target-name collision (see plugin). + [ + './plugins/withExpoShareIntent', + { + iosActivationRules: { + NSExtensionActivationSupportsText: true, + NSExtensionActivationSupportsWebURLWithMaxCount: 1, + NSExtensionActivationSupportsWebPageWithMaxCount: 1, + NSExtensionActivationSupportsImageWithMaxCount: 5, + NSExtensionActivationSupportsFileWithMaxCount: 5, + }, + androidIntentFilters: ['text/*', '*/*'], + androidMultiIntentFilters: ['*/*'], + iosAppGroupIdentifier: 'group.com.kilocode.kiloapp', + // Display name "Kilo" is applied by the wrapper; target is ShareExtension + // because iosShareExtensionName "Kilo" collides with the main app target. + iosShareExtensionName: 'Kilo', + }, + ], './plugins/withAndroidManifestFix', // ponytail: only registered when GOOGLE_IOS_CLIENT_ID is set, so prebuild works before the // Google OAuth clients exist. diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 9d533c25a6..df9b2e28c8 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -66,6 +66,7 @@ "expo-router": "55.0.16", "expo-screen-corner-radius": "^1.0.1", "expo-secure-store": "55.0.15", + "expo-share-intent": "^6.1.1", "expo-sharing": "~55.0.21", "expo-speech-recognition": "3.1.3", "expo-splash-screen": "55.0.22", diff --git a/apps/mobile/plugins/withExpoShareIntent.js b/apps/mobile/plugins/withExpoShareIntent.js new file mode 100644 index 0000000000..9fec1c42ad --- /dev/null +++ b/apps/mobile/plugins/withExpoShareIntent.js @@ -0,0 +1,88 @@ +const fs = require('fs'); +const Module = require('module'); +const path = require('path'); + +const { withFinalizedMod } = require('expo/config-plugins'); + +// expo-share-intent@6.1.1 requires @expo/plist without declaring it. Under pnpm's +// isolated linker the nested plugin cannot resolve that package from its own +// directory. Resolve the copy that ships with @expo/config-plugins and patch +// Module resolution only while loading the upstream plugin. +const expoConfigPluginsEntry = require.resolve('expo/config-plugins'); +const configPluginsPkg = require.resolve('@expo/config-plugins/package.json', { + paths: [path.dirname(expoConfigPluginsEntry)], +}); +const expoPlistEntry = require.resolve('@expo/plist', { + paths: [path.dirname(configPluginsPkg)], +}); + +function loadUpstreamPlugin() { + const originalResolveFilename = Module._resolveFilename; + Module._resolveFilename = function resolveWithPlistFallback(request, parent, isMain, options) { + if (request === '@expo/plist') { + return expoPlistEntry; + } + return originalResolveFilename.call(this, request, parent, isMain, options); + }; + try { + // Clear cache so a previous failed load does not stick. + const resolved = require.resolve('expo-share-intent/app.plugin.js'); + delete require.cache[resolved]; + const mod = require(resolved); + return typeof mod === 'function' ? mod : mod.default; + } finally { + Module._resolveFilename = originalResolveFilename; + } +} + +const withExpoShareIntentUpstream = loadUpstreamPlugin(); + +// Upstream uses iosShareExtensionName for both the Xcode target name and +// CFBundleDisplayName. The app target is already "Kilo", so "Kilo" collides and +// the extension target is skipped. Keep a distinct target name and force the +// share-sheet label to "Kilo" after files are written. +const SHARE_EXTENSION_TARGET = 'ShareExtension'; +const SHARE_SHEET_DISPLAY_NAME = 'Kilo'; + +function withKiloShareSheetDisplayName(config) { + // finalized runs after dangerous mods that write ShareExtension-Info.plist. + return withFinalizedMod(config, [ + 'ios', + async config => { + const infoPath = path.join( + config.modRequest.platformProjectRoot, + SHARE_EXTENSION_TARGET, + 'ShareExtension-Info.plist' + ); + if (!fs.existsSync(infoPath)) { + throw new Error( + `Expected ShareExtension-Info.plist so CFBundleDisplayName can be rewritten to "${SHARE_SHEET_DISPLAY_NAME}", but it was missing at ${infoPath}` + ); + } + const original = fs.readFileSync(infoPath, 'utf8'); + const updated = original.replace( + /(CFBundleDisplayName<\/key>\s*)[^<]*(<\/string>)/, + `$1${SHARE_SHEET_DISPLAY_NAME}$2` + ); + if (updated === original) { + throw new Error( + `Failed to rewrite CFBundleDisplayName to "${SHARE_SHEET_DISPLAY_NAME}" in ${infoPath}` + ); + } + fs.writeFileSync(infoPath, updated); + return config; + }, + ]); +} + +module.exports = function withExpoShareIntent(config, props = {}) { + const { iosShareExtensionName: _ignoredDisplayName, ...rest } = props; + const parameters = { + ...rest, + // Distinct from the main "Kilo" app target (see collision note above). + iosShareExtensionName: SHARE_EXTENSION_TARGET, + }; + config = withExpoShareIntentUpstream(config, parameters); + config = withKiloShareSheetDisplayName(config); + return config; +}; diff --git a/apps/mobile/src/app/+native-intent.tsx b/apps/mobile/src/app/+native-intent.tsx new file mode 100644 index 0000000000..58bb222ddf --- /dev/null +++ b/apps/mobile/src/app/+native-intent.tsx @@ -0,0 +1,15 @@ +import { getShareExtensionKey } from 'expo-share-intent'; + +export function redirectSystemPath({ path, initial }: { path: string; initial: boolean }) { + let shareKey: string | null = null; + try { + shareKey = getShareExtensionKey(); + } catch { + shareKey = null; + } + if (shareKey && path.includes(`dataUrl=${shareKey}`)) { + // Cold start: boot the app normally. Warm: stay exactly where the user is. + return initial ? '/' : null; + } + return path; +} diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 2315ce0d93..131b9b8b53 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -20,9 +20,11 @@ import { useSegments, } from 'expo-router'; import * as SplashScreen from 'expo-splash-screen'; +import { ShareIntentProvider, useShareIntentContext } from 'expo-share-intent'; import { StatusBar } from 'expo-status-bar'; import { useEffect, useState } from 'react'; import { View } from 'react-native'; +import { toast } from 'sonner-native'; import { AppRootProviders } from '@/components/app-root-providers'; import { BootstrapErrorScreen } from '@/components/bootstrap-error-screen'; @@ -44,6 +46,16 @@ import { setupNotificationResponseHandler, } from '@/lib/notifications'; import { resolvePendingNotificationNavigation } from '@/lib/pending-notification-navigation'; +import { + isShellReadyForShare, + resolvePendingShareNavigation, +} from '@/lib/pending-share-navigation'; +import { + normalizeShareIntent, + putSharePayload, + type ShareId, + type SharePayload, +} from '@/lib/share-payload'; import { sentryOptionsForConsent } from '@/lib/sentry-consent'; import { useSentryConsentSync } from '@/lib/hooks/use-sentry-consent-sync'; @@ -128,6 +140,30 @@ function RootLayoutNav() { const inForceUpdate = segments[0] === 'force-update'; const onConsentRoute = pathname === '/consent' || pathname === '/consent-details'; const onConsentReviewRoute = onConsentRoute && consentModeForSearchParam(mode) === 'review'; + const onGateRoute = (segments as readonly string[]).includes('share-gate'); + const { + hasShareIntent, + shareIntent, + resetShareIntent, + error: shareIntentError, + } = useShareIntentContext(); + const [pendingShareId, setPendingShareId] = useState(null); + + // Paired with isShellReadyForShare — keep the success-tail guards in lockstep. + const isShellReady = isShellReadyForShare({ + hasToken: token != null, + isLoading, + updateRequired, + inAuthGroup, + inForceUpdate, + userIdLoading, + userIdError, + consentCheckError: consentCheckError != null, + consentChecked, + needsConsent, + onConsentRoute, + onConsentReviewRoute, + }); useEffect(() => { let cancelled = false; @@ -186,6 +222,49 @@ function RootLayoutNav() { useAnalyticsConsentGate({ hasToken: token != null, consentChecked, needsConsent, email }); useScreenTracking(); + useEffect(() => { + if (shareIntentError) { + Sentry.captureException(new Error(shareIntentError)); + toast.error("Couldn't read the shared content"); + resetShareIntent(); + } + }, [shareIntentError, resetShareIntent]); + + // Keyed on hasShareIntent false→true only — not shareIntent identity. + useEffect(() => { + if (!hasShareIntent) { + return undefined; + } + + let cancelled = false; + + const ingestShareIntent = async () => { + try { + const payload: SharePayload = await normalizeShareIntent(shareIntent); + if (cancelled) { + return; + } + const shareId = putSharePayload(payload); + resetShareIntent(); + setPendingShareId(shareId); + } catch (error) { + if (cancelled) { + return; + } + Sentry.captureException(error); + toast.error("Couldn't read the shared content"); + resetShareIntent(); + } + }; + + void ingestShareIntent(); + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- false→true on hasShareIntent only + }, [hasShareIntent]); + useEffect(() => { if (isLoading) { return; @@ -246,6 +325,7 @@ function RootLayoutNav() { if (pendingNavigation) { router.navigate(pendingNavigation.href as Href); } + // Share-gate open is owned by the pendingShareId effect + isShellReadyForShare. } }, [ token, @@ -263,6 +343,29 @@ function RootLayoutNav() { onConsentReviewRoute, ]); + // Declared after the auth effect so that on the same flush a pending + // notification navigate runs first and the share gate opens on top. + useEffect(() => { + if (pendingShareId === null || !isShellReady) { + return; + } + + const navigation = resolvePendingShareNavigation({ + shareId: pendingShareId, + onGateRoute, + }); + if (!navigation) { + return; + } + + if (navigation.mode === 'replace') { + router.replace(navigation.href as Href); + } else { + router.push(navigation.href as Href); + } + setPendingShareId(null); + }, [pendingShareId, isShellReady, onGateRoute, router]); + const needsForceUpdate = updateRequired && !inForceUpdate; const showingForceUpdate = updateRequired && inForceUpdate; const needsAuth = !token && !inAuthGroup; @@ -352,12 +455,14 @@ function RootLayout() { }, []); return ( - - - - - - + + + + + + + + ); } diff --git a/apps/mobile/src/lib/pending-share-navigation.test.ts b/apps/mobile/src/lib/pending-share-navigation.test.ts new file mode 100644 index 0000000000..61b88d7191 --- /dev/null +++ b/apps/mobile/src/lib/pending-share-navigation.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; + +import { isShellReadyForShare, resolvePendingShareNavigation } from './pending-share-navigation'; + +const ready = { + hasToken: true, + isLoading: false, + updateRequired: false, + inAuthGroup: false, + inForceUpdate: false, + userIdLoading: false, + userIdError: false, + consentCheckError: false, + consentChecked: true, + needsConsent: false, + onConsentRoute: false, + onConsentReviewRoute: false, +} as const; + +describe('isShellReadyForShare', () => { + it('is true when every guard is satisfied', () => { + expect(isShellReadyForShare(ready)).toBe(true); + }); + + it('is false while loading', () => { + expect(isShellReadyForShare({ ...ready, isLoading: true })).toBe(false); + }); + + it('is false when an update is required', () => { + expect(isShellReadyForShare({ ...ready, updateRequired: true })).toBe(false); + }); + + it('is false on the force-update route', () => { + expect(isShellReadyForShare({ ...ready, inForceUpdate: true })).toBe(false); + }); + + it('is false without a token', () => { + expect(isShellReadyForShare({ ...ready, hasToken: false })).toBe(false); + }); + + it('is false when user id failed', () => { + expect(isShellReadyForShare({ ...ready, userIdError: true })).toBe(false); + }); + + it('is false when consent check failed', () => { + expect(isShellReadyForShare({ ...ready, consentCheckError: true })).toBe(false); + }); + + it('is false while user id is loading', () => { + expect(isShellReadyForShare({ ...ready, userIdLoading: true })).toBe(false); + }); + + it('is false before consent is checked', () => { + expect(isShellReadyForShare({ ...ready, consentChecked: false })).toBe(false); + }); + + it('is false when consent is still needed', () => { + expect(isShellReadyForShare({ ...ready, needsConsent: true })).toBe(false); + }); + + it('is false in the auth group', () => { + expect(isShellReadyForShare({ ...ready, inAuthGroup: true })).toBe(false); + }); + + it('is false on a non-review consent route', () => { + expect( + isShellReadyForShare({ + ...ready, + onConsentRoute: true, + onConsentReviewRoute: false, + }) + ).toBe(false); + }); + + it('is true on the consent review route when other guards pass', () => { + expect( + isShellReadyForShare({ + ...ready, + onConsentRoute: true, + onConsentReviewRoute: true, + }) + ).toBe(true); + }); +}); + +describe('resolvePendingShareNavigation', () => { + it('returns null without a share id', () => { + expect(resolvePendingShareNavigation({ shareId: null, onGateRoute: false })).toBeNull(); + }); + + it('pushes the gate when not already on it', () => { + expect(resolvePendingShareNavigation({ shareId: 'abc', onGateRoute: false })).toEqual({ + href: '/(app)/share-gate?shareId=abc', + mode: 'push', + }); + }); + + it('replaces when already on the gate route', () => { + expect(resolvePendingShareNavigation({ shareId: 'abc', onGateRoute: true })).toEqual({ + href: '/(app)/share-gate?shareId=abc', + mode: 'replace', + }); + }); + + it('includes the share id in the href', () => { + const result = resolvePendingShareNavigation({ shareId: 'share-42', onGateRoute: false }); + expect(result?.href).toContain('shareId=share-42'); + }); +}); diff --git a/apps/mobile/src/lib/pending-share-navigation.ts b/apps/mobile/src/lib/pending-share-navigation.ts new file mode 100644 index 0000000000..f0b27afeac --- /dev/null +++ b/apps/mobile/src/lib/pending-share-navigation.ts @@ -0,0 +1,47 @@ +import { type ShareId } from '@/lib/share-payload'; + +/** + * Conjunction of the guards the auth effect in `_layout.tsx` passes before its + * success tail. Keep in lockstep with that effect — do not refactor either in isolation. + */ +export function isShellReadyForShare(input: { + hasToken: boolean; + isLoading: boolean; + updateRequired: boolean; + inAuthGroup: boolean; + inForceUpdate: boolean; + userIdLoading: boolean; + userIdError: boolean; + consentCheckError: boolean; + consentChecked: boolean; + needsConsent: boolean; + onConsentRoute: boolean; + onConsentReviewRoute: boolean; +}): boolean { + return ( + !input.isLoading && + !input.updateRequired && + !input.inForceUpdate && + input.hasToken && + !input.userIdError && + !input.consentCheckError && + !input.userIdLoading && + input.consentChecked && + !input.needsConsent && + !input.inAuthGroup && + !(input.onConsentRoute && !input.onConsentReviewRoute) + ); +} + +export function resolvePendingShareNavigation(input: { + shareId: ShareId | null; + onGateRoute: boolean; +}): { href: string; mode: 'push' | 'replace' } | null { + if (input.shareId === null) { + return null; + } + return { + href: `/(app)/share-gate?shareId=${encodeURIComponent(input.shareId)}`, + mode: input.onGateRoute ? 'replace' : 'push', + }; +} diff --git a/apps/mobile/src/lib/share-payload.test.ts b/apps/mobile/src/lib/share-payload.test.ts new file mode 100644 index 0000000000..389caa1c6d --- /dev/null +++ b/apps/mobile/src/lib/share-payload.test.ts @@ -0,0 +1,212 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('expo-crypto', () => { + let n = 0; + return { + randomUUID: () => { + n += 1; + return `id-${n}`; + }, + }; +}); + +vi.mock('expo-file-system/legacy', () => ({ + cacheDirectory: 'file:///cache/', + copyAsync: vi.fn(async () => { + await Promise.resolve(); + }), +})); + +describe('composeShareText', () => { + it('returns trimmed text', async () => { + const { composeShareText } = await import('./share-payload'); + expect(composeShareText({ text: ' hello ', webUrl: null, meta: null, files: null })).toBe( + 'hello' + ); + }); + + it('returns empty string when text is blank and no webUrl', async () => { + const { composeShareText } = await import('./share-payload'); + expect(composeShareText({ text: ' ', webUrl: null, meta: null, files: null })).toBe(''); + }); + + it('falls back to webUrl when text is empty', async () => { + const { composeShareText } = await import('./share-payload'); + expect( + composeShareText({ + text: ' ', + webUrl: 'https://example.com', + meta: null, + files: null, + }) + ).toBe('https://example.com'); + }); + + it('prefers non-empty text over webUrl', async () => { + const { composeShareText } = await import('./share-payload'); + expect( + composeShareText({ + text: 'body', + webUrl: 'https://example.com', + meta: null, + files: null, + }) + ).toBe('body'); + }); + + it('prefixes title when base is non-empty and does not already contain it', async () => { + const { composeShareText } = await import('./share-payload'); + expect( + composeShareText({ + text: 'https://example.com', + webUrl: null, + meta: { title: 'Example' }, + files: null, + }) + ).toBe('Example\nhttps://example.com'); + }); + + it('does not re-prefix title when base already contains it', async () => { + const { composeShareText } = await import('./share-payload'); + expect( + composeShareText({ + text: 'Example page https://example.com', + webUrl: null, + meta: { title: 'Example' }, + files: null, + }) + ).toBe('Example page https://example.com'); + }); + + it('does not add title when base is empty', async () => { + const { composeShareText } = await import('./share-payload'); + expect( + composeShareText({ + text: '', + webUrl: null, + meta: { title: 'Example' }, + files: null, + }) + ).toBe(''); + }); + + it('clamps to SHARE_TEXT_MAX_CHARS last', async () => { + const { composeShareText, SHARE_TEXT_MAX_CHARS } = await import('./share-payload'); + const long = 'a'.repeat(SHARE_TEXT_MAX_CHARS + 50); + const composed = composeShareText({ + text: long, + webUrl: null, + meta: { title: 'T' }, + files: null, + }); + expect(composed.length).toBe(SHARE_TEXT_MAX_CHARS); + expect(composed.startsWith('T\n')).toBe(true); + }); +}); + +describe('share payload store', () => { + beforeEach(async () => { + const { __resetSharePayloadStoreForTests } = await import('./share-payload'); + __resetSharePayloadStoreForTests(); + vi.clearAllMocks(); + }); + + it('put returns unique ids', async () => { + const { putSharePayload, peekSharePayload } = await import('./share-payload'); + const a = putSharePayload({ text: 'a', files: [] }); + const b = putSharePayload({ text: 'b', files: [] }); + expect(a).not.toBe(b); + expect(peekSharePayload(a)?.text).toBe('a'); + expect(peekSharePayload(b)?.text).toBe('b'); + }); + + it('take is read-and-clear and returns null on second read or unknown id', async () => { + const { putSharePayload, takeSharePayload } = await import('./share-payload'); + const id = putSharePayload({ text: 'once', files: [] }); + expect(takeSharePayload(id)).toEqual({ text: 'once', files: [] }); + expect(takeSharePayload(id)).toBeNull(); + expect(takeSharePayload('missing')).toBeNull(); + }); + + it('peek does not consume', async () => { + const { putSharePayload, peekSharePayload, takeSharePayload } = await import('./share-payload'); + const id = putSharePayload({ text: 'peek', files: [] }); + expect(peekSharePayload(id)?.text).toBe('peek'); + expect(peekSharePayload(id)?.text).toBe('peek'); + expect(takeSharePayload(id)?.text).toBe('peek'); + }); + + it('clear is id-scoped', async () => { + const { putSharePayload, clearSharePayload, peekSharePayload } = + await import('./share-payload'); + const a = putSharePayload({ text: 'a', files: [] }); + const b = putSharePayload({ text: 'b', files: [] }); + clearSharePayload(a); + expect(peekSharePayload(a)).toBeNull(); + expect(peekSharePayload(b)?.text).toBe('b'); + }); + + it('evicts oldest first beyond the cap', async () => { + const { putSharePayload, peekSharePayload, SHARE_PAYLOAD_MAX_ENTRIES } = + await import('./share-payload'); + const ids: string[] = []; + for (let i = 0; i < SHARE_PAYLOAD_MAX_ENTRIES + 2; i += 1) { + ids.push(putSharePayload({ text: `t-${i}`, files: [] })); + } + const first = ids[0]; + const second = ids[1]; + const third = ids[2]; + const last = ids.at(-1); + expect(first).toBeDefined(); + expect(second).toBeDefined(); + expect(third).toBeDefined(); + expect(last).toBeDefined(); + expect(peekSharePayload(first ?? '')).toBeNull(); + expect(peekSharePayload(second ?? '')).toBeNull(); + expect(peekSharePayload(third ?? '')?.text).toBe('t-2'); + expect(peekSharePayload(last ?? '')?.text).toBe(`t-${SHARE_PAYLOAD_MAX_ENTRIES + 1}`); + }); +}); + +describe('normalizeShareIntent', () => { + it('copies files into cache paths rather than keeping share-container URIs', async () => { + const { normalizeShareIntent } = await import('./share-payload'); + const incoming = 'file:///share-container/photo.jpg'; + const payload = await normalizeShareIntent( + { + text: null, + webUrl: null, + meta: null, + files: [ + { + fileName: 'photo.jpg', + mimeType: 'image/jpeg', + path: incoming, + size: 12, + width: null, + height: null, + duration: null, + }, + ], + }, + async ({ from, fileName }) => { + expect(from).toBe(incoming); + await Promise.resolve(); + return `file:///cache/copied-${fileName}`; + } + ); + + expect(payload.files).toEqual([ + { + name: 'photo.jpg', + uri: 'file:///cache/copied-photo.jpg', + mimeType: 'image/jpeg', + size: 12, + }, + ]); + const file = payload.files[0]; + expect(file).toBeDefined(); + expect(file?.uri).not.toBe(incoming); + expect(file?.uri.includes('cache')).toBe(true); + }); +}); diff --git a/apps/mobile/src/lib/share-payload.ts b/apps/mobile/src/lib/share-payload.ts new file mode 100644 index 0000000000..7e5d122400 --- /dev/null +++ b/apps/mobile/src/lib/share-payload.ts @@ -0,0 +1,128 @@ +import * as Crypto from 'expo-crypto'; +import { cacheDirectory, copyAsync } from 'expo-file-system/legacy'; +import { type ShareIntent } from 'expo-share-intent'; + +import { type AgentAttachmentCandidate } from '@/lib/agent-attachments/use-agent-attachment-upload'; + +export type ShareId = string; + +export type SharePayload = { + text: string; + files: AgentAttachmentCandidate[]; +}; + +/** Mirrors PROMPT_INPUT_MAX_CHARS in new-session-prompt.tsx (module-local; composer clamps again). */ +export const SHARE_TEXT_MAX_CHARS = 4000; + +export const SHARE_PAYLOAD_MAX_ENTRIES = 5; + +type ShareIntentLike = Pick; + +type CopyToCache = (args: { from: string; fileName: string }) => Promise; + +const payloads = new Map(); +const insertionOrder: ShareId[] = []; + +function evictOldestIfNeeded(): void { + while (payloads.size > SHARE_PAYLOAD_MAX_ENTRIES && insertionOrder.length > 0) { + const oldest = insertionOrder.shift(); + if (oldest !== undefined) { + payloads.delete(oldest); + } + } +} + +/** Adds an entry and returns its id. Evicts oldest beyond SHARE_PAYLOAD_MAX_ENTRIES. */ +export function putSharePayload(payload: SharePayload): ShareId { + const id = Crypto.randomUUID(); + payloads.set(id, payload); + insertionOrder.push(id); + evictOldestIfNeeded(); + return id; +} + +/** Read-and-delete. Null if `id` is unknown or already consumed. */ +export function takeSharePayload(id: ShareId): SharePayload | null { + const payload = payloads.get(id) ?? null; + if (payload === null) { + return null; + } + payloads.delete(id); + const index = insertionOrder.indexOf(id); + if (index !== -1) { + insertionOrder.splice(index, 1); + } + return payload; +} + +/** Read-only, for the gate's own preview. */ +export function peekSharePayload(id: ShareId): SharePayload | null { + return payloads.get(id) ?? null; +} + +/** Id-scoped abandonment. Never clears another id's entry. */ +export function clearSharePayload(id: ShareId): void { + if (!payloads.has(id)) { + return; + } + payloads.delete(id); + const index = insertionOrder.indexOf(id); + if (index !== -1) { + insertionOrder.splice(index, 1); + } +} + +/** Test-only: wipe the module store between cases. */ +export function __resetSharePayloadStoreForTests(): void { + payloads.clear(); + insertionOrder.length = 0; +} + +export function composeShareText(shareIntent: ShareIntentLike): string { + let base = (shareIntent.text ?? '').trim(); + if (base === '' && shareIntent.webUrl) { + base = shareIntent.webUrl; + } + const title = shareIntent.meta?.title?.trim(); + if (title && base !== '' && !base.includes(title)) { + base = `${title}\n${base}`; + } + if (base.length > SHARE_TEXT_MAX_CHARS) { + return base.slice(0, SHARE_TEXT_MAX_CHARS); + } + return base; +} + +async function defaultCopyToCache(args: { from: string; fileName: string }): Promise { + const root = cacheDirectory; + if (!root) { + throw new Error('cacheDirectory is unavailable'); + } + const safeName = args.fileName.replaceAll(/[/\\]/g, '_') || 'shared-file'; + const destination = `${root}share-${Crypto.randomUUID()}-${safeName}`; + await copyAsync({ from: args.from, to: destination }); + return destination; +} + +export async function normalizeShareIntent( + shareIntent: ShareIntentLike, + copyToCache: CopyToCache = defaultCopyToCache +): Promise { + const text = composeShareText(shareIntent); + const files = await Promise.all( + (shareIntent.files ?? []).map(async file => { + const name = file.fileName || 'shared-file'; + const uri = await copyToCache({ from: file.path, fileName: name }); + const candidate: AgentAttachmentCandidate = { name, uri }; + if (file.mimeType) { + candidate.mimeType = file.mimeType; + } + if (file.size != null) { + candidate.size = file.size; + } + return candidate; + }) + ); + + return { text, files }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 590007df0b..7ba2f20475 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -129,6 +129,8 @@ overrides: axios: 1.16.1 fast-uri: 3.1.2 +packageExtensionsChecksum: sha256-fP3QP+V7K1eqAtnNLNuNhWo+eNmcw41BqhGZrm70Dw4= + patchedDependencies: expo-server-sdk: 7850520582b5b394397b35d1ea195192fe78589d8a6a748fe15177b818c4ed0b react-native-appsflyer@6.17.9: 82df99378c830e774b0f01796d8be595da114d1d13393d85ddd47d565c5c2aab @@ -408,6 +410,9 @@ importers: expo-secure-store: specifier: 55.0.15 version: 55.0.15(expo@55.0.27) + expo-share-intent: + specifier: ^6.1.1 + version: 6.1.1(expo-constants@55.0.16)(expo-linking@55.0.16)(expo@55.0.27)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) expo-sharing: specifier: ~55.0.21 version: 55.0.21(expo@55.0.27)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) @@ -4759,8 +4764,8 @@ packages: '@expo/json-file@10.2.0': resolution: {integrity: sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==} - '@expo/json-file@11.0.0': - resolution: {integrity: sha512-pHJCETqFL5x5BzNV6cEPwjwuECgGmnl0bNmfHIJ6LM1tlh2eVXi5HEdit3zby/JO/B8Otk5cgcqtJXgvvUat3A==} + '@expo/json-file@11.0.1': + resolution: {integrity: sha512-zxHWj4MKKMAL29ZQSY/Fssx4Thluk40JmuGNaeS078wy/NhlFhnVi+rHHunulE3xJAJ0CM73m8VK2+GkF9eRwQ==} '@expo/local-build-cache-provider@55.0.14': resolution: {integrity: sha512-Itv/Wm8wBuq2QoJeda7N0Ys2ciq3ZQW+JLL8XijWMYOO/llieKeZue95FsuaREp3Ijdo0pSw0/cu+EkrwmU0vA==} @@ -12296,6 +12301,15 @@ packages: resolution: {integrity: sha512-AxRdHqcv0H1g4s923vu+5n1Nrhne23bjXbP+Vl7+Lwfpe7MG9PuU1IS95IJK6a+7BVV1mRN6QlZvs8Yv7EEXNQ==} engines: {node: '>=20.16.0'} + expo-share-intent@6.1.1: + resolution: {integrity: sha512-2t9OnnycDU2k+WQEppsENA6c4Bm+VANsl4eNJ/Q0EWpwBj4KL2Th/UE50TrOOJmAJGHyu2YPwZXFYqOKDs085Q==} + peerDependencies: + expo: ^55 + expo-constants: '>=55.0.7' + expo-linking: '>=55.0.7' + react: '*' + react-native: '*' + expo-sharing@55.0.21: resolution: {integrity: sha512-iiM9nAIIouPRxms41F7ytacna1BzImb5KSI1a9ZlrvrW2ybKx8YDw7+3mClFplMrWp+sy8A+bWkR75mmhKE78w==} peerDependencies: @@ -19780,7 +19794,7 @@ snapshots: cjs-module-lexer: 1.2.3 esbuild: 0.27.4 miniflare: 4.20260603.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.27.4)(jiti@2.7.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) + vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.27.4)(jiti@2.7.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) wrangler: 4.98.0(@cloudflare/workers-types@4.20260605.1)(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 transitivePeerDependencies: @@ -20567,7 +20581,7 @@ snapshots: '@babel/code-frame': 7.29.7 json5: 2.2.3 - '@expo/json-file@11.0.0': + '@expo/json-file@11.0.1': dependencies: '@babel/code-frame': 7.29.7 json5: 2.2.3 @@ -20658,7 +20672,7 @@ snapshots: '@expo/package-manager@1.13.0': dependencies: - '@expo/json-file': 11.0.0 + '@expo/json-file': 11.0.1 '@expo/spawn-async': 1.8.0 chalk: 4.1.2 npm-package-arg: 11.0.3 @@ -24485,6 +24499,7 @@ snapshots: '@sentry/react-native@7.11.0(expo@55.0.27)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0)': dependencies: + '@expo/config-plugins': 55.0.10 '@sentry/babel-plugin-component-annotate': 4.8.0 '@sentry/browser': 10.37.0 '@sentry/cli': 3.4.2 @@ -24495,6 +24510,8 @@ snapshots: react-native: 0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6) optionalDependencies: expo: 55.0.27(@babel/core@7.29.0)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(bufferutil@4.1.0)(expo-router@55.0.16)(react-dom@19.2.6(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - supports-color '@sentry/react@10.37.0(react@19.2.0)': dependencies: @@ -29020,6 +29037,18 @@ snapshots: expo-server@55.0.11: {} + expo-share-intent@6.1.1(expo-constants@55.0.16)(expo-linking@55.0.16)(expo@55.0.27)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0): + dependencies: + '@expo/config-plugins': 55.0.10 + '@expo/plist': 0.5.4 + expo: 55.0.27(@babel/core@7.29.0)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(bufferutil@4.1.0)(expo-router@55.0.16)(react-dom@19.2.6(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + expo-constants: 55.0.16(expo@55.0.27)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6)) + expo-linking: 55.0.16(expo@55.0.27)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - supports-color + expo-sharing@55.0.21(expo@55.0.27)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0): dependencies: '@expo/config-plugins': 55.0.10 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ae0f8deecd..56777ef4bb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -111,6 +111,15 @@ overrides: lightningcss: 1.30.1 axios: 1.16.1 fast-uri: 3.1.2 +packageExtensions: + # Undeclared upstream deps that break config evaluation outside the Expo CLI + # under pnpm's isolated linker (autoInstallPeers is false). + '@sentry/react-native': + dependencies: + '@expo/config-plugins': '*' + expo-share-intent: + dependencies: + '@expo/plist': '*' patchedDependencies: expo-server-sdk: patches/expo-server-sdk.patch react-native-appsflyer@6.17.9: patches/react-native-appsflyer@6.17.9.patch From 5c3abbd062542e308a8ce4610d5bb2dcea57f869 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 26 Jul 2026 10:05:57 +0200 Subject: [PATCH 02/11] feat(mobile): add a share gate for choosing a new or existing session --- apps/mobile/src/app/(app)/_layout.tsx | 11 + apps/mobile/src/app/(app)/share-gate.tsx | 10 + .../src/components/agents/session-row.tsx | 14 + .../share/share-destination-list.tsx | 185 +++++++++++++ .../share/share-destinations.test.ts | 71 +++++ .../components/share/share-destinations.ts | 35 +++ .../src/components/share/share-gate-sheet.tsx | 253 ++++++++++++++++++ .../components/share/share-gate-state.test.ts | 201 ++++++++++++++ .../src/components/share/share-gate-state.ts | 152 +++++++++++ .../share/share-payload-navigator.tsx | 52 ++++ .../share/share-payload-preview.tsx | 128 +++++++++ .../share/share-payload-validation.test.ts | 158 +++++++++++ .../share/share-payload-validation.ts | 145 ++++++++++ apps/mobile/src/lib/share-navigation.test.ts | 104 +++++++ apps/mobile/src/lib/share-navigation.ts | 72 +++++ 15 files changed, 1591 insertions(+) create mode 100644 apps/mobile/src/app/(app)/share-gate.tsx create mode 100644 apps/mobile/src/components/share/share-destination-list.tsx create mode 100644 apps/mobile/src/components/share/share-destinations.test.ts create mode 100644 apps/mobile/src/components/share/share-destinations.ts create mode 100644 apps/mobile/src/components/share/share-gate-sheet.tsx create mode 100644 apps/mobile/src/components/share/share-gate-state.test.ts create mode 100644 apps/mobile/src/components/share/share-gate-state.ts create mode 100644 apps/mobile/src/components/share/share-payload-navigator.tsx create mode 100644 apps/mobile/src/components/share/share-payload-preview.tsx create mode 100644 apps/mobile/src/components/share/share-payload-validation.test.ts create mode 100644 apps/mobile/src/components/share/share-payload-validation.ts create mode 100644 apps/mobile/src/lib/share-navigation.test.ts create mode 100644 apps/mobile/src/lib/share-navigation.ts diff --git a/apps/mobile/src/app/(app)/_layout.tsx b/apps/mobile/src/app/(app)/_layout.tsx index e6f0efc91c..5ed3b260c0 100644 --- a/apps/mobile/src/app/(app)/_layout.tsx +++ b/apps/mobile/src/app/(app)/_layout.tsx @@ -3,6 +3,7 @@ import { Stack } from 'expo-router'; import { UserWebConnectionProvider } from '@/components/agents/user-web-connection-provider'; import { KiloChatPresenceMount } from '@/components/kilo-chat/kilo-chat-presence-mount'; import { KiloChatProvider } from '@/components/kilo-chat/kilo-chat-provider'; +import { SharePayloadNavigator } from '@/components/share/share-payload-navigator'; import { ActiveSessionsLiveSyncMount } from '@/lib/active-sessions-live-sync-mount'; import { useFormSheetDetents } from '@/lib/form-sheet'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; @@ -15,6 +16,7 @@ export default function AppLayout() { return ( + @@ -70,6 +72,15 @@ export default function AppLayout() { headerShown: false, }} /> + (); + // Param can be string | string[] depending on how the route was opened. + const id = Array.isArray(shareId) ? shareId[0] : shareId; + return ; +} diff --git a/apps/mobile/src/components/agents/session-row.tsx b/apps/mobile/src/components/agents/session-row.tsx index e66fbcd285..efad966d9b 100644 --- a/apps/mobile/src/components/agents/session-row.tsx +++ b/apps/mobile/src/components/agents/session-row.tsx @@ -65,6 +65,16 @@ type StoredSessionRowProps = { * Tap is preserved either way. Defaults to `true`. */ interactive?: boolean; + /** + * Forwarded to the base `SessionRow` live dot. Defaults to `false` so + * Home and the Agents list stay behavior-identical. + */ + live?: boolean; + /** + * Forwarded to the base `SessionRow` meta-while-live opt-in. Defaults to + * `false` so existing call sites are unchanged. + */ + metaWhileLive?: boolean; }; export function StoredSessionRow({ @@ -75,6 +85,8 @@ export function StoredSessionRow({ onRename, variant = 'list', interactive = true, + live = false, + metaWhileLive = false, }: Readonly) { const colors = useThemeColors(); const title = session.title && session.title.length > 0 ? session.title : 'Untitled session'; @@ -187,6 +199,8 @@ export function StoredSessionRow({ title={title} subtitle={session.git_branch} meta={visibleMeta} + live={live} + metaWhileLive={metaWhileLive} needsInput={needsInput} stripMode={variant === 'card' ? 'edge' : 'inline'} last={variant === 'card' ? true : undefined} diff --git a/apps/mobile/src/components/share/share-destination-list.tsx b/apps/mobile/src/components/share/share-destination-list.tsx new file mode 100644 index 0000000000..33d561c94e --- /dev/null +++ b/apps/mobile/src/components/share/share-destination-list.tsx @@ -0,0 +1,185 @@ +import { Search } from 'lucide-react-native'; +import { useMemo, useState } from 'react'; +import { FlatList, TextInput, View, type ViewStyle } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { StoredSessionRow } from '@/components/agents/session-row'; +import { QueryError } from '@/components/query-error'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +import { type ShareDestinationRow } from './share-destinations'; +import { type ShareGateState } from './share-gate-state'; + +const SEARCH_THRESHOLD = 8; +const SKELETON_COUNT = 5; + +type ShareDestinationListProps = { + state: ShareGateState; + destinations: readonly ShareDestinationRow[]; + onSelect: (row: ShareDestinationRow) => void; + onRetry: () => void; +}; + +function DestinationSearch({ onChange }: { onChange: (next: string) => void }) { + const colors = useThemeColors(); + return ( + + + + + ); +} + +function SkeletonRows() { + return ( + + {Array.from({ length: SKELETON_COUNT }, (_, i) => ( + + + + ))} + + ); +} + +function EmptyMessage({ message }: { message: string }) { + return ( + + {message} + + ); +} + +/** + * Destination FlatList for the share gate. Must be a direct child of the + * formSheet screen content (paired with the collapsable header View). + * Search is ListHeaderComponent — scrolls with the list, shown only when + * loaded destination count > 8. + */ +export function ShareDestinationList({ + state, + destinations, + onSelect, + onRetry, +}: Readonly) { + const { bottom } = useSafeAreaInsets(); + const [search, setSearch] = useState(''); + + const showSearch = destinations.length > SEARCH_THRESHOLD; + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + if (q === '') { + return destinations; + } + return destinations.filter(row => { + const title = (row.title ?? '').toLowerCase(); + const branch = (row.git_branch ?? '').toLowerCase(); + return title.includes(q) || branch.includes(q); + }); + }, [destinations, search]); + + const contentPad = useMemo(() => ({ paddingBottom: bottom + 16 }) satisfies ViewStyle, [bottom]); + const growContentPad = useMemo( + () => ({ paddingBottom: bottom + 16, flexGrow: 1 }) satisfies ViewStyle, + [bottom] + ); + + if (state.kind === 'loading') { + return ( + `skeleton-${index}`} + ListHeaderComponent={} + renderItem={() => null} + contentContainerStyle={contentPad} + keyboardShouldPersistTaps="handled" + /> + ); + } + + if (state.kind === 'retryable') { + return ( + 'error'} + ListEmptyComponent={ + + } + renderItem={() => null} + contentContainerStyle={growContentPad} + keyboardShouldPersistTaps="handled" + /> + ); + } + + if (state.kind === 'empty') { + return ( + 'empty'} + ListEmptyComponent={} + renderItem={() => null} + contentContainerStyle={growContentPad} + keyboardShouldPersistTaps="handled" + /> + ); + } + + // Terminal non-retryable states: header already shows the message; keep an + // empty FlatList so the formSheet still has [header, list] as direct children. + if (state.kind === 'stale-share' || state.kind === 'non-retryable-classification') { + return ( + 'terminal'} + renderItem={() => null} + contentContainerStyle={contentPad} + keyboardShouldPersistTaps="handled" + /> + ); + } + + // happy + return ( + item.session_id} + ListHeaderComponent={showSearch ? : null} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" + contentContainerStyle={contentPad} + renderItem={({ item }) => ( + { + onSelect(item); + }} + /> + )} + ListEmptyComponent={search.trim() ? : null} + /> + ); +} diff --git a/apps/mobile/src/components/share/share-destinations.test.ts b/apps/mobile/src/components/share/share-destinations.test.ts new file mode 100644 index 0000000000..8e9fefacb0 --- /dev/null +++ b/apps/mobile/src/components/share/share-destinations.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { type StoredSession } from '@/lib/hooks/use-agent-sessions'; + +import { selectShareDestinations, SHARE_DESTINATION_CAP } from './share-destinations'; + +function session(id: string, over: Partial = {}): StoredSession { + return { + session_id: id, + title: id, + cloud_agent_session_id: null, + parent_session_id: null, + organization_id: null, + created_on_platform: 'cloud-agent', + git_url: null, + git_branch: null, + status: null, + status_updated_at: null, + total_cost_microdollars: null, + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + version: 0, + associatedPr: null, + ...over, + }; +} + +describe('selectShareDestinations', () => { + it('hoists live rows to the top while preserving relative order', () => { + const stored = [session('a'), session('b'), session('c'), session('d')]; + const active = new Set(['c', 'a']); + const rows = selectShareDestinations(stored, active); + expect(rows.map(r => r.session_id)).toEqual(['a', 'c', 'b', 'd']); + expect(rows.filter(r => r.live).map(r => r.session_id)).toEqual(['a', 'c']); + }); + + it('marks live by id membership only', () => { + const stored = [session('a'), session('b')]; + const rows = selectShareDestinations(stored, new Set(['b'])); + expect(rows.find(r => r.session_id === 'a')?.live).toBe(false); + expect(rows.find(r => r.session_id === 'b')?.live).toBe(true); + }); + + it('caps at 30 after ordering', () => { + const stored = Array.from({ length: 40 }, (_, i) => session(`s${i}`)); + const active = new Set(['s35', 's36']); + const rows = selectShareDestinations(stored, active); + expect(rows).toHaveLength(SHARE_DESTINATION_CAP); + // Live rows that appear in the stored page are hoisted first. + expect(rows[0]?.session_id).toBe('s35'); + expect(rows[1]?.session_id).toBe('s36'); + expect(rows.every(r => r.session_id.startsWith('s'))).toBe(true); + }); + + it('never invents a row for an active id absent from the stored page', () => { + const stored = [session('a')]; + const rows = selectShareDestinations(stored, new Set(['ghost', 'a'])); + expect(rows.map(r => r.session_id)).toEqual(['a']); + expect(rows).toHaveLength(1); + }); + + it('returns an empty list when the stored page is empty', () => { + expect(selectShareDestinations([], new Set(['x']))).toEqual([]); + }); + + it('preserves organization_id on each row for navigation', () => { + const stored = [session('a', { organization_id: 'org_1' })]; + const rows = selectShareDestinations(stored, new Set()); + expect(rows[0]?.organization_id).toBe('org_1'); + }); +}); diff --git a/apps/mobile/src/components/share/share-destinations.ts b/apps/mobile/src/components/share/share-destinations.ts new file mode 100644 index 0000000000..e7ccb57cfa --- /dev/null +++ b/apps/mobile/src/components/share/share-destinations.ts @@ -0,0 +1,35 @@ +import { type StoredSession } from '@/lib/hooks/use-agent-sessions'; + +/** Hard cap after live hoist — matches the stored page size bound. */ +export const SHARE_DESTINATION_CAP = 30; + +export type ShareDestinationRow = StoredSession & { + live: boolean; +}; + +/** + * Derive the share-gate destination list from the org-scoped stored page. + * `activeSessionIds` is used only to mark and hoist live rows — never as a + * source of rows (activeSessions.list has no organizationId filter). + */ +export function selectShareDestinations( + storedSessions: readonly StoredSession[], + activeSessionIds: ReadonlySet +): ShareDestinationRow[] { + const live: ShareDestinationRow[] = []; + const rest: ShareDestinationRow[] = []; + + for (const session of storedSessions) { + const row: ShareDestinationRow = { + ...session, + live: activeSessionIds.has(session.session_id), + }; + if (row.live) { + live.push(row); + } else { + rest.push(row); + } + } + + return [...live, ...rest].slice(0, SHARE_DESTINATION_CAP); +} diff --git a/apps/mobile/src/components/share/share-gate-sheet.tsx b/apps/mobile/src/components/share/share-gate-sheet.tsx new file mode 100644 index 0000000000..deec953447 --- /dev/null +++ b/apps/mobile/src/components/share/share-gate-sheet.tsx @@ -0,0 +1,253 @@ +import * as Haptics from 'expo-haptics'; +import { useRouter } from 'expo-router'; +import { useShareIntentContext } from 'expo-share-intent'; +import { Plus, X } from 'lucide-react-native'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Pressable, View } from 'react-native'; + +import { getAgentSessionPath } from '@/components/agents/session-detail-routes'; +import { expandPlatformFilter } from '@/components/agents/session-list-helpers'; +import { getNewAgentSessionPath } from '@/components/agents/session-list-routes'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { useAgentSessions } from '@/lib/hooks/use-agent-sessions'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { useOrganization } from '@/lib/organization-context'; +import { setPendingShareNavigation } from '@/lib/share-navigation'; +import { clearSharePayload, peekSharePayload, type ShareId } from '@/lib/share-payload'; + +import { selectShareDestinations, type ShareDestinationRow } from './share-destinations'; +import { ShareDestinationList } from './share-destination-list'; +import { isShareCommitEnabled, selectShareGateState } from './share-gate-state'; +import { SharePayloadPreview } from './share-payload-preview'; +import { type SharePayloadValidation, validateSharePayload } from './share-payload-validation'; + +function appendShareId(base: string, shareId: ShareId): string { + const separator = base.includes('?') ? '&' : '?'; + return `${base}${separator}shareId=${encodeURIComponent(shareId)}`; +} + +type ShareGateSheetProps = { + shareId: string | undefined; +}; + +/** + * Share gate formSheet body. Exactly two direct children of the screen + * content: a collapsable={false} header block and the FlatList. + */ +export function ShareGateSheet({ shareId }: Readonly) { + const router = useRouter(); + const colors = useThemeColors(); + const { resetShareIntent } = useShareIntentContext(); + const { organizationId, isLoaded: orgLoaded } = useOrganization(); + const sessions = useAgentSessions({ + createdOnPlatform: expandPlatformFilter(['cloud-agent']), + organizationId, + enabled: orgLoaded, + }); + + // Committed id survives param replace + dismiss animation; only that id + // is exempt from clear on param change / unmount. + const committedShareIdRef = useRef(null); + // Track the shareId this instance owns so unmount clears only that id. + const ownedShareIdRef = useRef(shareId); + const previousShareIdRef = useRef(shareId); + ownedShareIdRef.current = shareId; + + // When S1 replaces an open gate with a newer shareId, clear the older id + // only if it was not committed. A committed previous id must survive the + // dismiss animation while a newer shareId is focused. + useEffect(() => { + const previous = previousShareIdRef.current; + if (previous && previous !== shareId && previous !== committedShareIdRef.current) { + clearSharePayload(previous); + } + previousShareIdRef.current = shareId; + }, [shareId]); + + const payload = useMemo(() => (shareId ? peekSharePayload(shareId) : null), [shareId]); + + const [validation, setValidation] = useState(null); + + useEffect(() => { + let cancelled = false; + setValidation(null); + + async function run(): Promise { + if (!payload) { + return; + } + const result = await validateSharePayload(payload); + if (!cancelled) { + setValidation(result); + } + } + + void run(); + + return () => { + cancelled = true; + }; + }, [payload, shareId]); + + const destinations = useMemo( + () => selectShareDestinations(sessions.storedSessions, sessions.activeSessionIds), + [sessions.storedSessions, sessions.activeSessionIds] + ); + + const state = useMemo( + () => + selectShareGateState({ + shareId, + payload, + validation, + storedIsError: sessions.storedIsError, + storedIsSuccess: sessions.storedIsSuccess, + activeIsError: sessions.activeIsError, + storedRowCount: destinations.length, + isLoading: sessions.isLoading || !orgLoaded, + }), + [ + shareId, + payload, + validation, + sessions.storedIsError, + sessions.storedIsSuccess, + sessions.activeIsError, + sessions.isLoading, + destinations.length, + orgLoaded, + ] + ); + + const abandon = useCallback(() => { + const id = ownedShareIdRef.current; + if (id) { + clearSharePayload(id); + } + resetShareIntent(); + }, [resetShareIntent]); + + const dismiss = useCallback(() => { + abandon(); + router.back(); + }, [abandon, router]); + + useEffect( + () => () => { + const id = ownedShareIdRef.current; + if (id !== committedShareIdRef.current) { + if (id) { + clearSharePayload(id); + } + resetShareIntent(); + } + }, + [resetShareIntent] + ); + + const commit = useCallback( + (href: string) => { + if (!shareId) { + return; + } + void Haptics.selectionAsync(); + committedShareIdRef.current = shareId; + setPendingShareNavigation({ href, shareId }); + router.back(); + }, + [router, shareId] + ); + + const handleNewSession = useCallback(() => { + if (!shareId) { + return; + } + const base = getNewAgentSessionPath(organizationId); + commit(appendShareId(base, shareId)); + }, [commit, organizationId, shareId]); + + const handleSelectDestination = useCallback( + (row: ShareDestinationRow) => { + if (!shareId) { + return; + } + const org = row.organization_id ?? undefined; + const base = getAgentSessionPath(row.session_id, org) as string; + commit(appendShareId(base, shareId)); + }, + [commit, shareId] + ); + + const handleRetry = useCallback(() => { + void sessions.refetch(); + }, [sessions]); + + const showNewSession = state.showNewSession; + const showTerminalMessage = + state.kind === 'stale-share' || state.kind === 'non-retryable-classification'; + const previewPayload = payload !== null && state.kind !== 'stale-share' ? payload : null; + const commitEnabled = isShareCommitEnabled({ orgLoaded, validation }); + + // Header block: title+close, preview, New session. collapsable={false} is + // required so react-native-screens finds it as the formSheet header. + const header = ( + + + + Share to Kilo + + + + + {previewPayload ? ( + + ) : null} + + {showTerminalMessage ? ( + + {state.message} + + ) : null} + + {showNewSession ? ( + + + + + New session + + ) : null} + + ); + + // Always pair the collapsable header with a FlatList (formSheet constraint). + return ( + <> + {header} + + + ); +} diff --git a/apps/mobile/src/components/share/share-gate-state.test.ts b/apps/mobile/src/components/share/share-gate-state.test.ts new file mode 100644 index 0000000000..9ab704df66 --- /dev/null +++ b/apps/mobile/src/components/share/share-gate-state.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest'; + +import { type SharePayload } from '@/lib/share-payload'; + +import { + isShareCommitEnabled, + selectShareGateState, + type ShareGateStateInput, +} from './share-gate-state'; +import { type SharePayloadValidation } from './share-payload-validation'; + +const payload: SharePayload = { text: 'hello', files: [] }; + +const okValidation: SharePayloadValidation = { + kind: 'ok', + accepted: [], + rejectedNotes: [], + truncated: false, + usable: true, +}; + +const allRejected: SharePayloadValidation = { + kind: 'all-rejected', + reason: 'denied', + message: "Executable files can't be attached", +}; + +function base(overrides: Partial = {}): ShareGateStateInput { + return { + shareId: 'share-1', + payload, + validation: okValidation, + storedIsError: false, + storedIsSuccess: true, + activeIsError: false, + storedRowCount: 3, + isLoading: false, + ...overrides, + }; +} + +describe('isShareCommitEnabled', () => { + it('false while validation is pending', () => { + expect(isShareCommitEnabled({ orgLoaded: true, validation: null })).toBe(false); + }); + + it('false while org is not loaded', () => { + expect(isShareCommitEnabled({ orgLoaded: false, validation: okValidation })).toBe(false); + }); + + it('false when settled all-rejected', () => { + expect(isShareCommitEnabled({ orgLoaded: true, validation: allRejected })).toBe(false); + }); + + it('true when ok and org loaded', () => { + expect(isShareCommitEnabled({ orgLoaded: true, validation: okValidation })).toBe(true); + }); +}); + +describe('selectShareGateState', () => { + it('stale-share when shareId is missing', () => { + const state = selectShareGateState(base({ shareId: undefined })); + expect(state.kind).toBe('stale-share'); + if (state.kind === 'stale-share') { + expect(state.message).toBe('This share is no longer available.'); + expect(state.showNewSession).toBe(false); + expect(state.showRetry).toBe(false); + expect(state.showList).toBe(false); + } + }); + + it('stale-share when shareId is empty', () => { + expect(selectShareGateState(base({ shareId: ' ' })).kind).toBe('stale-share'); + }); + + it('stale-share when payload is null (consumed/unknown)', () => { + const state = selectShareGateState(base({ payload: null })); + expect(state.kind).toBe('stale-share'); + expect(state.showNewSession).toBe(false); + expect(state.showRetry).toBe(false); + }); + + it('stale-share is detected before validation runs', () => { + const state = selectShareGateState(base({ payload: null, validation: null, isLoading: true })); + expect(state.kind).toBe('stale-share'); + }); + + it('non-retryable-classification for all-rejected with no CTA', () => { + const state = selectShareGateState(base({ validation: allRejected })); + expect(state.kind).toBe('non-retryable-classification'); + if (state.kind === 'non-retryable-classification') { + expect(state.message).toBe("Executable files can't be attached"); + expect(state.showNewSession).toBe(false); + expect(state.showRetry).toBe(false); + expect(state.showList).toBe(false); + } + }); + + it('does not conflate stale-share and non-retryable-classification', () => { + const stale = selectShareGateState(base({ payload: null })); + const rejected = selectShareGateState(base({ validation: allRejected })); + expect(stale.kind).not.toBe(rejected.kind); + expect(stale.kind).toBe('stale-share'); + expect(rejected.kind).toBe('non-retryable-classification'); + }); + + it('loading while validation is pending', () => { + const state = selectShareGateState(base({ validation: null })); + expect(state.kind).toBe('loading'); + if (state.kind === 'loading') { + expect(state.showNewSession).toBe(true); + expect(state.showRetry).toBe(false); + expect(state.showList).toBe(true); + expect(state.listMode).toBe('skeleton'); + } + }); + + it('loading while destination queries are in flight', () => { + const state = selectShareGateState( + base({ isLoading: true, storedIsSuccess: false, storedRowCount: 0 }) + ); + expect(state.kind).toBe('loading'); + expect(state.showNewSession).toBe(true); + }); + + it('retryable when storedIsError with zero stored rows', () => { + const state = selectShareGateState( + base({ + storedIsError: true, + storedIsSuccess: false, + storedRowCount: 0, + isLoading: false, + }) + ); + expect(state.kind).toBe('retryable'); + if (state.kind === 'retryable') { + expect(state.message).toBe("Couldn't load your sessions."); + expect(state.showRetry).toBe(true); + expect(state.showNewSession).toBe(true); + expect(state.showList).toBe(false); + } + }); + + it('activeIsError alone is NOT retryable', () => { + const state = selectShareGateState( + base({ + activeIsError: true, + storedIsError: false, + storedIsSuccess: true, + storedRowCount: 2, + }) + ); + expect(state.kind).toBe('happy'); + expect(state.showRetry).toBe(false); + }); + + it('activeIsError with empty stored success is empty, not retryable', () => { + const state = selectShareGateState( + base({ + activeIsError: true, + storedIsError: false, + storedIsSuccess: true, + storedRowCount: 0, + }) + ); + expect(state.kind).toBe('empty'); + expect(state.showRetry).toBe(false); + expect(state.showNewSession).toBe(true); + }); + + it('empty when settled with zero destinations', () => { + const state = selectShareGateState( + base({ storedRowCount: 0, storedIsSuccess: true, storedIsError: false }) + ); + expect(state.kind).toBe('empty'); + if (state.kind === 'empty') { + expect(state.message).toBe('No sessions yet — start a new one to send this.'); + expect(state.showNewSession).toBe(true); + expect(state.showRetry).toBe(false); + expect(state.showList).toBe(false); + } + }); + + it('happy when payload valid and queries settled with rows', () => { + const state = selectShareGateState(base({ storedRowCount: 5 })); + expect(state.kind).toBe('happy'); + if (state.kind === 'happy') { + expect(state.showNewSession).toBe(true); + expect(state.showRetry).toBe(false); + expect(state.showList).toBe(true); + expect(state.listMode).toBe('rows'); + } + }); + + it('classification beats loading (validation settled to all-rejected)', () => { + const state = selectShareGateState( + base({ validation: allRejected, isLoading: true, storedIsSuccess: false }) + ); + expect(state.kind).toBe('non-retryable-classification'); + }); +}); diff --git a/apps/mobile/src/components/share/share-gate-state.ts b/apps/mobile/src/components/share/share-gate-state.ts new file mode 100644 index 0000000000..59d0bf9d0b --- /dev/null +++ b/apps/mobile/src/components/share/share-gate-state.ts @@ -0,0 +1,152 @@ +import { type SharePayload } from '@/lib/share-payload'; + +import { type SharePayloadValidation } from './share-payload-validation'; + +export type ShareGateState = + | { + kind: 'stale-share'; + message: string; + showNewSession: false; + showRetry: false; + showList: false; + } + | { + kind: 'non-retryable-classification'; + message: string; + showNewSession: false; + showRetry: false; + showList: false; + } + | { + kind: 'loading'; + showNewSession: true; + showRetry: false; + showList: true; + listMode: 'skeleton'; + } + | { + kind: 'retryable'; + message: string; + showNewSession: true; + showRetry: true; + showList: false; + } + | { + kind: 'empty'; + message: string; + showNewSession: true; + showRetry: false; + showList: false; + } + | { + kind: 'happy'; + showNewSession: true; + showRetry: false; + showList: true; + listMode: 'rows'; + }; + +export type ShareGateStateInput = { + shareId: string | undefined; + payload: SharePayload | null; + /** null while Task-3 async validation has not settled. */ + validation: SharePayloadValidation | null; + storedIsError: boolean; + storedIsSuccess: boolean; + activeIsError: boolean; + storedRowCount: number; + isLoading: boolean; +}; + +const STALE_MESSAGE = 'This share is no longer available.'; +const RETRYABLE_MESSAGE = "Couldn't load your sessions."; +const EMPTY_MESSAGE = 'No sessions yet — start a new one to send this.'; + +/** + * New session is only committable once org is loaded and validation settled + * to `ok`. Pending validation (`null`) and `all-rejected` both disable commit + * so the user cannot navigate into a dead end. + */ +export function isShareCommitEnabled(input: { + orgLoaded: boolean; + validation: SharePayloadValidation | null; +}): boolean { + return input.orgLoaded && input.validation?.kind === 'ok'; +} + +/** + * Pure selector for the share gate's terminal/loading states. + * + * Priority: + * 1. stale-share (missing/unknown/consumed shareId) — before any validation + * 2. non-retryable-classification (all files rejected, no usable text) + * 3. loading (validation or destination queries in flight) + * 4. retryable (storedIsError with zero stored rows — never activeIsError alone) + * 5. empty (settled, not errored, zero destinations) + * 6. happy + */ +export function selectShareGateState(input: ShareGateStateInput): ShareGateState { + const shareId = input.shareId?.trim() ?? ''; + if (shareId === '' || input.payload === null) { + return { + kind: 'stale-share', + message: STALE_MESSAGE, + showNewSession: false, + showRetry: false, + showList: false, + }; + } + + if (input.validation?.kind === 'all-rejected') { + return { + kind: 'non-retryable-classification', + message: input.validation.message, + showNewSession: false, + showRetry: false, + showList: false, + }; + } + + const validationPending = input.validation === null; + const destinationsPending = input.isLoading || (!input.storedIsSuccess && !input.storedIsError); + + if (validationPending || destinationsPending) { + return { + kind: 'loading', + showNewSession: true, + showRetry: false, + showList: true, + listMode: 'skeleton', + }; + } + + // Retryable only when the stored list failed with no rows. activeIsError + // alone is indistinguishable from "nothing live" (list swallows failures). + if (input.storedIsError && input.storedRowCount === 0) { + return { + kind: 'retryable', + message: RETRYABLE_MESSAGE, + showNewSession: true, + showRetry: true, + showList: false, + }; + } + + if (input.storedRowCount === 0) { + return { + kind: 'empty', + message: EMPTY_MESSAGE, + showNewSession: true, + showRetry: false, + showList: false, + }; + } + + return { + kind: 'happy', + showNewSession: true, + showRetry: false, + showList: true, + listMode: 'rows', + }; +} diff --git a/apps/mobile/src/components/share/share-payload-navigator.tsx b/apps/mobile/src/components/share/share-payload-navigator.tsx new file mode 100644 index 0000000000..2c44474107 --- /dev/null +++ b/apps/mobile/src/components/share/share-payload-navigator.tsx @@ -0,0 +1,52 @@ +import { type Href, usePathname, useRootNavigationState, useRouter } from 'expo-router'; +import { useEffect, useRef } from 'react'; + +import { + isShareNavigationTargetFocused, + navigationContainsShareGate, + type PendingShareNavigation, + takePendingShareNavigation, +} from '@/lib/share-navigation'; + +/** + * Invisible mount: when a pending share navigation exists and the gate route + * is absent from the navigation state, take it and route to the destination. + * - Target not focused → router.push(href) + * - Target already focused → router.setParams({ shareId }) only + * Never cross-presentation replace; back stack stays intact. + */ +export function SharePayloadNavigator(): null { + const router = useRouter(); + const pathname = usePathname(); + const rootState = useRootNavigationState(); + const pathnameRef = useRef(pathname); + pathnameRef.current = pathname; + + useEffect(() => { + if (navigationContainsShareGate(rootState)) { + return; + } + + const pending = takePendingShareNavigation(); + if (!pending) { + return; + } + + deliver(pending, router, pathnameRef.current); + }, [rootState, router]); + + return null; +} + +function deliver( + pending: PendingShareNavigation, + router: ReturnType, + pathname: string +): void { + const focused = isShareNavigationTargetFocused(pending.href, pathname); + if (focused) { + router.setParams({ shareId: pending.shareId }); + return; + } + router.push(pending.href as Href); +} diff --git a/apps/mobile/src/components/share/share-payload-preview.tsx b/apps/mobile/src/components/share/share-payload-preview.tsx new file mode 100644 index 0000000000..07469f1e80 --- /dev/null +++ b/apps/mobile/src/components/share/share-payload-preview.tsx @@ -0,0 +1,128 @@ +import { formatFileSize } from '@kilocode/kilo-chat'; +import { File as FileIcon } from 'lucide-react-native'; +import { ScrollView, View } from 'react-native'; + +import { Image } from '@/components/ui/image'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Text } from '@/components/ui/text'; +import { describeClassificationFailure } from '@/lib/agent-attachments/validate'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { type SharePayload } from '@/lib/share-payload'; + +import { + type AcceptedShareFile, + type RejectedNote, + type SharePayloadValidation, +} from './share-payload-validation'; + +type SharePayloadPreviewProps = { + payload: SharePayload; + validation: SharePayloadValidation | null; +}; + +function PreviewImages({ files }: { files: readonly AcceptedShareFile[] }) { + const images = files.filter(f => f.kind === 'image').slice(0, 5); + if (images.length === 0) { + return null; + } + return ( + + {images.map(file => ( + + ))} + + ); +} + +function PreviewDocuments({ files }: { files: readonly AcceptedShareFile[] }) { + const colors = useThemeColors(); + const documents = files.filter(f => f.kind === 'document'); + if (documents.length === 0) { + return null; + } + return ( + + {documents.map(file => ( + + + + {file.name} + + {formatFileSize(file.measuredSize)} + + ))} + + ); +} + +function RejectionNotes({ notes }: { notes: readonly RejectedNote[] }) { + if (notes.length === 0) { + return null; + } + return ( + + {notes.map(note => ( + + {note.name}: {describeClassificationFailure(note.reason)} + + ))} + + ); +} + +/** + * Height-bounded payload preview for the share gate header. + * Images → horizontal thumbnail row (max 5, ~64pt). Documents → name + size. + * Text → clamped to 3 lines. Skeleton while async measurement runs. + */ +export function SharePayloadPreview({ payload, validation }: Readonly) { + if (validation === null) { + return ( + + + + ); + } + + if (validation.kind === 'all-rejected') { + return null; + } + + const text = payload.text.trim(); + const hasText = text !== ''; + const hasFiles = validation.accepted.length > 0; + + return ( + + {hasText ? ( + + {text} + + ) : null} + {hasFiles ? ( + <> + + + + ) : null} + {validation.truncated ? ( + + Only the first 5 files will be attached. + + ) : null} + + + ); +} diff --git a/apps/mobile/src/components/share/share-payload-validation.test.ts b/apps/mobile/src/components/share/share-payload-validation.test.ts new file mode 100644 index 0000000000..356e32b30f --- /dev/null +++ b/apps/mobile/src/components/share/share-payload-validation.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest'; + +import { describeClassificationFailure } from '@/lib/agent-attachments/validate'; +import { AGENT_ATTACHMENT_MAX_BYTES } from '@/lib/agent-attachments/constants'; + +import { validateMeasuredShareFiles } from './share-payload-validation'; + +function file(name: string, measuredSize: number) { + return { + name, + measuredSize, + uri: `file:///${name}`, + }; +} + +describe('validateMeasuredShareFiles', () => { + it('maps each classification reason to describeClassificationFailure copy', () => { + const cases: { reason: 'denied' | 'empty' | 'too-large'; name: string; size: number }[] = [ + { reason: 'denied', name: 'evil.exe', size: 10 }, + { reason: 'empty', name: 'notes.pdf', size: 0 }, + { reason: 'too-large', name: 'notes.pdf', size: AGENT_ATTACHMENT_MAX_BYTES + 1 }, + ]; + for (const { reason, name, size } of cases) { + const result = validateMeasuredShareFiles({ + text: '', + files: [file(name, size)], + }); + expect(result.kind).toBe('all-rejected'); + if (result.kind === 'all-rejected') { + expect(result.reason).toBe(reason); + expect(result.message).toBe(describeClassificationFailure(reason)); + } + } + }); + + it('all-rejected requires both all files rejected AND no usable text', () => { + const deniedOnly = validateMeasuredShareFiles({ + text: '', + files: [file('evil.exe', 10)], + }); + expect(deniedOnly.kind).toBe('all-rejected'); + + const deniedWithText = validateMeasuredShareFiles({ + text: 'hello', + files: [file('evil.exe', 10)], + }); + expect(deniedWithText.kind).toBe('ok'); + if (deniedWithText.kind === 'ok') { + expect(deniedWithText.accepted).toHaveLength(0); + expect(deniedWithText.rejectedNotes).toEqual([{ name: 'evil.exe', reason: 'denied' }]); + expect(deniedWithText.usable).toBe(true); + } + }); + + it('partial rejection keeps accepted files and rejected notes', () => { + const result = validateMeasuredShareFiles({ + text: '', + files: [file('good.png', 100), file('bad.exe', 10), file('empty.pdf', 0)], + }); + expect(result.kind).toBe('ok'); + if (result.kind === 'ok') { + expect(result.accepted.map(f => f.name)).toEqual(['good.png']); + expect(result.rejectedNotes).toEqual([ + { name: 'bad.exe', reason: 'denied' }, + { name: 'empty.pdf', reason: 'empty' }, + ]); + expect(result.truncated).toBe(false); + expect(result.usable).toBe(true); + } + }); + + it('truncates at 5 acceptable files', () => { + const files = Array.from({ length: 7 }, (_, i) => file(`f${i}.png`, 10)); + const result = validateMeasuredShareFiles({ text: '', files }); + expect(result.kind).toBe('ok'); + if (result.kind === 'ok') { + expect(result.accepted).toHaveLength(5); + expect(result.truncated).toBe(true); + expect(result.accepted.map(f => f.name)).toEqual([ + 'f0.png', + 'f1.png', + 'f2.png', + 'f3.png', + 'f4.png', + ]); + } + }); + + it('treats unknown-but-not-denied extensions as documents', () => { + const result = validateMeasuredShareFiles({ + text: '', + files: [file('blob.bin', 10)], + }); + expect(result.kind).toBe('ok'); + if (result.kind === 'ok') { + expect(result.accepted[0]?.kind).toBe('document'); + } + }); + + it('whitespace-only text is not usable', () => { + const result = validateMeasuredShareFiles({ + text: ' \n\t ', + files: [file('evil.exe', 10)], + }); + expect(result.kind).toBe('all-rejected'); + }); + + it('text-only payload with no files is ok and usable', () => { + const result = validateMeasuredShareFiles({ text: 'shared link', files: [] }); + expect(result.kind).toBe('ok'); + if (result.kind === 'ok') { + expect(result.usable).toBe(true); + expect(result.accepted).toHaveLength(0); + } + }); + + it('contentless payload uses dedicated message and null reason', () => { + const result = validateMeasuredShareFiles({ text: '', files: [] }); + expect(result.kind).toBe('all-rejected'); + if (result.kind === 'all-rejected') { + expect(result.reason).toBeNull(); + expect(result.message).toBe('Nothing to share — no text or files were included.'); + } + }); + + it('contentless whitespace-only text with no files uses dedicated message', () => { + const result = validateMeasuredShareFiles({ text: ' \n\t ', files: [] }); + expect(result.kind).toBe('all-rejected'); + if (result.kind === 'all-rejected') { + expect(result.reason).toBeNull(); + expect(result.message).toBe('Nothing to share — no text or files were included.'); + } + }); + + it('single rejection keeps describeClassificationFailure copy', () => { + const result = validateMeasuredShareFiles({ + text: '', + files: [file('evil.exe', 10)], + }); + expect(result.kind).toBe('all-rejected'); + if (result.kind === 'all-rejected') { + expect(result.reason).toBe('denied'); + expect(result.message).toBe(describeClassificationFailure('denied')); + } + }); + + it('mixed rejections with no accepted use first rejection copy', () => { + const result = validateMeasuredShareFiles({ + text: '', + files: [file('bad.exe', 10), file('empty.pdf', 0)], + }); + expect(result.kind).toBe('all-rejected'); + if (result.kind === 'all-rejected') { + expect(result.reason).toBe('denied'); + expect(result.message).toBe(describeClassificationFailure('denied')); + } + }); +}); diff --git a/apps/mobile/src/components/share/share-payload-validation.ts b/apps/mobile/src/components/share/share-payload-validation.ts new file mode 100644 index 0000000000..37141a6dbf --- /dev/null +++ b/apps/mobile/src/components/share/share-payload-validation.ts @@ -0,0 +1,145 @@ +import { + canAddAttachments, + classifyAttachment, + describeClassificationFailure, +} from '@/lib/agent-attachments/validate'; +import { type SharePayload } from '@/lib/share-payload'; + +export type ClassificationReason = 'empty' | 'denied' | 'too-large'; + +export type RejectedNote = { + name: string; + reason: ClassificationReason; +}; + +/** Minimal file shape accepted into the share payload after validation. */ +export type AcceptedShareFile = { + name: string; + uri: string; + mimeType?: string; + size?: number; + measuredSize: number; + kind: 'image' | 'document'; +}; + +export type SharePayloadValidation = + | { + kind: 'all-rejected'; + /** null when the share had no files and no usable text (contentless). */ + reason: ClassificationReason | null; + message: string; + } + | { + kind: 'ok'; + accepted: AcceptedShareFile[]; + rejectedNotes: RejectedNote[]; + truncated: boolean; + /** True when the payload has usable text and/or at least one accepted file. */ + usable: boolean; + }; + +const CONTENTLESS_MESSAGE = 'Nothing to share — no text or files were included.'; + +type MeasuredFileInput = { + name: string; + measuredSize: number; + uri: string; + mimeType?: string; +}; + +/** + * Pure pre-flight over already-measured file sizes. Injectable so unit tests + * need no filesystem. Reuses classifyAttachment / canAddAttachments / + * describeClassificationFailure — no second limit or allow-list. + */ +export function validateMeasuredShareFiles(input: { + text: string; + files: readonly MeasuredFileInput[]; +}): SharePayloadValidation { + const rejectedNotes: RejectedNote[] = []; + const classifiedAccepted: AcceptedShareFile[] = []; + + for (const file of input.files) { + const classified = classifyAttachment({ name: file.name, size: file.measuredSize }); + if (!classified.ok) { + rejectedNotes.push({ name: file.name, reason: classified.reason }); + } else { + const accepted: AcceptedShareFile = { + name: file.name, + uri: file.uri, + measuredSize: classified.size, + size: classified.size, + kind: classified.kind, + }; + if (file.mimeType !== undefined) { + accepted.mimeType = file.mimeType; + } + classifiedAccepted.push(accepted); + } + } + + const hasUsableText = input.text.trim() !== ''; + + if (classifiedAccepted.length === 0 && !hasUsableText) { + const firstRejection = rejectedNotes[0]; + if (firstRejection) { + return { + kind: 'all-rejected', + reason: firstRejection.reason, + message: describeClassificationFailure(firstRejection.reason), + }; + } + return { + kind: 'all-rejected', + reason: null, + message: CONTENTLESS_MESSAGE, + }; + } + + const limit = canAddAttachments(0, classifiedAccepted.length); + const accepted = classifiedAccepted.slice(0, limit.acceptedCount); + const truncated = Boolean(limit.truncated); + + return { + kind: 'ok', + accepted, + rejectedNotes, + truncated, + usable: hasUsableText || accepted.length > 0, + }; +} + +type MeasureFn = (uri: string) => Promise; + +/** + * Measure every file's real bytes, then run the pure classifier. Extension- + * reported sizes are unreliable on iOS. `measure` defaults to the real + * filesystem helper; inject in tests. + */ +export async function validateSharePayload( + payload: SharePayload, + measure?: MeasureFn +): Promise { + const measureSize = measure ?? (await loadDefaultMeasure()); + const measured = await Promise.all( + payload.files.map(async candidate => { + const measuredSize = (await measureSize(candidate.uri)) ?? candidate.size ?? 0; + const input: MeasuredFileInput = { + name: candidate.name, + measuredSize, + uri: candidate.uri, + }; + if (candidate.mimeType !== undefined) { + input.mimeType = candidate.mimeType; + } + return input; + }) + ); + + return validateMeasuredShareFiles({ text: payload.text, files: measured }); +} + +async function loadDefaultMeasure(): Promise { + const { measureLocalSize } = await import('@/lib/agent-attachments/upload-task'); + return measureLocalSize; +} diff --git a/apps/mobile/src/lib/share-navigation.test.ts b/apps/mobile/src/lib/share-navigation.test.ts new file mode 100644 index 0000000000..f90b9c4792 --- /dev/null +++ b/apps/mobile/src/lib/share-navigation.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + __resetPendingShareNavigationForTests, + isShareNavigationTargetFocused, + navigationContainsShareGate, + setPendingShareNavigation, + takePendingShareNavigation, +} from './share-navigation'; + +afterEach(() => { + __resetPendingShareNavigationForTests(); +}); + +describe('share-navigation', () => { + it('set then take returns the entry', () => { + setPendingShareNavigation({ href: '/(app)/agent-chat/new?shareId=a', shareId: 'a' }); + expect(takePendingShareNavigation()).toEqual({ + href: '/(app)/agent-chat/new?shareId=a', + shareId: 'a', + }); + }); + + it('second take returns null', () => { + setPendingShareNavigation({ href: '/(app)/agent-chat/new?shareId=a', shareId: 'a' }); + takePendingShareNavigation(); + expect(takePendingShareNavigation()).toBeNull(); + }); + + it('overwrite before take is last-write-wins', () => { + setPendingShareNavigation({ href: '/first', shareId: 'first' }); + setPendingShareNavigation({ href: '/second', shareId: 'second' }); + expect(takePendingShareNavigation()).toEqual({ href: '/second', shareId: 'second' }); + expect(takePendingShareNavigation()).toBeNull(); + }); + + it('take with nothing pending returns null', () => { + expect(takePendingShareNavigation()).toBeNull(); + }); +}); + +describe('isShareNavigationTargetFocused', () => { + it('matches same-session focused via concrete pathname', () => { + expect( + isShareNavigationTargetFocused('/(app)/agent-chat/ses_1?shareId=x', '/agent-chat/ses_1') + ).toBe(true); + }); + + it('is false when the session id differs', () => { + expect( + isShareNavigationTargetFocused('/(app)/agent-chat/ses_1?shareId=x', '/agent-chat/ses_2') + ).toBe(false); + }); + + it('matches focused agent-chat/new', () => { + expect( + isShareNavigationTargetFocused('/(app)/agent-chat/new?shareId=x', '/agent-chat/new') + ).toBe(true); + }); + + it('is false when on a different screen', () => { + expect(isShareNavigationTargetFocused('/(app)/agent-chat/new?shareId=x', '/')).toBe(false); + }); + + it('ignores query string on the href', () => { + expect( + isShareNavigationTargetFocused( + '/(app)/agent-chat/ses_1?shareId=stale&organizationId=o', + '/agent-chat/ses_1' + ) + ).toBe(true); + }); + + it('ignores group segments in the href', () => { + expect(isShareNavigationTargetFocused('/(app)/agent-chat/ses_1', '/agent-chat/ses_1')).toBe( + true + ); + }); + + it('is unaffected by a stale shareId already on the current URL path comparison', () => { + // Pathname from usePathname has no query; predicate must not consult params. + expect( + isShareNavigationTargetFocused('/(app)/agent-chat/ses_1?shareId=new', '/agent-chat/ses_1') + ).toBe(true); + }); +}); + +describe('navigationContainsShareGate', () => { + it('finds share-gate nested in routes', () => { + expect( + navigationContainsShareGate({ + routes: [{ name: '(app)', state: { routes: [{ name: 'share-gate' }] } }], + }) + ).toBe(true); + }); + + it('is false when the gate is absent', () => { + expect( + navigationContainsShareGate({ + routes: [{ name: '(app)', state: { routes: [{ name: '(tabs)' }] } }], + }) + ).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/share-navigation.ts b/apps/mobile/src/lib/share-navigation.ts new file mode 100644 index 0000000000..2ff63d8b79 --- /dev/null +++ b/apps/mobile/src/lib/share-navigation.ts @@ -0,0 +1,72 @@ +import { type ShareId } from '@/lib/share-payload'; + +export type PendingShareNavigation = { href: string; shareId: ShareId }; + +let pending: PendingShareNavigation | null = null; + +export function setPendingShareNavigation(next: PendingShareNavigation): void { + pending = next; +} + +/** Read-and-clear. */ +export function takePendingShareNavigation(): PendingShareNavigation | null { + const current = pending; + pending = null; + return current; +} + +/** + * True when a pending href's path (ignoring query params) matches the current + * pathname. Both sides are normalized by stripping group segments like + * `(app)`. A stale shareId on the URL must not make a focused destination look + * unfocused — only the path is compared. + * + * `pathname` is the concrete Expo Router path from `usePathname()` (e.g. + * `/agent-chat/ses_1`), not bracket-pattern segments from `useSegments()`. + */ +export function isShareNavigationTargetFocused(href: string, pathname: string): boolean { + const normalizedHref = normalizePath(href.split('?')[0] ?? href); + const normalizedPath = normalizePath(pathname.split('?')[0] ?? pathname); + + if (normalizedHref.length !== normalizedPath.length) { + return false; + } + return normalizedHref.every((part, i) => part === normalizedPath[i]); +} + +function normalizePath(path: string): string[] { + return path + .split('/') + .filter(Boolean) + .filter(p => !(p.startsWith('(') && p.endsWith(')'))); +} + +/** + * True when the navigation state still contains the share-gate route. + * Delivery waits until the formSheet is fully gone — never a fixed timer. + */ +export function navigationContainsShareGate(state: unknown): boolean { + if (!state || typeof state !== 'object') { + return false; + } + const record = state as { name?: unknown; routes?: unknown; state?: unknown }; + if (record.name === 'share-gate') { + return true; + } + if (Array.isArray(record.routes)) { + for (const route of record.routes) { + if (navigationContainsShareGate(route)) { + return true; + } + } + } + if (record.state) { + return navigationContainsShareGate(record.state); + } + return false; +} + +/** Test-only: wipe the module slot between cases. */ +export function __resetPendingShareNavigationForTests(): void { + pending = null; +} From f318f30f1eeb0d1f6db8c1530d1438031e18e78b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 26 Jul 2026 10:05:57 +0200 Subject: [PATCH 03/11] feat(mobile): let the session composers start from a prefilled draft --- .../src/app/(app)/agent-chat/[session-id].tsx | 5 + apps/mobile/src/app/(app)/agent-chat/new.tsx | 9 +- .../src/components/agents/chat-composer.tsx | 33 +- .../components/agents/new-session-prompt.tsx | 18 +- .../agents/session-detail-content.tsx | 4 + apps/mobile/src/lib/share-prefill.test.ts | 303 ++++++++++++++++++ apps/mobile/src/lib/share-prefill.ts | 108 +++++++ 7 files changed, 469 insertions(+), 11 deletions(-) create mode 100644 apps/mobile/src/lib/share-prefill.test.ts create mode 100644 apps/mobile/src/lib/share-prefill.ts diff --git a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx index ea6364d00a..8c41a0b364 100644 --- a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx +++ b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx @@ -21,6 +21,7 @@ export default function SessionDetailScreen() { organizationId: routeOrganizationId, via, spawned, + shareId: shareIdParam, } = useLocalSearchParams<{ 'session-id': string; organizationId?: string; @@ -36,7 +37,10 @@ export default function SessionDetailScreen() { * shows the same permanent state it always did. */ spawned?: string; + shareId?: string; }>(); + // Param can be string | string[] depending on how the route was opened. + const shareId = Array.isArray(shareIdParam) ? shareIdParam[0] : shareIdParam; const trpc = useTRPC(); const router = useRouter(); const sessionQuery = useQuery({ @@ -123,6 +127,7 @@ export default function SessionDetailScreen() { ); diff --git a/apps/mobile/src/app/(app)/agent-chat/new.tsx b/apps/mobile/src/app/(app)/agent-chat/new.tsx index dc509ecf6d..65f9a2d843 100644 --- a/apps/mobile/src/app/(app)/agent-chat/new.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/new.tsx @@ -40,7 +40,12 @@ import { settleVoiceInputBeforeSubmit } from '@/lib/voice-input/voice-input-subm export default function NewSessionScreen() { const colors = useThemeColors(); const { showActionSheetWithOptions } = useActionSheet(); - const { organizationId } = useLocalSearchParams<{ organizationId?: string }>(); + const { organizationId, shareId: shareIdParam } = useLocalSearchParams<{ + organizationId?: string; + shareId?: string; + }>(); + // Param can be string | string[] depending on how the route was opened. + const shareId = Array.isArray(shareIdParam) ? shareIdParam[0] : shareIdParam; // ── Selectors state ────────────────────────────────────────────── const [mode, setMode] = useState('code'); @@ -297,6 +302,8 @@ export default function NewSessionScreen() { onRefetchModels={() => { void refetchModels(); }} + onPrefillAttachments={addCandidates} + shareId={shareId} voiceInputSettlerRef={voiceInputSettlerRef} /> diff --git a/apps/mobile/src/components/agents/chat-composer.tsx b/apps/mobile/src/components/agents/chat-composer.tsx index c8e0a0af90..9f94afbc56 100644 --- a/apps/mobile/src/components/agents/chat-composer.tsx +++ b/apps/mobile/src/components/agents/chat-composer.tsx @@ -45,6 +45,7 @@ import { import { type ModelOption } from '@/lib/hooks/use-available-models'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { cn } from '@/lib/utils'; +import { useSharePrefill } from '@/lib/share-prefill'; import { createSubmitLock, type SubmitLock } from '@/lib/submit-lock'; import { useVoiceInput } from '@/lib/voice-input/use-voice-input'; import { applyVoiceDraftToInput } from '@/lib/voice-input/voice-input-draft'; @@ -91,6 +92,8 @@ type ChatComposerProps = { commands?: SlashCommandInfo[]; /** Remote command state — empty for non-remote sessions. */ commandState?: RemoteCommandState | null; + /** Share-gate delivery id; composer takes the payload and clears the route param. */ + shareId?: string; }; export function ChatComposer({ @@ -113,6 +116,7 @@ export function ChatComposer({ activeSessionType = null, commands = [], commandState = null, + shareId, }: Readonly) { const colors = useThemeColors(); const { showActionSheetWithOptions } = useActionSheet(); @@ -165,6 +169,23 @@ export function ChatComposer({ const toolbarDisabled = disabled || isSending; const voiceDisabled = toolbarDisabled; + function handleChangeText(value: string) { + textRef.current = value; + measure.setText(value); + setHasText(value.trim().length > 0); + setSlashCommandInput(getSlashCommandCandidate(value)); + } + + const { addCandidates, removeAttachment, retryAttachment } = upload; + + useSharePrefill({ + shareId, + inputRef, + maxLength: 4000, + onChangeText: handleChangeText, + addCandidates, + }); + const voiceInput = useVoiceInput({ disabled: voiceDisabled, getDraft: () => textRef.current, @@ -195,12 +216,8 @@ export function ChatComposer({ const slashCommandSuggestions = slashCommandInput === null ? [] : getSlashCommandSuggestions(slashCommandInput, commandList); - function handleChangeText(value: string) { - textRef.current = value; - measure.setText(value); - setHasText(value.trim().length > 0); - setSlashCommandInput(getSlashCommandCandidate(value)); - } + // The strip must show share-prefilled files before the session resolves. + const showAttachments = attachmentsEnabled || upload.attachments.length > 0; function clearDraft() { textRef.current = ''; @@ -323,8 +340,6 @@ export function ChatComposer({ setInputWidth(current => (current === nextWidth ? current : nextWidth)); } - const { addCandidates, removeAttachment, retryAttachment } = upload; - const handleAddAttachment = useCallback(async () => { // Fire-and-forget: the upload hook owns its own progress + error toasts, // and the composer's send flow consults `upload.isUploading` / @@ -362,7 +377,7 @@ export function ChatComposer({ ) : null} - {attachmentsEnabled ? ( + {showAttachments ? ( void; onRetryAttachment: (id: string) => void; onRefetchModels: () => void; + onPrefillAttachments: (candidates: AgentAttachmentCandidate[]) => Promise; + shareId?: string; voiceInputSettlerRef: RefObject<(() => Promise) | null>; }; @@ -91,6 +97,8 @@ export function NewSessionPrompt({ onRemoveAttachment, onRetryAttachment, onRefetchModels, + onPrefillAttachments, + shareId, voiceInputSettlerRef, }: Readonly) { const colors = useThemeColors(); @@ -115,6 +123,14 @@ export function NewSessionPrompt({ [onChangeText, promptMeasure] ); + useSharePrefill({ + shareId, + inputRef: promptInputRef, + maxLength: PROMPT_INPUT_MAX_CHARS, + onChangeText: handlePromptChange, + addCandidates: onPrefillAttachments, + }); + const voiceInput = useVoiceInput({ disabled: isCreating, getDraft: () => promptRef.current, diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 40a4b8a01f..6203170c38 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -96,6 +96,8 @@ import { cn } from '@/lib/utils'; type SessionDetailContentProps = { sessionId: KiloSessionId; openedVia?: 'push' | 'app'; + /** Share-gate delivery id; threaded to the composer for one-shot prefill. */ + shareId?: string; }; const COMPOSER_PLACEHOLDERS: Partial> = { @@ -106,6 +108,7 @@ const COMPOSER_PLACEHOLDERS: Partial> = { export function SessionDetailContent({ sessionId, openedVia = 'app', + shareId, }: Readonly) { const manager = useSessionManager(); const router = useRouter(); @@ -786,6 +789,7 @@ export function SessionDetailContent({ activeSessionType={activeSessionType} commands={availableCommands} commandState={remoteCommandState} + shareId={shareId} /> diff --git a/apps/mobile/src/lib/share-prefill.test.ts b/apps/mobile/src/lib/share-prefill.test.ts new file mode 100644 index 0000000000..1af26f7062 --- /dev/null +++ b/apps/mobile/src/lib/share-prefill.test.ts @@ -0,0 +1,303 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + __resetSharePayloadStoreForTests, + putSharePayload, + takeSharePayload, +} from '@/lib/share-payload'; +import { type AgentAttachmentCandidate } from '@/lib/agent-attachments/use-agent-attachment-upload'; + +import { applySharePrefill } from './share-prefill'; + +vi.mock('expo-crypto', () => { + let n = 0; + return { + randomUUID: () => { + n += 1; + return `share-id-${n}`; + }, + }; +}); + +vi.mock('expo-file-system/legacy', () => ({ + cacheDirectory: 'file:///cache/', + copyAsync: vi.fn(async () => { + await Promise.resolve(); + }), +})); + +// share-prefill also exports the React hook (expo-router). Keep the pure core +// testable without a renderer by stubbing RN + router so vitest never parses +// react-native's Flow sources. +vi.mock('react-native', () => ({})); +vi.mock('expo-router', () => ({ + useRouter: () => ({ + setParams: vi.fn(), + }), +})); + +function makeInput() { + const calls: { text: string }[] = []; + const input = { + setNativeProps(props: { text: string }): void { + calls.push({ text: props.text }); + }, + }; + return { input, calls }; +} + +function makeChange() { + const calls: string[] = []; + const onChangeText = (draft: string): void => { + calls.push(draft); + }; + return { onChangeText, calls }; +} + +function noopChange(): void { + // no-op change handler for cases that only assert side effects +} + +function noopClear(): void { + // no-op clear for cases that only assert text delivery +} + +async function resolveImmediately(): Promise { + await Promise.resolve(); +} + +async function rejectAfterTick(): Promise { + await Promise.resolve(); + throw new Error('upload failed'); +} + +describe('applySharePrefill', () => { + beforeEach(() => { + __resetSharePayloadStoreForTests(); + }); + + it('no-ops when take returns null (already consumed or unknown id)', async () => { + const { input, calls: nativeCalls } = makeInput(); + const { onChangeText, calls: changeCalls } = makeChange(); + const addCandidatesCalls: AgentAttachmentCandidate[][] = []; + const clearCalls: number[] = []; + + await applySharePrefill({ + shareId: 'missing', + input, + maxLength: 4000, + onChangeText, + addCandidates: async candidates => { + await Promise.resolve(); + addCandidatesCalls.push(candidates); + }, + clearShareIdParam: () => { + clearCalls.push(1); + }, + }); + + expect(nativeCalls).toEqual([]); + expect(changeCalls).toEqual([]); + expect(addCandidatesCalls).toEqual([]); + expect(clearCalls).toEqual([]); + }); + + it('no-ops when shareId is empty or undefined', async () => { + const { onChangeText, calls: changeCalls } = makeChange(); + const addCandidatesCalls: AgentAttachmentCandidate[][] = []; + const clearCalls: number[] = []; + const addCandidates = async (candidates: AgentAttachmentCandidate[]): Promise => { + await Promise.resolve(); + addCandidatesCalls.push(candidates); + }; + const clearShareIdParam = (): void => { + clearCalls.push(1); + }; + + await applySharePrefill({ + shareId: undefined, + input: null, + maxLength: 4000, + onChangeText, + addCandidates, + clearShareIdParam, + }); + await applySharePrefill({ + shareId: '', + input: null, + maxLength: 4000, + onChangeText, + addCandidates, + clearShareIdParam, + }); + + expect(changeCalls).toEqual([]); + expect(addCandidatesCalls).toEqual([]); + expect(clearCalls).toEqual([]); + }); + + it('applies text before files are awaited', async () => { + const id = putSharePayload({ + text: 'shared body', + files: [{ name: 'a.png', uri: 'file:///a.png' }], + }); + const { input, calls: nativeCalls } = makeInput(); + const order: string[] = []; + const onChangeText = (text: string): void => { + order.push(`text:${text}`); + }; + const fileHold = { + release: (): void => { + // replaced when the gate promise is constructed + }, + }; + const filesGate = new Promise(resolve => { + fileHold.release = (): void => { + resolve(undefined); + }; + }); + const addCandidates = async (): Promise => { + order.push('files-start'); + await filesGate; + order.push('files-done'); + }; + const clearShareIdParam = (): void => { + order.push('clear'); + }; + + const run = applySharePrefill({ + shareId: id, + input, + maxLength: 4000, + onChangeText, + addCandidates, + clearShareIdParam, + }); + + // Text is applied synchronously before addCandidates is entered; the + // files promise is still pending so clear has not run yet. + expect(order).toEqual(['text:shared body', 'files-start']); + expect(nativeCalls).toEqual([{ text: 'shared body' }]); + expect(order.indexOf('text:shared body')).toBeLessThan(order.indexOf('files-start')); + expect(order).not.toContain('clear'); + + fileHold.release(); + await run; + + expect(order).toEqual(['text:shared body', 'files-start', 'files-done', 'clear']); + }); + + it('keeps text and does not restore the payload when addCandidates throws', async () => { + const id = putSharePayload({ + text: 'keep me', + files: [{ name: 'bad.png', uri: 'file:///bad.png' }], + }); + const { input, calls: nativeCalls } = makeInput(); + const { onChangeText, calls: changeCalls } = makeChange(); + const clearCalls: number[] = []; + + await applySharePrefill({ + shareId: id, + input, + maxLength: 4000, + onChangeText, + addCandidates: rejectAfterTick, + clearShareIdParam: () => { + clearCalls.push(1); + }, + }); + + expect(changeCalls).toEqual(['keep me']); + expect(nativeCalls).toEqual([{ text: 'keep me' }]); + // Param still cleared after the throw path (hygiene; no retry). + expect(clearCalls).toHaveLength(1); + // Payload was consumed and not restored. + expect(takeSharePayload(id)).toBeNull(); + + // A second apply with the same id is a take-null no-op (no re-apply). + changeCalls.length = 0; + await applySharePrefill({ + shareId: id, + input, + maxLength: 4000, + onChangeText, + addCandidates: rejectAfterTick, + clearShareIdParam: () => { + clearCalls.push(1); + }, + }); + expect(changeCalls).toEqual([]); + }); + + it('clears the route param after a successful prefill', async () => { + const id = putSharePayload({ text: 'ok', files: [] }); + const clearCalls: number[] = []; + + await applySharePrefill({ + shareId: id, + input: null, + maxLength: 4000, + onChangeText: noopChange, + addCandidates: resolveImmediately, + clearShareIdParam: () => { + clearCalls.push(1); + }, + }); + + expect(clearCalls).toHaveLength(1); + expect(takeSharePayload(id)).toBeNull(); + }); + + it('re-applies when a second share arrives with a new id', async () => { + const firstId = putSharePayload({ text: 'first', files: [] }); + const secondId = putSharePayload({ text: 'second', files: [] }); + const { onChangeText, calls: changeCalls } = makeChange(); + + await applySharePrefill({ + shareId: firstId, + input: null, + maxLength: 4000, + onChangeText, + addCandidates: resolveImmediately, + clearShareIdParam: noopClear, + }); + await applySharePrefill({ + shareId: secondId, + input: null, + maxLength: 4000, + onChangeText, + addCandidates: resolveImmediately, + clearShareIdParam: noopClear, + }); + + expect(changeCalls).toEqual(['first', 'second']); + }); + + it('skips the text call for empty text + files-only payloads', async () => { + const files = [{ name: 'only.png', uri: 'file:///only.png' }]; + const id = putSharePayload({ text: '', files }); + const { input, calls: nativeCalls } = makeInput(); + const { onChangeText, calls: changeCalls } = makeChange(); + const addCandidatesCalls: AgentAttachmentCandidate[][] = []; + const clearCalls: number[] = []; + + await applySharePrefill({ + shareId: id, + input, + maxLength: 4000, + onChangeText, + addCandidates: async candidates => { + await Promise.resolve(); + addCandidatesCalls.push(candidates); + }, + clearShareIdParam: () => { + clearCalls.push(1); + }, + }); + + expect(nativeCalls).toEqual([]); + expect(changeCalls).toEqual([]); + expect(addCandidatesCalls).toEqual([files]); + expect(clearCalls).toHaveLength(1); + }); +}); diff --git a/apps/mobile/src/lib/share-prefill.ts b/apps/mobile/src/lib/share-prefill.ts new file mode 100644 index 0000000000..2a2665879e --- /dev/null +++ b/apps/mobile/src/lib/share-prefill.ts @@ -0,0 +1,108 @@ +import { type RefObject, useEffect, useRef } from 'react'; +import { useRouter } from 'expo-router'; + +import { type AgentAttachmentCandidate } from '@/lib/agent-attachments/use-agent-attachment-upload'; +import { takeSharePayload } from '@/lib/share-payload'; +import { applyVoiceDraftToInput } from '@/lib/voice-input/voice-input-draft'; + +type SharePrefillTextInput = { + setNativeProps(props: { text: string }): void; +}; + +type ApplySharePrefillOptions = { + shareId: string | undefined; + input: SharePrefillTextInput | null; + maxLength: number; + onChangeText: (text: string) => void; + addCandidates: (candidates: AgentAttachmentCandidate[]) => Promise; + clearShareIdParam: () => void; +}; + +/** + * Delivers a share payload into a composer once. Ordered: take → text → files → + * clear route param. Text is applied before awaiting files so a file failure + * cannot cost the user their text. On `addCandidates` throw the payload is not + * restored and text is not re-applied. + */ +export async function applySharePrefill(options: ApplySharePrefillOptions): Promise { + const shareId = options.shareId; + if (shareId === undefined || shareId === '') { + return; + } + + const payload = takeSharePayload(shareId); + if (payload === null) { + return; + } + + // Text first and unconditionally (when non-empty) so file failures keep it. + if (payload.text !== '') { + applyVoiceDraftToInput({ + input: options.input, + draft: payload.text, + maxLength: options.maxLength, + onChangeText: options.onChangeText, + }); + } + + if (payload.files.length > 0) { + try { + await options.addCandidates(payload.files); + } catch { + // Keep text; do not restore the payload. Upload hook toasts name failures. + } + } + + // URL hygiene only — nothing depends on clearing the param. + options.clearShareIdParam(); +} + +type UseSharePrefillOptions = { + shareId?: string; + inputRef: RefObject; + maxLength: number; + onChangeText: (text: string) => void; + addCandidates: (candidates: AgentAttachmentCandidate[]) => Promise; +}; + +/** + * Effect keyed on `shareId`. On a new non-empty id, takes the payload and + * prefills the composer. Call sites own the input ref, change handler, and + * attachment upload path. + */ +export function useSharePrefill({ + shareId, + inputRef, + maxLength, + onChangeText, + addCandidates, +}: UseSharePrefillOptions): void { + const router = useRouter(); + const onChangeTextRef = useRef(onChangeText); + onChangeTextRef.current = onChangeText; + const addCandidatesRef = useRef(addCandidates); + addCandidatesRef.current = addCandidates; + const maxLengthRef = useRef(maxLength); + maxLengthRef.current = maxLength; + + useEffect(() => { + if (shareId === undefined || shareId === '') { + return; + } + + void applySharePrefill({ + shareId, + input: inputRef.current, + maxLength: maxLengthRef.current, + onChangeText: text => { + onChangeTextRef.current(text); + }, + addCandidates: async candidates => { + await addCandidatesRef.current(candidates); + }, + clearShareIdParam: () => { + router.setParams({ shareId: undefined }); + }, + }); + }, [shareId, inputRef, router]); +} From 748246a823eb55c5249a221bef8f693eb5bac2f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 26 Jul 2026 14:31:15 +0200 Subject: [PATCH 04/11] feat(mobile): admit remote CLI sessions as share destinations --- .../share/share-cli-admission.test.ts | 140 ++++++++++++++++++ .../components/share/share-cli-admission.ts | 43 ++++++ .../src/components/share/share-gate-sheet.tsx | 32 +++- 3 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/src/components/share/share-cli-admission.test.ts create mode 100644 apps/mobile/src/components/share/share-cli-admission.ts diff --git a/apps/mobile/src/components/share/share-cli-admission.test.ts b/apps/mobile/src/components/share/share-cli-admission.test.ts new file mode 100644 index 0000000000..3a809597ff --- /dev/null +++ b/apps/mobile/src/components/share/share-cli-admission.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveShareDestinationAdmission } from './share-cli-admission'; + +const NOT_CONNECTED_TITLE = 'Session not connected'; +const NOT_CONNECTED_MESSAGE = + "This session runs on a Kilo CLI that isn't connected, so it can't receive messages right now. Reconnect the CLI on that machine, or pick another session."; + +const CANT_RECEIVE_FILES_TITLE = "This session can't receive files"; +const CANT_RECEIVE_FILES_MESSAGE = + "The Kilo CLI running this session can't receive files. Update the CLI on that machine, or share to a new session instead."; + +describe('resolveShareDestinationAdmission', () => { + it('passes non-cli platforms through untouched', () => { + expect( + resolveShareDestinationAdmission({ + createdOnPlatform: 'cloud-agent', + live: false, + attachmentsCapable: false, + hasFiles: true, + }) + ).toEqual({ ok: true }); + + expect( + resolveShareDestinationAdmission({ + createdOnPlatform: 'cloud-agent-web', + live: false, + attachmentsCapable: false, + hasFiles: true, + }) + ).toEqual({ ok: true }); + }); + + it('passes null createdOnPlatform through (non-cli)', () => { + expect( + resolveShareDestinationAdmission({ + createdOnPlatform: null, + live: false, + attachmentsCapable: false, + hasFiles: true, + }) + ).toEqual({ ok: true }); + }); + + it('rejects cli + not live, with or without files', () => { + expect( + resolveShareDestinationAdmission({ + createdOnPlatform: 'cli', + live: false, + attachmentsCapable: false, + hasFiles: false, + }) + ).toEqual({ + ok: false, + title: NOT_CONNECTED_TITLE, + message: NOT_CONNECTED_MESSAGE, + }); + + expect( + resolveShareDestinationAdmission({ + createdOnPlatform: 'cli', + live: false, + attachmentsCapable: true, + hasFiles: true, + }) + ).toEqual({ + ok: false, + title: NOT_CONNECTED_TITLE, + message: NOT_CONNECTED_MESSAGE, + }); + + expect(NOT_CONNECTED_MESSAGE).toMatch(/CLI/); + }); + + it('accepts cli + live + capable + files', () => { + expect( + resolveShareDestinationAdmission({ + createdOnPlatform: 'cli', + live: true, + attachmentsCapable: true, + hasFiles: true, + }) + ).toEqual({ ok: true }); + }); + + it('rejects cli + live + incapable + files', () => { + expect( + resolveShareDestinationAdmission({ + createdOnPlatform: 'cli', + live: true, + attachmentsCapable: false, + hasFiles: true, + }) + ).toEqual({ + ok: false, + title: CANT_RECEIVE_FILES_TITLE, + message: CANT_RECEIVE_FILES_MESSAGE, + }); + + expect(CANT_RECEIVE_FILES_MESSAGE).toMatch(/CLI/); + }); + + it('accepts cli + live + incapable + text-only', () => { + expect( + resolveShareDestinationAdmission({ + createdOnPlatform: 'cli', + live: true, + attachmentsCapable: false, + hasFiles: false, + }) + ).toEqual({ ok: true }); + }); + + it('rejects cli + live + capabilities-absent (attachmentsCapable false) + files', () => { + // Absent capabilities map to attachmentsCapable: false at the call site. + expect( + resolveShareDestinationAdmission({ + createdOnPlatform: 'cli', + live: true, + attachmentsCapable: false, + hasFiles: true, + }) + ).toEqual({ + ok: false, + title: CANT_RECEIVE_FILES_TITLE, + message: CANT_RECEIVE_FILES_MESSAGE, + }); + }); + + it('accepts cli + live + capable + text-only', () => { + expect( + resolveShareDestinationAdmission({ + createdOnPlatform: 'cli', + live: true, + attachmentsCapable: true, + hasFiles: false, + }) + ).toEqual({ ok: true }); + }); +}); diff --git a/apps/mobile/src/components/share/share-cli-admission.ts b/apps/mobile/src/components/share/share-cli-admission.ts new file mode 100644 index 0000000000..3daff6cb47 --- /dev/null +++ b/apps/mobile/src/components/share/share-cli-admission.ts @@ -0,0 +1,43 @@ +export type ShareDestinationAdmission = + | { ok: true } + | { ok: false; title: string; message: string }; + +/** + * Decide whether a share payload may be committed to a destination row. + * Non-CLI platforms pass through; CLI rows require a live session, and + * file payloads additionally require `capabilities.attachments`. + */ +export function resolveShareDestinationAdmission(input: { + /** `created_on_platform` of the stored row. */ + createdOnPlatform: string | null; + /** True when the row's session id is in the active-sessions set. */ + live: boolean; + /** `capabilities.attachments === true` for the live row; false otherwise. */ + attachmentsCapable: boolean; + /** True when the share payload carries at least one file. */ + hasFiles: boolean; +}): ShareDestinationAdmission { + if (input.createdOnPlatform !== 'cli') { + return { ok: true }; + } + + if (!input.live) { + return { + ok: false, + title: 'Session not connected', + message: + "This session runs on a Kilo CLI that isn't connected, so it can't receive messages right now. Reconnect the CLI on that machine, or pick another session.", + }; + } + + if (input.hasFiles && !input.attachmentsCapable) { + return { + ok: false, + title: "This session can't receive files", + message: + "The Kilo CLI running this session can't receive files. Update the CLI on that machine, or share to a new session instead.", + }; + } + + return { ok: true }; +} diff --git a/apps/mobile/src/components/share/share-gate-sheet.tsx b/apps/mobile/src/components/share/share-gate-sheet.tsx index deec953447..fb9c11fb03 100644 --- a/apps/mobile/src/components/share/share-gate-sheet.tsx +++ b/apps/mobile/src/components/share/share-gate-sheet.tsx @@ -3,7 +3,7 @@ import { useRouter } from 'expo-router'; import { useShareIntentContext } from 'expo-share-intent'; import { Plus, X } from 'lucide-react-native'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Pressable, View } from 'react-native'; +import { Alert, Pressable, View } from 'react-native'; import { getAgentSessionPath } from '@/components/agents/session-detail-routes'; import { expandPlatformFilter } from '@/components/agents/session-list-helpers'; @@ -16,6 +16,10 @@ import { useOrganization } from '@/lib/organization-context'; import { setPendingShareNavigation } from '@/lib/share-navigation'; import { clearSharePayload, peekSharePayload, type ShareId } from '@/lib/share-payload'; +import { + resolveShareDestinationAdmission, + type ShareDestinationAdmission, +} from './share-cli-admission'; import { selectShareDestinations, type ShareDestinationRow } from './share-destinations'; import { ShareDestinationList } from './share-destination-list'; import { isShareCommitEnabled, selectShareGateState } from './share-gate-state'; @@ -40,8 +44,10 @@ export function ShareGateSheet({ shareId }: Readonly) { const colors = useThemeColors(); const { resetShareIntent } = useShareIntentContext(); const { organizationId, isLoaded: orgLoaded } = useOrganization(); + // Org-scoped stored page only (cloud-agent + cli). Active list is an + // id/capability lookup — never a row source (no organizationId filter). const sessions = useAgentSessions({ - createdOnPlatform: expandPlatformFilter(['cloud-agent']), + createdOnPlatform: expandPlatformFilter(['cloud-agent', 'cli']), organizationId, enabled: orgLoaded, }); @@ -95,6 +101,14 @@ export function ShareGateSheet({ shareId }: Readonly) { [sessions.storedSessions, sessions.activeSessionIds] ); + const attachmentsCapableBySessionId = useMemo(() => { + const map = new Map(); + for (const session of sessions.activeSessions) { + map.set(session.id, session.capabilities?.attachments === true); + } + return map; + }, [sessions.activeSessions]); + const state = useMemo( () => selectShareGateState({ @@ -172,11 +186,23 @@ export function ShareGateSheet({ shareId }: Readonly) { if (!shareId) { return; } + const admission: ShareDestinationAdmission = resolveShareDestinationAdmission({ + createdOnPlatform: row.created_on_platform, + live: row.live, + attachmentsCapable: attachmentsCapableBySessionId.get(row.session_id) ?? false, + hasFiles: (payload?.files.length ?? 0) > 0, + }); + if (!admission.ok) { + // Keep the gate open and the payload staged so the user can pick + // another destination. + Alert.alert(admission.title, admission.message); + return; + } const org = row.organization_id ?? undefined; const base = getAgentSessionPath(row.session_id, org) as string; commit(appendShareId(base, shareId)); }, - [commit, shareId] + [attachmentsCapableBySessionId, commit, payload, shareId] ); const handleRetry = useCallback(() => { From 26d0c38ee6ab642b75a202b8097de719f3c2fcf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 26 Jul 2026 16:11:39 +0200 Subject: [PATCH 05/11] fix(mobile): guard the share flow against silent content loss --- apps/mobile/src/app/(app)/agent-chat/new.tsx | 169 ++++++----------- apps/mobile/src/app/_layout.tsx | 19 +- .../agents/new-session-cloud-form.tsx | 172 ++++++++++++++++++ .../agents/session-detail-content.test.ts | 21 ++- .../agents/session-detail-content.tsx | 7 + .../agents/session-detail-send-attachment.ts | 12 ++ .../agents/use-remote-spawn-dispatch.ts | 27 +++ .../src/components/share/share-gate-sheet.tsx | 17 +- .../lib/share-to-new-remote-session.test.ts | 58 ++++++ .../src/lib/share-to-new-remote-session.ts | 38 ++++ .../src/lib/use-new-session-share-remote.ts | 59 ++++++ .../src/lib/use-share-aware-run-on-change.ts | 62 +++++++ apps/mobile/src/lib/use-share-staged-latch.ts | 41 +++++ 13 files changed, 573 insertions(+), 129 deletions(-) create mode 100644 apps/mobile/src/components/agents/new-session-cloud-form.tsx create mode 100644 apps/mobile/src/lib/share-to-new-remote-session.test.ts create mode 100644 apps/mobile/src/lib/share-to-new-remote-session.ts create mode 100644 apps/mobile/src/lib/use-new-session-share-remote.ts create mode 100644 apps/mobile/src/lib/use-share-aware-run-on-change.ts create mode 100644 apps/mobile/src/lib/use-share-staged-latch.ts diff --git a/apps/mobile/src/app/(app)/agent-chat/new.tsx b/apps/mobile/src/app/(app)/agent-chat/new.tsx index 65f9a2d843..c58a632081 100644 --- a/apps/mobile/src/app/(app)/agent-chat/new.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/new.tsx @@ -1,22 +1,16 @@ import { useCallback, useMemo, useRef, useState } from 'react'; -import { ActivityIndicator, ScrollView, View } from 'react-native'; +import { View } from 'react-native'; import { useLocalSearchParams } from 'expo-router'; import { useActionSheet } from '@expo/react-native-action-sheet'; import { useQuery } from '@tanstack/react-query'; import * as WebBrowser from 'expo-web-browser'; import { toast } from 'sonner-native'; -import { InstanceSelector } from '@/components/agents/instance-selector'; -import { NewSessionPrompt } from '@/components/agents/new-session-prompt'; -import { NewSessionRepositorySection } from '@/components/agents/new-session-repository-section'; +import { NewSessionCloudForm } from '@/components/agents/new-session-cloud-form'; import { RemoteSpawnComposer } from '@/components/agents/remote-spawn-composer'; import { useNewSessionCreator } from '@/components/agents/use-new-session-creator'; -import { useRemoteSpawnDispatch } from '@/components/agents/use-remote-spawn-dispatch'; -import { REMOTE_SPAWN_INSTANCE_DISCONNECTED_NOTE } from '@/lib/remote-submit-outcome'; import { pickAgentAttachments } from '@/components/agents/attachment-picker'; import { type AgentMode } from '@/components/agents/mode-selector'; -import { Button } from '@/components/ui/button'; -import { Text } from '@/components/ui/text'; import { ScreenHeader } from '@/components/screen-header'; import { getGitHubIntegrationUrl, @@ -29,23 +23,22 @@ import { useAvailableModels } from '@/lib/hooks/use-available-models'; import { useAutoSelectModel } from '@/lib/hooks/use-auto-select-model'; import { useModelPreferences } from '@/lib/hooks/use-model-preferences'; import { usePersistedAgentModel } from '@/lib/hooks/use-persisted-agent-model'; -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { isRepositorySectionVisible } from '@/lib/is-repository-section-visible'; import { resolveNewSessionSubmitDisabled } from '@/lib/new-session-submit'; import { type InstancePickerInstance } from '@/lib/picker-bridge'; import { shouldShowRunOnSelector } from '@/lib/should-show-run-on-selector'; +import { useNewSessionShareRemote } from '@/lib/use-new-session-share-remote'; import { useTRPC } from '@/lib/trpc'; import { settleVoiceInputBeforeSubmit } from '@/lib/voice-input/voice-input-submit'; export default function NewSessionScreen() { - const colors = useThemeColors(); const { showActionSheetWithOptions } = useActionSheet(); const { organizationId, shareId: shareIdParam } = useLocalSearchParams<{ organizationId?: string; shareId?: string; }>(); // Param can be string | string[] depending on how the route was opened. - const shareId = Array.isArray(shareIdParam) ? shareIdParam[0] : shareIdParam; + const shareId: string | undefined = Array.isArray(shareIdParam) ? shareIdParam[0] : shareIdParam; // ── Selectors state ────────────────────────────────────────────── const [mode, setMode] = useState('code'); @@ -159,12 +152,9 @@ export default function NewSessionScreen() { variant, }); - // ── Remote-instance spawn transport (kilo remote) ──────────────── - // C3b: dispatches the remote submit and owns the outcome -> UX - // (toast, refetch, selection reset, nav). See - // `@/components/agents/use-remote-spawn-dispatch` and - // `@/lib/remote-submit-outcome` for the contract. - const remoteSpawn = useRemoteSpawnDispatch({ + // Share latch + remote spawn + share-aware Run-on (F1/F2). + const { remoteSpawn, handleRunOnInstanceChange } = useNewSessionShareRemote({ + shareId, organizationId, runOnInstance, setRunOnInstance, @@ -255,106 +245,61 @@ export default function NewSessionScreen() { void submitCreate(); }, [remoteSpawn, runOnInstance, submitCreate]); - // oxlint's `jsx-handler-names` rule requires the value of an - // `onX`-prefixed prop to start with `handle`. The dispatch hook's - // stable `onChangeRunOnInstance` reference is a closure over - // several pieces of state, so wrap the call here. - const handleRunOnInstanceChange = useCallback( - (next: InstancePickerInstance | null) => { - remoteSpawn.onChangeRunOnInstance(next); - }, - [remoteSpawn] - ); - return ( {isRepositorySectionVisible(runOnInstance) ? ( - - { - void handleAddAttachment(); - }} - onRemoveAttachment={id => { - attachments.removeAttachment(id); - }} - onRetryAttachment={id => { - attachments.retryAttachment(id); - }} - onRefetchModels={() => { - void refetchModels(); - }} - onPrefillAttachments={addCandidates} - shareId={shareId} - voiceInputSettlerRef={voiceInputSettlerRef} - /> - - {showRunOnSelector ? ( - - Run on - - {remoteSpawn.showInstanceDisconnectedNote ? ( - - {REMOTE_SPAWN_INSTANCE_DISCONNECTED_NOTE} - - ) : null} - - ) : null} - - { - void handleOpenGitHubIntegration(); - }} - onRefetch={() => { - void refetchRepos(); - }} - repositories={repositories} - showGitHubIntegrationPrompt={showGitHubIntegrationPrompt} - value={selectedRepo} - /> - - - + { + void handleAddAttachment(); + }} + onRemoveAttachment={id => { + attachments.removeAttachment(id); + }} + onRetryAttachment={id => { + attachments.retryAttachment(id); + }} + onRefetchModels={() => { + void refetchModels(); + }} + onPrefillAttachments={addCandidates} + shareId={shareId} + voiceInputSettlerRef={voiceInputSettlerRef} + showRunOnSelector={showRunOnSelector} + runOnInstance={runOnInstance} + instanceList={instanceList} + isLoadingInstances={isLoadingInstances} + onChangeRunOnInstance={handleRunOnInstanceChange} + showInstanceDisconnectedNote={remoteSpawn.showInstanceDisconnectedNote} + isReposError={isReposError} + isLoadingRepos={isLoadingRepos} + isRefetchingRepos={isRefetchingRepos} + onChangeRepo={setSelectedRepo} + onOpenGitHubIntegration={() => { + void handleOpenGitHubIntegration(); + }} + onRefetchRepos={() => { + void refetchRepos(); + }} + repositories={repositories} + showGitHubIntegrationPrompt={showGitHubIntegrationPrompt} + selectedRepo={selectedRepo} + isStartDisabled={isStartDisabled} + onStartSession={handleStartSession} + /> ) : ( (null); // Paired with isShellReadyForShare — keep the success-tail guards in lockstep. @@ -230,7 +234,11 @@ function RootLayoutNav() { } }, [shareIntentError, resetShareIntent]); - // Keyed on hasShareIntent false→true only — not shareIntent identity. + // Keyed per shareIntent identity so a newer intent cancels and supersedes + // an in-flight ingest. Success/failure reset for the happy path lives here + // (gate must never reset); the shareIntentError effect also resets on the + // error path. Calls go through resetShareIntentRef so the unstable context + // function stays out of the deps. useEffect(() => { if (!hasShareIntent) { return undefined; @@ -245,7 +253,7 @@ function RootLayoutNav() { return; } const shareId = putSharePayload(payload); - resetShareIntent(); + resetShareIntentRef.current(); setPendingShareId(shareId); } catch (error) { if (cancelled) { @@ -253,7 +261,7 @@ function RootLayoutNav() { } Sentry.captureException(error); toast.error("Couldn't read the shared content"); - resetShareIntent(); + resetShareIntentRef.current(); } }; @@ -262,8 +270,7 @@ function RootLayoutNav() { return () => { cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- false→true on hasShareIntent only - }, [hasShareIntent]); + }, [hasShareIntent, shareIntent]); useEffect(() => { if (isLoading) { diff --git a/apps/mobile/src/components/agents/new-session-cloud-form.tsx b/apps/mobile/src/components/agents/new-session-cloud-form.tsx new file mode 100644 index 0000000000..496f07f8be --- /dev/null +++ b/apps/mobile/src/components/agents/new-session-cloud-form.tsx @@ -0,0 +1,172 @@ +import { type RefObject } from 'react'; +import { ActivityIndicator, ScrollView, View } from 'react-native'; + +import { InstanceSelector } from '@/components/agents/instance-selector'; +import { NewSessionPrompt } from '@/components/agents/new-session-prompt'; +import { NewSessionRepositorySection } from '@/components/agents/new-session-repository-section'; +import { type AgentMode } from '@/components/agents/mode-selector'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { + type AgentAttachment, + type AgentAttachmentCandidate, +} from '@/lib/agent-attachments/use-agent-attachment-upload'; +import { type ModelOption } from '@/lib/hooks/use-available-models'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { type InstancePickerInstance } from '@/lib/picker-bridge'; +import { REMOTE_SPAWN_INSTANCE_DISCONNECTED_NOTE } from '@/lib/remote-submit-outcome'; + +type NewSessionCloudFormProps = { + attachments: AgentAttachment[]; + attachmentMax: number; + isCreating: boolean; + isModelsError: boolean; + isLoadingModels: boolean; + mode: AgentMode; + model: string; + variant: string; + modelOptions: ModelOption[]; + onChangeText: (text: string) => void; + onModeChange: (mode: AgentMode) => void; + onModelSelect: (modelId: string, variant: string) => void; + onAddAttachment: () => void; + onRemoveAttachment: (id: string) => void; + onRetryAttachment: (id: string) => void; + onRefetchModels: () => void; + onPrefillAttachments: (candidates: AgentAttachmentCandidate[]) => Promise; + shareId: string | undefined; + voiceInputSettlerRef: RefObject<(() => Promise) | null>; + showRunOnSelector: boolean; + runOnInstance: InstancePickerInstance | null; + instanceList: InstancePickerInstance[]; + isLoadingInstances: boolean; + onChangeRunOnInstance: (next: InstancePickerInstance | null) => void; + showInstanceDisconnectedNote: boolean; + isReposError: boolean; + isLoadingRepos: boolean; + isRefetchingRepos: boolean; + onChangeRepo: (fullName: string) => void; + onOpenGitHubIntegration: () => void; + onRefetchRepos: () => void; + repositories: { fullName: string; isPrivate: boolean }[]; + showGitHubIntegrationPrompt: boolean; + selectedRepo: string; + isStartDisabled: boolean; + onStartSession: () => void; +}; + +/** + * Cloud-Agent branch of the new-session screen: prompt, optional Run on, + * repository section, and start CTA. Extracted so the route file stays under + * the max-lines limit. + */ +export function NewSessionCloudForm({ + attachments, + attachmentMax, + isCreating, + isModelsError, + isLoadingModels, + mode, + model, + variant, + modelOptions, + onChangeText, + onModeChange, + onModelSelect, + onAddAttachment, + onRemoveAttachment, + onRetryAttachment, + onRefetchModels, + onPrefillAttachments, + shareId, + voiceInputSettlerRef, + showRunOnSelector, + runOnInstance, + instanceList, + isLoadingInstances, + onChangeRunOnInstance, + showInstanceDisconnectedNote, + isReposError, + isLoadingRepos, + isRefetchingRepos, + onChangeRepo, + onOpenGitHubIntegration, + onRefetchRepos, + repositories, + showGitHubIntegrationPrompt, + selectedRepo, + isStartDisabled, + onStartSession, +}: Readonly) { + const colors = useThemeColors(); + + return ( + + + + {showRunOnSelector ? ( + + Run on + + {showInstanceDisconnectedNote ? ( + + {REMOTE_SPAWN_INSTANCE_DISCONNECTED_NOTE} + + ) : null} + + ) : null} + + + + + + ); +} diff --git a/apps/mobile/src/components/agents/session-detail-content.test.ts b/apps/mobile/src/components/agents/session-detail-content.test.ts index dc6981ea13..555b69d9ea 100644 --- a/apps/mobile/src/components/agents/session-detail-content.test.ts +++ b/apps/mobile/src/components/agents/session-detail-content.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { resolveSendAttachmentKind } from '@/components/agents/session-detail-send-attachment'; +import { + resolveSendAttachmentKind, + shouldRefuseSilentAttachmentDrop, +} from '@/components/agents/session-detail-send-attachment'; describe('resolveSendAttachmentKind', () => { it.each([ @@ -20,3 +23,19 @@ describe('resolveSendAttachmentKind', () => { } ); }); + +describe('shouldRefuseSilentAttachmentDrop', () => { + it.each([ + { kind: 'none' as const, hasAttachments: true, expected: true }, + { kind: 'none' as const, hasAttachments: false, expected: false }, + { kind: 'cloud' as const, hasAttachments: true, expected: false }, + { kind: 'cloud' as const, hasAttachments: false, expected: false }, + { kind: 'remote-capable' as const, hasAttachments: true, expected: false }, + { kind: 'remote-capable' as const, hasAttachments: false, expected: false }, + ])( + 'returns $expected for kind=$kind, hasAttachments=$hasAttachments', + ({ kind, hasAttachments, expected }) => { + expect(shouldRefuseSilentAttachmentDrop(kind, hasAttachments)).toBe(expected); + } + ); +}); diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 6203170c38..aed0db92ab 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -33,6 +33,7 @@ import { buildRemoteAttachmentParts } from '@/components/agents/mobile-session-m import { buildRemoteAttachmentPartsWithRetryableFeedback, resolveSendAttachmentKind, + shouldRefuseSilentAttachmentDrop, } from '@/components/agents/session-detail-send-attachment'; import { useSessionManager } from '@/components/agents/session-provider'; import { SessionStatusIndicator } from '@/components/agents/session-status-indicator'; @@ -516,6 +517,12 @@ export function SessionDetailContent({ supportsAttachments, attachments !== undefined ); + if (shouldRefuseSilentAttachmentDrop(kind, attachments !== undefined)) { + const message = + "This session can't receive files. Remove the attachments to send your message."; + toast.error(message); + throw new Error(message); + } let attachmentParts: Awaited> | undefined = undefined; if (kind === 'remote-capable' && submission) { diff --git a/apps/mobile/src/components/agents/session-detail-send-attachment.ts b/apps/mobile/src/components/agents/session-detail-send-attachment.ts index 6a82d4b869..4ab8b97937 100644 --- a/apps/mobile/src/components/agents/session-detail-send-attachment.ts +++ b/apps/mobile/src/components/agents/session-detail-send-attachment.ts @@ -43,6 +43,18 @@ export function resolveSendAttachmentKind( return 'none'; } +/** + * True when the send path would omit attachments from the wire while the + * composer still holds uploaded files — refuse that silent drop so the user + * can remove attachments and retry. + */ +export function shouldRefuseSilentAttachmentDrop( + kind: 'cloud' | 'remote-capable' | 'none', + hasAttachments: boolean +): boolean { + return kind === 'none' && hasAttachments; +} + /** * Build remote attachment parts for a capable remote session, mapping a * transient presign failure to a retryable user-facing message. The caller diff --git a/apps/mobile/src/components/agents/use-remote-spawn-dispatch.ts b/apps/mobile/src/components/agents/use-remote-spawn-dispatch.ts index eaf823d1d0..fbcbfc24c8 100644 --- a/apps/mobile/src/components/agents/use-remote-spawn-dispatch.ts +++ b/apps/mobile/src/components/agents/use-remote-spawn-dispatch.ts @@ -42,6 +42,14 @@ type UseRemoteSpawnDispatchArgs = { * membership fallback if the refetch fails. */ instanceList: InstancePickerInstance[]; + /** + * When true on a ready outcome, skip `router.replace` so a share that + * arrived mid-spawn is not stranded by navigating away. Optional — + * only the new-session route passes this today. + */ + shouldCancelReadyNavigation?: () => boolean; + /** Called when ready navigation is cancelled by `shouldCancelReadyNavigation`. */ + onReadyNavigationCancelled?: () => void; }; type UseRemoteSpawnDispatchResult = { @@ -95,6 +103,8 @@ export function useRemoteSpawnDispatch({ setRunOnInstance, refetchInstances, instanceList, + shouldCancelReadyNavigation, + onReadyNavigationCancelled, }: UseRemoteSpawnDispatchArgs): UseRemoteSpawnDispatchResult { const router = useRouter(); const remoteSpawn: { @@ -117,6 +127,16 @@ export function useRemoteSpawnDispatch({ runOnInstanceRef.current = runOnInstance; }, [runOnInstance]); + // Same lifetime concern as runOnInstanceRef: the cancel predicate and + // cancelled callback must be read at ready-time, not captured from the + // render that started the spawn. + const shouldCancelReadyNavigationRef = useRef(shouldCancelReadyNavigation); + const onReadyNavigationCancelledRef = useRef(onReadyNavigationCancelled); + useEffect(() => { + shouldCancelReadyNavigationRef.current = shouldCancelReadyNavigation; + onReadyNavigationCancelledRef.current = onReadyNavigationCancelled; + }, [shouldCancelReadyNavigation, onReadyNavigationCancelled]); + const onStart = useCallback(() => { if (runOnInstance === null) { return; @@ -125,6 +145,13 @@ export function useRemoteSpawnDispatch({ void (async () => { const outcome = await remoteSpawn.spawn(selectedConnectionId); if (outcome.status === 'ready') { + if (shouldCancelReadyNavigationRef.current?.() === true) { + // Cancel contract: toast via callback, then leave the user on the + // cloud composer — not on RemoteSpawnComposer with a live selection. + onReadyNavigationCancelledRef.current?.(); + setRunOnInstance(null); + return; + } router.replace(getSpawnedAgentSessionPath(outcome.sessionID, organizationId)); return; } diff --git a/apps/mobile/src/components/share/share-gate-sheet.tsx b/apps/mobile/src/components/share/share-gate-sheet.tsx index fb9c11fb03..780b77ee76 100644 --- a/apps/mobile/src/components/share/share-gate-sheet.tsx +++ b/apps/mobile/src/components/share/share-gate-sheet.tsx @@ -1,6 +1,5 @@ import * as Haptics from 'expo-haptics'; import { useRouter } from 'expo-router'; -import { useShareIntentContext } from 'expo-share-intent'; import { Plus, X } from 'lucide-react-native'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Alert, Pressable, View } from 'react-native'; @@ -42,7 +41,6 @@ type ShareGateSheetProps = { export function ShareGateSheet({ shareId }: Readonly) { const router = useRouter(); const colors = useThemeColors(); - const { resetShareIntent } = useShareIntentContext(); const { organizationId, isLoaded: orgLoaded } = useOrganization(); // Org-scoped stored page only (cloud-agent + cli). Active list is an // id/capability lookup — never a row source (no organizationId filter). @@ -139,8 +137,9 @@ export function ShareGateSheet({ shareId }: Readonly) { if (id) { clearSharePayload(id); } - resetShareIntent(); - }, [resetShareIntent]); + // Never resetShareIntent here — a newly arriving intent is the layout + // effect's to read. + }, []); const dismiss = useCallback(() => { abandon(); @@ -150,14 +149,12 @@ export function ShareGateSheet({ shareId }: Readonly) { useEffect( () => () => { const id = ownedShareIdRef.current; - if (id !== committedShareIdRef.current) { - if (id) { - clearSharePayload(id); - } - resetShareIntent(); + // Never resetShareIntent — layout ingest owns intent reset. + if (id && id !== committedShareIdRef.current) { + clearSharePayload(id); } }, - [resetShareIntent] + [] ); const commit = useCallback( diff --git a/apps/mobile/src/lib/share-to-new-remote-session.test.ts b/apps/mobile/src/lib/share-to-new-remote-session.test.ts new file mode 100644 index 0000000000..a3cbdb5864 --- /dev/null +++ b/apps/mobile/src/lib/share-to-new-remote-session.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { + hasStagedShareId, + SHARE_STAGED_SPAWN_NAVIGATION_CANCELLED_TOAST, + SHARE_TO_NEW_REMOTE_SESSION_ALERT, + shouldBlockRemoteRunOnSelection, + shouldCancelSpawnNavigationForStagedShare, +} from '@/lib/share-to-new-remote-session'; + +describe('hasStagedShareId', () => { + it.each([ + { shareId: undefined, expected: false }, + { shareId: '', expected: false }, + { shareId: 'share-abc', expected: true }, + ])('returns $expected for shareId=$shareId', ({ shareId, expected }) => { + expect(hasStagedShareId(shareId)).toBe(expected); + }); +}); + +describe('shouldBlockRemoteRunOnSelection', () => { + it.each([ + { shareStaged: true, next: { connectionId: 'c1' }, expected: true }, + { shareStaged: true, next: null, expected: false }, + { shareStaged: false, next: { connectionId: 'c1' }, expected: false }, + { shareStaged: false, next: null, expected: false }, + ])( + 'returns $expected when shareStaged=$shareStaged and next is $next', + ({ shareStaged, next, expected }) => { + expect(shouldBlockRemoteRunOnSelection(shareStaged, next)).toBe(expected); + } + ); +}); + +describe('shouldCancelSpawnNavigationForStagedShare', () => { + it.each([ + { shareStaged: true, expected: true }, + { shareStaged: false, expected: false }, + ])('returns $expected when shareStaged=$shareStaged', ({ shareStaged, expected }) => { + expect(shouldCancelSpawnNavigationForStagedShare(shareStaged)).toBe(expected); + }); +}); + +describe('share-to-new-remote-session copy', () => { + it('pins the remote Run-on block alert strings', () => { + expect(SHARE_TO_NEW_REMOTE_SESSION_ALERT).toEqual({ + title: "Can't share to a new remote session", + message: + "A session started on a remote CLI can't receive shared text or files. Start a cloud session, or go back and pick the running CLI session from the share list.", + }); + }); + + it('pins the mid-spawn navigation-cancelled toast string', () => { + expect(SHARE_STAGED_SPAWN_NAVIGATION_CANCELLED_TOAST).toBe( + "Shared content can't start a remote session. The spawned session is in your session list." + ); + }); +}); diff --git a/apps/mobile/src/lib/share-to-new-remote-session.ts b/apps/mobile/src/lib/share-to-new-remote-session.ts new file mode 100644 index 0000000000..21b26280f9 --- /dev/null +++ b/apps/mobile/src/lib/share-to-new-remote-session.ts @@ -0,0 +1,38 @@ +/** Alert when the user picks a remote "Run on" target while a share is staged. */ +export const SHARE_TO_NEW_REMOTE_SESSION_ALERT = { + title: "Can't share to a new remote session", + message: + "A session started on a remote CLI can't receive shared text or files. Start a cloud session, or go back and pick the running CLI session from the share list.", +} as const; + +/** + * Toast when a remote spawn finishes ready but navigation is cancelled + * because a share was staged mid-flight. The spawned session still exists. + */ +export const SHARE_STAGED_SPAWN_NAVIGATION_CANCELLED_TOAST = + "Shared content can't start a remote session. The spawned session is in your session list."; + +/** True when a non-empty shareId is staged on the new-session route. */ +export function hasStagedShareId(shareId: string | undefined): boolean { + return shareId != null && shareId !== ''; +} + +/** + * Block selecting a remote "Run on" target while a share has been staged on + * this screen mount (one-way latch). Clearing Cloud Agent (`next === null`) + * is always allowed. + */ +export function shouldBlockRemoteRunOnSelection( + shareStaged: boolean, + next: unknown | null +): boolean { + return shareStaged && next !== null; +} + +/** + * Cancel ready-spawn navigation when a share was staged during the in-flight + * spawn so the prefilled draft is not stranded on the new-session screen. + */ +export function shouldCancelSpawnNavigationForStagedShare(shareStaged: boolean): boolean { + return shareStaged; +} diff --git a/apps/mobile/src/lib/use-new-session-share-remote.ts b/apps/mobile/src/lib/use-new-session-share-remote.ts new file mode 100644 index 0000000000..11bf58c95a --- /dev/null +++ b/apps/mobile/src/lib/use-new-session-share-remote.ts @@ -0,0 +1,59 @@ +import { useCallback } from 'react'; +import { toast } from 'sonner-native'; + +import { useRemoteSpawnDispatch } from '@/components/agents/use-remote-spawn-dispatch'; +import { type InstancePickerInstance } from '@/lib/picker-bridge'; +import { SHARE_STAGED_SPAWN_NAVIGATION_CANCELLED_TOAST } from '@/lib/share-to-new-remote-session'; +import { useShareAwareRunOnChange } from '@/lib/use-share-aware-run-on-change'; +import { useShareStagedLatch } from '@/lib/use-share-staged-latch'; + +type InstancesRefetch = () => Promise<{ + data: { instances: InstancePickerInstance[] } | undefined; +}>; + +type UseNewSessionShareRemoteArgs = { + shareId: string | undefined; + organizationId: string | undefined; + runOnInstance: InstancePickerInstance | null; + setRunOnInstance: (next: InstancePickerInstance | null) => void; + refetchInstances: InstancesRefetch; + instanceList: InstancePickerInstance[]; +}; + +/** + * Wires share-staged latch, remote spawn dispatch (with mid-spawn cancel), + * and the share-aware Run-on change handler for the new-session screen. + */ +export function useNewSessionShareRemote({ + shareId, + organizationId, + runOnInstance, + setRunOnInstance, + refetchInstances, + instanceList, +}: UseNewSessionShareRemoteArgs) { + const { isShareStaged, shouldCancelReadyNavigation } = useShareStagedLatch(shareId); + + const handleReadyNavigationCancelled = useCallback(() => { + toast.error(SHARE_STAGED_SPAWN_NAVIGATION_CANCELLED_TOAST); + }, []); + + const remoteSpawn = useRemoteSpawnDispatch({ + organizationId, + runOnInstance, + setRunOnInstance, + refetchInstances, + instanceList, + shouldCancelReadyNavigation, + onReadyNavigationCancelled: handleReadyNavigationCancelled, + }); + + const handleRunOnInstanceChange = useShareAwareRunOnChange({ + shareId, + isShareStaged, + runOnInstance, + onChangeRunOnInstance: remoteSpawn.onChangeRunOnInstance, + }); + + return { remoteSpawn, handleRunOnInstanceChange }; +} diff --git a/apps/mobile/src/lib/use-share-aware-run-on-change.ts b/apps/mobile/src/lib/use-share-aware-run-on-change.ts new file mode 100644 index 0000000000..cf595c9203 --- /dev/null +++ b/apps/mobile/src/lib/use-share-aware-run-on-change.ts @@ -0,0 +1,62 @@ +import { useCallback, useEffect } from 'react'; +import { Alert } from 'react-native'; + +import { type InstancePickerInstance } from '@/lib/picker-bridge'; +import { + hasStagedShareId, + SHARE_TO_NEW_REMOTE_SESSION_ALERT, + shouldBlockRemoteRunOnSelection, +} from '@/lib/share-to-new-remote-session'; + +type UseShareAwareRunOnChangeArgs = { + /** Current route shareId — drives the arrival-reset effect only. */ + shareId: string | undefined; + /** + * One-way latch for this screen mount: true once a share was staged, + * even after prefill clears the route param. + */ + isShareStaged: () => boolean; + runOnInstance: InstancePickerInstance | null; + onChangeRunOnInstance: (next: InstancePickerInstance | null) => void; +}; + +/** + * Blocks selecting a remote "Run on" target while a share is staged, and + * silently resets an already-selected remote target when a share arrives so + * NewSessionPrompt (with prefill) can render. + */ +export function useShareAwareRunOnChange({ + shareId, + isShareStaged, + runOnInstance, + onChangeRunOnInstance, +}: UseShareAwareRunOnChangeArgs): (next: InstancePickerInstance | null) => void { + const handleRunOnInstanceChange = useCallback( + (next: InstancePickerInstance | null) => { + // A newly spawned remote session cannot receive shared content; + // keep Cloud Agent selected and explain why. Uses the mount latch so + // the block still applies after prefill clears the route param. + if (shouldBlockRemoteRunOnSelection(isShareStaged(), next)) { + Alert.alert( + SHARE_TO_NEW_REMOTE_SESSION_ALERT.title, + SHARE_TO_NEW_REMOTE_SESSION_ALERT.message + ); + return; + } + onChangeRunOnInstance(next); + }, + [isShareStaged, onChangeRunOnInstance] + ); + + // Share arrived while a remote instance was already selected: reset to + // Cloud Agent so NewSessionPrompt (with prefill) renders. Silent by design — + // the visible swap to the prefilled composer is the feedback. Uses the live + // route param (not the latch) so this only fires on actual share arrival. + useEffect(() => { + if (hasStagedShareId(shareId) && runOnInstance !== null) { + handleRunOnInstanceChange(null); + } + }, [shareId, runOnInstance, handleRunOnInstanceChange]); + + return handleRunOnInstanceChange; +} diff --git a/apps/mobile/src/lib/use-share-staged-latch.ts b/apps/mobile/src/lib/use-share-staged-latch.ts new file mode 100644 index 0000000000..cc4e4948ef --- /dev/null +++ b/apps/mobile/src/lib/use-share-staged-latch.ts @@ -0,0 +1,41 @@ +import { useCallback, useRef } from 'react'; + +import { + hasStagedShareId, + shouldCancelSpawnNavigationForStagedShare, +} from '@/lib/share-to-new-remote-session'; + +type ShareStagedLatch = { + /** True once a share was staged on this screen mount (one-way). */ + isShareStaged: () => boolean; + /** Ready-spawn cancel predicate backed by the same latch. */ + shouldCancelReadyNavigation: () => boolean; +}; + +/** + * One-way latch for the new-session screen mount: once a non-empty shareId + * is observed during render, stay staged even after prefill clears the route + * param. Written during render (not a passive effect) so an async spawn tail + * that resolves before effects flush still sees staged=true. Resets only when + * the screen unmounts. + */ +export function useShareStagedLatch(shareId: string | undefined): ShareStagedLatch { + const shareStagedRef = useRef(false); + // One-way latch written during render (not a passive effect): once ANY + // render observes a non-empty shareId, every later read on this mount — + // including an async spawn tail resolving before passive effects flush — + // sees staged=true. Idempotent and side-effect free, so it is safe under + // concurrent/strict renders; a torn render can only fail-safe (block + // remote / cancel navigation), never lose shared content. + if (hasStagedShareId(shareId)) { + shareStagedRef.current = true; + } + + const isShareStaged = useCallback(() => shareStagedRef.current, []); + const shouldCancelReadyNavigation = useCallback( + () => shouldCancelSpawnNavigationForStagedShare(shareStagedRef.current), + [] + ); + + return { isShareStaged, shouldCancelReadyNavigation }; +} From a5efdc2c6203d88664b37b59c1402af037baaa41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 26 Jul 2026 16:11:41 +0200 Subject: [PATCH 06/11] fix(mobile): delete share cache files on clear and eviction --- apps/mobile/src/lib/share-payload.test.ts | 132 +++++++++++++++++----- apps/mobile/src/lib/share-payload.ts | 40 ++++++- 2 files changed, 138 insertions(+), 34 deletions(-) diff --git a/apps/mobile/src/lib/share-payload.test.ts b/apps/mobile/src/lib/share-payload.test.ts index 389caa1c6d..3706581054 100644 --- a/apps/mobile/src/lib/share-payload.test.ts +++ b/apps/mobile/src/lib/share-payload.test.ts @@ -1,5 +1,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + __resetSharePayloadStoreForTests, + __setDeleteCachedFileForTests, + clearSharePayload, + composeShareText, + normalizeShareIntent, + peekSharePayload, + putSharePayload, + SHARE_PAYLOAD_MAX_ENTRIES, + SHARE_TEXT_MAX_CHARS, + takeSharePayload, +} from './share-payload'; + vi.mock('expo-crypto', () => { let n = 0; return { @@ -15,23 +28,36 @@ vi.mock('expo-file-system/legacy', () => ({ copyAsync: vi.fn(async () => { await Promise.resolve(); }), + deleteAsync: vi.fn(async () => { + await Promise.resolve(); + }), })); +async function withDeleteTracking(run: (deleted: string[]) => Promise): Promise { + const deleted: string[] = []; + __setDeleteCachedFileForTests(async uri => { + deleted.push(uri); + await Promise.resolve(); + }); + try { + await run(deleted); + } finally { + __resetSharePayloadStoreForTests(); + } +} + describe('composeShareText', () => { - it('returns trimmed text', async () => { - const { composeShareText } = await import('./share-payload'); + it('returns trimmed text', () => { expect(composeShareText({ text: ' hello ', webUrl: null, meta: null, files: null })).toBe( 'hello' ); }); - it('returns empty string when text is blank and no webUrl', async () => { - const { composeShareText } = await import('./share-payload'); + it('returns empty string when text is blank and no webUrl', () => { expect(composeShareText({ text: ' ', webUrl: null, meta: null, files: null })).toBe(''); }); - it('falls back to webUrl when text is empty', async () => { - const { composeShareText } = await import('./share-payload'); + it('falls back to webUrl when text is empty', () => { expect( composeShareText({ text: ' ', @@ -42,8 +68,7 @@ describe('composeShareText', () => { ).toBe('https://example.com'); }); - it('prefers non-empty text over webUrl', async () => { - const { composeShareText } = await import('./share-payload'); + it('prefers non-empty text over webUrl', () => { expect( composeShareText({ text: 'body', @@ -54,8 +79,7 @@ describe('composeShareText', () => { ).toBe('body'); }); - it('prefixes title when base is non-empty and does not already contain it', async () => { - const { composeShareText } = await import('./share-payload'); + it('prefixes title when base is non-empty and does not already contain it', () => { expect( composeShareText({ text: 'https://example.com', @@ -66,8 +90,7 @@ describe('composeShareText', () => { ).toBe('Example\nhttps://example.com'); }); - it('does not re-prefix title when base already contains it', async () => { - const { composeShareText } = await import('./share-payload'); + it('does not re-prefix title when base already contains it', () => { expect( composeShareText({ text: 'Example page https://example.com', @@ -78,8 +101,7 @@ describe('composeShareText', () => { ).toBe('Example page https://example.com'); }); - it('does not add title when base is empty', async () => { - const { composeShareText } = await import('./share-payload'); + it('does not add title when base is empty', () => { expect( composeShareText({ text: '', @@ -90,8 +112,7 @@ describe('composeShareText', () => { ).toBe(''); }); - it('clamps to SHARE_TEXT_MAX_CHARS last', async () => { - const { composeShareText, SHARE_TEXT_MAX_CHARS } = await import('./share-payload'); + it('clamps to SHARE_TEXT_MAX_CHARS last', () => { const long = 'a'.repeat(SHARE_TEXT_MAX_CHARS + 50); const composed = composeShareText({ text: long, @@ -105,14 +126,12 @@ describe('composeShareText', () => { }); describe('share payload store', () => { - beforeEach(async () => { - const { __resetSharePayloadStoreForTests } = await import('./share-payload'); + beforeEach(() => { __resetSharePayloadStoreForTests(); vi.clearAllMocks(); }); - it('put returns unique ids', async () => { - const { putSharePayload, peekSharePayload } = await import('./share-payload'); + it('put returns unique ids', () => { const a = putSharePayload({ text: 'a', files: [] }); const b = putSharePayload({ text: 'b', files: [] }); expect(a).not.toBe(b); @@ -120,25 +139,21 @@ describe('share payload store', () => { expect(peekSharePayload(b)?.text).toBe('b'); }); - it('take is read-and-clear and returns null on second read or unknown id', async () => { - const { putSharePayload, takeSharePayload } = await import('./share-payload'); + it('take is read-and-clear and returns null on second read or unknown id', () => { const id = putSharePayload({ text: 'once', files: [] }); expect(takeSharePayload(id)).toEqual({ text: 'once', files: [] }); expect(takeSharePayload(id)).toBeNull(); expect(takeSharePayload('missing')).toBeNull(); }); - it('peek does not consume', async () => { - const { putSharePayload, peekSharePayload, takeSharePayload } = await import('./share-payload'); + it('peek does not consume', () => { const id = putSharePayload({ text: 'peek', files: [] }); expect(peekSharePayload(id)?.text).toBe('peek'); expect(peekSharePayload(id)?.text).toBe('peek'); expect(takeSharePayload(id)?.text).toBe('peek'); }); - it('clear is id-scoped', async () => { - const { putSharePayload, clearSharePayload, peekSharePayload } = - await import('./share-payload'); + it('clear is id-scoped', () => { const a = putSharePayload({ text: 'a', files: [] }); const b = putSharePayload({ text: 'b', files: [] }); clearSharePayload(a); @@ -146,9 +161,7 @@ describe('share payload store', () => { expect(peekSharePayload(b)?.text).toBe('b'); }); - it('evicts oldest first beyond the cap', async () => { - const { putSharePayload, peekSharePayload, SHARE_PAYLOAD_MAX_ENTRIES } = - await import('./share-payload'); + it('evicts oldest first beyond the cap', () => { const ids: string[] = []; for (let i = 0; i < SHARE_PAYLOAD_MAX_ENTRIES + 2; i += 1) { ids.push(putSharePayload({ text: `t-${i}`, files: [] })); @@ -166,11 +179,68 @@ describe('share payload store', () => { expect(peekSharePayload(third ?? '')?.text).toBe('t-2'); expect(peekSharePayload(last ?? '')?.text).toBe(`t-${SHARE_PAYLOAD_MAX_ENTRIES + 1}`); }); + + it('clear deletes the payload file uris', async () => { + await withDeleteTracking(async deleted => { + const id = putSharePayload({ + text: 'with-files', + files: [ + { name: 'a.jpg', uri: 'file:///cache/share-a.jpg' }, + { name: 'b.png', uri: 'file:///cache/share-b.png' }, + ], + }); + clearSharePayload(id); + await vi.waitFor(() => { + expect(deleted).toEqual(['file:///cache/share-a.jpg', 'file:///cache/share-b.png']); + }); + }); + }); + + it('eviction of the oldest entry deletes that entry file uris', async () => { + await withDeleteTracking(async deleted => { + putSharePayload({ + text: 'oldest', + files: [{ name: 'old.txt', uri: 'file:///cache/share-old.txt' }], + }); + for (let i = 0; i < SHARE_PAYLOAD_MAX_ENTRIES; i += 1) { + putSharePayload({ text: `keep-${i}`, files: [] }); + } + await vi.waitFor(() => { + expect(deleted).toEqual(['file:///cache/share-old.txt']); + }); + }); + }); + + it('take does not delete cache file uris', async () => { + await withDeleteTracking(async deleted => { + const id = putSharePayload({ + text: 'take-me', + files: [{ name: 'kept.bin', uri: 'file:///cache/share-kept.bin' }], + }); + expect(takeSharePayload(id)?.files[0]?.uri).toBe('file:///cache/share-kept.bin'); + await Promise.resolve(); + expect(deleted).toEqual([]); + }); + }); + + it('clear after take is a no-op and does not delete cache uris', async () => { + await withDeleteTracking(async deleted => { + const id = putSharePayload({ + text: 'taken-then-cleared', + files: [{ name: 'upload-me.bin', uri: 'file:///cache/share-upload-me.bin' }], + }); + expect(takeSharePayload(id)?.files[0]?.uri).toBe('file:///cache/share-upload-me.bin'); + expect(peekSharePayload(id)).toBeNull(); + // Composer may still be uploading; clear after take must not delete uris. + clearSharePayload(id); + await Promise.resolve(); + expect(deleted).toEqual([]); + }); + }); }); describe('normalizeShareIntent', () => { it('copies files into cache paths rather than keeping share-container URIs', async () => { - const { normalizeShareIntent } = await import('./share-payload'); const incoming = 'file:///share-container/photo.jpg'; const payload = await normalizeShareIntent( { diff --git a/apps/mobile/src/lib/share-payload.ts b/apps/mobile/src/lib/share-payload.ts index 7e5d122400..dd9a9168b9 100644 --- a/apps/mobile/src/lib/share-payload.ts +++ b/apps/mobile/src/lib/share-payload.ts @@ -1,5 +1,5 @@ import * as Crypto from 'expo-crypto'; -import { cacheDirectory, copyAsync } from 'expo-file-system/legacy'; +import { cacheDirectory, copyAsync, deleteAsync } from 'expo-file-system/legacy'; import { type ShareIntent } from 'expo-share-intent'; import { type AgentAttachmentCandidate } from '@/lib/agent-attachments/use-agent-attachment-upload'; @@ -20,14 +20,37 @@ type ShareIntentLike = Pick; type CopyToCache = (args: { from: string; fileName: string }) => Promise; +type DeleteCachedFile = (uri: string) => Promise; + const payloads = new Map(); const insertionOrder: ShareId[] = []; +async function defaultDeleteCachedFile(uri: string): Promise { + try { + await deleteAsync(uri, { idempotent: true }); + } catch { + // Best-effort hygiene; ignore delete failures. + } +} + +let deleteCachedFile: DeleteCachedFile = defaultDeleteCachedFile; + +/** Fire-and-forget delete of each file uri in a dropped payload. */ +function discardPayloadCacheFiles(payload: SharePayload): void { + for (const file of payload.files) { + void deleteCachedFile(file.uri); + } +} + function evictOldestIfNeeded(): void { while (payloads.size > SHARE_PAYLOAD_MAX_ENTRIES && insertionOrder.length > 0) { const oldest = insertionOrder.shift(); if (oldest !== undefined) { + const evicted = payloads.get(oldest); payloads.delete(oldest); + if (evicted) { + discardPayloadCacheFiles(evicted); + } } } } @@ -41,7 +64,10 @@ export function putSharePayload(payload: SharePayload): ShareId { return id; } -/** Read-and-delete. Null if `id` is unknown or already consumed. */ +/** + * Read-and-delete map entry only. Never deletes cache files here — the + * composer's uploads still read those uris after take. + */ export function takeSharePayload(id: ShareId): SharePayload | null { const payload = payloads.get(id) ?? null; if (payload === null) { @@ -62,7 +88,8 @@ export function peekSharePayload(id: ShareId): SharePayload | null { /** Id-scoped abandonment. Never clears another id's entry. */ export function clearSharePayload(id: ShareId): void { - if (!payloads.has(id)) { + const payload = payloads.get(id); + if (payload === undefined) { return; } payloads.delete(id); @@ -70,12 +97,19 @@ export function clearSharePayload(id: ShareId): void { if (index !== -1) { insertionOrder.splice(index, 1); } + discardPayloadCacheFiles(payload); } /** Test-only: wipe the module store between cases. */ export function __resetSharePayloadStoreForTests(): void { payloads.clear(); insertionOrder.length = 0; + deleteCachedFile = defaultDeleteCachedFile; +} + +/** Test-only: replace the cache-file delete implementation. */ +export function __setDeleteCachedFileForTests(fn: DeleteCachedFile | null): void { + deleteCachedFile = fn ?? defaultDeleteCachedFile; } export function composeShareText(shareIntent: ShareIntentLike): string { From af87eeac9ef53c3641afdf48805447c75d9ce5d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 26 Jul 2026 17:41:04 +0200 Subject: [PATCH 07/11] fix(mobile): preserve session capabilities across live-sync merges --- .../active-sessions-live.capabilities.test.ts | 94 +++++++++++++++++++ apps/mobile/src/lib/active-sessions-live.ts | 19 ++-- 2 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 apps/mobile/src/lib/active-sessions-live.capabilities.test.ts diff --git a/apps/mobile/src/lib/active-sessions-live.capabilities.test.ts b/apps/mobile/src/lib/active-sessions-live.capabilities.test.ts new file mode 100644 index 0000000000..e44c448383 --- /dev/null +++ b/apps/mobile/src/lib/active-sessions-live.capabilities.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; + +import { + type CachedActiveSession, + mergeHeartbeatForActiveSessions, + mergeSnapshotForActiveSessions, +} from '@/lib/active-sessions-live'; + +function makeCached(over: Partial = {}): CachedActiveSession { + return { + id: 'a1', + status: 'running', + title: 'test', + connectionId: 'c1', + ...over, + }; +} + +describe('mergeSnapshotForActiveSessions capabilities', () => { + it('takes capabilities from the wire when present', () => { + const current = [makeCached({ id: 'a', capabilities: { attachments: false } })]; + const snapshot = [ + { + id: 'a', + status: 'running', + title: 'A', + connectionId: 'c1', + capabilities: { attachments: true }, + }, + ]; + const result = mergeSnapshotForActiveSessions(current, snapshot); + expect(result[0]?.capabilities).toEqual({ attachments: true }); + }); + + it('preserves cached capabilities when the wire row lacks the field', () => { + const current = [makeCached({ id: 'a', capabilities: { attachments: true } })]; + const snapshot = [{ id: 'a', status: 'running', title: 'A', connectionId: 'c1' }]; + const result = mergeSnapshotForActiveSessions(current, snapshot); + expect(result[0]?.capabilities).toEqual({ attachments: true }); + }); + + it('replaces capabilities when the wire value changes (true→false)', () => { + const current = [makeCached({ id: 'a', capabilities: { attachments: true } })]; + const snapshot = [ + { + id: 'a', + status: 'running', + title: 'A', + connectionId: 'c1', + capabilities: { attachments: false }, + }, + ]; + const result = mergeSnapshotForActiveSessions(current, snapshot); + expect(result[0]?.capabilities).toEqual({ attachments: false }); + }); +}); + +describe('mergeHeartbeatForActiveSessions capabilities', () => { + it('takes capabilities from the wire when present', () => { + const current = [ + makeCached({ id: 'a', connectionId: 'c1', capabilities: { attachments: false } }), + ]; + const payload = { + connectionId: 'c1', + sessions: [{ id: 'a', status: 'running', title: 'A2', capabilities: { attachments: true } }], + }; + const result = mergeHeartbeatForActiveSessions(current, payload); + expect(result[0]?.capabilities).toEqual({ attachments: true }); + }); + + it('preserves cached capabilities when the wire row lacks the field', () => { + const current = [ + makeCached({ id: 'a', connectionId: 'c1', capabilities: { attachments: true } }), + ]; + const payload = { + connectionId: 'c1', + sessions: [{ id: 'a', status: 'running', title: 'A2' }], + }; + const result = mergeHeartbeatForActiveSessions(current, payload); + expect(result[0]?.capabilities).toEqual({ attachments: true }); + }); + + it('replaces capabilities when the wire value changes (true→false)', () => { + const current = [ + makeCached({ id: 'a', connectionId: 'c1', capabilities: { attachments: true } }), + ]; + const payload = { + connectionId: 'c1', + sessions: [{ id: 'a', status: 'running', title: 'A2', capabilities: { attachments: false } }], + }; + const result = mergeHeartbeatForActiveSessions(current, payload); + expect(result[0]?.capabilities).toEqual({ attachments: false }); + }); +}); diff --git a/apps/mobile/src/lib/active-sessions-live.ts b/apps/mobile/src/lib/active-sessions-live.ts index fa3fd2e209..2fbfa0625f 100644 --- a/apps/mobile/src/lib/active-sessions-live.ts +++ b/apps/mobile/src/lib/active-sessions-live.ts @@ -5,9 +5,11 @@ * `updatedAt`); the merge helpers preserve those fields for ids already in * the cache while letting every other field (including `connectionId`) * come from the latest WS payload, so session ownership can transfer - * between CLI connections. The functions here never touch React, the - * network, or a QueryClient — they are pure and exhaustively unit-tested - * alongside this file. + * between CLI connections. `capabilities` is the hybrid exception: the WS + * value wins when present (upgrade or downgrade), and the cached value is + * preserved only when the WS row omits the field. The functions here never + * touch React, the network, or a QueryClient — they are pure and + * exhaustively unit-tested alongside this file. * * Status resolution for live rows: CLI heartbeats/snapshots often report * only idle/busy while `cli_sessions_v2` holds question/permission. A @@ -37,6 +39,7 @@ type IncomingWsSession = { gitBranch?: string; parentSessionId?: string; connectionId?: string; + capabilities?: { attachments?: boolean }; }; /** Cached active session (tRPC output); enrichment fields preserved across WS. */ @@ -164,6 +167,9 @@ function withEnrichmentAndConnectionId( gitUrl: row.gitUrl, gitBranch: row.gitBranch, connectionId, + // Wire capabilities win when present (upgrade and downgrade); cache + // only when the WS row omits the field (legacy payloads). + capabilities: row.capabilities ?? current?.capabilities, ...enrichment, }; } @@ -171,9 +177,10 @@ function withEnrichmentAndConnectionId( /** * Replace the entire cache with the snapshot. Rows whose id is in both * the snapshot and the cache keep the three enrichment fields and any - * held attention status from the cache; every other field (including - * `connectionId`) comes from the snapshot. Rows absent from the snapshot - * are dropped. + * held attention status from the cache; `capabilities` comes from the + * snapshot when present and from the cache when omitted; every other + * field (including `connectionId`) comes from the snapshot. Rows absent + * from the snapshot are dropped. */ export function mergeSnapshotForActiveSessions( current: readonly CachedActiveSession[], From 0e2703447e311638078db3065e843b7c5a216fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 26 Jul 2026 17:41:05 +0200 Subject: [PATCH 08/11] fix(mobile): harden share ingest and CLI admission edge cases --- .../share/share-cli-admission.test.ts | 48 ++++++- .../components/share/share-cli-admission.ts | 21 +++ .../src/components/share/share-gate-sheet.tsx | 5 +- .../src/lib/share-payload.normalize.test.ts | 135 ++++++++++++++++++ apps/mobile/src/lib/share-payload.ts | 33 +++-- .../lib/share-to-new-remote-session.test.ts | 2 +- .../src/lib/share-to-new-remote-session.ts | 2 +- 7 files changed, 231 insertions(+), 15 deletions(-) create mode 100644 apps/mobile/src/lib/share-payload.normalize.test.ts diff --git a/apps/mobile/src/components/share/share-cli-admission.test.ts b/apps/mobile/src/components/share/share-cli-admission.test.ts index 3a809597ff..cb6f43a92d 100644 --- a/apps/mobile/src/components/share/share-cli-admission.test.ts +++ b/apps/mobile/src/components/share/share-cli-admission.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { resolveShareDestinationAdmission } from './share-cli-admission'; +import { resolveShareDestinationAdmission, resolveShareHasFiles } from './share-cli-admission'; +import { type SharePayloadValidation } from './share-payload-validation'; const NOT_CONNECTED_TITLE = 'Session not connected'; const NOT_CONNECTED_MESSAGE = @@ -10,6 +11,51 @@ const CANT_RECEIVE_FILES_TITLE = "This session can't receive files"; const CANT_RECEIVE_FILES_MESSAGE = "The Kilo CLI running this session can't receive files. Update the CLI on that machine, or share to a new session instead."; +describe('resolveShareHasFiles', () => { + it('pending validation uses the raw file count (0 and >0)', () => { + expect(resolveShareHasFiles(null, 0)).toBe(false); + expect(resolveShareHasFiles(null, 2)).toBe(true); + }); + + it('ok with accepted files is true', () => { + const validation: SharePayloadValidation = { + kind: 'ok', + accepted: [ + { + name: 'a.jpg', + uri: 'file:///a.jpg', + measuredSize: 1, + kind: 'image', + }, + ], + rejectedNotes: [], + truncated: false, + usable: true, + }; + expect(resolveShareHasFiles(validation, 1)).toBe(true); + }); + + it('ok with zero accepted is false even when raw count > 0', () => { + const validation: SharePayloadValidation = { + kind: 'ok', + accepted: [], + rejectedNotes: [{ name: 'bad.exe', reason: 'denied' }], + truncated: false, + usable: true, + }; + expect(resolveShareHasFiles(validation, 3)).toBe(false); + }); + + it('all-rejected is false', () => { + const validation: SharePayloadValidation = { + kind: 'all-rejected', + reason: 'denied', + message: 'None of the shared files can be attached.', + }; + expect(resolveShareHasFiles(validation, 2)).toBe(false); + }); +}); + describe('resolveShareDestinationAdmission', () => { it('passes non-cli platforms through untouched', () => { expect( diff --git a/apps/mobile/src/components/share/share-cli-admission.ts b/apps/mobile/src/components/share/share-cli-admission.ts index 3daff6cb47..e737905593 100644 --- a/apps/mobile/src/components/share/share-cli-admission.ts +++ b/apps/mobile/src/components/share/share-cli-admission.ts @@ -1,7 +1,28 @@ +import { type SharePayloadValidation } from './share-payload-validation'; + export type ShareDestinationAdmission = | { ok: true } | { ok: false; title: string; message: string }; +/** + * Whether the share still carries files the destination must accept. + * Uses the validated accepted set once classification finishes so rejected + * files do not block a text-only commit to an attachments-incapable CLI. + */ +export function resolveShareHasFiles( + validation: SharePayloadValidation | null, + rawFileCount: number +): boolean { + if (validation === null) { + return rawFileCount > 0; + } + if (validation.kind === 'ok') { + return validation.accepted.length > 0; + } + // all-rejected: gate is terminal no-list; harmless default. + return false; +} + /** * Decide whether a share payload may be committed to a destination row. * Non-CLI platforms pass through; CLI rows require a live session, and diff --git a/apps/mobile/src/components/share/share-gate-sheet.tsx b/apps/mobile/src/components/share/share-gate-sheet.tsx index 780b77ee76..f91625fca5 100644 --- a/apps/mobile/src/components/share/share-gate-sheet.tsx +++ b/apps/mobile/src/components/share/share-gate-sheet.tsx @@ -17,6 +17,7 @@ import { clearSharePayload, peekSharePayload, type ShareId } from '@/lib/share-p import { resolveShareDestinationAdmission, + resolveShareHasFiles, type ShareDestinationAdmission, } from './share-cli-admission'; import { selectShareDestinations, type ShareDestinationRow } from './share-destinations'; @@ -187,7 +188,7 @@ export function ShareGateSheet({ shareId }: Readonly) { createdOnPlatform: row.created_on_platform, live: row.live, attachmentsCapable: attachmentsCapableBySessionId.get(row.session_id) ?? false, - hasFiles: (payload?.files.length ?? 0) > 0, + hasFiles: resolveShareHasFiles(validation, payload?.files.length ?? 0), }); if (!admission.ok) { // Keep the gate open and the payload staged so the user can pick @@ -199,7 +200,7 @@ export function ShareGateSheet({ shareId }: Readonly) { const base = getAgentSessionPath(row.session_id, org) as string; commit(appendShareId(base, shareId)); }, - [attachmentsCapableBySessionId, commit, payload, shareId] + [attachmentsCapableBySessionId, commit, payload, shareId, validation] ); const handleRetry = useCallback(() => { diff --git a/apps/mobile/src/lib/share-payload.normalize.test.ts b/apps/mobile/src/lib/share-payload.normalize.test.ts new file mode 100644 index 0000000000..7a082556af --- /dev/null +++ b/apps/mobile/src/lib/share-payload.normalize.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { normalizeShareIntent } from './share-payload'; + +vi.mock('expo-crypto', () => { + let n = 0; + return { + randomUUID: () => { + n += 1; + return `id-${n}`; + }, + }; +}); + +vi.mock('expo-file-system/legacy', () => ({ + cacheDirectory: 'file:///cache/', + copyAsync: vi.fn(async () => { + await Promise.resolve(); + }), + deleteAsync: vi.fn(async () => { + await Promise.resolve(); + }), +})); + +describe('normalizeShareIntent partial copy failures', () => { + it('one-of-two copy failures keeps text and the successful file', async () => { + const payload = await normalizeShareIntent( + { + text: 'keep me', + webUrl: null, + meta: null, + files: [ + { + fileName: 'ok.jpg', + mimeType: 'image/jpeg', + path: 'file:///share/ok.jpg', + size: 1, + width: null, + height: null, + duration: null, + }, + { + fileName: 'bad.jpg', + mimeType: 'image/jpeg', + path: 'file:///share/bad.jpg', + size: 2, + width: null, + height: null, + duration: null, + }, + ], + }, + async ({ from, fileName }) => { + await Promise.resolve(); + if (from.includes('bad')) { + throw new Error('copy failed'); + } + return `file:///cache/copied-${fileName}`; + } + ); + + expect(payload.text).toBe('keep me'); + expect(payload.files).toEqual([ + { + name: 'ok.jpg', + uri: 'file:///cache/copied-ok.jpg', + mimeType: 'image/jpeg', + size: 1, + }, + ]); + }); + + it('all copy failures with text present return a text-only payload', async () => { + const payload = await normalizeShareIntent( + { + text: 'text survives', + webUrl: null, + meta: null, + files: [ + { + fileName: 'a.jpg', + mimeType: 'image/jpeg', + path: 'file:///share/a.jpg', + size: 1, + width: null, + height: null, + duration: null, + }, + { + fileName: 'b.jpg', + mimeType: 'image/jpeg', + path: 'file:///share/b.jpg', + size: 2, + width: null, + height: null, + duration: null, + }, + ], + }, + async () => { + await Promise.resolve(); + throw new Error('copy failed'); + } + ); + + expect(payload).toEqual({ text: 'text survives', files: [] }); + }); + + it('all copy failures without text throw', async () => { + await expect( + normalizeShareIntent( + { + text: null, + webUrl: null, + meta: null, + files: [ + { + fileName: 'a.jpg', + mimeType: 'image/jpeg', + path: 'file:///share/a.jpg', + size: 1, + width: null, + height: null, + duration: null, + }, + ], + }, + async () => { + await Promise.resolve(); + throw new Error('copy failed'); + } + ) + ).rejects.toThrow('Failed to copy shared files'); + }); +}); diff --git a/apps/mobile/src/lib/share-payload.ts b/apps/mobile/src/lib/share-payload.ts index dd9a9168b9..090dbfcb70 100644 --- a/apps/mobile/src/lib/share-payload.ts +++ b/apps/mobile/src/lib/share-payload.ts @@ -143,20 +143,33 @@ export async function normalizeShareIntent( copyToCache: CopyToCache = defaultCopyToCache ): Promise { const text = composeShareText(shareIntent); - const files = await Promise.all( - (shareIntent.files ?? []).map(async file => { + const incomingFiles = shareIntent.files ?? []; + const copied = await Promise.all( + incomingFiles.map(async file => { const name = file.fileName || 'shared-file'; - const uri = await copyToCache({ from: file.path, fileName: name }); - const candidate: AgentAttachmentCandidate = { name, uri }; - if (file.mimeType) { - candidate.mimeType = file.mimeType; + try { + const uri = await copyToCache({ from: file.path, fileName: name }); + const candidate: AgentAttachmentCandidate = { name, uri }; + if (file.mimeType) { + candidate.mimeType = file.mimeType; + } + if (file.size != null) { + candidate.size = file.size; + } + return candidate; + } catch { + // Drop only this file; text and other successful copies still count. + return null; } - if (file.size != null) { - candidate.size = file.size; - } - return candidate; }) ); + const files = copied.filter((file): file is AgentAttachmentCandidate => file !== null); + + // Throw only when nothing usable remains: no text and zero successful copies + // while at least one file was attempted. + if (text === '' && files.length === 0 && incomingFiles.length > 0) { + throw new Error('Failed to copy shared files'); + } return { text, files }; } diff --git a/apps/mobile/src/lib/share-to-new-remote-session.test.ts b/apps/mobile/src/lib/share-to-new-remote-session.test.ts index a3cbdb5864..d0e48faedc 100644 --- a/apps/mobile/src/lib/share-to-new-remote-session.test.ts +++ b/apps/mobile/src/lib/share-to-new-remote-session.test.ts @@ -46,7 +46,7 @@ describe('share-to-new-remote-session copy', () => { expect(SHARE_TO_NEW_REMOTE_SESSION_ALERT).toEqual({ title: "Can't share to a new remote session", message: - "A session started on a remote CLI can't receive shared text or files. Start a cloud session, or go back and pick the running CLI session from the share list.", + "A session started on a remote CLI can't receive shared text or files. Start a cloud session, or share again and pick the running CLI session.", }); }); diff --git a/apps/mobile/src/lib/share-to-new-remote-session.ts b/apps/mobile/src/lib/share-to-new-remote-session.ts index 21b26280f9..b1347bdeb0 100644 --- a/apps/mobile/src/lib/share-to-new-remote-session.ts +++ b/apps/mobile/src/lib/share-to-new-remote-session.ts @@ -2,7 +2,7 @@ export const SHARE_TO_NEW_REMOTE_SESSION_ALERT = { title: "Can't share to a new remote session", message: - "A session started on a remote CLI can't receive shared text or files. Start a cloud session, or go back and pick the running CLI session from the share list.", + "A session started on a remote CLI can't receive shared text or files. Start a cloud session, or share again and pick the running CLI session.", } as const; /** From 8f9b631797902c287d63fdbd014401971723789c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 26 Jul 2026 18:58:47 +0200 Subject: [PATCH 09/11] fix(mobile): surface share ingest failures and release superseded shares --- apps/mobile/src/app/_layout.tsx | 17 +++++- .../components/share/share-gate-state.test.ts | 2 +- .../share/share-payload-preview.tsx | 3 +- .../share/share-payload-validation.test.ts | 58 +++++++++++++++++++ .../share/share-payload-validation.ts | 15 ++++- .../lib/agent-attachments/validate.test.ts | 1 + .../src/lib/agent-attachments/validate.ts | 7 ++- .../src/lib/pending-share-navigation.test.ts | 26 ++++++++- .../src/lib/pending-share-navigation.ts | 15 +++++ .../src/lib/share-payload.normalize.test.ts | 9 ++- apps/mobile/src/lib/share-payload.test.ts | 23 +++++--- apps/mobile/src/lib/share-payload.ts | 21 +++++-- apps/mobile/src/lib/share-prefill.test.ts | 10 ++-- 13 files changed, 177 insertions(+), 30 deletions(-) diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 2c6e0df16e..cc812c2f4e 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -49,8 +49,10 @@ import { resolvePendingNotificationNavigation } from '@/lib/pending-notification import { isShellReadyForShare, resolvePendingShareNavigation, + resolveSupersededPendingShareId, } from '@/lib/pending-share-navigation'; import { + clearSharePayload, normalizeShareIntent, putSharePayload, type ShareId, @@ -148,10 +150,14 @@ function RootLayoutNav() { error: shareIntentError, } = useShareIntentContext(); // expo-share-intent rebuilds resetShareIntent every render; keep it out of - // the ingest effect deps via ref (same pattern as share-prefill.ts). + // the ingest/error effect deps via ref (same pattern as share-prefill.ts). const resetShareIntentRef = useRef(resetShareIntent); resetShareIntentRef.current = resetShareIntent; const [pendingShareId, setPendingShareId] = useState(null); + // Mirror pendingShareId so the ingest effect can release a superseded share + // without reading stale state or adding the id to effect deps. + const pendingShareIdRef = useRef(pendingShareId); + pendingShareIdRef.current = pendingShareId; // Paired with isShellReadyForShare — keep the success-tail guards in lockstep. const isShellReady = isShellReadyForShare({ @@ -230,9 +236,9 @@ function RootLayoutNav() { if (shareIntentError) { Sentry.captureException(new Error(shareIntentError)); toast.error("Couldn't read the shared content"); - resetShareIntent(); + resetShareIntentRef.current(); } - }, [shareIntentError, resetShareIntent]); + }, [shareIntentError]); // Keyed per shareIntent identity so a newer intent cancels and supersedes // an in-flight ingest. Success/failure reset for the happy path lives here @@ -254,6 +260,11 @@ function RootLayoutNav() { } const shareId = putSharePayload(payload); resetShareIntentRef.current(); + // Latest-wins: a superseded pending share is released — never silently orphaned. + const superseded = resolveSupersededPendingShareId(pendingShareIdRef.current, shareId); + if (superseded !== null) { + clearSharePayload(superseded); + } setPendingShareId(shareId); } catch (error) { if (cancelled) { diff --git a/apps/mobile/src/components/share/share-gate-state.test.ts b/apps/mobile/src/components/share/share-gate-state.test.ts index 9ab704df66..3d2a132b23 100644 --- a/apps/mobile/src/components/share/share-gate-state.test.ts +++ b/apps/mobile/src/components/share/share-gate-state.test.ts @@ -9,7 +9,7 @@ import { } from './share-gate-state'; import { type SharePayloadValidation } from './share-payload-validation'; -const payload: SharePayload = { text: 'hello', files: [] }; +const payload: SharePayload = { text: 'hello', files: [], failedFiles: [] }; const okValidation: SharePayloadValidation = { kind: 'ok', diff --git a/apps/mobile/src/components/share/share-payload-preview.tsx b/apps/mobile/src/components/share/share-payload-preview.tsx index 07469f1e80..1600aa0085 100644 --- a/apps/mobile/src/components/share/share-payload-preview.tsx +++ b/apps/mobile/src/components/share/share-payload-preview.tsx @@ -5,6 +5,7 @@ import { ScrollView, View } from 'react-native'; import { Image } from '@/components/ui/image'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; +import { AGENT_ATTACHMENT_MAX_FILES } from '@/lib/agent-attachments/constants'; import { describeClassificationFailure } from '@/lib/agent-attachments/validate'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { type SharePayload } from '@/lib/share-payload'; @@ -119,7 +120,7 @@ export function SharePayloadPreview({ payload, validation }: Readonly - Only the first 5 files will be attached. + Only the first {AGENT_ATTACHMENT_MAX_FILES} files will be attached. ) : null} diff --git a/apps/mobile/src/components/share/share-payload-validation.test.ts b/apps/mobile/src/components/share/share-payload-validation.test.ts index 356e32b30f..9c03c1805d 100644 --- a/apps/mobile/src/components/share/share-payload-validation.test.ts +++ b/apps/mobile/src/components/share/share-payload-validation.test.ts @@ -155,4 +155,62 @@ describe('validateMeasuredShareFiles', () => { expect(result.message).toBe(describeClassificationFailure('denied')); } }); + + it('appends unreadable notes after classification notes', () => { + const result = validateMeasuredShareFiles({ + text: 'caption', + files: [file('good.png', 100), file('bad.exe', 10)], + failedCopies: ['lost.jpg', 'gone.png'], + }); + expect(result.kind).toBe('ok'); + if (result.kind === 'ok') { + expect(result.accepted.map(f => f.name)).toEqual(['good.png']); + expect(result.rejectedNotes).toEqual([ + { name: 'bad.exe', reason: 'denied' }, + { name: 'lost.jpg', reason: 'unreadable' }, + { name: 'gone.png', reason: 'unreadable' }, + ]); + expect(result.usable).toBe(true); + } + }); + + it('text + all-copies-failed is ok/usable with unreadable notes (not clean text-only)', () => { + const result = validateMeasuredShareFiles({ + text: 'caption only', + files: [], + failedCopies: ['a.jpg', 'b.jpg'], + }); + expect(result.kind).toBe('ok'); + if (result.kind === 'ok') { + expect(result.usable).toBe(true); + expect(result.accepted).toHaveLength(0); + expect(result.rejectedNotes).toEqual([ + { name: 'a.jpg', reason: 'unreadable' }, + { name: 'b.jpg', reason: 'unreadable' }, + ]); + } + + const cleanTextOnly = validateMeasuredShareFiles({ + text: 'caption only', + files: [], + }); + expect(cleanTextOnly.kind).toBe('ok'); + if (cleanTextOnly.kind === 'ok') { + expect(cleanTextOnly.rejectedNotes).toEqual([]); + expect(result).not.toEqual(cleanTextOnly); + } + }); + + it('failed copies alone without text remain all-rejected and do not invent usable', () => { + const result = validateMeasuredShareFiles({ + text: '', + files: [], + failedCopies: ['a.jpg'], + }); + expect(result.kind).toBe('all-rejected'); + if (result.kind === 'all-rejected') { + expect(result.reason).toBe('unreadable'); + expect(result.message).toBe(describeClassificationFailure('unreadable')); + } + }); }); diff --git a/apps/mobile/src/components/share/share-payload-validation.ts b/apps/mobile/src/components/share/share-payload-validation.ts index 37141a6dbf..f53e4bda75 100644 --- a/apps/mobile/src/components/share/share-payload-validation.ts +++ b/apps/mobile/src/components/share/share-payload-validation.ts @@ -5,7 +5,7 @@ import { } from '@/lib/agent-attachments/validate'; import { type SharePayload } from '@/lib/share-payload'; -export type ClassificationReason = 'empty' | 'denied' | 'too-large'; +export type ClassificationReason = 'empty' | 'denied' | 'too-large' | 'unreadable'; export type RejectedNote = { name: string; @@ -55,6 +55,7 @@ type MeasuredFileInput = { export function validateMeasuredShareFiles(input: { text: string; files: readonly MeasuredFileInput[]; + failedCopies?: readonly string[]; }): SharePayloadValidation { const rejectedNotes: RejectedNote[] = []; const classifiedAccepted: AcceptedShareFile[] = []; @@ -78,6 +79,12 @@ export function validateMeasuredShareFiles(input: { } } + // Failed cache copies are not classified files; surface them after + // classification notes and never count them toward usable/accepted. + for (const name of input.failedCopies ?? []) { + rejectedNotes.push({ name, reason: 'unreadable' }); + } + const hasUsableText = input.text.trim() !== ''; if (classifiedAccepted.length === 0 && !hasUsableText) { @@ -136,7 +143,11 @@ export async function validateSharePayload( }) ); - return validateMeasuredShareFiles({ text: payload.text, files: measured }); + return validateMeasuredShareFiles({ + text: payload.text, + files: measured, + failedCopies: payload.failedFiles, + }); } async function loadDefaultMeasure(): Promise { diff --git a/apps/mobile/src/lib/agent-attachments/validate.test.ts b/apps/mobile/src/lib/agent-attachments/validate.test.ts index b3022cb4a6..575c57c21f 100644 --- a/apps/mobile/src/lib/agent-attachments/validate.test.ts +++ b/apps/mobile/src/lib/agent-attachments/validate.test.ts @@ -147,6 +147,7 @@ describe('describeClassificationFailure', () => { expect(describeClassificationFailure('denied')).toMatch(/can't be attached/i); expect(describeClassificationFailure('empty')).toMatch(/empty/i); expect(describeClassificationFailure('too-large')).toMatch(/5 MB/); + expect(describeClassificationFailure('unreadable')).toBe("Couldn't read this file"); }); }); diff --git a/apps/mobile/src/lib/agent-attachments/validate.ts b/apps/mobile/src/lib/agent-attachments/validate.ts index 28191fc686..9661c84461 100644 --- a/apps/mobile/src/lib/agent-attachments/validate.ts +++ b/apps/mobile/src/lib/agent-attachments/validate.ts @@ -106,12 +106,15 @@ const CLASSIFICATION_FAILURE_MESSAGES = { denied: "Executable files can't be attached", empty: 'File is empty', 'too-large': 'Files must be 5 MB or smaller', -} as const satisfies Record<'denied' | 'empty' | 'too-large', string>; + unreadable: "Couldn't read this file", +} as const satisfies Record<'denied' | 'empty' | 'too-large' | 'unreadable', string>; /** * Human-readable copy for a single classification outcome. Centralized so * the picker, the upload hook, and the chip surface use the same strings. */ -export function describeClassificationFailure(reason: 'denied' | 'empty' | 'too-large'): string { +export function describeClassificationFailure( + reason: 'denied' | 'empty' | 'too-large' | 'unreadable' +): string { return CLASSIFICATION_FAILURE_MESSAGES[reason]; } diff --git a/apps/mobile/src/lib/pending-share-navigation.test.ts b/apps/mobile/src/lib/pending-share-navigation.test.ts index 61b88d7191..ed608886c4 100644 --- a/apps/mobile/src/lib/pending-share-navigation.test.ts +++ b/apps/mobile/src/lib/pending-share-navigation.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { isShellReadyForShare, resolvePendingShareNavigation } from './pending-share-navigation'; +import { + isShellReadyForShare, + resolvePendingShareNavigation, + resolveSupersededPendingShareId, +} from './pending-share-navigation'; const ready = { hasToken: true, @@ -107,3 +111,23 @@ describe('resolvePendingShareNavigation', () => { expect(result?.href).toContain('shareId=share-42'); }); }); + +describe('resolveSupersededPendingShareId', () => { + // 2×2: current null|set × next same|different + it('returns null when the slot is empty', () => { + expect(resolveSupersededPendingShareId(null, 'next')).toBeNull(); + }); + + it('returns null when current equals next', () => { + expect(resolveSupersededPendingShareId('same', 'same')).toBeNull(); + }); + + it('returns current when it differs from next', () => { + expect(resolveSupersededPendingShareId('old', 'new')).toBe('old'); + }); + + it('returns the prior id for any distinct pair', () => { + expect(resolveSupersededPendingShareId('a', 'b')).toBe('a'); + expect(resolveSupersededPendingShareId('b', 'a')).toBe('b'); + }); +}); diff --git a/apps/mobile/src/lib/pending-share-navigation.ts b/apps/mobile/src/lib/pending-share-navigation.ts index f0b27afeac..f4548b494f 100644 --- a/apps/mobile/src/lib/pending-share-navigation.ts +++ b/apps/mobile/src/lib/pending-share-navigation.ts @@ -45,3 +45,18 @@ export function resolvePendingShareNavigation(input: { mode: input.onGateRoute ? 'replace' : 'push', }; } + +/** + * Latest-wins pending slot: when a newer share id replaces a different + * pending id, return the superseded id so callers can release its payload + * and cache copies. Same id or empty slot → null (nothing to release). + */ +export function resolveSupersededPendingShareId( + current: ShareId | null, + next: ShareId +): ShareId | null { + if (current !== null && current !== next) { + return current; + } + return null; +} diff --git a/apps/mobile/src/lib/share-payload.normalize.test.ts b/apps/mobile/src/lib/share-payload.normalize.test.ts index 7a082556af..4be6ce7e39 100644 --- a/apps/mobile/src/lib/share-payload.normalize.test.ts +++ b/apps/mobile/src/lib/share-payload.normalize.test.ts @@ -68,9 +68,10 @@ describe('normalizeShareIntent partial copy failures', () => { size: 1, }, ]); + expect(payload.failedFiles).toEqual(['bad.jpg']); }); - it('all copy failures with text present return a text-only payload', async () => { + it('all copy failures with text present return a text-only payload with failed names', async () => { const payload = await normalizeShareIntent( { text: 'text survives', @@ -103,7 +104,11 @@ describe('normalizeShareIntent partial copy failures', () => { } ); - expect(payload).toEqual({ text: 'text survives', files: [] }); + expect(payload).toEqual({ + text: 'text survives', + files: [], + failedFiles: ['a.jpg', 'b.jpg'], + }); }); it('all copy failures without text throw', async () => { diff --git a/apps/mobile/src/lib/share-payload.test.ts b/apps/mobile/src/lib/share-payload.test.ts index 3706581054..f5266cd0f2 100644 --- a/apps/mobile/src/lib/share-payload.test.ts +++ b/apps/mobile/src/lib/share-payload.test.ts @@ -132,30 +132,30 @@ describe('share payload store', () => { }); it('put returns unique ids', () => { - const a = putSharePayload({ text: 'a', files: [] }); - const b = putSharePayload({ text: 'b', files: [] }); + const a = putSharePayload({ text: 'a', files: [], failedFiles: [] }); + const b = putSharePayload({ text: 'b', files: [], failedFiles: [] }); expect(a).not.toBe(b); expect(peekSharePayload(a)?.text).toBe('a'); expect(peekSharePayload(b)?.text).toBe('b'); }); it('take is read-and-clear and returns null on second read or unknown id', () => { - const id = putSharePayload({ text: 'once', files: [] }); - expect(takeSharePayload(id)).toEqual({ text: 'once', files: [] }); + const id = putSharePayload({ text: 'once', files: [], failedFiles: [] }); + expect(takeSharePayload(id)).toEqual({ text: 'once', files: [], failedFiles: [] }); expect(takeSharePayload(id)).toBeNull(); expect(takeSharePayload('missing')).toBeNull(); }); it('peek does not consume', () => { - const id = putSharePayload({ text: 'peek', files: [] }); + const id = putSharePayload({ text: 'peek', files: [], failedFiles: [] }); expect(peekSharePayload(id)?.text).toBe('peek'); expect(peekSharePayload(id)?.text).toBe('peek'); expect(takeSharePayload(id)?.text).toBe('peek'); }); it('clear is id-scoped', () => { - const a = putSharePayload({ text: 'a', files: [] }); - const b = putSharePayload({ text: 'b', files: [] }); + const a = putSharePayload({ text: 'a', files: [], failedFiles: [] }); + const b = putSharePayload({ text: 'b', files: [], failedFiles: [] }); clearSharePayload(a); expect(peekSharePayload(a)).toBeNull(); expect(peekSharePayload(b)?.text).toBe('b'); @@ -164,7 +164,7 @@ describe('share payload store', () => { it('evicts oldest first beyond the cap', () => { const ids: string[] = []; for (let i = 0; i < SHARE_PAYLOAD_MAX_ENTRIES + 2; i += 1) { - ids.push(putSharePayload({ text: `t-${i}`, files: [] })); + ids.push(putSharePayload({ text: `t-${i}`, files: [], failedFiles: [] })); } const first = ids[0]; const second = ids[1]; @@ -188,6 +188,7 @@ describe('share payload store', () => { { name: 'a.jpg', uri: 'file:///cache/share-a.jpg' }, { name: 'b.png', uri: 'file:///cache/share-b.png' }, ], + failedFiles: [], }); clearSharePayload(id); await vi.waitFor(() => { @@ -201,9 +202,10 @@ describe('share payload store', () => { putSharePayload({ text: 'oldest', files: [{ name: 'old.txt', uri: 'file:///cache/share-old.txt' }], + failedFiles: [], }); for (let i = 0; i < SHARE_PAYLOAD_MAX_ENTRIES; i += 1) { - putSharePayload({ text: `keep-${i}`, files: [] }); + putSharePayload({ text: `keep-${i}`, files: [], failedFiles: [] }); } await vi.waitFor(() => { expect(deleted).toEqual(['file:///cache/share-old.txt']); @@ -216,6 +218,7 @@ describe('share payload store', () => { const id = putSharePayload({ text: 'take-me', files: [{ name: 'kept.bin', uri: 'file:///cache/share-kept.bin' }], + failedFiles: [], }); expect(takeSharePayload(id)?.files[0]?.uri).toBe('file:///cache/share-kept.bin'); await Promise.resolve(); @@ -228,6 +231,7 @@ describe('share payload store', () => { const id = putSharePayload({ text: 'taken-then-cleared', files: [{ name: 'upload-me.bin', uri: 'file:///cache/share-upload-me.bin' }], + failedFiles: [], }); expect(takeSharePayload(id)?.files[0]?.uri).toBe('file:///cache/share-upload-me.bin'); expect(peekSharePayload(id)).toBeNull(); @@ -274,6 +278,7 @@ describe('normalizeShareIntent', () => { size: 12, }, ]); + expect(payload.failedFiles).toEqual([]); const file = payload.files[0]; expect(file).toBeDefined(); expect(file?.uri).not.toBe(incoming); diff --git a/apps/mobile/src/lib/share-payload.ts b/apps/mobile/src/lib/share-payload.ts index 090dbfcb70..cb14a7dca8 100644 --- a/apps/mobile/src/lib/share-payload.ts +++ b/apps/mobile/src/lib/share-payload.ts @@ -9,6 +9,8 @@ export type ShareId = string; export type SharePayload = { text: string; files: AgentAttachmentCandidate[]; + /** Names of incoming files whose cache copy threw; empty when none failed. */ + failedFiles: string[]; }; /** Mirrors PROMPT_INPUT_MAX_CHARS in new-session-prompt.tsx (module-local; composer clamps again). */ @@ -144,7 +146,7 @@ export async function normalizeShareIntent( ): Promise { const text = composeShareText(shareIntent); const incomingFiles = shareIntent.files ?? []; - const copied = await Promise.all( + const outcomes = await Promise.all( incomingFiles.map(async file => { const name = file.fileName || 'shared-file'; try { @@ -156,14 +158,23 @@ export async function normalizeShareIntent( if (file.size != null) { candidate.size = file.size; } - return candidate; + return { ok: true as const, candidate }; } catch { // Drop only this file; text and other successful copies still count. - return null; + // Name is kept so the gate preview can surface the silent loss. + return { ok: false as const, name }; } }) ); - const files = copied.filter((file): file is AgentAttachmentCandidate => file !== null); + const files: AgentAttachmentCandidate[] = []; + const failedFiles: string[] = []; + for (const outcome of outcomes) { + if (outcome.ok) { + files.push(outcome.candidate); + } else { + failedFiles.push(outcome.name); + } + } // Throw only when nothing usable remains: no text and zero successful copies // while at least one file was attempted. @@ -171,5 +182,5 @@ export async function normalizeShareIntent( throw new Error('Failed to copy shared files'); } - return { text, files }; + return { text, files, failedFiles }; } diff --git a/apps/mobile/src/lib/share-prefill.test.ts b/apps/mobile/src/lib/share-prefill.test.ts index 1af26f7062..26bec099fa 100644 --- a/apps/mobile/src/lib/share-prefill.test.ts +++ b/apps/mobile/src/lib/share-prefill.test.ts @@ -140,6 +140,7 @@ describe('applySharePrefill', () => { const id = putSharePayload({ text: 'shared body', files: [{ name: 'a.png', uri: 'file:///a.png' }], + failedFiles: [], }); const { input, calls: nativeCalls } = makeInput(); const order: string[] = []; @@ -191,6 +192,7 @@ describe('applySharePrefill', () => { const id = putSharePayload({ text: 'keep me', files: [{ name: 'bad.png', uri: 'file:///bad.png' }], + failedFiles: [], }); const { input, calls: nativeCalls } = makeInput(); const { onChangeText, calls: changeCalls } = makeChange(); @@ -230,7 +232,7 @@ describe('applySharePrefill', () => { }); it('clears the route param after a successful prefill', async () => { - const id = putSharePayload({ text: 'ok', files: [] }); + const id = putSharePayload({ text: 'ok', files: [], failedFiles: [] }); const clearCalls: number[] = []; await applySharePrefill({ @@ -249,8 +251,8 @@ describe('applySharePrefill', () => { }); it('re-applies when a second share arrives with a new id', async () => { - const firstId = putSharePayload({ text: 'first', files: [] }); - const secondId = putSharePayload({ text: 'second', files: [] }); + const firstId = putSharePayload({ text: 'first', files: [], failedFiles: [] }); + const secondId = putSharePayload({ text: 'second', files: [], failedFiles: [] }); const { onChangeText, calls: changeCalls } = makeChange(); await applySharePrefill({ @@ -275,7 +277,7 @@ describe('applySharePrefill', () => { it('skips the text call for empty text + files-only payloads', async () => { const files = [{ name: 'only.png', uri: 'file:///only.png' }]; - const id = putSharePayload({ text: '', files }); + const id = putSharePayload({ text: '', files, failedFiles: [] }); const { input, calls: nativeCalls } = makeInput(); const { onChangeText, calls: changeCalls } = makeChange(); const addCandidatesCalls: AgentAttachmentCandidate[][] = []; From ca833fee45bef79abaa9682b1e6a58f57d6f7d18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 26 Jul 2026 19:21:52 +0200 Subject: [PATCH 10/11] fix(mobile): clean cache copies when a share is superseded mid-ingest --- apps/mobile/src/app/_layout.tsx | 3 +++ apps/mobile/src/lib/share-payload.test.ts | 29 +++++++++++++++++++++++ apps/mobile/src/lib/share-payload.ts | 5 ++++ 3 files changed, 37 insertions(+) diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index cc812c2f4e..2598e63ce4 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -53,6 +53,7 @@ import { } from '@/lib/pending-share-navigation'; import { clearSharePayload, + discardUnstoredSharePayload, normalizeShareIntent, putSharePayload, type ShareId, @@ -256,6 +257,8 @@ function RootLayoutNav() { try { const payload: SharePayload = await normalizeShareIntent(shareIntent); if (cancelled) { + // Superseded mid-copy: never stored, so no lifecycle path can clean it. + discardUnstoredSharePayload(payload); return; } const shareId = putSharePayload(payload); diff --git a/apps/mobile/src/lib/share-payload.test.ts b/apps/mobile/src/lib/share-payload.test.ts index f5266cd0f2..06a266aaab 100644 --- a/apps/mobile/src/lib/share-payload.test.ts +++ b/apps/mobile/src/lib/share-payload.test.ts @@ -5,6 +5,7 @@ import { __setDeleteCachedFileForTests, clearSharePayload, composeShareText, + discardUnstoredSharePayload, normalizeShareIntent, peekSharePayload, putSharePayload, @@ -241,6 +242,34 @@ describe('share payload store', () => { expect(deleted).toEqual([]); }); }); + + it('discardUnstoredSharePayload deletes copied file uris', async () => { + await withDeleteTracking(async deleted => { + discardUnstoredSharePayload({ + text: 'never-stored', + files: [ + { name: 'a.jpg', uri: 'file:///cache/share-a.jpg' }, + { name: 'b.png', uri: 'file:///cache/share-b.png' }, + ], + failedFiles: ['lost.pdf'], + }); + await vi.waitFor(() => { + expect(deleted).toEqual(['file:///cache/share-a.jpg', 'file:///cache/share-b.png']); + }); + }); + }); + + it('discardUnstoredSharePayload is a no-op for empty files', async () => { + await withDeleteTracking(async deleted => { + discardUnstoredSharePayload({ + text: 'text-only', + files: [], + failedFiles: ['lost.pdf'], + }); + await Promise.resolve(); + expect(deleted).toEqual([]); + }); + }); }); describe('normalizeShareIntent', () => { diff --git a/apps/mobile/src/lib/share-payload.ts b/apps/mobile/src/lib/share-payload.ts index cb14a7dca8..595d4ad490 100644 --- a/apps/mobile/src/lib/share-payload.ts +++ b/apps/mobile/src/lib/share-payload.ts @@ -44,6 +44,11 @@ function discardPayloadCacheFiles(payload: SharePayload): void { } } +/** Best-effort delete of copies for a payload that never entered the store. */ +export function discardUnstoredSharePayload(payload: SharePayload): void { + discardPayloadCacheFiles(payload); +} + function evictOldestIfNeeded(): void { while (payloads.size > SHARE_PAYLOAD_MAX_ENTRIES && insertionOrder.length > 0) { const oldest = insertionOrder.shift(); From 6ac79b381889bbe77018308cd488d0523e1a3cfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 26 Jul 2026 20:26:06 +0200 Subject: [PATCH 11/11] fix(mobile): deliver shares to the committed org, queue committed destinations, keep committed payloads on dismiss --- .../src/components/share/share-gate-sheet.tsx | 3 +- .../share/share-payload-navigator.tsx | 10 ++- apps/mobile/src/lib/share-navigation.test.ts | 70 ++++++++++++++++++- apps/mobile/src/lib/share-navigation.ts | 40 ++++++++--- 4 files changed, 109 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/components/share/share-gate-sheet.tsx b/apps/mobile/src/components/share/share-gate-sheet.tsx index f91625fca5..aeaf3376a7 100644 --- a/apps/mobile/src/components/share/share-gate-sheet.tsx +++ b/apps/mobile/src/components/share/share-gate-sheet.tsx @@ -135,7 +135,8 @@ export function ShareGateSheet({ shareId }: Readonly) { const abandon = useCallback(() => { const id = ownedShareIdRef.current; - if (id) { + // Committed ids survive every gate-side clear; delivery owns consumption. + if (id && id !== committedShareIdRef.current) { clearSharePayload(id); } // Never resetShareIntent here — a newly arriving intent is the layout diff --git a/apps/mobile/src/components/share/share-payload-navigator.tsx b/apps/mobile/src/components/share/share-payload-navigator.tsx index 2c44474107..a55779b63a 100644 --- a/apps/mobile/src/components/share/share-payload-navigator.tsx +++ b/apps/mobile/src/components/share/share-payload-navigator.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react'; import { isShareNavigationTargetFocused, navigationContainsShareGate, + parseShareHrefParams, type PendingShareNavigation, takePendingShareNavigation, } from '@/lib/share-navigation'; @@ -12,7 +13,7 @@ import { * Invisible mount: when a pending share navigation exists and the gate route * is absent from the navigation state, take it and route to the destination. * - Target not focused → router.push(href) - * - Target already focused → router.setParams({ shareId }) only + * - Target already focused → router.setParams({ shareId, organizationId }) only * Never cross-presentation replace; back stack stays intact. */ export function SharePayloadNavigator(): null { @@ -45,7 +46,12 @@ function deliver( ): void { const focused = isShareNavigationTargetFocused(pending.href, pathname); if (focused) { - router.setParams({ shareId: pending.shareId }); + // Committed href's org is the destination identity; path-only focus is + // about the screen, not its params. undefined clears a stale org param. + router.setParams({ + shareId: pending.shareId, + organizationId: parseShareHrefParams(pending.href).organizationId, + }); return; } router.push(pending.href as Href); diff --git a/apps/mobile/src/lib/share-navigation.test.ts b/apps/mobile/src/lib/share-navigation.test.ts index f90b9c4792..f9058ca3fd 100644 --- a/apps/mobile/src/lib/share-navigation.test.ts +++ b/apps/mobile/src/lib/share-navigation.test.ts @@ -1,13 +1,30 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { SHARE_PAYLOAD_MAX_ENTRIES } from './share-payload'; import { __resetPendingShareNavigationForTests, isShareNavigationTargetFocused, navigationContainsShareGate, + parseShareHrefParams, setPendingShareNavigation, takePendingShareNavigation, } from './share-navigation'; +// share-navigation imports SHARE_PAYLOAD_MAX_ENTRIES from share-payload, which +// pulls expo modules that transitively load react-native Flow sources. +vi.mock('expo-crypto', () => ({ + randomUUID: () => 'id-test', +})); +vi.mock('expo-file-system/legacy', () => ({ + cacheDirectory: 'file:///cache/', + copyAsync: vi.fn(async () => { + await Promise.resolve(); + }), + deleteAsync: vi.fn(async () => { + await Promise.resolve(); + }), +})); + afterEach(() => { __resetPendingShareNavigationForTests(); }); @@ -27,16 +44,65 @@ describe('share-navigation', () => { expect(takePendingShareNavigation()).toBeNull(); }); - it('overwrite before take is last-write-wins', () => { + it('enqueues FIFO: take returns oldest first', () => { setPendingShareNavigation({ href: '/first', shareId: 'first' }); setPendingShareNavigation({ href: '/second', shareId: 'second' }); + expect(takePendingShareNavigation()).toEqual({ href: '/first', shareId: 'first' }); expect(takePendingShareNavigation()).toEqual({ href: '/second', shareId: 'second' }); expect(takePendingShareNavigation()).toBeNull(); }); + it('drops oldest when enqueue would exceed SHARE_PAYLOAD_MAX_ENTRIES', () => { + for (let i = 0; i < SHARE_PAYLOAD_MAX_ENTRIES + 2; i += 1) { + setPendingShareNavigation({ href: `/${i}`, shareId: `id-${i}` }); + } + const taken: string[] = []; + let next = takePendingShareNavigation(); + while (next) { + taken.push(next.shareId); + next = takePendingShareNavigation(); + } + expect(taken).toHaveLength(SHARE_PAYLOAD_MAX_ENTRIES); + expect(taken.at(0)).toBe('id-2'); + expect(taken.at(-1)).toBe(`id-${SHARE_PAYLOAD_MAX_ENTRIES + 1}`); + }); + it('take with nothing pending returns null', () => { expect(takePendingShareNavigation()).toBeNull(); }); + + it('reset clears the full queue', () => { + setPendingShareNavigation({ href: '/a', shareId: 'a' }); + setPendingShareNavigation({ href: '/b', shareId: 'b' }); + __resetPendingShareNavigationForTests(); + expect(takePendingShareNavigation()).toBeNull(); + }); +}); + +describe('parseShareHrefParams', () => { + it('extracts organizationId from a full new-session href', () => { + expect(parseShareHrefParams('/(app)/agent-chat/new?shareId=abc&organizationId=org_1')).toEqual({ + organizationId: 'org_1', + }); + }); + + it('returns undefined organizationId when absent', () => { + expect(parseShareHrefParams('/(app)/agent-chat/new?shareId=abc')).toEqual({ + organizationId: undefined, + }); + }); + + it('returns undefined organizationId when query is absent', () => { + expect(parseShareHrefParams('/(app)/agent-chat/new')).toEqual({ + organizationId: undefined, + }); + }); + + it('extracts organizationId from an existing-session href the same way', () => { + expect(parseShareHrefParams('/(app)/agent-chat/ses_1?shareId=x&organizationId=org_2')).toEqual({ + organizationId: 'org_2', + }); + }); }); describe('isShareNavigationTargetFocused', () => { diff --git a/apps/mobile/src/lib/share-navigation.ts b/apps/mobile/src/lib/share-navigation.ts index 2ff63d8b79..db335ecac4 100644 --- a/apps/mobile/src/lib/share-navigation.ts +++ b/apps/mobile/src/lib/share-navigation.ts @@ -1,18 +1,40 @@ -import { type ShareId } from '@/lib/share-payload'; +import { SHARE_PAYLOAD_MAX_ENTRIES, type ShareId } from '@/lib/share-payload'; export type PendingShareNavigation = { href: string; shareId: ShareId }; -let pending: PendingShareNavigation | null = null; +/** FIFO of committed share destinations waiting for gate dismiss + delivery. */ +const pendingQueue: PendingShareNavigation[] = []; export function setPendingShareNavigation(next: PendingShareNavigation): void { - pending = next; + pendingQueue.push(next); + // Store evicts oldest beyond the same cap; keep navigation queue in lockstep. + while (pendingQueue.length > SHARE_PAYLOAD_MAX_ENTRIES) { + pendingQueue.shift(); + } } -/** Read-and-clear. */ +/** Read-and-remove the oldest pending navigation, or null when empty. */ export function takePendingShareNavigation(): PendingShareNavigation | null { - const current = pending; - pending = null; - return current; + return pendingQueue.shift() ?? null; +} + +/** Parse the destination params a focused delivery must set from a pending href. */ +export function parseShareHrefParams(href: string): { organizationId: string | undefined } { + const queryStart = href.indexOf('?'); + if (queryStart === -1) { + return { organizationId: undefined }; + } + const query = href.slice(queryStart + 1); + if (!query) { + return { organizationId: undefined }; + } + try { + const params = new URLSearchParams(query); + const organizationId = params.get('organizationId'); + return { organizationId: organizationId ?? undefined }; + } catch { + return { organizationId: undefined }; + } } /** @@ -66,7 +88,7 @@ export function navigationContainsShareGate(state: unknown): boolean { return false; } -/** Test-only: wipe the module slot between cases. */ +/** Test-only: wipe the pending queue between cases. */ export function __resetPendingShareNavigationForTests(): void { - pending = null; + pendingQueue.length = 0; }