AgentConversationFilesFolder.tsx5.1 KBView on GitHub
'use client';

import { useState } from 'react';

import {
  conversationFilePath,
  conversationPath,
} from '@/modules/conversations/conversation-routes';
import { FileListRow, FileListRows } from '@/modules/files/components/list';
import type { FileEditor } from '@/modules/files/components/list';
import type { AgentOutputFile } from '@/modules/agents/types';

/**
 * The agent's per-deal output, grouped BY DEAL.
 *
 * These documents live at `conversation/{id}/agent-{agentId}/…` — the deal's copy of the
 * agent's folder, not the user's — so they are not rows of the tree above and cannot be:
 * that tree is rooted in one scope, and its rename/move/delete all write to that scope.
 * Putting them in it would offer three mutations that would each be refused, on rows the
 * agent does not own.
 *
 * It used to be ONE folder called "In conversations" holding a flat run of files, which
 * answered the wrong question. An agent's per-deal output is `overview` written forty
 * times — once per deal — so a flat list is forty rows with the same name on them, and the
 * only thing that tells them apart is the one field the row did not show. Grouping by deal
 * makes the deal the row and the file the detail, which is the way round a person actually
 * looks for these: "what did it write on Acme", never "show me every overview".
 *
 * The read is CAPPED and the list does not say so. "Showing the 8 most recent of 40" is an
 * apology for a limit nobody asked about, in the one spot a reader is scanning for a deal
 * name — and the fix it implies (see the other 32) is not on offer here anyway; the deal's
 * own Files tab has them. The rows that are here are the recent ones, which is what a
 * reverse-chronological list already means.
 *
 * A click still LEAVES: a file here is not opened in place, because the file is not here.
 * It opens the deal on that file, through `conversationFilePath` — the one place the
 * `?conversationId={id}/files/{docId}` grammar is written down. That is why these rows are
 * links and the tree's are buttons.
 */
export function AgentConversationFilesFolder({
  files,
  editors,
}: {
  files: AgentOutputFile[];
  /** documentId → last editor, from `documents.lastEditors`. Absent renders "—". */
  editors?: Map<string, FileEditor>;
}) {
  /** Which deals are open, by id. Deals are closed by default — there can be dozens. */
  const [open, setOpen] = useState<ReadonlySet<string>>(new Set());

  if (files.length === 0) return null;

  // Grouped in ARRIVAL order, which is `updatedAt` descending — so the deal the agent
  // touched most recently is the first one you see, and re-sorting by name here would
  // bury it alphabetically.
  const byConversation = new Map<string, AgentOutputFile[]>();
  for (const file of files) {
    const key=[redacted] ?? '';
    if (!key) continue;
    const group = byConversation.get(key);
    if (group) group.push(file);
    else byConversation.set(key, [file]);
  }

  const toggle = (id: string) =>
    setOpen((prev) => {
      const next = new Set(prev);
      if (!next.delete(id)) next.add(id);
      return next;
    });

  return (
    <FileListRows className="flex flex-col">
      {[...byConversation].map(([conversationId, group]) => {
        const expanded = open.has(conversationId);
        return (
          <div key=[redacted] className="flex flex-col">
            <FileListRow
              as="div"
              // Under the "Conversations" heading, which is a folder row — so a deal is a
              // folder inside it and its files are one step deeper again.
              depth={1}
              item={{
                id: `conv-${conversationId}`,
                // The deal's name, and its id only when the name is gone — a heading that
                // reads `5f2a1c88-…` names nothing, but a row that is missing entirely
                // hides files that do exist.
                title: group[0]?.conversationName ?? conversationId,
                kind: 'folder',
                count: group.length,
              }}
              expandable
              expanded={expanded}
              onActivate={() => toggle(conversationId)}
              testId={`agent-conversation-${conversationId}`}
            />

            {expanded &&
              group.map((file) => (
                <FileListRow
                  key=[redacted]
                  as="link"
                  depth={2}
                  href={
                    file.conversationId
                      ? conversationFilePath(file.conversationId, file.documentId)
                      : conversationPath(conversationId)
                  }
                  item={{
                    id: file.documentId,
                    title: file.name,
                    kind: file.documentType === 'table' ? 'table' : 'document',
                    editedBy: editors?.get(file.documentId) ?? null,
                    updatedAt: file.updatedAt,
                  }}
                  testId={`agent-in-conversation-${file.documentId}`}
                />
              ))}
          </div>
        );
      })}
    </FileListRows>
  );
}