Feature: Panel layouts transcript surface - #166
Merged
Conversation
…further up we-scroll-area gains pin="end". Every log in the app wants the same behaviour and none of them should implement it twice: a transcript, a chat, an activity feed. The conditional half is the whole feature. Pinning unconditionally yanks somebody out of what they scrolled up to re-read, every time a line lands — which in a live transcript is constantly. So the element remembers whether the reader was at the end before the content changed, and only follows if they were. Two observers, because content grows for two reasons and only one is a mutation: rows arriving are a childList change on the light DOM, and the host being resized changes what "the end" means without touching the content. Neither catches an image loading inside an existing row, which is worth knowing rather than worth a third observer — a log is rows of text. No event yet. A consumer wanting "N new" while scrolled up can have one when something actually renders that affordance; an event nobody listens to is API that has to be kept working for nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Everyone who used this asked for the same two things — see what other people said, and still have it there after the call restarts. Both were one cause, and neither needed anything built. The shared record already holds every agent's utterances, each carrying its author and the moment it was said, and it already outlives the session: spaceStore.exportCallTranscript has been reading exactly that to write a text file with real names in it. The panel was the only thing not looking at it. It rendered `recent` instead — a session-local buffer of the last twenty blocks *this* agent wrote, which is own-only, capped and gone on reload by construction. So it drills into the collection the same way the calls list already does for a finished meeting, oldest first, with $agent putting a real name and face on each line. Attribution needs no diarization: each agent transcribes only their own microphone, so the block's author is the speaker. `recent` is removed rather than kept as a local echo. There is already a finer one — `pending` shows the words not yet flushed — so an echo of the flushed blocks would only double-render the reader's own lines against the query. The empty state now gates on there being no record rather than on an empty buffer, which fixes a smaller bug in passing: re-opening the panel on a call that had already been transcribed offered to start recording as though nothing had ever been said. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s on it A call's transcript is a CollectionBlock with kind 'call', mode 'feed', its utterances as children and its roster as participants — and none of that is enforced anywhere. `kind` is a free-text string with no registry, nothing ties it to `mode`, and nothing says children are utterances in time order. That was fine while transcribe was the only reader. It is not any more: the panel now drills into the collection, CallsList renders finished meetings, Header creates one, the graph module styles call nodes, and exportCallTranscript writes a text file from it. Five readers, and none of them can import the constants — templates → modules and modules → modules are both sideways edges, so each spells 'call' itself. So the page is the contract, and it says so: change the shape and every row in its table changes with it. Deliberately not a shared kind registry — a free-text discriminator on a generic container is the symptom, and the answer is a declared content type rather than a lookup table of magic strings. Also records the two things a reader gets wrong: `include` crashes on CollectionBlock.children because it is untyped, and mode: 'feed' is what stops reconcileBlocks deleting other agents' utterances. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…te can place transcriptFeed is the utterances and nothing else — no capture controls, no status notes — exported and registered in the module's `schemas`, which exists precisely so a template can place a named fragment. It is the half of the panel that means something on its own: a template putting a transcript beside a graph wants the lines, not the chrome around them. That is also what the panel-layout work needs — a template-authored panel has to be able to hold this directly. Only the feed. The plan called for splitting captureControls out to match, and that is speculative: nothing places it separately, and a fragment with no second caller is an extraction waiting to be got wrong. extractionStatus was already its own file for an unrelated reason — it is contributed to the call bar rather than composed into this panel. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ch other Two floats snapped to the same edge overlapped outright — occupiedFor returns early for anything that does not displace, and the eight snaps are points rather than a column, so top-left and bottom-left put two seeded cards in two corners with a hole between them. A left-hand column sharing the height was not expressible at all. Note the axis, because an earlier reading of this got it wrong: insertionSlots sorts a left-edge strip by x and draws full-height vertical lines, so *displacing* panels stack perpendicular to their edge, growing inward. That is right for what it does. A column divides *along* the edge instead. Both arrangements are wanted; this adds the second and leaves the first alone. The division is flexbox, computed rather than delegated: each member has a base (its own h on a side edge, w on a top or bottom one) and a grow, spare room goes out by grow ratio, and a shortfall shrinks bases proportionally with a floor at MIN_FLOAT_PX. Proportions were the obvious alternative and are wrong — three panels at 2/1/1 that lose the middle one leave the survivors at 2/1 of the whole, so the top one grows for a reason nobody can see. Base-plus-grow gives a closed panel's room to its neighbours and moves nothing else, which is what people expect and what the test asserts. Real CSS flex is not available for this: making a column a flex container means reparenting panels out of fixed positioning, which remounts their subtrees and would drop a call's live video. Below NARROW_VIEWPORT_PX every member takes the whole region as a full-bleed sheet. Two 350px cards over content on a 400px viewport leave nothing of any of the three, so the arrangement changes its mind rather than shrinking — the same threshold that switches displacing off, for the same reason. Membership is implied by the snap, not declared, exactly as a strip's is. Corners are excluded: a corner is a place for one card and has no edge to divide. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
columnSlots is insertionSlots' sibling, and the axis is the whole difference: a strip's lines run across the region because its members stack inward from their edge, a column's run along it because its members share the edge. Same convention either way — a drop that can only report an edge can only ever append, so the seams become targets and the drop reports an index. insertDock now takes the mode. A strip is the panels displacing an edge, a column the ones floating on it; the renumbering is identical and the only differences are which set the dropped panel joins and whether landing there means taking room. activeInsert carries the mode so the two cannot be confused when both offer a seat on the same edge. An empty edge gets no column seam. A column is started by snapping to the edge, which the eight targets already offer — a seam with nothing to sit beside would be describing a boundary that is not there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
moduleChrome summed chromeReserve off every module store and nothing else, so a shell template that pins a nav strip reserved nothing and a panel snapped to that corner opened underneath it. A template has the same problem the call bar has and no store to publish from, because a template is data. So it declares the box in meta.chromeReserve, TemplateLayout registers it into hostChromeReserves — the sibling of hostDockStores, keyed and announcing for the same reasons — and the shell folds it into the same sum. Withdrawn on unmount and replaced rather than accumulated on re-register, so a shell that stops declaring a bar stops reserving the band. The shape is restated in TemplateMeta rather than imported from @we/module-shared: schema types are the layer modules point at, so naming a module type there would be an edge running the wrong way. This is also the reverse of what the interpretation panel needs later — when the extraction readout leaves the fixed chrome column for a dock of its own it must stop contributing here, or panels keep clearing a band that is gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… where they start meta.panels is one list answering one question — what panels does this interface have — with two kinds of entry, because splitting them would have meant two declarations that could disagree about the same edge. `module` places a panel some module already contributes; `node` supplies the content itself. A declaration is a suggestion, and the placement chain now has three rungs: what the user last dragged the panel to, then what the interface asked for, then the module's own opening bid. The middle rung is resolved live and never written, so switching template or view is non-destructive, switching back restores what was there, and an author improving a layout is not overruled forever by one stray drag. The same shape meta.themeId already follows. Everything the declaration carries is a name — a snap, a DockSize, a grow ratio. placementFromDeclaration turns those into pixels against a viewport the template cannot see, exactly as a module's `md` becomes 440, and it refuses to displace from a corner because a rectangular layout cannot flow around one. A `node` panel becomes a real dock whose open flag the shell owns, since there is no module to own it — the arrangement shellDocks.ts already uses for space settings, one level more dynamic. Entries are diffed rather than cleared and rebuilt: registration announces, the geometry memo re-runs on every announcement, and a template being edited re-declares on every keystroke. Declarations travel on their own change channel rather than dockRegistry's, or the effect that turns a declaration into dock entries would re-run itself forever: register, announce, re-read, register. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ou leave A layout is about a section rather than a whole interface — a graph wants a transcript beside it and an inbox does not, and both are sections of one template. So meta.panels is read off the view on screen as well as the shell, with the shell having the last word: a section is portable and renders inside interfaces it knows nothing about, so what it says about the screen is a suggestion and the interface that owns the screen overrules it. The active section is derived, not stored. The shell's routes are built from the same resolved sections, so the segment in the URL is the section on screen. Declaring a module's panel now opens it, through the two keys the launcher already declares: read activeWhen, fire action only if it is false. That is load-bearing rather than fastidious — transcribe's action is togglePanel, so firing it blindly at an open panel would close the thing the template asked for. A module with no activeWhen cannot be asked, so it is opened once and never toggled back; an unanswerable question is better left alone than guessed. And provenance, which is why this keeps a set. A panel a layout opened belongs to the layout and is withdrawn when the layout stops naming it; a panel somebody opened themselves is theirs and survives navigation. Without the distinction a per-view layout either accumulates every panel you have walked past, or closes one holding live state — leaving the graph view would stop a recording. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…uthor one The capability does not exist until it is documented here. The globe is the case on record in this repo: a good layer protocol, no catalogue of layer names in the generated context, and the conclusion written down is that an LLM cannot author a globe template. meta.panels would have gone the same way. So the fragment covers the declaration — both kinds of entry, every field, and that sizes are named because only the host can see the viewport — plus the two arrangements an edge can hold, and chromeReserve. It leads with the panel-versus-flow test, which is the load-bearing part. A dashboard where nothing overlaps, nothing is dragged and nothing persists position is a Grid of cards; built from panels instead it is busier, harder to read and worse on a phone. Without the test in front of it, a generated dashboard comes out as eight draggable windows because somebody said the word "panel". Placed after routing, since a section's layout is read against the routes it renders at, and before the rules, which are prohibitions rather than guidance. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three-rung chain was one-way. A drag beat the interface's declaration for good, with nothing to return to it — so an author improving a layout would be overruled forever by one stray drag somebody made once, and the "resolved live, never written" property bought nothing the reader could feel. layoutPinned says a panel has been moved away from something there is to go back to; resetDockToLayout forgets the stored placement. Deleting rather than writing the declared one is the point: the panel goes on following the layout afterwards, including when the template changes it. Offered first on the position menu and disabled rather than hidden — the same choice displaceButton makes, for the same reason. A control that vanishes when you move a panel is one you stop looking for, and the disabled state carries the rule. It sits before the eight rather than among them because it undoes a position rather than choosing one. Same pairing as spaceThemePinned and clearSpaceThemePin, which is where this shape came from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…egenerate CI's build-drift check caught the generated context being stale: layoutPinned and resetDockToLayout were added after the last generate-context run, so a full build regenerated CLAUDE.md and friends and the tree came back dirty. Regenerating alone would have satisfied the check and shipped both members as `unknown`, which is exactly what the architecture plan rules out — a named capability with no semantics looks complete, so an LLM either avoids it or guesses. Described in fragments/stores.ts instead, then regenerated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Panel layouts and the transcript surface
Branch:
feat/panel-layouts-transcript-surfaceoffdev.Summary
Two efforts that meet in the same space UI. A template can now say what panels its interface has
and where each one starts, floating panels can share an edge instead of covering each other, and
the transcript panel shows the transcript instead of a session-local buffer of the reader's own
lines.
The thread through all of it is that arrangement is data and the host owns the geometry. A template
declares in named positions — a snap, a
DockSize, a grow ratio — and never in pixels, because onlythe host can see the viewport. The declaration is a suggestion: the placement chain is now what the
reader last dragged, then what the interface asked for, then the module's own opening bid, resolved
live and never written. That is the same rung
meta.themeIdalready occupies for themes, and it iswhat makes switching template or section non-destructive.
The transcript half turned out to need almost nothing built. The shared record already held every
agent's utterances with authors and timestamps, and already outlived the session —
spaceStore.exportCallTranscripthas been reading exactly that to write a text file with real namesin it. The panel was the only thing not looking at it.
Changes
The transcript surface
we-scroll-areagainspin="end"(design-system/3-primitives). Follows the end of thecontent as it grows, only while the reader is already there. The conditional half is the whole
feature: pinning unconditionally yanks somebody out of what they scrolled up to re-read, every
time a line lands. Two observers, because content grows for two reasons and only one is a
mutation. No event yet — a consumer wanting "N new" can have one when something renders that
affordance.
module-system/transcribe). It renderedmodules.transcribe.recent, a session-local buffer of the last twenty blocks this agent wrote —own-only, capped and gone on reload by construction. All three complaints people raised were that
one cause. It now drills into the collection the way
CallsListalready does for a finishedmeeting, oldest first, with
$agentputting a real name and face on each line.recentis removedrather than kept as an echo:
pendingalready shows the words not yet flushed, so an echo of theflushed ones would double-render the reader's own lines.
re-opening the panel on an already-transcribed call offered to start recording as though nothing
had been said.
docs/architecture/transcripts.mdwrites the shape down. Five things read it now and none canimport the constants, because
templates → modulesandmodules → modulesare both sidewaysedges. Deliberately not a shared kind registry: a free-text discriminator on a generic container is
the symptom, and the answer is a declared content type.
transcriptFeedis exported and registered in the module'sschemas, so a template can placethe utterances without the capture chrome around them.
Shell geometry
dockGeometry.ts).occupiedForreturns early for anythingthat does not displace, so two floats overlapped outright, and the eight snaps are points rather
than a column. Note the axis, because an earlier reading of this got it wrong and the plan has been
corrected: a strip (displacing panels) stacks perpendicular to its edge, growing inward; a
column (floating panels) divides along it. Both are wanted; this adds the second.
the obvious alternative and are wrong: three panels at 2/1/1 that lose the middle one leave the
survivors at 2/1 of the whole, so the top one grows for a reason nobody can see. Real CSS flex is
unavailable — a flex container means reparenting panels out of fixed positioning, which remounts
their subtrees and would drop a call's live video.
NARROW_VIEWPORT_PXa column collapses to full-bleed sheets. Two 350px cards overcontent on a 400px viewport leave nothing of any of the three.
columnSlotsdraws the seams so a float can be dragged into a column rather than onlyappended to it.
insertDocktakes the mode;activeInsertcarries it so a strip seat and a columnseat on the same edge cannot be confused.
moduleChromesummedchromeReserveoff module storesand nothing else, so a shell template pinning a nav strip reserved nothing and a panel snapped to
that corner opened underneath it.
meta.chromeReserveplushostChromeReserves.Template-facing panels
meta.panels— one list, two kinds of entry.moduleplaces a panel a feature module alreadycontributes and opens it;
nodesupplies the content itself. Splitting them would have meant twodeclarations that could disagree about the same edge.
placementFromDeclarationturning names into pixels andrefusing to displace from a corner.
module to own it — the arrangement
shellDocks.tsuses for space settings, one level moredynamic. Entries are diffed rather than rebuilt: registration announces, the geometry memo re-runs
on every announcement, and a template being edited re-declares on every keystroke.
dock entries would re-run itself forever.
meta.panelsis read off the view on screen as well asthe shell, with the shell winning on a collision — a section is portable and renders inside
interfaces it knows nothing about, so what it says about the screen is a suggestion.
declares: read
activeWhen, fireactiononly if false. Load-bearing rather than fastidious —transcribe's action is
togglePanel, so firing it blindly would close the thing the template askedfor.
stops naming it; a panel somebody opened themselves survives navigation. Without it a per-view
layout either accumulates every panel you walked past or closes one holding live state — leaving
the graph view would stop a recording.
layoutPinned/resetDockToLayout. The chain was one-way: a drag beat the declaration forgood. Reset forgets the stored placement rather than writing the declared one, so the panel goes
on following the layout afterwards. Offered first on the position menu, disabled rather than
hidden.
panelscontext fragment. The capability does not exist until it reaches@we/ai-context— the globe is the case on record. Leads with the panel-versus-flow test, which isthe load-bearing part: without it a generated dashboard comes out as eight draggable windows
because somebody said the word "panel".
Deviations from the plan
$reversewas not added. The architecture plan's rules now forbid new value operators and namethis proposal as the first entry on the deferred list. Rather than smuggling it in as a query or
$eachconstruct, the transcript renders ascending and unbounded, pinned to the end. A singlecall's transcript is bounded by the length of the call, so the window the plan worried about is not
load-bearing yet; it waits for the expression layer's function library.
transcriptFeedwas split out, notcaptureControls. Nothing places the capture chromeseparately, and a fragment with no second caller is an extraction waiting to be got wrong.
extractionStatuswas already its own file for an unrelated reason.panel needs a descendant selector, which inline styles cannot express — so it needs a real
stylesheet or a component, not a prop. Doing it through
hoverPropson the titlebar itself wouldmean hovering a thin strip, which is a worse feature than none. Left for a follow-up that can pick
the mechanism deliberately.
deferred — see "What comes next". Moving extraction observability to a host dock means rewriting
ExtractionStatus.schema.ts(671 lines) againstinterpretationStorerather thanmodules.transcribe.*, and deciding the split between observability (host) and "extract this"(with the content).
What ships unexercised
No template in the repo declares
meta.panelsyet. The mechanism is complete and tested, andthe first real consumer is the workshop space below.
That is the one genuinely uncomfortable thing about merging this as it stands, and it is worth
naming rather than papering over: it is the shape of failure the architecture plan records about the
globe — a good protocol nobody could reach. What makes it acceptable here is that the half which
actually made the globe unauthorable is in this PR: the
panelscontext fragment ships alongsidethe capability, so it is documented before it is used rather than after.
Everything else is exercisable by hand today, and was: opening two panels and dragging both to one
edge is the column path, with no declaration involved.
What comes next
PR 2 — the workshop space. The interpretation panel and the first real consumer of
meta.panelsbelong in the same change, because they are the same screen: a graph of a call's extracted records
in the centre, the transcript down the left, the extraction readout beneath it, the call on the
right, and a floating strip switching between that and a task view. The left-hand extraction panel
is PR D, which is why it is a dependency of the workshop rather than a follow-up beside it.
One constraint to settle before that is designed: per-view panel layouts resolve against
spaceStore.spaceViews()matched to route segments, so the workshop shell has to be a$views-based shell (like the default template's Header/Sidebar layouts). A showcase templatecarries its own route table and no
$viewsmarker, so one built that way would get the shell'spanels and no per-view variation — degrading quietly, which is worse than breaking.
Known follow-ups
runningPasses/settledPasses/hasActivity, and itschrome reserve shrinking when the readout leaves the fixed chrome column — both fall out of the
interpretation panel in PR 2.
mobile-plan.md's container-query idiombefore it is relied on.
installFromMarketplace/installToSpacehave still not been checked againstrole: 'view'.Test plan
Run and passing:
@we/primitives— 138 tests, including five new ones forpin="end": follows when at the end,holds when scrolled up, treats a few pixels short as the bottom, re-arms on return, and does
nothing without the prop.
@we/app-shell— 721 tests. New coverage for column layout (stacking, even division, grow, theadd/remove property, per-member widths, axis flip, the minimum, narrow collapse), column
membership, column seams, declared placement, the declaration registry, and the reset-to-layout
menu item.
@we/module-transcribe— 80 tests, unmoved by the panel and fragment changes.@we/schema-shared— 615 tests;validateclean across 32 schemas;role-auditclean.@we/ai-context— 14 tests; context regenerated and committed.Verified by hand in the Electron app:
Not verified, and honestly so:
meta.panels, and the layout changing between sections. Nothing declaresany yet; PR 2 is the first exercise of that path.
what has been seen working; that every participant's lines appear is inference from the record's
shape and from
exportCallTranscriptalready reading it that way.mobile-plan.mdfirst.