// ABOUTME: Regression tests for i3-style workspace cleanup semantics.
// ABOUTME: Verifies sticky lifetime content survives transient thread clears.

import { afterEach, describe, expect, it, vi } from "vitest";

type MockThreadKind = "chat" | "agent" | "terminal" | "editor";
type MockThreadStatus = "idle" | "running" | "waiting-input" | "error";
const harness = vi.hoisted(() => ({
  activeThreadId: null as string | null,
  activeThreadKind: null as MockThreadKind | null,
  effects: [] as Array<() => void>,
  threads: [] as Array<{
    id: string;
    kind: MockThreadKind;
    status: "idle" | "running" | "waiting-input" | "error";
  }>,
}));

vi.mock("solid-js", async () => {
  const actual = await vi.importActual<typeof import("solid-js")>("solid-js");
  return {
    ...actual,
    createEffect(fn: () => void): void {
      harness.effects.push(fn);
      fn();
    },
    untrack<T>(fn: () => T): T {
      return fn();
    },
  };
});

vi.mock("@/stores/thread.store", () => {
  const setActiveThread = vi.fn((id: string | null) => {
    if (id && !harness.threads.some((t) => t.id === id)) {
      harness.threads.push({ id, kind: "chat", status: "idle" });
    }
    harness.activeThreadId = id;
    harness.activeThreadKind =
      id === null
        ? null
        : (harness.threads.find((t) => t.id === id)?.kind ?? null);
    for (const effect of harness.effects) effect();
  });

  const selectThread = vi.fn((id: string, kind: MockThreadKind) => {
    harness.activeThreadId = id;
    harness.activeThreadKind = kind;
    for (const effect of harness.effects) effect();
  });

  return {
    threadStore: {
      get activeThreadId(): string | null {
        return harness.activeThreadId;
      },
      get activeThreadKind(): MockThreadKind | null {
        return harness.activeThreadKind;
      },
      get threads() {
        return harness.threads;
      },
      setActiveThread,
      selectThread,
    },
    __setMockActiveThread(id: string | null): void {
      setActiveThread.mockClear();
      harness.activeThreadId = id;
      if (id && !harness.threads.some((t) => t.id === id)) {
        harness.threads.push({ id, kind: "chat", status: "idle" });
      }
    },
    __setMockActiveThreadWithoutThread(id: string | null): void {
      setActiveThread.mockClear();
      harness.activeThreadId = id;
      for (const effect of harness.effects) effect();
    },
    __addMockThread(id: string, kind: MockThreadKind = "chat"): void {
      if (!harness.threads.some((thread) => thread.id === id)) {
        harness.threads.push({ id, kind, status: "idle" });
      }
      for (const effect of harness.effects) effect();
    },
    __removeMockThread(id: string): void {
      harness.threads = harness.threads.filter((thread) => thread.id !== id);
      for (const effect of harness.effects) effect();
    },
    __setMockThreadStatus(id: string, status: MockThreadStatus): void {
      const thread = harness.threads.find((t) => t.id === id);
      if (thread) thread.status = status;
      for (const effect of harness.effects) effect();
    },
  };
});

async function setup(initialActiveThreadId: string | null = null) {
  harness.activeThreadId = initialActiveThreadId;
  harness.effects = [];
  harness.threads = initialActiveThreadId
    ? [{ id: initialActiveThreadId, kind: "chat", status: "idle" }]
    : [];
  vi.resetModules();
  type ThreadStoreTestModule = typeof import("@/stores/thread.store") & {
    __setMockActiveThread(id: string | null): void;
    __setMockActiveThreadWithoutThread(id: string | null): void;
    __addMockThread(
      id: string,
      kind?: MockThreadKind,
    ): void;
    __removeMockThread(id: string): void;
    __setMockThreadStatus(id: string, status: MockThreadStatus): void;
  };
  const threadModule = (await import(
    "@/stores/thread.store"
  )) as ThreadStoreTestModule;
  threadModule.__setMockActiveThread(initialActiveThreadId);

  const workspaceModule = await import("@/stores/workspace.store");
  workspaceModule.initWorkspaceStore();
  await Promise.resolve();

  return {
    threadModule,
    threadStore: threadModule.threadStore,
    workspaceStore: workspaceModule.workspaceStore,
  };
}

