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
5 changes: 4 additions & 1 deletion apps/mobile/src/app/(app)/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,10 @@ export default function TabsLayout() {
const needsInputRows = activeSessions.map(session => ({
id: session.id,
status: session.status,
isAcked: isAttentionAcked(session.id, session.status),
isAcked: isAttentionAcked(session.id, {
raiseId: session.statusUpdatedAt ?? session.status,
status: session.status,
}),
}));
const needsInputCount = needsInputRows.filter(session =>
shouldShowNeedsInput({
Expand Down
72 changes: 58 additions & 14 deletions apps/mobile/src/components/agents/agents-tab-badge.mounted.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- one cohesive mounted suite: the tab badge, the live header, and the ack store share the mock harness */
import { createElement } from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import { act, type ReactTestInstance } from '@/test/renderer';
Expand Down Expand Up @@ -27,6 +28,27 @@ import { renderWithProviders, waitFor } from '@/test/render-with-providers';
type Mount = Awaited<ReturnType<typeof renderWithProviders>>;
const mounts: Mount[] = [];

/** The tray rows a dated raise renders: one waiting permission, one working. */
function datedRaise(statusUpdatedAt: string): ActiveSession[] {
return [
{
id: 'personal-0',
connectionId: 'cli',
title: 'Session 0',
status: 'permission',
organizationId: null,
statusUpdatedAt,
},
{
id: 'personal-1',
connectionId: 'cli',
title: 'Session 1',
status: 'busy',
organizationId: null,
},
];
}

async function mount(queryClient = makeTestQueryClient()) {
const result = await renderWithProviders(createElement(CountSurfaces), { queryClient });
mounts.push(result);
Expand Down Expand Up @@ -136,12 +158,12 @@ describe('Agents needs-input badge and shared live count', () => {
const cached = result.queryClient.getQueryData(key());
const fetchCount = fetchSessions.mock.calls.length;
act(() => {
ackSessionAttention('personal-0');
ackSessionAttention('personal-0', status);
});
expectCounts(result.renderer, 1, 3);
expect(__peekSessionAttentionForTests('personal-0')).toEqual({ raiseId: status });
act(() => {
ackSessionAttention('personal-1');
ackSessionAttention('personal-1', status);
});
expectCounts(result.renderer, undefined, 3);
expect(result.queryClient.getQueryData(key())).toBe(cached);
Expand All @@ -155,24 +177,27 @@ describe('Agents needs-input badge and shared live count', () => {
const result = await mount();
await updateSessions(result, sessions(['question']));
act(() => {
ackSessionAttention('personal-0');
ackSessionAttention('personal-0', 'question');
});
expectCounts(result.renderer, undefined, 1);
await updateSessions(result, sessions([status]));
expect(isAttentionAcked('personal-0', 'question')).toBe(false);
expect(isAttentionAcked('personal-0', { raiseId: 'question' })).toBe(false);
expectCounts(result.renderer, status === 'permission' ? 1 : undefined, 1);
await updateSessions(result, sessions(['question']));
expectCounts(result.renderer, 1, 1);
}
);

it.each([
{ status: 'question', raiseId: 'question' },
{ status: 'question', raiseId: null },
{ status: 'busy', raiseId: 'question' },
{ status: 'question', raiseId: 'question', suppresses: true },
// A blob from a build that stored unbound acks names no raise, so it is
// dropped on hydration and suppresses nothing.
{ status: 'question', raiseId: null, suppresses: false },
// The raise it names is gone: the busy row deletes the entry.
{ status: 'busy', raiseId: 'question', suppresses: false },
] as const)(
'reconciles hydrated $raiseId acknowledgments against $status',
async ({ status, raiseId }) => {
async ({ status, raiseId, suppresses }) => {
const pending = Promise.withResolvers<string | null>();
attentionKv.getItem.mockReturnValueOnce(pending.promise);
const hydration = __hydrateSessionAttentionForTests();
Expand All @@ -193,15 +218,34 @@ describe('Agents needs-input badge and shared live count', () => {
);
await hydration;
});
expectCounts(result.renderer, 1, 2);
// The row the dropped ack named is answered only when the ack survived.
const answeredCount = suppresses || status !== 'question' ? 1 : 2;
expectCounts(result.renderer, answeredCount, 2);
expect(__peekSessionAttentionForTests('personal-0')).toEqual(
status === 'question' ? { raiseId: 'question' } : undefined
suppresses ? { raiseId: 'question' } : undefined
);
await updateSessions(result, sessions(['question', 'permission']));
expectCounts(result.renderer, status === 'question' ? 1 : 2, 2);
expectCounts(result.renderer, suppresses ? 1 : 2, 2);
}
);

it('counts a same-status re-raise after an ack once the row names its raise', async () => {
const result = await mount();

await updateSessions(result, datedRaise('2026-09-18T21:00:00.000Z'));
expectCounts(result.renderer, 1, 2);

// The answer path acks the raise its row names.
act(() => {
ackSessionAttention('personal-0', 'permission', '2026-09-18T21:00:00.000Z');
});
expectCounts(result.renderer, undefined, 2);

// A permission raised later reads as its own raise, not the answered one.
await updateSessions(result, datedRaise('2026-09-18T21:30:00.000Z'));
expectCounts(result.renderer, 1, 2);
});

it('hides counts while the initial live query loads', async () => {
const { renderer, queryClient } = await mount();
expect(queryClient.getQueryState(key())?.fetchStatus).toBe('fetching');
Expand Down Expand Up @@ -275,7 +319,7 @@ describe('Agents needs-input badge and shared live count', () => {
const result = await mount(queryClient);
expectCounts(result.renderer, 2, 3);
act(() => {
ackSessionAttention('org-a-0');
ackSessionAttention('org-a-0', 'question');
});
expectCounts(result.renderer, 1, 3);
const pending = Promise.withResolvers<{ sessions: ActiveSession[] }>();
Expand All @@ -293,7 +337,7 @@ describe('Agents needs-input badge and shared live count', () => {
queryClient.setQueryData(key('org-a'), { sessions: sessions(['busy'], 'org-a') });
});
expectCounts(result.renderer);
expect(isAttentionAcked('org-a-0', 'question')).toBe(true);
expect(isAttentionAcked('org-a-0', { raiseId: 'question' })).toBe(true);
fetchSessions.mockResolvedValue({
sessions: [
...sessions(['question', 'busy'], 'org-b'),
Expand All @@ -306,7 +350,7 @@ describe('Agents needs-input badge and shared live count', () => {
});
await waitFor(() => agentsOptions(result.renderer).tabBarBadge === 1);
expectCounts(result.renderer, 1, 2);
expect(isAttentionAcked('org-a-0', 'question')).toBe(true);
expect(isAttentionAcked('org-a-0', { raiseId: 'question' })).toBe(true);
expect(
queryClient
.getQueryCache()
Expand Down
12 changes: 7 additions & 5 deletions apps/mobile/src/components/agents/remote-session-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,14 @@ export const RemoteSessionRow = memo(function RemoteSessionRow({
// bumps the shared revision, so the flag re-derives when it ticks. A parent
// re-render with an unchanged payload and no ack reuses this result.
const needsInput = useMemo(
() =>
shouldShowNeedsInput({
() => {
const raiseId = session.statusUpdatedAt ?? session.status;
return shouldShowNeedsInput({
status: session.status,
raiseId: session.status,
isAcked: isAttentionAcked(session.id, session.status),
}),
raiseId,
isAcked: isAttentionAcked(session.id, { raiseId, status: session.status }),
});
},
// eslint-disable-next-line react/exhaustive-deps -- the revision is a real input: `isAttentionAcked` reads the ack store, so the flag must re-derive when that store ticks.
[session, revision]
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,9 @@ vi.mock('@/lib/hooks/use-session-model-options', () => {
vi.mock('@/lib/hooks/use-remote-model-catalog-retry', () => ({
useRemoteModelCatalogRetry: vi.fn(),
}));
vi.mock('@/lib/hooks/use-session-raise-reader', () => ({
useSessionRaiseReader: () => () => null,
}));
vi.mock('@/lib/hooks/use-theme-colors', () => ({ useThemeColors: () => ({}) }));
vi.mock('@/lib/persist/drafts', () => ({ agentComposerDraftKey: (id: string) => id }));
vi.mock('@/lib/persist/use-draft-load', () => ({
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/components/agents/session-detail-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ import {
useSessionModelOptions,
} from '@/lib/hooks/use-session-model-options';
import { useRemoteModelCatalogRetry } from '@/lib/hooks/use-remote-model-catalog-retry';
import { useSessionRaiseReader } from '@/lib/hooks/use-session-raise-reader';
import {
buildContinueHref,
buildContinuePrefillParams,
Expand Down Expand Up @@ -481,6 +482,7 @@ export function SessionDetailContent({
const analyticsSurface: AnalyticsSurface = fetchedData?.cloudAgentSessionId
? 'cloud-agent'
: 'remote-session';
const readObservedRaise = useSessionRaiseReader();

const {
isAnswering,
Expand All @@ -496,6 +498,7 @@ export function SessionDetailContent({
activeQuestion,
activePermission,
surface: analyticsSurface,
readObservedRaise,
});

const autoApproveState = resolveSessionAutoApproveState({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,9 @@ vi.mock('@/lib/hooks/use-session-model-options', () => ({
vi.mock('@/lib/hooks/use-remote-model-catalog-retry', () => ({
useRemoteModelCatalogRetry: vi.fn(),
}));
vi.mock('@/lib/hooks/use-session-raise-reader', () => ({
useSessionRaiseReader: () => () => null,
}));
vi.mock('@/lib/use-github-repos-refresh', () => ({
useGitHubReposRefresh: () => ({ openGitHubIntegration: vi.fn() }),
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export function SessionPreviewHeaderMeta({
? shouldShowNeedsInput({
status: liveRow.status,
raiseId,
isAcked: isAttentionAcked(target.sessionId, raiseId),
isAcked: isAttentionAcked(target.sessionId, { raiseId, status: liveRow.status }),
})
: target.needsInput;
const liveStatus = liveRow?.status ?? null;
Expand Down
37 changes: 36 additions & 1 deletion apps/mobile/src/components/agents/session-row.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { prefetchSessionTranscript } from '@/lib/agent-session-cache';
import { createKiloAppQueryClient } from '@/lib/query-client';
import { i18n } from '@/i18n';
import { type ActiveSession, type StoredSession } from '@/lib/hooks/use-agent-sessions';
import { __resetSessionAttentionForTests } from '@/lib/session-attention';
import { __resetSessionAttentionForTests, ackSessionAttention } from '@/lib/session-attention';
import { RemoteSessionRow } from './remote-session-row';
import { formatScheduledWake } from './session-list-helpers';
import {
Expand Down Expand Up @@ -294,6 +294,41 @@ describe('StoredSessionRow live speech', () => {
}
);

it('brings the badge back for a raise the user has not answered', () => {
const answered = {
...session,
status: 'permission',
status_updated_at: '2026-09-18T21:00:00.000Z',
};
const renderer = mount(row({ session: answered, live: true }));

expect(texts(renderer)).toContain('NEEDS INPUT');

// The answer path acks the raise it answered.
act(() => {
ackSessionAttention('stored-1', 'permission', '2026-09-18T21:00:00.000Z');
});
expect(texts(renderer)).not.toContain('NEEDS INPUT');

// A permission raised after the answered one reaches the row before the
// answered status round-trip lands: it must show again.
act(() => {
renderer.update(
wrapped(
row({
session: { ...answered, status_updated_at: '2026-09-18T21:30:00.000Z' },
live: true,
})
)
);
});

expect(texts(renderer)).toContain('NEEDS INPUT');
expect(hosts(renderer, 'Pressable')[0]?.props.accessibilityLabel).toBe(
'Fix login bug, needs input, feature/live, and CLI'
);
});

it('reads SCHEDULED (label only) for a stored scheduled row instead of Idle', () => {
// The scheduled branch keys off the status, not the live flag: a stored
// history row has no wake time, so it shows the label alone.
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/components/agents/session-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ export function StoredSessionRow({
const needsInput = shouldShowNeedsInput({
status: session.status,
raiseId,
isAcked: isAttentionAcked(session.session_id, raiseId),
isAcked: isAttentionAcked(session.session_id, { raiseId, status: session.status }),
});
useEffect(() => {
reconcileSessionAttention(session.session_id, session.status, session.status_updated_at);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,16 @@ type HookDispatcher = {
};

type InteractionHandlersArgs = Parameters<typeof useInteractionHandlers>[0];
/** The cases here never read a live row, so they may omit the raise reader. */
type HandlerArgs = Omit<InteractionHandlersArgs, 'readObservedRaise'>;
type InteractionHandlersResult = ReturnType<typeof useInteractionHandlers>;

function renderInteractionHandlers(args: InteractionHandlersArgs) {
function renderInteractionHandlers(
args: Omit<InteractionHandlersArgs, 'readObservedRaise'> &
Partial<Pick<InteractionHandlersArgs, 'readObservedRaise'>>
) {
// Read per render: the scoping cases mutate `args` between renders.
const readObservedRaise = args.readObservedRaise ?? (() => null);
const reactInternals = React as typeof React & ReactInternals;
const hookState: unknown[] = [];
let hookIndex = 0;
Expand Down Expand Up @@ -71,7 +78,7 @@ function renderInteractionHandlers(args: InteractionHandlersArgs) {
reactInternals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H = dispatcher;
try {
const mountHandlers = useInteractionHandlers;
return mountHandlers(args);
return mountHandlers({ ...args, readObservedRaise });
} finally {
reactInternals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H =
previousDispatcher;
Expand All @@ -92,7 +99,7 @@ describe('useInteractionHandlers request-id scoping', () => {
rejectQuestion: vi.fn(),
respondToPermission: vi.fn(),
};
const args: InteractionHandlersArgs = {
const args: HandlerArgs = {
manager: manager as never,
kiloSessionId: 'kilo-session-1',
activeQuestion: { requestId: 'q-req-1' },
Expand Down Expand Up @@ -125,7 +132,7 @@ describe('useInteractionHandlers request-id scoping', () => {
rejectQuestion: vi.fn(),
respondToPermission: vi.fn(),
};
const args: InteractionHandlersArgs = {
const args: HandlerArgs = {
manager: manager as never,
kiloSessionId: 'kilo-session-1',
activeQuestion: { requestId: 'q-req-1' },
Expand Down Expand Up @@ -168,7 +175,7 @@ describe('useInteractionHandlers request-id scoping', () => {
rejectQuestion: vi.fn(),
respondToPermission: vi.fn(),
};
const args: InteractionHandlersArgs = {
const args: HandlerArgs = {
manager: manager as never,
kiloSessionId: 'kilo-session-1',
activeQuestion: { requestId: 'q-req-1' },
Expand Down Expand Up @@ -207,7 +214,7 @@ describe('useInteractionHandlers request-id scoping', () => {
rejectQuestion: vi.fn(),
respondToPermission: vi.fn().mockReturnValue(respondPromise),
};
const args: InteractionHandlersArgs = {
const args: HandlerArgs = {
manager: manager as never,
kiloSessionId: 'kilo-session-1',
activeQuestion: null,
Expand Down Expand Up @@ -240,7 +247,7 @@ describe('useInteractionHandlers request-id scoping', () => {
rejectQuestion: vi.fn(),
respondToPermission: vi.fn().mockReturnValue(respondPromise),
};
const args: InteractionHandlersArgs = {
const args: HandlerArgs = {
manager: manager as never,
kiloSessionId: 'kilo-session-1',
activeQuestion: null,
Expand Down Expand Up @@ -283,7 +290,7 @@ describe('useInteractionHandlers request-id scoping', () => {
rejectQuestion: vi.fn(),
respondToPermission,
};
const args: InteractionHandlersArgs = {
const args: HandlerArgs = {
manager: manager as never,
kiloSessionId: 'kilo-session-1',
activeQuestion: null,
Expand Down
Loading
Loading