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
22 changes: 21 additions & 1 deletion frontend/src/v2/__tests__/V2ApprovalCardAction.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import V2ApprovalCard from '../components/V2ApprovalCard';
import en from '../../i18n/locales/en.json';
Expand Down Expand Up @@ -203,6 +203,26 @@ describe('tool-call approval details stay owner-scoped', () => {
expect(screen.getByText(en.approvalCard.decline)).toBeDisabled();
});

test('a parked call that expires while the card is on screen stops being decidable at that moment', () => {
jest.useFakeTimers();
// The owner's card reads the pending index while the call is live.
mockGet.mockReturnValue(new Promise(() => {}));
try {
renderCard({
kind: 'approval-card', approvalId: 'tool-expiring', status: 'flagged',
ownerUserId: 'viewer-1', actionType: 'tool_call', toolCall: { tool: 'github.create_issue' },
expiresAt: new Date(Date.now() + 60_000).toISOString(),
});
expect(screen.queryByText(en.approvalCard.toolCall.expiredPending)).not.toBeInTheDocument();
act(() => { jest.advanceTimersByTime(60_001); });
expect(screen.getByText(en.approvalCard.toolCall.expiredPending)).toBeInTheDocument();
expect(screen.getByText(en.approvalCard.approve)).toBeDisabled();
expect(screen.getByText(en.approvalCard.decline)).toBeDisabled();
} finally {
jest.useRealTimers();
}
});

