Skip to content
Open
118 changes: 105 additions & 13 deletions apps/mobile/src/components/agents/session-detail-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2024,6 +2024,91 @@ describe('session detail slow load', () => {
}
});

// The explorer finding this suite guards: the skeleton and a "Connection
// lost" line painted in the same frame, with the last bubble sliced by the
// row. A lost transport now owns the body, so the skeleton is gone and the
// failure is stated once, with the Retry that resolves it.
it('replaces the skeleton with one retryable error when the agent transport is lost', async () => {
const view = await mountDetails(null);
expect(view.renderer.root.findAllByType(SessionSkeletonMessages)).toHaveLength(1);

act(() => {
view.store.set<SessionStatusIndicator | null, [SessionStatusIndicator | null], unknown>(
view.manager.atoms.statusIndicator,
{
type: 'error',
message: 'Agent connection lost',
code: 'agent-connection-lost',
timestamp: 0,
}
);
});

expect(view.renderer.root.findAllByType(SessionSkeletonMessages)).toHaveLength(0);
const error = view.renderer.root.findByType(QueryError).props as ComponentProps<
typeof QueryError
>;
expect(error.message).toBe(i18n.t('agentChat.session.connectionTrouble'));
expect(error.onRetry).toBeDefined();
// The failure is stated once: no footer status line and no send reason
// naming the same thing beside the card.
expect(indicatorNodes(view)).toHaveLength(0);
expect(composerProps(view).sendDisabledReason).toBeNull();
});

it('resolves a lost transport whose indicator carries no error type', async () => {
// The SDK's own code names the failure; the indicator's type must not decide
// whether the skeleton keeps painting beside the connection-lost line.
const view = await mountDetails(null);
act(() => {
view.store.set<SessionStatusIndicator | null, [SessionStatusIndicator | null], unknown>(
view.manager.atoms.statusIndicator,
{
type: 'progress',
message: 'Agent connection lost',
code: 'agent-connection-lost',
timestamp: 0,
}
);
});

expect(view.renderer.root.findAllByType(SessionSkeletonMessages)).toHaveLength(0);
expect(indicatorNodes(view)).toHaveLength(0);
expect(composerProps(view).sendDisabledReason).toBeNull();
const error = view.renderer.root.findByType(QueryError).props as ComponentProps<
typeof QueryError
>;
expect(error.title).toBe(i18n.t('agentChat.session.couldNotLoadThisSession'));
});

it('resolves an exhausted app-wide leg into the same retryable error', async () => {
connectionHealth.isConnected = false;
connectionHealth.reconnectExhausted = true;
const view = await mountDetails(null);
act(() => {
view.store.set<
'cloud-agent' | 'read-only' | 'remote' | null,
['cloud-agent' | 'read-only' | 'remote' | null],
unknown
>(view.manager.atoms.activeSessionType, 'remote');
});

expect(view.renderer.root.findAllByType(SessionSkeletonMessages)).toHaveLength(0);
const error = view.renderer.root.findByType(QueryError).props as ComponentProps<
typeof QueryError
>;
expect(error.message).toBe(i18n.t('agentChat.session.connectionTrouble'));
expect(error.onRetry).toBeDefined();
// A spent reconnect budget is only cleared by resetting the shared
// transport leg; reopening over the same dead socket would return here.
const switchSession = vi.spyOn(view.manager, 'switchSession');
act(() => {
(error.onRetry as () => void)();
});
expect(connectionHealth.retryConnection).toHaveBeenCalledTimes(1);
expect(switchSession).not.toHaveBeenCalled();
});

