diff --git a/apps/mobile/src/components/agents/chat-toolbar-model-name.mounted.test.tsx b/apps/mobile/src/components/agents/chat-toolbar-model-name.mounted.test.tsx index 8c0d0fcad69..6eaefa9e871 100644 --- a/apps/mobile/src/components/agents/chat-toolbar-model-name.mounted.test.tsx +++ b/apps/mobile/src/components/agents/chat-toolbar-model-name.mounted.test.tsx @@ -56,8 +56,9 @@ vi.mock('@/lib/utils', () => ({ cn: (...parts: unknown[]) => parts.filter(Boolean).join(' '), })); -// The model name from the explorer capture: 19 characters, wider than the -// space one nowrap row leaves after the shrink-0 mode chip and the effort badge. +// The model name from the explorer capture: 19 characters, wider than one +// nowrap toolbar row leaves after the shrink-0 mode chip and the effort badge. +// The row must truncate it via `numberOfLines={1}` instead of letting it wrap. const LONG_MODEL_NAME = 'DeepSeek V4.1 Flash'; const MODEL_OPTIONS: SessionModelOption[] = [ @@ -138,21 +139,57 @@ function renderToolbar( return renderer; } +function findPressable( + renderer: TestRenderer.ReactTestRenderer, + matchesLabel: (label: string) => boolean +): TestRenderer.ReactTestInstance | undefined { + return renderer.root.findAll( + node => + typeof node.type === 'string' && + (node.type as string) === 'Pressable' && + typeof node.props.accessibilityLabel === 'string' && + matchesLabel(node.props.accessibilityLabel) + )[0]; +} + +function ancestorsOf( + node: TestRenderer.ReactTestInstance | undefined +): TestRenderer.ReactTestInstance[] { + const chain: TestRenderer.ReactTestInstance[] = []; + for ( + let current: TestRenderer.ReactTestInstance | null | undefined = node; + current; + current = current.parent + ) { + chain.push(current); + } + return chain; +} + +/** The nearest row an ancestor of both nodes lays out in. */ +function nearestSharedAncestor( + a: TestRenderer.ReactTestInstance | undefined, + b: TestRenderer.ReactTestInstance | undefined +): TestRenderer.ReactTestInstance | undefined { + const ancestorSet = new Set(ancestorsOf(b)); + return ancestorsOf(a).find(candidate => ancestorSet.has(candidate)); +} + describe('ChatToolbar long model name', () => { - it('lets the control row reflow instead of squeezing the model name to a few characters', () => { + it('keeps the toolbar a single nowrap row', () => { const renderer = renderToolbar(); - // A nowrap row gives the model chip only what the shrink-0 mode chip leaves, - // and the chip then sheds that from the label. Wrapping moves the model chip - // to its own line, where it keeps the full name. - const rows = renderer.root.findAll( - node => - typeof node.type === 'string' && - (node.type as string) === 'View' && - typeof node.props.className === 'string' && - node.props.className.includes('flex-wrap') - ); - expect(rows).toHaveLength(1); - expect(rows[0]?.props.className).toContain('flex-row'); + const modeChip = findPressable(renderer, label => label.startsWith('Mode: ')); + const modelChip = findPressable(renderer, label => label.startsWith(LONG_MODEL_NAME)); + expect(modeChip).toBeDefined(); + expect(modelChip).toBeDefined(); + + // The mode chip and the model chip share the toolbar row. A wrapping row + // would drop the model chip onto a second line as soon as the long name + // outgrows the first, so the shared row must be a plain nowrap row. + const toolbarRow = nearestSharedAncestor(modeChip, modelChip); + expect(toolbarRow).toBeDefined(); + expect(toolbarRow?.props.className).toContain('flex-row'); + expect(toolbarRow?.props.className).not.toContain('flex-wrap'); }); it('hands the full model name to the chip label, never a pre-shortened string', () => { @@ -167,47 +204,24 @@ describe('ChatToolbar long model name', () => { expect(label[0]?.props.numberOfLines).toBe(1); }); - it('packs the paste button into the model chip row so it cannot wrap alone', () => { + it('packs the paste button into the model chip row so it stays on the chip line', () => { const renderer = renderToolbar(); - const modelChip = renderer.root.findAll( - node => - typeof node.type === 'string' && - (node.type as string) === 'Pressable' && - typeof node.props.accessibilityLabel === 'string' && - node.props.accessibilityLabel.startsWith(LONG_MODEL_NAME) - )[0]; - const pasteButton = renderer.root.findAll( - node => - typeof node.type === 'string' && - (node.type as string) === 'Pressable' && - node.props.accessibilityLabel === 'Paste from clipboard' - )[0]; + const modelChip = findPressable(renderer, label => label.startsWith(LONG_MODEL_NAME)); + const pasteButton = findPressable(renderer, label => label === 'Paste from clipboard'); expect(modelChip).toBeDefined(); expect(pasteButton).toBeDefined(); - // The nearest shared ancestor is the row the chip and the button wrap in. + // The nearest shared ancestor is the row the chip and the button lay out in. // It must be a plain row: a wrapping row would let the button leave the // chip's line on its own. - const chipChain: TestRenderer.ReactTestInstance[] = []; - for ( - let node: TestRenderer.ReactTestInstance | null | undefined = modelChip; - node; - node = node.parent - ) { - chipChain.push(node); - } - const pasteChain: TestRenderer.ReactTestInstance[] = []; - for ( - let node: TestRenderer.ReactTestInstance | null | undefined = pasteButton; - node; - node = node.parent - ) { - pasteChain.push(node); - } - const shared = chipChain.find(candidate => pasteChain.includes(candidate)); - expect(shared).toBeDefined(); - expect(shared?.props.className).toContain('flex-row'); - expect(shared?.props.className).not.toContain('flex-wrap'); + const chipRow = nearestSharedAncestor(modelChip, pasteButton); + expect(chipRow).toBeDefined(); + expect(chipRow?.props.className).toContain('flex-row'); + expect(chipRow?.props.className).not.toContain('flex-wrap'); + // The row must also shrink, or the long name overflows the row instead of + // truncating (React Native defaults `flexShrink` to 0). + expect(chipRow?.props.className).toContain('shrink'); + expect(chipRow?.props.className).toContain('min-w-0'); // The button still ends the chip's line at its trailing edge. expect(pasteButton?.props.className).toContain('ml-auto'); diff --git a/apps/mobile/src/components/agents/chat-toolbar.test.ts b/apps/mobile/src/components/agents/chat-toolbar.test.ts index f1c73d587be..222433619d5 100644 --- a/apps/mobile/src/components/agents/chat-toolbar.test.ts +++ b/apps/mobile/src/components/agents/chat-toolbar.test.ts @@ -113,7 +113,7 @@ describe('ChatToolbar', () => { expect(props.disabled).toBe(false); }); - it('lets the control row reflow so a long model chip keeps its own width', () => { + it('keeps the control row on one line so it never wraps the model chip', () => { const onPaste = vi.fn(() => undefined); // eslint-disable-next-line new-cap -- plain function call, matching repo test convention const element = ChatToolbar({ ...defaultProps(), onPaste }) as Node; @@ -125,9 +125,9 @@ describe('ChatToolbar', () => { ? element.props.className : ''; expect(className).toContain('flex-row'); - // The mode chip is shrink-0, so a nowrap row would squeeze the model name - // down to a few characters. Wrapping gives the model chip its own line. - expect(className).toContain('flex-wrap'); + // The row must never wrap: the mode chip is shrink-0, so the model chip + // takes the remaining width and truncates its own label. + expect(className).not.toContain('flex-wrap'); const pasteButtonProps = findElementByType(element, 'ComposerPasteButton') ?? {}; expect(pasteButtonProps.className).toContain('shrink-0'); @@ -151,7 +151,9 @@ describe('ChatToolbar', () => { expect(pasteButtonProps.className).toContain('shrink-0'); }); - it('wraps the chips for the narrow new-session viewport', () => { + it('keeps the chips on one row even when a caller passes the superseded wrap flag', () => { + // `wrap` opted into #6349's second row. The toolbar never wraps now, and the + // new-session and clone callers still pass the flag, so it must stay inert. // eslint-disable-next-line new-cap -- plain function call, matching repo test convention const element = ChatToolbar({ ...defaultProps(), wrap: true }) as Node; @@ -162,7 +164,7 @@ describe('ChatToolbar', () => { ? element.props.className : ''; expect(className).toContain('flex-row'); - expect(className).toContain('flex-wrap'); + expect(className).not.toContain('flex-wrap'); }); it('forwards onLayout to the row', () => { @@ -173,22 +175,24 @@ describe('ChatToolbar', () => { expect(element).toMatchObject({ props: { onLayout } }); }); - it('packs the paste button with the model chip so it never wraps to a line of its own', () => { + it('packs the paste button with the model chip so it never leaves the chip line', () => { const onPaste = vi.fn(() => undefined); // eslint-disable-next-line new-cap -- plain function call, matching repo test convention const element = ChatToolbar({ ...defaultProps(), onPaste }) as Node; - // A paste button that is a sibling of the chips overflows the first line on - // its own and wraps to an empty row below the model chip. One inner row - // holding both makes the outer wrap move them together. + // The paste button and the chip share one inner row, so the button cannot + // break away from the chip onto a line of its own. const packRow = findRowHolding(element, ['ModelSelector', 'ComposerPasteButton']); expect(packRow).not.toBeNull(); const packClassName = typeof packRow?.className === 'string' ? packRow.className : ''; expect(packClassName).toContain('flex-row'); expect(packClassName).not.toContain('flex-wrap'); + // Without `shrink` the nowrap row overflows instead of truncating (React + // Native defaults `flexShrink` to 0), pushing the paste button off the row. + expect(packClassName).toContain('shrink'); + expect(packClassName).toContain('min-w-0'); - // On the chip's line the button still keeps the trailing edge, as it did - // when every item fit on the first line. + // The button still keeps the trailing edge of the chip's line. const pasteButtonProps = findElementByType(element, 'ComposerPasteButton') ?? {}; expect(pasteButtonProps.className).toContain('ml-auto'); }); diff --git a/apps/mobile/src/components/agents/chat-toolbar.tsx b/apps/mobile/src/components/agents/chat-toolbar.tsx index 83d1ec62fad..c718f99656d 100644 --- a/apps/mobile/src/components/agents/chat-toolbar.tsx +++ b/apps/mobile/src/components/agents/chat-toolbar.tsx @@ -33,14 +33,12 @@ type ChatToolbarProps = { modelLockLabel?: string; className?: string; /** - * Lets the chips wrap onto a second row when their combined intrinsic width - * exceeds the row (narrow viewports), instead of ellipsizing the selected - * model name (#6349). On by default; `wrap={false}` pins the chips to one - * row. The host owns the extra height: the new-session form scrolls and its - * input floor reserves the measured toolbar height. + * Accepted for the new-session and clone callers that used to allow a second + * chip row (#6349); superseded. The toolbar now stays on one row everywhere — + * a long model name truncates inside its chip — so this prop has no effect. */ wrap?: boolean; - /** Forwards the row's layout, e.g. to measure the wrapped height. */ + /** Forwards the row's layout, e.g. to measure the toolbar height. */ onLayout?: (event: LayoutChangeEvent) => void; }; @@ -60,7 +58,6 @@ export function ChatToolbar({ modelLocked = false, modelLockLabel, className, - wrap = true, onLayout, }: Readonly) { const modeSelector = ( @@ -82,17 +79,16 @@ export function ChatToolbar({ lockLabel={modelLocked ? modelLockLabel : undefined} /> ); - // The paste button rides with the model chip as one wrap unit. As a sibling - // of the chips it is the item that overflows the full first line, so it wraps - // alone onto the next line and strands itself at the far edge with an empty - // row to its left. Packed, the chip and the button move to the next line - // together and the button stays at the end of the chip's line. + // The paste button shares the model chip's line. The wrapper grows to fill the + // width the shrink-0 mode chip leaves; inside it the model chip is the only + // part that gives up width, so the paste button keeps `shrink-0` and stays on + // the same line at the row's trailing edge. const modelSelectorWithPaste = ( - // Content-sized for the wrap decision (grow leaves the basis at auto), so - // the outer row still sees the chip's real width and wraps the unit instead - // of squeezing the chip; on its line the unit fills the row and the paste - // keeps the trailing edge. - + // `min-w-0` lets the chip shrink below its content width, `shrink` makes the + // wrapper give up that width (React Native defaults `flexShrink` to 0) and + // `grow` takes the remaining row width, so the chip truncates and the paste + // button never needs a line of its own. + {modelSelector} {onPaste ? ( {order === 'model-first' ? modelSelectorWithPaste : modeSelector} {order === 'model-first' ? modeSelector : modelSelectorWithPaste} diff --git a/apps/mobile/src/components/agents/live-session-filters.test.ts b/apps/mobile/src/components/agents/live-session-filters.test.ts index 9cc47bf73c7..7a061b2ddb7 100644 --- a/apps/mobile/src/components/agents/live-session-filters.test.ts +++ b/apps/mobile/src/components/agents/live-session-filters.test.ts @@ -53,6 +53,27 @@ describe('buildLiveFilterOptions', () => { ]); }); + it('merges repositories that render to one label into a single option', () => { + const ssh = { + id: 'ssh', + title: 'ssh clone', + gitUrl: 'git@github.com:kilo/cloud.git', + createdOnPlatform: 'cli', + }; + const https = { + id: 'https', + title: 'https clone', + gitUrl: 'https://github.com/kilo/cloud.git', + createdOnPlatform: 'cli', + }; + const { projectOptions } = buildLiveFilterOptions([ssh, https]); + + // First-seen git URL wins, so the option stays stable across renders. + expect(projectOptions).toEqual([ + { gitUrl: 'git@github.com:kilo/cloud.git', displayName: 'kilo/cloud' }, + ]); + }); + it('offers only the platform buckets that are live, in canonical order', () => { expect(buildLiveFilterOptions([CLI, VSCODE, CLOUD, BARE]).platformOptions).toEqual([ 'cloud-agent', @@ -98,6 +119,35 @@ describe('filterLiveSessions', () => { ).toEqual(['vscode']); }); + it('matches a persisted variant selection as the bucket row the sheet checks', () => { + // A legacy record can hold `cloud-agent-web`; the badge and the sheet + // collapse it to the single `cloud-agent` row, so the list must too. + expect( + filterLiveSessions(sessions, query({ platformFilter: ['cloud-agent-web'] })).map(s => s.id) + ).toEqual(['cloud']); + expect( + filterLiveSessions(sessions, query({ platformFilter: ['agent-manager'] })).map(s => s.id) + ).toEqual(['vscode']); + }); + + it('matches every alias of a merged repository from one stored URL', () => { + const aliases = [ + { id: 'ssh', gitUrl: 'git@github.com:kilo/cloud.git', createdOnPlatform: 'cli' }, + { id: 'https', gitUrl: 'https://github.com/kilo/cloud.git', createdOnPlatform: 'cli' }, + ]; + expect( + filterLiveSessions( + aliases, + query({ projectFilter: ['https://github.com/kilo/cloud.git'] }) + ).map(s => s.id) + ).toEqual(['ssh', 'https']); + expect( + filterLiveSessions(aliases, query({ projectFilter: ['git@github.com:kilo/cloud.git'] })).map( + s => s.id + ) + ).toEqual(['ssh', 'https']); + }); + it('combines every dimension with AND', () => { expect( filterLiveSessions( diff --git a/apps/mobile/src/components/agents/live-session-filters.ts b/apps/mobile/src/components/agents/live-session-filters.ts index 88cd278e0cb..e7cb5aeef19 100644 --- a/apps/mobile/src/components/agents/live-session-filters.ts +++ b/apps/mobile/src/components/agents/live-session-filters.ts @@ -1,8 +1,10 @@ import { - expandPlatformFilter, formatGitUrlProject, + knownPlatformBucket, + normalisePlatformSelection, PLATFORM_FILTERS, type ProjectFilterOption, + projectOptionKey, } from '@/components/agents/session-list-helpers'; /** The part of an active session the live filters read. */ @@ -27,39 +29,39 @@ export type LiveFilterOptions = { platformOptions: string[]; }; -// Inverse of the history screen's platform expansion, so the live list speaks -// the same vocabulary ('cloud-agent' covers 'cloud-agent-web', 'extension' -// covers 'vscode' and 'agent-manager'). Built from `expandPlatformFilter` so -// the mapping stays in one place. -const BUCKET_BY_PLATFORM = new Map( - PLATFORM_FILTERS.filter(bucket => bucket !== 'other').flatMap(bucket => - expandPlatformFilter([bucket]).map(platform => [platform, bucket] as const) - ) -); - /** * Filter bucket for one live row's origin. Returns null when the origin is * missing or still 'unknown' (a CLI row before its first enrichment), so such - * a row offers no option and is never claimed by a platform filter. + * a row offers no option and is never claimed by a platform filter. The + * variant→bucket mapping lives in `knownPlatformBucket` so there is one place + * to keep in sync. */ export function liveSessionPlatformBucket(createdOnPlatform: string | undefined): string | null { if (!createdOnPlatform || createdOnPlatform === 'unknown') { return null; } - return BUCKET_BY_PLATFORM.get(createdOnPlatform) ?? 'other'; + return knownPlatformBucket(createdOnPlatform) ?? 'other'; } /** * Build the filter options from the live rows themselves, so the picker never * offers a repository or an origin that has nothing running. Options are * derived from the unfiltered set, so applying a filter does not shrink them. + * Repositories that render to the same label are one option (first git URL + * wins), so the sheet never shows the same project twice. */ export function buildLiveFilterOptions(sessions: readonly LiveFilterSession[]): LiveFilterOptions { - const projects = new Map(); + const projects = new Map(); const platforms = new Set(); for (const session of sessions) { if (session.gitUrl) { - projects.set(session.gitUrl, formatGitUrlProject(session.gitUrl)); + const key = projectOptionKey(session.gitUrl); + if (!projects.has(key)) { + projects.set(key, { + gitUrl: session.gitUrl, + displayName: formatGitUrlProject(session.gitUrl), + }); + } } const bucket = liveSessionPlatformBucket(session.createdOnPlatform); if (bucket) { @@ -67,9 +69,8 @@ export function buildLiveFilterOptions(sessions: readonly LiveFilterSession[]): } } return { - projectOptions: [...projects] - .map(([gitUrl, displayName]) => ({ gitUrl, displayName })) - // eslint-disable-next-line unicorn/no-array-sort -- Hermes does not implement Array.prototype.toSorted; map already copies so nothing shared is mutated + projectOptions: [...projects.values()] + // eslint-disable-next-line unicorn/no-array-sort -- Hermes does not implement Array.prototype.toSorted; the spread already copies so nothing shared is mutated .sort((a, b) => a.displayName.localeCompare(b.displayName)), // Keep the canonical platform order the filter modal uses. platformOptions: PLATFORM_FILTERS.filter(bucket => platforms.has(bucket)), @@ -103,13 +104,21 @@ export function filterLiveSessions( if (platformFilter.length === 0 && projectFilter.length === 0 && needle.length === 0) { return sessions; } + // Collapse each persisted platform through its bucket (the same vocabulary + // the sheet and the badge use), so a legacy stored variant still matches the + // single row it renders. + const platformBuckets = new Set(normalisePlatformSelection(platformFilter)); + // Match by visible-label identity, not by the raw stored URL, so one stored + // URL still matches every row the merged option covers (https vs ssh, a + // `.git` suffix, host case). + const projectKeys = new Set(projectFilter.map(gitUrl => projectOptionKey(gitUrl))); return sessions.filter(session => { const bucket = liveSessionPlatformBucket(session.createdOnPlatform); const platformMatches = - platformFilter.length === 0 || (bucket !== null && platformFilter.includes(bucket)); + platformBuckets.size === 0 || (bucket !== null && platformBuckets.has(bucket)); const projectMatches = projectFilter.length === 0 || - (session.gitUrl != null && projectFilter.includes(session.gitUrl)); + (session.gitUrl != null && projectKeys.has(projectOptionKey(session.gitUrl))); const searchMatches = needle.length === 0 || matchesSearch(session, needle); return platformMatches && projectMatches && searchMatches; }); diff --git a/apps/mobile/src/components/agents/platform-filter-modal.mounted.test.tsx b/apps/mobile/src/components/agents/platform-filter-modal.mounted.test.tsx index eb8aa8849ed..f1e75b253c2 100644 --- a/apps/mobile/src/components/agents/platform-filter-modal.mounted.test.tsx +++ b/apps/mobile/src/components/agents/platform-filter-modal.mounted.test.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- cohesive mounted-test suite for the session filter sheet */ import { act, type ComponentProps } from 'react'; import { Modal, Pressable, ScrollView } from 'react-native'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -234,6 +235,82 @@ describe('SessionFilterModal', () => { expect(props.onClose).toHaveBeenCalledOnce(); }); + it('renders one platform row per bucket and collapses a persisted variant', async () => { + const { renderer } = await renderModal({ + platformOptions: ['cloud-agent', 'extension'], + selectedPlatforms: ['cloud-agent-web', 'vscode'], + projectOptions: [], + }); + const checkboxes = renderer.root.findAllByProps({ accessibilityRole: 'checkbox' }); + expect(checkboxes.map(row => row.findByType(Text).props.children)).toEqual([ + i18n.t('agentChat.sessionFilter.platformCloud'), + i18n.t('agentChat.sessionFilter.platformExtension'), + ]); + expect( + checkboxes.map( + row => (row.props as ComponentProps).accessibilityState?.checked + ) + ).toEqual([true, true]); + }); + + it('renders one project row per visible label and stores every alias on toggle', async () => { + const sshAlias = { + gitUrl: 'git@github.com:iscekic/kilo-workflow.git', + displayName: 'ISCEKIC/KILO-WORKFLOW', + }; + const { renderer, props } = await renderModal({ + projectOptions: [firstProject, sshAlias, secondProject], + selectedProjects: [], + }); + const labels = renderer.root + .findAllByProps({ accessibilityRole: 'checkbox' }) + .map(row => row.findByType(Text).props.children); + expect(labels.filter(label => label === firstProject.displayName)).toHaveLength(1); + expect(findCheckbox(renderer, firstProject.displayName).props.accessibilityState).toEqual({ + checked: false, + }); + + act(() => { + (findCheckbox(renderer, firstProject.displayName).props.onPress as () => void)(); + }); + expect(findCheckbox(renderer, firstProject.displayName).props.accessibilityState).toEqual({ + checked: true, + }); + + pressButton(renderer, i18n.t('common.apply')); + expect(props.onApply).toHaveBeenCalledExactlyOnceWith({ + platformFilter: [], + projectFilter: [firstProject.gitUrl, sshAlias.gitUrl], + }); + }); + + it('toggles off every alias of a merged project when any is stored', async () => { + const sshAlias = { + gitUrl: 'git@github.com:iscekic/kilo-workflow.git', + displayName: 'ISCEKIC/KILO-WORKFLOW', + }; + const { renderer, props } = await renderModal({ + projectOptions: [firstProject, sshAlias, secondProject], + selectedProjects: [sshAlias.gitUrl], + }); + expect(findCheckbox(renderer, firstProject.displayName).props.accessibilityState).toEqual({ + checked: true, + }); + + act(() => { + (findCheckbox(renderer, firstProject.displayName).props.onPress as () => void)(); + }); + expect(findCheckbox(renderer, firstProject.displayName).props.accessibilityState).toEqual({ + checked: false, + }); + + pressButton(renderer, i18n.t('common.apply')); + expect(props.onApply).toHaveBeenCalledExactlyOnceWith({ + platformFilter: [], + projectFilter: [], + }); + }); + it('applies empty filters after deselecting both dimensions', async () => { const { renderer, props } = await renderModal({ selectedPlatforms: ['cli'], diff --git a/apps/mobile/src/components/agents/platform-filter-modal.tsx b/apps/mobile/src/components/agents/platform-filter-modal.tsx index 35e0de3f134..9bb113dfb27 100644 --- a/apps/mobile/src/components/agents/platform-filter-modal.tsx +++ b/apps/mobile/src/components/agents/platform-filter-modal.tsx @@ -7,8 +7,10 @@ import { useTranslation } from 'react-i18next'; import { i18n } from '@/i18n'; import { formatGitUrlProject, + knownPlatformBucket, PLATFORM_FILTERS, type ProjectFilterOption, + projectOptionKey, } from '@/components/agents/session-list-helpers'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; @@ -38,6 +40,12 @@ type FilterCheckboxRowProps = { onPress: () => void; }; +/** Every git URL that renders to one project label, offered as a single row. */ +type ProjectFilterGroup = { + gitUrls: string[]; + displayName: string; +}; + function platformFilterLabel(p: string): string { switch (p) { case 'cloud-agent': { @@ -67,6 +75,12 @@ function platformFilterLabel(p: string): string { } } +/** Collapse a persisted platform variant into the single bucket row it maps to. + * An unknown platform keeps its own row. */ +function normalisePlatform(platform: string): string { + return knownPlatformBucket(platform) ?? platform; +} + function FilterCheckboxRow({ label, isChecked, onPress }: Readonly) { const colors = useThemeColors(); @@ -112,13 +126,34 @@ export function SessionFilterModal({ 0, windowHeight - insets.top - insets.bottom - SHEET_EDGE_MARGIN * 2 ); - const [draftPlatforms, setDraftPlatforms] = useState(selectedPlatforms); + const [draftPlatforms, setDraftPlatforms] = useState(() => [ + ...new Set(selectedPlatforms.map(platform => normalisePlatform(platform))), + ]); const [draftProjects, setDraftProjects] = useState(selectedProjects); - const platforms = [...new Set([...platformOptions, ...selectedPlatforms])]; - const projectsByUrl = new Map(projectOptions.map(project => [project.gitUrl, project])); - for (const gitUrl of selectedProjects) { - if (!projectsByUrl.has(gitUrl)) { - projectsByUrl.set(gitUrl, { gitUrl, displayName: formatGitUrlProject(gitUrl) }); + // A persisted platform variant collapses into its bucket row; an unknown + // selected platform keeps its own row. + const platforms = [ + ...new Set([ + ...platformOptions, + ...selectedPlatforms.map(platform => normalisePlatform(platform)), + ]), + ]; + // One row per visible project label, in first-seen order. Two git URLs that + // `formatGitUrlProject` renders identically (https vs ssh, a `.git` suffix, + // host case) share a group, so the sheet never shows the same project twice. + const projectGroups = new Map(); + for (const project of [ + ...projectOptions, + ...selectedProjects.map(gitUrl => ({ gitUrl, displayName: formatGitUrlProject(gitUrl) })), + ]) { + const key = projectOptionKey(project.gitUrl); + const group = projectGroups.get(key); + if (group) { + if (!group.gitUrls.includes(project.gitUrl)) { + group.gitUrls.push(project.gitUrl); + } + } else { + projectGroups.set(key, { gitUrls: [project.gitUrl], displayName: project.displayName }); } } @@ -128,9 +163,13 @@ export function SessionFilterModal({ ); }; - const toggleProject = (gitUrl: string) => { + // Toggle the whole group: every alias is stored or removed together, so the + // server query keeps the sessions of both variants filtered in. + const toggleProjectGroup = (group: ProjectFilterGroup) => { setDraftProjects(prev => - prev.includes(gitUrl) ? prev.filter(value => value !== gitUrl) : [...prev, gitUrl] + group.gitUrls.some(gitUrl => prev.includes(gitUrl)) + ? prev.filter(gitUrl => !group.gitUrls.includes(gitUrl)) + : [...prev, ...group.gitUrls.filter(gitUrl => !prev.includes(gitUrl))] ); }; @@ -184,18 +223,18 @@ export function SessionFilterModal({ /> ))} - {projectsByUrl.size > 0 && ( + {projectGroups.size > 0 && ( {t('agentChat.sessionFilter.project')} - {[...projectsByUrl.values()].map(project => ( + {[...projectGroups.entries()].map(([key, group]) => ( draftProjects.includes(gitUrl))} onPress={() => { - toggleProject(project.gitUrl); + toggleProjectGroup(group); }} /> ))} diff --git a/apps/mobile/src/components/agents/session-detail-content.test.ts b/apps/mobile/src/components/agents/session-detail-content.test.ts index b744ebc842f..0c1d1454f74 100644 --- a/apps/mobile/src/components/agents/session-detail-content.test.ts +++ b/apps/mobile/src/components/agents/session-detail-content.test.ts @@ -14,6 +14,7 @@ import { act, type ReactTestInstance, type ReactTestRenderer } from '@/test/rend import { type Pressable } from 'react-native'; import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; import { + type AssociatedPrData, createSessionManager, createUserWebConnection, type KiloSessionId, @@ -500,6 +501,18 @@ const PERSONAL_DISPLAY_SCOPE = { organizationId: null, isResolved: true }; * the shared `mountDetails` fixture at its existing three-parameter signature. */ let goalMountOptions: { goal?: SessionGoal; resolvedType?: 'read-only' | 'remote' } = {}; +/** The PR `fetchSession` reports; `null` keeps the PR row off the screen. */ +let associatedPrMountOption: AssociatedPrData | null = null; +const ASSOCIATED_PR: AssociatedPrData = { + url: 'https://github.com/acme/repo/pull/42', + number: 42, + state: 'open', + title: 'Harden the header', + headSha: 'abc123', + lastSyncedAt: '2026-01-01T00:00:00.000Z', + reviewDecision: null, + reviewDecisionPending: false, +}; const ROOT_ID = kiloId('ses-root'); const NEXT_ROOT_ID = kiloId('ses-next-root'); const SELECTED_ID = kiloId('ses-selected'); @@ -644,6 +657,7 @@ beforeEach(() => { hideThinking.current = false; hideThinking.loaded = true; goalMountOptions = {}; + associatedPrMountOption = null; globalContext.organizationId = 'global-org'; globalContext.setOrganizationId.mockClear(); rootPageNextCursor = null; @@ -752,7 +766,7 @@ async function mountDetails( isPreparingAsync: false, prompt: null, initialMessageId: null, - associatedPr: null, + associatedPr: associatedPrMountOption, }; }, }); @@ -924,6 +938,8 @@ describe('SessionDetailContent display scope', () => { ); expect(header.props.context).toBeUndefined(); expect(header.findAllByType(ContextControl)).toHaveLength(0); + // The PR link shares the goal row now, so the header row holds no badge. + expect(header.findAllByType('SessionPrBadge')).toHaveLength(0); expect( header.findAll(node => node.props.accessibilityHint === i18n.t('profile.selectAccount')) ).toHaveLength(0); @@ -2569,6 +2585,52 @@ describe('SessionDetailContent goal visibility', () => { expect(view.renderer.root.findAllByType(SessionGoalSection)).toHaveLength(0); }); + it('hands the PR badge to the goal row and keeps it out of the header', async () => { + goalMountOptions = { goal: pausedGoal, resolvedType: 'remote' }; + associatedPrMountOption = ASSOCIATED_PR; + const view = await mountDetails([], { displayScope: PERSONAL_DISPLAY_SCOPE }); + + // The badge lives on the goal row now, not beside the context pill. + const header = view.renderer.root.findByType(ScreenHeader); + expect(header.findAllByType('SessionPrBadge')).toHaveLength(0); + + const section = goalSectionOf(view); + expect(section.props.goal).toEqual(pausedGoal); + expect(section.findAllByType('SessionPrBadge')).toHaveLength(1); + }); + + it('shows the goal row for a PR-only session', async () => { + associatedPrMountOption = ASSOCIATED_PR; + const view = await mountDetails([], { displayScope: PERSONAL_DISPLAY_SCOPE }); + + const section = goalSectionOf(view); + expect(section.props.goal).toBeNull(); + expect(section.findAllByType('SessionPrBadge')).toHaveLength(1); + // The row exists because the PR landed, so the badge never renders the + // session-loading skeleton; the wrapper's FadeIn reveals it. + expect(section.findAllByType('SessionPrBadge')[0]?.props.loading).toBe(false); + }); + + it('omits the goal row when it holds neither a goal nor a PR', async () => { + const view = await mountDetails([], { displayScope: PERSONAL_DISPLAY_SCOPE }); + + expect(view.renderer.root.findAllByType(SessionGoalSection)).toHaveLength(0); + }); + + it('omits the goal row while a no-goal, no-PR session is still loading', async () => { + const view = await mountDetails([], { displayScope: PERSONAL_DISPLAY_SCOPE }); + act(() => { + view.store.set(view.manager.atoms.isLoading, true); + }); + + // The fetch is in flight and the session has neither a goal nor a PR, so + // row 2 has nothing to hold. It must not reserve a min-h-12 box for a + // phantom PR skeleton that unmounts (and jumps the transcript 48px) the + // moment the fetch lands with no PR. + expect(view.renderer.root.findAllByType(SessionGoalSection)).toHaveLength(0); + expect(view.renderer.root.findAllByType('SessionPrBadge')).toHaveLength(0); + }); + it('persists the goal disclosure through the per-session store', async () => { goalMountOptions = { goal: pausedGoal, resolvedType: 'remote' }; const view = await mountDetails([], { displayScope: PERSONAL_DISPLAY_SCOPE }); diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index c042b11a5f5..3430a7ff411 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -1637,7 +1637,6 @@ export function SessionDetailContent({ const handleRenameClose = rename.closeModal; const headerRight = ( - ); + // The PR link shares the goal row so the header stays at two rows. The row + // mounts only once it holds data: while the fetch is in flight a no-goal, + // no-PR session reserves no row, so the transcript never jumps when the fetch + // lands with nothing. The wrapper's FadeIn reveals the PR when it lands. + const associatedPr = fetchedData?.associatedPr ?? null; + const prBadge = ; + const hasPrRow = associatedPr !== null; const blockingInteraction = getBlockingInteraction({ activeQuestion, activePermission }); // A pending permission ask that the auto-reply is already answering is // suppressed: the card is gated out below (`suppressedRequestId`). Blocking @@ -2040,7 +2046,7 @@ export function SessionDetailContent({ } : {})} /> - {sessionGoal ? ( + {sessionGoal !== null || hasPrRow ? ( { toggleSessionGoalCollapsed(sessionId); }} diff --git a/apps/mobile/src/components/agents/session-goal-section.mounted.test.tsx b/apps/mobile/src/components/agents/session-goal-section.mounted.test.tsx index 69acdae4fe7..3b416518f50 100644 --- a/apps/mobile/src/components/agents/session-goal-section.mounted.test.tsx +++ b/apps/mobile/src/components/agents/session-goal-section.mounted.test.tsx @@ -5,8 +5,11 @@ // exposes a sibling disclosure pressable whose label and accessibility state // follow the state, keeps the tuned top padding, and rotates the chevron with // a 200ms timing that the reduced-motion policy turns into an instant jump. +// The same row carries the trailing control (the PR link) between the goal and +// the chevron, and renders it alone in the same shell when the goal is absent. import { type ComponentProps, type ElementType } from 'react'; +import { Pressable } from 'react-native'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act, type ReactTestInstance, type ReactTestRenderer, TestRenderer } from '@/test/renderer'; @@ -56,12 +59,18 @@ const STATUS_TEXT = 'agentChat.goal.statusPaused'; const ROW_LABEL = 'agentChat.goal.sectionAccessibility'; const COLLAPSE_LABEL = 'agentChat.goal.collapse'; const EXPAND_LABEL = 'agentChat.goal.expand'; +const TRAILING_LABEL = 'pull request'; let mounted: ReactTestRenderer | undefined = undefined; const onToggleCollapsed = vi.fn<() => void>(); +/** The shared row's trailing control: the PR link the screen passes in. */ +function trailingControl() { + return ; +} + function mountSection( - props: Partial, 'goal'>> = {} + props: Partial> = {} ): ReactTestRenderer { act(() => { mounted = TestRenderer.create( @@ -121,6 +130,49 @@ function hostAncestor(node: ReactTestInstance): ReactTestInstance | null { return current; } +/** + * The host box a row child renders to: the child itself when it is already a + * host element, otherwise the box a mounted component (the chevron) renders. + */ +function rowChildBox(child: ReactTestInstance | string): ReactTestInstance | null { + if (typeof child === 'string') { + return null; + } + if (typeof child.type === 'string') { + return child; + } + const inner = child.children.find( + (rendered): rendered is ReactTestInstance => typeof rendered !== 'string' + ); + return inner ?? null; +} + +/** + * The boxes the row keeps after the trailing control: the disclosure chevron + * beside a goal, the reserved gutter on a PR-only row. Both are 24px wide, so + * the control ends on one right edge whether or not a goal is shown. + */ +function trailingReserve(control: ReactTestInstance): string[] { + const slot = hostAncestor(control); + if (slot == null) { + throw new Error('the trailing control is not inside a row slot'); + } + const row = hostAncestor(slot); + if (row == null) { + throw new Error('the trailing slot is not inside a row'); + } + const children = row.children; + const index = children.indexOf(slot); + if (index === -1) { + throw new Error('the trailing slot is not a row child'); + } + return children + .slice(index + 1) + .map(child => rowChildBox(child)) + .filter((box): box is ReactTestInstance => box != null) + .map(box => String(box.props.className)); +} + /** The rotation shared value is the only one the section creates; each render * asks for it again, so the last holder belongs to the newest commit. */ function rotationValue(): unknown { @@ -192,6 +244,81 @@ describe('SessionGoalSection disclosure', () => { expect(hostAncestor(rowAction(renderer))).toBe(rowContainer(renderer)); }); + it('renders the trailing control between the goal and the chevron', () => { + const renderer = mountSection({ trailing: trailingControl() }); + + const pressables = renderer.root.findAll(node => node.type === ('Pressable' as ElementType)); + // The goal action, the trailing control, and the disclosure chevron. + expect(pressables).toHaveLength(3); + expect(pressables.map(node => node.props.accessibilityLabel)).toEqual([ + ROW_LABEL, + TRAILING_LABEL, + COLLAPSE_LABEL, + ]); + + // The trailing control keeps its own box at the row end, top-aligned. + const slot = hostAncestor(renderer.root.findByProps({ accessibilityLabel: TRAILING_LABEL })); + expect(String(slot?.props.className)).toContain('shrink-0'); + expect(String(slot?.props.className)).toContain('self-start'); + expect(String(slot?.props.className)).toContain('pt-0.5'); + // `ml-auto` keeps the PR link at the row's trailing edge, and the chevron + // that follows it is the only box left of that edge. + expect(String(slot?.props.className)).toContain('ml-auto'); + // The goal pressable still owns the tap target for the whole row. + expect( + String(renderer.root.findByProps({ accessibilityLabel: ROW_LABEL }).props.className) + ).toContain('self-stretch'); + }); + + it('renders only the trailing control in the same shell when the goal is absent', () => { + const renderer = mountSection({ goal: null, trailing: trailingControl() }); + + const pressables = renderer.root.findAll(node => node.type === ('Pressable' as ElementType)); + expect(pressables).toHaveLength(1); + expect(pressables[0]?.props.accessibilityLabel).toBe(TRAILING_LABEL); + // No goal text, no chevron, and no goal accessibility state. + expect(renderedText(renderer)).toEqual([]); + expect(renderer.root.findAll(node => node.props.accessibilityState !== undefined)).toHaveLength( + 0 + ); + + const row = rowContainer(renderer); + expect(row.props.className).toContain('min-h-12'); + expect(row.props.className).toContain('pt-0.5'); + expect(row.props.className).toContain('pb-2'); + + // The lone PR link takes the same trailing edge it has beside a goal, so + // the control does not jump to the row's leading edge on a PR-only session. + const slot = hostAncestor(renderer.root.findByProps({ accessibilityLabel: TRAILING_LABEL })); + expect(String(slot?.props.className)).toContain('ml-auto'); + }); + + it('keeps the trailing control on one right edge with and without a goal', () => { + const withGoal = mountSection({ trailing: trailingControl() }); + // Beside a goal only the 24px disclosure chevron follows the control, so + // the control's right edge sits one chevron plus the row's 8px gap from the + // row edge. + const goalReserve = trailingReserve( + withGoal.root.findByProps({ accessibilityLabel: TRAILING_LABEL }) + ); + expect(goalReserve).toHaveLength(1); + expect(goalReserve[0]).toContain('w-6'); + expect(goalReserve[0]).toContain('shrink-0'); + + act(() => mounted?.unmount()); + reanimated.sharedValues = []; + + const withoutGoal = mountSection({ goal: null, trailing: trailingControl() }); + // A PR-only row reserves that same box, so the control's right edge does + // not move by the chevron's width plus the row's gap between the two. + const prOnlyReserve = trailingReserve( + withoutGoal.root.findByProps({ accessibilityLabel: TRAILING_LABEL }) + ); + expect(prOnlyReserve).toHaveLength(1); + expect(prOnlyReserve[0]).toContain('w-6'); + expect(prOnlyReserve[0]).toContain('shrink-0'); + }); + it('calls onToggleCollapsed from the disclosure pressable', () => { const renderer = mountSection(); const pressable = disclosure(renderer); diff --git a/apps/mobile/src/components/agents/session-goal-section.tsx b/apps/mobile/src/components/agents/session-goal-section.tsx index a907093881e..0a71b6e5dd3 100644 --- a/apps/mobile/src/components/agents/session-goal-section.tsx +++ b/apps/mobile/src/components/agents/session-goal-section.tsx @@ -1,4 +1,5 @@ import { type SessionGoal, type SessionGoalStatus } from '@kilocode/cloud-agent-sdk'; +import { type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { Pressable, View } from 'react-native'; @@ -19,11 +20,18 @@ const STATUS_LABEL_KEY = { } as const satisfies Record; type SessionGoalSectionProps = { - goal: SessionGoal; + goal: SessionGoal | null; /** Collapsed shows the icon and the status only; the parent owns the value. */ collapsed: boolean; onToggleCollapsed: () => void; onPress: () => void; + /** + * Sits at the row end, between the goal pressable and the chevron. The PR + * link rides here so the goal status and the pull request share one row; + * with no goal the row renders the trailing control alone, reserving the + * chevron's box so the control keeps one right edge across both. + */ + trailing?: ReactNode; }; /** @@ -44,15 +52,43 @@ type SessionGoalSectionProps = { * The carat rotation and the height transition are the app's shared disclosure * primitives (Reanimated, one implementation on iOS and Android), not a * second copy of the animation. + * + * The row is shared with the pull-request link (`trailing`), which keeps the + * header to two rows. With no goal it renders the trailing control in the same + * shell without the chevron or the goal accessibility state, reserving the + * chevron's 24px box so the control's right edge is the same one it has beside + * a goal (the row's `gap-2` adds the same 8px in both). */ export function SessionGoalSection({ goal, collapsed, onToggleCollapsed, onPress, + trailing, }: Readonly) { const colors = useThemeColors(); const { t } = useTranslation(); + + const trailingSlot = trailing ? ( + {trailing} + ) : null; + + // The disclosure chevron is `h-6 w-6`; a PR-only row still reserves that box + // so the trailing control's right edge matches the goal+PR row instead of + // sliding 24px (plus the row's 8px gap) closer to the row edge. + const chevronGutter = trailing ? : null; + + if (!goal) { + return ( + + + {trailingSlot} + {chevronGutter} + + + ); + } + const isActive = goal.status === 'active'; const status = t(STATUS_LABEL_KEY[goal.status]); @@ -90,6 +126,7 @@ export function SessionGoalSection({ )} + {trailingSlot} { }); }); + it('renders a persisted platform variant as the single bucket row it matches', async () => { + readFilterRecord.mockResolvedValue( + JSON.stringify({ projectFilter: [], platformFilter: ['cloud-agent-web'] }) + ); + listState.storedSessions = sessions; + const renderer = await renderScreen(); + // The sheet shows one checked Cloud Agent row, so the badge counts 1 and + // the history query covers both raw platforms of that row. + expect(historyHeaderActions(renderer).activeFilterCount).toBe(1); + expect(storedSessionIds(renderer)).toEqual(['workflow', 'code-cloud', 'other']); + }); + it('clears no-result filters and resets platform-only filtering through the modal', async () => { listState.storedSessions = sessions; const renderer = await renderScreen(); diff --git a/apps/mobile/src/components/agents/session-list-helpers.test.ts b/apps/mobile/src/components/agents/session-list-helpers.test.ts index b50c8bb92e4..db18ca94a18 100644 --- a/apps/mobile/src/components/agents/session-list-helpers.test.ts +++ b/apps/mobile/src/components/agents/session-list-helpers.test.ts @@ -14,6 +14,10 @@ import { composeSessionProvenanceSubtitle, expandPlatformFilter, formatMeta, + knownPlatformBucket, + normalisePlatformSelection, + PLATFORM_FILTERS, + projectOptionKey, remoteAgentLabel, remoteMeta, remoteSessionEyebrowLabel, @@ -186,6 +190,70 @@ describe('expandPlatformFilter (regression guard for filter expansion)', () => { }); }); +describe('knownPlatformBucket (inverse of expandPlatformFilter)', () => { + it('folds a platform variant into its filter bucket', () => { + expect(knownPlatformBucket('cloud-agent-web')).toBe('cloud-agent'); + expect(knownPlatformBucket('vscode')).toBe('extension'); + expect(knownPlatformBucket('agent-manager')).toBe('extension'); + }); + + it('returns the bucket itself for a bucket', () => { + for (const bucket of PLATFORM_FILTERS) { + expect(knownPlatformBucket(bucket)).toBe(bucket); + } + }); + + it('returns null for an unknown platform', () => { + expect(knownPlatformBucket('future-platform')).toBeNull(); + expect(knownPlatformBucket('')).toBeNull(); + }); + + it('inverts expandPlatformFilter on every bucket', () => { + for (const bucket of PLATFORM_FILTERS) { + for (const platform of expandPlatformFilter([bucket])) { + expect(knownPlatformBucket(platform)).toBe(bucket); + } + } + }); +}); + +describe('normalisePlatformSelection (persisted selection → sheet rows)', () => { + it('collapses a stored variant into its bucket and keeps an unknown platform', () => { + expect(normalisePlatformSelection(['cloud-agent-web', 'vscode'])).toEqual([ + 'cloud-agent', + 'extension', + ]); + expect(normalisePlatformSelection(['jetbrains'])).toEqual(['jetbrains']); + }); + + it('dedupes a bucket saved alongside one of its variants', () => { + expect(normalisePlatformSelection(['cloud-agent', 'cloud-agent-web'])).toEqual(['cloud-agent']); + expect(normalisePlatformSelection(['extension', 'vscode', 'agent-manager'])).toEqual([ + 'extension', + ]); + }); + + it('expands to every raw platform of the checked row, so the history query covers the bucket', () => { + expect( + expandPlatformFilter(normalisePlatformSelection(['cloud-agent-web'])).toSorted() + ).toEqual(['cloud-agent', 'cloud-agent-web'].toSorted()); + }); +}); + +describe('projectOptionKey (visible-label identity)', () => { + it('folds https, ssh, and a .git suffix into one key', () => { + const key = projectOptionKey('https://github.com/org/repo.git'); + expect(key).toBe('org/repo'); + expect(projectOptionKey('https://github.com/org/repo')).toBe(key); + expect(projectOptionKey('git@github.com:org/repo.git')).toBe(key); + expect(projectOptionKey('git@github.com:org/repo')).toBe(key); + }); + + it('trims and lowercases the formatted label', () => { + expect(projectOptionKey('https://github.com/Kilo-Org/KiloCode.git')).toBe('kilo-org/kilocode'); + }); +}); + describe('repoNameFromGitUrl', () => { it('returns null for null', () => { expect(repoNameFromGitUrl(null)).toBeNull(); diff --git a/apps/mobile/src/components/agents/session-list-helpers.ts b/apps/mobile/src/components/agents/session-list-helpers.ts index 04e344c533d..e0df03a34b0 100644 --- a/apps/mobile/src/components/agents/session-list-helpers.ts +++ b/apps/mobile/src/components/agents/session-list-helpers.ts @@ -44,6 +44,48 @@ export function expandPlatformFilter(filter: string[]): string[] { return filter.flatMap(p => platformExpansion.get(p) ?? [p]); } +// Inverse of `expandPlatformFilter` for the UI buckets, so the live list and the +// filter sheet speak the same vocabulary ('cloud-agent' covers +// 'cloud-agent-web', 'extension' covers 'vscode' and 'agent-manager'). Built +// from `expandPlatformFilter` so the mapping stays in one place. +const BUCKET_BY_PLATFORM = new Map( + PLATFORM_FILTERS.filter(bucket => bucket !== 'other').flatMap(bucket => + expandPlatformFilter([bucket]).map(platform => [platform, bucket] as const) + ) +); + +const PLATFORM_BUCKETS = new Set(PLATFORM_FILTERS); + +/** + * Inverse of `expandPlatformFilter` for the UI buckets: a platform variant + * maps to its bucket (`cloud-agent-web` -> `cloud-agent`, `vscode` and + * `agent-manager` -> `extension`), a bucket maps to itself, and anything else + * returns null. Used to collapse a persisted variant into the single row the + * filter sheet offers for its bucket. + */ +export function knownPlatformBucket(platform: string): string | null { + if (PLATFORM_BUCKETS.has(platform)) { + return platform; + } + return BUCKET_BY_PLATFORM.get(platform) ?? null; +} + +/** + * Collapse a persisted platform selection into the bucket rows the filter sheet + * renders: a stored variant maps to its bucket (`cloud-agent-web` -> + * `cloud-agent`, `vscode` and `agent-manager` -> `extension`), a bucket maps to + * itself, and an unknown platform keeps its own value. Deduplicated, so a + * bucket saved alongside one of its variants is one entry. + * + * The badge count and the sheet already speak this vocabulary; the live-list + * comparison and the history query use this so a legacy selection behaves the + * same way everywhere: one checked row means one active filter that covers each + * of that row's raw platforms. + */ +export function normalisePlatformSelection(filter: readonly string[]): string[] { + return [...new Set(filter.map(platform => knownPlatformBucket(platform) ?? platform))]; +} + export function formatGitUrlProject(gitUrl: string): string { const sshMatch = /^git@[^:]+:(.+?)(?:\.git)?$/.exec(gitUrl); const sshPath = sshMatch?.[1]; @@ -73,6 +115,19 @@ export function formatGitUrlProject(gitUrl: string): string { return gitUrl; } +/** + * Identity of a repository filter option: the visible label, trimmed and + * case-folded. Two git URLs (https vs ssh, a `.git` suffix, host case) that + * `formatGitUrlProject` renders identically share one key, so the filter sheet + * offers them as a single row and one stored URL matches every row the merged + * option covers. Case-folds with the active locale (bare `toLowerCase()` is + * banned by `src/lib/case-guard.test.ts`); both sides of every comparison are + * folded in the same locale, so the key stays a stable comparison key. + */ +export function projectOptionKey(gitUrl: string): string { + return formatGitUrlProject(gitUrl).trim().toLocaleLowerCase(i18n.language); +} + export function formatMeta(timestamp: string): string { return timeAgo(parseTimestamp(timestamp)).toLocaleUpperCase(i18n.language); } diff --git a/apps/mobile/src/components/agents/session-list-screen.mounted.test.tsx b/apps/mobile/src/components/agents/session-list-screen.mounted.test.tsx index 5969896dedb..52f61415cc0 100644 --- a/apps/mobile/src/components/agents/session-list-screen.mounted.test.tsx +++ b/apps/mobile/src/components/agents/session-list-screen.mounted.test.tsx @@ -15,7 +15,7 @@ import { LiveSessionListEmptyState } from './live-session-list-empty-state'; import { ScreenHeader } from '@/components/screen-header'; import { Text } from '@/components/ui/text'; import { PULL_FEEDBACK_MIN_BEAT_MS } from './use-pull-refresh'; -import { getEmptyStateFullHeight, SESSION_ROW_PITCH } from '@/lib/agents-bottom-chrome'; +import { getEmptyStateFullHeight } from '@/lib/agents-bottom-chrome'; import { type ActiveSession, type useLiveAgentSessions } from '@/lib/hooks/use-agent-sessions'; import { type BannerState } from '@/lib/offline-banner-state'; @@ -341,10 +341,10 @@ type HeaderElement = { }; }; function headerActions() { - const context = header().props.context as { + const inlineActions = header().props.inlineActions as { props: { children: (HeaderElement | null)[] }; }; - return context.props.children.filter((child): child is HeaderElement => child !== null); + return inlineActions.props.children.filter((child): child is HeaderElement => child !== null); } function headerAction(testID = 'agents-view-history') { const button = headerActions().find(child => child.props.testID === testID); @@ -581,9 +581,12 @@ describe('AgentSessionListScreen live presentation', () => { expect(header().parent?.children[0]).toBe(header()); expect(header().props.className).toContain('px-[22px]'); - // The list controls sit below the title (the header's context slot), so - // nothing shares the title's row and squeezes it on a narrow viewport. + // The list controls share the title's row through `inlineActions`; the + // heading keeps `flex-1 min-w-0`, so the title keeps its tail ellipsis and + // nothing stacks the controls onto a second row. expect(header().props.headerRight).toBeUndefined(); + expect(header().props.context).toBeUndefined(); + expect(header().props.inlineActions).toBeDefined(); expect(emptyState.props.placement).toBeUndefined(); expect(nodes('ScrollView')).toHaveLength(0); expect(root().findByType(StateSurfaceInsets).props.bottomInset).toBe(60); @@ -1083,7 +1086,7 @@ describe('AgentSessionListScreen live presentation', () => { nodes('FlatList')[0]?.props.contentContainerStyle as Record; expect(contentContainerStyle()).toEqual({ paddingTop: 0, - paddingBottom: 0, + paddingBottom: 64, paddingLeft: 0, paddingRight: 0, }); @@ -1094,50 +1097,50 @@ describe('AgentSessionListScreen live presentation', () => { await renderScreen(); expect(contentContainerStyle()).toEqual({ paddingTop: 0, - paddingBottom: 0, + paddingBottom: 64, paddingLeft: 47, paddingRight: 59, }); }); - it('insets the live list viewport by the FAB band so no row sits under the button', async () => { + it('keeps the list frame at the tab bar and clears the FAB in the content', async () => { state.live.activeSessions = [row]; await renderScreen(); - // The viewport must end above the band on both platforms: a frame margin - // shrinks the list, where a content or frame padding would let iOS rows - // park under the bar (and a content inset only cleared the row under the - // button once the user scrolled). - const listStyle = () => nodes('FlatList')[0]?.props.style as { marginBottom: number }; - expect(listStyle()).toEqual({ marginBottom: state.tabBarHeight + 64 }); + // The frame keeps only the tab-bar inset, so the list background runs clean + // to the tab bar edge with no bare band. The FAB clearance is content + // padding instead: the button floats over the list and the last row still + // scrolls clear of it. + const listStyle = () => nodes('FlatList')[0]?.props.style as Record; + const contentStyle = () => + nodes('FlatList')[0]?.props.contentContainerStyle as Record; + expect(listStyle()).toEqual({ marginBottom: state.tabBarHeight }); + expect(contentStyle().paddingBottom).toBe(64); }); - it('clamps the live list frame to one row pitch in a short window and scrolls the rest under the FAB', async () => { + it('keeps the frame at the tab bar and the FAB clearance in the content in a short window', async () => { state.live.activeSessions = [row]; await renderScreen(); const listStyle = () => nodes('FlatList')[0]?.props.style as Record; const contentContainerStyle = () => nodes('FlatList')[0]?.props.contentContainerStyle as Record; - const fullBand = state.tabBarHeight + 64; - // Short landscape window: the body keeps less than the full FAB band, so - // the frame yields the soft band down to one row pitch — the branch line - // is never clipped at rest — and the yielded part rides on the content so - // the last row can still be scrolled clear of the button. - const shortHeight = 180; + // The frame never yields the tab bar and the FAB band never rides on it, so + // the split is the same after the body measures in a short landscape window + // as in a tall one: the viewport ends at the tab bar (no bare band) and the + // FAB clearance stays on the content, where the last row can still scroll + // clear of the button. This replaces origin/main's frame-clamp assertion, + // whose yield-the-band frame is exactly the band papercut 3 removes. act(() => { - layoutBody(shortHeight); + layoutBody(180); }); - const clampedFrame = shortHeight - SESSION_ROW_PITCH; - expect(clampedFrame).toBeLessThan(fullBand); - expect(listStyle()).toEqual({ marginBottom: clampedFrame }); - expect(contentContainerStyle().paddingBottom).toBe(fullBand - clampedFrame); + expect(listStyle()).toEqual({ marginBottom: state.tabBarHeight }); + expect(contentContainerStyle().paddingBottom).toBe(64); - // A tall window keeps the whole band and puts nothing on the content. act(() => { layoutBody(900); }); - expect(listStyle()).toEqual({ marginBottom: fullBand }); - expect(contentContainerStyle().paddingBottom).toBe(0); + expect(listStyle()).toEqual({ marginBottom: state.tabBarHeight }); + expect(contentContainerStyle().paddingBottom).toBe(64); }); it('renders no history list, animated wrappers, or active-now section and keeps one history label without a plus icon', async () => { @@ -1175,24 +1178,32 @@ describe('AgentSessionListScreen header and admission', () => { const history = action('Összes megtekintése'); const label = history.findByType(Text); const actionsRow = history.parent; + const actionsWrapper = actionsRow?.parent; const title = header().findByProps({ accessibilityRole: 'header' }); - // The controls own a full-width row below the title (the header's - // `context` slot), so nothing shares the title's row: through the old + // The controls share the title's row (the header's `inlineActions` slot) as + // a sibling of the heading, so the heading keeps `min-w-0 flex-1` and its + // tail ellipsis while this label keeps the room it needs. Through the old // `headerRight` half-row cap the two columns squeezed each other on a // 480x1040 capture until "Agents" broke mid-word ("Age / nts") and this // label stacked ("SEE / ALL"). expect(header().props.headerRight).toBeUndefined(); - expect(actionsRow?.parent).toBe(title.parent); + expect(header().props.context).toBeUndefined(); + expect(actionsWrapper?.parent).toBe(title.parent?.parent?.parent); + expect(actionsWrapper?.props.className).toContain('min-w-0'); + expect(actionsWrapper?.props.className).toContain('shrink'); + expect(actionsWrapper?.props.className).not.toContain('max-w-[50%]'); expect(title.parent?.props.className).toContain('min-w-0 flex-1'); - expect(title.parent?.parent?.props.className).toContain('flex-row'); + expect(actionsWrapper?.parent?.props.className).toContain('flex-row'); expect(actionsRow?.props.className).toContain('justify-end'); expect(actionsRow?.props.className).toContain('min-h-11'); + expect(actionsRow?.props.className).toContain('min-w-0'); + expect(actionsRow?.props.className).toContain('shrink'); expect(actionsRow?.props.className).not.toContain('max-w-[50%]'); expect(history.props.className).toContain('min-w-0'); expect(history.props.className).toContain('shrink'); expect(actionsRow?.props.className).toContain('items-center'); expect(label.props.className).toContain('text-center'); - expect(label.props.numberOfLines).toBeUndefined(); + expect(label.props.numberOfLines).toBe(1); expect(label.props.allowFontScaling).not.toBe(false); expect(label.props.adjustsFontSizeToFit).not.toBe(true); expect(header().props.reserveEyebrow).toBe(true); @@ -1225,16 +1236,19 @@ describe('AgentSessionListScreen header and admission', () => { expect(header().props.eyebrow).toBe('1 LIVE'); }); - it('renders the list controls below the title with no account control above search', async () => { + it('renders the list controls in the title row with no account control above search', async () => { state.live.activeSessions = [{ ...row, gitUrl: 'https://github.com/kilo/cloud.git' }]; await renderScreen(); - // The controls own a full-width row under the title. Sharing the title's - // row squeezed both columns on a narrow viewport until the title broke - // mid-word and SEE ALL stacked (device capture at 480x1040). - const actions = header().props.context as { props: { className: string } }; + // The controls share the title's row through `inlineActions`, trailing the + // heading. Sharing that row through the old `headerRight` half-row cap + // squeezed both columns on a narrow viewport until the title broke mid-word + // and SEE ALL stacked (device capture at 480x1040). + const actions = header().props.inlineActions as { props: { className: string } }; expect(actions.props.className).toContain('justify-end'); expect(actions.props.className).toContain('min-h-11'); + expect(actions.props.className).toContain('shrink'); expect(header().props.headerRight).toBeUndefined(); + expect(header().props.context).toBeUndefined(); expect( nodes('Pressable').filter(node => node.props.accessibilityHint === 'Select account') ).toHaveLength(0); @@ -1244,9 +1258,13 @@ describe('AgentSessionListScreen header and admission', () => { const filters = nodes('Pressable').find(node => node.props.testID === 'agents-open-filters'); const title = header().findByProps({ accessibilityRole: 'header' }); const actionsRow = history?.parent; - // The title column holds the title and the controls row only, stacked: - // the controls never sit beside the title and squeeze it. - expect(actionsRow?.parent).toBe(title.parent); + const actionsWrapper = actionsRow?.parent; + // The controls row is a sibling of the heading in the row that holds the + // title, so the heading keeps `min-w-0 flex-1` and the title is not squeezed. + expect(actionsWrapper?.parent).toBe(title.parent?.parent?.parent); + expect(actionsWrapper?.props.className).toContain('min-w-0'); + expect(actionsWrapper?.props.className).toContain('shrink'); + expect(actionsWrapper?.props.className).not.toContain('max-w-[50%]'); expect(actionsRow?.props.className).toContain('items-center'); expect(actionsRow?.props.className).toContain('min-h-11'); expect(filters?.parent?.parent).toBe(actionsRow); @@ -1525,9 +1543,10 @@ describe('AgentSessionListScreen live filtering', () => { const fabBand = state.tabBarHeight + FAB_SIZE + FAB_MARGIN; expect(fab()).toBeDefined(); expect(surfaceBottomInset()).toBe(fabBand); - // The button's band rides the rows list's frame, where the FAB is the rows - // list's own overlay. - expect(nodes('FlatList')[0]?.props.style).toEqual({ marginBottom: fabBand }); + // Keyboard down the rows frame ends at the tab bar and the FAB clearance + // rides on the content instead (papercut 3), so no bare band sits above the + // bar and the button floats over the list. + expect(nodes('FlatList')[0]?.props.style).toEqual({ marginBottom: state.tabBarHeight }); act(() => { showKeyboard(100); @@ -1546,10 +1565,9 @@ describe('AgentSessionListScreen live filtering', () => { state.live.activeSessions = [row]; await renderScreen(); const listStyle = () => nodes('FlatList')[0]?.props.style as { marginBottom: number }; - const fabBand = state.tabBarHeight + FAB_SIZE + FAB_MARGIN; - // Keyboard down the container pads nothing, so the frame carries the whole - // band. - expect(listStyle()).toEqual({ marginBottom: fabBand }); + // Keyboard down the container pads nothing and the frame ends at the tab + // bar: the FAB clearance rides on the content, not the frame. + expect(listStyle()).toEqual({ marginBottom: state.tabBarHeight }); expect(keyboardContainerPadding()).toBe(0); act(() => { @@ -1567,7 +1585,7 @@ describe('AgentSessionListScreen live filtering', () => { hideKeyboard(); }); expect(keyboardContainerPadding()).toBe(0); - expect(listStyle()).toEqual({ marginBottom: fabBand }); + expect(listStyle()).toEqual({ marginBottom: state.tabBarHeight }); }); it('keeps the rows viewport clear of the FAB when a raised IME is shorter than the button', async () => { @@ -1597,7 +1615,7 @@ describe('AgentSessionListScreen live filtering', () => { act(() => { hideKeyboard(); }); - expect(listStyle()).toEqual({ marginBottom: fabBand }); + expect(listStyle()).toEqual({ marginBottom: state.tabBarHeight }); }); it('subscribes to the keyboard once for the bands, not once per band consumer', async () => { diff --git a/apps/mobile/src/components/agents/session-list-screen.tsx b/apps/mobile/src/components/agents/session-list-screen.tsx index 4beacbf1fe2..50a9f8a93ea 100644 --- a/apps/mobile/src/components/agents/session-list-screen.tsx +++ b/apps/mobile/src/components/agents/session-list-screen.tsx @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- The live list keeps its query, pull-refresh, keyboard container, and FAB orchestration together on one screen. */ -import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AppState, FlatList, @@ -49,6 +49,11 @@ import { type Href, useFocusEffect, useNavigation, useRouter, useScrollToTop } f const SKELETON_ROW_COUNT = 8; +/** The See-all label's uppercase micro type, kept out of the JSX so the long + * class list does not force the `Text` open tag to wrap. */ +const SEE_ALL_TEXT_CLASS = + 'shrink text-center font-mono-medium text-[11px] uppercase tracking-[1.5px] text-primary'; + export function AgentSessionListScreen() { const router = useRouter(); const navigation = useNavigation(); @@ -207,17 +212,18 @@ export function AgentSessionListScreen() { const navigateToSession = useAgentSessionNavigator(); const seeAllLabel = t('home.seeAll'); - // The list controls take the header's `context` slot, one line below the - // title, so the 30px title owns the whole title row (Quick Chat puts its - // account control in the same slot). Sharing that row through `headerRight`, - // the slot's half-row cap squeezed both columns on a narrow viewport until + // The list controls share the title's row through the header's `inlineActions` + // slot, trailing the eyebrow + title heading. The heading keeps + // `min-w-0 flex-1`, so the 30px title keeps its tail ellipsis while the + // controls keep their full width — sharing that row through the old + // `headerRight` half-row cap squeezed both columns on a narrow viewport until // the title broke mid-word and this label stacked onto two lines (device - // capture at 480x1040: "Age / nts" beside "SEE / ALL"). On its own row the - // control keeps the header's full width at every display size, and the - // reserved row height keeps the header from moving when the filter button - // appears with the loaded sessions. + // capture at 480x1040: "Age / nts" beside "SEE / ALL"). The reserved row + // height keeps the header from moving when the filter button appears with the + // loaded sessions, and the box can shrink so an extreme accessibility scale + // ellipsizes the label instead of wrapping it to a second line. const headerActions = ( - + { router.push('/(app)/(tabs)/(2_agents)/history' as Href); @@ -229,7 +235,7 @@ export function AgentSessionListScreen() { testID="agents-view-history" className="min-w-0 shrink justify-center active:opacity-70" > - + {seeAllLabel} @@ -257,18 +263,31 @@ export function AgentSessionListScreen() { [navigateToSession, organizationId] ); - // The list's frame/content insets, the FAB style, the body measurement, and - // the centered states' reserve and presentation: the tab bar and the FAB are - // absolutely-positioned overlays, and in a short window the frame yields the - // soft FAB band so one row stays readable (see `useAgentsListChrome`). - const { - onBodyLayout, - listInsets: chromeListInsets, - fabStyle, - sidePadding, - centeredBottomInset, - compactEmptyState, - } = useAgentsListChrome({ showFab, tabBarHeight, fontScale, left, right }); + // The FAB style, the side padding, the body measurement, and the centered + // states' reserve and presentation come from the list-chrome owner (see + // `useAgentsListChrome`). The rows list's bottom split stays here: the FAB + // clearance rides on the list's content, so the frame keeps only the tab bar + // (see `listInsets` below). + const { onBodyLayout, fabStyle, sidePadding, centeredBottomInset, compactEmptyState } = + useAgentsListChrome({ showFab, tabBarHeight, fontScale, left, right }); + + // The tab bar and the FAB are absolutely-positioned overlays, so scrollable + // content must clear them. The tab bar keeps its viewport inset as the list + // frame's `marginBottom` — the same viewport inset `TabScreenScrollView` uses + // — so the list's own background runs clean to the tab bar edge with no bare + // band between the last row and the bar. The FAB's clearance rides on the + // list's content as `paddingBottom` instead: the button floats over the list, + // and the end padding still carries the last row clear of it. Frame margin + // plus content padding together match the screen's `StateSurfaceInsets`. The + // landscape side insets keep row text clear of the sensor housing; portrait + // insets are 0, keeping the geometry unchanged. + const listInsets = useMemo(() => { + const fabPad = showFab ? FAB_SIZE + FAB_MARGIN : 0; + return { + frame: { marginBottom: tabBarHeight }, + content: { paddingTop: 0, paddingBottom: fabPad, paddingLeft: left, paddingRight: right }, + }; + }, [showFab, tabBarHeight, left, right]); // The rows list's total band, mirroring `useAgentsBottomBands`'s `listBand` // (the hook keeps that value private): while the FAB is admitted its overlay @@ -287,15 +306,16 @@ export function AgentSessionListScreen() { const centeredBand = keyboardUp ? surfaceBand : centeredBottomInset; // The rows list's frame and content insets. While the keyboard is down the - // shared chrome geometry owns them: it yields the soft FAB band in a short - // window so one row stays readable and hands the yielded part to the content. + // screen's own split owns them: the frame ends at the tab bar (no bare band) + // and the FAB clearance rides on the content, so the button floats over the + // list and the last row still scrolls clear of it (see `listInsets` above). // While the keyboard is up the container already pads the IME's occlusion, so // the frame carries only the remainder of the rows band (`rowsFrameBand`) and // the viewport ends at the band's edge instead of a whole IME height above it // (review finding, session-list-screen.tsx:418). The landscape side insets // keep row text clear of the sensor housing. const keyboardRowsInsets = useSessionListInsets({ bottomBand: rowsFrameBand, left, right }); - const listInsets = keyboardUp ? keyboardRowsInsets : chromeListInsets; + const rowsInsets = keyboardUp ? keyboardRowsInsets : listInsets; let body: ReactNode = null; if (!query.hasLoaded || content === 'pending') { @@ -347,9 +367,9 @@ export function AgentSessionListScreen() { /> ); } else if (hasLiveRows) { - // The FAB-band inset shrinks the list's frame (`marginBottom`), so the - // viewport ends above the button's band and no row can scroll into it on - // either platform. + // The tab-bar inset rides on the list frame, so the viewport ends at the tab + // bar; the FAB clearance rides on the content's `paddingBottom`, so the + // button floats over the list and the last row still scrolls clear of it. body = ( item.id} extraData={attentionFocusRevision} - style={listInsets.frame} - contentContainerStyle={listInsets.content} + style={rowsInsets.frame} + contentContainerStyle={rowsInsets.content} refreshControl={rowsControl} maintainVisibleContentPosition={{ minIndexForVisible: 0, autoscrollToTopThreshold: 10 }} /> @@ -430,7 +450,7 @@ export function AgentSessionListScreen() { size="large" showBackButton={false} className="px-[22px] pb-1" - context={headerActions} + inlineActions={headerActions} /> {hasLiveRows || isSearching ? ( (platformFilter.length > 0 ? expandPlatformFilter(platformFilter) : undefined), - [platformFilter] - ); + const createdOnPlatform = useMemo(() => { + if (platformFilter.length === 0) { + return undefined; + } + // Collapse a persisted variant into its bucket first, so the history query + // covers the raw platforms of the single row the sheet checks. + return expandPlatformFilter(normalisePlatformSelection(platformFilter)); + }, [platformFilter]); const gitUrl = useMemo( () => (projectFilter.length > 0 ? projectFilter : undefined), [projectFilter] diff --git a/apps/mobile/src/components/screen-header.mounted.test.tsx b/apps/mobile/src/components/screen-header.mounted.test.tsx index f082dd63ffa..40426ea0306 100644 --- a/apps/mobile/src/components/screen-header.mounted.test.tsx +++ b/apps/mobile/src/components/screen-header.mounted.test.tsx @@ -191,6 +191,26 @@ function findHeaderRight(root: TestInstance): TestInstance { return headerRight; } +/** + * The trailing wrapper that carries `inlineActions`. It shares the `ms-3` + * start margin with the `headerRight` wrapper but drops the half-row + * `max-w-[50%]` cap. + */ +function findInlineActionsWrapper(root: TestInstance): TestInstance { + const wrapper = root.findAll( + node => + typeof node.type === 'string' && + (node.type as string) === 'View' && + typeof node.props.className === 'string' && + /(^|\s)ms-3(\s|$)/.test(node.props.className) && + !node.props.className.includes('max-w-[50%]') + )[0]; + if (!wrapper) { + throw new Error('inlineActions view not found'); + } + return wrapper; +} + function renderHeader(props: ScreenHeaderProps): TestRenderer.ReactTestRenderer { const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; act(() => { @@ -263,6 +283,60 @@ describe('ScreenHeader mounted', () => { expect(headerRight.props.className).not.toContain('shrink-0'); }); + it('renders inlineActions in the title row uncapped while headerRight keeps its cap', () => { + const renderer = renderHeader({ + title: 'Agents', + headerRight: 'RIGHT', + inlineActions: 'ACTIONS', + }); + + const capped = findHeaderRight(renderer.root); + const inline = findInlineActionsWrapper(renderer.root); + + // Both trailing slots sit in the title row beside the heading, so the + // heading keeps `flex-1 min-w-0` and the title keeps its tail ellipsis. + expect(inline.parent).toBe(capped.parent); + expect(inline.children).toEqual(['ACTIONS']); + expect(inline.parent?.props.className).toContain('flex-row'); + const heading = inline.parent?.children[0] as TestInstance; + expect(heading.props.className).toContain('min-w-0 flex-1'); + // `headerRight` keeps its half-row cap; `inlineActions` keeps its full width + // instead of wrapping the controls. + expect(capped.props.className).toContain('max-w-[50%]'); + expect(inline.props.className).not.toContain('max-w-[50%]'); + expect(inline.props.className).toContain('min-w-0'); + expect(inline.props.className).toContain('shrink'); + }); + + it('renders inlineActions alone in the title row', () => { + const renderer = renderHeader({ title: 'Agents', inlineActions: 'ACTIONS' }); + + const inline = findInlineActionsWrapper(renderer.root); + expect(inline.parent?.props.className).toContain('flex-row'); + const capped = renderer.root.findAll( + node => + typeof node.type === 'string' && + (node.type as string) === 'View' && + typeof node.props.className === 'string' && + node.props.className.includes('max-w-[50%]') + ); + expect(capped).toHaveLength(0); + }); + + it('renders inlineActions beside a centered title too', () => { + // A centered title (`centerTitle`, e.g. a modal with a title or eyebrow) + // shares its row with the leading control; the inline actions slot must not + // be dropped just because the title is centered. + const renderer = renderHeader({ modal: true, title: 'Filters', inlineActions: 'ACTIONS' }); + + const inline = findInlineActionsWrapper(renderer.root); + expect(inline.children).toEqual(['ACTIONS']); + expect(inline.parent?.props.className).toContain('flex-row'); + expect(inline.props.className).not.toContain('max-w-[50%]'); + const title = renderer.root.findByProps({ accessibilityRole: 'header' }); + expect(title.props.className).toContain('text-center'); + }); + it('keeps the title hit slop asymmetric so it never overlaps the back target', () => { const renderer = renderHeader({ title: 'Sessions', onTitlePress: () => undefined }); diff --git a/apps/mobile/src/components/screen-header.tsx b/apps/mobile/src/components/screen-header.tsx index 0222fe4a17e..43aec5b1dda 100644 --- a/apps/mobile/src/components/screen-header.tsx +++ b/apps/mobile/src/components/screen-header.tsx @@ -87,7 +87,12 @@ type ScreenHeaderProps = { /** Use Focus's large 30px H1 style (list roots). Default 18px (detail). */ size?: 'default' | 'large'; headerRight?: React.ReactNode; - /** Home, Agents, Quick Chat, and session headers supply context below the title. + /** Controls rendered at the trailing edge of the title row, in the + * leading-aligned row and beside a centered title alike. The heading keeps + * `flex-1 min-w-0`, so the title keeps its tail ellipsis and the controls keep + * their full width instead of the `headerRight` half-row cap wrapping them. */ + inlineActions?: React.ReactNode; + /** Home, Quick Chat, and session headers supply context below the title. * Other callers keep their existing title-only layout when this slot is absent. */ context?: React.ReactNode; modal?: boolean; @@ -140,6 +145,7 @@ export function ScreenHeader({ reserveEyebrow = false, size = 'default', headerRight, + inlineActions, context, modal, centerTitle = modal ?? false, @@ -329,7 +335,7 @@ export function ScreenHeader({ ) : null; const centeredControls = - separateHeading && backControl ? ( + separateHeading && backControl && !inlineActions && (!headerRight || stackActions) ? ( ) : null; @@ -346,6 +352,7 @@ export function ScreenHeader({ ) : ( centeredControls )} + {inlineActions ? {inlineActions} : null} {headerRight && stackActions ? ( // The actions keep the title's own row only while it can hold a @@ -365,6 +372,7 @@ export function ScreenHeader({ {headerRight && !stackActions ? ( {headerRight} ) : null} + {inlineActions ? {inlineActions} : null} {headerRight && stackActions ? ( {headerRight} diff --git a/apps/mobile/src/components/tabs-layout.mounted.test.tsx b/apps/mobile/src/components/tabs-layout.mounted.test.tsx index 7464c5b736d..d36526b5393 100644 --- a/apps/mobile/src/components/tabs-layout.mounted.test.tsx +++ b/apps/mobile/src/components/tabs-layout.mounted.test.tsx @@ -1,147 +1,51 @@ -// Kept out of `src/app/(app)/(tabs)/` on purpose: expo-router reads any file named -// `_layout..tsx` as that directory's layout, and the typed-routes generator then -// skips a layout's children, dropping every `(tabs)` route from the generated -// `Href` union and failing `pnpm typecheck`. Mounted tests run from anywhere under -// `src/` (vitest.mounted.config.ts), so this one lives beside the tab-bar tests. -// The tab bar yields the bottom band to the IME: the shared `screenOptions` -// must carry `tabBarHideOnKeyboard: true` so the absolutely-positioned bar -// hides while the keyboard is up (its labels and the empty-state second line -// otherwise sit under the IME strip). The option lives on the same object that -// carries the existing `tabBarStyle`, so every tab and both platforms share it. +import { createElement } from 'react'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { createElement, type ReactNode } from 'react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import TabsLayout from '@/app/(app)/(tabs)/_layout'; -import { i18n } from '@/i18n'; -import { getEffectiveTabBarHeight } from '@/lib/tab-bar-layout'; +import { CountSurfaces } from '@/components/agents/agents-tab-badge.test-helpers'; +import { type ReactTestInstance } from '@/test/renderer'; import { renderWithProviders } from '@/test/render-with-providers'; -type CapturedTabsOptions = Record; +type Mount = Awaited>; +const mounts: Mount[] = []; -const tabsRenders = vi.hoisted(() => ({ list: [] as { screenOptions?: CapturedTabsOptions }[] })); -// The centered-state band the tabs navigator hands its screens. It must be the -// tab bar's own height: reserving the scroll-content gap as well shrank the band -// below the empty state's height in a short landscape window and parked its -// second line and action behind the bar (landscape spot defect e8). -const surfaceInsets = vi.hoisted(() => ({ list: [] as { bottomInset?: number }[] })); +function isHostType(item: ReactTestInstance, type: string) { + return typeof item.type === 'string' && item.type === type; +} -vi.mock('expo-router', () => { - const Tabs = Object.assign( - (props: { screenOptions?: CapturedTabsOptions }) => { - tabsRenders.list.push(props); - return null; - }, - { Screen: () => null } - ); - return { - useRouter: () => ({ replace: vi.fn(), navigate: vi.fn(), push: vi.fn() }), - usePathname: () => '/(app)/(tabs)/(0_home)', - useSegments: () => ['(app)', '(tabs)', '(0_home)'], - Tabs, +function screenOptions(renderer: Mount['renderer']) { + return renderer.root.find(item => isHostType(item, 'Tabs')).props.screenOptions as { + tabBarHideOnKeyboard: boolean; + tabBarShowLabel: boolean; + tabBarStyle: { display?: string }; }; -}); -vi.mock('expo-haptics', () => ({ - selectionAsync: vi.fn(), - impactAsync: vi.fn(), - notificationAsync: vi.fn(), -})); -vi.mock('react-native', () => ({ - Platform: { OS: 'ios' }, - View: 'View', - useWindowDimensions: () => ({ fontScale: 1, width: 800, height: 360 }), -})); -vi.mock('react-native-safe-area-context', () => ({ - useSafeAreaInsets: () => ({ bottom: 24, top: 0, left: 0, right: 0 }), -})); -vi.mock('@/components/centered-state-surface', async () => { - const react = await import('react'); - return { - StateSurfaceInsets: (props: { children?: ReactNode; bottomInset?: number }) => { - surfaceInsets.list.push({ bottomInset: props.bottomInset }); - return react.createElement(react.Fragment, null, props.children); - }, - }; -}); -vi.mock('@/components/ui/blur-bar', () => ({ BlurBar: 'BlurBar' })); -vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); -vi.mock('@/components/ui/icons', () => ({ - Bot: 'Bot', - House: 'House', - MessageCircle: 'MessageCircle', - MessageSquare: 'MessageSquare', - UserRound: 'UserRound', -})); -vi.mock('@/lib/analytics/posthog', () => ({ - FEATURE_FLAG_QUICK_CHAT: 'mobile-quick-chat', - useFeatureFlag: () => false, -})); -vi.mock('@/lib/app-actions/use-pending-app-action', () => ({ - usePendingAppAction: () => undefined, -})); -vi.mock('@/lib/finding-detail-back', () => ({ - PROFILE_TAB_ROOT: '/(app)/(tabs)/(3_profile)', -})); -vi.mock('@/lib/hooks/use-agent-sessions', () => ({ - useLiveAgentSessions: () => ({ activeSessions: [], isLoading: false, isError: false }), -})); -vi.mock('@/lib/hooks/use-kiloclaw-tab-visible', () => ({ - useKiloClawTabVisible: () => false, -})); -vi.mock('@/lib/hooks/use-theme-colors', () => ({ - useThemeColors: () => ({ foreground: '#000000', mutedForeground: '#888888' }), -})); -vi.mock('@/lib/organization-context', () => ({ - useOrganization: () => ({ - organizationId: 'org-1', - isLoaded: true, - error: null, - retry: vi.fn(), - setOrganizationId: vi.fn(), - }), -})); - -/** The `screenOptions` object captured from the most recent `Tabs` render. */ -function latestScreenOptions(): CapturedTabsOptions | undefined { - return tabsRenders.list.at(-1)?.screenOptions; } -describe('TabsLayout screen options', () => { +describe('Tabs layout with the keyboard raised', () => { beforeEach(() => { - tabsRenders.list.length = 0; - surfaceInsets.list.length = 0; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; }); - it('hides the tab bar while the keyboard is up, on the shared option object', async () => { - await renderWithProviders(createElement(TabsLayout)); - - expect(i18n.isInitialized).toBe(true); - const screenOptions = latestScreenOptions(); - expect(screenOptions).toBeDefined(); - expect(screenOptions?.tabBarHideOnKeyboard).toBe(true); - // The same shared object keeps the bar's absolute placement, so hiding it - // moves no surrounding layout and dismissal restores it in place. - const tabBarStyle = screenOptions?.tabBarStyle as { - display?: string; - position?: string; - height?: unknown; - }; - expect(tabBarStyle).toMatchObject({ display: 'flex', position: 'absolute' }); - expect(typeof tabBarStyle.height).toBe('number'); - // Labels stay configured, so the bar comes back whole when the IME is gone. - expect(screenOptions?.tabBarShowLabel).toBe(true); + afterEach(() => { + for (const result of mounts) { + result.unmount(); + } + mounts.length = 0; }); - it('ends the centered-state band at the tab bar, not at the scroll gap above it', async () => { - await renderWithProviders(createElement(TabsLayout)); - - // The harness reports a 24pt bottom safe inset on iOS, so the bar is 74pt. - const tabBarHeight = getEffectiveTabBarHeight({ - bottomInset: 24, - platform: 'ios', - fontScale: 1, - }); - expect(tabBarHeight).toBe(74); - expect(surfaceInsets.list.at(-1)?.bottomInset).toBe(tabBarHeight); + it('steps the whole bar out of the IME’s way instead of leaving a clipped icon strip', async () => { + const result = await renderWithProviders(createElement(CountSurfaces)); + mounts.push(result); + const options = screenOptions(result.renderer); + + // The bar is absolutely positioned, so the keyboard covers its lower half and + // only the icon row peeks out above it (explorer finding, agents-search-empty). + // Hide-on-keyboard is the navigator's own IME handling: it watches the same + // platform keyboard events the rest of the app pads for and animates the bar + // out of the way, leaving no half-covered strip. + expect(options.tabBarHideOnKeyboard).toBe(true); + // Labels stay configured, so the bar comes back whole when the IME is gone. + expect(options.tabBarShowLabel).toBe(true); + // The resting presentation is unchanged: the bar renders in place. + expect(options.tabBarStyle.display).toBe('flex'); }); }); diff --git a/apps/mobile/src/lib/agent-session-filters.test.ts b/apps/mobile/src/lib/agent-session-filters.test.ts index 14502360a99..cad7890400e 100644 --- a/apps/mobile/src/lib/agent-session-filters.test.ts +++ b/apps/mobile/src/lib/agent-session-filters.test.ts @@ -60,6 +60,62 @@ describe('countActiveSessionFilters', () => { }) ).toBe(3); }); + + it('counts an empty project filter as zero', () => { + expect(countActiveSessionFilters(createDefaultAgentSessionFilters())).toBe(0); + }); + + it('counts one project option as one', () => { + expect( + countActiveSessionFilters({ + platformFilter: [], + projectFilter: ['https://github.com/org/repo.git'], + }) + ).toBe(1); + }); + + it('counts one merged project option once across its git-URL aliases', () => { + expect( + countActiveSessionFilters({ + platformFilter: [], + projectFilter: ['https://github.com/org/repo.git', 'git@github.com:org/repo.git'], + }) + ).toBe(1); + }); + + it('counts two distinct projects as two', () => { + expect( + countActiveSessionFilters({ + platformFilter: [], + projectFilter: ['https://github.com/org/a', 'https://github.com/org/b'], + }) + ).toBe(2); + }); + + it('counts a persisted platform bucket and its variant as one checked row', () => { + expect( + countActiveSessionFilters({ + platformFilter: ['cloud-agent', 'cloud-agent-web'], + projectFilter: [], + }) + ).toBe(1); + expect( + countActiveSessionFilters({ + platformFilter: ['extension', 'vscode', 'agent-manager'], + projectFilter: [], + }) + ).toBe(1); + }); + + it('counts distinct platform buckets separately', () => { + expect(countActiveSessionFilters({ platformFilter: ['cli', 'slack'], projectFilter: [] })).toBe( + 2 + ); + }); + + it('counts an unknown platform as its own row', () => { + expect(countActiveSessionFilters({ platformFilter: ['jetbrains'], projectFilter: [] })).toBe(1); + }); }); it('ignores a legacy stored sortBy field', () => { diff --git a/apps/mobile/src/lib/agent-session-filters.ts b/apps/mobile/src/lib/agent-session-filters.ts index 05d99ea4c09..e71b131cc09 100644 --- a/apps/mobile/src/lib/agent-session-filters.ts +++ b/apps/mobile/src/lib/agent-session-filters.ts @@ -1,9 +1,18 @@ import { z } from 'zod'; +import { + normalisePlatformSelection, + projectOptionKey, +} from '@/components/agents/session-list-helpers'; + /** * Pure contract for the persisted session filter set. Intentionally free of - * any Expo / SecureStore imports so it can be unit-tested in node and re-used - * by tests/mocks without touching the native bridge. + * any Expo / SecureStore / native-bridge imports so it can be unit-tested in + * node and re-used by tests/mocks without touching the native bridge. It + * imports the visible-label project key (`projectOptionKey`) and the + * platform-bucket collapse (`normalisePlatformSelection`) from the session-list + * helpers so the badge counts each row the filter sheet renders once; that + * module is native-free too, so the node test still runs. * * Both session-list pages persist this shape, under their own storage key. */ @@ -73,5 +82,15 @@ export function parseStoredAgentSessionFilters(raw: string | null): AgentSession /** How many narrowing filters are applied — drives the header badge count. */ export function countActiveSessionFilters(filters: AgentSessionFilters): number { - return filters.platformFilter.length + filters.projectFilter.length; + // One visible project option can carry several git-URL aliases (https vs ssh, + // a `.git` suffix, host case) that render to one label, so the raw array + // length over-counts the rows the sheet shows. + const projectCount = new Set(filters.projectFilter.map(gitUrl => projectOptionKey(gitUrl))).size; + // The sheet collapses a persisted platform variant into its bucket row + // (`normalisePlatform` in the filter modal), so a legacy selection holding a + // bucket and one of its variants (`cloud-agent` + `cloud-agent-web`) renders + // one checked row and must count once. An unknown platform keeps its own row, + // so it counts as itself. + const platformCount = normalisePlatformSelection(filters.platformFilter).length; + return platformCount + projectCount; }