Document Sidecar
A slide-in PDF/email viewer with live AI topic-summary bubbles, running as a second concurrent moderator lane.
Context
Today an Align room shows only chat + a "Coverage" rail of section titles/statuses. Participants review a PDF or Outlook email thread but never see the document itself, and the moderator's per-topic understanding is only visible post-session (the annotated review record).
This adds a document sidecar: a slide-in card (open on click) that renders the room's uploaded PDF (self-hosted PDF.js) or email thread, with live moderator-generated topic summaries as bubbles anchored to the relevant section/email. The moderator gains a second concurrent lane (lane B) that summarizes the topic currently being discussed and pushes it to the sidecar, independent of the main-chat moderation (lane A).
Outcome: reviewers see the source beside the conversation, and the moderator's understanding of each topic surfaces as it forms, grounded in the document — not just at the end.
Research outcome — viewer selection
- PDF → self-hosted PDF.js (Mozilla). Adobe PDF Embed API rejected: no offline/self-hosted mode, requires internet + a domain-bound client ID — incompatible with Align's local-first, no-CDN design. PDF.js is fully offline once vendored (like GSAP/Three), and exposes a positioned text layer used for client-side section anchoring.
- Email → render from the existing server parse, not a new lib. No embeddable Outlook web
renderer exists.
server/execution/parsers/email.jsalready yields{messages:[{from,date,subject,body}]}, so the sidecar renders our own escaped/styled thread. (Reference libs if ever needed: postal-mime / eml-parser / msgreader + DOMPurify — not adopted v1.)
Locked decisions (confirmed with user)
- Section-anchored bubbles (v1). Clicking a topic scrolls the doc to that section and pins the bubble beside the heading, located client-side via PDF.js text-layer search for the section title. Email = message-anchored (scroll to the message referencing the topic). No pixel-precise server coordinate feed in v1.
- Enterprise = direct-to-Resident. In
TIER=enterprisethe browser fetches document bytes AND summary text straight from the Resident (:3200); the coordinator stays document-blind and never handles bytes. InTIER=teameverything runs in the one process.
Architecture — two lanes, plane-aware "side wall"
- Lane A (existing, unchanged): main-chat moderation on the 1s room tick (
rooms.jsstartChat). Guards:room.modBusy(moderator-context.js:104),room.nluBusy. - Lane B (new): live topic summarization. Trigger =
room.goal.lastDiscussedIdchange + section maturing (messageCount>=2, set inontology/goals/docreview.js:89). Debounced, own single-flight guard, lighter model — can never starve lane A. Reuses the summary shape fromknowledge.reviewSummaries(), made incremental.- Topic-scoped context (critical — multiple topics interleave): lane B must summarize a topic
from only the messages attributed to that
sectionId, never the raw last-N global chat. Attribution is the samesemantic/matcher.matchEntitiesthe goal engine already uses indocreview.reduce. Implementation: maintain a per-section rolling message bufferroom.goal.sections[id].msgs(append the attributed message insidereducewherelastDiscussedId/messageCountare already updated), and feed that buffer + the section's own grounded excerpt fromsearch(sectionTitle)intosummarizeTopic. This keeps each bubble about its own topic even when discussion ping-pongs between sections.
- Topic-scoped context (critical — multiple topics interleave): lane B must summarize a topic
from only the messages attributed to that
- Plane split (the side wall):
team→ lane B + byte serving in the monolith, summary rides the socket inline.enterprise→ lane B runs inside the Resident (it holds the document; the moderator is document-blind); the client pulls bytes + summary text directly from the Resident; the coordinator emits only a contentlesstopic:summarysignal. UnderTRUST_EPHEMERAL, summaries are sealed at rest withK_room(ephemeral-key.js:52-78) and crypto-shredded ondialog-end.
One event contract, discriminated by source, shared by both tiers:
topic:summary { sectionId, seq, source:'monolith'|'resident',
ref:{kind:'pdf',sectionTitle} | {kind:'email',messageIndex},
text? /* present ONLY when source==='monolith' */ }Modularity & layered hierarchy (must hold)
Follow the existing four-layer architecture — no cross-layer shortcuts, LLM only via the injected
port ({chat,extractJSON,embed}), enforced by test/layers-test.js:
- semantic (
semantic/matcher.js): message→section attribution — reused, not reimplemented. - ontology (
ontology/goals/docreview.js): topic lifecycle + the per-section message buffer (sec.msgs) that scopes lane-B context. No I/O, no LLM here — pure goal state. - context (
context/knowledge.js):summarizeTopic— retrieval + LLM summarization behind the port. Knows nothing about sockets, HTTP, or the sidecar. - execution (
execution/presenter.js,http-api.js,resident/index.js,kxp-client.js,rooms.js,index.js): wiring only — the tick hook, event emit, routes, plane branch, Resident. - client (
public/v4/,lib/align/client.ts): rendering only, over the SDK/protocol.
New client-side viewer logic is its own module (public/v4/sidecar.js, lazy-imported like
scene4.js) so v4.js stays a thin wiring layer and a PDF.js load failure is isolated.
Deliverable location
This plan lives in docs/ alongside TRUST.md, DEPLOYMENT.md, and
EPHEMERAL-ARCHITECTURE.md as the module's design document.
Phased implementation
Phase 1 — Vendor PDF.js + raw-document route + viewer shell (team path)
server/index.js(after :75):app.use('/vendor/pdfjs', express.static('node_modules/pdfjs-dist/build')); addpdfjs-disttopackage.json(pin a version; worker served same-origin, never CDN).server/execution/http-api.js(by :335):GET /api/rooms/:code/document/raw→application/pdffromroom.doc.pdfBuffer, or{kind:'email',title,messages}fromroom.doc.emailMessages;409 {error:'bytes-off-plane',plane:'resident'}whenroom.compliant(the enterprise discriminator);409 {error:'not-retained'}for seeded/replayed rooms. Reuses retention at :156 / null-at :63.public/v4/v4.js: lazyloadPdfjs()mirroring thescene4.jslazy-import (:67) withGlobalWorkerOptions.workerSrc='/vendor/pdfjs/pdf.worker.min.mjs';renderDoc(container)branches on content-type (PDF → page canvases + text layer; JSON → email list).
Phase 2 — Sidecar UI: slide-in, open-on-click, render, section scroll
public/v4/index.html(sibling of#takeaway, ~:507):#sidecar(.sc-head/#sc-title/#sc-close,#sc-doc,#sc-bubbles) +#sc-opentab. Show#sc-openonly whengoalType==='docreview'anddoc.title.public/v4/v4.css:.sidecarfixed right,translateX(100%)→.open{translateX(0)}withtransition: transform .28s(plain CSS, no GSAP, matching#takeaway); mobile full-width overlay under the existing 1-col breakpoint (never a 3rd.room-bodycolumn).public/v4/sidecar.js(new module, lazy-imported fromv4.js):openSidecar()/close,renderDoc,scrollToSection(title)(walks each page text-layer for the best title match →scrollIntoView+ return heading rect),scrollToMessage(idx)viadata-msg-idx,renderBubble.v4.jsonly wires the#sc-openclick + thetopicSummarysubscription to this module — keepingv4.jsthin.
Phase 3 — Lane B live summarization (team/monolith)
server/ontology/goals/docreview.js(ontology layer): inreduce()wherelastDiscussedId/messageCountare set (:89), also append the attributed message text to a bounded per-section buffersec.msgs(cap ~12, drop oldest). This is the topic-scoped context source — attribution already happens here, so no second attribution pass.server/context/knowledge.js(context layer): addsummarizeTopic(room, sectionId) → Promise<{sectionId,title,ref,text,seq}>(export at :617, prompt inPROMPTS:633). Context =room.goal.sections[sectionId].msgs(topic-scoped, from ontology) + the section's grounded excerpt viasearch(sectionTitle)— never the global transcript. Onellm.chat({model:SUMMARY_MODEL,maxTokens:90,temperature:0.3,timeoutMs:15000})behind the injected LLM port; deterministic fail-soft one-liner (mirrorreviewSummaries:589).SUMMARY_MODEL=process.env.SUMMARY_MODEL||ollama.MODEL. Emailref.messageIndexby matching title→emailMessages; PDFref={kind:'pdf',sectionTitle}.server/execution/presenter.js: addmaybeSummarizeTopic(room)— debounce onroom.goal.lastDiscussedIdvsroom.summaryLastId; requiremessageCount>=2; single-flight on newroom.summaryBusy; ≥8s per-topic cooldown (room.summaryAt); bumproom.summarySeq.server/index.js+server/execution/rooms.js: new manager handleronTopicTick(room)→presenter.maybeSummarizeTopic(room)called fromstartChat(:163) right afteronCheckNudges— reuse the existing 1s tick, no new interval. Lane A'smodBusy/nudge path untouched.
Phase 4 — topic:summary event + anchored bubbles
server/execution/presenter.js: emitio.to(channel(room)).emit('topic:summary', payload); persistroom.topicSummaries[sectionId]=payload. Do NOT touchpublicRoomState(:34, frozen).server/execution/http-api.js:GET /api/rooms/:code/topic-summaries?since=seqcatch-up (409→resident under enterprise).lib/align/client.ts: one line in the wire→SDK event map:'topic:summary':'topicSummary'.public/v4/v4.js:client.on('topicSummary', …)→renderBubble(reusemdToHtml:592) into#sc-bubbles, thenscrollToSection/scrollToMessageand pin beside the heading/message rect; de-dupe/replace bysectionIdkeeping latestseq(mirror the[data-msg-id]mutate pattern :464-511). If no text-layer match (scanned PDF) → pin to page top, flag "approx." — never drop the summary.
Phase 5 — Enterprise plane split (Resident routes, direct-to-Resident, sealing)
server/resident/index.js: bind configurableRESIDENT_BIND||'127.0.0.1'(:243); CORS (RESIDENT_CORS_ORIGIN+ OPTIONS 204) on the new GET read routes only (keep KXP write routes HMAC/loopback). AddGET /kxp/document/raw?room=(bytes/email fromd.pdfBuffer/d.emailMessages),GET /kxp/summaries?room=&since=(keyring.opendecrypt-on-read;[]after shred),POST /kxp/summarize(HMAC-verified like/kxp/ask:177). Lane B here usesd.summaryBusyseparate fromd.busy(:57) so live summaries and@hasks never serialize against each other; reusesummarizeTopicagainstd.pseudoRoom; modelRESIDENT_SUMMARY_MODEL||RESIDENT_MODEL. Seal on completion (keyring.seal(room, …)); dropd.summariesondialog-end(:201) = crypto-shred.server/execution/presenter.maybeSummarizeTopic: branch onroom.compliant— team = in-process + emit withtext; enterprise =kxp.summarizeSigned(...)(add tokxp-client.jsmirroringaskSigned:62) then emit contentless{sectionId,seq,source:'resident',ref}.server/index.js: addresidentPublicUrl: process.env.RESIDENT_PUBLIC_URLto/api/trust(:93) — the browser-reachable Resident origin (loopbackresidentUrlisn't a browser origin).public/v4/v4.js: storeS.residentBasefrom/api/trust;docSource/renderDocfall back to${S.residentBase}/kxp/document/rawon409 bytes-off-plane;topicSummarywithsource:'resident'→fetch ${S.residentBase}/kxp/summaries?since=thenrenderBubble; one catch-up pull on sidecar open.
Phase 6 — Tests
test/harness/sidecar-team-test.js(new):spawnServer({TIER:'team'}), uploadgsPdf, drive chat that flipslastDiscussedId; assertdocument/rawisapplication/pdf, atopic:summarywithtext+refarrives, and a moderator nudge + a summary both fire in one window (lane B doesn't block lane A).test/harness/sidecar-enterprise-test.js(new): moderatorTIER=enterprise+ spawned Resident (RESIDENT_BIND/RESIDENT_CORS_ORIGIN); assert monolithdocument/raw→409 bytes-off-plane, Resident/kxp/document/rawreturns bytes,topic:summaryarrives withouttext(source:'resident'),/kxp/summariesreturns decrypted text, and[]afterdialog-end(shred). Add both totest/run-all.js.test/v4-test.js(headless Chrome): click#sc-open→#sidecar.open,#sc-dochas PDF.js canvas/text-layer (proves offline/vendor/pdfjsworker), a real chat topic-switch yields an anchored#sc-bubblesnode; scanned-PDF "approx." fallback; narrow-viewport full-width overlay with.room-bodyintact.
Critical files
server/execution/http-api.js— raw-document + catch-up routes, 409 discriminatorsserver/execution/presenter.js—maybeSummarizeTopiclane-B driver,topic:summaryemit, team/enterprise branchserver/context/knowledge.js— newsummarizeTopic(reusematcher/search, light model)server/resident/index.js— client-reachable bind+CORS,/kxp/document/raw,/kxp/summaries,/kxp/summarize,d.summaryBusy,K_roomsealingpublic/v4/v4.js— PDF.js lazy loader, sidecar open/render, section/message anchoring,topicSummaryhandler, direct-to-Resident fetch- Supporting:
server/index.js(/vendor/pdfjs,residentPublicUrl),server/execution/kxp-client.js(summarize/summarizeSigned),lib/align/client.ts(one map line),public/v4/index.html+v4.css(sidecar + CSS slide),package.json(pdfjs-dist).
Key risks & mitigations
- PDF.js worker offline: same-origin worker only; lazy-import so a vendor 404 degrades to a "preview unavailable" panel, never crashes the module graph. Pin
pdfjs-dist, verify worker filename. - Large PDFs (≤15MB cap): render pages lazily on scroll (IntersectionObserver), cap concurrent renders.
- Heading not in text layer (scanned PDF): section search fails → page-top "approx." pin, never drop the summary.
- Resident not browser-reachable / CORS: default loopback+no-CORS today; Phase 5 makes bind+CORS explicit config and ships
RESIDENT_PUBLIC_URL; sidecar stays chat-only if Resident unreachable. - Lane B backlog: dedicated busy guard (team
room.summaryBusy/ Residentd.summaryBusy≠d.busy), debounce, ≥8s cooldown, lighter model, shortmaxTokens. - Frozen wire shapes: never extend
publicRoomState/docreview.snapshot; summaries travel on the new event + catch-up route only.
Verification
- Team:
npm run team; upload a PDF and an.eml; open sidecar; confirm live bubbles anchor on topic switches. - Enterprise:
npm run resident(RESIDENT_BIND/RESIDENT_PUBLIC_URL/RESIDENT_CORS_ORIGIN) +npm run enterprise; confirm moderator holds no bytes (document/raw→409), the browser pulls bytes+summaries from :3200 directly, and summaries vanish on room end. - Automated: the two new harness suites +
test/v4-test.jsadditions above;npm test/npm run qa.
Last updated on
