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
35 changes: 32 additions & 3 deletions apps/mobile/src/components/agents/session-detail-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,12 @@ import {
import { useInteractionHandlers } from '@/components/agents/use-interaction-handlers';
import { useSessionAutoScroll } from '@/components/agents/use-session-auto-scroll';
import { useSessionConfigSync } from '@/components/agents/use-session-config-sync';
import { useSessionDetailRename } from '@/components/agents/use-session-detail-rename';
import { WorkingIndicator } from '@/components/agents/working-indicator';
import { ChildSessionSheet } from '@/components/agents/child-session-sheet';
import { PartRenderer } from '@/components/agents/part-renderer';
import { QueryError } from '@/components/query-error';
import { RenameModal } from '@/components/rename-modal';
import { ScreenHeader } from '@/components/screen-header';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
Expand Down Expand Up @@ -452,8 +454,16 @@ export function SessionDetailContent({

const emptyStateText = statusIndicator ? null : 'No messages yet';

const title =
fetchedData?.kiloSessionId === sessionId ? (fetchedData.title ?? 'Session') : 'Session';
const isSessionLoaded = fetchedData?.kiloSessionId === sessionId;
const serverTitle = isSessionLoaded ? (fetchedData.title ?? undefined) : undefined;
const rename = useSessionDetailRename({
sessionId,
isLoaded: isSessionLoaded,
serverTitle,
fallbackTitle: 'Session',
});
const handleRenameSave = rename.submit;
const handleRenameClose = rename.closeModal;
const requiresModel = Boolean(fetchedData?.cloudAgentSessionId);
const blockingInteraction = getBlockingInteraction({ activeQuestion, activePermission });
const hasBlockingInteraction = blockingInteraction !== 'none';
Expand Down Expand Up @@ -507,7 +517,16 @@ export function SessionDetailContent({

return (
<View className="flex-1 bg-background">
<ScreenHeader title={title} headerRight={headerRight} />
<ScreenHeader
title={rename.title}
headerRight={headerRight}
{...(rename.isTitleInteractive
? {
onTitlePress: rename.openModal,
onTitlePressAccessibilityLabel: `Rename session: ${rename.title}`,
}
: {})}
/>

{!isConnected && <ConnectivityBanner />}

Expand Down Expand Up @@ -552,6 +571,16 @@ export function SessionDetailContent({
}}
/>
) : null}

{rename.isTitleInteractive && rename.isModalOpen ? (
<RenameModal
title="Rename session"
placeholder="Session name"
initialValue={rename.modalInitialValue}
onSave={handleRenameSave}
onClose={handleRenameClose}
/>
) : null}
</View>
);

Expand Down
200 changes: 200 additions & 0 deletions apps/mobile/src/components/agents/session-detail-rename-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { describe, expect, it } from 'vitest';

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

describe('getSessionDetailRenameState', () => {
const fallbackTitle = 'Session';

it('returns a non-interactive title while the session record is still loading', () => {
expect(
getSessionDetailRenameState({
fallbackTitle,
isLoaded: false,
serverTitle: undefined,
renameState: initialRenameState(),
})
).toEqual({
title: fallbackTitle,
isTitleInteractive: false,
modalInitialValue: null,
isModalOpen: false,
});
});

it('exposes the title as interactive once the current session record is loaded', () => {
expect(
getSessionDetailRenameState({
fallbackTitle,
isLoaded: true,
serverTitle: 'Original',
renameState: initialRenameState(),
})
).toEqual({
title: 'Original',
isTitleInteractive: true,
modalInitialValue: null,
isModalOpen: false,
});
});

it('hides interactivity when fetched data belongs to a different session', () => {
expect(
getSessionDetailRenameState({
fallbackTitle,
isLoaded: false,
serverTitle: undefined,
renameState: initialRenameState(),
})
).toEqual({
title: fallbackTitle,
isTitleInteractive: false,
modalInitialValue: null,
isModalOpen: false,
});
});

it('shows the optimistic override in the header when one is pending', () => {
expect(
getSessionDetailRenameState({
fallbackTitle,
isLoaded: true,
serverTitle: 'Original',
renameState: { ...initialRenameState(), optimisticTitle: 'Pending' },
})
).toEqual({
title: 'Pending',
isTitleInteractive: true,
modalInitialValue: null,
isModalOpen: false,
});
});

it('seeds the modal with the current title only while it is open', () => {
const open = getSessionDetailRenameState({
fallbackTitle,
isLoaded: true,
serverTitle: 'Original',
renameState: { ...initialRenameState(), isModalOpen: true },
});
expect(open.modalInitialValue).toBe('Original');
expect(open.isTitleInteractive).toBe(true);
expect(open.isModalOpen).toBe(true);

const closed = getSessionDetailRenameState({
fallbackTitle,
isLoaded: true,
serverTitle: 'Original',
renameState: initialRenameState(),
});
expect(closed.modalInitialValue).toBeNull();
expect(closed.isModalOpen).toBe(false);
});

it('seeds the modal with the optimistic override when one is pending', () => {
// The modal should always start from whatever the header is currently
// showing so re-opening after a prior optimistic update still presents
// the live value.
expect(
getSessionDetailRenameState({
fallbackTitle,
isLoaded: true,
serverTitle: 'Original',
renameState: { ...initialRenameState(), isModalOpen: true, optimisticTitle: 'Pending' },
}).modalInitialValue
).toBe('Pending');
});
});

describe('renameStateReducer', () => {
it('opens and closes the modal', () => {
const opened = renameStateReducer(initialRenameState(), { type: 'openModal' });
expect(opened.isModalOpen).toBe(true);

const closed = renameStateReducer(opened, { type: 'closeModal' });
expect(closed.isModalOpen).toBe(false);
expect(closed).toEqual(initialRenameState());
});

it('sets the optimistic title while keeping the modal open on submit', () => {
const modalOpen = renameStateReducer(initialRenameState(), { type: 'openModal' });
const next = renameStateReducer(modalOpen, {
type: 'submit',
nextTitle: 'Renamed',
});
// RenameModal awaits onSave and only calls onClose after success. The
// reducer must keep the modal mounted so a rejection can display the
// inline error and allow retry.
expect(next.isModalOpen).toBe(true);
expect(next.optimisticTitle).toBe('Renamed');
});

it('restores the previously displayed title on submit failure while keeping the modal open', () => {
const modalOpen = renameStateReducer(initialRenameState(), { type: 'openModal' });
const submitted = renameStateReducer(modalOpen, {
type: 'submit',
nextTitle: 'Renamed',
});
const failed = renameStateReducer(submitted, {
type: 'submitFailure',
previousTitle: 'Original',
});
expect(failed.optimisticTitle).toBe('Original');
expect(failed.isModalOpen).toBe(true);
});

it('restores the previously displayed title on a second failure after a successful rename', () => {
// The authoritative server title stays A because the detail manager's
// fetchedData is not refreshed by list-query invalidation. After the
// first rename A→B succeeds, the displayed title is optimistic B. A
// second rename B→C must fail back to B, not to stale A.
const getDisplayedTitle = (state: RenameState) =>
getSessionDetailRenameState({
fallbackTitle: 'Session',
isLoaded: true,
serverTitle: 'A',
renameState: state,
}).title;

let state = initialRenameState();
state = renameStateReducer(state, { type: 'openModal' });
state = renameStateReducer(state, { type: 'submit', nextTitle: 'B' });
expect(getDisplayedTitle(state)).toBe('B');

state = renameStateReducer(state, { type: 'openModal' });
const displayedBeforeSecondSubmit = getDisplayedTitle(state);
expect(displayedBeforeSecondSubmit).toBe('B');

state = renameStateReducer(state, { type: 'submit', nextTitle: 'C' });
state = renameStateReducer(state, {
type: 'submitFailure',
previousTitle: displayedBeforeSecondSubmit,
});
expect(getDisplayedTitle(state)).toBe('B');
});

it('clears the optimistic override when the authoritative server title changes', () => {
const submitted = renameStateReducer(initialRenameState(), {
type: 'submit',
nextTitle: 'Renamed',
});
const updated = renameStateReducer(submitted, { type: 'serverTitleChanged' });
expect(updated.optimisticTitle).toBeNull();
expect(updated.isModalOpen).toBe(false);
});

it('closes the modal and clears the optimistic override when the session changes', () => {
const modalOpen = renameStateReducer(initialRenameState(), { type: 'openModal' });
const submitted = renameStateReducer(modalOpen, {
type: 'submit',
nextTitle: 'Renamed',
});
const changed = renameStateReducer(submitted, { type: 'sessionChanged' });
expect(changed.isModalOpen).toBe(false);
expect(changed.optimisticTitle).toBeNull();
});
});
74 changes: 74 additions & 0 deletions apps/mobile/src/components/agents/session-detail-rename-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
export type RenameState = {
isModalOpen: boolean;
optimisticTitle: string | null;
};

type RenameEvent =
| { type: 'openModal' }
| { type: 'closeModal' }
| { type: 'submit'; nextTitle: string }
| { type: 'submitFailure'; previousTitle: string }
| { type: 'serverTitleChanged' }
| { type: 'sessionChanged' };

export function initialRenameState(): RenameState {
return { isModalOpen: false, optimisticTitle: null };
}

/**
* Pure reducer that owns the rename modal and optimistic header title
* transitions. It is extracted so the full lifecycle stays unit-testable
* without rendering React Native.
*/
export function renameStateReducer(state: RenameState, event: RenameEvent): RenameState {
switch (event.type) {
case 'openModal': {
return { ...state, isModalOpen: true };
}
case 'closeModal': {
return { ...state, isModalOpen: false };
}
case 'submit': {
return { ...state, optimisticTitle: event.nextTitle };
}
case 'submitFailure': {
return { ...state, optimisticTitle: event.previousTitle };
}
case 'serverTitleChanged':
case 'sessionChanged': {
return { ...state, isModalOpen: false, optimisticTitle: null };
}
default: {
return state;
}
}
}

type SessionDetailRenameState = {
title: string;
isTitleInteractive: boolean;
modalInitialValue: string | null;
isModalOpen: boolean;
};

/**
* Pure helper that derives the session-detail header display state from the
* authoritative server title and the reducer state.
*/
export function getSessionDetailRenameState(input: {
fallbackTitle: string;
isLoaded: boolean;
serverTitle: string | undefined;
renameState: RenameState;
}): SessionDetailRenameState {
const baseTitle = input.isLoaded
? (input.serverTitle ?? input.fallbackTitle)
: input.fallbackTitle;
const title = input.renameState.optimisticTitle ?? baseTitle;
return {
title,
isTitleInteractive: input.isLoaded,
modalInitialValue: input.renameState.isModalOpen ? title : null,
isModalOpen: input.renameState.isModalOpen,
};
}
Loading