Skip to content
Open
2 changes: 2 additions & 0 deletions apps/mobile/src/app/(app)/agent-chat/[session-id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ScreenHeader } from '@/components/screen-header';
import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
import { useTRPC } from '@/lib/trpc';
import { useAckSessionAttentionOnOpen } from '@/lib/session-attention';

export default function SessionDetailScreen() {
const {
Expand All @@ -26,6 +27,7 @@ export default function SessionDetailScreen() {
}>();
const trpc = useTRPC();
const router = useRouter();
useAckSessionAttentionOnOpen(sessionId);
const sessionQuery = useQuery({
...trpc.cliSessionsV2.get.queryOptions(
{ session_id: sessionId },
Expand Down
23 changes: 23 additions & 0 deletions apps/mobile/src/components/agents/session-list-content.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useFocusEffect } from 'expo-router';
import { Bot, Plus, Search, SearchX } from 'lucide-react-native';
import { useCallback, useMemo, useRef, useState } from 'react';
import {
Expand All @@ -24,6 +25,7 @@ import { type AgentSessionSortBy } from '@/lib/agent-session-sort';
import { type StoredSession } from '@/lib/hooks/use-agent-sessions';
import { useSessionMutations } from '@/lib/hooks/use-session-mutations';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { getRevisionSnapshot } from '@/lib/session-attention';
import { getTabBarOverlayHeight } from '@/lib/tab-bar-layout';

// Height of the hidden-by-default search bar (mt-3 12 + border 1 + py-1.5 12 + line-20 + border 1 + mb-14 14 = 60).
Expand Down Expand Up @@ -189,6 +191,25 @@ export function AgentSessionListContent({
[storedSessions]
);

// The tabs navigator uses `freezeOnBlur`, so while the session detail screen
// is pushed the Agents list is frozen. react-freeze reveals the previously
// rendered (cached) cells on return WITHOUT re-running them, so the attention
// store's `useSyncExternalStore` subscription does not re-render the list and
// the detail-screen mount ack is not reflected. Snapshot the attention
// revision only when the tab (re)gains focus, via `useFocusEffect`, which
// fires reliably after unfreeze. Keying the list on that focus snapshot
// remounts it exactly when an ack/reconcile happened while the list was away
// (e.g. returning from a session that was just opened) so frozen cells re-read
// the ack store — while a revision bump for some unrelated session that occurs
// *during* browsing does not touch the snapshot, so scroll is preserved.
const [attentionFocusRevision, setAttentionFocusRevision] = useState(getRevisionSnapshot);
useFocusEffect(
useCallback(() => {
setAttentionFocusRevision(getRevisionSnapshot());
}, [])
);
const attentionListKey = `${sortBy}:${attentionFocusRevision}`;

const handleRefresh = useCallback(() => {
void (async () => {
setRefreshing(true);
Expand Down Expand Up @@ -304,10 +325,12 @@ export function AgentSessionListContent({
return (
<Animated.View entering={FadeIn.duration(200)} className="flex-1">
<SectionList<SessionItem, SessionSection>
key={attentionListKey}
sections={sections}
renderItem={renderItem}
renderSectionHeader={renderSectionHeader}
keyExtractor={keyExtractor}
extraData={attentionListKey}
ListHeaderComponent={listHeader}
ListEmptyComponent={emptyComponent}
ListFooterComponent={
Expand Down
41 changes: 38 additions & 3 deletions apps/mobile/src/components/agents/session-row.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import * as Haptics from 'expo-haptics';
import { useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { ActionSheetIOS, Alert, Modal, Platform, Pressable, TextInput, View } from 'react-native';

import { SessionRow } from '@/components/ui/session-row';
import { Text } from '@/components/ui/text';
import { type AgentSessionSortBy, getAgentSessionTimestamp } from '@/lib/agent-session-sort';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import {
isAttentionAcked,
reconcileSessionAttention,
shouldShowNeedsInput,
useSessionAttentionRevision,
} from '@/lib/session-attention';
import { parseTimestamp, timeAgo } from '@/lib/utils';

type StoredSessionRowProps = {
Expand All @@ -19,6 +25,7 @@ type StoredSessionRowProps = {
updated_at: string;
git_branch: string | null;
status: string | null;
status_updated_at: string | null;
};
isLive: boolean;
/**
Expand Down Expand Up @@ -115,6 +122,17 @@ export function StoredSessionRow({
const renameTextRef = useRef(title);
const agentLabel = platformLabel(session.created_on_platform);

const revision = useSessionAttentionRevision();
const raiseId = session.status_updated_at ?? session.status ?? null;
const needsInput = shouldShowNeedsInput({
status: session.status,
raiseId,
isAcked: isAttentionAcked(session.session_id, raiseId),
});
useEffect(() => {
reconcileSessionAttention(session.session_id, session.status, session.status_updated_at);
}, [session.session_id, session.status, session.status_updated_at, revision]);

const handleRenameConfirm = () => {
const newName = renameTextRef.current.trim();
setRenameVisible(false);
Expand Down Expand Up @@ -167,7 +185,7 @@ export function StoredSessionRow({
<Pressable
onPress={onPress}
onLongPress={handleLongPress}
accessibilityLabel={title}
accessibilityLabel={needsInput ? `${title}, needs input` : title}
className="active:opacity-70"
>
<SessionRow
Expand All @@ -176,6 +194,7 @@ export function StoredSessionRow({
subtitle={session.git_branch}
meta={formatMeta(getAgentSessionTimestamp(session, sortBy))}
live={isLive}
needsInput={needsInput}
stripMode="inline"
className="pl-[22px] pr-[22px]"
/>
Expand Down Expand Up @@ -236,14 +255,30 @@ export function StoredSessionRow({
export function RemoteSessionRow({ session, onPress }: Readonly<RemoteSessionRowProps>) {
const title = session.title.length > 0 ? session.title : 'Untitled session';

const revision = useSessionAttentionRevision();
const raiseId = session.status;
const needsInput = shouldShowNeedsInput({
status: session.status,
raiseId,
isAcked: isAttentionAcked(session.id, raiseId),
});
useEffect(() => {
reconcileSessionAttention(session.id, session.status, null);
}, [session.id, session.status, revision]);

return (
<Pressable onPress={onPress} accessibilityLabel={title} className="active:opacity-70">
<Pressable
onPress={onPress}
accessibilityLabel={needsInput ? `${title}, needs input` : title}
className="active:opacity-70"
>
<SessionRow
agentLabel="CLOUD AGENT"
title={title}
subtitle={session.gitBranch}
meta={session.status.toUpperCase()}
live
needsInput={needsInput}
stripMode="inline"
className="pl-[22px] pr-[22px]"
/>
Expand Down
37 changes: 30 additions & 7 deletions apps/mobile/src/components/ui/session-row.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as React from 'react';
import { ChevronRight } from 'lucide-react-native';
import { Pressable, View } from 'react-native';

Expand All @@ -18,6 +19,11 @@ type SessionRowProps = {
meta?: string;
/** When true, renders a pulsing good-tone StatusDot before the meta. */
live?: boolean;
/**
* When true, replaces the live dot / meta with a pulsing warn-tone dot
* and a `NEEDS INPUT` label. Highest priority in the eyebrow row.
*/
needsInput?: boolean;
onPress?: () => void;
/** Suppress bottom divider on the last row of a group. */
last?: boolean;
Expand All @@ -43,14 +49,36 @@ export function SessionRow({
subtitle,
meta,
live,
needsInput = false,
onPress,
last,
stripMode = 'edge',
className,
}: Readonly<SessionRowProps>) {
const colors = useThemeColors();
const color = agentColor(agentLabel);
const dimStrip = !live;
const dimStrip = !live && !needsInput;

let eyebrowRight: React.ReactNode = null;
if (needsInput) {
eyebrowRight = (
<View className="flex-row items-center gap-1.5">
<StatusDot tone="warn" pulse />
<Text variant="mono" className="shrink text-xs text-warn">
NEEDS INPUT
</Text>
</View>
);
} else if (live) {
eyebrowRight = <StatusDot tone="good" />;
} else if (meta) {
eyebrowRight = (
<Text variant="mono" className="shrink text-xs text-ink2">
{meta}
</Text>
);
}

const row = (
<View
className={cn(
Expand All @@ -77,12 +105,7 @@ export function SessionRow({
<View className="min-w-0 flex-1">
<View className="mb-[3px] flex-row items-center justify-between">
<Eyebrow className={color.hueTextClass}>{agentLabel}</Eyebrow>
{live && <StatusDot tone="good" />}
{!live && meta && (
<Text variant="mono" className="shrink text-xs text-ink2">
{meta}
</Text>
)}
{eyebrowRight}
</View>
<Text className="text-sm font-medium tracking-tight text-foreground" numberOfLines={2}>
{title}
Expand Down
68 changes: 67 additions & 1 deletion apps/mobile/src/components/ui/status-dot.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
import { useEffect } from 'react';
import { View } from 'react-native';
import Animated, {
cancelAnimation,
Easing,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withRepeat,
withTiming,
} from 'react-native-reanimated';

import { cn } from '@/lib/utils';

Expand All @@ -7,6 +17,8 @@ export type StatusDotTone = 'good' | 'warn' | 'danger' | 'muted';
type StatusDotProps = {
tone?: StatusDotTone;
className?: string;
/** Soft opacity breathe on the dot+halo. Static when reduced motion is on. */
pulse?: boolean;
};

// Solid inner-dot and outer-halo classes per tone. The halo uses the
Expand All @@ -19,12 +31,66 @@ const TONE: Record<StatusDotTone, { dot: string; halo: string }> = {
muted: { dot: 'bg-muted-soft', halo: 'bg-neutral-500/20' },
};

// Soft breathe range and cadence. Mirrors the provisioning-step pulse
// pattern: 1.0 (fully visible) down to ~0.45 (faded), reversed, looping.
const PULSE_LOW = 0.45;
const PULSE_DURATION_MS = 1100;

/**
* Status indicator dot with a halo (replaces CSS box-shadow).
* 7px inner dot centered inside a 13px halo.
*
* When `pulse` is true, the entire dot+halo softly breathes via opacity —
* never a hard on/off blink. Respects `useReducedMotion()` and renders
* statically (fully visible) when motion is reduced.
*/
export function StatusDot({ tone = 'good', className }: Readonly<StatusDotProps>) {
export function StatusDot({ tone = 'good', className, pulse = false }: Readonly<StatusDotProps>) {
const styles = TONE[tone];

// Animated branch: opacity breathe on the wrapper so the inner dot and
// halo fade together. Static when reduced motion is on.
const reducedMotion = useReducedMotion();
const opacity = useSharedValue(1);
useEffect(() => {
// Only the pulsing branch animates. Non-pulsing dots (every existing
// caller) must not start a perpetual invisible animation, and reduced
// motion keeps the dot fully visible.
if (!pulse || reducedMotion) {
cancelAnimation(opacity);
opacity.value = 1;
return undefined;
}
opacity.value = withRepeat(
withTiming(PULSE_LOW, {
duration: PULSE_DURATION_MS,
easing: Easing.inOut(Easing.ease),
}),
-1,
true
);
return () => {
cancelAnimation(opacity);
};
}, [opacity, pulse, reducedMotion]);
const pulseStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));

if (pulse) {
return (
<Animated.View
className={cn(
'h-[13px] w-[13px] items-center justify-center rounded-full',
styles.halo,
className
)}
style={pulseStyle}
>
<View className={cn('h-[7px] w-[7px] rounded-full', styles.dot)} />
</Animated.View>
);
}

return (
<View
className={cn(
Expand Down
Loading