'use client';

import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import {
  FileCode,
  GitCompare,
  Loader2,
  MessagesSquare,
  PanelLeft,
  SquareTerminal,
} from 'lucide-react';
import { toast } from 'sonner';

import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
  Button,
  cn,
  Sheet,
  SheetContent,
  SheetHeader,
  SheetTitle,
  Tabs,
  TabsContent,
  TabsList,
  TabsTrigger,
} from '@spoon/ui';

import type { DiffResponse, FileResponse, FileTreeNode } from './types';
import { AgentThread } from './agent-thread';
import { CodeEditor } from './code-editor';
import { CommandPanel } from './command-panel';
import { DiffViewer } from './diff-viewer';
import { FileTabs } from './file-tabs';
import { FileTree } from './file-tree';
import { JobStatusBar } from './job-status-bar';
import { WorkspaceActions } from './workspace-actions';
import { WorkspaceTerminal } from './workspace-terminal';
import { createRequestSequencer, shouldShowRecovery } from './workspace-helpers';

type WorkspaceTab = 'editor' | 'diff' | 'thread' | 'terminal';

type OpenFileState = {
  path: string;
  content: string;
  savedContent: string;
  loading: boolean;
  saving: boolean;
  error?: string;
  // Set when the agent edited this file on disk while the user had unsaved
  // local edits, so the UI can warn instead of silently stomping either side.
  conflicted?: boolean;
};

type PendingOverwrite = {
  path: string;
  content: string;
};

