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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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