Skip to content
17 changes: 15 additions & 2 deletions apps/mobile/src/components/agents/remote-session-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,17 @@ import {
} from '@/lib/session-attention';
import {
activeSessionMetaTimestamp,
composeActiveSessionVisibleMeta,
formatSessionTotalCost,
remoteMeta,
remoteSessionEyebrowLabel,
selectRemoteRowSpokenMeta,
} from './session-list-helpers';
import { selectRowPlatformPresentation, SessionPlatformIcon } from './session-platform-icon';
import { type RowVariant } from './session-row';
import { copySessionId, showRenamePrompt, showSessionActionMenu } from './session-row-actions';
import {
formatSpokenCost,
formatSpokenTimeAgo,
sessionRowAccessibilityLabel,
} from './session-row-accessibility-label';
Expand Down Expand Up @@ -68,7 +72,13 @@ export function RemoteSessionRow({
// so the label omits it. Otherwise announce the same timestamp as
// `remoteMeta` (prefer lastActivityAt, fall back to updatedAt).
const metaTimestamp = activeSessionMetaTimestamp(session);
const spokenMeta = !needsInput && metaTimestamp ? formatSpokenTimeAgo(metaTimestamp) : null;
const costSpoken = formatSpokenCost(session.totalCostMicrodollars);
const timeSpoken = metaTimestamp ? formatSpokenTimeAgo(metaTimestamp) : null;
const spokenMeta = selectRemoteRowSpokenMeta({
needsInput,
costSpoken,
timeSpoken,
});

const { iconKind: platformIconKind, spokenPlatform } = selectRowPlatformPresentation({
platform: session.createdOnPlatform,
Expand Down Expand Up @@ -125,7 +135,10 @@ export function RemoteSessionRow({
agentLabel={agentLabel}
title={title}
subtitle={session.gitBranch ?? null}
meta={remoteMeta(session)}
meta={composeActiveSessionVisibleMeta(
formatSessionTotalCost(session.totalCostMicrodollars),
remoteMeta(session)
)}
live
needsInput={needsInput}
metaWhileLive
Expand Down
27 changes: 24 additions & 3 deletions apps/mobile/src/components/agents/session-list-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { getRevisionSnapshot } from '@/lib/session-attention';
import { getEffectiveTabBarHeight } from '@/lib/tab-bar-layout';

export const FAB_SIZE = 56;
export const FAB_MARGIN = 16;

type AgentSessionListContentProps = {
sections: SessionSection[];
hasAnySessions: boolean;
Expand Down Expand Up @@ -122,8 +125,26 @@ export function AgentSessionListContent({
const [refreshing, setRefreshing] = useState(false);

// The tab bar is an absolutely-positioned overlay, so scrollable content
// must clear it or the last rows are stuck underneath it.
// must clear it or the last rows are stuck underneath it. The FAB adds its
// own inset so the last row scrolls clear of the button too.
const tabBarClearanceStyle = useMemo(
() => ({
paddingBottom:
getEffectiveTabBarHeight({
bottomInset: bottom,
platform: Platform.OS,
fontScale,
}) +
FAB_SIZE +
FAB_MARGIN,
}),
[bottom, fontScale]
);

// Tab-bar-only clearance for the full-screen error and first-use empty
// containers — the FAB is hidden in those states so they must not include
// the FAB inset.
const tabBarOnlyClearanceStyle = useMemo(
() => ({
paddingBottom: getEffectiveTabBarHeight({
bottomInset: bottom,
Expand Down Expand Up @@ -244,7 +265,7 @@ export function AgentSessionListContent({
<Animated.View
entering={FadeIn.duration(200)}
className="flex-1 items-center justify-center"
style={tabBarClearanceStyle}
style={tabBarOnlyClearanceStyle}
>
<QueryError message="Could not load sessions" onRetry={onRetry} />
</Animated.View>
Expand All @@ -261,7 +282,7 @@ export function AgentSessionListContent({
<Animated.View
entering={FadeIn.duration(200)}
className="flex-1 items-center justify-center"
style={tabBarClearanceStyle}
style={tabBarOnlyClearanceStyle}
>
<EmptyState
icon={Bot}
Expand Down
96 changes: 96 additions & 0 deletions apps/mobile/src/components/agents/session-list-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { parseTimestamp, timeAgo } from '@/lib/utils';

import {
activeSessionMetaTimestamp,
composeActiveSessionSpokenMeta,
composeActiveSessionVisibleMeta,
excludeActiveFromGroups,
expandPlatformFilter,
formatMeta,
Expand All @@ -15,6 +17,7 @@ import {
remoteSessionEyebrowLabel,
repoNameFromGitUrl,
selectPinnedActiveSessions,
selectRemoteRowSpokenMeta,
storedSessionEyebrowLabel,
} from './session-list-helpers';
import { type AgentSessionDateGroup } from '@/lib/agent-session-groups';
Expand Down Expand Up @@ -623,3 +626,96 @@ describe('remoteSessionEyebrowLabel (canonical eyebrow — repo-name-first)', ()
).toBe('MY-REPO');
});
});

describe('composeActiveSessionVisibleMeta', () => {
it('both cost and time → "$cost · timeMeta"', () => {
expect(composeActiveSessionVisibleMeta('$0.12', '5M AGO')).toBe('$0.12 · 5M AGO');
});

it('cost only → cost', () => {
expect(composeActiveSessionVisibleMeta('$3.50', undefined)).toBe('$3.50');
});

it('time only → timeMeta', () => {
expect(composeActiveSessionVisibleMeta(null, '1H AGO')).toBe('1H AGO');
});

it('neither → undefined', () => {
expect(composeActiveSessionVisibleMeta(null, undefined)).toBeUndefined();
});

it('null cost with empty time → undefined', () => {
expect(composeActiveSessionVisibleMeta(null, '')).toBeUndefined();
});
});

describe('composeActiveSessionSpokenMeta', () => {
it('both cost and time → "cost <cost>, <time>"', () => {
expect(composeActiveSessionSpokenMeta('12 cents', '5 minutes ago')).toBe(
'cost 12 cents, 5 minutes ago'
);
});

it('cost only → "cost <cost>"', () => {
expect(composeActiveSessionSpokenMeta('3 dollars', null)).toBe('cost 3 dollars');
});

it('time only → timeSpoken', () => {
expect(composeActiveSessionSpokenMeta(null, '1 hour ago')).toBe('1 hour ago');
});

it('neither → null', () => {
expect(composeActiveSessionSpokenMeta(null, null)).toBeNull();
});
});

describe('selectRemoteRowSpokenMeta', () => {
const costSpoken = '12 cents';
const timeSpoken = '5 minutes ago';

it('needsInput + cost + time → null', () => {
expect(selectRemoteRowSpokenMeta({ needsInput: true, costSpoken, timeSpoken })).toBeNull();
});

it('needsInput + cost only → null', () => {
expect(
selectRemoteRowSpokenMeta({ needsInput: true, costSpoken, timeSpoken: null })
).toBeNull();
});

it('needsInput + time only → null', () => {
expect(
selectRemoteRowSpokenMeta({ needsInput: true, costSpoken: null, timeSpoken })
).toBeNull();
});

it('needsInput + neither → null', () => {
expect(
selectRemoteRowSpokenMeta({ needsInput: true, costSpoken: null, timeSpoken: null })
).toBeNull();
});

it('cost + time → combined spoken form', () => {
expect(selectRemoteRowSpokenMeta({ needsInput: false, costSpoken, timeSpoken })).toBe(
'cost 12 cents, 5 minutes ago'
);
});

it('cost only → spoken cost alone', () => {
expect(selectRemoteRowSpokenMeta({ needsInput: false, costSpoken, timeSpoken: null })).toBe(
'cost 12 cents'
);
});

it('time only → spoken time alone', () => {
expect(selectRemoteRowSpokenMeta({ needsInput: false, costSpoken: null, timeSpoken })).toBe(
'5 minutes ago'
);
});

it('neither → null', () => {
expect(
selectRemoteRowSpokenMeta({ needsInput: false, costSpoken: null, timeSpoken: null })
).toBeNull();
});
});
72 changes: 72 additions & 0 deletions apps/mobile/src/components/agents/session-list-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,78 @@ export function composeStoredSessionSpokenMeta(cost: string | null, timeSpoken:
return cost ? `cost ${cost}, ${timeSpoken}` : timeSpoken;
}

/**
* Compose the visible `meta` string for an active-session row by folding an
* optional cost segment in front of the relative timestamp. All four quadrants:
* - both → `$cost · timeMeta`
* - cost only → `cost`
* - time only → `timeMeta`
* - neither → `undefined`
*
* Used in `RemoteSessionRow` where `timeMeta` comes from `remoteMeta()` (may
* return `undefined` when no timestamp exists — bare-live dot).
*/
export function composeActiveSessionVisibleMeta(
cost: string | null,
timeMeta: string | undefined
): string | undefined {
if (cost && timeMeta) {
return `${cost} · ${timeMeta}`;
}
if (cost) {
return cost;
}
if (timeMeta) {
return timeMeta;
}
return undefined;
}

/**
* Compose the spoken `meta` string for an active-session row's accessibility
* label. All four quadrants:
* - both → `cost <costSpoken>, <timeSpoken>`
* - cost only → `cost <costSpoken>`
* - time only → `timeSpoken`
* - neither → `null`
*
* The `cost` param is the `formatSpokenCost` phrase (e.g. `"12 cents"`), never
* the visible `"$"` string, matching the convention in
* `composeStoredSessionSpokenMeta`.
*/
export function composeActiveSessionSpokenMeta(
cost: string | null,
timeSpoken: string | null
): string | null {
if (cost && timeSpoken) {
return `cost ${cost}, ${timeSpoken}`;
}
if (cost) {
return `cost ${cost}`;
}
if (timeSpoken) {
return timeSpoken;
}
return null;
}

/**
* Selector for the spoken meta of a remote session row. When `needsInput`
* is true, spoken cost/time are suppressed entirely (`null`) — the spoken
* label announces "needs input" instead via `sessionRowAccessibilityLabel`.
* Otherwise delegates to `composeActiveSessionSpokenMeta`.
*/
export function selectRemoteRowSpokenMeta(params: {
needsInput: boolean;
costSpoken: string | null;
timeSpoken: string | null;
}): string | null {
if (params.needsInput) {
return null;
}
return composeActiveSessionSpokenMeta(params.costSpoken, params.timeSpoken);
}

/**
* Pinned-tray label for an active session. Reuses `platformLabel` when the
* origin is known, otherwise falls back to 'LIVE'. An undefined, empty, or
Expand Down
Loading