export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
  const job = useQuery(api.agentJobs.get, { jobId });
  const messages =
    useQuery(
      api.agentJobs.listMessages,
      job ? { jobId, limit: 200 } : 'skip',
    ) ?? [];
  const events =
    useQuery(api.agentJobs.listEvents, job ? { jobId, limit: 200 } : 'skip') ??
    [];
  const workspaceChanges =
    useQuery(
      api.agentJobs.listWorkspaceChanges,
      job ? { jobId, limit: 200 } : 'skip',
    ) ?? [];
  const interactions =
    useQuery(
      api.agentJobs.listInteractionRequests,
      job ? { jobId, status: 'all' } : 'skip',
    ) ?? [];
  const uiState = useQuery(
    api.agentJobs.getWorkspaceUiState,
    job ? { jobId } : 'skip',
  );
  const patchUiState = useMutation(api.agentJobs.patchWorkspaceUiState);
  const createJobForThread = useMutation(api.agentJobs.createForThread);
  const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
  const markWorkspaceLost = useMutation(api.agentJobs.markWorkspaceLost);
  const [tree, setTree] = useState<FileTreeNode | null>(null);
  const [files, setFiles] = useState<Record<string, OpenFileState>>({});
  const [openFilePaths, setOpenFilePaths] = useState<string[]>([]);
  const [activeFilePath, setActiveFilePath] = useState<string>();
  const [expandedDirectoryPaths, setExpandedDirectoryPaths] = useState<
    string[]
  >([]);
  const [agentThreadWidth, setAgentThreadWidth] = useState(420);
  const [vimEnabled, setVimEnabled] = useState(false);
  const [hydratedUiState, setHydratedUiState] = useState(false);
  const [diff, setDiff] = useState('');
  const [focusedDiffPath, setFocusedDiffPath] = useState<string>();
  const [workspaceError, setWorkspaceError] = useState<string>();
  const [agentTurnActive, setAgentTurnActive] = useState(false);
  const [activeWorkspaceTab, setActiveWorkspaceTab] =
    useState<WorkspaceTab>('editor');
  const [pendingOverwrite, setPendingOverwrite] = useState<PendingOverwrite>();
  const [pendingClosePath, setPendingClosePath] = useState<string>();
  const [fileTreeOpen, setFileTreeOpen] = useState(false);

  // Per-loader sequence guards: only the latest response for each loader is
  // applied, so a slower earlier fetch can't overwrite newer tree/diff data.
  const treeSequencerRef = useRef<
    ReturnType<typeof createRequestSequencer> | undefined
  >(undefined);
  treeSequencerRef.current ??= createRequestSequencer();
  const treeSequencer = treeSequencerRef.current;
  const diffSequencerRef = useRef<
    ReturnType<typeof createRequestSequencer> | undefined
  >(undefined);
  diffSequencerRef.current ??= createRequestSequencer();
  const diffSequencer = diffSequencerRef.current;

  // Count consecutive workspace status/health failures. A single transient
  // 'workspace is not active' blip (worker restart) must not flash the big red
  // recovery panel — only surface it after RECOVERY_THRESHOLD in a row.
  const consecutiveFailuresRef = useRef(0);
  const processedChangeSignatureRef = useRef(0);
  const conflictToastPathsRef = useRef<Set<string>>(new Set());

  const recordWorkspaceSuccess = useCallback(() => {
    consecutiveFailuresRef.current = 0;
    setWorkspaceError(undefined);
  }, []);

  const recordWorkspaceFailure = useCallback((message: string) => {
    consecutiveFailuresRef.current += 1;
    if (shouldShowRecovery(consecutiveFailuresRef.current)) {
      setWorkspaceError(message);
    }
  }, []);

  const workspaceDisabled =
    !job ||
    ['draft_pr_opened', 'failed', 'cancelled', 'timed_out'].includes(
      job.status,
    ) ||
    ['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');

  // The worker only exposes the live runtime (tree/diff/file endpoints) once it
  // has claimed the job and materialized the workspace (workspaceStatus
  // 'active'/'idle'). Before that, hitting those endpoints returns "workspace is
  // not active" — which is expected startup, not an error.
  const workspaceReady = ['active', 'idle'].includes(
    job?.workspaceStatus ?? '',
  );
  const workspaceFailed =
    ['failed', 'cancelled', 'timed_out'].includes(job?.status ?? '') ||
    ['stopped', 'expired', 'failed'].includes(job?.workspaceStatus ?? '');
  // Waiting for a worker to pick up the job and start the runtime.
  const workspacePending =
    Boolean(job) &&
    !workspaceReady &&
    !workspaceFailed &&
    ['queued', 'claimed', 'preparing', 'running', 'checks_running'].includes(
      job?.status ?? '',
    );

  const loadTree = useCallback(async () => {
    const token = treeSequencer.next();
    const response = await fetch(`/api/agent-jobs/${jobId}/tree`);
    if (!response.ok) throw new Error(await response.text());
    const data = (await response.json()) as { tree: FileTreeNode | null };
    if (!treeSequencer.isLatest(token)) return;
    recordWorkspaceSuccess();
    setTree(data.tree);
  }, [jobId, recordWorkspaceSuccess, treeSequencer]);

  const loadDiff = useCallback(async () => {
    const token = diffSequencer.next();
    const response = await fetch(`/api/agent-jobs/${jobId}/diff`);
    if (!response.ok) throw new Error(await response.text());
    const data = (await response.json()) as DiffResponse;
    if (!diffSequencer.isLatest(token)) return;
    recordWorkspaceSuccess();
    setDiff(data.diff);
  }, [diffSequencer, jobId, recordWorkspaceSuccess]);

  const loadAgentStatus = useCallback(async () => {
    const response = await fetch(`/api/agent-jobs/${jobId}/agent/status`);
    if (!response.ok) {
      setAgentTurnActive(false);
      const body = await response.text();
      if (body.includes('workspace is not active')) {
        recordWorkspaceFailure(body);
      }
      return;
    }
    const data = (await response.json()) as { active?: boolean };
    recordWorkspaceSuccess();
    setAgentTurnActive(Boolean(data.active));
  }, [jobId, recordWorkspaceFailure, recordWorkspaceSuccess]);

  const loadFile = useCallback(
    async (path: string) => {
      setFiles((current) => ({
        ...current,
        [path]: current[path] ?? {
          path,
          content: '',
          savedContent: '',
          loading: true,
          saving: false,
        },
      }));
      const response = await fetch(
        `/api/agent-jobs/${jobId}/file?path=${encodeURIComponent(path)}`,
      );
      if (!response.ok) throw new Error(await response.text());
      const data = (await response.json()) as FileResponse;
      setFiles((current) => ({
        ...current,
        [data.path]: {
          path: data.path,
          content: data.content,
          savedContent: data.content,
          loading: false,
          saving: false,
        },
      }));
    },
    [jobId],
  );

  // Re-fetch an already-open file and replace its editor buffer. Used when the
  // agent edits a file that is currently open: if the local buffer is clean we
  // adopt the new on-disk content; if the user has unsaved edits we flag a
  // conflict instead of stomping their work.
  const refreshFileBuffer = useCallback(
    async (path: string) => {
      const response = await fetch(
        `/api/agent-jobs/${jobId}/file?path=${encodeURIComponent(path)}`,
      );
      if (!response.ok) return;
      const data = (await response.json()) as FileResponse;
      setFiles((current) => {
        const existing = current[path];
        if (!existing) return current;
        if (existing.content !== existing.savedContent) {
          return { ...current, [path]: { ...existing, conflicted: true } };
        }
        conflictToastPathsRef.current.delete(path);
        return {
          ...current,
          [path]: {
            path,
            content: data.content,
            savedContent: data.content,
            loading: false,
            saving: false,
            conflicted: false,
          },
        };
      });
    },
    [jobId],
  );

  const openFile = useCallback(
    (path: string) => {
      setOpenFilePaths((current) =>
        current.includes(path) ? current : [...current, path],
      );
      setActiveFilePath(path);
      if (!files[path]) {
        void loadFile(path).catch((error) => {
          console.error(error);
          setFiles((current) => {
            const next = { ...current };
            delete next[path];
            return next;
          });
          setOpenFilePaths((current) =>
            current.filter((filePath) => filePath !== path),
          );
          toast.error('Could not load file.');
        });
      }
    },
    [files, loadFile],
  );

  useEffect(() => {
    if (!workspaceReady) return;
    // Recovery is driven solely by the recurring status heartbeat
    // (loadAgentStatus) so a one-off tree/diff hiccup can't trip the panel.
    const handleError = (error: unknown) => {
      console.error(error);
    };
    const timeout = window.setTimeout(() => {
      void loadTree().catch(handleError);
      void loadDiff().catch(handleError);
      void loadAgentStatus();
    }, 0);
    return () => window.clearTimeout(timeout);
  }, [workspaceReady, loadAgentStatus, loadDiff, loadTree]);

  useEffect(() => {
    if (!workspaceReady) return;
    const interval = window.setInterval(() => {
      void loadAgentStatus();
    }, 5_000);
    return () => window.clearInterval(interval);
  }, [workspaceReady, loadAgentStatus]);

  // Surface a gentle "taking longer than usual" hint if a worker never picks the
  // job up (e.g. the worker is offline) instead of spinning forever.
  const [pendingTooLong, setPendingTooLong] = useState(false);
  useEffect(() => {
    if (!workspacePending) return;
    const timer = window.setTimeout(() => setPendingTooLong(true), 90_000);
    return () => {
      window.clearTimeout(timer);
      setPendingTooLong(false);
    };
  }, [workspacePending]);

  // Refresh the tree and diff whenever the agent records a workspace change
  // (file edit / tool call that touched files) or a turn starts/ends, so the
  // diff viewer stays current without a manual refresh. Rapid bursts of changes
  // debounce into a single reload via the timeout cleanup.
  const workspaceChangeSignature = workspaceChanges.reduce(
    (latest, change) => Math.max(latest, change._creationTime),
    0,
  );
  // Read the (freshly-allocated-each-render) changes array via a ref inside the
  // signature-gated effect below, so the effect's deps stay on the stable
  // numeric signature instead of the array identity.
  const workspaceChangesRef = useRef(workspaceChanges);
  workspaceChangesRef.current = workspaceChanges;
  useEffect(() => {
    if (!workspaceReady) return;
    const timeout = window.setTimeout(() => {
      void loadDiff().catch(() => undefined);
      void loadTree().catch(() => undefined);
    }, 200);
    return () => window.clearTimeout(timeout);
  }, [
    workspaceChangeSignature,
    agentTurnActive,
    workspaceReady,
    loadDiff,
    loadTree,
  ]);

  // When the agent records a workspace change that touches a currently OPEN
  // file, refresh that file's editor buffer (clean buffers adopt the new
  // content; dirty buffers get a conflict flag + one-time toast) so the editor
  // doesn't keep showing pre-edit content.
  useEffect(() => {
    if (!workspaceReady) return;
    if (workspaceChangeSignature <= processedChangeSignatureRef.current) return;
    const previousSignature = processedChangeSignatureRef.current;
    processedChangeSignatureRef.current = workspaceChangeSignature;
    const changedPaths = new Set(
      workspaceChangesRef.current
        .filter((change) => change._creationTime > previousSignature)
        .map((change) => change.path),
    );
    for (const path of openFilePaths) {
      if (!changedPaths.has(path)) continue;
      const file = files[path];
      if (!file) continue;
      if (file.content !== file.savedContent) {
        setFiles((current) =>
          current[path]
            ? { ...current, [path]: { ...current[path], conflicted: true } }
            : current,
        );
        if (!conflictToastPathsRef.current.has(path)) {
          conflictToastPathsRef.current.add(path);
          toast.warning(`${path} changed on disk`);
        }
      } else {
        void refreshFileBuffer(path).catch(() => undefined);
      }
    }
  }, [
    files,
    openFilePaths,
    refreshFileBuffer,
    workspaceChangeSignature,
    workspaceReady,
  ]);

  useEffect(() => {
    if (!uiState || hydratedUiState) return;
    const timeout = window.setTimeout(() => {
      setOpenFilePaths(uiState.openFilePaths);
      setActiveFilePath(uiState.activeFilePath);
      setExpandedDirectoryPaths(uiState.expandedDirectoryPaths);
      setAgentThreadWidth(uiState.agentThreadWidth ?? 420);
      setVimEnabled(uiState.vimEnabled);
      setHydratedUiState(true);
    }, 0);
    return () => window.clearTimeout(timeout);
  }, [hydratedUiState, uiState]);

  useEffect(() => {
    if (!hydratedUiState) return;
    const timeout = window.setTimeout(() => {
      void patchUiState({
        jobId,
        openFilePaths,
        activeFilePath,
        vimEnabled,
        expandedDirectoryPaths,
        agentThreadWidth,
      }).catch((error: unknown) => {
        console.error(error);
      });
    }, 400);
    return () => window.clearTimeout(timeout);
  }, [
    activeFilePath,
    expandedDirectoryPaths,
    agentThreadWidth,
    hydratedUiState,
    jobId,
    openFilePaths,
    patchUiState,
    vimEnabled,
  ]);

  useEffect(() => {
    if (!hydratedUiState) return;
    const timeout = window.setTimeout(() => {
      for (const path of openFilePaths) {
        if (!files[path]) {
          void loadFile(path).catch((error) => {
            console.error(error);
          });
        }
      }
    }, 0);
    return () => window.clearTimeout(timeout);
  }, [files, hydratedUiState, loadFile, openFilePaths]);

  if (job === undefined) {
    return (
      <main className='text-muted-foreground p-6'>Loading workspace...</main>
    );
  }

  if (job === null) {
    return (
      <main className='text-muted-foreground p-6'>
        This workspace was not found.
      </main>
    );
  }

  const activeFile = activeFilePath ? files[activeFilePath] : undefined;
  const recoverWorkspace = async () => {
    if (!job.threadId) return;
    await createJobForThread({
      threadId: job.threadId,
      jobType: job.jobType ?? 'user_change',
    });
    window.location.href = `/threads/${job.threadId}`;
  };

  const deleteStaleWorkspace = async () => {
    await markWorkspaceLost({ jobId });
    await deleteWorkspace({ jobId });
    window.location.href = job.threadId
      ? `/threads/${job.threadId}`
      : `/spoons/${job.spoonId}`;
  };

  const writeFileContent = async (path: string, content: string) => {
    setFiles((current) => ({
      ...current,
      [path]: {
        ...(current[path] ?? {
          path,
          savedContent: '',
          loading: false,
        }),
        content,
        saving: true,
      },
    }));
    const response = await fetch(`/api/agent-jobs/${jobId}/file`, {
      method: 'PUT',
      body: JSON.stringify({ path, content }),
    });
    if (!response.ok) {
      toast.error('Could not save file.');
      setFiles((current) => ({
        ...current,
        [path]: {
          ...(current[path] ?? {
            path,
            content,
            savedContent: '',
            loading: false,
          }),
          saving: false,
        },
      }));
      throw new Error(await response.text());
    }
    conflictToastPathsRef.current.delete(path);
    setFiles((current) => ({
      ...current,
      [path]: {
        ...(current[path] ?? {
          path,
          loading: false,
        }),
        content,
        savedContent: content,
        saving: false,
        conflicted: false,
      },
    }));
    await loadDiff();
    toast.success('File saved.');
  };

  const saveFile = async (content: string) => {
    if (!activeFilePath) return;
    const path = activeFilePath;
    const activeFileBeforeSave = files[path];
    if (activeFileBeforeSave) {
      const latestResponse = await fetch(
        `/api/agent-jobs/${jobId}/file?path=${encodeURIComponent(path)}`,
      );
      if (latestResponse.ok) {
        const latestData = (await latestResponse.json()) as FileResponse;
        if (latestData.content !== activeFileBeforeSave.savedContent) {
          setPendingOverwrite({
            path,
            content,
          });
          return;
        }
      }
    }
    await writeFileContent(path, content);
  };

  const closeFileUnchecked = (path: string) => {
    const index = openFilePaths.indexOf(path);
    const nextOpen = openFilePaths.filter((filePath) => filePath !== path);
    setOpenFilePaths(nextOpen);
    setFiles((current) => {
      const next = { ...current };
      delete next[path];
      return next;
    });
    if (activeFilePath === path) {
      setActiveFilePath(nextOpen[index - 1] ?? nextOpen[index] ?? undefined);
    }
  };

  const closeFile = (path: string) => {
    const file = files[path];
    if (file && file.content !== file.savedContent) {
      setPendingClosePath(path);
      return;
    }
    closeFileUnchecked(path);
  };

  const toggleDirectory = (path: string) => {
    setExpandedDirectoryPaths((current) =>
      current.includes(path)
        ? current.filter((directoryPath) => directoryPath !== path)
        : [...current, path],
    );
  };

  const openFileFromActivity = (path: string) => {
    openFile(path);
    setActiveWorkspaceTab('editor');
  };

  const openDiffFromActivity = (path: string) => {
    setFocusedDiffPath(path);
    setActiveWorkspaceTab('diff');
  };

  const resizeAgentThread = (event: ReactPointerEvent<HTMLDivElement>) => {
    event.preventDefault();
    const startX = event.clientX;
    const startWidth = agentThreadWidth;
    const move = (moveEvent: PointerEvent) => {
      const nextWidth = Math.min(
        Math.max(startWidth - (moveEvent.clientX - startX), 320),
        720,
      );
      setAgentThreadWidth(Math.round(nextWidth));
    };
    const up = () => {
      window.removeEventListener('pointermove', move);
      window.removeEventListener('pointerup', up);
    };
    window.addEventListener('pointermove', move);
    window.addEventListener('pointerup', up);
  };

  return (
    <main className='border-border bg-muted/20 flex h-[calc(100dvh-8.5rem)] min-h-[70dvh] flex-col overflow-hidden rounded-md border lg:min-h-[720px]'>
      <JobStatusBar job={job} />
      {workspacePending && !workspaceError ? (
        <div className='border-border bg-background border-b p-4'>
          <div
            className={`flex items-center gap-3 rounded-md border p-4 ${
              pendingTooLong
                ? 'border-amber-500/40 bg-amber-500/5'
                : 'border-border bg-muted/30'
            }`}
          >
            <Loader2 className='text-muted-foreground size-5 flex-none animate-spin' />
            <div>
              <p className='font-medium'>
                {pendingTooLong
                  ? 'Still waiting for a worker…'
                  : 'Setting up your workspace…'}
              </p>
              <p className='text-muted-foreground text-sm'>
                {pendingTooLong
                  ? 'This is taking longer than usual — the worker may be busy or offline. It will start automatically once a worker is available.'
                  : 'Waiting for a worker to pick up this job. Files and diffs will appear automatically once the agent starts.'}
              </p>
            </div>
          </div>
        </div>
      ) : null}
      {workspaceError ? (
        <div className='border-border bg-background border-b p-4'>
          <div className='border-destructive/40 bg-destructive/5 rounded-md border p-4'>
            <p className='font-medium'>Thread workspace needs recovery</p>
            <p className='text-muted-foreground mt-1 text-sm'>
              The saved workspace record exists, but this worker cannot reach
              its active runtime. This usually happens after a worker restart or
              local container cleanup.
            </p>
            <p className='text-muted-foreground mt-2 text-xs break-all'>
              {workspaceError}
            </p>
            <div className='mt-3 flex flex-wrap gap-2'>
              {job.threadId ? (
                <Button type='button' onClick={() => void recoverWorkspace()}>
                  Start a fresh run
                </Button>
              ) : null}
              <AlertDialog>
                <AlertDialogTrigger asChild>
                  <Button type='button' variant='outline'>
                    Delete stale record
                  </Button>
                </AlertDialogTrigger>
                <AlertDialogContent>
                  <AlertDialogHeader>
                    <AlertDialogTitle>
                      Delete this stale workspace record?
                    </AlertDialogTitle>
                    <AlertDialogDescription>
                      This marks the unreachable workspace as failed and removes
                      its stored messages, events, artifacts, diffs, and UI
                      state. The thread itself is kept.
                    </AlertDialogDescription>
                  </AlertDialogHeader>
                  <AlertDialogFooter>
                    <AlertDialogCancel>Keep record</AlertDialogCancel>
                    <AlertDialogAction
                      variant='destructive'
                      onClick={() => void deleteStaleWorkspace()}
                    >
                      Delete stale record
                    </AlertDialogAction>
                  </AlertDialogFooter>
                </AlertDialogContent>
              </AlertDialog>
              {job.threadId ? (
                <Button type='button' variant='outline' asChild>
                  <a href={`/threads/${job.threadId}`}>Open thread</a>
                </Button>
              ) : null}
            </div>
          </div>
        </div>
      ) : null}
      <div className='border-border bg-background flex items-center justify-between gap-2 border-b px-4 py-2'>
        <Button
          type='button'
          variant='outline'
          size='sm'
          className='lg:hidden'
          onClick={() => setFileTreeOpen(true)}
        >
          <PanelLeft className='size-4' />
          Files
        </Button>
        <div className='flex flex-1 items-center justify-end'>
          <WorkspaceActions job={job} disabled={workspaceDisabled} />
        </div>
      </div>
      <Sheet open={fileTreeOpen} onOpenChange={setFileTreeOpen}>
        <SheetContent side='left' className='w-[300px] p-0 lg:hidden'>
          <SheetHeader className='border-border border-b'>
            <SheetTitle>Files</SheetTitle>
          </SheetHeader>
          <div className='min-h-0 flex-1 overflow-y-auto'>
            <FileTree
              tree={tree}
              selectedPath={activeFilePath}
              expandedPaths={expandedDirectoryPaths}
              onSelect={(path) => {
                openFile(path);
                setFileTreeOpen(false);
              }}
              onToggleDirectory={toggleDirectory}
            />
          </div>
        </SheetContent>
      </Sheet>
      <div
        className='grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] 2xl:grid-cols-[300px_minmax(0,1fr)_6px_var(--agent-thread-width)]'
        style={
          {
            '--agent-thread-width': `${agentThreadWidth}px`,
          } as CSSProperties
        }
      >
        <aside className='border-border bg-background hidden min-h-0 border-r lg:block'>
          <div className='border-border border-b p-3'>
            <h2 className='text-sm font-semibold'>Files</h2>
            <p className='text-muted-foreground text-xs'>Current workspace</p>
          </div>
          <FileTree
            tree={tree}
            selectedPath={activeFilePath}
            expandedPaths={expandedDirectoryPaths}
            onSelect={openFile}
            onToggleDirectory={toggleDirectory}
          />
        </aside>
        <section className='bg-background flex min-w-0 flex-col overflow-hidden'>
          <Tabs
            value={activeWorkspaceTab}
            onValueChange={(value) =>
              setActiveWorkspaceTab(value as WorkspaceTab)
            }
            className='flex min-h-0 flex-1 flex-col'
          >
            <TabsList className='border-border bg-muted/30 h-12 flex-none justify-start rounded-none border-b px-3'>
              <TabsTrigger
                value='editor'
                className='data-active:bg-background data-active:text-foreground data-active:shadow-sm'
              >
                <FileCode className='size-4' />
                Editor
              </TabsTrigger>
              <TabsTrigger
                value='diff'
                className='data-active:bg-background data-active:text-foreground data-active:shadow-sm'
              >
                <GitCompare className='size-4' />
                Diff viewer
              </TabsTrigger>
              <TabsTrigger
                value='terminal'
                className='data-active:bg-background data-active:text-foreground data-active:shadow-sm'
              >
                <SquareTerminal className='size-4' />
                Terminal
              </TabsTrigger>
              <TabsTrigger
                value='thread'
                className='data-active:bg-background data-active:text-foreground data-active:shadow-sm 2xl:hidden'
              >
                <MessagesSquare className='size-4' />
                Thread
              </TabsTrigger>
            </TabsList>
            <TabsContent
              value='editor'
              className='m-0 flex min-h-0 flex-1 flex-col'
            >
              <FileTabs
                tabs={openFilePaths.map((path) => ({
                  path,
                  dirty: files[path]
                    ? files[path].content !== files[path].savedContent
                    : false,
                }))}
                activePath={activeFilePath}
                onActivate={setActiveFilePath}
                onClose={closeFile}
              />
              <CodeEditor
                path={activeFilePath}
                content={activeFile?.content ?? ''}
                savedContent={activeFile?.savedContent ?? ''}
                readOnly={workspaceDisabled}
                vimEnabled={vimEnabled}
                conflicted={activeFile?.conflicted}
                onSave={saveFile}
                onVimEnabledChange={setVimEnabled}
                onChange={(content) => {
                  if (!activeFilePath) return;
                  setFiles((current) => ({
                    ...current,
                    [activeFilePath]: {
                      ...(current[activeFilePath] ?? {
                        path: activeFilePath,
                        savedContent: '',
                        loading: false,
                        saving: false,
                      }),
                      content,
                    },
                  }));
                }}
              />
            </TabsContent>
            <TabsContent
              value='terminal'
              forceMount
              className={cn(
                'm-0 min-h-0 flex-1 overflow-hidden',
                activeWorkspaceTab !== 'terminal' && 'hidden',
              )}
            >
              <WorkspaceTerminal
                jobId={jobId}
                active={workspaceReady}
                visible={activeWorkspaceTab === 'terminal'}
                waitingLabel={
                  workspacePending ? 'Waiting for workspace…' : undefined
                }
              />
            </TabsContent>
            <TabsContent value='diff' className='m-0 min-h-0 flex-1'>
              <DiffViewer
                diff={diff}
                focusedPath={focusedDiffPath}
                onRefresh={loadDiff}
                onClearFocusedPath={() => setFocusedDiffPath(undefined)}
              />
            </TabsContent>
            <TabsContent
              value='thread'
              className='m-0 min-h-0 flex-1 overflow-hidden 2xl:hidden'
            >
              <AgentThread
                jobId={jobId}
                messages={messages}
                events={events}
                interactions={interactions}
                workspaceChanges={workspaceChanges}
                disabled={workspaceDisabled}
                agentTurnActive={agentTurnActive}
                onOpenFile={openFileFromActivity}
                onOpenDiff={openDiffFromActivity}
              />
            </TabsContent>
          </Tabs>
          <CommandPanel jobId={jobId} disabled={workspaceDisabled} />
        </section>
        <div
          role='separator'
          aria-label='Resize agent thread'
          aria-orientation='vertical'
          className='bg-border hover:bg-primary/50 hidden cursor-col-resize transition-colors 2xl:block'
          onPointerDown={resizeAgentThread}
        />
        <aside className='border-border bg-muted/20 hidden min-h-0 min-w-0 overflow-hidden border-l 2xl:block'>
          <AgentThread
            jobId={jobId}
            messages={messages}
            events={events}
            interactions={interactions}
            workspaceChanges={workspaceChanges}
            disabled={workspaceDisabled}
            agentTurnActive={agentTurnActive}
            onOpenFile={openFileFromActivity}
            onOpenDiff={openDiffFromActivity}
          />
        </aside>
      </div>
      <AlertDialog
        open={Boolean(pendingOverwrite)}
        onOpenChange={(open) => {
          if (!open) setPendingOverwrite(undefined);
        }}
      >
        <AlertDialogContent>
          <AlertDialogHeader>
            <AlertDialogTitle>
              Overwrite newer workspace changes?
            </AlertDialogTitle>
            <AlertDialogDescription>
              {pendingOverwrite?.path} changed after you opened it. Overwriting
              will replace the newer workspace contents with your editor
              contents.
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel>Keep editing</AlertDialogCancel>
            <AlertDialogAction
              variant='destructive'
              onClick={() => {
                const pending = pendingOverwrite;
                setPendingOverwrite(undefined);
                if (pending) {
                  void writeFileContent(pending.path, pending.content);
                }
              }}
            >
              Overwrite file
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
      <AlertDialog
        open={Boolean(pendingClosePath)}
        onOpenChange={(open) => {
          if (!open) setPendingClosePath(undefined);
        }}
      >
        <AlertDialogContent>
          <AlertDialogHeader>
            <AlertDialogTitle>Discard unsaved changes?</AlertDialogTitle>
            <AlertDialogDescription>
              {pendingClosePath} has unsaved changes. Closing this tab will
              discard the editor contents that have not been saved.
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel>Keep tab open</AlertDialogCancel>
            <AlertDialogAction
              variant='destructive'
              onClick={() => {
                const path = pendingClosePath;
                setPendingClosePath(undefined);
                if (path) closeFileUnchecked(path);
              }}
            >
              Discard and close
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
    </main>
  );
};
