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');
  });
});