REASON docs sidebar — files on the document routes, opened in CardMirror

The Files / Topic Starters / Open Tabs panels that sit above the tool nav tree, and the URL round trip that turns a click on one of them into "that file, loaded in CardMirror, in the main column."

  • Routes: /cards (and below it) and /reason-editor — the routes the documents are the subject of, per packages/debate-webview/src/lib/reason-docs/sidebar-routes.ts
  • Panels: packages/debate-webview/src/components/reason-docs/ (ReasonDocsSidebarPanels, FileTree, TopicStarterTree, OpenTabsPanel), over ReasonDocsProvider in the root layout
  • URL rules: packages/debate-webview/src/lib/reason-docs/route-selection.ts, over the filename paths in packages/debate-webview/src/lib/reason-docs/doc-path.ts
  • Panel stack and its height split: packages/debate-webview/src/lib/reason-docs/panel-layout.ts
  • Stored file format: packages/debate-webview/src/lib/cardmirror/ (format.ts, content-format.ts, stored-cmir.ts)
  • Public lookup by name: /api/topic-starters/by-path
  • Where they show: packages/debate-webview/src/lib/reason-docs/sidebar-routes.ts
  • Editor: /reason-editor (debate-editor's CardMirror embed)

Where the panels mount

ReasonDocsSidebarPanels is one component mounted in two places:

  1. AppSidebarShell's <aside>, on the routes showsReasonDocsPanels admits — /cards (the dock's "Shared" destination, and any page below it) and /reason-editor, whose desktop file navigation is this sidebar, the editor route having no <aside> of its own.
  2. /reason-editor itself, as a collapsible strip below md, since that sidebar is hidden md:flex.

They are deliberately absent everywhere else. The panels used to ride in every sidebar, /videos included — which took a third mount, an app-owned docsSlot on LecturesPage, since /videos renders its own sidebar rather than the shell. That put a document tree above the video library's own nav on a page that is not about documents; the slot is gone and the videos sidebar is the dock plus the video nav (packages/debate-videos/test/lectures-sidebar.test.tsx). The trade is that a file opened from /cards is no longer one click away after hopping to the video library — the dock hop back is.

In the sidebar the panels sit under the dock and above the nav tree: the tree is long enough (a section auto-expands to show where you are) that anything below it starts under the fold.

What else the /cards sidebar carries

Nothing. Clicking "Shared" in the dock otherwise lands on a column holding four navigations at once — the document panels, the whole Apps / Coaching / Research / Practice tree, the glossary and rankings links, and the site footer — on a page that is about the first of them.

showsCardsOnlySidebar (same module) marks the /cards subtree, and there AppSidebarShell renders the dock and the docs panels and stops — no nav tree, no reference links, no footer. Dropping the tree is also what frees the column's height for the panels to divide (next section); narrowing it to its Research section, as an earlier pass did, still left a submenu of cards links under the file tree and still made the column something to scroll. The Research entries are not lost: they are in the full tree that every other tool route's sidebar carries, one dock hop away. The dock itself stays — it is the control the reader clicked "Shared" in, and the way back to /videos.

/reason-editor keeps the full tree. It is reached from those other sections rather than being one of them, and unlike /cards its sidebar is not only the documents — the tree under the panels is how a reader leaves the editor for the tool they were writing for.

The section is expanded by default on both routes — they are the only two it mounts on, and on /cards these panels are the whole sidebar, so a collapsed "Documents" row would leave that column empty but for the dock. A reader's own collapse still wins and sticks, per-device, in localStorage. Nothing is fetched until the section is actually open, so collapsing it costs no document request.

Dividing the column's height

With nothing under them on /cards, the panels take the column's leftover height rather than a fixed slice of it — AppSidebarShell passes fill, and that route's <aside> stops scrolling as a whole (a scrolling parent has no height to hand a flexible child). Each panel then scrolls inside its own share.

The shares are the reader's to set. The enabled panels are a vertical react-resizable-panels group with a draggable separator on every seam, so a reader with forty files can give the tree most of the column and leave Open Tabs a couple of rows — sizes that used to be fixed in CSS, with the lower panel capped at 40%. A panel cannot be dragged below 72px, which is a heading plus a row: less than that is a sliver that says nothing and is too thin to aim at to drag back.

