/* Parley — the product's own routing and its commit machinery. Every action
   that changes anything goes through this file, so the reversibility tiers are
   decided in one place: harmless controls apply bare on their screens,
   consequential ones apply here and raise an undo toast, irreversible ones open
   a confirm dialog, and anything needing two fields opens a staged dialog.

   The chains that matter, and why the rules ask for them:
     · Sending writes the turn into the fixture. The context meter moves, the
       thread's cost moves, the usage screen's headline and its day chart move,
       and the thread jumps to the top of the nested list — because all four
       read the messages array rather than a stored number.
     · Reindexing a file that failed gives it tokens, which puts it into the
       context of every thread it is attached to and takes one off the Files
       badge. One predicate, three altitudes.
     · Attaching a file costs window on every future turn, so the meter moves
       the moment it is attached, not the next time you send.
     · Branching copies the conversation up to a turn into a thread of your own,
       which is the only way to carry on someone else's shared thread. */
function App(props) {
  const options = props || {};
  const [path, setPath] = React.useState(options.startPath || "/");
  const [dark, setDark] = React.useState(options.theme === "dark");
  const [toasts, setToasts] = React.useState([]);
  const [confirm, setConfirm] = React.useState(null);
  const [form, setForm] = React.useState(null);
  const [draft, setDraft] = React.useState("");
  const [resetEmail, setResetEmail] = React.useState("");
  const [user, setUser] = React.useState(PARLEY_USER);
  const [settings, setSettings] = React.useState({ model: "corvus-l", autoAttach: true, privateByDefault: true });
  const [newChat, setNewChat] = React.useState({ model: "corvus-l", fileIds: [], projectId: null });
  const [stream, setStream] = React.useState(null);
  const [search, setSearch] = React.useState({ query: "", kind: "all" });
  const [data, setData] = React.useState(() => ({
    threads: THREADS, projects: PROJECTS, files: FILES, prompts: PROMPTS, people: PEOPLE,
    invites: INVITES, invoices: INVOICES, payment: PAYMENT, now: PARLEY_NOW,
  }));
  const palette = useCommandPalette();
  const usedCanned = React.useRef({});
  const uploadRef = React.useRef(null);
  const now = data.now;

  React.useEffect(() => { if (options.startPath) setPath(options.startPath); }, [options.startPath]);
  React.useEffect(() => { setDark(options.theme === "dark"); }, [options.theme]);
  React.useEffect(() => {
    document.documentElement.classList.toggle("dark", dark);
    document.body.classList.toggle("dark", dark);
  }, [dark]);

  const dismiss = (id) => setToasts((list) => list.filter((t) => t.id !== id));
  const pushToast = (toast) => {
    const id = "t" + Date.now() + Math.random().toString(36).slice(2, 6);
    setToasts((list) => list.concat([Object.assign({ id: id }, toast)]));
    window.setTimeout(() => dismiss(id), 8000);
  };
  /* Undo restores the whole fixture as it was before the commit — one
     mechanism, so no action has to remember what it touched. */
  const undoTo = (snapshot) => () => setData(snapshot);

  const patchThread = (id, fields) => setData((d) => Object.assign({}, d, { threads: d.threads.map((t) => (t.id === id ? Object.assign({}, t, fields) : t)) }));
  /* The workspace record is one object read by the shell, the usage screen and
     both settings screens, so a change to it is written into that object and the
     tree re-rendered — the same commit the other entities get through setData.
     Undo puts the previous fields back, field by field, because a fixture
     snapshot would not carry them. */
  const patchWorkspace = (fields) => { Object.assign(WORKSPACE, fields); setData((d) => Object.assign({}, d)); };
  const undoWorkspace = (fields) => () => patchWorkspace(fields);
  const patchMessage = (threadId, messageId, fields) => setData((d) => Object.assign({}, d, {
    threads: d.threads.map((t) => (t.id === threadId
      ? Object.assign({}, t, { messages: t.messages.map((m) => (m.id === messageId ? Object.assign({}, m, fields) : m)) })
      : t)),
  }));

  /* ── Streaming ─────────────────────────────────────────────────────── */
  /* The reply is written the moment the turn starts and revealed a few
     characters at a time; nothing about the fixture is invented as it goes. */
  React.useEffect(() => {
    if (!stream) return undefined;
    if (stream.chars >= stream.total) {
      const thread = threadById(data.threads, stream.threadId);
      const message = thread && thread.messages.find((m) => m.id === stream.messageId);
      if (message) patchMessage(stream.threadId, stream.messageId, { status: "complete", tokens: blockTokens(message.blocks) });
      setStream(null);
      return undefined;
    }
    const id = window.setTimeout(() => setStream((s) => (s ? Object.assign({}, s, { chars: Math.min(s.total, s.chars + 14) }) : s)), 22);
    return () => window.clearTimeout(id);
  }, [stream, data.threads]);

  const startTurn = (thread, blocks) => {
    const messageId = "m" + (thread.messages.length + 1) + "_" + Date.now().toString(36).slice(-4);
    const message = { id: messageId, role: "assistant", model: thread.model, status: "streaming", at: now, tokens: 0, blocks: blocks };
    setData((d) => Object.assign({}, d, {
      threads: d.threads.map((t) => (t.id === thread.id ? Object.assign({}, t, { messages: t.messages.concat([message]) }) : t)),
    }));
    setStream({ threadId: thread.id, messageId: messageId, chars: 0, total: blocksLength(blocks) });
  };

  const stopTurn = () => {
    if (!stream) return;
    const thread = threadById(data.threads, stream.threadId);
    const message = thread && thread.messages.find((m) => m.id === stream.messageId);
    if (message) {
      const shown = sliceBlocks(message.blocks, stream.chars);
      patchMessage(stream.threadId, stream.messageId, { status: "stopped", blocks: shown, tokens: blockTokens(shown) });
    }
    setStream(null);
  };

  /* What the assistant answers. A thread's own next turn is written in
     fixtures.js and used once; after that the reply works from what was
     actually typed and what is actually in context. */
  const replyFor = (thread, text) => {
    const canned = NEXT_REPLY[thread.id];
    if (canned && !usedCanned.current[thread.id]) { usedCanned.current[thread.id] = true; return canned; }
    const files = (thread.fileIds || []).map((id) => fileById(data.files, id)).filter(Boolean);
    const readable = files.filter(FILE_FLAGS.inContext);
    const note = readable.length
      ? "In context: " + readable.map((f) => f.name).join(", ") + " — " + formatTokens(readable.reduce((s, f) => s + f.tokens, 0)) + " tokens, read again on every turn."
      : "Nothing is attached to this thread, so I am working from the conversation alone.";
    return GENERIC_REPLY(text, note);
  };

  const scrollToBottom = () => {
    const box = document.querySelector(".pl-inset");
    if (box) window.requestAnimationFrame(() => { box.scrollTop = box.scrollHeight; });
  };

  /* ── Sending ───────────────────────────────────────────────────────── */

  const titleFor = (text) => {
    const first = text.trim().split(/(?<=[.?!])\s|\n/)[0].trim();
    return first.length > 52 ? first.slice(0, 52).trim() + "…" : first;
  };

  const onSend = (text) => {
    const message = { id: "m" + Date.now().toString(36).slice(-5), role: "user", at: now, tokens: estimateTokens(text), blocks: [{ t: "p", text: text }] };
    const thread = currentThread();
    if (thread) {
      setData((d) => Object.assign({}, d, {
        threads: d.threads.map((t) => (t.id === thread.id ? Object.assign({}, t, { messages: t.messages.concat([message]) }) : t)),
      }));
      setDraft("");
      startTurn(Object.assign({}, thread, { messages: thread.messages.concat([message]) }), replyFor(thread, text));
      scrollToBottom();
      return;
    }
    /* A new chat becomes a thread on its first send, with the defaults from the
       models screen. */
    const created = {
      id: "t_new_" + Date.now().toString(36).slice(-5),
      title: titleFor(text),
      projectId: newChat.projectId,
      ownerId: user.id,
      model: newChat.model,
      visibility: settings.privateByDefault ? "private" : "workspace",
      pinned: false,
      createdAt: now,
      fileIds: newChat.fileIds,
      messages: [message],
    };
    setData((d) => Object.assign({}, d, { threads: [created].concat(d.threads) }));
    setDraft("");
    setNewChat({ model: settings.model, fileIds: [], projectId: null });
    setPath(resolvePath("/chats/:id", { id: created.id }));
    startTurn(created, replyFor(created, text));
  };

  /* ── One turn's actions ────────────────────────────────────────────── */

  /* Branching is the only way to carry on somebody else's thread, and it is
     reachable from a turn's own footer and from the shared-with-me list, so it
     lives here rather than inside the message handler. */
  const branchFrom = (thread, message) => {
    const cut = thread.messages.findIndex((m) => m.id === message.id);
    const created = {
      id: "t_branch_" + Date.now().toString(36).slice(-5),
      title: thread.title + " (branch)",
      projectId: thread.projectId,
      ownerId: user.id,
      model: thread.model,
      visibility: settings.privateByDefault ? "private" : "workspace",
      pinned: false,
      createdAt: now,
      archivedAt: null,
      fileIds: (thread.fileIds || []).slice(),
      messages: thread.messages.slice(0, cut + 1).map((m) => Object.assign({}, m)),
    };
    setData((d) => Object.assign({}, d, { threads: [created].concat(d.threads) }));
    setPath(resolvePath("/chats/:id", { id: created.id }));
    pushToast({
      title: "Branched",
      description: created.messages.length + " messages copied into a thread of your own. " + personById(data.people, thread.ownerId).name.split(" ")[0] + "’s thread is untouched.",
    });
  };

  const onRunMessage = (message, transition) => {
    const thread = currentThread();
    if (!thread || !message) return;
    if (transition.id === "copy") {
      copyText(blocksText(message.blocks));
      pushToast({ title: "Copied", description: labelOf(MODELS, message.model || thread.model) + "’s answer, as plain text." });
      return;
    }
    if (transition.id === "retry") {
      patchMessage(thread.id, message.id, { status: "streaming", blocks: [], failedReason: undefined });
      const blocks = replyFor(thread, blocksText(thread.messages[thread.messages.length - 2].blocks));
      patchMessage(thread.id, message.id, { status: "streaming", blocks: blocks, tokens: 0, failedReason: undefined, at: now });
      setStream({ threadId: thread.id, messageId: message.id, chars: 0, total: blocksLength(blocks) });
      return;
    }
    if (transition.id === "continue") {
      startTurn(thread, replyFor(thread, blocksText(message.blocks)));
      scrollToBottom();
      return;
    }
    if (transition.id === "branch") branchFrom(thread, message);
  };

  const onFeedback = (message, value) => {
    const thread = currentThread();
    patchMessage(thread.id, message.id, { feedback: message.feedback === value ? null : value });
  };

  /* ── A thread ──────────────────────────────────────────────────────── */

  const onRunThread = (thread, transition) => {
    const snapshot = data;
    if (transition.id === "pin" || transition.id === "unpin") { patchThread(thread.id, { pinned: transition.id === "pin" }); return; }

    /* Archiving is reversible and watchable, so it applies at once and offers an
       undo rather than a dialog. The turns it holds keep counting on the usage
       screen, which the toast says out loud. */
    if (transition.id === "archive" || transition.id === "restore") {
      const archiving = transition.id === "archive";
      patchThread(thread.id, { archivedAt: archiving ? now : null, pinned: archiving ? false : thread.pinned });
      if (archiving && path === resolvePath("/chats/:id", { id: thread.id })) setPath("/chats/archived");
      pushToast({
        title: archiving ? "Archived" : "Restored",
        description: archiving
          ? "“" + thread.title + "” is out of the list. Its " + threadBilling(thread, data.files).rows.length + " billed turns still count " + formatCost(threadBilling(thread, data.files).cost) + " on the usage screen — they really ran."
          : "“" + thread.title + "” is back in the list and can take a turn again.",
        onUndo: undoTo(snapshot),
      });
      return;
    }

    if (transition.id === "share_workspace" || transition.id === "make_private" || transition.id === "create_link" || transition.id === "disable_link") {
      const to = transition.id === "share_workspace" ? "workspace" : transition.id === "make_private" ? "private" : transition.id === "create_link" ? "link" : "workspace";
      patchThread(thread.id, { visibility: to, linkNote: to === "link" ? "Anyone with the link can read this thread. They cannot reply to it." : undefined });
      pushToast({
        title: statusValue(VISIBILITY, to).label,
        description: to === "private" ? "Only you can open “" + thread.title + "” now."
          : to === "workspace" ? "All " + WORKSPACE.seats + " seats in " + WORKSPACE.name + " can read “" + thread.title + "”. They cannot reply to it."
            : "A public link now exists for “" + thread.title + "”. Anyone holding it can read every turn, including the files it cites.",
        onUndo: undoTo(snapshot),
      });
      return;
    }

    if (transition.id === "rename") {
      setForm({
        title: "Rename this thread",
        description: "Titles are how you find a thread three weeks later. The list shows about forty characters.",
        submitLabel: "Rename",
        initial: { title: thread.title },
        fields: [{ id: "title", kind: "text", label: "Title", placeholder: thread.title }],
        validate: (v) => (v.title.trim().length >= 3 ? {} : { title: "Three characters at least." }),
        run: (v) => {
          patchThread(thread.id, { title: v.title.trim() });
          pushToast({ title: "Renamed", description: "“" + thread.title + "” is now “" + v.title.trim() + "”.", onUndo: undoTo(snapshot) });
        },
      });
      return;
    }

    if (transition.id === "export") {
      const lines = thread.messages.map((m) => (m.role === "user" ? user.name : labelOf(MODELS, m.model || thread.model)) + " · " + formatAbsolute(m.at) + "\n\n" + blocksText(m.blocks));
      const text = thread.title + "\n" + formatAbsolute(thread.createdAt) + " · " + WORKSPACE.name + "\n\n" + lines.join("\n\n---\n\n") + "\n";
      const url = URL.createObjectURL(new Blob([text], { type: "text/plain" }));
      const a = document.createElement("a");
      a.href = url; a.download = thread.title.replace(/[^\w]+/g, "-").toLowerCase() + ".txt";
      document.body.appendChild(a); a.click(); document.body.removeChild(a);
      window.setTimeout(() => URL.revokeObjectURL(url), 1000);
      pushToast({ title: "Exported", description: thread.messages.length + " messages as plain text. Files are not included — they live in Files." });
      return;
    }

    if (transition.id === "delete") {
      setForm({
        title: "Delete “" + thread.title + "”?",
        description: thread.messages.length + " messages go, and so does the " + formatCost(threadBilling(thread, data.files).cost) + " of usage recorded against them — the usage screen will read differently afterwards. The " + (thread.fileIds || []).length + " attached files stay in Files. This cannot be undone.",
        submitLabel: "Delete thread",
        destructive: true,
        initial: {},
        fields: [{ id: "reason", kind: "radio", label: "Why, for the record", options: DELETE_REASONS }],
        validate: (v) => (v.reason ? {} : { reason: "Pick one." }),
        run: (v) => {
          setData((d) => Object.assign({}, d, { threads: d.threads.filter((t) => t.id !== thread.id) }));
          setPath("/");
          pushToast({ title: "Thread deleted", description: "“" + thread.title + "” — " + labelOf(DELETE_REASONS, v.reason).toLowerCase() + "." });
        },
      });
    }
  };

  const onAttach = (file) => {
    const thread = currentThread();
    if (!thread) { setNewChat(Object.assign({}, newChat, { fileIds: newChat.fileIds.concat([file.id]) })); return; }
    const snapshot = data;
    patchThread(thread.id, { fileIds: (thread.fileIds || []).concat([file.id]) });
    const after = contextUsed(Object.assign({}, thread, { fileIds: (thread.fileIds || []).concat([file.id]) }), data.files);
    pushToast({
      title: file.name + " attached",
      description: formatTokens(file.tokens) + " tokens, read on every turn from now on. This thread is at " + formatTokens(after) + " of " + formatTokens(modelOf(thread.model).contextTokens) + ".",
      onUndo: undoTo(snapshot),
    });
  };

  const onDetach = (file) => {
    const thread = currentThread();
    const snapshot = data;
    patchThread(thread.id, { fileIds: (thread.fileIds || []).filter((id) => id !== file.id) });
    pushToast({
      title: file.name + " removed from this thread",
      description: "It stays in Files. Turns already written keep citing it — they read it when they ran.",
      onUndo: undoTo(snapshot),
    });
  };

  const onModelChange = (value) => {
    const thread = currentThread();
    if (!thread) { setNewChat(Object.assign({}, newChat, { model: value })); return; }
    patchThread(thread.id, { model: value });
  };

  /* ── Files ─────────────────────────────────────────────────────────── */

  const indexFile = (file) => {
    /* Indexing is asynchronous in the product and asynchronous here: the row
       goes to Indexing, then lands, and the badge follows it. */
    window.setTimeout(() => {
      setData((d) => Object.assign({}, d, {
        files: d.files.map((f) => (f.id === file.id ? Object.assign({}, f, { status: "ready", tokens: Math.round(f.bytes / 4.3), statusNote: undefined }) : f)),
      }));
      const attached = data.threads.filter((t) => (t.fileIds || []).indexOf(file.id) > -1);
      pushToast({
        variant: "success",
        title: file.name + " indexed",
        description: formatTokens(Math.round(file.bytes / 4.3)) + " tokens, readable from now on" + (attached.length ? " by the " + attached.length + " " + (attached.length === 1 ? "thread" : "threads") + " it is attached to." : ". Attach it to a thread to use it."),
      });
    }, 1600);
  };

  const onRunFile = (file, transition) => {
    const snapshot = data;
    if (transition.id === "reindex") {
      setData((d) => Object.assign({}, d, { files: d.files.map((f) => (f.id === file.id ? Object.assign({}, f, { status: "indexing", statusNote: undefined }) : f)) }));
      indexFile(file);
      return;
    }
    if (transition.id === "detach") {
      const project = projectById(data.projects, file.projectId);
      setData((d) => Object.assign({}, d, { files: d.files.map((f) => (f.id === file.id ? Object.assign({}, f, { projectId: null }) : f)) }));
      pushToast({ title: file.name + " moved to the workspace", description: "It is no longer sent with new threads in " + project.name + ". Threads that already hold it keep it.", onUndo: undoTo(snapshot) });
      return;
    }
    if (transition.id === "delete") {
      const attached = data.threads.filter((t) => (t.fileIds || []).indexOf(file.id) > -1);
      setConfirm({
        title: "Delete " + file.name + "?",
        description: (attached.length
          ? "It is attached to " + attached.length + " " + (attached.length === 1 ? "thread" : "threads") + " — " + attached.map((t) => "“" + t.title + "”").join(", ") + " — and they lose it from their context immediately. Turns already written keep citing it."
          : "It is not attached to any thread.") + " This cannot be undone; the file would have to be uploaded again.",
        confirmLabel: "Delete file",
        destructive: true,
        run: () => {
          setData((d) => Object.assign({}, d, {
            files: d.files.filter((f) => f.id !== file.id),
            threads: d.threads.map((t) => Object.assign({}, t, { fileIds: (t.fileIds || []).filter((id) => id !== file.id) })),
          }));
          pushToast({ title: file.name + " deleted", description: attached.length ? attached.length + " " + (attached.length === 1 ? "thread" : "threads") + " lost it from context." : "Nothing else changed." });
        },
      });
    }
  };

  /* Upload reads the real file the person picked — its name and its size are
     what the row shows, and an archive lands unsupported exactly as it would. */
  const onUpload = () => { if (uploadRef.current) uploadRef.current.click(); };
  const onUploadPicked = (event) => {
    const picked = Array.from(event.target.files || []);
    if (!picked.length) return;
    const unsupported = /\.(zip|rar|7z|exe|dmg|mp4|mov)$/i;
    const created = picked.map((file, i) => ({
      id: "f_new_" + Date.now().toString(36).slice(-4) + i,
      name: file.name,
      bytes: file.size,
      tokens: 0,
      status: unsupported.test(file.name) ? "unsupported" : "indexing",
      statusNote: unsupported.test(file.name) ? "Archives and binaries are not read. Upload the text inside them." : undefined,
      projectId: null,
      uploadedBy: user.id,
      at: now,
      note: "Uploaded from this device.",
    }));
    setData((d) => Object.assign({}, d, { files: d.files.concat(created) }));
    created.filter((f) => f.status === "indexing").forEach(indexFile);
    const bad = created.filter((f) => f.status === "unsupported");
    pushToast({
      title: created.length === 1 ? created[0].name + " uploaded" : created.length + " files uploaded",
      description: bad.length ? bad.length + " of them cannot be read and are already on the Files badge." : "Indexing now. They become attachable as soon as they land.",
    });
    event.target.value = "";
  };

  /* ── Projects and prompts ──────────────────────────────────────────── */

  const onRunProject = (project, transition) => {
    const snapshot = data;
    const patch = (fields) => setData((d) => Object.assign({}, d, { projects: d.projects.map((p) => (p.id === project.id ? Object.assign({}, p, fields) : p)) }));
    if (transition.id === "share_workspace" || transition.id === "make_private") {
      const to = transition.id === "share_workspace" ? "workspace" : "private";
      patch({ visibility: to });
      pushToast({ title: statusValue(VISIBILITY, to).label, description: to === "workspace" ? project.name + " and its " + filesInProject(data.files, project.id).length + " files are readable by everyone in " + WORKSPACE.name + "." : "Only you can open " + project.name + " now. Its shared threads stay shared.", onUndo: undoTo(snapshot) });
      return;
    }
    if (transition.id === "archive" || transition.id === "restore") {
      patch({ archivedAt: transition.id === "archive" ? now : null });
      pushToast({
        title: transition.id === "archive" ? project.name + " archived" : project.name + " restored",
        description: transition.id === "archive"
          ? "Its " + threadsInProject(data.threads, project.id).length + " threads stay readable and its files stay indexed. New chats cannot be started in it."
          : "New chats can be started in it again.",
        onUndo: undoTo(snapshot),
      });
      return;
    }
    if (transition.id === "delete") {
      const threads = threadsInProject(data.threads, project.id);
      const files = filesInProject(data.files, project.id);
      setConfirm({
        title: "Delete " + project.name + "?",
        description: "Its instructions go. Its " + threads.length + " " + (threads.length === 1 ? "thread stays" : "threads stay") + " and " + (threads.length === 1 ? "becomes" : "become") + " project-less; its " + files.length + " files move to the workspace. This cannot be undone.",
        confirmLabel: "Delete project",
        destructive: true,
        run: () => {
          setData((d) => Object.assign({}, d, {
            projects: d.projects.filter((p) => p.id !== project.id),
            threads: d.threads.map((t) => (t.projectId === project.id ? Object.assign({}, t, { projectId: null }) : t)),
            files: d.files.map((f) => (f.projectId === project.id ? Object.assign({}, f, { projectId: null }) : f)),
          }));
          setPath("/projects");
          pushToast({ title: project.name + " deleted", description: threads.length + " threads and " + files.length + " files kept." });
        },
      });
    }
  };

  const onNewProject = () => setForm({
    title: "New project",
    description: "A project is instructions plus files. Every thread started inside it is sent both, on every turn.",
    submitLabel: "New project",
    initial: { name: "", purpose: "", instructions: "" },
    fields: [
      { id: "name", kind: "text", label: "Name", placeholder: "Q4 pricing" },
      { id: "purpose", kind: "text", label: "What it is for", placeholder: "One line, so the card says something in three weeks." },
      { id: "instructions", kind: "area", label: "Instructions", placeholder: "Write in the Northgate voice. Say how many responses a claim rests on.", hint: "Sent ahead of every turn in this project." },
    ],
    validate: (v) => {
      const errors = {};
      if (v.name.trim().length < 2) errors.name = "Give it a name.";
      if (v.instructions.trim().length < 12) errors.instructions = "A sentence is enough, but write one — this is the whole point of a project.";
      return errors;
    },
    run: (v) => {
      const created = { id: "p_new_" + Date.now().toString(36).slice(-4), name: v.name.trim(), ownerId: user.id, visibility: settings.privateByDefault ? "private" : "workspace", createdAt: now, archivedAt: null, purpose: v.purpose.trim() || "No description yet.", instructions: v.instructions.trim() };
      setData((d) => Object.assign({}, d, { projects: d.projects.concat([created]) }));
      setPath(resolvePath("/projects/:id", { id: created.id }));
      pushToast({ title: created.name + " created", description: "Nothing is attached to it yet. Upload files in Files and move them here." });
    },
  });

  const onRunPrompt = (prompt, transition) => {
    const snapshot = data;
    if (transition.id === "use") { onUsePrompt(prompt); return; }
    if (transition.id === "copy") {
      copyText(prompt.body);
      pushToast({ title: "Copied", description: "“" + prompt.title + "” as plain text — its uses are unchanged, because nothing was sent." });
      return;
    }
    if (transition.id === "edit") {
      setForm({
        title: "Edit “" + prompt.title + "”",
        description: "Everyone in " + WORKSPACE.name + " uses this one. Threads already written keep what they say.",
        submitLabel: "Save prompt",
        initial: { category: prompt.category, body: prompt.body },
        fields: [
          { id: "category", kind: "select", label: "Category", options: PROMPT_CATEGORIES },
          { id: "body", kind: "area", label: "The prompt", hint: "Used as written. Nothing is appended to it." },
        ],
        validate: (v) => {
          const errors = {};
          if (!v.category) errors.category = "Pick a category.";
          if (v.body.trim().length < 20) errors.body = "Longer than that — a prompt worth saving says what it wants back.";
          return errors;
        },
        run: (v) => {
          setData((d) => Object.assign({}, d, { prompts: d.prompts.map((p) => (p.id === prompt.id ? Object.assign({}, p, { category: v.category, body: v.body.trim() }) : p)) }));
          pushToast({ title: "Prompt saved", description: "“" + prompt.title + "” is " + formatTokens(estimateTokens(v.body.trim())) + " tokens now.", onUndo: undoTo(snapshot) });
        },
      });
      return;
    }
    if (transition.id === "rename") {
      setForm({
        title: "Rename this prompt",
        description: "The title is what shows in the composer's menu.",
        submitLabel: "Rename",
        initial: { title: prompt.title },
        fields: [{ id: "title", kind: "text", label: "Title" }],
        validate: (v) => (v.title.trim().length >= 3 ? {} : { title: "Three characters at least." }),
        run: (v) => {
          setData((d) => Object.assign({}, d, { prompts: d.prompts.map((p) => (p.id === prompt.id ? Object.assign({}, p, { title: v.title.trim() }) : p)) }));
          pushToast({ title: "Renamed", description: "“" + prompt.title + "” is now “" + v.title.trim() + "”.", onUndo: undoTo(snapshot) });
        },
      });
      return;
    }
    if (transition.id === "delete") {
      setConfirm({
        title: "Delete “" + prompt.title + "”?",
        description: "It has been used " + prompt.uses + " times, most recently " + formatAge(prompt.lastUsedAt, now) + ". Threads written with it keep what they say. This cannot be undone.",
        confirmLabel: "Delete prompt",
        destructive: true,
        run: () => {
          setData((d) => Object.assign({}, d, { prompts: d.prompts.filter((p) => p.id !== prompt.id) }));
          pushToast({ title: "Prompt deleted", description: "“" + prompt.title + "” is gone from the library and the composer's menu." });
        },
      });
    }
  };

  const onNewPrompt = () => setForm({
    title: "New prompt",
    description: "Saved prompts are shared with everyone in " + WORKSPACE.name + ".",
    submitLabel: "New prompt",
    initial: { title: "", category: "", body: "" },
    fields: [
      { id: "title", kind: "text", label: "Title", placeholder: "Turn a rough note into a brief" },
      { id: "category", kind: "select", label: "Category", placeholder: "Pick one", options: PROMPT_CATEGORIES },
      { id: "body", kind: "area", label: "The prompt", placeholder: "Write it as you would type it, including what you want back." },
    ],
    validate: (v) => {
      const errors = {};
      if (v.title.trim().length < 3) errors.title = "Give it a title.";
      if (!v.category) errors.category = "Pick a category.";
      if (v.body.trim().length < 20) errors.body = "Longer than that — a prompt worth saving says what it wants back.";
      return errors;
    },
    run: (v) => {
      const created = { id: "pr_new_" + Date.now().toString(36).slice(-4), title: v.title.trim(), category: v.category, authorId: user.id, uses: 0, lastUsedAt: now, body: v.body.trim() };
      setData((d) => Object.assign({}, d, { prompts: d.prompts.concat([created]) }));
      pushToast({ title: created.title + " saved", description: "It is in the library, and in the composer's menu once it has been used a few times." });
    },
  });

  const onUsePrompt = (prompt) => {
    setData((d) => Object.assign({}, d, { prompts: d.prompts.map((p) => (p.id === prompt.id ? Object.assign({}, p, { uses: p.uses + 1, lastUsedAt: now }) : p)) }));
    setDraft(prompt.body);
    setNewChat({ model: settings.model, fileIds: [], projectId: null });
    setPath("/");
  };

  /* ── The workspace: people, invites, policy ────────────────────────── */

  const onChangeAccess = (person, value) => {
    const snapshot = data;
    setData((d) => Object.assign({}, d, { people: d.people.map((p) => (p.id === person.id ? Object.assign({}, p, { access: value }) : p)) }));
    pushToast({
      title: person.name.split(" ")[0] + " is " + (value === "admin" ? "an" : "a") + " " + labelOf(ACCESS_LEVELS, value).toLowerCase(),
      description: (ACCESS_LEVELS.find((a) => a.value === value) || {}).note,
      onUndo: undoTo(snapshot),
    });
  };

  const onRunMember = (person, transition) => {
    if (transition.id !== "remove") return;
    const owned = data.threads.filter((t) => t.ownerId === person.id);
    const shared = owned.filter((t) => t.visibility !== "private");
    const priv = owned.filter((t) => t.visibility === "private");
    const uploaded = data.files.filter((f) => f.uploadedBy === person.id);
    setConfirm({
      title: "Remove " + person.name + " from " + WORKSPACE.name + "?",
      description: shared.length + " " + (shared.length === 1 ? "thread they shared becomes" : "threads they shared become") + " yours and stay readable. "
        + (priv.length ? priv.length + " private " + (priv.length === 1 ? "thread goes" : "threads go") + " with them, and the " + billingRows(priv, data.files).length + " turns inside " + (priv.length === 1 ? "it" : "them") + " stop counting on the usage screen. " : "They have no private threads. ")
        + uploaded.length + " uploaded " + (uploaded.length === 1 ? "file stays" : "files stay") + " indexed. Their seat comes back. This cannot be undone.",
      confirmLabel: "Remove from workspace",
      destructive: true,
      run: () => {
        setData((d) => Object.assign({}, d, {
          people: d.people.filter((p) => p.id !== person.id),
          threads: d.threads.filter((t) => t.ownerId !== person.id || t.visibility !== "private")
            .map((t) => (t.ownerId === person.id ? Object.assign({}, t, { ownerId: user.id }) : t)),
        }));
        pushToast({ title: person.name + " removed", description: shared.length + " shared threads are yours now, and " + (seatsLeft(data) + 1) + " seats are free." });
      },
    });
  };

  const onInvite = () => setForm({
    title: "Invite someone to " + WORKSPACE.name,
    description: "An invite holds a seat from the moment it is sent, and expires in seven days. " + seatsLeft(data) + " of the " + WORKSPACE.seats + " seats are free.",
    submitLabel: "Invite people",
    initial: { email: "", access: "member" },
    fields: [
      { id: "email", kind: "text", label: "Work email", placeholder: "lena@northgate.co" },
      { id: "access", kind: "select", label: "Access", options: ACCESS_LEVELS.filter((a) => a.value !== "owner") },
    ],
    validate: (v) => {
      const errors = {};
      const email = (v.email || "").trim().toLowerCase();
      if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) errors.email = "Enter a valid email address.";
      else if (data.people.some((p) => p.email.toLowerCase() === email)) errors.email = "They are already in the workspace.";
      else if (standingInvites(data).some((i) => i.email.toLowerCase() === email)) errors.email = "An invite to that address is already standing.";
      if (!v.access) errors.access = "Pick what they may do.";
      return errors;
    },
    run: (v) => {
      const created = { id: "i_new_" + Date.now().toString(36).slice(-4), email: v.email.trim(), access: v.access, invitedBy: user.id, at: now, expiresAt: now + 7 * DAY, note: "Invited from the workspace page." };
      setData((d) => Object.assign({}, d, { invites: d.invites.concat([created]) }));
      pushToast({ title: "Invite sent to " + created.email, description: "It holds one seat until " + formatDay(created.expiresAt) + ", leaving " + Math.max(0, seatsLeft(data) - 1) + " free." });
    },
  });

  const onRunInvite = (invite, transition) => {
    const snapshot = data;
    if (transition.id === "copy_link") {
      copyText("https://parley.app/accept-invite/" + invite.id);
      pushToast({ title: "Invite link copied", description: "It works once, for " + invite.email + ", until " + formatDay(invite.expiresAt) + "." });
      return;
    }
    if (transition.id === "resend") {
      setData((d) => Object.assign({}, d, { invites: d.invites.map((i) => (i.id === invite.id ? Object.assign({}, i, { at: now, expiresAt: now + 7 * DAY }) : i)) }));
      pushToast({ title: "Invite resent to " + invite.email, description: "It stands until " + formatDay(now + 7 * DAY) + " and holds a seat again, leaving " + Math.max(0, seatsLeft(data) - 1) + " free.", onUndo: undoTo(snapshot) });
      return;
    }
    if (transition.id === "revoke") {
      const standing = INVITE_FLAGS.isStanding(invite, now);
      setConfirm({
        title: "Revoke the invite to " + invite.email + "?",
        description: "The link stops working immediately. " + (standing ? "Its seat comes back, leaving " + (seatsLeft(data) + 1) + " free." : "It had already expired, so no seat changes hands.") + " The same address can be invited again.",
        confirmLabel: "Revoke invite",
        destructive: true,
        run: () => {
          setData((d) => Object.assign({}, d, { invites: d.invites.filter((i) => i.id !== invite.id) }));
          pushToast({ title: "Invite revoked", description: invite.email + " can no longer accept it." });
        },
      });
    }
  };

  const onRenameWorkspace = () => setForm({
    title: "Rename the workspace",
    description: "It appears on every invite, on the sign-in page, and beside the mark in the sidebar.",
    submitLabel: "Save workspace",
    initial: { name: WORKSPACE.name },
    fields: [{ id: "name", kind: "text", label: "Name", placeholder: WORKSPACE.name }],
    validate: (v) => (v.name.trim().length >= 2 ? {} : { name: "Two characters at least." }),
    run: (v) => {
      const before = WORKSPACE.name;
      patchWorkspace({ name: v.name.trim() });
      pushToast({ title: "Workspace renamed", description: "“" + before + "” is now “" + v.name.trim() + "” everywhere it appears.", onUndo: undoWorkspace({ name: before }) });
    },
  });

  const onRetention = (value) => {
    const before = WORKSPACE.retentionDays;
    patchWorkspace({ retentionDays: parseInt(value, 10) });
    pushToast({
      title: labelOf(RETENTION_OPTIONS, value),
      description: parseInt(value, 10) === 0
        ? "Deleted threads stay recoverable by support indefinitely."
        : "A thread deleted from now on is unrecoverable after " + value + " days. Threads already deleted are not affected.",
      onUndo: undoWorkspace({ retentionDays: before }),
    });
  };

  const onSso = (checked) => {
    const before = WORKSPACE.ssoRequired;
    patchWorkspace({ ssoRequired: checked });
    pushToast({
      title: checked ? "Single sign-on required" : "Single sign-on optional",
      description: checked
        ? "All " + data.people.length + " people sign in through your identity provider from now on. Passwords stop working."
        : "Passwords work again for everyone in " + WORKSPACE.name + ".",
      onUndo: undoWorkspace({ ssoRequired: before }),
    });
  };

  /* ── Billing ───────────────────────────────────────────────────────── */

  /* Changing plan is reversible and its effect is visible on the same screen, so
     it applies at once with an undo rather than asking twice. */
  const onChangePlan = (option) => {
    const before = WORKSPACE.plan;
    patchWorkspace({ plan: option.value, includedTokens: option.includedTokens });
    pushToast({
      title: WORKSPACE.name + " is on " + option.label,
      description: formatCost(option.seatPrice * WORKSPACE.seats) + " a month for " + WORKSPACE.seats + " seats, " + formatTokens(option.includedTokens) + " tokens included. This cycle is charged at the new rate.",
      onUndo: undoWorkspace({ plan: before, includedTokens: planOf(before).includedTokens }),
    });
  };

  const payInvoice = (invoice) => {
    setData((d) => Object.assign({}, d, { invoices: d.invoices.map((i) => (i.id === invoice.id ? Object.assign({}, i, { status: "paid", paidAt: now, statusNote: null }) : i)) }));
    pushToast({
      variant: "success",
      title: invoice.number + " paid",
      description: formatCost(invoiceTotal(invoice)) + " charged to the card on file.",
    });
  };

  const onUpdateCard = (pending) => setForm({
    title: pending ? "Update the card, then retry " + pending.number : "Update the card on file",
    description: pending
      ? "Nothing can be charged to an expired card. Saving a working one retries " + pending.number + " straight away."
      : "One card, charged on the first of the month. Replacing it does not retry anything on its own.",
    submitLabel: "Update card",
    initial: { holder: data.payment.holder, number: "", expiry: "" },
    fields: [
      { id: "holder", kind: "text", label: "Name on the card" },
      { id: "number", kind: "text", label: "Card number", placeholder: "4242 4242 4242 4242" },
      { id: "expiry", kind: "text", label: "Expires", placeholder: "MM/YY", hint: "The month it stops working, as printed on the card." },
    ],
    validate: (v) => {
      const errors = {};
      const digits = (v.number || "").replace(/\s/g, "");
      if (!(v.holder || "").trim()) errors.holder = "The name as printed on the card.";
      if (!/^\d{15,16}$/.test(digits)) errors.number = "Fifteen or sixteen digits.";
      const match = (v.expiry || "").match(/^(\d{2})\s*\/\s*(\d{2})$/);
      if (!match) errors.expiry = "Two digits, a slash, two digits.";
      else {
        const month = parseInt(match[1], 10);
        const year = 2000 + parseInt(match[2], 10);
        if (month < 1 || month > 12) errors.expiry = "Months run 01 to 12.";
        else if (new Date(year, month, 0, 23, 59).getTime() < now) errors.expiry = "That is already in the past.";
      }
      return errors;
    },
    run: (v) => {
      const digits = v.number.replace(/\s/g, "");
      const match = v.expiry.match(/^(\d{2})\s*\/\s*(\d{2})$/);
      const card = {
        brand: digits[0] === "4" ? "Visa" : digits[0] === "5" ? "Mastercard" : digits[0] === "3" ? "Amex" : "Card",
        last4: digits.slice(-4),
        expMonth: parseInt(match[1], 10),
        expYear: 2000 + parseInt(match[2], 10),
        holder: v.holder.trim(),
        addedAt: now,
      };
      setData((d) => Object.assign({}, d, { payment: card }));
      if (pending) window.setTimeout(() => payInvoice(pending), 40);
      else pushToast({ variant: "success", title: "Card updated", description: card.brand + " ···· " + card.last4 + ", good until " + String(card.expMonth).padStart(2, "0") + "/" + card.expYear + ". Nothing has been charged to it yet." });
    },
  });

  const onSetCap = () => setForm({
    title: "Set the spend cap",
    description: "A ceiling for one cycle. Turns stop when it is reached; seats are charged either way. This cycle stands at " + formatCost(cycleCharges(data).total) + ".",
    submitLabel: "Set spend cap",
    initial: { amount: WORKSPACE.spendCapCents ? String(Math.round(WORKSPACE.spendCapCents / 100)) : "0" },
    fields: [{ id: "amount", kind: "text", label: "Cap, in whole dollars", placeholder: "400", hint: "Zero removes the cap altogether." }],
    validate: (v) => {
      const raw = (v.amount || "").trim();
      if (!/^\d+$/.test(raw)) return { amount: "Whole dollars, digits only." };
      const amount = Number(raw);
      const floor = Math.ceil(cycleCharges(data).total / 100);
      if (amount !== 0 && amount < floor) return { amount: "This cycle already stands at " + formatCost(cycleCharges(data).total) + ", so the cap cannot be under $" + floor + "." };
      return {};
    },
    run: (v) => {
      const before = WORKSPACE.spendCapCents;
      const cents = Number(v.amount.trim()) * 100;
      patchWorkspace({ spendCapCents: cents });
      pushToast({
        title: cents ? "Cap set at " + formatCost(cents) : "Cap removed",
        description: cents
          ? "This cycle stands at " + formatCost(cycleCharges(data).total) + ", which is " + Math.round((cycleCharges(data).total / cents) * 100) + "% of it."
          : "Nothing stops a turn now.",
        onUndo: undoWorkspace({ spendCapCents: before }),
      });
    },
  });

  const onRunInvoice = (invoice, transition) => {
    if (transition.id === "retry") {
      if (PAYMENT_FLAGS.isExpired(data.payment, now)) onUpdateCard(invoice);
      else payInvoice(invoice);
      return;
    }
    if (transition.id === "download") {
      const lines = [
        WORKSPACE.name + " — " + invoice.number,
        formatDay(invoice.periodStart) + " to " + formatDay(invoice.periodEnd),
        "",
        invoice.seats + " seats: " + formatCost(invoice.seatCost),
        "Usage beyond the allowance: " + formatCost(invoice.usageCost),
        "Total: " + formatCost(invoiceTotal(invoice)),
        "",
        statusValue(INVOICE_STATUS, invoice.status).label + (invoice.paidAt ? " · " + formatAbsolute(invoice.paidAt) : ""),
        invoice.statusNote || "",
      ];
      const url = URL.createObjectURL(new Blob([lines.join("\n") + "\n"], { type: "text/plain" }));
      const a = document.createElement("a");
      a.href = url; a.download = invoice.number.toLowerCase() + ".txt";
      document.body.appendChild(a); a.click(); document.body.removeChild(a);
      window.setTimeout(() => URL.revokeObjectURL(url), 1000);
      pushToast({ title: invoice.number + " downloaded", description: formatCost(invoiceTotal(invoice)) + " for " + formatDay(invoice.periodStart) + " to " + formatDay(invoice.periodEnd) + ", as plain text." });
    }
  };

  /* ── Account ───────────────────────────────────────────────────────── */

  const onSaveAccount = (next) => {
    const before = user;
    setUser(Object.assign({}, user, { name: next.name.trim(), email: next.email.trim(), role: next.role.trim(), avatar: next.avatar }));
    pushToast({ variant: "success", title: "Account saved", description: next.name.trim() + " · " + next.email.trim(), onUndo: () => setUser(before) });
  };
  const onSaveSettings = (next) => {
    const before = settings;
    setSettings(next);
    setNewChat((c) => Object.assign({}, c, { model: next.model }));
    pushToast({ variant: "success", title: "Defaults saved", description: "New chats start on " + labelOf(MODELS, next.model) + (next.privateByDefault ? ", private." : ", shared with the workspace."), onUndo: () => setSettings(before) });
  };
  const onSignOutEverywhere = () => setConfirm({
    title: "Sign out everywhere?",
    description: "Every other browser and device signs out immediately. This one stays signed in. Nothing in " + WORKSPACE.name + " is deleted.",
    confirmLabel: "Sign out everywhere",
    destructive: true,
    run: () => pushToast({ title: "Signed out everywhere", description: "Other sessions ended. You are still signed in here." }),
  });

  /* ── Routing ───────────────────────────────────────────────────────── */

  const chatMatch = path === "/chats/archived" ? null : path.match(/^\/chats\/([^/]+)$/);
  const projectMatch = path.match(/^\/projects\/([^/]+)$/);
  const promptMatch = path.match(/^\/library\/([^/]+)$/);
  const fileMatch = path.match(/^\/files\/([^/]+)$/);
  function currentThread() { return chatMatch ? threadById(data.threads, chatMatch[1]) : null; }
  const thread = currentThread();
  const project = projectMatch ? projectById(data.projects, projectMatch[1]) : null;
  const prompt = promptMatch ? promptById(data.prompts, promptMatch[1]) : null;
  const file = fileMatch ? fileById(data.files, fileMatch[1]) : null;

  const onNewChat = () => { setDraft(""); setNewChat({ model: settings.model, fileIds: [], projectId: null }); setPath("/"); };

  /* ⌘⇧O and Escape, both listed in the shortcuts dialog and both bound here.
     ⌘K belongs to the palette and Enter to the composer. */
  React.useEffect(() => {
    const onKey = (e) => {
      if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "o") { e.preventDefault(); onNewChat(); }
      if (e.key === "Escape" && stream) stopTurn();
    };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  });

  /* Harness chrome, not product chrome: the floating page menu is how a reader
     reaches every registered screen, including the auth ones the shell cannot
     render. It sits outside the shell so it is on those screens too. */
  const preview = (
    <TemplateMenu product="Parley" path={path} onNavigate={setPath} groups={PARLEY_PAGES(data)}
      dark={dark} onToggleTheme={() => setDark(!dark)} />
  );

  if (PARLEY_AUTH_PATHS.indexOf(path) > -1) {
    const authScreen = () => {
      if (path === "/signin") return <SignInScreen user={user} onSignIn={() => setPath("/")} onNavigate={setPath} />;
      if (path === "/signup") return <SignUpScreen onSignIn={() => setPath("/")} onNavigate={setPath} />;
      if (path === "/forgot-password") return <ForgotPasswordScreen onSent={(email) => { setResetEmail(email); setPath("/check-email"); }} onNavigate={setPath} />;
      if (path === "/check-email") return <CheckEmailScreen email={resetEmail} onNavigate={setPath} />;
      return <SignedOutScreen user={user} onNavigate={setPath} />;
    };
    return <>{authScreen()}{preview}</>;
  }

  const screen = () => {
    if (path === "/") {
      return <NewChatScreen draft={draft} data={data} user={user} model={newChat.model} onDraft={setDraft}
        onModelChange={onModelChange} onSend={onSend} onAttach={onAttach} onNavigate={setPath} />;
    }
    if (thread) {
      return <ThreadScreen thread={thread} data={data} user={user} draft={draft} onDraft={setDraft} onSend={onSend}
        onStop={stopTurn} streamChars={stream && stream.threadId === thread.id ? stream.chars : 0}
        onRunThread={onRunThread} onRunMessage={onRunMessage} onFeedback={onFeedback}
        onModelChange={onModelChange} onAttach={onAttach} onDetach={onDetach} onNavigate={setPath} />;
    }
    if (path === "/projects") return <ProjectsScreen data={data} onNavigate={setPath} onRun={onRunProject} onNew={onNewProject} />;
    if (project) {
      return <ProjectScreen project={project} data={data} onNavigate={setPath} onRun={onRunProject} onRunFile={onRunFile}
        onNewThread={(p) => { setDraft(""); setNewChat({ model: settings.model, fileIds: settings.autoAttach ? filesInProject(data.files, p.id).filter(FILE_FLAGS.inContext).map((f) => f.id) : [], projectId: p.id }); setPath("/"); }} />;
    }
    if (path === "/library") return <LibraryScreen data={data} user={user} onNavigate={setPath} onRun={onRunPrompt} onNew={onNewPrompt} />;
    if (promptMatch) {
      return prompt
        ? <PromptScreen prompt={prompt} data={data} user={user} onRun={onRunPrompt} onNavigate={setPath} />
        : <MissingRecord what="prompt" where="/library" label="Back to the library" onNavigate={setPath} />;
    }
    if (path === "/files") return <FilesScreen data={data} onNavigate={setPath} onRun={onRunFile} onUpload={onUpload} />;
    if (fileMatch) {
      return file
        ? <FileScreen file={file} data={data} onRun={onRunFile} onNavigate={setPath} />
        : <MissingRecord what="file" where="/files" label="Back to files" onNavigate={setPath} />;
    }
    if (path === "/chats/archived") return <ArchivedScreen data={data} user={user} onNavigate={setPath} onRun={onRunThread} />;
    if (path === "/shared") {
      return <SharedScreen data={data} user={user} onNavigate={setPath}
        onBranch={(t) => branchFrom(t, THREAD_FLAGS.lastMessage(t))} />;
    }
    if (path === "/search") {
      return <SearchScreen data={data} query={search.query} kind={search.kind} onNavigate={setPath}
        onQuery={(q) => setSearch({ query: q, kind: search.kind })} onKind={(k) => setSearch({ query: search.query, kind: k })} />;
    }
    if (path === "/usage") return <UsageScreen data={data} user={user} onNavigate={setPath} />;
    if (path === "/settings/account") return <AccountScreen user={user} data={data} onSave={onSaveAccount} onSignOutEverywhere={onSignOutEverywhere} />;
    if (path === "/settings/models") return <ModelsScreen settings={settings} data={data} onSave={onSaveSettings} />;
    if (path === "/settings/workspace") {
      return <WorkspaceScreen data={data} user={user} onNavigate={setPath}
        onChangeAccess={onChangeAccess} onRunMember={onRunMember} onRunInvite={onRunInvite} onInvite={onInvite}
        onRename={onRenameWorkspace} onRetention={onRetention} onSso={onSso} />;
    }
    if (path === "/settings/billing") {
      return <BillingScreen data={data} user={user} onNavigate={setPath}
        onRunInvoice={onRunInvoice} onChangePlan={onChangePlan} onUpdateCard={() => onUpdateCard(null)} onSetCap={onSetCap} />;
    }
    return <RouteNotBuilt path={path} onNavigate={setPath} />;
  };

  /* The nested list is the live shelf; the archive is its own screen, reached
     from the foot of the list. */
  const sidebarThreads = liveThreads(visibleThreads(data.threads, user.id))
    .slice()
    .sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0) || THREAD_FLAGS.lastActivityAt(b) - THREAD_FLAGS.lastActivityAt(a));

  const recordGroups = [
    { label: "Chats", items: liveThreads(visibleThreads(data.threads, user.id)).map((t) => ({ value: t.id, label: t.title + " · " + (t.projectId ? projectById(data.projects, t.projectId).name : "no project") + " · " + formatAge(THREAD_FLAGS.lastActivityAt(t), now), route: resolvePath("/chats/:id", { id: t.id }) })) },
    { label: "Projects", items: data.projects.map((p) => ({ value: p.id, label: p.name + " · " + threadsInProject(data.threads, p.id).length + " threads", route: resolvePath("/projects/:id", { id: p.id }) })) },
    { label: "Files", items: data.files.map((f) => ({ value: f.id, label: f.name + " · " + statusValue(FILE_STATUS, f.status).label.toLowerCase(), route: resolvePath("/files/:id", { id: f.id }) })) },
    { label: "Prompts", items: data.prompts.map((p) => ({ value: p.id, label: p.title + " · " + labelOf(PROMPT_CATEGORIES, p.category).toLowerCase(), route: resolvePath("/library/:id", { id: p.id }) })) },
  ];

  const crumbLabels = {};
  if (thread) crumbLabels["/chats/:id"] = thread.title;
  if (project) crumbLabels["/projects/:id"] = project.name;
  if (prompt) crumbLabels["/library/:id"] = prompt.title;
  if (file) crumbLabels["/files/:id"] = file.name;

  return (
    <ParleyShell
      path={path}
      onNavigate={setPath}
      dark={dark}
      onToggleTheme={() => setDark(!dark)}
      data={data}
      user={user}
      threads={sidebarThreads}
      palette={palette}
      crumbLabels={crumbLabels}
      crumbParams={{ id: thread ? thread.id : project ? project.id : prompt ? prompt.id : file ? file.id : "" }}
      archivedCount={archivedThreads(data.threads).length}
      onNewChat={onNewChat}
      toasts={toasts}
      onDismissToast={dismiss}
    >
      {screen()}
      <input ref={uploadRef} type="file" multiple className="pl-sr" onChange={onUploadPicked} aria-hidden="true" tabIndex={-1} />
      <CommandPalette
        open={palette.open}
        onClose={() => palette.setOpen(false)}
        routes={PARLEY_ROUTES}
        recordGroups={recordGroups}
        onNavigate={setPath}
        placeholder="Search chats, projects, files…"
        footer={(close) => (
          <button type="button" className="pl-command-foot" onClick={() => { close(); setPath("/search"); }}>
            <span>Search every chat, project, file and prompt</span>
            <Icon name="arrow-right" size={14} />
          </button>
        )}
      />
      <ConfirmDialog request={confirm} onCancel={() => setConfirm(null)} onConfirm={() => { confirm.run(); setConfirm(null); }} />
      <FormDialog request={form} onCancel={() => setForm(null)} onSubmit={(values) => { form.run(values); setForm(null); }} />
      {preview}
    </ParleyShell>
  );
}
/* boot.js publishes a stub on window.ParleyApp before anything is fetched, so
   <x-import> always resolves; the real component lands here and the stub swaps
   it in. */
window.__parleyReal = App;
if (!window.ParleyApp) window.ParleyApp = App;
