openTaskKeepsExecutionMode.test.tsx3.8 KBView on GitHub import { act, render } from '@testing-library/react';
import { useCedarStore } from '@/modules/store';
import { useExitTaskExecutionOnClose } from '@/modules/userTasks/hooks/use-exit-task-execution-on-close';
import { useOpenTaskInExecutionMode } from '@/modules/userTasks/hooks/use-open-task-in-execution-mode';
/**
* Opening a task must not be mistaken for CLOSING one.
*
* `useExitTaskExecutionOnClose` clears `?task=` when the centre column goes empty,
* because the task-list rail must not outlive the thing it was opened alongside. It detects
* that as a TRANSITION — something was open, now nothing is.
*
* Opening a task's draft dips through exactly that state. `useOpenTaskInExecutionMode` writes
* the two params, then calls `switchThread(task.chatThreadId)` to repoint the sidepanel at the
* task's own run — and the display flags are DERIVED from the active chat thread's
* `selectedArtifact` (`displayFlagsFor`), so switching to a thread that has none drops
* isThreadOpen/isConversationOpen/isTaskOutputOpen to false for that render. The artifact is
* restored a beat later, by LayoutUrlSync reacting to the `?threadOpen=` this same click wrote.
*
* So the "close" is a gap inside an OPEN, and clearing there tears down the execution-mode rail
* the click just asked for.
*/
const mockParams: Record<string, string | null> = {};
const mockSetters: Record<string, jest.Mock> = {};
const setterFor = (key=[redacted] jest.Mock => {
mockSetters[key] ||= jest.fn((value: string | null) => {
mockParams[key] = value;
});
return mockSetters[key];
};
jest.mock('nuqs', () => ({
useQueryState: (key=[redacted] => [mockParams[key] ?? null, setterFor(key)],
}));
const TASK = {
id: 'task-1',
conversationId: 'conv-1',
chatThreadId: 'chat-run',
taskActionData: { channel: 'email' as const, threadId: 'thread-1' },
};
let openTask: (task: typeof TASK) => void;
function Harness() {
openTask = useOpenTaskInExecutionMode();
useExitTaskExecutionOnClose();
return null;
}
/** The state the user is in when they click "Open draft": a conversation open on its Overview tab. */
function seedConversationOpen({ runThreadHasArtifact }: { runThreadHasArtifact: boolean }) {
const s = useCedarStore.getState();
s.createThread('chat-run', 'Task run');
s.createThread('chat-current', 'Current');
s.switchThread('chat-run');
// A run that left something on screen vs. one that did not — the difference this turns on.
s.setSelectedArtifact(runThreadHasArtifact ? { kind: 'email_thread', id: 'thread-1' } : null);
s.switchThread('chat-current');
s.setActiveConversationId('conv-1');
s.setSelectedArtifact({ kind: 'conversation', id: 'conv-1' });
}
describe('opening a task does not clear its own execution-mode params', () => {
beforeEach(() => {
for (const key of Object.keys(mockParams)) delete mockParams[key];
for (const key of Object.keys(mockSetters)) delete mockSetters[key];
});
it('keeps ?task= when the task run left nothing displayed', async () => {
seedConversationOpen({ runThreadHasArtifact: false });
render(<Harness />);
await act(async () => {});
await act(async () => {
openTask(TASK);
});
// The click wrote both params…
expect(setterFor('task')).toHaveBeenCalledWith('task-1');
// …and nothing may then clear them: the rail is the point of execution mode.
expect(setterFor('task')).not.toHaveBeenCalledWith(null, { history: 'replace' });
expect(mockParams.task).toBe('task-1');
});
it('keeps ?task= when the task run does have its own displayed artifact', async () => {
seedConversationOpen({ runThreadHasArtifact: true });
render(<Harness />);
await act(async () => {});
await act(async () => {
openTask(TASK);
});
expect(mockParams.task).toBe('task-1');
});
});