Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- root layout bootstrap: auth/consent/update gating, notification wiring, theme readiness gate, and Sentry init are kept together */
import '../global.css';
import '@/lib/cloud-agent-runtime';

Expand Down Expand Up @@ -34,6 +35,7 @@ import { useForceUpdate } from '@/lib/hooks/use-force-update';
import { useCurrentUserId } from '@/lib/hooks/use-current-user-id';
import { useScreenTracking } from '@/lib/hooks/use-screen-tracking';
import { useNavigationTheme } from '@/lib/hooks/use-theme-colors';
import { applyThemePreference, useThemePreference } from '@/lib/hooks/use-theme-preference';
import { useTrackingPermissionPrompt } from '@/lib/hooks/use-tracking-permission-prompt';
import {
checkInitialNotification,
Expand Down Expand Up @@ -93,6 +95,7 @@ function RootLayoutNav() {
const pathname = usePathname();
const { mode } = useGlobalSearchParams<{ mode?: string }>();
const router = useRouter();
const { preference: themePreference, hasLoaded: themeHasLoaded } = useThemePreference();
const {
userId,
email,
Expand All @@ -114,7 +117,13 @@ function RootLayoutNav() {
useSentryConsentSync(consentChecked && !needsConsent, initSentry);

const fontsReady = fontsLoaded || fontsError !== null;
const isLoading = authLoading || updateChecking || !fontsReady;
const isLoading = authLoading || updateChecking || !fontsReady || !themeHasLoaded;

useEffect(() => {
if (themeHasLoaded) {
applyThemePreference(themePreference);
}
}, [themeHasLoaded, themePreference]);
const inAuthGroup = segments[0] === '(auth)';
const inForceUpdate = segments[0] === 'force-update';
const onConsentRoute = pathname === '/consent' || pathname === '/consent-details';
Expand Down
119 changes: 119 additions & 0 deletions apps/mobile/src/components/agents/session-list-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- cohesive unit-test suite for session-list-helpers pure functions */
import { describe, expect, it } from 'vitest';

import { type ActiveSession } from '@/lib/hooks/use-agent-sessions';
Expand All @@ -10,7 +11,10 @@ import {
platformLabel,
remoteAgentLabel,
remoteMeta,
remoteSessionEyebrowLabel,
repoNameFromGitUrl,
selectPinnedActiveSessions,
storedSessionEyebrowLabel,
} from './session-list-helpers';
import { type AgentSessionDateGroup } from '@/lib/agent-session-groups';

Expand Down Expand Up @@ -330,3 +334,118 @@ describe('expandPlatformFilter (regression guard for filter expansion)', () => {
expect(expandPlatformFilter(['cli', 'other'])).toEqual(['cli', 'other']);
});
});

describe('repoNameFromGitUrl', () => {
it('returns null for null', () => {
expect(repoNameFromGitUrl(null)).toBeNull();
});

it('returns null for undefined', () => {
expect(repoNameFromGitUrl(undefined)).toBeNull();
});

it('returns null for empty string', () => {
expect(repoNameFromGitUrl('')).toBeNull();
});

it('returns the last path segment for an SSH URL with .git suffix', () => {
expect(repoNameFromGitUrl('git@github.com:org/my-repo.git')).toBe('my-repo');
});

it('returns the last path segment for an SSH URL without .git suffix', () => {
expect(repoNameFromGitUrl('git@github.com:org/my-repo')).toBe('my-repo');
});

it('returns the last path segment for an https URL with .git suffix', () => {
expect(repoNameFromGitUrl('https://github.com/org/my-repo.git')).toBe('my-repo');
});

it('returns the last path segment for an https URL without .git suffix', () => {
expect(repoNameFromGitUrl('https://github.com/org/my-repo')).toBe('my-repo');
});

it('handles a nested group/project path (GitLab style) by returning the last segment', () => {
// formatGitUrlProject strips the dash-prefixed project segment when there
// are >=2 leading parts, so the last segment is the repo name.
expect(repoNameFromGitUrl('https://gitlab.com/group/sub/my-repo.git')).toBe('my-repo');
});
});

describe('storedSessionEyebrowLabel (canonical eyebrow — repo-name-first)', () => {
it('returns the uppercased repo name when git_url is present (SSH)', () => {
expect(
storedSessionEyebrowLabel({
git_url: 'git@github.com:org/my-repo.git',
created_on_platform: 'cli',
})
).toBe('MY-REPO');
});

it('returns the uppercased repo name when git_url is present (https)', () => {
expect(
storedSessionEyebrowLabel({
git_url: 'https://github.com/org/my-repo.git',
created_on_platform: 'cloud-agent',
})
).toBe('MY-REPO');
});

it('falls back to the platform label when git_url is null', () => {
expect(storedSessionEyebrowLabel({ git_url: null, created_on_platform: 'cli' })).toBe('CLI');
});

it('falls back to the platform label when git_url is the empty string', () => {
expect(storedSessionEyebrowLabel({ git_url: '', created_on_platform: 'cloud-agent' })).toBe(
'CLOUD AGENT'
);
});
});

describe('remoteSessionEyebrowLabel (canonical eyebrow — repo-name-first)', () => {
it('returns the uppercased repo name when gitUrl is present (SSH)', () => {
expect(
remoteSessionEyebrowLabel({
gitUrl: 'git@github.com:org/my-repo.git',
createdOnPlatform: 'cli',
})
).toBe('MY-REPO');
});

it('returns the uppercased repo name when gitUrl is present (https)', () => {
expect(
remoteSessionEyebrowLabel({
gitUrl: 'https://github.com/org/my-repo.git',
createdOnPlatform: 'cloud-agent',
})
).toBe('MY-REPO');
});

it('returns "LIVE" when gitUrl is null and origin is undefined (origin-not-heartbeat)', () => {
expect(remoteSessionEyebrowLabel({ gitUrl: null, createdOnPlatform: undefined })).toBe('LIVE');
});

it('returns "LIVE" when gitUrl is undefined and origin is "unknown"', () => {
expect(remoteSessionEyebrowLabel({ gitUrl: undefined, createdOnPlatform: 'unknown' })).toBe(
'LIVE'
);
});

it('returns "CLI" when gitUrl is undefined and origin is "cli"', () => {
expect(remoteSessionEyebrowLabel({ gitUrl: undefined, createdOnPlatform: 'cli' })).toBe('CLI');
});

it('returns "CLOUD AGENT" when gitUrl is undefined and origin is "cloud-agent-web"', () => {
expect(
remoteSessionEyebrowLabel({ gitUrl: undefined, createdOnPlatform: 'cloud-agent-web' })
).toBe('CLOUD AGENT');
});

it('repo name wins over a known platform origin', () => {
expect(
remoteSessionEyebrowLabel({
gitUrl: 'https://github.com/org/my-repo.git',
createdOnPlatform: 'cli',
})
).toBe('MY-REPO');
});
});
42 changes: 42 additions & 0 deletions apps/mobile/src/components/agents/session-list-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,48 @@ export function remoteMeta(session: { status: string; updatedAt?: string }): str
return session.updatedAt ? formatMeta(session.updatedAt) : session.status.toUpperCase();
}

/**
* Pure helper: extract the repo's last path segment from a git URL, using the
* same SSH/https handling as the list-grouping logic. Returns null when the
* URL is missing so callers can fall back to a platform label. Exposed for the
* unified row wrappers' eyebrow label and its unit tests.
*/
export function repoNameFromGitUrl(gitUrl: string | null | undefined): string | null {
if (!gitUrl) {
return null;
}
const project = formatGitUrlProject(gitUrl);
const parts = project.split('/');
return parts.at(-1) ?? project;
}

/**
* Canonical eyebrow label for a stored session: repo name (uppercased) when
* a git URL is present, else the platform fallback. Replaces the legacy
* platform-only label in the Agents list.
*/
export function storedSessionEyebrowLabel(session: {
git_url: string | null;
created_on_platform: string;
}): string {
const repo = repoNameFromGitUrl(session.git_url);
return repo ? repo.toUpperCase() : platformLabel(session.created_on_platform);
}

/**
* Canonical eyebrow label for a remote (active) session: repo name
* (uppercased) when a git URL is present, else the LIVE/CLI origin fallback
* via `remoteAgentLabel` (preserves the origin-not-heartbeat behavior — live
* CLI sessions start with origin 'unknown' and get the neutral 'LIVE' label).
*/
export function remoteSessionEyebrowLabel(session: {
gitUrl?: string | null;
createdOnPlatform?: string;
}): string {
const repo = repoNameFromGitUrl(session.gitUrl);
return repo ? repo.toUpperCase() : remoteAgentLabel(session.createdOnPlatform);
}

const KNOWN_PLATFORM_VALUES: readonly string[] = KNOWN_PLATFORMS;

/**
Expand Down
46 changes: 46 additions & 0 deletions apps/mobile/src/components/agents/session-row-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import * as Clipboard from 'expo-clipboard';
import * as Haptics from 'expo-haptics';
import { Alert } from 'react-native';
import { toast } from 'sonner-native';

export function showDeleteConfirm(onDelete: () => void) {
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
Alert.alert('Delete session?', 'This cannot be undone.', [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Delete', style: 'destructive', onPress: onDelete },
]);
}

/** iOS-only — uses Alert.prompt which is unavailable on Android. */
export function showRenamePrompt(currentTitle: string, onRename: (newTitle: string) => void) {
Alert.prompt(
'Rename session',
'Enter a new name for this session',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Rename',
onPress: (newName: string | undefined) => {
if (newName?.trim()) {
onRename(newName.trim());
}
},
},
],
'plain-text',
currentTitle
);
}

export async function copySessionId(sessionId: string) {
try {
const copied = await Clipboard.setStringAsync(sessionId);
if (!copied) {
throw new Error('Clipboard rejected session ID');
}
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
toast.success('Session ID copied');
} catch {
toast.error('Could not copy session ID');
}
}
Loading