A drag is remembered per-device in localStorage (reason-editor-sidebar-layout), alongside the panel choice and the collapse state. Only a drag is stored — the group reports its computed layout on mount and on a constraint recompute through the same callback, and writing those back would overwrite a deliberate split. A layout belongs to the panel set it was dragged on, so switching a panel on reopens the group at the sizes saved for that set instead of squeezing the new panel into what the others left; a set with no saved sizes opens on the defaults, which weight Files 3 against 2 for each of the others. The stack order, the weights, the drag floor and the parsing of a stored split are lib/reason-docs/panel-layout.ts, kept out of the component so they can be unit-tested without rendering a sidebar (packages/debate-webview/test/lib/reason-docs/panel-layout.test.ts); reading localStorage stays in the component, alongside the panel-choice and collapse-state reads it already does.

Off /cards the box keeps its definite height (h-[380px], capped against the viewport), because something else sits below it in a scrolling column: /reason-editor's sidebar has the nav tree under the panels, and below md they ride as a strip above the editor. The separators work there too — they just divide a fixed box instead of the column.

Opening a file

Picking a file — an owned document, a public topic starter, or an open tab — does two things:

  • sets it active in ReasonDocsProvider, so a client-side hop switches the editor instantly; and
  • routes to /reason-editor?doc=<file name> (or ?topic=<file name>), so the selection is addressable.

Provider state alone covers the hop but not a reload, a pasted link, or a hard navigation — /videos is its own layout branch and can boot the editor with an empty provider. Carrying the file in the query is what makes "click a file, get that file in CardMirror" hold from every sidebar rather than only from the ones that hop client-side.

The name is the title slugified (lib/reason-docs/doc-slug.ts): lowercased, accents folded, apostrophes dropped, everything else non-alphanumeric collapsed to single hyphens, capped at 80 characters on a word boundary, and untitled when a title leaves nothing behind. Titles are not unique and ids are, so two files that slugify the same are told apart by a ~<id> suffix — impact-turns~d12 for document 12, impact-turns~t7 for topic starter 7. No title can forge one: ~ is stripped out of titles by the same slugifier.

Already on the editor route, a pick routes nothing at all. /reason-editor and /reason-editor/<slug> are separate Next routes, so routing between them would remount CardMirror — and its undo history — on every tab switch; the provider already has the file open, and ReasonDocsRouteSync renames the address bar in place with history.replaceState. That same rewrite is what upgrades an older ?doc=<id> / ?topic=<id> link (still read, and still resolved) to the named path once the catalogue says what that file is called, and what keeps the address on a file that is renamed while open. Any other query parameter is kept — ?share= and ?shareWith= are read by the same page.

ReasonDocsRouteSync (mounted once on /reason-editor, inside a <Suspense> since it reads useSearchParams, renders nothing) reads it back. The rules themselves are pure functions in lib/reason-docs/route-selection.ts, shared with the sidebar so the two ends of the round trip cannot drift, and unit-tested in lib/reason-docs/__tests__/route-selection.test.ts.

Resolution order, per URL:

  1. the URL's topic name, if it names a live topic starter;
  2. the URL's doc name, against the reader's own tree and then the public one — a shared link names a file, not a table, so the same URL has to work for the person who owns the file and the person who only has the public copy;
  3. a server lookup for a name neither tree placed (below);
  4. otherwise, and only when nothing is open, the first file — so the reader lands on something readable instead of an empty pane.

