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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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');
Expand Down
28 changes: 16 additions & 12 deletions apps/mobile/src/components/agents/chat-toolbar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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');
Expand All @@ -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;

Expand All @@ -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', () => {
Expand All @@ -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');
});
Expand Down
47 changes: 19 additions & 28 deletions apps/mobile/src/components/agents/chat-toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand All @@ -60,7 +58,6 @@ export function ChatToolbar({
modelLocked = false,
modelLockLabel,
className,
wrap = true,
onLayout,
}: Readonly<ChatToolbarProps>) {
const modeSelector = (
Expand All @@ -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.
<View className="min-w-0 grow flex-row items-center gap-2">
// `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.
<View className="min-w-0 shrink grow flex-row items-center gap-2">
{modelSelector}
{onPaste ? (
<ComposerPasteButton
Expand All @@ -106,19 +102,14 @@ export function ChatToolbar({
);

return (
// The chips reflow instead of shrinking each other: the mode chip is
// `shrink-0`, so in a nowrap row the only flexible part is the model chip,
// and a long model name ("DeepSeek V4.1 Flash") collapses to "Dee..." next
// to the effort badge. Wrapping moves the model chip to its own line, where
// it has the full row width to show the selected model.
// The row never wraps: it stays one line on both the session and the
// new-session composer at every width and locale. The mode chip is
// `shrink-0`, so the model chip takes the remaining width and truncates a
// long model name ("DeepSeek V4.1 Flash") with its own `numberOfLines={1}`,
// while the paste button keeps the trailing edge of the same line.
<View
onLayout={onLayout}
className={cn(
'flex-row items-center gap-2 px-3 py-2.5',
wrap && 'flex-wrap',
disabled && 'opacity-50',
className
)}
className={cn('flex-row items-center gap-2 px-3 py-2.5', disabled && 'opacity-50', className)}
>
{order === 'model-first' ? modelSelectorWithPaste : modeSelector}
{order === 'model-first' ? modeSelector : modelSelectorWithPaste}
Expand Down
50 changes: 50 additions & 0 deletions apps/mobile/src/components/agents/live-session-filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading