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

The two collapsible sections, Topic Starters and My Personal Documents, that sit above the Videos library 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: /research/cards (and below it) and /reason-editor — the routes the documents are the subject of, per apps/debate-ai.com/lib/reason-docs/sidebar-routes.ts
  • Panels: packages/debate-webview/src/components/reason-docs/ (ReasonDocsSidebarPanels, FileTree, TopicStarterTree, FileManagerDialog), over ReasonDocsProvider in the root layout
  • URL rules: apps/debate-ai.com/lib/reason-docs/route-selection.ts
  • Where they show: apps/debate-ai.com/lib/reason-docs/sidebar-routes.ts
  • Editor: /reason-editor (debate-editor's CardMirror embed), with its tab strip in components/reason-editor/EditorTabStrip.tsx and the tab rules in lib/reason-docs/tab-actions.ts

Where the panels mount

ReasonDocsSidebarPanels is one component mounted in two places:

  1. AppSidebarShell's <aside>, on the routes showsReasonDocsPanels admits — /research/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. The trade is that a file opened from /research/cards is no longer one click away after hopping to the video library — the dock hop back is.

In the sidebar the sections sit under the dock and above the Videos tree: the tree is long enough (a section auto-expands to show where you are) that anything below it starts under the fold. Topic Starters comes first, then My Personal Documents. There is no panel switcher and no Open Tabs list: the open files are the tab strip above the editor (below).

Each section has a chevron header and is expanded by default. Collapsing one sticks, per-device, in localStorage (reason-editor-sidebar-topics-open, reason-editor-sidebar-files-open). Nothing is fetched until a section is actually open, so a page that leaves both collapsed makes no document request at all.

Tabs above the editor

Every file opened on /reason-editor gets a tab in the strip above CardMirror. Click a tab to switch to it, or its × to close it. Right-click a tab for:

  • Close that tab.
  • Close others: every tab but this one, which becomes the open file.
  • Close to the right: every tab after this one.
  • Close all.
  • Undo close: reopens the most recently closed tab. It walks back through the last 20 closed tabs, skipping files deleted since.

Closing the open tab moves to its neighbour, preferring the tab that slid into its place.

Opening a file

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

  • sets it active in ReasonDocsProvider, so a client-side hop switches the editor instantly; and
  • routes to /reason-editor/<the file's name> — /reason-editor/cp-answer-to-states, not /reason-editor?topic=2 — so the selection is addressable, and legible in a shared link.

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 URL 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 path's name — an exact ~<id> discriminator first, then the bare slug matched against topic starters and then the reader's own documents, then the segment read as an id;
  2. the URL's topic id, if it names a live topic starter;
  3. the URL's doc id, if it names one of the reader's own non-folder documents;
  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 or id that matches nothing (a renamed file, a deleted one, someone else's link) falls through to that same fallback rather than erroring — a stale link is not a 404 — and a malformed id (?doc=abc, ?doc=3.5) reads as absent. The deep link applies once per URL: after it opens its file the reader can pick another from the sidebar without the stale URL dragging them back, and every pick rewrites the address, 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.

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: topicStarterHtml (lib/topic-starters/content.ts) 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.

Rows imported before this carry HTML and still open: format says which a row is (lib/topic-starters/format.ts), 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.

Organising files

Every file is a CardMirror file whatever it was uploaded as, so no row in either tree carries a DOCX or CMIR badge; the name is the whole label.

Right-clicking a row opens the same menu as its "…" button, so every action on a file is one gesture from the file itself:

  • Files: Open, Edit, Share…, Rename, Duplicate, Move to (any folder, or the top level), Download as .docx, Add to Topic Starters, Delete. Folders get New File / New Folder in place of Open, Edit and Share.
  • Topic Starters: Open, Edit a copy, Save to my files, Copy link, Download as .docx. The catalogue is read-only, so a topic starter is edited by editing your own copy of it.

Share… opens the file in the editor with the share-with-contacts dialog already up (?shareOpen=1, read and stripped by ShareWithContacts). Sharing starts a live co-editing session in the editor, which is why the file has to be open there first.

The Sort button in the My Personal Documents header orders the tree by name (A–Z or Z–A) or by last modified (lib/reason-docs/file-sort.ts). Folders always come before files. The choice is remembered per device.

Moving files between Files and Topic Starters

Rows drag both ways between the two trees, and each drop means what it can (lib/reason-docs/drag-data.ts):

  • Topic Starters → Files: saves your own copy into the folder you drop on. A folder comes with everything in it, and a .cmir stays a .cmir. "Save to my files" in the menu does the same.
  • Files → Topic Starters: publishes the file to the public catalogue (POST /api/admin/topic-starters/items). Only admins can do this. Anyone else sees a message saying so.
  • Files → Files: moves the row, as before. A folder can't be dropped into itself.
  • Desktop → either tree: an upload, as before.

File manager

The File manager button in the My Personal Documents header opens a full-window file manager over the same files: SVAR's @svar-ui/react-filemanager, the widget quick search's REASON sidebar opens from its own file manager dialog. Every action it offers is routed back to the real rows (components/reason-docs/FileManagerView.tsx, with the path ↔ row mapping in lib/reason-docs/file-manager-data.ts):

  • Open (double-click or Enter) opens the file in this app's editor.
  • Add file, add folder and upload create rows in your folders.
  • Rename, delete, cut/paste and drag rename, delete and move your rows. Copy/paste duplicates them.
  • Download is the same .docx download as the sidebar menu.

Topic Starters appear under a read-only "Topic Starters" folder. Copying or moving out of it saves your own copy. Copying or moving one of your files into it publishes it (admins only). Renaming or deleting inside it is refused. The widget is loaded only when the dialog first opens.

Searching files from Ctrl/Cmd-K

The app-wide search palette (Ctrl/Cmd-K) lists matching files under Files, below the tools: your own files and the public Topic Starters, matched by name, folder, outline headings and contents (lib/reason-docs/file-search.ts). Every word you type has to appear somewhere. Name matches rank first, then outline matches (a pocket, hat, block or tag heading holding every word, shown as § Heading), then contents matches, which show the text around the match. Picking a file opens it in the editor.

Below the files, Videos & Lectures lists up to eight matches from the whole video library, rounds and lectures alike, most-viewed first. The palette asks the Videos page's own feed (/api/videos?q=…) once typing pauses, so nothing about the library is downloaded until you search. Picking one opens its watch page (lib/videos/video-palette-search.ts).

A .cmir file's text is read with CardMirror's engine, which is loaded the first time a query needs it, so the first search over card files can take a moment longer. On /reason-editor the palette answers Ctrl/Cmd-K, while Ctrl/Cmd-P stays the editor's own palette.

Known gaps

  • The docs section starts collapsed on /research/cards, so the files are one click away there rather than on screen. Fixed: both sections start expanded everywhere; a reader's own collapse is remembered per device.
  • Undo close lives in the tab strip's right-click menu, and the strip is hidden once every tab is closed, so after Close all there is no tab left to right-click. Reopen a file from the sidebar instead. There is no keyboard shortcut for it either: browsers keep Ctrl/Cmd-Shift-T for themselves.
  • 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. Fixed: ShareWithContacts.tsx's dropQueryParam (used by both the ?share= and ?shareWith= one-shot params) calls route-selection.ts#urlWithoutParam, which drops only the named parameter and keeps the rest of the URL — ?doc=/?topic= alongside a share link now survive being joined. Pinned by route-selection.test.ts's urlWithoutParam cases.
  • 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. Fixed: both the Files panel's and the Topic Starters panel's row menu now have a "Download as .docx" item — your own documents and public topic starters alike can be gotten out as a file without opening them in the editor first.
  • The Topic Starters catalogue sorted files before folders, so once it held more than its 100-row cap the folders were cut off and every file inside them vanished from the tree. Fixed: /api/topic-starters sorts folders first, and both trees show a row whose folder is missing at the top level instead of dropping it.
  • Folders in both trees started collapsed whenever the files loaded after the panel mounted (the usual case), and the Topic Starters search matched words from a row's raw JSON, so searching "folder" matched everything. Fixed: folders start open, and the search matches names and tags only.
  • Publishing to Topic Starters is admin-only and always publishes at once. There is no draft or review step for a reader's own file.
  • Ctrl/Cmd-K's file search converts every .cmir file it searches on the reader's device. That is fine for the 100-row catalogue and a typical library, but a very large library would want a server-side text index.
  • Choosing an outline match opens the file at the top, not at the heading that matched.
  • The video rows show only the first eight matches; there is no "more videos" row that carries the query over to the Videos page.

Save status above the editor

The status line over the REASON editor (/reason-editor) says where the open document stands: Unsaved changes (an edit is queued), Saving…, or Couldn't save — retrying (shown in red; your edits stay in the tab and the queue retries with backoff). Once the document is saved the line disappears. A public topic starter reads Public topic starter because it is read-only. The text comes from describeDocSaveStatus (lib/reason-docs/doc-save-status.ts).