A name that matches nothing anywhere (a deleted file, someone else's private document) falls through to that same fallback rather than erroring. The deep link applies once per URL: after it opens its file the reader can pick another from the sidebar without the stale query dragging them back, and the sidebar rewrites the query on every pick, which re-arms it.

The deep-link rule and the first-file fallback are one function on purpose. As two React effects they raced: the fallback's closure still saw no selection in the commit where the deep link opened its file, and opened the first file over the top of it.

The URL is the file's name

?doc=impacts/warming-1ac, not ?doc=34. lib/reason-docs/doc-path.ts builds a path from the row's own title and the folders above it, and reads one back; the sidebar and the public lookup route both go through it, so the two ends cannot drift. It owes callers three things:

  • Stable. The file extension is dropped before slugging, so the .docx a reader uploaded and the .cmir it is stored as address the same file. Accents fold (Réchauffement → rechauffement) rather than collapsing to a row of dashes.
  • Unique. Two files can share a name; two URLs cannot. Duplicates are disambiguated by row id, and the lower id keeps the bare path, so a link in someone's history doesn't start opening a different file because a newer file was uploaded or the sidebar re-sorted.
  • Forgiving. A URL that names only the file (?doc=warming-1ac) resolves when exactly one file ends that way — two candidates fall through to the fallback rather than a coin flip. A folder resolves to the first file inside it (descending into subfolders), so a folder link lands on something readable. And a numeric ref still resolves as a row id, so links minted before paths keep working. A rename is forgiven once: documents.previous_slug holds the file's own slug from just before its last rename, and a URL that matches neither the current path nor any other file's resolves against that instead before falling through to the fallback.

A public file's URL works for anyone

The browsable catalogue (/api/topic-starters) is capped at 100 rows for the sidebar's sake, so it cannot be what a link resolves against: a reader following a shared link may be signed out, with no documents of their own and a catalogue that never held the file. When neither tree places the name, ReasonDocsRouteSync asks /api/topic-starters/by-path?path=<name> once, which searches every published row (using the same findItemByRef) and returns the one file, content included, so the editor opens it in the same trip. Published rows only, in both the match and the ancestry. A lookup that comes back empty is remembered for that URL, so the link falls through to the normal fallback instead of asking again on every render.

The Documents row

Above the panels sits the row of actions on files: New file, New folder, Upload, and — on the editor route, signed in, with a document open — Share with contacts (components/reason-editor/ShareWithContacts, drawn in its icon variant so it matches the three beside it). Sharing was a labelled button in the editor's own header strip; it is an action on the open file, so it belongs in the row that file's other actions are in. It is scoped to the editor route because sharing starts a live co-editing session through the CardMirror engine, and that page is what mounts the engine — on /cards the control would open a dialog that could only fail.

Uploading a file

The Documents header has an Upload button, and the file tree takes a drop from the desktop — onto a folder to import there, anywhere else to import at the root. Both go through importFiles on the provider.

Every upload is stored as a CardMirror native file, whatever it arrived as (lib/cardmirror/stored-cmir.ts): a .docx through docxToCmir, a .cmir kept byte-for-byte (parsed first, so a mislabelled file is refused now rather than on open), and .html / .md / .txt parsed through the schema. The card parser is not in this path at all — .cmir is the only shape that holds what CardMirror's OOXML reader gets out of a Verbatim document, and the HTML the app would otherwise keep flattens the outline, comments, images and marks away.

The row keeps the name it was uploaded under, extension included, so the .docx you dropped is the .docx you see in the sidebar (with its type on a badge); clicking it opens it in CardMirror, decoded from the stored .cmir. The first file of an upload is opened and routed to, which makes uploading and clicking one file the same gesture. One file that cannot be converted doesn't lose the rest of a drop: it comes back as a message under the tree, naming the file and the remedy.

Editing an imported file keeps it a .cmir. The editor reports HTML, so the live copy of an open file is held as HTML in a ref and re-encoded on its own 600ms debounce in front of the save queue's — gzipping a card document on every keystroke would be felt. An edit still waiting to be encoded when the page goes away is encoded synchronously by the same pagehide / tab-hidden handler that flushes the save queue, so the last half-second of typing isn't lost. A re-encode that fails leaves the stored file alone rather than overwriting a readable .cmir with a failure.

documents.format records which shape a row holds, exactly as topic_starter_items.format does (drizzle/0038_document_cmir.sql); documents written in the editor stay HTML and are untouched by any of this.

Read-only topic starters

A topic starter opens in the same CardMirror embed as an owned document, but read-only: no toolbar, no AI tools, no onChange, a readOnly title, and a "Public topic starter" label beside it. It gets its own contentKey namespace (topic-<id>) so the engine treats it as a distinct claim from an owned document that happens to share the id — the same reason ?doc=7 and ?topic=7 are different URLs.

Topic starters are stored as .cmir

The admin importer on /admin takes a .docx (or a .zip of them) and stores each one as a CardMirror native file — .cmir, gzipped JSON, base64-encoded into the row's content column, with format set to "cmir".

The conversion is CardMirror's own OOXML importer (docxToCmir in packages/debate-editor/src/native/convert.ts — fromDocxFull piped into serializeNative, the same pair the desktop bulk converter runs), wrapped for the route by apps/debate-ai.com/lib/topic-starters/import.ts. What that buys over the card HTML the importer used to store is everything HTML has no element for: the Verbatim outline (pocket / hat / block / tag / analytic) as typed nodes rather than <h1>…<h4>, comment threads, inline images, and the named character styles that distinguish a cite from an emphasis from a plain underline.

Reading it back is client-side: storedContentToHtml (lib/cardmirror/stored-cmir.ts, aliased for this table as topicStarterHtml) parses the file with parseNative and serializes it through the schema's own toDOM specs for the embed's content prop, memoized per file on /reason-editor. Doing it in the browser keeps the catalogue endpoint — which returns up to 100 rows — from converting files nobody opens.

The rules are the app's rather than this table's — the reader's own uploads are stored the same way — so they live in lib/cardmirror/ and lib/topic-starters/format.ts is the Topic Starter names for them.

Rows imported before this carry HTML and still open: format says which a row is, and content that arrives without its row falls back to sniffing the bytes for the gzip magic. A .cmir that will not parse renders a notice naming the failure rather than a blank document.

Known gaps

  • A dragged split is remembered per-device, so it does not follow a reader to another machine — and it is keyed by panel set, so three sets of panels are three separate splits to arrange.
  • showsReasonDocsPanels is a hard-coded route list, not something derived from the nav data. A new documents route has to be added to it by hand.
  • SharedCardOpener's ?share=<id> flow replaces the URL with a bare /reason-editor, dropping any ?doc=/?topic= alongside it. The two never travel together in practice (a share link comes from /contacts), but nothing enforces that.
  • Conversion runs in the import request, so a 100-file ZIP converts 100 documents inside one Worker invocation — heavier than the regex pass it replaces. A batch large enough to hit the CPU limit fails as a dead request, leaving the files converted before it in the library. Splitting the archive is the workaround; moving the conversion into the browser (as the Parquet card importer does) is the fix.
  • There is no way to download a topic starter's .cmir. The file is stored and opened in the editor, but the library exposes no file endpoint. The same holds for an uploaded document: it goes in as a .docx and can only come back out through the editor. Fixed: both FileTree's and TopicStarterTree's row menus have a "Download as .docx" item (not offered for a folder row — there is nothing to convert). FileTree's wires to ReasonDocsProvider.downloadDocument, which runs the row's HTML (whatever shape it is stored in — documentHtml already resolves that, live edits included) through htmlToDocxBytes (lib/cardmirror/stored-cmir.ts, htmlToDoc + debate-editor/engine's toDocx) and triggers a browser download named by docxDownloadFilename. TopicStarterTree's wires to the new ReasonDocsProvider.downloadTopicDocument, the same conversion run against topicStarterHtml(item) instead — a topic starter is read-only, so unlike downloadDocument there is no live-edit lookup to resolve first. Both share the actual browser-download trigger (triggerDocxDownload, a small helper in ReasonDocsProvider.tsx) so the Blob/anchor-click mechanics live in one place. ReasonDocsSidebarPanels shows a download failure from either tree under that tree specifically (downloadError for Files, topicDownloadError for Topic Starters), not the other. The editor itself still exposes no export action of its own — opening the sidebar row menu is still the only way to export — which is unchanged and not tracked as a gap, since it was never a stated goal. Vitest-covered: packages/debate-webview/test/lib/cardmirror/stored-cmir-render.test.ts adds the storedContentToHtml/topicStarterHtml coverage this download path leans on (a legacy HTML row passed through untouched, a .cmir row parsed and reserialized, format-less sniffing, an absent-content row, and a .cmir that fails to parse rendering its escaped-safe notice instead of throwing) that neither function had before — previously they only fed the editor's content prop, where the same bug would have been a rendering glitch rather than a broken or silently wrong file download.
  • Renaming a file changes its URL, and nothing redirects the old one — it falls through to the first-file fallback like any other name that no longer resolves. Fixed: documents gets a nullable previous_slug column (drizzle/0046_document_previous_slug.sql). PUT /api/doc/documents/:id sets it to the file's own old slug (slugifySegment(doc.title)) whenever a title edit actually changes that slug — a capitalization or punctuation-only edit that resolves to the same slug leaves it alone, and an old title with nothing sluggable in it has no slug worth remembering. findItemByRef (lib/reason-docs/doc-path.ts) tries each file's previous path — its current path with only the leaf segment swapped back to previousSlug — the same exact-then-suffix way it already tries the current path, after both of those come up empty and before falling through to the folder/first-file fallback. This remembers one rename back, not a full history: a link from before a file's second-most-recent rename still falls through, and a folder rename (which moves every file under it) isn't tracked here at all — only a file's own leaf-level rename is.
  • Upload converts one file at a time in the browser and has no .zip expansion, so a squad file set arrives as a multi-select rather than an archive. The admin Topic Starter importer is the one that unpacks a ZIP.
  • ?doc= reaching into the public library means a reader with no file by that name silently gets the public one. That is the point for a shared link, but it also means a private file deleted mid-session can be replaced in the pane by a public file that happens to share its name.