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
44 changes: 44 additions & 0 deletions apps/mobile/src/components/agents/markdown-renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,50 @@ describe('MarkdownRenderer list marker alignment', () => {
await unmountMarkdown(mounted);
});

it('keeps a wrapped bullet item under its own first line, not under the marker', async () => {
// The finding's own shape: one tight bullet whose text wraps over several
// lines with an inline code span and an em-dash continuation. The row is a
// row — a fixed-width marker box beside a content View that shrinks — so
// every wrapped line lays out inside that content View and starts at the
// first line's left edge, never at the marker's. A change that moved the
// item text out of the shrinking View, or stopped the View shrinking, would
// let a continuation line start at the marker and must fail this test.
const mounted = await mountMarkdown(
'- Current branch `kilo/breezy-engine-kdc` has **no CLI** — only `src/strategies/120-ema.ts` (a top-level-await script that simulates and console.logs trades), db.ts, batch-db.ts, tickers.ts, base-url.ts.'
);
assertAlignedList(mounted, 1);
assertItemTextMetrics(mounted, ['Current']);

const row = mounted.root.findAll(node => propOf(node, 'testID') === 'marked-list-item')[0];
if (!row) {
throw new Error('list item row missing');
}
const contentView = viewChildren(row)[0];
if (!contentView) {
throw new Error('list item content View missing');
}
const textNodes = (phrase: string) =>
mounted.root.findAll(
node =>
typeof node.type === 'string' &&
(node.type as unknown) === 'Text' &&
node.children.some(child => typeof child === 'string' && child.includes(phrase))
);
// The first line and its wrapped continuation are separate inline Text
// nodes; both must be descendants of the one shrinking content View.
const firstLine = textNodes('Current branch');
const continuation = textNodes('— only');
expect(firstLine).toHaveLength(1);
expect(continuation).toHaveLength(1);
for (const text of [firstLine[0], continuation[0]]) {
if (!text) {
throw new Error('item text node missing');
}
expect(contentView.findAll(node => node === text)).toHaveLength(1);
}
await unmountMarkdown(mounted);
});