afterEach(() => {
  harness.activeThreadId = null;
  harness.threads = [];
  harness.effects = [];
  vi.clearAllMocks();
});

describe("workspaceStore", () => {
  it("auto-deletes a created workspace that never had content", async () => {
    const { workspaceStore } = await setup();

    workspaceStore.addWorkspace();
    expect(workspaceStore.activeNumber).toBe(2);

    workspaceStore.switchTo(1);

    expect(workspaceStore.workspaces.map((w) => w.number)).toEqual([1]);
  });

  it("notifies listeners when an empty workspace auto-deletes", async () => {
    const { workspaceStore } = await setup();
    const removed = vi.fn();
    const unsubscribe = workspaceStore.onWorkspaceRemoved(removed);

    workspaceStore.addWorkspace();
    workspaceStore.switchTo(1);

    expect(removed).toHaveBeenCalledOnce();
    expect(removed).toHaveBeenCalledWith(2);

    unsubscribe();
    workspaceStore.switchOrCreate(3);
    workspaceStore.switchTo(1);

    expect(removed).toHaveBeenCalledOnce();
  });

  it("keeps a workspace that had content after its live thread mirror clears", async () => {
    const { threadStore, workspaceStore } = await setup();

    workspaceStore.addWorkspace();
    threadStore.setActiveThread("thread-1");

    const usedWorkspace = () =>
      workspaceStore.workspaces.find((w) => w.number === 2);
    expect(usedWorkspace()?.windows[0]).toMatchObject({
      threadId: "thread-1",
      kind: "chat",
    });
    expect(usedWorkspace()?.hasHadContent).toBe(true);

    threadStore.setActiveThread(null);
    expect(usedWorkspace()?.windows[0]).toMatchObject({
      threadId: "thread-1",
      kind: "chat",
    });
    expect(usedWorkspace()?.hasHadContent).toBe(true);

    workspaceStore.switchTo(1);

    expect(workspaceStore.workspaces.map((w) => w.number)).toEqual([1, 2]);
  });

  it("closes windows for threads that are actually removed", async () => {
    const { threadModule, threadStore, workspaceStore } = await setup();

    workspaceStore.addWorkspace();
    threadStore.setActiveThread("thread-1");

    threadStore.setActiveThread(null);
    expect(workspaceStore.workspaces.find((w) => w.number === 2)).toMatchObject(
      {
        windows: [
          {
            threadId: "thread-1",
          },
        ],
        hasHadContent: true,
      },
    );

    threadModule.__removeMockThread("thread-1");

    expect(workspaceStore.workspaces.find((w) => w.number === 2)).toMatchObject(
      {
        windows: [],
        focusedWindowId: null,
        hasHadContent: true,
      },
    );
  });

  it("captures the outgoing thread even if the mirror has not flushed yet", async () => {
    const { threadModule, workspaceStore } = await setup();

    workspaceStore.addWorkspace();
    threadModule.__setMockActiveThread("thread-1");

    workspaceStore.switchTo(1);
    workspaceStore.switchTo(2);

    expect(workspaceStore.workspaces.find((w) => w.number === 2)).toMatchObject(
      {
        windows: [
          {
            threadId: "thread-1",
            kind: "chat",
          },
        ],
        hasHadContent: true,
      },
    );
  });

  it("marks workspace 1 as used when a thread is already active before init", async () => {
    const { workspaceStore } = await setup("restored-thread");

    expect(workspaceStore.workspaces).toEqual([
      {
        number: 1,
        windows: [
          {
            id: "workspace-1-primary",
            threadId: "restored-thread",
            kind: "chat",
            size: 1,
          },
        ],
        focusedWindowId: "workspace-1-primary",
        splitDirection: "row",
        layout: {
          type: "pane",
          id: "workspace-1-primary",
          windowId: "workspace-1-primary",
          size: 1,
        },
        hasHadContent: true,
        needsAttention: false,
      },
    ]);
  });

  it("binds an already-active thread when the thread list arrives later", async () => {
    const { threadModule, workspaceStore } = await setup();

    threadModule.__setMockActiveThreadWithoutThread("late-thread");
    expect(workspaceStore.activeWindow).toBeNull();

    threadModule.__addMockThread("late-thread", "agent");

    expect(workspaceStore.activeWindow).toMatchObject({
      id: "workspace-1-primary",
      threadId: "late-thread",
      kind: "agent",
    });
    expect(workspaceStore.activeWorkspace.hasHadContent).toBe(true);
  });

  it("does not reselect when switching between workspaces sharing a thread", async () => {
    const { threadStore, workspaceStore } = await setup();

    threadStore.setActiveThread("thread-1");
    workspaceStore.switchOrCreate(2);
    threadStore.setActiveThread("thread-1");
    vi.mocked(threadStore.setActiveThread).mockClear();

    workspaceStore.switchTo(1);

    expect(threadStore.setActiveThread).not.toHaveBeenCalled();
    expect(workspaceStore.activeWindow).toMatchObject({
      threadId: "thread-1",
      kind: "chat",
    });
  });

  it("keeps shortcut-created workspaces empty across repeated same-target switches", async () => {
    const { workspaceStore } = await setup();

    workspaceStore.switchOrCreate(5);
    workspaceStore.switchOrCreate(5);

    const workspace5 = workspaceStore.workspaces.find((w) => w.number === 5);
    expect(workspace5?.windows).toEqual([]);
    expect(workspace5?.hasHadContent).toBe(false);

    workspaceStore.switchTo(1);

    expect(workspaceStore.workspaces.map((w) => w.number)).toEqual([1]);
  });

  it("ignores direct switches to missing workspaces", async () => {
    const { workspaceStore } = await setup();

    workspaceStore.switchTo(7);

    expect(workspaceStore.activeNumber).toBe(1);
    expect(workspaceStore.workspaces.map((w) => w.number)).toEqual([1]);
  });

  it("resets workspace lifetime state for a new auth session", async () => {
    const { threadStore, workspaceStore } = await setup();

    workspaceStore.switchOrCreate(3);
    threadStore.setActiveThread("thread-3");

    workspaceStore.reset();

    expect(workspaceStore.activeNumber).toBe(1);
    expect(workspaceStore.workspaces).toEqual([
      {
        number: 1,
        windows: [],
        focusedWindowId: null,
        splitDirection: "row",
        layout: null,
        hasHadContent: false,
        needsAttention: false,
      },
    ]);
  });

  it("flags an inactive workspace when its thread transitions running -> idle", async () => {
    const { threadModule, threadStore, workspaceStore } = await setup();

    // Bind a thread to ws2 then leave for ws1.
    workspaceStore.addWorkspace();
    threadStore.setActiveThread("agent-thread");
    workspaceStore.switchTo(1);

    const ws2 = () => workspaceStore.workspaces.find((w) => w.number === 2);
    expect(ws2()?.needsAttention).toBe(false);

    // Agent starts a turn, then finishes - the dot should appear on
    // ws2 because the user is currently looking at ws1.
    threadModule.__setMockThreadStatus("agent-thread", "running");
    expect(ws2()?.needsAttention).toBe(false);

    threadModule.__setMockThreadStatus("agent-thread", "idle");
    expect(ws2()?.needsAttention).toBe(true);
  });

  it("does not flag the active workspace when its thread settles", async () => {
    const { threadModule, threadStore, workspaceStore } = await setup();

    threadStore.setActiveThread("agent-thread");
    threadModule.__setMockThreadStatus("agent-thread", "running");
    threadModule.__setMockThreadStatus("agent-thread", "idle");

    expect(workspaceStore.activeWorkspace.needsAttention).toBe(false);
  });

  it("clears needsAttention when the user switches into the workspace", async () => {
    const { threadModule, threadStore, workspaceStore } = await setup();

    workspaceStore.addWorkspace();
    threadStore.setActiveThread("agent-thread");
    workspaceStore.switchTo(1);
    threadModule.__setMockThreadStatus("agent-thread", "running");
    threadModule.__setMockThreadStatus("agent-thread", "idle");
    expect(
      workspaceStore.workspaces.find((w) => w.number === 2)?.needsAttention,
    ).toBe(true);

    workspaceStore.switchTo(2);

    expect(workspaceStore.activeWorkspace.needsAttention).toBe(false);
  });

  it("splits the focused pane and inserts an empty placeholder", async () => {
    const { threadStore, workspaceStore } = await setup();

    threadStore.setActiveThread("thread-1");
    expect(workspaceStore.activeWorkspace.windows).toHaveLength(1);

    workspaceStore.splitFocusedPane("row");

    const ws = workspaceStore.activeWorkspace;
    expect(ws.splitDirection).toBe("row");
    expect(ws.windows).toHaveLength(2);
    expect(ws.windows[0].threadId).toBe("thread-1");
    expect(ws.windows[1].threadId).toBeNull();
    expect(ws.focusedWindowId).toBe(ws.windows[1].id);
    expect(threadStore.activeThreadId).toBeNull();
  });

  it("keeps a split placeholder focused across workspace switches", async () => {
    const { threadStore, workspaceStore } = await setup();

    threadStore.setActiveThread("thread-1");
    workspaceStore.splitFocusedPane("row");
    const placeholderId = workspaceStore.activeWorkspace.windows[1].id;

    workspaceStore.switchOrCreate(2);
    workspaceStore.switchTo(1);

    expect(workspaceStore.activeWorkspace.focusedWindowId).toBe(placeholderId);
    expect(workspaceStore.activeWindow?.threadId).toBeNull();
  });

  it("fills a focused empty placeholder when a thread is selected", async () => {
    const { threadStore, workspaceStore } = await setup();

    threadStore.setActiveThread("thread-1");
    workspaceStore.splitFocusedPane("row");
    threadStore.setActiveThread("thread-2");

    const ws = workspaceStore.activeWorkspace;
    expect(ws.windows.map((w) => w.threadId)).toEqual([
      "thread-1",
      "thread-2",
    ]);
  });

  it("refocuses an existing pane instead of binding the same thread twice", async () => {
    const { threadStore, workspaceStore } = await setup();

    threadStore.setActiveThread("thread-1");
    workspaceStore.splitFocusedPane("row");
    threadStore.setActiveThread("thread-2");

    const beforeWindowCount = workspaceStore.activeWorkspace.windows.length;
    workspaceStore.focusWindow(workspaceStore.activeWorkspace.windows[1].id);
    threadStore.setActiveThread("thread-1");

    const ws = workspaceStore.activeWorkspace;
    expect(ws.windows).toHaveLength(beforeWindowCount);
    expect(ws.focusedWindowId).toBe(ws.windows[0].id);
  });

  it("reuses an existing non-editor pane when selecting a thread from an editor-focused workspace", async () => {
    const { threadStore, workspaceStore } = await setup();

    threadStore.setActiveThread("thread-1");
    workspaceStore.bindEditorToWorkspace("/tmp/skill/SKILL.md");
    expect(workspaceStore.activeWindow?.kind).toBe("editor");

    threadStore.setActiveThread("thread-2");

    const ws = workspaceStore.activeWorkspace;
    const threadPanes = ws.windows.filter((w) => w.kind !== "editor");
    expect(ws.windows).toHaveLength(2);
    expect(ws.windows.filter((w) => w.kind === "editor")).toHaveLength(1);
    expect(threadPanes).toHaveLength(1);
    expect(threadPanes[0]).toMatchObject({
      threadId: "thread-2",
      kind: "chat",
    });
    expect(ws.focusedWindowId).toBe(threadPanes[0].id);
  });

  it("focuses the active workspace pane when given a singleton pane id from another workspace", async () => {
    const { threadStore, workspaceStore } = await setup();

    threadStore.setActiveThread("shared-thread");
    const workspace1SharedPaneId = workspaceStore.activeWorkspace.windows[0].id;

    workspaceStore.switchOrCreate(2);
    threadStore.setActiveThread("other-thread");
    workspaceStore.splitFocusedPane("row");
    threadStore.setActiveThread("shared-thread");

    const workspace2 = workspaceStore.activeWorkspace;
    const workspace2OtherPaneId = workspace2.windows[0].id;
    const workspace2SharedPaneId = workspace2.windows[1].id;
    workspaceStore.focusWindow(workspace2OtherPaneId);
    expect(workspaceStore.activeWorkspace.focusedWindowId).toBe(
      workspace2OtherPaneId,
    );

    workspaceStore.focusWindow(workspace1SharedPaneId);

    expect(workspaceStore.activeWorkspace.focusedWindowId).toBe(
      workspace2SharedPaneId,
    );
    expect(threadStore.activeThreadId).toBe("shared-thread");
  });

  it("binds a dropped thread directly to the target placeholder", async () => {
    const { threadModule, threadStore, workspaceStore } = await setup();

    threadStore.setActiveThread("thread-1");
    workspaceStore.splitFocusedPane("row");
    const placeholderId = workspaceStore.activeWorkspace.focusedWindowId;
    expect(placeholderId).not.toBeNull();
    const beforeWindowCount = workspaceStore.activeWorkspace.windows.length;

    threadModule.__addMockThread("thread-2", "agent");
    vi.mocked(threadStore.selectThread).mockClear();
    workspaceStore.bindThreadToWindow(placeholderId ?? "", "thread-2");

    const ws = workspaceStore.activeWorkspace;
    expect(ws.windows).toHaveLength(beforeWindowCount);
    const target = ws.windows.find((w) => w.id === placeholderId);
    expect(target?.threadId).toBe("thread-2");
    expect(target?.kind).toBe("agent");
    expect(ws.focusedWindowId).toBe(placeholderId);
    expect(ws.hasHadContent).toBe(true);
    expect(threadStore.selectThread).toHaveBeenCalledWith("thread-2", "agent");
    expect(threadStore.activeThreadId).toBe("thread-2");
  });

  it("rejects a drop whose thread does not exist in the thread store", async () => {
    const { threadStore, workspaceStore } = await setup();

    threadStore.setActiveThread("thread-1");
    workspaceStore.splitFocusedPane("row");
    const placeholderId = workspaceStore.activeWorkspace.focusedWindowId;
    expect(placeholderId).not.toBeNull();
    vi.mocked(threadStore.selectThread).mockClear();

    workspaceStore.bindThreadToWindow(placeholderId ?? "", "ghost-thread");

    const ws = workspaceStore.activeWorkspace;
    const target = ws.windows.find((w) => w.id === placeholderId);
    expect(target?.threadId).toBeNull();
    expect(target?.kind).toBeNull();
    expect(threadStore.selectThread).not.toHaveBeenCalled();
  });

  it("does not rewrite the target row when dropping a thread onto its own pane", async () => {
    const { threadStore, workspaceStore } = await setup();

    threadStore.setActiveThread("thread-1");
    const targetWindow = workspaceStore.activeWorkspace.windows[0];
    const beforeRowRef = workspaceStore.activeWorkspace.windows[0];
    vi.mocked(threadStore.selectThread).mockClear();

    workspaceStore.bindThreadToWindow(targetWindow.id, "thread-1");

    // Same row identity preserved so ThreadContent's wrapperCache does
    // not invalidate and remount the pane.
    expect(workspaceStore.activeWorkspace.windows[0]).toBe(beforeRowRef);
    expect(workspaceStore.activeWorkspace.windows[0].threadId).toBe("thread-1");
    expect(threadStore.selectThread).toHaveBeenCalledWith("thread-1", "chat");
  });

  it("snaps focus to the existing pane when the dropped thread is already in this workspace", async () => {
    const { threadStore, workspaceStore } = await setup();

    threadStore.setActiveThread("thread-1");
    workspaceStore.splitFocusedPane("row");
    const placeholderId = workspaceStore.activeWorkspace.focusedWindowId;
    const existingPaneId = workspaceStore.activeWorkspace.windows[0].id;
    vi.mocked(threadStore.selectThread).mockClear();

    workspaceStore.bindThreadToWindow(placeholderId ?? "", "thread-1");

    const ws = workspaceStore.activeWorkspace;
    expect(ws.focusedWindowId).toBe(existingPaneId);
    const placeholder = ws.windows.find((w) => w.id === placeholderId);
    expect(placeholder?.threadId).toBeNull();
    expect(threadStore.selectThread).toHaveBeenCalledWith("thread-1", "chat");
  });

  it("closes the focused pane and shifts focus to a sibling", async () => {
    const { threadStore, workspaceStore } = await setup();

    threadStore.setActiveThread("thread-1");
    workspaceStore.splitFocusedPane("row");
    threadStore.setActiveThread("thread-2");

    workspaceStore.closeFocusedWindow();

    const ws = workspaceStore.activeWorkspace;
    expect(ws.windows.map((w) => w.threadId)).toEqual(["thread-1"]);
    expect(ws.focusedWindowId).toBe(ws.windows[0].id);
  });

  it("closes a non-focused pane by id without killing the active thread", async () => {
    const { threadStore, workspaceStore } = await setup();

    threadStore.setActiveThread("thread-1");
    workspaceStore.splitFocusedPane("row");
    threadStore.setActiveThread("thread-2");

    const wsBefore = workspaceStore.activeWorkspace;
    const inactivePane = wsBefore.windows.find((w) => w.threadId === "thread-1");
    const focusedPane = wsBefore.windows.find((w) => w.threadId === "thread-2");
    expect(inactivePane).toBeDefined();
    expect(focusedPane).toBeDefined();
    expect(wsBefore.focusedWindowId).toBe(focusedPane?.id);

    workspaceStore.closeWindow(inactivePane?.id ?? "");

    const ws = workspaceStore.activeWorkspace;
    expect(ws.windows.map((w) => w.threadId)).toEqual(["thread-2"]);
    expect(ws.focusedWindowId).toBe(focusedPane?.id);
    expect(threadStore.activeThreadId).toBe("thread-2");
  });

  it("clears the active thread when the last pane closes", async () => {
    const { threadStore, workspaceStore } = await setup();

    threadStore.setActiveThread("thread-1");
    workspaceStore.closeFocusedWindow();

    expect(workspaceStore.activeWorkspace.windows).toEqual([]);
    expect(threadStore.setActiveThread).toHaveBeenLastCalledWith(null);
  });

  it("resizes panes by id", async () => {
    const { threadStore, workspaceStore } = await setup();

    threadStore.setActiveThread("thread-1");
    workspaceStore.splitFocusedPane("row");
    const [a, b] = workspaceStore.activeWorkspace.windows;
    workspaceStore.resizePanes([
      { id: a.id, size: 0.7 },
      { id: b.id, size: 0.3 },
    ]);

    const ws = workspaceStore.activeWorkspace;
    expect(ws.windows[0].size).toBeCloseTo(0.7);
    expect(ws.windows[1].size).toBeCloseTo(0.3);
  });

  it("creates a placeholder when splitting an empty workspace", async () => {
    const { workspaceStore } = await setup();

    workspaceStore.splitFocusedPane("column");

    const ws = workspaceStore.activeWorkspace;
    expect(ws.windows).toHaveLength(1);
    expect(ws.windows[0].threadId).toBeNull();
    expect(ws.windows[0].kind).toBeNull();
    expect(ws.focusedWindowId).toBe(ws.windows[0].id);
    expect(ws.splitDirection).toBe("column");
  });

  it("closes a focused placeholder without touching sibling threads", async () => {
    const { threadStore, workspaceStore } = await setup();

    threadStore.setActiveThread("thread-1");
    workspaceStore.splitFocusedPane("row");
    expect(workspaceStore.activeWorkspace.windows).toHaveLength(2);
    const placeholder = workspaceStore.activeWorkspace.windows.find(
      (w) => w.id === workspaceStore.activeWorkspace.focusedWindowId,
    );
    expect(placeholder?.threadId).toBeNull();

    workspaceStore.closeFocusedWindow();

    const ws = workspaceStore.activeWorkspace;
    expect(ws.windows).toHaveLength(1);
    expect(ws.windows[0].threadId).toBe("thread-1");
    expect(ws.focusedWindowId).toBe(ws.windows[0].id);
    expect(threadStore.activeThreadId).toBe("thread-1");
  });

  it("nests opposite-direction splits instead of reflowing the root", async () => {
    const { threadStore, workspaceStore } = await setup();

    threadStore.setActiveThread("thread-1");
    workspaceStore.splitFocusedPane("row");
    threadStore.setActiveThread("thread-2");
    expect(workspaceStore.activeWorkspace.splitDirection).toBe("row");

    workspaceStore.splitFocusedPane("column");
    expect(workspaceStore.activeWorkspace.windows).toHaveLength(3);
    expect(workspaceStore.activeWorkspace.splitDirection).toBe("row");
    expect(workspaceStore.activeWorkspace.layout).toMatchObject({
      type: "split",
      direction: "row",
      children: [
        {
          type: "pane",
          windowId: workspaceStore.activeWorkspace.windows[0].id,
        },
        {
          type: "split",
          direction: "column",
          children: [
            {
              type: "pane",
              windowId: workspaceStore.activeWorkspace.windows[1].id,
            },
            {
              type: "pane",
              windowId: workspaceStore.activeWorkspace.windows[2].id,
            },
          ],
        },
      ],
    });
  });

  it("preserves placeholder focus when an unrelated thread is added", async () => {
    const { threadModule, threadStore, workspaceStore } = await setup();

    threadStore.setActiveThread("thread-1");
    workspaceStore.splitFocusedPane("row");
    const placeholder = workspaceStore.activeWorkspace.windows[1];
    expect(workspaceStore.activeWorkspace.focusedWindowId).toBe(placeholder.id);

    // A new thread arriving in the store (e.g. background session
    // creation) re-runs the mirror effect. Focus on the placeholder
    // must not snap back to the existing pane that holds thread-1.
    threadModule.__addMockThread("unrelated-thread", "agent");

    expect(workspaceStore.activeWorkspace.focusedWindowId).toBe(placeholder.id);
  });

  it("opens the editor as a workspace pane without replacing the thread pane", async () => {
    const { threadStore, workspaceStore } = await setup();

    threadStore.setActiveThread("thread-1");
    workspaceStore.bindEditorToWorkspace("/tmp/skill/SKILL.md");

    const ws = workspaceStore.activeWorkspace;
    expect(ws.windows).toHaveLength(2);
    expect(ws.windows[0]).toMatchObject({
      threadId: "thread-1",
      kind: "chat",
    });
    expect(ws.windows[1]).toMatchObject({
      threadId: null,
      kind: "editor",
      editorFilePath: "/tmp/skill/SKILL.md",
    });
    expect(ws.focusedWindowId).toBe(ws.windows[1].id);
    expect(threadStore.activeThreadId).toBeNull();
  });

  it("reuses the existing editor pane in a workspace", async () => {
    const { workspaceStore } = await setup();

    workspaceStore.bindEditorToWorkspace("/tmp/skill-a/SKILL.md");
    const editorId = workspaceStore.activeWorkspace.windows[0].id;

    workspaceStore.bindEditorToWorkspace("/tmp/skill-b/SKILL.md");

    const ws = workspaceStore.activeWorkspace;
    expect(ws.windows).toHaveLength(1);
    expect(ws.windows[0]).toMatchObject({
      id: editorId,
      kind: "editor",
      editorFilePath: "/tmp/skill-b/SKILL.md",
    });
    expect(ws.focusedWindowId).toBe(editorId);
  });

  it("fills the focused placeholder with an editor and preserves pane size", async () => {
    const { workspaceStore } = await setup();

    workspaceStore.splitFocusedPane("row");
    const placeholder = workspaceStore.activeWorkspace.windows[0];
    workspaceStore.resizePanes([{ id: placeholder.id, size: 0.4 }]);

    workspaceStore.bindEditorToWorkspace("/tmp/skill/SKILL.md");

    const ws = workspaceStore.activeWorkspace;
    expect(ws.windows).toHaveLength(1);
    expect(ws.windows[0]).toMatchObject({
      id: placeholder.id,
      threadId: null,
      kind: "editor",
      editorFilePath: "/tmp/skill/SKILL.md",
    });
    expect(ws.windows[0].size).toBeCloseTo(0.4);
  });

  it("closing an editor pane does not auto-shift the active thread", async () => {
    const { threadModule, threadStore, workspaceStore } = await setup();

    // User is editing a skill: editor session is the active thread.
    threadStore.setActiveThread("thread-1");
    workspaceStore.bindEditorToWorkspace("/skill/SKILL.md");
    threadModule.__addMockThread("editor:/skill", "editor");
    threadStore.setActiveThread("editor:/skill");
    expect(threadStore.activeThreadId).toBe("editor:/skill");

    // Workspace looks like [chat thread-1, editor (focused)].
    // Closing the editor pane must NOT yank focus back to thread-1; the
    // user just finished editing the skill and shouldn't lose its
    // currentRoot bubble-to-top in the sidebar.
    workspaceStore.closeFocusedWindow();

    expect(threadStore.activeThreadId).toBe("editor:/skill");
  });

  it("does not replace a focused editor pane when a thread is selected", async () => {
    const { threadStore, workspaceStore } = await setup();

    workspaceStore.bindEditorToWorkspace("/tmp/skill/SKILL.md");
    expect(workspaceStore.activeWorkspace.windows).toHaveLength(1);
    expect(workspaceStore.activeWorkspace.windows[0].kind).toBe("editor");

    threadStore.setActiveThread("thread-1");

    const ws = workspaceStore.activeWorkspace;
    expect(ws.windows).toHaveLength(2);
    const editor = ws.windows.find((w) => w.kind === "editor");
    const thread = ws.windows.find((w) => w.threadId === "thread-1");
    expect(editor).toBeDefined();
    expect(editor?.editorFilePath).toBe("/tmp/skill/SKILL.md");
    expect(thread).toBeDefined();
    expect(thread?.kind).toBe("chat");
    expect(ws.focusedWindowId).toBe(thread?.id);
  });

  it("dragging an editor session onto a chat pane converts that pane into the editor", async () => {
    const { threadModule, threadStore, workspaceStore } = await setup();

    threadStore.setActiveThread("thread-1");
    expect(workspaceStore.activeWorkspace.windows[0].kind).toBe("chat");
    const chatPaneId = workspaceStore.activeWorkspace.windows[0].id;

    threadModule.__addMockThread("editor:/skill", "editor");
    workspaceStore.bindThreadToWindow(chatPaneId, "editor:/skill");

    const ws = workspaceStore.activeWorkspace;
    expect(ws.windows).toHaveLength(1);
    expect(ws.windows[0]).toMatchObject({
      id: chatPaneId,
      kind: "editor",
      threadId: null,
    });
    expect(ws.focusedWindowId).toBe(chatPaneId);
  });

  it("dragging an editor session refocuses the existing editor pane instead of duplicating", async () => {
    const { threadModule, threadStore, workspaceStore } = await setup();

    threadStore.setActiveThread("thread-1");
    workspaceStore.bindEditorToWorkspace("/skill/SKILL.md");
    const ws0 = workspaceStore.activeWorkspace;
    expect(ws0.windows.filter((w) => w.kind === "editor")).toHaveLength(1);
    const editorPaneId =
      ws0.windows.find((w) => w.kind === "editor")?.id ?? "";
    const chatPaneId =
      ws0.windows.find((w) => w.kind === "chat")?.id ?? "";

    // Now drag a second editor session onto the chat pane. The single-editor
    // invariant must hold: no second editor pane is created; the existing one
    // is refocused.
    threadModule.__addMockThread("editor:/other", "editor");
    workspaceStore.bindThreadToWindow(chatPaneId, "editor:/other");

    const ws = workspaceStore.activeWorkspace;
    expect(ws.windows.filter((w) => w.kind === "editor")).toHaveLength(1);
    expect(ws.windows.some((w) => w.kind === "chat")).toBe(true);
    expect(ws.focusedWindowId).toBe(editorPaneId);
  });

  it("routes editor-kind thread selection through bindEditorToWorkspace", async () => {
    const { threadModule, threadStore, workspaceStore } = await setup();

    // Existing chat thread; user is focused on it.
    threadStore.setActiveThread("thread-1");
    expect(workspaceStore.activeWorkspace.windows[0].kind).toBe("chat");

    // Now an editor session appears in the thread store and the user clicks it.
    threadModule.__addMockThread("editor:/skill", "editor");
    threadStore.setActiveThread("editor:/skill");

    // The editor opens in a new pane next to the chat - it does NOT replace
    // the chat pane and it does NOT use the kind="chat" replace path.
    const ws = workspaceStore.activeWorkspace;
    expect(ws.windows.some((w) => w.kind === "chat")).toBe(true);
    expect(ws.windows.some((w) => w.kind === "editor")).toBe(true);
  });

  it("clears the active thread when switching back to an editor-focused workspace", async () => {
    const { threadStore, workspaceStore } = await setup();

    threadStore.setActiveThread("thread-1");
    workspaceStore.bindEditorToWorkspace("/tmp/skill/SKILL.md");
    workspaceStore.switchOrCreate(2);
    threadStore.setActiveThread("thread-2");

    workspaceStore.switchTo(1);

    expect(workspaceStore.activeWindow).toMatchObject({
      kind: "editor",
      threadId: null,
    });
    expect(threadStore.activeThreadId).toBeNull();
  });
});
