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, perpackages/debate-webview/src/lib/reason-docs/sidebar-routes.ts - Panels:
packages/debate-webview/src/components/reason-docs/(ReasonDocsSidebarPanels,FileTree,TopicStarterTree,OpenTabsPanel), overReasonDocsProviderin the root layout - URL rules:
packages/debate-webview/src/lib/reason-docs/route-selection.ts, over the filename paths inpackages/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:
AppSidebarShell's<aside>, on the routesshowsReasonDocsPanelsadmits —/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./reason-editoritself, as a collapsible strip belowmd, since that sidebar ishidden 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:
- the URL's
topicname, if it names a live topic starter; - the URL's
docname, 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; - a server lookup for a name neither tree placed (below);
- 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
.docxa reader uploaded and the.cmirit 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_slugholds 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.
showsReasonDocsPanelsis 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'sFixed: both.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.docxand can only come back out through the editor.FileTree's andTopicStarterTree's row menus have a "Download as .docx" item (not offered for a folder row — there is nothing to convert).FileTree's wires toReasonDocsProvider.downloadDocument, which runs the row's HTML (whatever shape it is stored in —documentHtmlalready resolves that, live edits included) throughhtmlToDocxBytes(lib/cardmirror/stored-cmir.ts,htmlToDoc+debate-editor/engine'stoDocx) and triggers a browser download named bydocxDownloadFilename.TopicStarterTree's wires to the newReasonDocsProvider.downloadTopicDocument, the same conversion run againsttopicStarterHtml(item)instead — a topic starter is read-only, so unlikedownloadDocumentthere is no live-edit lookup to resolve first. Both share the actual browser-download trigger (triggerDocxDownload, a small helper inReasonDocsProvider.tsx) so theBlob/anchor-click mechanics live in one place.ReasonDocsSidebarPanelsshows a download failure from either tree under that tree specifically (downloadErrorfor Files,topicDownloadErrorfor 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.tsadds thestoredContentToHtml/topicStarterHtmlcoverage this download path leans on (a legacy HTML row passed through untouched, a.cmirrow parsed and reserialized, format-less sniffing, an absent-content row, and a.cmirthat fails to parse rendering its escaped-safe notice instead of throwing) that neither function had before — previously they only fed the editor'scontentprop, 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:documentsgets a nullableprevious_slugcolumn (drizzle/0046_document_previous_slug.sql).PUT /api/doc/documents/:idsets 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 topreviousSlug— 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
.zipexpansion, 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.
