Skip to content
Merged
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
53 changes: 53 additions & 0 deletions apps/mobile/src/components/agents/active-now-section.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';

import { RemoteSessionRow } from '@/components/agents/session-row';
import { SessionListSectionHeader } from '@/components/agents/session-list-section-header';
import { type ActiveSession } from '@/lib/hooks/use-agent-sessions';

type ActiveNowSectionProps = {
/** Pinned sessions. The section renders `null` when empty. */
pinned: ActiveSession[];
/**
* Organization id for each session id, when one is known from the stored
* pages. Tray rows that also live in history reuse the stored org id so
* navigation stays in the user's org context.
*/
organizationIdBySessionId: Map<string, string | null | undefined>;
onSessionPress: (sessionId: string, organizationId?: string | null) => void;
};

/**
* Pinned "Active now" tray for the Agents session list. Renders above the
* history list. The section never scrolls itself — it sits inside the
* screen's non-scrolling vertical layout so it animates in/out with
* Reanimated `FadeIn`/`FadeOut` while the screen's `LinearTransition`
* wrappers absorb the layout change without jumping the history list.
*/
export function ActiveNowSection({
pinned,
organizationIdBySessionId,
onSessionPress,
}: Readonly<ActiveNowSectionProps>) {
if (pinned.length === 0) {
return null;
}

return (
<Animated.View
entering={FadeIn.duration(180)}
exiting={FadeOut.duration(120)}
className="bg-background"
>
<SessionListSectionHeader title="Active now" count={pinned.length} />
{pinned.map(session => (
<RemoteSessionRow
key={session.id}
session={session}
onPress={() => {
onSessionPress(session.id, organizationIdBySessionId.get(session.id));
}}
/>
))}
</Animated.View>
);
}
81 changes: 81 additions & 0 deletions apps/mobile/src/components/agents/session-list-body-empty.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { History, SearchX } from 'lucide-react-native';
import { type ReactNode } from 'react';
import { View } from 'react-native';

import { EmptyState } from '@/components/empty-state';
import { QueryError } from '@/components/query-error';

type BodyEmptyProps = {
kind: 'filtered-empty' | 'query-error-empty' | 'no-past-sessions';
isSearching: boolean;
secondaryAction?: 'clear-search' | 'clear-filters' | 'none';
emptyStateAction: ReactNode;
clearQueryAction: ReactNode;
onRetry: () => void;
};

/**
* Renders the body empty-state for the Agents session list, switched on the
* `kind` returned by the body render model. Each branch is a compact
* `View` matching the design language of the rest of the list (icon + title
* + description + one CTA).
*/
export function BodyEmpty({
kind,
isSearching,
secondaryAction,
emptyStateAction,
clearQueryAction,
onRetry,
}: Readonly<BodyEmptyProps>) {
if (kind === 'filtered-empty') {
// Active search/filter narrowed the results to zero matches — never
// show the "create a task" CTA here, it's not the fix for a filter
// that's too narrow.
return (
<View className="items-center justify-center pt-16">
<EmptyState
icon={SearchX}
title="No sessions match"
description={
isSearching ? 'Try a different search term.' : 'Try adjusting or clearing your filters.'
}
action={clearQueryAction}
/>
</View>
);
}
if (kind === 'query-error-empty') {
// The query in error produced no rows to show — surface a retry for
// it (search or list, whichever `onRetry` targets). A Clear CTA is
// shown whenever the model reports an active query, choosing the
// label that matches the query type.
return (
<View className="items-center gap-4 pt-16">
<QueryError
placement="top"
className="pt-0"
message={isSearching ? 'Could not search sessions' : 'Could not load sessions'}
onRetry={onRetry}
/>
{secondaryAction === 'clear-search' || secondaryAction === 'clear-filters'
? clearQueryAction
: null}
</View>
);
}
// 'no-past-sessions' — body is empty but the tray is populated. The
// screen-level first-use empty ("No sessions yet") is handled by the
// caller when there is no tray either.
return (
<View className="items-center justify-center pt-12">
<EmptyState
icon={History}
title="No past sessions"
description="Completed sessions will appear here."
action={emptyStateAction}
placement="top"
/>
</View>
);
}
200 changes: 200 additions & 0 deletions apps/mobile/src/components/agents/session-list-body-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { describe, expect, it } from 'vitest';

import { selectSessionListBodyModel } from './session-list-body-model';

