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 @@ -217,6 +217,7 @@ vi.mock('@/lib/hooks/use-session-mutations', () => ({
vi.mock('@/components/agents/session-detail-content', async () => {
const { mergeSessionTranscript } = await import('@/components/agents/session-transcript');
const { MessageBubble } = await import('@/components/agents/message-bubble');
const { displaySessionTitle } = await import('@/components/agents/session-detail-rename-state');
return {
SessionDetailContent: function SessionDetailContent(
props: Readonly<{ sessionId: KiloSessionId; cachedTitle?: string }>
Expand All @@ -236,7 +237,7 @@ vi.mock('@/components/agents/session-detail-content', async () => {
const rename = useSessionDetailRename({
sessionId,
isLoaded: isSessionLoaded,
serverTitle: isSessionLoaded ? (fetchedData.title ?? undefined) : undefined,
serverTitle: isSessionLoaded ? displaySessionTitle(fetchedData.title) : undefined,
fallbackTitle: cachedTitle ?? t('agentChat.session.title'),
});
if (realTranscriptProbe.active) {
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/app/(app)/agent-chat/[session-id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ export default function SessionDetailScreen() {
title={t('agentChat.session.title')}
reserveTitleSpace
titleNumberOfLines={SESSION_HEADER_TITLE_LINES}
backFallback="/(app)/(tabs)/(2_agents)"
backFallback={'/(app)/(tabs)/(2_agents)' as Href}
headerRight={
<View className="min-w-0 shrink flex-row items-center gap-2">
<SessionContextMetrics
Expand Down Expand Up @@ -273,7 +273,7 @@ export default function SessionDetailScreen() {
title={t('agentChat.session.title')}
reserveTitleSpace
titleNumberOfLines={SESSION_HEADER_TITLE_LINES}
backFallback="/(app)/(tabs)/(2_agents)"
backFallback={'/(app)/(tabs)/(2_agents)' as Href}
/>
<CenteredState>
<View className="items-center gap-3 px-6">
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/app/(app)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Redirect } from 'expo-router';
import { type Href, Redirect } from 'expo-router';

export default function AppIndex() {
return <Redirect href="/(app)/(tabs)/(0_home)" />;
return <Redirect href={'/(app)/(tabs)/(0_home)' as Href} />;
}
6 changes: 3 additions & 3 deletions apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -376,13 +376,13 @@ function RootLayoutNav({
if (returnTarget === 'login') {
router.replace('/(auth)/login');
} else if (returnTarget === 'profile') {
router.replace('/(app)/(tabs)/(3_profile)');
router.replace('/(app)/(tabs)/(3_profile)' as Href);
} else if (returnTarget === 'preferences') {
// Two steps, not one `replace`: the relaunched stack has no entry
// below the reopened screen, so a lone `replace` leaves the header's
// back control with nothing to pop.
router.replace('/(app)/(tabs)/(3_profile)');
router.push('/(app)/(tabs)/(3_profile)/preferences');
router.replace('/(app)/(tabs)/(3_profile)' as Href);
router.push('/(app)/(tabs)/(3_profile)/preferences' as Href);
}
try {
// The plural-rules polyfill must be in place before the first render in the new language.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ export async function exitRemoteSessionWithFeedback({
// `dismissTo` dispatches POP_TO, which finds the existing `(tabs)` route
// at the stack root and truncates the stack so a back gesture cannot
// return to the exited `agent-chat` route.
router.dismissTo(SESSIONS_ROUTE);
router.dismissTo(SESSIONS_ROUTE as Href);
};

await runExit();
Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/src/components/agents/remote-session-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ import {
import { useTRPC } from '@/lib/trpc';
import { exitRemoteSessionFromList } from './exit-remote-session-from-list';
import { showRemoteSessionExitConfirmation } from './remote-session-exit-alert';
import {
namedSessionTitle,
SESSION_TITLE_MAX_LENGTH,
useUserSessionTitlesRevision,
} from './session-detail-rename-state';
import {
activeSessionMetaTimestamp,
canExitSessionFromList,
Expand All @@ -39,7 +44,6 @@ import {
selectRemoteRowSpokenMeta,
} from './session-list-helpers';
import { selectRowPlatformPresentation, SessionPlatformIcon } from './session-platform-icon';
import { namedSessionTitle, useUserSessionTitlesRevision } from './session-detail-rename-state';
import { type RowVariant } from './session-row';
import { copySessionId, showRenamePrompt, showSessionActionMenu } from './session-row-actions';
import {
Expand Down Expand Up @@ -272,6 +276,7 @@ export function RemoteSessionRow({
title={t('agentChat.session.renameSession')}
placeholder={t('agentChat.session.renamePlaceholder')}
initialValue={renameInitialValue}
maxLength={SESSION_TITLE_MAX_LENGTH}
onClose={() => {
setRenameVisible(false);
}}
Expand Down
32 changes: 30 additions & 2 deletions apps/mobile/src/components/agents/session-detail-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
setSessionGoalCollapsed,
} from '@/components/agents/session-goal-collapse';
import { SessionDetailContent } from '@/components/agents/session-detail-content';
import { SESSION_TITLE_MAX_LENGTH } from '@/components/agents/session-detail-rename-state';
import { SessionContextSheet } from '@/components/agents/session-context-sheet';
import { SessionGoalSection } from '@/components/agents/session-goal-section';
import { SessionSkeletonMessages } from '@/components/agents/session-detail-skeleton';
Expand Down Expand Up @@ -351,6 +352,9 @@ vi.mock('@/components/agents/use-session-config-sync', () => ({
useSessionConfigSync: () => ({ currentMode: 'code', currentModel: '', currentVariant: '' }),
}));
const openRenameModal = vi.hoisted(() => vi.fn());
// Mirrors the real hook's modal fields; a test opens the dialog by flipping
// `isOpen` so it can inspect the RenameModal the screen renders.
const renameModalState = vi.hoisted(() => ({ isOpen: false, initialValue: '' }));
vi.mock('@/components/agents/use-session-detail-rename', () => ({
useSessionDetailRename: ({
serverTitle,
Expand All @@ -361,7 +365,11 @@ vi.mock('@/components/agents/use-session-detail-rename', () => ({
}) => ({
title: serverTitle ?? fallbackTitle,
isTitleInteractive: serverTitle !== undefined,
isModalOpen: renameModalState.isOpen,
modalInitialValue: renameModalState.initialValue,
openModal: openRenameModal,
closeModal: vi.fn(),
submit: vi.fn().mockResolvedValue(undefined),
}),
}));
vi.mock('@/lib/analytics/posthog', () => ({
Expand Down Expand Up @@ -630,6 +638,8 @@ function transcriptKeys(renderer: ReactTestRenderer): string[] {
beforeEach(() => {
navigationRoutes.splice(0, navigationRoutes.length, 'session-detail');
openRenameModal.mockClear();
renameModalState.isOpen = false;
renameModalState.initialValue = '';
showActionSheetWithOptions.mockClear();
hideThinking.current = false;
hideThinking.loaded = true;
Expand Down Expand Up @@ -936,11 +946,12 @@ describe('SessionDetailContent header title', () => {
// The title shares its row with a 44pt context pill and a copy action, so on
// a narrow phone the title column is a fraction of the row width. The header
// and this screen share the three-line cap (`SESSION_HEADER_TITLE_LINES`), so
// a long name wraps onto the extra line instead of being cut short mid-word;
// a long name wraps onto the extra line instead of being cut short mid-word
// the way the previous one-line clamp did ("Moving-average empty windo…");
// the tail ellipsis only applies past the cap. The placeholder header keeps
// the same cap, so the reserved title box does not move the body when the
// loaded name replaces "Session".
it('caps a long session title at the shared line count with a tail ellipsis', async () => {
it('shows a long session title across the shared reserved lines without clipping mid-word', async () => {
sessionTitleOverride = 'Moving-average rage empty baseline';
const { renderer } = await mountDetails();
const header = renderer.root.findByType(ScreenHeader);
Expand All @@ -949,6 +960,23 @@ describe('SessionDetailContent header title', () => {
expect(title.props.ellipsizeMode).toBe('tail');
});

// The rename dialog inherited RenameModal's 50-character default, below the
// 200-character cap the rename endpoint accepts. A longer title was dropped
// after character 50, and the header then rendered the leftover fragment
// ("Moving-average empty window rollup verification pa") as if it were the
// whole title.
it('lets the rename dialog hold a title as long as the server accepts', async () => {
renameModalState.isOpen = true;
renameModalState.initialValue = 'Moving-average rage empty baseline';
const { renderer } = await mountDetails([], { displayScope: PERSONAL_DISPLAY_SCOPE });
const modal = renderer.root.findAllByType('RenameModal')[0];
expect(modal?.props).toMatchObject({
maxLength: SESSION_TITLE_MAX_LENGTH,
initialValue: renameModalState.initialValue,
});
expect(SESSION_TITLE_MAX_LENGTH).toBe(200);
});

it('shows the localized unnamed name instead of the backend placeholder cached title', async () => {
// The route passes its cached metadata title as `cachedTitle`, and that
// cache can hold the backend's ISO placeholder. It must not become the
Expand Down
23 changes: 20 additions & 3 deletions apps/mobile/src/components/agents/session-detail-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,10 @@ import {
type TranscriptItemKeysByPart,
} from '@/components/agents/session-transcript';
import { resolveSessionTranscriptView } from '@/components/agents/session-transcript-view';
import { namedSessionTitle } from '@/components/agents/session-detail-rename-state';
import {
namedSessionTitle,
SESSION_TITLE_MAX_LENGTH,
} from '@/components/agents/session-detail-rename-state';
import { useSessionDetailRename } from '@/components/agents/use-session-detail-rename';
import { WorkingIndicator } from '@/components/agents/working-indicator';
import { getChildSessionStreaming } from '@/components/agents/child-session-card-state';
Expand Down Expand Up @@ -1609,7 +1612,12 @@ export function SessionDetailContent({
});

const isSessionLoaded = fetchedData?.kiloSessionId === sessionId;
const serverTitle = isSessionLoaded ? (fetchedData.title ?? undefined) : undefined;
// A `New session - <ISO>` placeholder is not a title: showing it truncates
// the header to "New session - 2026-…". Fall through to the short fallback
// until auto-titling (or a rename) supplies a real one. `namedSessionTitle`
// applies the shared placeholder rule and still returns a placeholder-shaped
// title the user's own rename wrote, so the header keeps their name.
const serverTitle = isSessionLoaded ? namedSessionTitle(fetchedData.title, sessionId) : undefined;
const rename = useSessionDetailRename({
sessionId,
isLoaded: isSessionLoaded,
Expand Down Expand Up @@ -2008,9 +2016,14 @@ export function SessionDetailContent({
) : null}
<ScreenHeader
title={rename.title}
// The loaded header, the route's loading header and its error state
// all share one cap (`SESSION_HEADER_TITLE_LINES`), and
// `reserveTitleSpace` holds exactly that many lines, so a long title
// wraps at a word boundary instead of being cut to one tail-ellipsized
// line ("Moving-average empty windo…").
reserveTitleSpace
titleNumberOfLines={SESSION_HEADER_TITLE_LINES}
backFallback="/(app)/(tabs)/(2_agents)"
backFallback={'/(app)/(tabs)/(2_agents)' as Href}
headerRight={headerRight}
className="pb-1"
{...(rename.isTitleInteractive
Expand Down Expand Up @@ -2152,6 +2165,10 @@ export function SessionDetailContent({
title={t('agentChat.session.renameSession')}
placeholder={t('agentChat.session.renamePlaceholder')}
initialValue={rename.modalInitialValue}
// The server accepts a title this long; the modal's 50-character
// default would cut a longer title mid-word and the header would
// then show the fragment.
maxLength={SESSION_TITLE_MAX_LENGTH}
onSave={handleRenameSave}
onClose={handleRenameClose}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ vi.mock('@/lib/external-link', () => ({ openExternalUrl: vi.fn() }));
vi.mock('@/lib/session-handoff', () => ({ SessionHandoffAdvertiser: () => null }));
vi.mock('@kilocode/cloud-agent-sdk', () => ({
createSessionManager: vi.fn(),
// The header normalizes the auto-title placeholder through this contract;
// this suite's fixture titles are all real, so nothing is a placeholder.
isDefaultSessionTitle: () => false,
}));
vi.mock('@kilocode/cloud-agent-sdk/preparation-attempts', () => ({
isNoOpCompletedPreparationAttempt: () => false,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
/* eslint-disable max-lines -- the header title-state suite and the v2 session.updated title suite share one module. */
import { describe, expect, it } from 'vitest';

import {
displaySessionTitle,
getSessionDetailRenameState,
initialRenameState,
type RenameState,
renameStateReducer,
titleFromSessionUpdatedEvent,
} from './session-detail-rename-state';

describe('getSessionDetailRenameState', () => {
Expand Down Expand Up @@ -216,6 +219,42 @@ describe('getSessionDetailRenameState', () => {
}).modalInitialValue
).toBe('Pending');
});

it('falls back to the untitled copy for a loaded session whose server title normalized away', () => {
// A `New session - <ISO>` placeholder is normalized to undefined by
// displaySessionTitle at the data boundary; the header then shows the
// short fallback in full instead of an ellipsized timestamp.
expect(
getSessionDetailRenameState({
fallbackTitle,
isLoaded: true,
serverTitle: undefined,
renameState: initialRenameState(),
})
).toEqual({
title: fallbackTitle,
isTitleInteractive: true,
modalInitialValue: null,
isModalOpen: false,
});
});
});

describe('displaySessionTitle', () => {
it('hides the auto-title placeholder so the header shows its fallback', () => {
expect(displaySessionTitle('New session - 2026-01-01T00:00:00.000Z')).toBeUndefined();
expect(displaySessionTitle('Child session - 2026-01-01T00:00:00.000Z')).toBeUndefined();
});

it('keeps a real title untouched', () => {
expect(displaySessionTitle('Mobile layout refinement')).toBe('Mobile layout refinement');
});

it('treats null, undefined and blank titles as untitled', () => {
expect(displaySessionTitle(null)).toBeUndefined();
expect(displaySessionTitle(undefined)).toBeUndefined();
expect(displaySessionTitle(' ')).toBeUndefined();
});
});

describe('renameStateReducer', () => {
Expand Down Expand Up @@ -306,3 +345,54 @@ describe('renameStateReducer', () => {
expect(changed.optimisticTitle).toBeNull();
});
});

function sessionUpdatedPayload(
over: { sessionId?: string; title?: string | null; source?: string } = {}
) {
return {
source: over.source ?? 'v2',
session: {
sessionId: over.sessionId ?? 'ses-1',
title: over.title === undefined ? 'Auto Title' : over.title,
},
};
}

describe('titleFromSessionUpdatedEvent', () => {
it('returns the title for this session', () => {
expect(titleFromSessionUpdatedEvent('ses-1', sessionUpdatedPayload())).toBe('Auto Title');
});

it('ignores another session', () => {
expect(
titleFromSessionUpdatedEvent('ses-1', sessionUpdatedPayload({ sessionId: 'ses-2' }))
).toBeUndefined();
});

it('ignores a blank or null title', () => {
expect(
titleFromSessionUpdatedEvent('ses-1', sessionUpdatedPayload({ title: null }))
).toBeUndefined();
expect(
titleFromSessionUpdatedEvent('ses-1', sessionUpdatedPayload({ title: ' ' }))
).toBeUndefined();
});

it('ignores a placeholder title echoed for this session', () => {
expect(
titleFromSessionUpdatedEvent(
'ses-1',
sessionUpdatedPayload({ title: 'New session - 2026-01-01T00:00:00.000Z' })
)
).toBeUndefined();
});

it('ignores the backend placeholder so a live event cannot repaint the machine string', () => {
expect(
titleFromSessionUpdatedEvent(
'ses-1',
sessionUpdatedPayload({ title: 'New session - 2026-09-22T01:09:45.623Z' })
)
).toBeUndefined();
});
});
26 changes: 26 additions & 0 deletions apps/mobile/src/components/agents/session-detail-rename-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,32 @@ type SessionDetailRenameState = {
isModalOpen: boolean;
};

/**
* The longest session title the rename endpoint accepts, in characters. It
* mirrors the server's `RenameSessionInputSchema` (`title: z.string().max(200)`
* in apps/web/src/routers/cli-sessions-v2-router.ts). Every rename surface must
* allow this many characters: a lower field cap silently drops the rest of the
* title, and the header then renders a mid-word fragment such as
* "…verification pa" instead of the title the user typed.
*/
export const SESSION_TITLE_MAX_LENGTH = 200;

/**
* The title as it may be shown on screen, or undefined when the session has
* no real title yet. A freshly created session carries a
* `New session - <ISO timestamp>` placeholder until auto-titling replaces it;
* the placeholder is an internal marker (the web router normalizes it to
* null the same way), and rendering it in the header truncates to
* "New session - 2026-…" instead of showing the title in full. Treat it as
* untitled so the header keeps its short fallback copy.
*
* Shared with every other title surface through `sessionDisplayTitle`
* (`@/lib/session-display-title`), so the placeholder rule has one owner.
*/
export function displaySessionTitle(title: string | null | undefined): string | undefined {
return sessionDisplayTitle(title);
}

/**
* Titles the user set through the app's rename flow, keyed by session id.
*
Expand Down
Loading
Loading