Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ export function createMobileAgentSessionManager({
initialMessageId: rs?.initialMessageId ?? null,
associatedPr: sessionResult.associatedPr,
runtimeAgents: rs?.runtimeAgents,
createdOnPlatform: sessionResult.created_on_platform,
};
},
});
Expand Down
20 changes: 20 additions & 0 deletions apps/mobile/src/components/agents/session-detail-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ import { ChildSessionSheet } from '@/components/agents/child-session-sheet';
import { PartRenderer } from '@/components/agents/part-renderer';
import { QueryError } from '@/components/query-error';
import { RenameModal } from '@/components/rename-modal';
import {
SessionPlatformIcon,
sessionPlatformIconKind,
} from '@/components/agents/session-platform-icon';
import { ScreenHeader } from '@/components/screen-header';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
Expand All @@ -85,6 +89,8 @@ import {
revalidateLegacyGatewayOverride,
useSessionModelOptions,
} from '@/lib/hooks/use-session-model-options';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { platformLabel } from '@/lib/platform-label';
import { resolveSessionContextInfo } from '@/lib/session-context-info';
import {
areModelPickerSelectionScopesEqual,
Expand All @@ -109,6 +115,7 @@ export function SessionDetailContent({
}: Readonly<SessionDetailContentProps>) {
const manager = useSessionManager();
const router = useRouter();
const colors = useThemeColors();
const [childSession, setChildSession] = useState<{
sessionId: KiloSessionId;
title: string;
Expand Down Expand Up @@ -479,6 +486,18 @@ export function SessionDetailContent({
});
const handleRenameSave = rename.submit;
const handleRenameClose = rename.closeModal;
const platform = isSessionLoaded ? (fetchedData.createdOnPlatform ?? null) : null;
const platformKind = sessionPlatformIconKind(platform);
const leadingAccessory =
platform != null && platformKind != null ? (
<View
accessibilityRole="image"
accessibilityLabel={platformLabel(platform)}
testID={`platform-icon-${platformKind}`}
>
<SessionPlatformIcon platform={platform} size={14} color={colors.mutedForeground} />
</View>
) : null;
const requiresModel = Boolean(fetchedData?.cloudAgentSessionId);
const blockingInteraction = getBlockingInteraction({ activeQuestion, activePermission });
const hasBlockingInteraction = blockingInteraction !== 'none';
Expand Down Expand Up @@ -625,6 +644,7 @@ export function SessionDetailContent({
<ScreenHeader
title={rename.title}
headerRight={headerRight}
leadingAccessory={leadingAccessory}
{...(rename.isTitleInteractive
? {
onTitlePress: rename.openModal,
Expand Down
46 changes: 32 additions & 14 deletions apps/mobile/src/components/agents/session-list-content.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useScrollToTop } from '@react-navigation/native';
import { useFocusEffect } from 'expo-router';
import { Bot, Plus } from 'lucide-react-native';
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Platform,
Expand All @@ -18,6 +19,7 @@ import {
type SessionListBodyModel,
} from '@/components/agents/session-list-body-model';
import { type SessionSection } from '@/components/agents/session-list-helpers';
import { shouldResetScrollOnCommittedQuery } from '@/components/agents/session-list-scroll-reset';
import { SessionListSectionHeader } from '@/components/agents/session-list-section-header';
import { StoredSessionRow } from '@/components/agents/session-row';
import { EmptyState } from '@/components/empty-state';
Expand Down Expand Up @@ -55,6 +57,8 @@ type AgentSessionListContentProps = {
onSessionPress: (sessionId: string, organizationId?: string | null) => void;
hasActiveQuery: boolean;
isSearching: boolean;
/** Committed (debounced) search query — scroll-to-top fires when this value changes. */
searchQuery: string;
onClearQuery: () => void;
onCreateSession: () => void;
sortBy: AgentSessionSortBy;
Expand All @@ -74,10 +78,28 @@ export function AgentSessionListContent({
onSessionPress,
hasActiveQuery,
isSearching,
searchQuery,
onClearQuery,
onCreateSession,
sortBy,
}: Readonly<AgentSessionListContentProps>) {
const listRef = useRef<SectionList<StoredSession, SessionSection>>(null);
useScrollToTop(listRef);

// Scroll to top on committed-query change only. Skip the initial mount
// (offset is already 0). Must not fire on focus refetch, attention
// revision, sort remount, pagination, pull-to-refresh, or section-data
// identity changes with an unchanged query.
const prevSearchQueryRef = useRef<string | null>(null);
useEffect(() => {
const prev = prevSearchQueryRef.current;
prevSearchQueryRef.current = searchQuery;
if (!shouldResetScrollOnCommittedQuery(prev, searchQuery)) {
return;
}
listRef.current?.getScrollResponder()?.scrollTo({ y: 0, animated: false });
}, [searchQuery]);

const colors = useThemeColors();
const { bottom } = useSafeAreaInsets();
const { fontScale } = useWindowDimensions();
Expand Down Expand Up @@ -127,23 +149,18 @@ export function AgentSessionListContent({
);

// 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.
// is pushed the Agents list is frozen. On return, each row re-reads the ack
// store via its own `useSyncExternalStore` subscription
// (`useSessionAttentionRevision`). Snapshot the attention revision only when
// the tab (re)gains focus via `useFocusEffect` (fires after unfreeze) and
// pass it as `extraData` so visible cells re-render without remounting the
// list — preserving scroll. Remount only on sort change (`key={sortBy}`).
const [attentionFocusRevision, setAttentionFocusRevision] = useState(getRevisionSnapshot);
useFocusEffect(
useCallback(() => {
setAttentionFocusRevision(getRevisionSnapshot());
}, [])
);
const attentionListKey = `${sortBy}:${attentionFocusRevision}`;

const handleRefresh = useCallback(() => {
void (async () => {
Expand Down Expand Up @@ -252,12 +269,13 @@ export function AgentSessionListContent({
return (
<Animated.View entering={FadeIn.duration(200)} className="flex-1">
<SectionList<StoredSession, SessionSection>
key={attentionListKey}
ref={listRef}
key={sortBy}
sections={sections}
renderItem={renderItem}
renderSectionHeader={renderSectionHeader}
keyExtractor={keyExtractor}
extraData={attentionListKey}
extraData={attentionFocusRevision}
ListEmptyComponent={emptyComponent}
ListFooterComponent={
isFetchingNextPage ? (
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/components/agents/session-list-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ export function AgentSessionListScreen() {
onSessionPress={navigateToSession}
hasActiveQuery={isSearching || hasActiveFilter}
isSearching={isSearching}
searchQuery={searchQuery}
onClearQuery={handleClearQuery}
onCreateSession={() => {
router.push(getNewAgentSessionPath(organizationId) as Href);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';

import { shouldResetScrollOnCommittedQuery } from './session-list-scroll-reset';

describe('shouldResetScrollOnCommittedQuery', () => {
it('returns false on initial mount (prev is null)', () => {
expect(shouldResetScrollOnCommittedQuery(null, '')).toBe(false);
expect(shouldResetScrollOnCommittedQuery(null, 'foo')).toBe(false);
});

it('returns false when the committed query is unchanged', () => {
expect(shouldResetScrollOnCommittedQuery('', '')).toBe(false);
expect(shouldResetScrollOnCommittedQuery('foo', 'foo')).toBe(false);
});

it('returns true when the committed query changes', () => {
expect(shouldResetScrollOnCommittedQuery('foo', 'bar')).toBe(true);
});

it('treats empty-string ↔ non-empty transitions as a change', () => {
expect(shouldResetScrollOnCommittedQuery('', 'foo')).toBe(true);
expect(shouldResetScrollOnCommittedQuery('foo', '')).toBe(true);
});
});
11 changes: 11 additions & 0 deletions apps/mobile/src/components/agents/session-list-scroll-reset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Whether the session list should scroll to top after a committed search
* query change. Skips the initial mount (`prev === null`, offset already 0)
* and no-ops when the committed query is unchanged.
*/
export function shouldResetScrollOnCommittedQuery(prev: string | null, next: string): boolean {
if (prev === null) {
return false;
}
return prev !== next;
}
54 changes: 54 additions & 0 deletions apps/mobile/src/components/agents/session-platform-icon.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from 'vitest';

import { sessionPlatformIconKind } from './session-platform-icon';

// The module under test is a .tsx that imports Lucide / brand icons (RN).
// Stub those so the pure mapper can be unit-tested in the node environment.
// (`vi.mock` calls are hoisted above the import by vitest.)
vi.mock('lucide-react-native', () => ({
Cloud: () => null,
Code: () => null,
Terminal: () => null,
}));
vi.mock('@/components/icons/github-icon', () => ({
GitHubIcon: () => null,
}));
vi.mock('@/components/icons/slack-icon', () => ({
SlackIcon: () => null,
}));

describe('sessionPlatformIconKind', () => {
it('maps cloud-agent and cloud-agent-web to cloud', () => {
expect(sessionPlatformIconKind('cloud-agent')).toBe('cloud');
expect(sessionPlatformIconKind('cloud-agent-web')).toBe('cloud');
});

it('maps cli to terminal', () => {
expect(sessionPlatformIconKind('cli')).toBe('terminal');
});

it('maps vscode and agent-manager to code', () => {
expect(sessionPlatformIconKind('vscode')).toBe('code');
expect(sessionPlatformIconKind('agent-manager')).toBe('code');
});

it('maps slack to slack', () => {
expect(sessionPlatformIconKind('slack')).toBe('slack');
});

it('maps github to github', () => {
expect(sessionPlatformIconKind('github')).toBe('github');
});

it('returns null for unmapped and absent platforms', () => {
expect(sessionPlatformIconKind('unknown')).toBeNull();
expect(sessionPlatformIconKind('other')).toBeNull();
expect(sessionPlatformIconKind('gastown')).toBeNull();
expect(sessionPlatformIconKind('linear')).toBeNull();
expect(sessionPlatformIconKind('app-builder')).toBeNull();
expect(sessionPlatformIconKind('agent-builder')).toBeNull();
expect(sessionPlatformIconKind(null)).toBeNull();
expect(sessionPlatformIconKind(undefined)).toBeNull();
expect(sessionPlatformIconKind('')).toBeNull();
});
});
66 changes: 66 additions & 0 deletions apps/mobile/src/components/agents/session-platform-icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { type ReactElement } from 'react';
import { Cloud, Code, Terminal } from 'lucide-react-native';

import { GitHubIcon } from '@/components/icons/github-icon';
import { SlackIcon } from '@/components/icons/slack-icon';

type SessionPlatformIconKind = 'cloud' | 'terminal' | 'code' | 'slack' | 'github';

const PLATFORM_TO_KIND: Readonly<Record<string, SessionPlatformIconKind>> = {
'cloud-agent': 'cloud',
'cloud-agent-web': 'cloud',
cli: 'terminal',
vscode: 'code',
'agent-manager': 'code',
slack: 'slack',
github: 'github',
};

/**
* Map a backend `created_on_platform` string to a list/detail icon kind.
* Unmapped / absent platforms return null so callers render nothing
* (subtle-by-design — no wrong or generic glyph).
*/
export function sessionPlatformIconKind(
platform: string | null | undefined
): SessionPlatformIconKind | null {
if (platform == null || platform === '') {
return null;
}
return PLATFORM_TO_KIND[platform] ?? null;
}

type SessionPlatformIconProps = Readonly<{
platform: string | null | undefined;
size: number;
color: string;
}>;

/**
* Pure renderer for the session-origin platform glyph. Returns null when
* the platform is unmapped. No wrapper View, testID, or a11y props —
* call sites own those.
*/
export function SessionPlatformIcon({
platform,
size,
color,
}: SessionPlatformIconProps): ReactElement | null {
const kind = sessionPlatformIconKind(platform);
if (kind === 'cloud') {
return <Cloud size={size} color={color} />;
}
if (kind === 'terminal') {
return <Terminal size={size} color={color} />;
}
if (kind === 'code') {
return <Code size={size} color={color} />;
}
if (kind === 'slack') {
return <SlackIcon size={size} color={color} />;
}
if (kind === 'github') {
return <GitHubIcon size={size} color={color} />;
}
return null;
}
Loading