function model(overrides: Partial<Parameters<typeof selectSessionListBodyModel>[0]> = {}) {
return selectSessionListBodyModel({
hasHistoryContent: false,
hasPinnedActive: false,
hasActiveQuery: false,
isSearching: false,
isError: false,
activeIsError: false,
...overrides,
});
}

describe('selectSessionListBodyModel', () => {
describe('happy (history present)', () => {
it('renders the list with no CTA and no inline error', () => {
expect(
model({
hasHistoryContent: true,
hasPinnedActive: true,
activeIsError: true,
})
).toEqual({ kind: 'render-list', primaryAction: 'none', showInlineError: true });
});

it('hides the inline error when nothing is in error and history is shown', () => {
expect(model({ hasHistoryContent: true })).toEqual({
kind: 'render-list',
primaryAction: 'none',
showInlineError: false,
});
});
});

describe('empty body with active query', () => {
it('shows filtered-empty + Clear search when a search is active', () => {
expect(
model({
hasHistoryContent: false,
hasActiveQuery: true,
isSearching: true,
})
).toEqual({
kind: 'filtered-empty',
primaryAction: 'clear-search',
showInlineError: false,
});
});

it('shows filtered-empty + Clear filters when only a narrowing filter is active', () => {
expect(
model({
hasHistoryContent: false,
hasActiveQuery: true,
})
).toEqual({
kind: 'filtered-empty',
primaryAction: 'clear-filters',
showInlineError: false,
});
});

it('shows query-error + Retry + Clear search for a search in error', () => {
expect(
model({
hasHistoryContent: false,
hasActiveQuery: true,
isSearching: true,
isError: true,
})
).toEqual({
kind: 'query-error-empty',
primaryAction: 'retry',
secondaryAction: 'clear-search',
showInlineError: false,
});
});

it('shows query-error + Retry + Clear filters for a filter in error', () => {
expect(
model({
hasHistoryContent: false,
hasActiveQuery: true,
isError: true,
})
).toEqual({
kind: 'query-error-empty',
primaryAction: 'retry',
secondaryAction: 'clear-filters',
showInlineError: false,
});
});

it('a populated tray does not change the active-query body decision', () => {
expect(
model({
hasHistoryContent: false,
hasPinnedActive: true,
hasActiveQuery: true,
isSearching: true,
}).kind
).toBe('filtered-empty');
});
});

describe('empty body without active query', () => {
it('shows retryable error empty with Retry (no Clear) when the body errored', () => {
expect(
model({
hasHistoryContent: false,
isError: true,
})
).toEqual({
kind: 'query-error-empty',
primaryAction: 'retry',
secondaryAction: 'none',
showInlineError: false,
});
});

it('shows the compact "No past sessions" empty with New coding task when no error and a tray is present', () => {
expect(
model({
hasHistoryContent: false,
hasPinnedActive: true,
})
).toEqual({
kind: 'no-past-sessions',
primaryAction: 'new-task',
showInlineError: false,
});
});

it('returns no-past-sessions even when the tray is empty (first-use is handled by the caller)', () => {
expect(model({ hasHistoryContent: false })).toEqual({
kind: 'no-past-sessions',
primaryAction: 'new-task',
showInlineError: false,
});
});
});

describe('inline error / staleness surfacing', () => {
it('shows the inline error when only the active poll failed and the tray is present', () => {
expect(
model({
hasHistoryContent: false,
hasPinnedActive: true,
activeIsError: true,
})
).toEqual({
kind: 'no-past-sessions',
primaryAction: 'new-task',
showInlineError: true,
});
});

it('does not show the inline error when only the active poll failed and nothing is visible', () => {
expect(
model({
hasHistoryContent: false,
hasPinnedActive: false,
activeIsError: true,
})
).toEqual({
kind: 'no-past-sessions',
primaryAction: 'new-task',
showInlineError: false,
});
});

it('does NOT show the inline error when the body and tray are empty even if search+active errored', () => {
expect(
model({
hasHistoryContent: false,
hasActiveQuery: true,
isSearching: true,
isError: true,
activeIsError: true,
}).showInlineError
).toBe(false);
});

it('does NOT collapse a simultaneous search+active failure into the search-error message (search surface still wins)', () => {
const result = model({
hasHistoryContent: false,
hasActiveQuery: true,
isSearching: true,
isError: true,
activeIsError: true,
});
expect(result.kind).toBe('query-error-empty');
expect(result.primaryAction).toBe('retry');
expect(result.showInlineError).toBe(false);
});
});
});
Loading