Skip to content

Commit fb558fe

Browse files
authored
Merge pull request #7268 from senamakel/ci-e2e-green-followup
fix: stabilize onboarding and thread selection e2e
2 parents 74289b3 + 1da9b39 commit fb558fe

21 files changed

Lines changed: 500 additions & 157 deletions

‎app/src/components/InitProgressScreen/InitProgressScreen.test.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ describe('InitProgressScreen', () => {
6565
<InitProgressScreen snapshot={snapshot()} onRetry={vi.fn()} onContinue={onContinue} />
6666
);
6767

68+
expect(screen.getByTestId('harness-init-background')).toBeInTheDocument();
69+
expect(screen.queryByTestId('harness-init-continue-anyway')).not.toBeInTheDocument();
6870
fireEvent.click(screen.getByText('Run in background'));
6971
expect(onContinue).toHaveBeenCalledTimes(1);
7072
});
@@ -92,6 +94,8 @@ describe('InitProgressScreen', () => {
9294
);
9395

9496
expect(screen.getByText('pip install timed out')).toBeInTheDocument();
97+
expect(screen.getByTestId('harness-init-continue-anyway')).toBeInTheDocument();
98+
expect(screen.queryByTestId('harness-init-background')).not.toBeInTheDocument();
9599

96100
fireEvent.click(screen.getByText('Retry'));
97101
expect(onRetry).toHaveBeenCalledTimes(1);

‎app/src/components/InitProgressScreen/InitProgressScreen.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export default function InitProgressScreen({
8282
<div className="fixed inset-0 z-9999 flex items-center justify-center bg-stone-950/90 p-4 backdrop-blur-sm">
8383
<div
8484
role="dialog"
85+
data-testid="harness-init-dialog"
8586
aria-modal="true"
8687
aria-labelledby="harness-init-title"
8788
className="w-full max-w-md rounded-2xl border border-stone-700/60 bg-stone-900 p-6 shadow-2xl">
@@ -101,6 +102,7 @@ export default function InitProgressScreen({
101102
<p className="text-xs text-content-muted">{t('harnessInit.backgroundHint')}</p>
102103
<button
103104
type="button"
105+
data-testid="harness-init-background"
104106
onClick={onContinue}
105107
className="shrink-0 rounded-lg border border-stone-700 px-3 py-1.5 text-sm text-content-faint hover:bg-stone-800 hover:text-white">
106108
{t('harnessInit.runInBackground')}
@@ -121,6 +123,7 @@ export default function InitProgressScreen({
121123
<div className="mt-4 flex justify-end gap-2">
122124
<button
123125
type="button"
126+
data-testid="harness-init-continue-anyway"
124127
onClick={onContinue}
125128
className="rounded-lg px-3 py-1.5 text-sm text-content-faint hover:text-white">
126129
{t('harnessInit.continueAnyway')}

‎app/src/features/conversations/Conversations.tsx‎

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import debugFactory from 'debug';
22
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
3+
import { useStore } from 'react-redux';
34
import { useLocation, useNavigate, useParams } from 'react-router-dom';
45

56
import { type ChatSendError, chatSendError } from '../../chat/chatSendError';
@@ -59,6 +60,7 @@ import {
5960
useRustChat,
6061
} from '../../services/chatService';
6162
import { callCoreRpc } from '../../services/coreRpcClient';
63+
import type { RootState } from '../../store';
6264
import {
6365
beginInferenceTurn,
6466
clearRuntimeForThread,
@@ -79,6 +81,7 @@ import {
7981
clearThreadInferenceActive,
8082
createNewThread,
8183
deleteThread,
84+
invalidateThreadSelection,
8285
loadThreadMessages,
8386
loadThreads,
8487
markThreadInferenceActive,
@@ -259,6 +262,7 @@ const Conversations = ({
259262
const composer = composerOverride ?? composerProp;
260263
const { t } = useT();
261264
const dispatch = useAppDispatch();
265+
const store = useStore<RootState>();
262266
const navigate = useNavigate();
263267
const location = useLocation();
264268
const { threadId: routeThreadId } = useParams<{ threadId?: string }>();
@@ -347,10 +351,9 @@ const Conversations = ({
347351
const persistedFailedMessageByThreadRef = useRef<Map<string, ThreadMessage>>(new Map());
348352
const createThreadErrorRef = useRef(createThreadError);
349353
createThreadErrorRef.current = createThreadError;
350-
// Startup thread restoration is asynchronous. If the user chooses a thread
351-
// while its initial `loadThreads` is in flight, that older callback must not
352-
// overwrite the newer selection when it resolves.
353-
const threadSelectionIntentRef = useRef(0);
354+
// The Redux selection intent is updated by every setSelectedThread action,
355+
// including worker-thread cards rendered inside the transcript. Async
356+
// startup/create continuations compare against it before selecting a thread.
354357
const displayedSendError = deriveChatErrorBanner(
355358
sendError,
356359
createThreadError,
@@ -726,11 +729,19 @@ const Conversations = ({
726729
const turnSignatureByThreadRef = useRef<Map<string, readonly unknown[]>>(new Map());
727730

728731
const handleCreateNewThread = async (fromInitialLoad = false) => {
729-
if (!fromInitialLoad) threadSelectionIntentRef.current += 1;
730-
const selectionIntentAtCreate = threadSelectionIntentRef.current;
732+
if (!fromInitialLoad) dispatch(invalidateThreadSelection());
733+
const selectionIntentAtCreate = store.getState().thread.selectionIntentVersion ?? 0;
731734
try {
732735
const thread = await dispatch(createNewThread()).unwrap();
733-
if (threadSelectionIntentRef.current !== selectionIntentAtCreate) return;
736+
const currentSelectionIntent = store.getState().thread.selectionIntentVersion ?? 0;
737+
if (currentSelectionIntent !== selectionIntentAtCreate) {
738+
debug(
739+
'[chat] create thread selection superseded; dropping result intent_at_create=%d current_intent=%d',
740+
selectionIntentAtCreate,
741+
currentSelectionIntent
742+
);
743+
return;
744+
}
734745
dispatch(setSelectedThread(thread.id));
735746
void dispatch(loadThreadMessages(thread.id));
736747
if (shouldSyncChatRoute) {
@@ -790,12 +801,21 @@ const Conversations = ({
790801

791802
useEffect(() => {
792803
let cancelled = false;
793-
const selectionIntentAtLoad = threadSelectionIntentRef.current;
804+
const selectionIntentAtLoad = store.getState().thread.selectionIntentVersion ?? 0;
794805

795806
void dispatch(loadThreads())
796807
.unwrap()
797808
.then(data => {
798-
if (cancelled || threadSelectionIntentRef.current !== selectionIntentAtLoad) return;
809+
const currentSelectionIntent = store.getState().thread.selectionIntentVersion ?? 0;
810+
if (cancelled || currentSelectionIntent !== selectionIntentAtLoad) {
811+
debug(
812+
'[chat] initial thread load selection superseded; dropping result cancelled=%s intent_at_load=%d current_intent=%d',
813+
cancelled,
814+
selectionIntentAtLoad,
815+
currentSelectionIntent
816+
);
817+
return;
818+
}
799819
// Match the sidebar's default General filter here so initial/resume
800820
// selection can't auto-pick a thread hidden by the selected tab.
801821
const visibleThreads = data.threads.filter(t => isThreadVisibleInTab(t, GENERAL_TAB_VALUE));
@@ -1889,7 +1909,6 @@ const Conversations = ({
18891909
selectedThreadId={selectedThreadId ?? null}
18901910
onCreateThread={() => handleCreateNewThread()}
18911911
onSelectThread={id => {
1892-
threadSelectionIntentRef.current += 1;
18931912
dispatch(setSelectedThread(id));
18941913
void dispatch(loadThreadMessages(id));
18951914
if (shouldSyncChatRoute) {
@@ -2257,7 +2276,6 @@ const Conversations = ({
22572276
type="button"
22582277
data-analytics-id="chat-header-back-to-parent-thread"
22592278
onClick={() => {
2260-
threadSelectionIntentRef.current += 1;
22612279
dispatch(setSelectedThread(selectedThreadParent.id));
22622280
void dispatch(loadThreadMessages(selectedThreadParent.id));
22632281
navigate(chatThreadPath(selectedThreadParent.id));

‎app/src/pages/__tests__/Conversations.render.test.tsx‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ async function openSidebar() {
267267
const emptyThreadState = {
268268
threads: [],
269269
selectedThreadId: null,
270+
selectionIntentVersion: 0,
270271
activeThreadIds: {},
271272
welcomeThreadId: null,
272273
messagesByThreadId: {},
@@ -734,6 +735,43 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
734735
});
735736
});
736737

738+
it('keeps an explicit sidebar selection made while initial thread loading is pending', async () => {
739+
const threads = [
740+
makeThread({ id: 't-1', title: 'Initial Thread' }),
741+
makeThread({ id: 't-2', title: 'Explicitly Selected Thread' }),
742+
];
743+
let resolveThreads: ((value: { threads: Thread[]; count: number }) => void) | undefined;
744+
mockGetThreads.mockImplementation(
745+
() =>
746+
new Promise(resolve => {
747+
resolveThreads = resolve;
748+
})
749+
);
750+
751+
let store: ReturnType<typeof buildStore> | undefined;
752+
await act(async () => {
753+
store = await renderConversations({
754+
thread: {
755+
...emptyThreadState,
756+
threads,
757+
selectedThreadId: 't-1',
758+
messagesByThreadId: { 't-1': [], 't-2': [] },
759+
},
760+
});
761+
});
762+
await waitFor(() => expect(mockGetThreads).toHaveBeenCalled());
763+
764+
fireEvent.click(screen.getByTestId('thread-row-t-2'));
765+
expect(store?.getState().thread.selectedThreadId).toBe('t-2');
766+
767+
await act(async () => {
768+
resolveThreads?.({ threads, count: threads.length });
769+
});
770+
771+
expect(store?.getState().thread.selectedThreadId).toBe('t-2');
772+
expect(screen.getByTestId('thread-row-t-2')).toHaveClass('bg-surface/70');
773+
});
774+
737775
// Sidebar "New thread" button was removed in the composer flattening refactor.
738776
// The "+ New" header button (tested below) is the remaining create-thread entry point.
739777

‎app/src/store/__tests__/threadSlice.test.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,13 +168,15 @@ describe('threadSlice synchronous reducers', () => {
168168
store.dispatch(setSelectedThread('t-1'));
169169
store.dispatch(setActiveThread('t-1'));
170170

171+
const selectionVersion = store.getState().thread.selectionIntentVersion;
171172
store.dispatch(clearAllThreads());
172173
const state = store.getState().thread;
173174
expect(state.threads).toEqual([]);
174175
expect(state.messagesByThreadId).toEqual({});
175176
expect(state.selectedThreadId).toBeNull();
176177
expect(state.activeThreadIds).toEqual({});
177178
expect(state.messages).toEqual([]);
179+
expect(state.selectionIntentVersion).toBeGreaterThan(selectionVersion);
178180
});
179181

180182
it('clearStaleThread removes stale selection, cache, and active id', async () => {
@@ -192,12 +194,14 @@ describe('threadSlice synchronous reducers', () => {
192194
store.dispatch(setSelectedThread('t-1'));
193195
store.dispatch(setActiveThread('t-1'));
194196

197+
const selectionVersion = store.getState().thread.selectionIntentVersion;
195198
store.dispatch(clearStaleThread('t-1'));
196199

197200
const state = store.getState().thread;
198201
expect(state.threads.map(thread => thread.id)).toEqual(['t-2']);
199202
expect(state.messagesByThreadId['t-1']).toBeUndefined();
200203
expect(state.selectedThreadId).toBeNull();
204+
expect(state.selectionIntentVersion).toBeGreaterThan(selectionVersion);
201205
expect(state.activeThreadIds).toEqual({});
202206
expect(state.messages).toEqual([]);
203207
});
@@ -208,6 +212,44 @@ describe('threadSlice loadThreads thunk', () => {
208212
vi.clearAllMocks();
209213
});
210214

215+
it('does not restore threads from a load superseded by clearing all threads', async () => {
216+
const store = createStore();
217+
let resolveThreads!: (value: { threads: Thread[]; count: number }) => void;
218+
mockedThreadApi.getThreads.mockImplementationOnce(
219+
() => new Promise(resolve => (resolveThreads = resolve))
220+
);
221+
222+
const request = store.dispatch(loadThreads());
223+
store.dispatch(clearAllThreads());
224+
resolveThreads({ threads: [makeThread({ id: 'stale' })], count: 1 });
225+
await request;
226+
227+
expect(store.getState().thread.threads).toEqual([]);
228+
expect(store.getState().thread.selectedThreadId).toBeNull();
229+
});
230+
231+
it('does not restore a cleared stale thread from an older list response', async () => {
232+
const store = createStore();
233+
mockedThreadApi.getThreads.mockResolvedValueOnce({
234+
threads: [makeThread({ id: 't-1' }), makeThread({ id: 't-2' })],
235+
count: 2,
236+
});
237+
await store.dispatch(loadThreads());
238+
store.dispatch(setSelectedThread('t-1'));
239+
240+
let resolveThreads!: (value: { threads: Thread[]; count: number }) => void;
241+
mockedThreadApi.getThreads.mockImplementationOnce(
242+
() => new Promise(resolve => (resolveThreads = resolve))
243+
);
244+
const request = store.dispatch(loadThreads());
245+
store.dispatch(clearStaleThread('t-1'));
246+
resolveThreads({ threads: [makeThread({ id: 't-1' }), makeThread({ id: 't-2' })], count: 2 });
247+
await request;
248+
249+
expect(store.getState().thread.threads.map(thread => thread.id)).toEqual(['t-2']);
250+
expect(store.getState().thread.selectedThreadId).toBeNull();
251+
});
252+
211253
it('sets isLoadingThreads while pending and stores threads on fulfilled', async () => {
212254
const store = createStore();
213255
const payload = { threads: [makeThread({ id: 'a' })], count: 1 };
@@ -230,6 +272,21 @@ describe('threadSlice loadThreads thunk', () => {
230272
expect(result.type).toBe('thread/loadThreads/rejected');
231273
expect(store.getState().thread.isLoadingThreads).toBe(false);
232274
});
275+
276+
it('preserves a newer selection when an older thread-list response omits it', async () => {
277+
const store = createStore();
278+
let resolveThreads: ((value: { threads: Thread[]; count: number }) => void) | undefined;
279+
mockedThreadApi.getThreads.mockImplementationOnce(
280+
() => new Promise(resolve => (resolveThreads = resolve))
281+
);
282+
283+
const request = store.dispatch(loadThreads());
284+
store.dispatch(setSelectedThread('worker-created-after-request'));
285+
resolveThreads?.({ threads: [makeThread({ id: 'existing-thread' })], count: 1 });
286+
await request;
287+
288+
expect(store.getState().thread.selectedThreadId).toBe('worker-created-after-request');
289+
});
233290
});
234291

235292
describe('threadSlice loadThreadMessages thunk', () => {

0 commit comments

Comments
 (0)