diff --git a/apps/mobile/src/components/agents/active-now-section.tsx b/apps/mobile/src/components/agents/active-now-section.tsx new file mode 100644 index 0000000000..1ceedfa37d --- /dev/null +++ b/apps/mobile/src/components/agents/active-now-section.tsx @@ -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; + 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) { + if (pinned.length === 0) { + return null; + } + + return ( + + + {pinned.map(session => ( + { + onSessionPress(session.id, organizationIdBySessionId.get(session.id)); + }} + /> + ))} + + ); +} diff --git a/apps/mobile/src/components/agents/session-list-body-empty.tsx b/apps/mobile/src/components/agents/session-list-body-empty.tsx new file mode 100644 index 0000000000..caa289ce1e --- /dev/null +++ b/apps/mobile/src/components/agents/session-list-body-empty.tsx @@ -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) { + 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 ( + + + + ); + } + 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 ( + + + {secondaryAction === 'clear-search' || secondaryAction === 'clear-filters' + ? clearQueryAction + : null} + + ); + } + // '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 ( + + + + ); +} diff --git a/apps/mobile/src/components/agents/session-list-body-model.test.ts b/apps/mobile/src/components/agents/session-list-body-model.test.ts new file mode 100644 index 0000000000..58a2b7a519 --- /dev/null +++ b/apps/mobile/src/components/agents/session-list-body-model.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest'; + +import { selectSessionListBodyModel } from './session-list-body-model'; + +function model(overrides: Partial[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); + }); + }); +}); diff --git a/apps/mobile/src/components/agents/session-list-body-model.ts b/apps/mobile/src/components/agents/session-list-body-model.ts new file mode 100644 index 0000000000..fef9ddd9dc --- /dev/null +++ b/apps/mobile/src/components/agents/session-list-body-model.ts @@ -0,0 +1,139 @@ +/** + * Pure decision tree for the Agents session list body. + * + * Encapsulates the "what should the body show right now?" question so the + * component only has to map the result onto the existing UI pieces + * (`EmptyState`, `QueryError`, `SectionList`). Every input is a boolean + * flag, the output is a small discriminated union — there is no React or + * native dependency, so this module is unit-testable in plain Node. + * + * Classification rules (see Task 3 feature-state matrix): + * - The screen-level first-use empty ("No sessions yet" + New coding task) + * is handled by the caller BEFORE this function runs — it does not + * appear in the union. The model only decides what to render when + * there is at least some content (the screen-level "has any sessions" + * gate has already been passed, or the caller is asking what to put + * inside the SectionList body). + * - When the list body is empty (no history sections rendered): + * 1. Active search/filter query → filtered-empty OR query-error + * (when in error). The query-error variant always gets a Retry + * CTA; a secondary Clear CTA is shown for any active query + * ("Clear search" or "Clear filters"), while a no-query error + * shows only Retry. + * 2. No query, error → retry-capable error empty state. + * 3. No query, no error → compact "No past sessions" + New coding + * task CTA. + * - `showInlineError` mirrors the prior inline header ("Couldn't refresh. + * Pull down to try again.") and is ADDITIONALLY driven by the + * active-only failure flag (`activeIsError`) so the tray's non-blocking + * staleness surface is rendered whenever there is visible content. + */ + +export type SessionListBodyModel = + | { + kind: 'render-list'; + primaryAction: 'none'; + showInlineError: boolean; + } + | { + kind: 'filtered-empty'; + primaryAction: 'clear-search' | 'clear-filters'; + showInlineError: boolean; + } + | { + kind: 'query-error-empty'; + primaryAction: 'retry'; + secondaryAction: 'clear-search' | 'clear-filters' | 'none'; + showInlineError: boolean; + } + | { + kind: 'no-past-sessions'; + primaryAction: 'new-task'; + showInlineError: boolean; + }; + +type SessionListBodyModelInputs = { + /** True when rendered history sections contain at least one row. */ + hasHistoryContent: boolean; + /** + * True when the pinned "Active now" tray is non-empty. A populated tray + * suppresses the full-screen QueryError (the screen-level first-use + * gate already accounts for it) and contributes to the + * "visible cached content" check that drives the inline error line. + */ + hasPinnedActive: boolean; + /** True when a search query OR a platform/project filter is active. */ + hasActiveQuery: boolean; + /** True when the active search text is non-empty. */ + isSearching: boolean; + /** + * Body-driving error flag. The screen combines search and non-search + * errors here, BUT active-only failures are NOT folded in — they + * surface via `showInlineError` and never select the search-error + * message. + */ + isError: boolean; + /** + * Whether the active-poll query itself failed. Drives ONLY the inline + * error line when content is visible; never selects a body empty state + * or the search-error message. + */ + activeIsError: boolean; +}; + +export function selectSessionListBodyModel( + inputs: SessionListBodyModelInputs +): SessionListBodyModel { + const { + hasHistoryContent, + hasPinnedActive, + hasActiveQuery, + isSearching, + isError, + activeIsError, + } = inputs; + + const showInlineError = (isError || activeIsError) && (hasHistoryContent || hasPinnedActive); + + // History has rows — nothing to decide at the body level. + if (hasHistoryContent) { + return { kind: 'render-list', primaryAction: 'none', showInlineError }; + } + + // History empty: priority is the active query branch (even when the + // tray is populated — a query should still narrow the body). + if (hasActiveQuery) { + if (isError) { + // Query-error empty: Retry is always available. A Clear CTA is also + // shown whenever an active query exists, choosing the label that + // matches the active query type (search vs. narrowing filter). + return { + kind: 'query-error-empty', + primaryAction: 'retry', + secondaryAction: isSearching ? 'clear-search' : 'clear-filters', + showInlineError, + }; + } + return { + kind: 'filtered-empty', + primaryAction: isSearching ? 'clear-search' : 'clear-filters', + showInlineError, + }; + } + + // No active query, history empty. + if (isError) { + return { + kind: 'query-error-empty', + primaryAction: 'retry', + secondaryAction: 'none', + showInlineError, + }; + } + + return { + kind: 'no-past-sessions', + primaryAction: 'new-task', + showInlineError, + }; +} diff --git a/apps/mobile/src/components/agents/session-list-content.tsx b/apps/mobile/src/components/agents/session-list-content.tsx index 447e6ca59c..6300dd111d 100644 --- a/apps/mobile/src/components/agents/session-list-content.tsx +++ b/apps/mobile/src/components/agents/session-list-content.tsx @@ -1,27 +1,30 @@ import { useFocusEffect } from 'expo-router'; -import { Search, X } from 'lucide-react-native'; +import { Bot, Plus } from 'lucide-react-native'; import { useCallback, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, Platform, - Pressable, RefreshControl, SectionList, - TextInput, + type TextInput, useWindowDimensions, View, } from 'react-native'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { type SessionItem, type SessionSection } from '@/components/agents/session-list-helpers'; +import { BodyEmpty } from '@/components/agents/session-list-body-empty'; import { - AgentSessionFilteredEmptyState, - AgentSessionListEmptyState, -} from '@/components/agents/session-list-empty-states'; -import { RemoteSessionRow, StoredSessionRow } from '@/components/agents/session-row'; + selectSessionListBodyModel, + type SessionListBodyModel, +} from '@/components/agents/session-list-body-model'; +import { type SessionSection } from '@/components/agents/session-list-helpers'; +import { SessionListSearchHeader } from '@/components/agents/session-list-search-header'; +import { SessionListSectionHeader } from '@/components/agents/session-list-section-header'; +import { StoredSessionRow } from '@/components/agents/session-row'; +import { EmptyState } from '@/components/empty-state'; import { QueryError } from '@/components/query-error'; -import { Eyebrow } from '@/components/ui/eyebrow'; +import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { type AgentSessionSortBy } from '@/lib/agent-session-sort'; @@ -36,11 +39,21 @@ const SEARCH_BAR_HEIGHT = 60; type AgentSessionListContentProps = { sections: SessionSection[]; - storedSessions: StoredSession[]; hasAnySessions: boolean; + /** True when the pinned "Active now" tray is non-empty. Used by the + * render model to keep the inline "Couldn't refresh" line visible and + * to suppress the full-screen QueryError when the tray is the only + * thing on screen. */ + hasPinnedActive: boolean; isLoading: boolean; isSearchPending: boolean; + /** Body-driving error flag — a search failure (when searching) OR a + * stored/history failure. Active-only failures are surfaced separately + * via the body's `showInlineError` output, NEVER as the empty-state + * message. */ isError: boolean; + /** Active-poll failure — drives ONLY the inline staleness line. */ + activeIsError: boolean; isFetchingNextPage: boolean; refetch: () => Promise; onRetry: () => void; @@ -51,10 +64,10 @@ type AgentSessionListContentProps = { isSearching: boolean; onClearQuery: () => void; /** - * Narrow clear path used by the in-field X: resets the debounced - * search query (and the local `hasText` flag) WITHOUT touching the - * persisted platform/project narrowing filters. The broad - * `onClearQuery` path keeps owning that. + * Narrow clear path used by the in-field X: resets the debounced search + * query (and the local `hasText` flag) WITHOUT touching the persisted + * platform/project filters. The empty-state "Clear search"/"Clear filters" + * CTA keeps owning the broader `onClearQuery` path. */ onClearSearchOnly: () => void; onCreateSession: () => void; @@ -63,11 +76,12 @@ type AgentSessionListContentProps = { export function AgentSessionListContent({ sections, - storedSessions, hasAnySessions, + hasPinnedActive, isLoading, isSearchPending, isError, + activeIsError, isFetchingNextPage, refetch, onRetry, @@ -87,37 +101,19 @@ export function AgentSessionListContent({ const { deleteSession, renameSession } = useSessionMutations(); const [refreshing, setRefreshing] = useState(false); const searchInputRef = useRef(null); - // Tracks whether the uncontrolled TextInput currently has visible - // text — the iOS search-field X is only rendered while this is true. - // Derived locally from `onChangeText`; the TextInput itself stays - // uncontrolled per the iOS TextInput rule (controlled `value` races - // with native keystrokes on iOS). + // Drives the in-field X's visibility only — the input itself stays + // uncontrolled (see iOS TextInput rules). Derived from `onChangeText`. const [hasText, setHasText] = useState(false); // The search TextInput is uncontrolled (see iOS TextInput rules — controlled // `value` causes keystroke races), so clearing the query in state alone // wouldn't clear what's visibly typed. Imperatively clear the input too. - // - // Broad path used by the empty-state "Clear search" / "Clear filters" - // CTAs: the screen's `onClearQuery` ALSO resets the persisted - // platform/project narrowing filters, which is why the X has its own - // narrower handler below. const handleClearQuery = useCallback(() => { searchInputRef.current?.clear(); setHasText(false); onClearQuery(); }, [onClearQuery]); - // Narrow path used by the in-field X: clears the input, dismisses - // the keyboard, and resets the local `hasText` flag so the X - // disappears — all without touching the persisted narrowing filters. - const handleClearSearchOnly = useCallback(() => { - searchInputRef.current?.clear(); - searchInputRef.current?.blur(); - setHasText(false); - onClearSearchOnly(); - }, [onClearSearchOnly]); - const handleSearchInputChange = useCallback( (text: string) => { setHasText(text.length > 0); @@ -125,6 +121,15 @@ export function AgentSessionListContent({ }, [onSearchChange] ); + + // Narrow path used by the in-field X: clears the input, dismisses the + // keyboard, and resets `hasText` so the X hides — without touching filters. + const handleClearSearchOnly = useCallback(() => { + searchInputRef.current?.clear(); + searchInputRef.current?.blur(); + setHasText(false); + onClearSearchOnly(); + }, [onClearSearchOnly]); // The tab bar is an absolutely-positioned overlay, so scrollable content // must clear it or the last rows are stuck underneath it. const tabBarClearanceStyle = useMemo( @@ -132,65 +137,59 @@ export function AgentSessionListContent({ [bottom, fontScale] ); - // When the list is empty the error surface below (QueryError + retry) - // already covers it — don't double up with the inline header line. - const showInlineError = isError && sections.length > 0; + const hasHistoryContent = sections.length > 0; + + // Pure body decision — see `session-list-body-model.ts`. + const bodyModel = useMemo( + () => + selectSessionListBodyModel({ + hasHistoryContent, + hasPinnedActive, + hasActiveQuery, + isSearching, + isError, + activeIsError, + }), + [activeIsError, hasActiveQuery, hasHistoryContent, hasPinnedActive, isError, isSearching] + ); const listHeader = useMemo( () => ( - - - - - {hasText ? ( - - - - ) : null} - - {isSearchPending ? ( - - - - Searching… - - - ) : null} - {showInlineError ? ( - - Couldn't refresh. Pull down to try again. - - ) : null} - + ), [ - colors.mutedForeground, + bodyModel.showInlineError, handleClearSearchOnly, handleSearchInputChange, hasText, isSearchPending, - showInlineError, ] ); - const organizationIdBySessionId = useMemo( - () => new Map(storedSessions.map(s => [s.session_id, s.organization_id])), - [storedSessions] + const emptyStateAction = useMemo( + () => ( + + ), + [colors.foreground, onCreateSession] + ); + + const clearQueryAction = useMemo( + () => ( + + ), + [handleClearQuery, isSearching] ); // The tabs navigator uses `freezeOnBlur`, so while the session detail screen @@ -224,59 +223,32 @@ export function AgentSessionListContent({ }, [refetch]); const renderItem = useCallback( - ({ item }: { item: SessionItem }) => { - if (item.kind === 'stored') { - return ( - { - onSessionPress(item.session.session_id, item.session.organization_id); - }} - onDelete={() => { - deleteSession(item.session.session_id); - }} - onRename={newTitle => { - renameSession(item.session.session_id, newTitle); - }} - /> - ); - } - return ( - { - onSessionPress(item.session.id, organizationIdBySessionId.get(item.session.id)); - }} - /> - ); - }, - [onSessionPress, deleteSession, renameSession, organizationIdBySessionId, sortBy] + ({ item }: { item: StoredSession }) => ( + { + onSessionPress(item.session_id, item.organization_id); + }} + onDelete={() => { + deleteSession(item.session_id); + }} + onRename={newTitle => { + renameSession(item.session_id, newTitle); + }} + /> + ), + [onSessionPress, deleteSession, renameSession, sortBy] ); const renderSectionHeader = useCallback( ({ section }: { section: SessionSection }) => ( - - {section.title} - - {section.data.length} - - + ), [] ); - const keyExtractor = useCallback( - (item: SessionItem) => (item.kind === 'stored' ? item.session.session_id : item.session.id), - [] - ); + const keyExtractor = useCallback((item: StoredSession) => item.session_id, []); if (isLoading) { return ( @@ -293,7 +265,8 @@ export function AgentSessionListContent({ // Full-screen error only when there is nothing cached to fall back on — // a background refetch/search failure with stale sessions already in // cache (keepPreviousData) must never blank out what's already rendered. - if (isError && !hasAnySessions) { + // A populated tray counts as "something on screen" and also suppresses. + if (isError && !hasAnySessions && !hasPinnedActive) { return ( - + ); } - let emptyComponent = null; - if (hasActiveQuery) { + let emptyComponent: React.ReactNode = null; + if (bodyModel.kind !== 'render-list') { emptyComponent = ( - ); @@ -334,7 +317,7 @@ export function AgentSessionListContent({ return ( - + key={attentionListKey} sections={sections} renderItem={renderItem} diff --git a/apps/mobile/src/components/agents/session-list-empty-states.tsx b/apps/mobile/src/components/agents/session-list-empty-states.tsx deleted file mode 100644 index 24f0a54342..0000000000 --- a/apps/mobile/src/components/agents/session-list-empty-states.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { Bot, Plus, SearchX } from 'lucide-react-native'; -import { View } from 'react-native'; - -import { EmptyState } from '@/components/empty-state'; -import { QueryError } from '@/components/query-error'; -import { Button } from '@/components/ui/button'; -import { Text } from '@/components/ui/text'; -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; - -/** - * Shown when the user has no sessions at all (first use). Split out of - * `session-list-content.tsx` purely to keep that file under the repo's - * max-lines limit; it has no state of its own. - */ -export function AgentSessionListEmptyState({ - onCreateSession, -}: Readonly<{ onCreateSession: () => void }>) { - const colors = useThemeColors(); - return ( - - - - New coding task - - } - /> - - ); -} - -/** - * Shown when an active search or filter narrowed the results to zero - * matches (`filtered`), or when the search/list query itself errored - * while there were no cached rows to fall back on (`queryError`). Only - * reachable when `hasAnySessions` is true — the true first-use empty - * state is `AgentSessionListEmptyState` above. - */ -export function AgentSessionFilteredEmptyState({ - variant, - isSearching, - onClearQuery, - onRetry, -}: Readonly<{ - variant: 'filtered' | 'queryError'; - isSearching: boolean; - onClearQuery: () => void; - onRetry: () => void; -}>) { - const clearAction = ( - - ); - - if (variant === 'queryError') { - return ( - - - {clearAction} - - ); - } - - return ( - - - - ); -} diff --git a/apps/mobile/src/components/agents/session-list-helpers.test.ts b/apps/mobile/src/components/agents/session-list-helpers.test.ts new file mode 100644 index 0000000000..71ca5ec892 --- /dev/null +++ b/apps/mobile/src/components/agents/session-list-helpers.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, it } from 'vitest'; + +import { type ActiveSession } from '@/lib/hooks/use-agent-sessions'; +import { parseTimestamp, timeAgo } from '@/lib/utils'; + +import { + excludeActiveFromGroups, + expandPlatformFilter, + formatMeta, + platformLabel, + remoteAgentLabel, + remoteMeta, + selectPinnedActiveSessions, +} from './session-list-helpers'; +import { type AgentSessionDateGroup } from '@/lib/agent-session-groups'; + +function makeActive(over: Partial = {}): ActiveSession { + return { + id: 'a1', + status: 'running', + title: 'test', + connectionId: 'c1', + ...over, + }; +} + +describe('selectPinnedActiveSessions', () => { + it('does not dedup against stored pages (returns actives regardless)', () => { + // The helper intentionally has no stored-sessions parameter. The + // exclusivity contract lives on the history side (Task 3); the pinned + // set is always driven solely by filters and the active input. + const actives: ActiveSession[] = [makeActive({ id: 'a1' }), makeActive({ id: 'a2' })]; + const result = selectPinnedActiveSessions({ + activeSessions: actives, + projectFilter: [], + platformFilter: [], + }); + expect(result).toEqual(actives); + }); + + it('has no search parameter (search is ignored by construction)', () => { + // Compile-time assertion: selectPinnedActiveSessions must not accept + // a `search` key. If one is added later, @ts-expect-error stops firing + // and typecheck fails. + selectPinnedActiveSessions({ + activeSessions: [], + projectFilter: [], + platformFilter: [], + // @ts-expect-error — search must not be a param of selectPinnedActiveSessions + search: 'anything', + }); + }); + + describe('project rules', () => { + it('excludes rows with missing gitUrl under an active project filter', () => { + const withUrl = makeActive({ id: 'with', gitUrl: 'git@github.com:org/repo.git' }); + const withoutUrl = makeActive({ id: 'without' }); + const result = selectPinnedActiveSessions({ + activeSessions: [withUrl, withoutUrl], + projectFilter: ['git@github.com:org/repo.git'], + platformFilter: [], + }); + expect(result.map(s => s.id)).toEqual(['with']); + }); + + it('includes rows whose gitUrl is in the active project filter', () => { + const match = makeActive({ id: 'match', gitUrl: 'git@github.com:org/repo.git' }); + const other = makeActive({ id: 'other', gitUrl: 'git@github.com:org/other.git' }); + const result = selectPinnedActiveSessions({ + activeSessions: [match, other], + projectFilter: ['git@github.com:org/repo.git'], + platformFilter: [], + }); + expect(result.map(s => s.id)).toEqual(['match']); + }); + + it('includes every active session when no project filter is set', () => { + const a = makeActive({ id: 'a' }); + const b = makeActive({ id: 'b', gitUrl: 'git@github.com:org/repo.git' }); + const c = makeActive({ id: 'c', gitUrl: 'git@github.com:org/other.git' }); + const result = selectPinnedActiveSessions({ + activeSessions: [a, b, c], + projectFilter: [], + platformFilter: [], + }); + expect(result.map(s => s.id)).toEqual(['a', 'b', 'c']); + }); + }); + + describe('platform rules', () => { + it('includes rows whose createdOnPlatform matches a concrete filter', () => { + const cli = makeActive({ id: 'cli', createdOnPlatform: 'cli' }); + const slack = makeActive({ id: 'slack', createdOnPlatform: 'slack' }); + const result = selectPinnedActiveSessions({ + activeSessions: [cli, slack], + projectFilter: [], + platformFilter: ['cli'], + }); + expect(result.map(s => s.id)).toEqual(['cli']); + }); + + it('expands a cloud-agent filter to also match cloud-agent-web', () => { + const web = makeActive({ id: 'web', createdOnPlatform: 'cloud-agent-web' }); + const ca = makeActive({ id: 'ca', createdOnPlatform: 'cloud-agent' }); + const result = selectPinnedActiveSessions({ + activeSessions: [web, ca], + projectFilter: [], + platformFilter: ['cloud-agent'], + }); + expect(result.map(s => s.id).toSorted()).toEqual(['ca', 'web']); + }); + + it('expands an extension filter to vscode and agent-manager', () => { + const vscode = makeActive({ id: 'vscode', createdOnPlatform: 'vscode' }); + const am = makeActive({ id: 'am', createdOnPlatform: 'agent-manager' }); + const slack = makeActive({ id: 'slack', createdOnPlatform: 'slack' }); + const result = selectPinnedActiveSessions({ + activeSessions: [vscode, am, slack], + projectFilter: [], + platformFilter: ['extension'], + }); + expect(result.map(s => s.id).toSorted()).toEqual(['am', 'vscode']); + }); + + it("'other' filter matches an unknown platform", () => { + const weird = makeActive({ id: 'weird', createdOnPlatform: 'my-new-platform' }); + const result = selectPinnedActiveSessions({ + activeSessions: [weird], + projectFilter: [], + platformFilter: ['other'], + }); + expect(result.map(s => s.id)).toEqual(['weird']); + }); + + it("'other' filter rejects a known platform", () => { + const cli = makeActive({ id: 'cli', createdOnPlatform: 'cli' }); + const result = selectPinnedActiveSessions({ + activeSessions: [cli], + projectFilter: [], + platformFilter: ['other'], + }); + expect(result).toEqual([]); + }); + + it("'other' combined with a concrete filter accepts both", () => { + const cli = makeActive({ id: 'cli', createdOnPlatform: 'cli' }); + const weird = makeActive({ id: 'weird', createdOnPlatform: 'my-new-platform' }); + const vscode = makeActive({ id: 'vscode', createdOnPlatform: 'vscode' }); + const result = selectPinnedActiveSessions({ + activeSessions: [cli, weird, vscode], + projectFilter: [], + platformFilter: ['cli', 'other'], + }); + expect(result.map(s => s.id).toSorted()).toEqual(['cli', 'weird']); + }); + + it('excludes rows with undefined createdOnPlatform under any platform filter', () => { + const unknown = makeActive({ id: 'unknown' }); + const cli = makeActive({ id: 'cli', createdOnPlatform: 'cli' }); + const result = selectPinnedActiveSessions({ + activeSessions: [unknown, cli], + projectFilter: [], + platformFilter: ['cli'], + }); + expect(result.map(s => s.id)).toEqual(['cli']); + }); + + it('includes rows with undefined createdOnPlatform when no platform filter is set', () => { + const unknown = makeActive({ id: 'unknown' }); + const cli = makeActive({ id: 'cli', createdOnPlatform: 'cli' }); + const result = selectPinnedActiveSessions({ + activeSessions: [unknown, cli], + projectFilter: [], + platformFilter: [], + }); + expect(result.map(s => s.id).toSorted()).toEqual(['cli', 'unknown']); + }); + }); + + it('combines project and platform filters with AND semantics', () => { + const pass = makeActive({ + id: 'pass', + gitUrl: 'git@github.com:org/repo.git', + createdOnPlatform: 'cli', + }); + const wrongProject = makeActive({ + id: 'wrongProject', + gitUrl: 'git@github.com:org/other.git', + createdOnPlatform: 'cli', + }); + const wrongPlatform = makeActive({ + id: 'wrongPlatform', + gitUrl: 'git@github.com:org/repo.git', + createdOnPlatform: 'slack', + }); + const result = selectPinnedActiveSessions({ + activeSessions: [pass, wrongProject, wrongPlatform], + projectFilter: ['git@github.com:org/repo.git'], + platformFilter: ['cli'], + }); + expect(result.map(s => s.id)).toEqual(['pass']); + }); +}); + +describe('remoteAgentLabel', () => { + it('returns the platform label for cli', () => { + expect(remoteAgentLabel('cli')).toBe('CLI'); + }); + + it('returns the platform label for cloud-agent-web', () => { + expect(remoteAgentLabel('cloud-agent-web')).toBe('CLOUD AGENT'); + }); + + it("returns 'LIVE' for undefined", () => { + expect(remoteAgentLabel(undefined)).toBe('LIVE'); + }); +}); + +describe('remoteMeta', () => { + it('returns the uppercased relative time when updatedAt is present', () => { + const updatedAt = '2024-01-01T00:00:00.000Z'; + const expected = timeAgo(parseTimestamp(updatedAt)).toUpperCase(); + expect(remoteMeta({ status: 'running', updatedAt })).toBe(expected); + }); + + it('returns the uppercased status when updatedAt is absent', () => { + expect(remoteMeta({ status: 'running' })).toBe('RUNNING'); + expect(remoteMeta({ status: 'needs_input' })).toBe('NEEDS_INPUT'); + }); +}); + +describe('platformLabel (moved helper, regression guard)', () => { + it('matches the original mapping', () => { + expect(platformLabel('cloud-agent')).toBe('CLOUD AGENT'); + expect(platformLabel('cloud-agent-web')).toBe('CLOUD AGENT'); + expect(platformLabel('vscode')).toBe('VSCODE'); + expect(platformLabel('agent-manager')).toBe('VSCODE'); + expect(platformLabel('slack')).toBe('SLACK'); + expect(platformLabel('cli')).toBe('CLI'); + expect(platformLabel('my-new-platform')).toBe('MY-NEW-PLATFORM'); + }); +}); + +describe('formatMeta (moved helper, regression guard)', () => { + it('matches the original timeAgo + toUpperCase behavior', () => { + expect(formatMeta('2024-01-01T00:00:00.000Z')).toBe( + timeAgo(parseTimestamp('2024-01-01T00:00:00.000Z')).toUpperCase() + ); + }); +}); + +type ExcludeRow = { session_id: string; created_at: string; updated_at: string }; + +function makeGroup(label: string, ids: string[]): AgentSessionDateGroup { + return { + label, + sessions: ids.map(id => ({ session_id: id, created_at: '', updated_at: '' })), + }; +} + +describe('excludeActiveFromGroups', () => { + type Row = ExcludeRow; + + it('drops matching sessions and drops groups that become empty', () => { + const groups: AgentSessionDateGroup[] = [ + makeGroup('Today', ['a', 'b', 'c']), + makeGroup('Yesterday', ['d', 'e']), + makeGroup('Older', ['f']), + ]; + const result = excludeActiveFromGroups(groups, new Set(['b', 'e', 'f'])); + expect(result.map(g => g.label)).toEqual(['Today', 'Yesterday']); + expect(result[0]?.sessions.map(s => s.session_id)).toEqual(['a', 'c']); + expect(result[1]?.sessions.map(s => s.session_id)).toEqual(['d']); + }); + + it('preserves order when nothing is excluded', () => { + const groups: AgentSessionDateGroup[] = [ + makeGroup('Today', ['a']), + makeGroup('Yesterday', ['b']), + makeGroup('Older', ['c']), + ]; + const result = excludeActiveFromGroups(groups, new Set()); + expect(result.map(g => g.label)).toEqual(['Today', 'Yesterday', 'Older']); + expect(result.map(g => g.sessions.map(s => s.session_id))).toEqual([['a'], ['b'], ['c']]); + }); + + it('returns an empty array when every session is active', () => { + const groups: AgentSessionDateGroup[] = [ + makeGroup('Today', ['a']), + makeGroup('Yesterday', ['b']), + ]; + const result = excludeActiveFromGroups(groups, new Set(['a', 'b'])); + expect(result).toEqual([]); + }); + + it('preserves group label and remaining session order when partially excluded', () => { + const groups: AgentSessionDateGroup[] = [ + makeGroup('Today', ['x', 'y', 'z']), + makeGroup('Older', ['p', 'q']), + ]; + const result = excludeActiveFromGroups(groups, new Set(['y'])); + expect(result.map(g => g.label)).toEqual(['Today', 'Older']); + expect(result[0]?.sessions.map(s => s.session_id)).toEqual(['x', 'z']); + expect(result[1]?.sessions.map(s => s.session_id)).toEqual(['p', 'q']); + }); +}); + +describe('expandPlatformFilter (regression guard for filter expansion)', () => { + it('expands cloud-agent to include cloud-agent-web', () => { + expect(expandPlatformFilter(['cloud-agent']).toSorted()).toEqual( + ['cloud-agent', 'cloud-agent-web'].toSorted() + ); + }); + + it('expands extension to vscode and agent-manager', () => { + expect(expandPlatformFilter(['extension']).toSorted()).toEqual( + ['agent-manager', 'vscode'].toSorted() + ); + }); + + it('passes through unknown concrete values unchanged', () => { + expect(expandPlatformFilter(['cli', 'other'])).toEqual(['cli', 'other']); + }); +}); diff --git a/apps/mobile/src/components/agents/session-list-helpers.ts b/apps/mobile/src/components/agents/session-list-helpers.ts index 2630621f49..cc5c888df9 100644 --- a/apps/mobile/src/components/agents/session-list-helpers.ts +++ b/apps/mobile/src/components/agents/session-list-helpers.ts @@ -1,21 +1,17 @@ -import { type ActiveSession, type StoredSession } from '@/lib/hooks/use-agent-sessions'; - -export type StoredSessionItem = { - kind: 'stored'; - session: StoredSession; - isLive: boolean; -}; +import { KNOWN_PLATFORMS } from '@kilocode/app-shared/platforms'; -export type RemoteSessionItem = { - kind: 'remote'; - session: ActiveSession; -}; - -export type SessionItem = StoredSessionItem | RemoteSessionItem; +import { type AgentSessionDateGroup } from '@/lib/agent-session-groups'; +import { type ActiveSession, type StoredSession } from '@/lib/hooks/use-agent-sessions'; +import { parseTimestamp, timeAgo } from '@/lib/utils'; +/** + * One stored-history section. Exclusivity against the "Active now" tray is + * enforced on the history side via `excludeActiveFromGroups`; active sessions + * no longer appear as `SessionItem`s in any section. + */ export type SessionSection = { title: string; - data: SessionItem[]; + data: StoredSession[]; }; const platformExpansion: Record = { @@ -60,9 +56,117 @@ export function formatGitUrlProject(gitUrl: string): string { return gitUrl; } -export function matchesSearch(query: string, title: string | null, gitUrl: string | null): boolean { - const q = query.toLowerCase(); - return ( - (title?.toLowerCase().includes(q) ?? false) || (gitUrl?.toLowerCase().includes(q) ?? false) - ); +/** + * Map backend `created_on_platform` strings to a pretty uppercase label + * for the row eyebrow. The row's hue is hashed from this label. + */ +export function platformLabel(platform: string): string { + switch (platform) { + case 'cloud-agent': + case 'cloud-agent-web': { + return 'CLOUD AGENT'; + } + case 'vscode': + case 'agent-manager': { + return 'VSCODE'; + } + case 'slack': { + return 'SLACK'; + } + case 'cli': { + return 'CLI'; + } + default: { + return platform.toUpperCase(); + } + } +} + +export function formatMeta(timestamp: string): string { + return timeAgo(parseTimestamp(timestamp)).toUpperCase(); +} + +/** + * Pinned-tray label for an active session. Reuses `platformLabel` when the + * platform is known, otherwise falls back to 'LIVE'. + */ +export function remoteAgentLabel(createdOnPlatform: string | undefined): string { + return createdOnPlatform ? platformLabel(createdOnPlatform) : 'LIVE'; +} + +/** + * Pinned-tray meta line for an active session. Mirrors `formatMeta` when an + * `updatedAt` timestamp is available, otherwise falls back to the uppercased + * status string (matches the legacy RemoteSessionRow behavior). + */ +export function remoteMeta(session: { status: string; updatedAt?: string }): string { + return session.updatedAt ? formatMeta(session.updatedAt) : session.status.toUpperCase(); +} + +const KNOWN_PLATFORM_VALUES: readonly string[] = KNOWN_PLATFORMS; + +/** + * Select which active sessions appear in the pinned "Active now" tray. + * + * Free-text search is not a parameter: the pinned set ignores search by + * construction. Filters mirror the server-side platform/project narrowing + * used by the stored-session list so the tray never shows a session that + * the user has explicitly filtered out. + * + * No dedup against stored pages is performed here — exclusivity is enforced + * on the history side by Task 3, so this helper stays pure and symmetric. + */ +export function selectPinnedActiveSessions(params: { + activeSessions: ActiveSession[]; + projectFilter: string[]; + platformFilter: string[]; +}): ActiveSession[] { + const { activeSessions, projectFilter, platformFilter } = params; + const projectActive = projectFilter.length > 0; + const platformActive = platformFilter.length > 0; + + const concretePlatforms = platformFilter.filter(p => p !== 'other'); + const includeOther = platformFilter.includes('other'); + const expanded = expandPlatformFilter(concretePlatforms); + return activeSessions.filter(session => { + if (projectActive && (!session.gitUrl || !projectFilter.includes(session.gitUrl))) { + return false; + } + + if (platformActive) { + if (!session.createdOnPlatform) { + return false; + } + const knownMatch = expanded.includes(session.createdOnPlatform); + const otherMatch = includeOther && !KNOWN_PLATFORM_VALUES.includes(session.createdOnPlatform); + if (!knownMatch && !otherMatch) { + return false; + } + } + + return true; + }); +} + +/** + * Drop sessions whose `session_id` is in the active set from date-bucketed + * groups, and drop any groups that become empty as a result. Preserves the + * original group order. + * + * The generic is constrained to the intersection of what the helper needs + * (`session_id`) and what `AgentSessionDateGroup` itself requires + * (`created_at`/`updated_at`); the Task 3 caller passes + * `AgentSessionDateGroup[]` which satisfies both bounds. + */ +export function excludeActiveFromGroups< + T extends { session_id: string; created_at: string; updated_at: string }, +>(groups: AgentSessionDateGroup[], activeSessionIds: Set): AgentSessionDateGroup[] { + const result: AgentSessionDateGroup[] = []; + for (const group of groups) { + const remaining = group.sessions.filter(s => !activeSessionIds.has(s.session_id)); + if (remaining.length > 0) { + result.push({ label: group.label, sessions: remaining }); + } + } + return result; } diff --git a/apps/mobile/src/components/agents/session-list-screen.tsx b/apps/mobile/src/components/agents/session-list-screen.tsx index 3195b7cff0..21ed50e989 100644 --- a/apps/mobile/src/components/agents/session-list-screen.tsx +++ b/apps/mobile/src/components/agents/session-list-screen.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { View } from 'react-native'; import Animated, { LinearTransition } from 'react-native-reanimated'; +import { ActiveNowSection } from '@/components/agents/active-now-section'; import { getNewAgentSessionPath } from '@/components/agents/session-list-routes'; import { AgentSessionListContent } from '@/components/agents/session-list-content'; import { SessionListHeaderActions } from '@/components/agents/session-list-header-actions'; @@ -16,12 +17,11 @@ import { SessionFilterModal, } from '@/components/agents/platform-filter-modal'; import { + excludeActiveFromGroups, expandPlatformFilter, formatGitUrlProject, - matchesSearch, - type RemoteSessionItem, + selectPinnedActiveSessions, type SessionSection, - type StoredSessionItem, } from '@/components/agents/session-list-helpers'; import { ScreenHeader } from '@/components/screen-header'; import { @@ -98,8 +98,9 @@ export function AgentSessionListScreen() { dateGroups, activeSessions, activeSessionIds, + activeIsError, isLoading, - isError, + storedIsError, hasNextPage, isFetchingNextPage, fetchNextPage, @@ -125,20 +126,31 @@ export function AgentSessionListScreen() { enabled: ready, }); - // While searching, only the search query's own error/pending state matters — - // it's the one actually driving what's on screen. Retrying should hit - // whichever query is really in error instead of always refetching the base - // list underneath a failed search. - const contentIsError = isSearching ? search.isError : isError; + // The body's error/empty state is driven only by the query that actually + // fills the body: the search query while searching, otherwise the stored + // (history) list. A transient active-poll blip must never fold into this — + // it surfaces solely through the inline "Couldn't refresh" line via + // `activeIsError`. Retrying still hits whichever query is really in error + // instead of always refetching the base list underneath a failed search; + // an active-only failure during search additionally retries the active poll + // so the inline staleness line clears. + const contentIsError = isSearching ? search.isError : storedIsError; const isSearchPending = isSearching && search.isPending; const searchRefetch = search.refetch; const handleRetry = useCallback(() => { - if (isSearching) { - void searchRefetch(); - } else { + if (!isSearching) { void refetch(); + return; + } + if (activeIsError) { + // search is the body, active is the tray — retry both. + void (async () => { + await Promise.all([searchRefetch(), refetch()]); + })(); + return; } - }, [isSearching, searchRefetch, refetch]); + void searchRefetch(); + }, [activeIsError, isSearching, refetch, searchRefetch]); // Pull-to-refresh must also retry the search query while one is active — // it's the query actually driving what's on screen. @@ -190,69 +202,33 @@ export function AgentSessionListScreen() { // `isSearchPending` drives a lightweight inline indicator instead. const effectiveSearchQuery = isSearchPending ? '' : searchQuery; - const sections = useMemo(() => { - const result: SessionSection[] = []; - const storedSessionIds = new Set(storedSessions.map(session => session.session_id)); - - const filteredActive = activeSessions.filter(session => { - if (storedSessionIds.has(session.id)) { - return false; - } - - if (projectFilter.length > 0 && !session.gitUrl) { - return false; - } - - if (projectFilter.length > 0 && session.gitUrl && !projectFilter.includes(session.gitUrl)) { - return false; - } - - return effectiveSearchQuery - ? matchesSearch(effectiveSearchQuery, session.title, session.gitUrl ?? null) - : true; - }); + // Pinned "Active now" tray. Free-text search is intentionally NOT a + // narrowing input here — the tray persists while the user types. The + // helper applies only the platform/project filters so the tray never + // shows a session the user has explicitly filtered out. + const pinnedActive = useMemo( + () => selectPinnedActiveSessions({ activeSessions, projectFilter, platformFilter }), + [activeSessions, platformFilter, projectFilter] + ); + const hasPinnedActive = pinnedActive.length > 0; - if (filteredActive.length > 0) { - result.push({ - title: 'Remote', - data: filteredActive.map( - (session): RemoteSessionItem => ({ - kind: 'remote', - session, - }) - ), - }); - } + const organizationIdBySessionId = useMemo( + () => new Map(storedSessions.map(s => [s.session_id, s.organization_id])), + [storedSessions] + ); + // History sections only. The pinned tray takes over for active sessions + // and `excludeActiveFromGroups` keeps history exclusivity. + const sections = useMemo(() => { // Stored sessions are cursor-paginated, so a client-side filter would only // see the loaded pages. When a query is active, use the server search // results (which cover the full history) instead. const storedGroups = effectiveSearchQuery ? search.dateGroups : dateGroups; - for (const group of storedGroups) { - if (group.sessions.length > 0) { - result.push({ - title: group.label, - data: group.sessions.map( - (session): StoredSessionItem => ({ - kind: 'stored', - session, - isLive: activeSessionIds.has(session.session_id), - }) - ), - }); - } - } - - return result; - }, [ - activeSessionIds, - activeSessions, - dateGroups, - effectiveSearchQuery, - projectFilter, - search.dateGroups, - storedSessions, - ]); + return excludeActiveFromGroups(storedGroups, activeSessionIds).map(group => ({ + title: group.label, + data: group.sessions, + })); + }, [activeSessionIds, dateGroups, effectiveSearchQuery, search.dateGroups]); const navigateToSession = useCallback( (sessionId: string, sessionOrgId?: string | null) => { @@ -313,14 +289,20 @@ export function AgentSessionListScreen() { }} /> + ; + /** Drives the in-field X's visibility. Derived from `onChangeText` by the + * parent so the TextInput itself stays uncontrolled (iOS TextInput rules). */ + hasText: boolean; + isSearchPending: boolean; + showInlineError: boolean; + onChangeText: (text: string) => void; + onClearSearch: () => void; +}; + +export function SessionListSearchHeader({ + inputRef, + hasText, + isSearchPending, + showInlineError, + onChangeText, + onClearSearch, +}: Readonly) { + const colors = useThemeColors(); + return ( + + + + + {hasText ? ( + + + + ) : null} + + {isSearchPending ? ( + + + + Searching… + + + ) : null} + {showInlineError ? ( + + Couldn't refresh. Pull down to try again. + + ) : null} + + ); +} diff --git a/apps/mobile/src/components/agents/session-list-section-header.tsx b/apps/mobile/src/components/agents/session-list-section-header.tsx new file mode 100644 index 0000000000..78224e1d96 --- /dev/null +++ b/apps/mobile/src/components/agents/session-list-section-header.tsx @@ -0,0 +1,30 @@ +import { View } from 'react-native'; + +import { Eyebrow } from '@/components/ui/eyebrow'; +import { Text } from '@/components/ui/text'; + +type SessionListSectionHeaderProps = { + title: string; + count: number; +}; + +/** + * Shared "section header + mono count" row used by the Agents list + * date sections AND the pinned "Active now" tray. Matches the existing + * `flex-row items-center justify-between bg-background px-[22px] pb-2 + * pt-[18px]` header with `` + a mono count + * `text-[10px] uppercase tracking-[1.5px] text-muted-soft`. + */ +export function SessionListSectionHeader({ + title, + count, +}: Readonly) { + return ( + + {title} + + {count} + + + ); +} diff --git a/apps/mobile/src/components/agents/session-row.tsx b/apps/mobile/src/components/agents/session-row.tsx index d493136b02..0ed5e85f87 100644 --- a/apps/mobile/src/components/agents/session-row.tsx +++ b/apps/mobile/src/components/agents/session-row.tsx @@ -6,14 +6,14 @@ 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 { type ActiveSession } from '@/lib/hooks/use-agent-sessions'; import { isAttentionAcked, reconcileSessionAttention, shouldShowNeedsInput, useSessionAttentionRevision, } from '@/lib/session-attention'; -import { platformLabel } from '@/lib/platform-label'; -import { parseTimestamp, timeAgo } from '@/lib/utils'; +import { formatMeta, platformLabel, remoteAgentLabel, remoteMeta } from './session-list-helpers'; type StoredSessionRowProps = { session: { @@ -28,7 +28,6 @@ type StoredSessionRowProps = { status: string | null; status_updated_at: string | null; }; - isLive: boolean; /** * Which timestamp drives the row's relative meta label. The list * section the session lands in and the timestamp shown here are @@ -41,27 +40,10 @@ type StoredSessionRowProps = { }; type RemoteSessionRowProps = { - session: { - id: string; - title: string; - status: string; - gitBranch?: string; - /** - * Backend-reported platform for this live session (e.g. `'cli'` for a - * `kilo remote` connection, `'vscode'` for the extension). Legacy CLIs - * predating the platform field never report one; in that case the - * row falls back to the `'cloud-agent'` label so behavior is - * byte-identical to the previous hardcode. - */ - platform?: string; - }; + session: ActiveSession; onPress: () => void; }; -function formatMeta(timestamp: string): string { - return timeAgo(parseTimestamp(timestamp)).toUpperCase(); -} - function showDeleteConfirm(onDelete: () => void) { void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); Alert.alert('Delete session?', 'This cannot be undone.', [ @@ -93,7 +75,6 @@ function showRenamePrompt(currentTitle: string, onRename: (newTitle: string) => export function StoredSessionRow({ session, - isLive, sortBy, onPress, onDelete, @@ -176,7 +157,6 @@ export function StoredSessionRow({ title={title} subtitle={session.git_branch} meta={formatMeta(getAgentSessionTimestamp(session, sortBy))} - live={isLive} needsInput={needsInput} stripMode="inline" className="pl-[22px] pr-[22px]" @@ -237,12 +217,6 @@ export function StoredSessionRow({ export function RemoteSessionRow({ session, onPress }: Readonly) { const title = session.title.length > 0 ? session.title : 'Untitled session'; - // Platform present → real label via the shared helper. Platform absent - // (legacy CLI) → fall through to `'cloud-agent'`, which the helper - // renders as the same "CLOUD AGENT" string the row hardcoded before - // this slice. Keeping the literal in the helper (not a magic constant - // here) means future per-platform tweaks still apply to the legacy case. - const agentLabel = platformLabel(session.platform ?? 'cloud-agent'); const revision = useSessionAttentionRevision(); const raiseId = session.status; @@ -262,12 +236,13 @@ export function RemoteSessionRow({ session, onPress }: Readonly diff --git a/apps/mobile/src/components/ui/session-row-eyebrow-right.test.ts b/apps/mobile/src/components/ui/session-row-eyebrow-right.test.ts new file mode 100644 index 0000000000..d7811b0422 --- /dev/null +++ b/apps/mobile/src/components/ui/session-row-eyebrow-right.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; + +import { selectSessionRowEyebrowRight } from './session-row-eyebrow-right'; + +describe('selectSessionRowEyebrowRight', () => { + it('returns needs-input regardless of live/meta flags (highest priority)', () => { + expect( + selectSessionRowEyebrowRight({ + needsInput: true, + live: true, + hasMeta: true, + metaWhileLive: true, + }) + ).toEqual({ kind: 'needs-input' }); + expect( + selectSessionRowEyebrowRight({ + needsInput: true, + live: true, + hasMeta: true, + metaWhileLive: false, + }) + ).toEqual({ kind: 'needs-input' }); + expect( + selectSessionRowEyebrowRight({ + needsInput: true, + live: false, + hasMeta: false, + metaWhileLive: false, + }) + ).toEqual({ kind: 'needs-input' }); + }); + + it('returns live-and-meta only when opted in', () => { + expect( + selectSessionRowEyebrowRight({ + needsInput: false, + live: true, + hasMeta: true, + metaWhileLive: true, + }) + ).toEqual({ kind: 'live-and-meta' }); + }); + + it('returns live (not live-and-meta) when metaWhileLive is false even with meta and live', () => { + // Preserves Home's byte-for-byte default behavior. + expect( + selectSessionRowEyebrowRight({ + needsInput: false, + live: true, + hasMeta: true, + metaWhileLive: false, + }) + ).toEqual({ kind: 'live' }); + }); + + it('returns live when live is true and there is no meta', () => { + expect( + selectSessionRowEyebrowRight({ + needsInput: false, + live: true, + hasMeta: false, + metaWhileLive: true, + }) + ).toEqual({ kind: 'live' }); + }); + + it('returns meta when not live and meta is present', () => { + expect( + selectSessionRowEyebrowRight({ + needsInput: false, + live: false, + hasMeta: true, + metaWhileLive: false, + }) + ).toEqual({ kind: 'meta' }); + }); + + it('returns none when nothing is set', () => { + expect( + selectSessionRowEyebrowRight({ + needsInput: false, + live: false, + hasMeta: false, + metaWhileLive: false, + }) + ).toEqual({ kind: 'none' }); + }); + + it('precedence summary: needsInput > live+meta(composition) > live > meta > none', () => { + // needsInput beats everything + expect( + selectSessionRowEyebrowRight({ + needsInput: true, + live: true, + hasMeta: true, + metaWhileLive: true, + }).kind + ).toBe('needs-input'); + // live+meta composition + expect( + selectSessionRowEyebrowRight({ + needsInput: false, + live: true, + hasMeta: true, + metaWhileLive: true, + }).kind + ).toBe('live-and-meta'); + // live alone + expect( + selectSessionRowEyebrowRight({ + needsInput: false, + live: true, + hasMeta: true, + metaWhileLive: false, + }).kind + ).toBe('live'); + // meta alone + expect( + selectSessionRowEyebrowRight({ + needsInput: false, + live: false, + hasMeta: true, + metaWhileLive: false, + }).kind + ).toBe('meta'); + // none + expect( + selectSessionRowEyebrowRight({ + needsInput: false, + live: false, + hasMeta: false, + metaWhileLive: false, + }).kind + ).toBe('none'); + }); +}); diff --git a/apps/mobile/src/components/ui/session-row-eyebrow-right.ts b/apps/mobile/src/components/ui/session-row-eyebrow-right.ts new file mode 100644 index 0000000000..8cbf55a1fb --- /dev/null +++ b/apps/mobile/src/components/ui/session-row-eyebrow-right.ts @@ -0,0 +1,43 @@ +/** + * Pure decision for the right-hand side of `SessionRow`'s eyebrow row. + * + * The eyebrow can show at most one of: + * - a pulsing warn dot + `NEEDS INPUT` (highest priority) + * - a `metaWhileLive` composition: live dot + meta text + * - a live dot alone (default) + * - a meta text alone + * - nothing + * + * Home and the Agents list both call this, but only the Agents tray + * opts into `metaWhileLive`. Keeping the rule here makes it testable + * without a render tree. + */ +export type SessionRowEyebrowRight = + | { kind: 'needs-input' } + | { kind: 'live-and-meta' } + | { kind: 'live' } + | { kind: 'meta' } + | { kind: 'none' }; + +export function selectSessionRowEyebrowRight(inputs: { + needsInput: boolean; + live: boolean; + hasMeta: boolean; + metaWhileLive: boolean; +}): SessionRowEyebrowRight { + const { needsInput, live, hasMeta, metaWhileLive } = inputs; + + if (needsInput) { + return { kind: 'needs-input' }; + } + if (live && hasMeta && metaWhileLive) { + return { kind: 'live-and-meta' }; + } + if (live) { + return { kind: 'live' }; + } + if (hasMeta) { + return { kind: 'meta' }; + } + return { kind: 'none' }; +} diff --git a/apps/mobile/src/components/ui/session-row.tsx b/apps/mobile/src/components/ui/session-row.tsx index fccd6ebf0e..73e194ec94 100644 --- a/apps/mobile/src/components/ui/session-row.tsx +++ b/apps/mobile/src/components/ui/session-row.tsx @@ -4,6 +4,7 @@ import { Pressable, View } from 'react-native'; import { AgentBadge } from '@/components/ui/agent-badge'; import { Eyebrow } from '@/components/ui/eyebrow'; +import { selectSessionRowEyebrowRight } from '@/components/ui/session-row-eyebrow-right'; import { StatusDot } from '@/components/ui/status-dot'; import { Text } from '@/components/ui/text'; import { agentColor } from '@/lib/agent-color'; @@ -17,13 +18,21 @@ type SessionRowProps = { /** Small mono line shown below the title (e.g. git branch). */ subtitle?: string | null; meta?: string; - /** When true, renders a pulsing good-tone StatusDot before the meta. */ + /** When true, renders a good-tone StatusDot in the eyebrow. */ 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; + /** + * Opt-in: when true AND `live` AND `meta` are set (and `needsInput` is + * false), render the live dot AND the meta text side-by-side instead + * of choosing one. Default false — Home passes `meta` with `live` and + * must stay byte-for-byte unchanged. The Agents "Active now" tray + * opts in so tray rows show a dot beside the relative-time meta. + */ + metaWhileLive?: boolean; onPress?: () => void; /** Suppress bottom divider on the last row of a group. */ last?: boolean; @@ -50,6 +59,7 @@ export function SessionRow({ meta, live, needsInput = false, + metaWhileLive = false, onPress, last, stripMode = 'edge', @@ -59,8 +69,14 @@ export function SessionRow({ const color = agentColor(agentLabel); const dimStrip = !live && !needsInput; + const eyebrowDecision = selectSessionRowEyebrowRight({ + needsInput, + live: Boolean(live), + hasMeta: Boolean(meta), + metaWhileLive, + }); let eyebrowRight: React.ReactNode = null; - if (needsInput) { + if (eyebrowDecision.kind === 'needs-input') { eyebrowRight = ( @@ -69,9 +85,18 @@ export function SessionRow({ ); - } else if (live) { + } else if (eyebrowDecision.kind === 'live-and-meta') { + eyebrowRight = ( + + + + {meta} + + + ); + } else if (eyebrowDecision.kind === 'live') { eyebrowRight = ; - } else if (meta) { + } else if (eyebrowDecision.kind === 'meta' && meta) { eyebrowRight = ( {meta} diff --git a/apps/mobile/src/lib/agent-session-groups.ts b/apps/mobile/src/lib/agent-session-groups.ts index 0b0ffb3b28..1c2f64b400 100644 --- a/apps/mobile/src/lib/agent-session-groups.ts +++ b/apps/mobile/src/lib/agent-session-groups.ts @@ -7,7 +7,7 @@ import { parseTimestamp } from '@/lib/utils'; type SessionTimestamps = { created_at: string; updated_at: string }; -type AgentSessionDateGroup = { +export type AgentSessionDateGroup = { label: string; sessions: T[]; }; diff --git a/apps/mobile/src/lib/hooks/use-agent-sessions.ts b/apps/mobile/src/lib/hooks/use-agent-sessions.ts index ba69f95fdd..fb8e14ef70 100644 --- a/apps/mobile/src/lib/hooks/use-agent-sessions.ts +++ b/apps/mobile/src/lib/hooks/use-agent-sessions.ts @@ -1,6 +1,6 @@ import { type inferRouterOutputs, type RootRouter } from '@kilocode/trpc'; import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query'; -import { useMemo } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { buildAgentSessionListInput, @@ -175,6 +175,37 @@ export function useAgentSessions(options?: UseAgentSessionsOptions) { [storedSessions, sortBy] ); + // Departure-triggered stored refetch. Only the active poll has a refetch + // interval (10s); the stored/history list does not. When a session id + // leaves the active set, the just-terminated session has not yet shown up + // in history, so refetching once makes it reappear. We use `refetch()` so + // the fresh fetch bypasses the 30s `staleTime` that would otherwise keep + // the cached page hidden. The refetch only refreshes loaded pages — + // sufficient because a just-terminated session always lands on page 1. + // + // The guard is strictly "id present before, absent now": the empty→populated + // transition (first poll) is ignored, and the initial mount with a non-empty + // set is ignored (no "before" to compare against). + const previousActiveIdsRef = useRef | null>(null); + const refetch = stored.refetch; + useEffect(() => { + const previous = previousActiveIdsRef.current; + previousActiveIdsRef.current = activeSessionIds; + if (!previous) { + return; + } + let departedId: string | undefined = undefined; + for (const id of previous) { + if (!activeSessionIds.has(id)) { + departedId = id; + break; + } + } + if (departedId) { + void refetch(); + } + }, [activeSessionIds, refetch]); + return { storedSessions, activeSessions, diff --git a/apps/web/src/routers/active-sessions-router.list.test.ts b/apps/web/src/routers/active-sessions-router.list.test.ts new file mode 100644 index 0000000000..54691d5581 --- /dev/null +++ b/apps/web/src/routers/active-sessions-router.list.test.ts @@ -0,0 +1,195 @@ +import { createCallerForUser } from '@/routers/test-utils'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { db } from '@/lib/drizzle'; +import { cli_sessions_v2 } from '@kilocode/db/schema'; +import { eq } from 'drizzle-orm'; +import type { User } from '@kilocode/db/schema'; + +jest.mock('@/lib/config.server', () => { + const actual: Record = jest.requireActual('@/lib/config.server'); + return { + ...actual, + SESSION_INGEST_WORKER_URL: 'https://test-ingest.example.com', + }; +}); + +let regularUser: User; + +function mockWorkerSessions(sessions: Array>): jest.SpyInstance { + return jest.spyOn(global, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ sessions }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); +} + +function mockMalformedWorkerResponse(): jest.SpyInstance { + return jest.spyOn(global, 'fetch').mockResolvedValue( + new Response('not valid json', { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }) + ); +} + +describe('active-sessions-router.list', () => { + beforeAll(async () => { + regularUser = await insertTestUser({ + google_user_email: 'active-sessions-router-user@example.com', + google_user_name: 'Active Sessions Router User', + is_admin: false, + }); + }); + + let fetchSpy: jest.SpyInstance; + + afterEach(() => { + fetchSpy?.mockRestore(); + }); + + it('merges enrichment fields from cli_sessions_v2 with explicit camelCase keys', async () => { + const sessionId = 'ses_active_enrich_match_1234'; + const createdAt = '2026-07-01 10:00:00+00'; + const updatedAt = '2026-07-02 11:00:00+00'; + await db.insert(cli_sessions_v2).values({ + session_id: sessionId, + kilo_user_id: regularUser.id, + created_on_platform: 'cli', + created_at: createdAt, + updated_at: updatedAt, + }); + + fetchSpy = mockWorkerSessions([ + { + id: sessionId, + status: 'running', + title: 'matched', + connectionId: 'conn-1', + gitUrl: 'https://github.com/kilo/repo', + gitBranch: 'main', + }, + ]); + + try { + const caller = await createCallerForUser(regularUser.id); + const result = await caller.activeSessions.list(); + + expect(result.sessions).toEqual([ + { + id: sessionId, + status: 'running', + title: 'matched', + connectionId: 'conn-1', + gitUrl: 'https://github.com/kilo/repo', + gitBranch: 'main', + createdOnPlatform: 'cli', + createdAt, + updatedAt, + }, + ]); + // Assert the explicit camelCase keys exist (not snake_case). + const row = result.sessions[0]!; + expect(Object.keys(row)).toEqual( + expect.arrayContaining(['createdOnPlatform', 'createdAt', 'updatedAt']) + ); + expect(Object.keys(row)).not.toEqual( + expect.arrayContaining(['created_on_platform', 'created_at', 'updated_at']) + ); + } finally { + await db.delete(cli_sessions_v2).where(eq(cli_sessions_v2.session_id, sessionId)); + } + }); + + it('passes sessions with undefined enrichment fields when no matching row exists', async () => { + const unmatchedId = 'ses_active_enrich_unmatched_1234'; + + fetchSpy = mockWorkerSessions([ + { + id: unmatchedId, + status: 'running', + title: 'no row', + connectionId: 'conn-2', + }, + ]); + + const caller = await createCallerForUser(regularUser.id); + const result = await caller.activeSessions.list(); + + expect(result.sessions).toEqual([ + { + id: unmatchedId, + status: 'running', + title: 'no row', + connectionId: 'conn-2', + createdOnPlatform: undefined, + createdAt: undefined, + updatedAt: undefined, + }, + ]); + }); + + it('performs no DB query when the active list is empty', async () => { + fetchSpy = mockWorkerSessions([]); + + const selectSpy = jest.spyOn(db, 'select'); + try { + const caller = await createCallerForUser(regularUser.id); + const result = await caller.activeSessions.list(); + + expect(result.sessions).toEqual([]); + // The router short-circuits the enrichment query when there are no + // sessions to enrich. + expect(selectSpy).not.toHaveBeenCalled(); + } finally { + selectSpy.mockRestore(); + } + }); + + it('returns unenriched sessions when the enrichment DB query fails', async () => { + const sessionId = 'ses_active_enrich_db_fail_1234'; + fetchSpy = mockWorkerSessions([ + { + id: sessionId, + status: 'running', + title: 'db fail', + connectionId: 'conn-3', + }, + ]); + + // Force the enrichment Drizzle query to throw. The router must catch + // the failure and return the parsed sessions unenriched — NOT an empty + // list. + const selectSpy = jest.spyOn(db, 'select').mockImplementationOnce(() => { + throw new Error('synthetic enrichment db failure'); + }); + + try { + const caller = await createCallerForUser(regularUser.id); + const result = await caller.activeSessions.list(); + + expect(result.sessions).toEqual([ + { + id: sessionId, + status: 'running', + title: 'db fail', + connectionId: 'conn-3', + createdOnPlatform: undefined, + createdAt: undefined, + updatedAt: undefined, + }, + ]); + } finally { + selectSpy.mockRestore(); + } + }); + + it('degrades to empty sessions when the worker returns a malformed response', async () => { + fetchSpy = mockMalformedWorkerResponse(); + + const caller = await createCallerForUser(regularUser.id); + const result = await caller.activeSessions.list(); + + expect(result).toEqual({ sessions: [] }); + }); +}); diff --git a/apps/web/src/routers/active-sessions-router.ts b/apps/web/src/routers/active-sessions-router.ts index 5266e59e4c..e61edffd74 100644 --- a/apps/web/src/routers/active-sessions-router.ts +++ b/apps/web/src/routers/active-sessions-router.ts @@ -4,6 +4,9 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { SESSION_INGEST_WORKER_URL } from '@/lib/config.server'; import { generateInternalServiceToken } from '@/lib/tokens'; +import { db } from '@/lib/drizzle'; +import { cli_sessions_v2 } from '@kilocode/db/schema'; +import { and, eq, inArray } from 'drizzle-orm'; export const activeSessionSchema = z.object({ id: z.string(), @@ -12,6 +15,9 @@ export const activeSessionSchema = z.object({ connectionId: z.string(), gitUrl: z.string().optional(), gitBranch: z.string().optional(), + createdOnPlatform: z.string().optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), /** * Capabilities advertised by the CLI connection that owns this session. * Omitted when the owning connection's latest heartbeat did not include a @@ -55,6 +61,11 @@ export const activeSessionsRouter = createTRPCRouter({ const token = generateInternalServiceToken(ctx.user.id); const url = `${SESSION_INGEST_WORKER_URL}/api/sessions/active`; + // Phase 1: fetch + parse the worker response. Any failure here + // (HTTP error, malformed JSON, schema mismatch) degrades to an empty + // list exactly as before — these are "no data" outcomes from the + // mobile client's point of view. + let parsed: { sessions: ActiveSession[] }; try { const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, @@ -69,11 +80,64 @@ export const activeSessionsRouter = createTRPCRouter({ } const raw = await response.json(); - return activeSessionsResponseSchema.parse(raw); + parsed = activeSessionsResponseSchema.parse(raw); } catch (error) { console.warn('[active-sessions] error:', error); return { sessions: [] as ActiveSession[] }; } + + // Phase 2: enrich parsed sessions with per-session platform + timestamps + // by joining against cli_sessions_v2. A DB failure here MUST NOT + // collapse the list to empty — callers fall back to unenriched rows. + if (parsed.sessions.length === 0) { + return parsed; + } + + const ids = parsed.sessions.map(s => s.id); + let rows: Array<{ + session_id: string; + created_on_platform: string | null; + created_at: string; + updated_at: string; + }> = []; + try { + rows = await db + .select({ + session_id: cli_sessions_v2.session_id, + created_on_platform: cli_sessions_v2.created_on_platform, + created_at: cli_sessions_v2.created_at, + updated_at: cli_sessions_v2.updated_at, + }) + .from(cli_sessions_v2) + .where( + and( + eq(cli_sessions_v2.kilo_user_id, ctx.user.id), + inArray(cli_sessions_v2.session_id, ids) + ) + ); + } catch (error) { + console.warn('[active-sessions] enrichment db query failed:', error); + return parsed; + } + + const byId = new Map(rows.map(r => [r.session_id, r])); + const sessions: ActiveSession[] = parsed.sessions.map(session => { + const row = byId.get(session.id); + if (!row) { + return session; + } + // Explicit snake_case → camelCase mapping: the mobile client only + // reads createdOnPlatform/createdAt/updatedAt, so we do not spread + // the DB row (which carries snake_case keys it never uses). + return { + ...session, + createdOnPlatform: row.created_on_platform ?? undefined, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + }); + + return { sessions }; }), /** diff --git a/apps/web/src/routers/cli-sessions-router.ts b/apps/web/src/routers/cli-sessions-router.ts index 30b7241e67..55ca56ca80 100644 --- a/apps/web/src/routers/cli-sessions-router.ts +++ b/apps/web/src/routers/cli-sessions-router.ts @@ -30,6 +30,7 @@ import { import { ensureOrganizationAccess } from '@/routers/organizations/utils'; import { getCodeReviewById } from '@/lib/code-reviews/db/code-reviews'; import { verifyWebhookTriggerAccess } from '@/lib/webhook-trigger-ownership'; +import { KNOWN_PLATFORMS } from '@kilocode/app-shared/platforms'; export const BLOB_TYPES = [ 'api_conversation_history', @@ -38,20 +39,6 @@ export const BLOB_TYPES = [ 'git_state', ] as const satisfies readonly FileName[]; -/** Known platform values that have dedicated filters. "Other" is everything else. */ -export const KNOWN_PLATFORMS = [ - 'cloud-agent', - 'cloud-agent-web', - 'cli', - 'vscode', - 'agent-manager', - 'app-builder', - 'slack', - 'github', - 'linear', - 'gastown', -] as const; - const PAGE_SIZE = 10; const commonSessionFields = { diff --git a/apps/web/src/routers/cli-sessions-v2-router.ts b/apps/web/src/routers/cli-sessions-v2-router.ts index 0c95eaf908..d272e474df 100644 --- a/apps/web/src/routers/cli-sessions-v2-router.ts +++ b/apps/web/src/routers/cli-sessions-v2-router.ts @@ -39,7 +39,7 @@ import { validateKiloSdkMessagesCursor, } from '@kilocode/session-ingest-contracts'; import { baseGetSessionNextOutputSchema } from './cloud-agent-next-schemas'; -import { KNOWN_PLATFORMS } from '@/routers/cli-sessions-router'; +import { KNOWN_PLATFORMS } from '@kilocode/app-shared/platforms'; import { verifyWebhookTriggerAccess } from '@/lib/webhook-trigger-ownership'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; import { diff --git a/apps/web/src/routers/unified-sessions-router.ts b/apps/web/src/routers/unified-sessions-router.ts index 37a754c3ec..157ae92195 100644 --- a/apps/web/src/routers/unified-sessions-router.ts +++ b/apps/web/src/routers/unified-sessions-router.ts @@ -4,7 +4,7 @@ import * as z from 'zod'; import { db } from '@/lib/drizzle'; import { sql, type SQL } from 'drizzle-orm'; import { cliSessions, cli_sessions_v2 } from '@kilocode/db/schema'; -import { KNOWN_PLATFORMS } from '@/routers/cli-sessions-router'; +import { KNOWN_PLATFORMS } from '@kilocode/app-shared/platforms'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; const PAGE_SIZE = 10; diff --git a/packages/app-shared/package.json b/packages/app-shared/package.json index 4b15e13b41..7182025696 100644 --- a/packages/app-shared/package.json +++ b/packages/app-shared/package.json @@ -8,7 +8,8 @@ "./utils": "./src/utils.ts", "./security-agent": "./src/security-agent/index.ts", "./code-review": "./src/code-review/index.ts", - "./organizations": "./src/organizations/index.ts" + "./organizations": "./src/organizations/index.ts", + "./platforms": "./src/platforms.ts" }, "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/app-shared/src/platforms.ts b/packages/app-shared/src/platforms.ts new file mode 100644 index 0000000000..01f4f1ca4f --- /dev/null +++ b/packages/app-shared/src/platforms.ts @@ -0,0 +1,13 @@ +/** Known platform values that have dedicated filters. "Other" is everything else. */ +export const KNOWN_PLATFORMS = [ + 'cloud-agent', + 'cloud-agent-web', + 'cli', + 'vscode', + 'agent-manager', + 'app-builder', + 'slack', + 'github', + 'linear', + 'gastown', +] as const;