unified-files-ui.md17.3 KBView on GitHub # Unified Files UI
One `Files` component system for every surface that lists files — `/brain`, a conversation's
Files tab, and an agent's Output/Memory tabs — with the Google Drive **layout** (icon, title,
then columns) and Linear's **visual identity** (one surface, inset hairlines, our Geist type
scale).
Companion to [`wiki/conversation-files-tab.md`](./wiki/conversation-files-tab.md) and
[`optimistic-files.md`](./optimistic-files.md).
---
## 1. Present state
### 1.1 Three surfaces, ten row renderers, no shared component
| Surface | Entry | Row renderer(s) |
|---|---|---|
| `/brain` → Files | [`app/(routes)/brain/page.tsx`](../app/(routes)/brain/page.tsx) → `FileExplorerTabs` → `CompanyExplorer` | `NodeRow` (`CompanyExplorer.tsx:568-760`), `SearchResultRow` (`:852-899`), `ScopeRoot` header (`:788-848`), `DragOverlay` pill (`:2456-2467`) |
| Conversation → Files | `ConversationTabBody` → `FilesTab` | `FileTreeRow` (`ConversationFileTree.tsx:587-849`), `DriveFileRow` (`DriveSection.tsx:41-81`), `DriveFolderRow` header (`:126-181`), `DriveGroupRow` header (`:248-293`), attached row (`AttachedSection.tsx:67-85`), `AgentFilesGroup` file/folder/agent rows (`:44-57`, `:87-100`, `:61-78`) |
| Agent → Output / Memory | `AgentView` → `AgentOutputTab` / `AgentMemoryTab` → `AgentFileBrowser` | reuses `FileTreeRow`, plus `TouchedRow` (`AgentOutputTab.tsx:44-71`) and `AgentConversationFilesFolder.tsx:36-90` |
`AgentFileBrowser` already reuses `useFileTree`/`FileTreeRow` from the conversation tree — the one
piece of existing sharing, and the proof the abstraction works. `/brain` shares nothing with either.
### 1.2 What the divergence actually looks like
Every renderer has independently re-decided the same five things:
- **Icon chrome** — `/brain`: a bare `size-3.5` glyph or the doc's emoji. Conversation/agent tree: a
`h-7 w-7 rounded-full bg-muted` medallion that doubles as the multi-select hit target.
`AgentOutputTab`: a `h-7 w-7 rounded-md bg-muted` tile. `AttachedSection`: no icon at all.
- **Type size** — `text-sm` (`/brain`, attached, `AgentFilesGroup`) vs `text-base font-medium`
(conversation tree, Drive, `AgentConversationFilesFolder`).
- **Indent** — inline `paddingLeft: 8 + depth*14` (`/brain`), inline `12 + depth*16`
(conversation/agent), a fixed Tailwind ladder `['pl-10','pl-16','pl-20',…]` (Drive), and
hardcoded `pl-16`/`pl-24` (`AgentFilesGroup`).
- **Selected state** — `bg-accent` (`NodeRow`) vs `bg-muted` (`SearchResultRow`) vs `bg-primary/5`
(conversation tree).
- **Separators** — `divide-y divide-border/50` (conversation tree), nothing (`/brain`),
`divide-border/60` (`AgentOutputTab`).
There is no `nodeIcon(node)` helper: the icon ternary is written out three times inside
`CompanyExplorer.tsx` alone, each slightly differently. There are **six** copies of a byte
formatter in `apps/mail` (`lib/utils.ts:279`, `AttachedSection.tsx:92`,
`agentExecutions/utils/format.ts:34`, `threads/thread/utils/thread-display-utils.ts:6`,
`threads/thread/components/mail-display.tsx:45`, `drafting/utils/image-compression.ts:101`).
### 1.3 What a row shows today
Nothing but a name. No surface renders size, source, an author, or a date — the conversation tree
has one hover-only activity string (`"Opened 5h ago"` / `"Edited by agent 3 days ago"`), and
`AttachedSection` renders a size for email attachments only. `AgentOutputTab`'s `TouchedRow` is the
only place with a persistent timestamp column.
### 1.4 The data underneath
`files.listChildren` returns full `FsNode`s
([`apps/server/src/services/file-system/index.ts:97-120`](../../server/src/services/file-system/index.ts)):
```ts
{ id, orgId, userId, parentId, documentType, path, title, emoji, description,
content?, metadata, contentYjs, yjsRevision, lastEditedBy, version,
wordCount, lastOpened, createdAt, updatedAt }
```
Against the five columns we want:
| Column | Status today |
|---|---|
| icon | ✅ `documentType` + `emoji` + `metadata.mimeType` |
| title | ✅ `title`, falling back to the last path segment |
| **source** | ⚠️ derivable but not a field — `metadata.kind` is `upload` / `email_attachment` / `drive_pin` / `drive_mount` / `google_drive` / `shared_link` / `url` / `table`, plus path prefixes (`…/agent-{id}/…`, `organisation/wiki/…`) |
| **size** | ⚠️ `metadata.sizeBytes` for attachments and `DriveNode.sizeBytes` for Drive **only** — nothing at all for the markdown/Y.js docs that are most of the tree |
| **edited by** | ⚠️ `documents.lastEditedBy` is the coarse enum `'agent' \| 'human' \| 'system'`. Real identity lives in `document_updates` (`actorUserId`, `actorLabel`, `actorAgentId`) and is exposed only by `documents.history`, one document per call |
| date | ✅ `updatedAt` / `createdAt` |
`documents` has **no** `created_by` and **no** `size_bytes` column
([`apps/server/src/db/documents-schema.ts:54-176`](../../server/src/db/documents-schema.ts)).
### 1.5 Headless coverage today
`pnpm cedar-cli document` has exactly two verbs, `read` and `write`, both path-addressed. There is
**no way to list a folder from the command line**, so none of the three surfaces' data paths are
headlessly inspectable. `files.listChildren`, `files.listAttachedForConversation`,
`drive.listForConversation` and `agent.getOutputs` have no CLI driver at all.
---
## 2. Designed state
### 2.1 The row
```
┌──────────────────────────────────────────────────────────────────────────────────┐
│ Name Size Modified [+] │ ← the header
├──────────────────────────────────────────────────────────────────────────────────┤ IS the top row
│ ▸ ▦ Knowledge base (12) — 6h ago by you [⋮] │
│ ▦ Pricing deck.pdf 104.2 MB 3 days ago by Jesse Li[⋮] │
│ ▦ Acme — call notes 4 KB 2 weeks ago by ◕ [⋮] │
└──────────────────────────────────────────────────────────────────────────────────┘
```
Structure is Google Drive's: an icon tile, the name, then right-aligned columns. Identity is
Linear's, and is taken from the two files that already encode it — `components/ui/settings.tsx`
(inset hairline separators, one card, 12px radius) and `components/ui/field.tsx` (one label style,
one control height, no per-row class strings).
Row geometry, fixed once in the kit:
- **Height** 40px (`py-2` around a `size-7` tile) — Drive's density, not the 60px of a settings row,
because a file row has no description under it.
- **Icon tile** `size-7 rounded-md bg-muted`, a `size-3.5` lucide glyph or the doc's emoji inside.
Rounded-square, per Linear's document-template row; the conversation tree's `rounded-full`
medallion becomes the *selectable* variant of the same tile, so multi-select survives.
- **Name** `text-sm font-medium`, truncating, `min-w-0 flex-1`.
- **Columns** `text-xs text-muted-foreground tabular-nums`, fixed widths, right of the name.
- **Separator** a 1px `foreground/7%` hairline **inset to the row's padding**, drawn as a
`::before` exactly as `SETTINGS_ROW_BASE` does, so it reads as a seam and not a table rule.
- **Hover** `bg-muted/40` on the whole row, `cursor-pointer` whenever the row activates.
- **Selected** `bg-accent` — one value, replacing today's three.
- **Indent** `paddingLeft: 12 + depth * 16`, one mechanism, replacing the four.
### 2.2 Columns are declarative and drop by container width
The same list renders in a 1100px `/brain` pane and a ~380px conversation panel. Columns are
declared, given a width and a minimum container width, and the list is an `@container` so they drop
on their own — no per-call-site prop juggling:
| Key | Header | Width | Shown at | Value |
|---|---|---|---|---|
| `size` | Size | `w-20` | `@lg` | `104.2 MB`, `—` for folders and Google-native docs |
| `modified` | Modified | `w-40` | `@sm` | `3 days ago by Jesse Li` — when AND who, one column; an agent is its own avatar, not the word |
| — | (menu) | `w-6` | always | the row's `⋮`; the header's `+` sits directly above it |
**There is no "Edited by" column.** Who last touched a file is only interesting alongside
*when* — the two read as one fact, and splitting them spent a whole column's width to say
something the reader reassembles anyway. It also means the common case (nothing recorded)
degrades to a plain `3 days ago` instead of a date plus an em dash in its own column. The
viewer's own edits read `by you`.
**A folder's count sits beside its title** — `Knowledge base (12)` — not as a lone number at
the far right, where it reads as an unlabelled column only some rows have a value in.
**The right edge is one fixed cell, always.** A row with four affordances and a row with one
still end at the same x, and the header's create control sits directly above the column of
`⋮`s. That cell is also where "New file" / "New folder" live: on the header for the list's
root, and in each folder's own menu for creating inside it. Neither is a strip of buttons
above the list — that was a row of its own that pushed the files down the page to say what
a menu says in one glyph.
Below `@lg` the row is name-only — which is what the conversation panel gets today, so that surface
does not regress into a horizontal scroll.
### 2.3 The kit
```
modules/files/components/list/
├── FileList.tsx surface: card, header row, column header, rows, empty state
├── FileListRow.tsx one row — the only place row geometry is decided
├── FileListColumns.tsx column registry: key, header, width, container breakpoint
├── FileTypeIcon.tsx the tile + the one icon-picking function
├── types.ts FileListItem, FileListColumnKey, FileSource, FileKind
└── index.ts
modules/files/utils/
├── file-presentation.ts toFileListItem(FsNode) — the single normaliser
├── format-file-size.ts the one byte formatter
└── relative-time.ts shortAgo / longAgo, promoted out of ConversationFileTree
```
`FileListRow` stays **presentational**: expansion state, fetching and mutations remain with each
caller. It takes slots (`actions`, `meta`, `after`) and an `as: 'div' | 'button' | 'link'` — Drive
rows must be a real `<a href>` with their action buttons as siblings, never nested inside the
anchor, and that constraint is called out three times in `DriveSection.tsx` for good reason.
`FileListItem` is the normalised shape every surface maps into:
```ts
type FileListItem = {
id: string;
title: string;
kind: FileKind; // folder | document | table | board | attachment | drive | url | agent | …
emoji?: string | null;
mimeType?: string | null;
source: FileSource; // cedar | upload | email | drive | web | slack | agent
sizeBytes?: number | null;
editedBy?: { name: string; kind: 'human' | 'agent' | 'system' } | null;
updatedAt?: Date | string | null;
};
```
### 2.4 Server: give the columns real data
Two additions, **no migration**:
1. **`sizeBytes` and `source` become first-class on `FsNode`**, computed in
`services/file-system/index.ts`'s `toFsNode` so every consumer (tree, search, CLI) gets them
without re-implementing the `metadata.kind` discriminants:
- size — `metadata.sizeBytes` for `upload` / `email_attachment`, else `octet_length(content)`
for text-bearing docs, else `null` (folders, Google-native).
- source — `metadata.kind` first, path prefix as fallback, `documentType` last.
2. **A batched `documents.lastEditors({ documentIds })`** resolving real identity from
`document_updates` via `DISTINCT ON (document_id) … ORDER BY document_id, to_seq DESC`, joined to
`user` for `{ id, name, image }`. The unique index `document_updates_doc_to_seq_idx
(documentId, toSeq)` already supports it. Shaped like the existing batched
`files.unseenAgentEdits`, for the same reason: no N+1.
**Deliberately not doing:** adding a `created_by` column. It would need a migration and would
backfill to NULL for every agent- and system-authored row plus everything written before history
capture shipped — a column that is mostly "—" is worse than no column. `document_updates` already
carries real identity for human edits, so the person column is **Edited by**, which the user's brief
allows ("created by or last edited by"). Recorded here so the choice is not silently re-litigated.
### 2.5 Headless surface
Every data path this design touches gets a CLI driver calling the *same* procedure the UI calls:
```
pnpm cedar-cli files ls --scope user|org|conversation [--id <id>] [--path <p>] [--json]
pnpm cedar-cli files tree --scope … [--path <p>] [--depth N]
pnpm cedar-cli files attached --conversation <id> # files.listAttachedForConversation
pnpm cedar-cli files drive --conversation <id> # drive.listForConversation
pnpm cedar-cli files outputs --agent <id> # agent.getOutputs
pnpm cedar-cli files editors --ids <a,b,c> # documents.lastEditors
```
`files ls` prints exactly the columns the UI renders, so a wrong column is visible from the command
line before it is visible in a browser.
---
## 3. Critical files
**New**
- `apps/mail/modules/files/components/list/{FileList,FileListRow,FileListColumns,FileTypeIcon,types,index}.tsx`
- `apps/mail/modules/files/utils/{file-presentation,format-file-size,relative-time}.ts`
- `apps/server/src/files-admin/cli.ts`
- `apps/mail/tests/modules/files/list/*.test.tsx`
**Changed — server**
- `apps/server/src/services/file-system/index.ts` — `toFsNode` gains `sizeBytes` + `source`
- `apps/server/src/trpc/routes/documents.ts` — new `lastEditors`
- `apps/server/src/cli/index.ts` — register `files`
**Changed — frontend**
- `apps/mail/modules/conversations/components/files/{ConversationFileTree,AttachedSection,DriveSection,AgentFilesGroup}.tsx`
- `apps/mail/modules/agents/components/{AgentOutputTab,AgentConversationFilesFolder}.tsx`
- `apps/mail/modules/company/components/CompanyExplorer.tsx`
- `apps/mail/modules/files/components/FileExplorerTabs.tsx`
---
## 4. Implementation phases
### Phase 0 — Unblock, and make the file surfaces headless
- [x] Fix the rules-of-hooks + dead-import errors that stop `pnpm dev` booting
- [x] `apps/server/src/files-admin/cli.ts` with `ls` / `tree` / `attached` / `drive` / `outputs`, registered as `cedar-cli files`
- [x] Verify each verb green as `<email>`
- Test: `pnpm cedar-cli files ls --scope org --json` returns the org root's children
### Phase 1 — Server: source, size, editor identity
- [x] `toFsNode` derives `source` and `sizeBytes`; unit-test the derivation table
- [x] `documents.lastEditors` batched route + `cedar-cli files editors`
- [x] `pnpm --filter @zero/server exec vitest run` for the touched services
- Test: `files ls --json` shows a non-null `source` for every row and a `sizeBytes` for docs and uploads
### Phase 2 — The Files kit
- [x] `format-file-size`, `relative-time`, `file-presentation` (+ jest)
- [x] `FileTypeIcon`, `FileListColumns`, `FileListRow`, `FileList` (+ RTL jest)
- Test: `pnpm --filter @zero/mail exec jest tests/modules/files/list`
### Phase 3 — Conversation Files tab adopts the kit
- [x] `ConversationFileTree`'s `FileTreeRow` renders `FileListRow`; test ids preserved verbatim
- [x] `AttachedSection`, `DriveSection`, `AgentFilesGroup` rows render `FileListRow`
- [x] Column header + counts on the tab
- Test: the existing `tests/modules/conversations/filesTab*.test.tsx` suite stays green
### Phase 4 — Agent workspace adopts the kit
- [x] `AgentOutputTab`'s `TouchedRow` and `AgentConversationFilesFolder` render `FileListRow`
- [x] `AgentFileBrowser` gets the column header
- Test: `modules/agents/__tests__/AgentOutputTab.test.tsx` stays green
### Phase 5 — `/brain` adopts the kit
- [x] `NodeRow`, `SearchResultRow`, `DragOverlay` render `FileListRow`; `ScopeRoot` becomes a `prominent` root row (it is a folder, not a category)
- [x] Column header on the explorer
- Test: new jest coverage for the `/brain` rows (there is none today)
### Phase 6 — Wiki + de-duplication
- [x] Collapse the byte formatters used by file surfaces onto `format-file-size`
- [x] `docs/wiki/files-ui.md`
---
## 5. Verification
**Headless (the end-to-end criterion).** As `<email>`:
```
pnpm cedar-cli files ls --scope org --json # /brain org root
pnpm cedar-cli files ls --scope user --json # /brain user root
pnpm cedar-cli files tree --scope conversation --id <convId>
pnpm cedar-cli files outputs --agent <agentId>
pnpm cedar-cli files editors --ids <docIds>
```
Green means: every row carries `title`, `source`, `sizeBytes` (or an explained null), `editedBy`
and `updatedAt` — the exact five values the UI renders — for all three surfaces.
**Component.** `pnpm --filter @zero/mail exec jest --config jest.config.cjs --no-coverage --ci`.
**Visual.** The three surfaces at `localhost:5173`, checked for one row geometry, one separator, one
selected colour, and columns dropping cleanly as the conversation panel narrows.