Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
126 changes: 113 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,99 @@ 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();
// The session's own transport dropped, so its Retry reopens the session;
// the shared leg is not the cause here.
const switchSession = vi.spyOn(view.manager, 'switchSession').mockResolvedValue(undefined);
act(() => {
(error.onRetry as () => void)();
});
expect(switchSession).toHaveBeenCalledWith(ROOT_ID);
expect(connectionHealth.retryConnection).not.toHaveBeenCalled();
});

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 +3582,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 +3602,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 +4211,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 +4225,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