it('keeps a leading blockquote margin in a list item', async () => {
const mounted = await mountMarkdown('- > quoted text');
const rows = mounted.root.findAll(node => propOf(node, 'testID') === 'marked-list-item');
Expand Down
17 changes: 10 additions & 7 deletions apps/mobile/src/components/agents/remote-session-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,16 @@ export function RemoteSessionRow({
};
}, [refreshScope]);
const exitingRef = useRef(false);
// The server's creation-default title (`New session - <ISO timestamp>`) is an
// internal marker, never row copy: `namedSessionTitle` resolves it through
// the shared `sessionDisplayTitle` helper and additionally keeps a
// placeholder-shaped title the user's own rename wrote, so the same label
// feeds the row, the accessibility label, and the rename prompt. The
// subscription repaints the row once the durable record hydrates after a
// cold start.
// One derivation for the visible label, the spoken label and the rename
// prompt: the server's creation-default title (`New session - <ISO
// timestamp>`) is an internal marker, never row copy, so a creation
// placeholder title reads as "Untitled session" — the row falls back to the
// localized unnamed name the same way the session header does.
// `namedSessionTitle` makes that judgement through the shared
// `sessionDisplayTitle` helper and additionally keeps a placeholder-shaped
// title the user's own rename wrote, so the same label feeds the row, the
// accessibility label, and the rename prompt. The subscription repaints the
// row once the durable record hydrates after a cold start.
useUserSessionTitlesRevision();
const title = namedSessionTitle(session.title, session.id) ?? t('agents.sessionRow.untitled');
// Same seeding as the stored row: a session the backend has not named yet
Expand Down
20 changes: 19 additions & 1 deletion apps/mobile/src/components/agents/session-detail-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -673,7 +673,10 @@ type MountDetailsOptions = {
metadataReady?: Promise<undefined>;
displayScope?: ComponentProps<typeof SessionDetailContent>['displayScope'];
cachedRows?: StoredMessage[] | null;
/** The route's cached metadata title, as `[session-id].tsx` passes it. */
/**
* The route's cached list title, seeded before the session record loads, as
* `[session-id].tsx` passes it.
*/
cachedTitle?: string;
/** The route's `?at=` param the screen mounts with. */
resumeAt?: string | null;
Expand Down Expand Up @@ -1028,6 +1031,21 @@ describe('SessionDetailContent header title', () => {
// `ScreenHeader` caps the trailing slot at 50% of the row, but RN's default
// flexShrink is 0: unless the cluster and the pill opt in, their children
// keep their natural width and paint past the row's right edge, off-screen.
// The route seeds the header with the cached list title it opened from. A
// session created through cloud-agent-next carries the creation placeholder
// `New session - <ISO instant>` there, and the header must fall back to its
// own title rather than paint the machine string while the record loads.
it('shows the fallback title instead of a placeholder cached title', async () => {
const metadata = Promise.withResolvers<undefined>();
const view = await mountDetails([], {
metadataReady: metadata.promise,
cachedTitle: 'New session - 2026-09-22T17:26:31.465Z',
});
const header = view.renderer.root.findByType(ScreenHeader);
expect(header.props.title).toBe(i18n.t('agentChat.session.title'));
expect(String(header.props.title)).not.toContain('2026-09-22');
});

it('lets the trailing header cluster shrink instead of spilling off-screen', async () => {
const { renderer } = await mountDetails();
const headerRight = renderer.root.findByType(ScreenHeader).props.headerRight as {
Expand Down
8 changes: 5 additions & 3 deletions apps/mobile/src/components/agents/session-detail-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1628,9 +1628,11 @@ export function SessionDetailContent({
isLoaded: isSessionLoaded,
serverTitle,
// Same seed the route's loading screen used, so the header keeps the
// title it opened with instead of blinking back to "Session". The route's
// cached metadata can hold the backend's ISO placeholder, which must not
// paint either, while a title the user's own rename wrote is kept.
// title it opened with instead of blinking back to "Session". A creation
// placeholder cached in the list is not a title: fall back to "Session".
// The route's cached metadata can hold the backend's ISO placeholder,
// which must not paint either, while a title the user's own rename wrote
// is kept.
fallbackTitle: namedSessionTitle(cachedTitle, sessionId) ?? t('agentChat.session.title'),
});
const handleRenameSave = rename.submit;
Expand Down
13 changes: 10 additions & 3 deletions apps/mobile/src/components/agents/session-detail-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { type KiloSessionId, type StoredMessage } from '@kilocode/cloud-agent-sd
import type * as ReactI18next from 'react-i18next';

import { type SessionTranscriptItem } from '@/components/agents/session-transcript';
import type * as SessionListHelpers from '@/components/agents/session-list-helpers';
import { SessionMessageList } from '@/components/agents/session-message-list';
import { MessageDetailsSheet } from '@/components/agents/message-details-sheet';
import { MessageBubble } from '@/components/agents/message-bubble';
Expand Down Expand Up @@ -368,9 +369,15 @@ vi.mock('@/components/agents/context-usage-display', () => ({
vi.mock('@/components/agents/session-composer-disabled', () => ({
resolveSessionComposerDisabled: () => false,
}));
vi.mock('@/components/agents/session-list-helpers', () => ({
selectSessionCostInputs: () => ({ breakdownCostUsd: null, totalMicrodollars: null }),
}));
// Keep the real pure helpers (the header derives its fallback title through
// `sessionDisplayTitle`); only the cost derivation is stubbed for this suite.
vi.mock('@/components/agents/session-list-helpers', async importOriginal => {
const actual = await importOriginal<typeof SessionListHelpers>();
return {
...actual,
selectSessionCostInputs: () => ({ breakdownCostUsd: null, totalMicrodollars: null }),
};
});
vi.mock('@/components/agents/mobile-session-manager-helpers', () => ({
buildRemoteAttachmentParts: vi.fn(),
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,49 @@ describe('getSessionDetailRenameState', () => {
});
});

it('falls back to the caller title when the server title is the creation placeholder', () => {
// A session created through cloud-agent-next carries
// `New session - <ISO instant>` (or the child variant) until it is named.
// The header must never paint that machine string, whether it arrived as
// the fetched server title or as a live `session.updated` title (which
// reaches the hook as `serverTitle`).
const state = getSessionDetailRenameState({
fallbackTitle: 'Session',
isLoaded: true,
serverTitle: 'New session - 2026-09-22T17:26:31.465Z',
renameState: { ...initialRenameState(), isModalOpen: true },
});
expect(state.title).toBe('Session');
expect(state.modalInitialValue).toBe('Session');
expect(state.title).not.toContain('2026-09-22');
});

it('returns a real caller fallback unchanged and normalizes a placeholder one', () => {
// The screen pre-sanitizes the cached list title before it becomes
// `fallbackTitle` (session-detail-content.tsx), so a real name passes
// through untouched. The helper normalizes a placeholder or blank fallback
// to the generic label as well, so a machine timestamp can never reach the
// header even if one is handed in.
for (const fallback of ['Session', 'Fix login bug']) {
const state = getSessionDetailRenameState({
fallbackTitle: fallback,
isLoaded: true,
serverTitle: undefined,
renameState: initialRenameState(),
});
expect(state.title).toBe(fallback);
}
for (const fallback of ['New session - 2026-09-22T17:26:31.465Z', ' ']) {
const state = getSessionDetailRenameState({
fallbackTitle: fallback,
isLoaded: true,
serverTitle: undefined,
renameState: initialRenameState(),
});
expect(state.title).toBe(i18n.t('agentChat.session.title'));
}
});

it('falls back to the fallback name when the server title is a generated placeholder', () => {
expect(
getSessionDetailRenameState({
Expand Down
11 changes: 8 additions & 3 deletions apps/mobile/src/components/agents/session-detail-rename-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,14 @@ export function namedSessionTitle(
* Both the server title and the cached fallback run through the shared title
* helpers, so the server's creation-default placeholder (`New session -
* <ISO timestamp>`) can never reach the header or seed the rename field —
* either would otherwise show a raw timestamp. A missing or placeholder
* fallback becomes the generic `Session` label. The user's `optimisticTitle`
* is their own input and is never filtered.
* either would otherwise show a raw timestamp. The server title — the fetched
* title, or a live `session.updated` title the hook folds into `serverTitle` —
* passes through `namedSessionTitle`, and a missing or placeholder fallback
* becomes the generic `Session` label here as well; the screen also routes the
* cached list title through `namedSessionTitle` before passing it here
* (`session-detail-content.tsx`), so a placeholder cached in the list cannot
* reach the header either. The user's `optimisticTitle` is their own input and
* is never filtered.
*/
export function getSessionDetailRenameState(input: {
sessionId?: string;
Expand Down
25 changes: 25 additions & 0 deletions apps/mobile/src/components/agents/session-row.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,23 @@ describe('StoredSessionRow live speech', () => {
}
);

it('shows the untitled fallback for a creation placeholder title and never speaks the ISO instant', () => {
const renderer = mount(
row({
session: {
...session,
title: 'New session - 2026-09-22T17:26:31.465Z',
git_branch: null,
total_cost_microdollars: null,
},
})
);
expect(texts(renderer)).toContain(i18n.t('agents.sessionRow.untitled'));
const button = hosts(renderer, 'Pressable')[0];
expect(button?.props.accessibilityLabel).toContain(i18n.t('agents.sessionRow.untitled'));
expect(button?.props.accessibilityLabel).not.toContain('2026-09-22');
});

const placeholderTitle = 'New session - 2026-09-21T15:44:47.176Z';

it('renders the generic untitled label for the server creation-default title', () => {
Expand Down Expand Up @@ -585,4 +602,12 @@ describe('RemoteSessionRow live speech', () => {
'Live work, Idle, feature/live, LIVE-REPO, and 5 minutes ago'
);
});

it('shows the untitled fallback for a creation placeholder title and never speaks the ISO instant', () => {
const renderer = mountRemote({ title: 'New session - 2026-09-22T17:26:31.465Z' });
expect(texts(renderer)).toContain(i18n.t('agents.sessionRow.untitled'));
const button = hosts(renderer, 'Pressable')[0];
expect(button?.props.accessibilityLabel).toContain(i18n.t('agents.sessionRow.untitled'));
expect(button?.props.accessibilityLabel).not.toContain('2026-09-22');
});
});
17 changes: 10 additions & 7 deletions apps/mobile/src/components/agents/session-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,16 @@ export function StoredSessionRow({
const { t } = useTranslation();
const themedSheet = useThemedActionSheetOptions();
const { showActionSheetWithOptions } = useActionSheet();
// The server's creation-default title (`New session - <ISO timestamp>`) is an
// internal marker, never row copy: `namedSessionTitle` resolves it through
// the shared `sessionDisplayTitle` helper and additionally keeps a
// placeholder-shaped title the user's own rename wrote, so the same label
// feeds the row, the accessibility label, and the rename prompt. The
// subscription repaints the row once the durable record hydrates after a
// cold start.
// One derivation for the visible label, the spoken label and the rename
// prompt: the server's creation-default title (`New session - <ISO
// timestamp>`) is an internal marker, never row copy, so a creation
// placeholder title reads as "Untitled session" — the row falls back to the
// localized unnamed name the same way the session header does.
// `namedSessionTitle` makes that judgement through the shared
// `sessionDisplayTitle` helper and additionally keeps a placeholder-shaped
// title the user's own rename wrote, so the same label feeds the row, the
// accessibility label, and the rename prompt. The subscription repaints the
// row once the durable record hydrates after a cold start.
useUserSessionTitlesRevision();
const title =
namedSessionTitle(session.title, session.session_id) ?? t('agents.sessionRow.untitled');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
renderLoaded,
resetHookSlots,
seedRecents,
textValues,
} from './pr-review-entry-screen-test-utils';

// The field draws the visible placeholder with the same one-line overlay on
Expand Down Expand Up @@ -186,4 +187,21 @@ describe('provider-neutral URL field', () => {
const after = render();
expect(find(after, 'Pressable', p => p.accessibilityLabel === 'Clear link')).toBeTruthy();
});

it('states the instruction once, in the header eyebrow, not the field caption', async () => {
const tree = await renderLoaded();
const header = find(tree, 'ScreenHeader', () => true);
// Finding: the field's own letter-spaced "Paste a pull request or merge
// request link" caption repeated the header instruction. The header keeps
// the single instruction as its eyebrow; the field keeps its placeholder.
expect(header.props?.title).toBe('PR Review');
expect(header.props?.eyebrow).toBe('Open a pull request or merge request by URL');
const input = find(tree, 'TextInput', () => true);
expect(input.props?.placeholder).toBe('Pull request or merge request URL');
// The paste block no longer carries a caption repeating the instruction.
const list = find(tree, 'PrReviewInboxList', () => true);
const pasteBlockTexts = textValues(propsOf(list).header);
expect(pasteBlockTexts).not.toContain('Paste a pull request or merge request link');
expect(pasteBlockTexts).not.toContain('Open a pull request or merge request by URL');
});
});
Loading
Loading