test('a resolved tool call renders its broker ending and code instead of generic success', () => {
renderCard({
kind: 'approval-card', approvalId: 'tool-failed', status: 'resolved', decision: 'approved',
Expand Down
80 changes: 80 additions & 0 deletions frontend/src/v2/__tests__/V2TranscriptArrivalRender.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// @ts-nocheck
// A live message appends one row. The rows already on screen get the same
// props, so they must not render again: re-rendering the whole transcript cost
// one 84-107ms frame per arrival on a phone (4x CPU, 2026-11-01).
import React from 'react';
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import V2Thread from '../components/V2Thread';
import { AuthContext } from '../../context/AuthContext';

const mockAvatarRenders = [];
jest.mock('../../context/SocketContext', () => ({ useSocket: () => ({ socket: null, connected: false }) }));
jest.mock('../components/V2Avatar', () => {
const MockAvatar = ({ name }) => { mockAvatarRenders.push(name); return <span data-testid="avatar" />; };
MockAvatar.displayName = 'MockAvatar';
return MockAvatar;
});
jest.mock('../utils/avatars', () => ({ initialsFor: (name: string) => name.slice(0, 2) }));
const mockApi = {
get: jest.fn(() => Promise.resolve({})),
post: jest.fn(() => Promise.resolve({})),
put: jest.fn(() => Promise.resolve({})),
patch: jest.fn(() => Promise.resolve({})),
del: jest.fn(() => Promise.resolve({})),
};
jest.mock('../hooks/useV2Api', () => ({ useV2Api: () => mockApi }));
beforeAll(() => { Element.prototype.scrollIntoView = jest.fn(); });

const authValue = {
currentUser: { _id: 'u1', username: 'viewer' },
user: { _id: 'u1', username: 'viewer' },
token: 't',
loading: false,
isAuthenticated: true,
error: null,
register: jest.fn(), login: jest.fn(), logout: jest.fn(), updateProfile: jest.fn(),
};

// Different authors an hour apart, so no row groups with the one before it.
const message = (id, author, hour) => ({
id, pod_id: 'p1', user_id: `id-${author}`, content: `from ${author}`, message_type: 'text',
created_at: `2026-10-19T${String(hour).padStart(2, '0')}:00:00Z`, user: { username: author },
});
const history = [message('m1', 'ada', 9), message('m2', 'brook', 10), message('m3', 'cass', 11)];

const detail = {
pod: { _id: 'p1', name: 'Pod', type: 'chat' },
members: [{ _id: 'u1', username: 'viewer', isBot: false }],
messages: history,
agents: [],
sendMessage: jest.fn(() => Promise.resolve({})),
loading: false,
error: null,
sendError: null,
hasMore: false,
loadingOlder: false,
loadOlder: jest.fn(() => Promise.resolve()),
refresh: jest.fn(),
initialLoadComplete: true,
};

const node = (d) => (
<AuthContext.Provider value={authValue}>
<MemoryRouter initialEntries={['/v2/pods/p1']}>
<V2Thread detail={d} />
</MemoryRouter>
</AuthContext.Provider>
);

test('a live arrival renders only the new row; the rows already shown stay as they are', () => {
const view = render(node(detail));
const seen = (names) => mockAvatarRenders.filter((name) => names.includes(name)).length;
const before = seen(['ada', 'brook', 'cass']);
expect(before).toBeGreaterThanOrEqual(3);

view.rerender(node({ ...detail, messages: [...history, { ...message('m4', 'dev', 12), liveArrivedAt: Date.now() }] }));

expect(seen(['dev'])).toBeGreaterThanOrEqual(1);
expect(seen(['ada', 'brook', 'cass'])).toBe(before);
});
14 changes: 12 additions & 2 deletions frontend/src/v2/components/V2ApprovalCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,18 @@ const V2ApprovalCard: React.FC<V2ApprovalCardProps> = ({ message, authorLabel, t
const status = payload.status || 'flagged';
const isOwner = !!currentUser?._id && String(currentUser._id) === String(payload.ownerUserId || '');
const isToolCall = payload.actionType === 'tool_call';
const aged = status === 'flagged' && !!payload.expiresAt
&& new Date(payload.expiresAt).getTime() < Date.now();
// The card re-renders itself at the expiry moment. Its row is memoized, so
// nothing else is guaranteed to render it again while the pod stays open.
const expiresMs = status === 'flagged' && payload.expiresAt ? new Date(payload.expiresAt).getTime() : NaN;
const [expiryTick, setExpiryTick] = useState(0);
useEffect(() => {
const wait = expiresMs - Date.now();
if (!Number.isFinite(wait) || wait < 0) return undefined;
// setTimeout caps near 24.8 days; a longer wait re-arms on the tick.
const timer = window.setTimeout(() => setExpiryTick((n) => n + 1), Math.min(wait + 1, 2 ** 31 - 1));
return () => window.clearTimeout(timer);
}, [expiresMs, expiryTick]);
const aged = Number.isFinite(expiresMs) && expiresMs < Date.now();
const needsToolCall = isOwner && status === 'flagged' && isToolCall && !aged;
const [pendingToolCall, setPendingToolCall] = useState<ToolCallEnvelope | null>(null);
const [toolCallLoading, setToolCallLoading] = useState(false);
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/v2/components/V2MessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -954,4 +954,7 @@ const V2MessageRow: React.FC<V2MessageRowProps> = ({ message, sourceMessageId, d
);
};

export default V2MessageRow;
// Memoized: a live message appends one row, and every other row's props are
// unchanged, so only the new row renders. Callers keep the callbacks and maps
// they pass here stable (V2Thread's aimAtMessageThread reads messages by ref).
export default React.memo(V2MessageRow);
9 changes: 7 additions & 2 deletions frontend/src/v2/components/V2Thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -340,14 +340,19 @@ const V2Thread: React.FC<V2ThreadProps> = ({ detail, firstRunVisible = false, in
setReplyTarget(m);
composerInputRef.current?.focus();
}, []);
// Read through a ref so the callback stays the same object while messages
// arrive: every message row receives it, and a new one each time would
// re-render the whole transcript for one new message.
const messagesRef = useRef(messages);
useLayoutEffect(() => { messagesRef.current = messages; }, [messages]);
const aimAtMessageThread = useCallback((m: import('../hooks/useV2PodDetail').V2Message) => {
// The action is available on every visible message, including replies.
// Explicit roots may not themselves be inside a thread (#1128), so a
// reply joins its existing root while a standalone message starts one.
const rootId = String(m.thread_root_id ?? m.id);
const root = messages.find((candidate) => String(candidate.id) === rootId);
const root = messagesRef.current.find((candidate) => String(candidate.id) === rootId);
aimAtThread(rootId, String(root?.content || m.content || ''));
}, [aimAtThread, messages]);
}, [aimAtThread]);

// Memoized: it was called in the render body, so every keystroke in the
// composer re-folded the whole message list. @sprint-review on #1150.
Expand Down
Loading