it('keeps a terminal error ahead of the slow state', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
try {
Expand Down Expand Up @@ -3489,10 +3574,10 @@ describe('session detail composer after a failed turn', () => {
});

describe('session detail composer cannot-send reason', () => {
it('states the load-failure reason beside send in the load-error state', async () => {
// The audit's state (owner evidence A4/A12): the open fails, the transcript
// is empty and the manager cannot send, but the input stays editable. The
// reason beside send must name the load failure, not a runtime class.
it('leaves the load-failure statement to the error card in the body', async () => {
// The open fails, the transcript is empty and the manager cannot send. The
// body states the failure once with the Retry that resolves it, so the
// reason beside send does not name the same failure a second time.
const view = await mountDetails([]);
act(() => {
view.store.set<boolean, [boolean], unknown>(view.manager.atoms.canSend, false);
Expand All @@ -3509,8 +3594,10 @@ describe('session detail composer cannot-send reason', () => {

const props = composerProps(view);
expect(props.sendDisabled).toBe(true);
expect(props.sendDisabledReason).toBe(i18n.t('agentChat.composer.sessionLoadFailed'));
expect(props.sendDisabledReason).not.toBe(i18n.t('agentChat.session.connectionTrouble'));
expect(props.sendDisabledReason).toBeNull();
expect(view.renderer.root.findByType(QueryError).props.message).toBe(
i18n.t('agentChat.session.connectionTrouble')
);
});

it('states the class reason for a running session that cannot send', async () => {
Expand Down Expand Up @@ -4116,7 +4203,7 @@ describe('SessionDetailContent fixed indicator row', () => {
}
);

it('renders no progress item for an empty transcript, keeping only the send reason', async () => {
it('renders no footer item while the error card owns the empty transcript', async () => {
const view = await mountDetails([]);
act(() => {
view.store.set<boolean, [boolean], unknown>(view.manager.atoms.canSend, false);
Expand All @@ -4130,13 +4217,18 @@ describe('SessionDetailContent fixed indicator row', () => {
);
view.store.set<string | null, [string | null], unknown>(view.manager.atoms.error, null);
});
// The empty/connecting body states progress itself, so the row drops it and
// keeps the one line only it can state: why send is unavailable. The
// has-messages gate must not take the load-failure line with it.
// One failure, one statement: the body states it with the Retry that
// resolves it, so the row states neither the progress nor the send reason.
expect(view.renderer.root.findAllByType(WorkingIndicator)).toHaveLength(0);
const items = footerRowItems(view);
expect(items).toHaveLength(1);
expect(items[0]?.props.message).toBe(i18n.t('agentChat.composer.sessionLoadFailed'));
expect(indicatorNodes(view)).toHaveLength(0);
expect(
view.renderer.root.findAll(
node =>
Object.is(node.type, 'AnimatedView') &&
String(node.props.className ?? '') === 'bg-background'
)
).toHaveLength(0);
expect(composerProps(view).sendDisabledReason).toBeNull();
});

it('states the cannot-send reason in the row with the row item typography', async () => {
Expand Down
73 changes: 52 additions & 21 deletions apps/mobile/src/components/agents/session-detail-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1769,6 +1769,24 @@ export function SessionDetailContent({
isStreaming,
pendingMessageCount: inFlightMessageCount,
});
// One failure owns the screen. The body's terminal error is resolved here,
// before the footer rows, so the footer never restates a failure the body
// already states and the loading skeleton never paints beside a lost
// connection. `connectionLost` is the app-wide leg's answer for a session
// whose own status atom carries nothing yet, and it only counts while the
// open still has nothing to show: a settled empty session keeps its empty
// state instead of a Retry it cannot use.
const connectionLost = connectionState === 'exhausted' && shouldShowLoading;
const terminalError = useMemo(
() =>
resolveSessionTerminalError({
error,
statusIndicator,
messageCount: messages.length,
connectionLost,
}),
[error, statusIndicator, messages.length, connectionLost]
);
// A failed last row states its own failure and carries the action. The fixed
// footer's error line must not state the same failure a second time (explorer
// finding: the same failure stated three times), so a status error the row
Expand Down Expand Up @@ -1798,6 +1816,7 @@ export function SessionDetailContent({
]
);
const footerStatusIndicator =
terminalError === null &&
statusIndicator !== null &&
!statusIndicatorDuplicatesMessageFailure({
indicator: statusIndicator,
Expand Down Expand Up @@ -1831,24 +1850,34 @@ export function SessionDetailContent({
// the reason must not restate that sentence beside it. Dropping it here keeps
// the reason's lower branches alive — a read-only session then names read-only
// instead of repeating the failed row.
const sendDisabledReason = resolveComposerSendDisabledReason({
canSend,
isReadOnly,
error,
statusIndicator: footerStatusIndicator,
cloudStatus,
messageCount: messages.length,
});
// The body already states this failure once, with the action that resolves
// it, so the reason beside send stays silent instead of naming the same
// failure a second time (mobile-app gate finding: the load-error card and
// "The session could not be loaded. Retry first." on one screen).
const sendDisabledReason =
terminalError === null
? resolveComposerSendDisabledReason({
canSend,
isReadOnly,
error,
statusIndicator: footerStatusIndicator,
cloudStatus,
messageCount: messages.length,
})
: null;
// The tone rides the same inputs, so a progress phase ("Setting up
// environment…") never renders in the destructive error color.
const sendDisabledReasonTone = resolveComposerSendDisabledReasonTone({
canSend,
isReadOnly,
error,
statusIndicator: footerStatusIndicator,
cloudStatus,
messageCount: messages.length,
});
const sendDisabledReasonTone =
terminalError === null
? resolveComposerSendDisabledReasonTone({
canSend,
isReadOnly,
error,
statusIndicator: footerStatusIndicator,
cloudStatus,
messageCount: messages.length,
})
: null;
// A read-only transcript ends in the continue section, which states the
// read-only fact once and replaces the composer and its send reason.
const isReadOnlyTranscript = isReadOnly && messages.length > 0;
Expand Down Expand Up @@ -2699,11 +2728,6 @@ export function SessionDetailContent({
}

function renderContent() {
const terminalError = resolveSessionTerminalError({
error,
statusIndicator,
messageCount: messages.length,
});
if (terminalError) {
const copyText = buildTerminalErrorCopyText({
sessionId,
Expand All @@ -2723,6 +2747,13 @@ export function SessionDetailContent({
onRetry={
terminalError.retryable
? () => {
// A spent reconnect budget on the shared transport leg is
// only cleared by resetting that leg; reopening the
// session over the same dead socket would return here.
if (terminalError.retryWith === 'connection') {
retrySessionConnection();
return;
}
void manager.switchSession(sessionId);
}
: undefined
Expand Down
23 changes: 13 additions & 10 deletions apps/mobile/src/components/agents/session-detail-skeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,20 +33,23 @@ function shapeFor(sessionId: string) {
* `justify-end` matters: the real list is a FlashList with
* `startRenderingFromBottom`, so a top-anchored placeholder would drop the
* whole transcript from the top of the screen to the bottom on first paint.
*
* The bubbles are the list's flex items, not the rows of a nested column: each
* one carries its own margin and alignment, so `shrink` acts on a definite
* height. A body shorter than the three bubbles then compresses them, and
* `overflow-hidden` keeps whatever is left inside the body instead of painting
* over the row below it — a placeholder is never sliced by the status line.
*/
export function SessionSkeletonMessages({ sessionId }: Readonly<{ sessionId?: string }>) {
const [first, second, third] = shapeFor(sessionId ?? '');
return (
<Animated.View exiting={FadeOut.duration(150)} className="flex-1 justify-end pb-2">
<View className="items-start px-4 py-1">
<Skeleton className={`w-3/4 rounded-2xl rounded-tl-sm ${first}`} />
</View>
<View className="items-end px-4 py-1">
<Skeleton className={`w-1/2 rounded-2xl rounded-tr-sm ${second}`} />
</View>
<View className="items-start px-4 py-1">
<Skeleton className={`w-2/3 rounded-2xl rounded-tl-sm ${third}`} />
</View>
<Animated.View
exiting={FadeOut.duration(150)}
className="flex-1 justify-end overflow-hidden px-4 pb-2"
>
<Skeleton className={`my-1 w-3/4 shrink self-start rounded-2xl rounded-tl-sm ${first}`} />
<Skeleton className={`my-1 w-1/2 shrink self-end rounded-2xl rounded-tr-sm ${second}`} />
<Skeleton className={`my-1 w-2/3 shrink self-start rounded-2xl rounded-tl-sm ${third}`} />
</Animated.View>
);
}
Expand Down
Loading
Loading