The workspace browser's two hover-raised popups both died on the way to
them. HoverCard closed on the first pointerleave and rendered its card
pointer-events:none, but the card sits 8px off the anchor, so every path
to it crossed ground belonging to neither. The row action menus put
closeOnPointerLeave's handler on the portaled list, so aiming back at the
... trigger that opened it, or overshooting a list edge, closed it with no
window to come back.
usePointerGrace owns one cancelable delayed close (200ms) shared by both
atoms: leaving arms it, returning cancels it. The hover card becomes
hit-testable so resting on it holds it open, and Menu moves pointer-leave
dismissal to the wrapper span, where React's enter/leave traversal makes
trigger and portaled list one region.
Both gestures are pinned in the real browser lane; each fails without the
corresponding fix.
fix(web): keep one composer bar DOM across the no-workspace transition
The composer.bar slot moves from session to session-maybe scope: with no
current session the entry still mounts, the machine faces (keyboard, stop,
command) arrive undefined, and the bar renders its normal DOM inert via the
disabled owner prop. DisabledInputBar and its parallel tree are gone, so the
textarea node survives the cold-start workspace pick instead of flashing
through a remount. A blank session whose workspace was deleted takes the
same inert path through owner props.
test(web): add the composer DOM-continuity acceptance probe
Drives a real dsh web server with headless chromium through the cold-start
-> pick-workspace -> type flow and asserts the composer textarea is the
same DOM node throughout (a marker property must survive). Rerun
prerequisites are in the header comment.
docs(web): session-maybe identity is adoption, not hold-forever
fix(web): session-maybe entries adopt the first session, then remount like strict entries
A session-maybe entry used to keep one React instance across every
transition, so component-local state leaked between sessions once the
composer bar moved to that scope (PermissionSelect's optimistic pick, the
IME composition guard). Identity is now adoption: an incarnation born
session-less holds through the arrival of the first session (the blank
shell's DOM survives the workspace pick), and afterwards behaves exactly
like a strict session entry — a switch or a drop to no-session remounts,
clearing local state by construction. The child key is an incarnation
counter kept in the stable outlet wrapper via render-phase setState.
chore: knip knows the root acceptance probe's playwright dependency
scripts/hero-composer-dom-continuity.mjs resolves playwright through
apps/web's devDependency tree (createRequire), which knip cannot follow;
ignore it at the root workspace.
The seat's height publisher needs a ResizeObserver stub in every spec that
renders ConversationRoot (jsdom has none), and the two branch-tail StatsLine
assertions move to the grouped detail-row output.
Stats line moves into the InputBar's new footer slot (sharing the card's
width column) and expands to the design's grouped detail row: turns/steps,
LLM and tool wall time, cache hit, and input/output token split, all
derived client-side from the snapshot. The composer stack owns one 8px
rhythm, the seat fades the transcript through a fixed 36px gradient band,
back-to-bottom follows a live --dsh-composer-height, and goal/todo strips
share one 752px tip-fill column.
The #921 selector change and the duplication gate both first ran against
this branch after the merge made it MERGEABLE: assembly-surfaces still
queried the retired data-sample="todo-row" hook (the composed TodoRow
carries ToolRow's data-tool attribute instead), and the redesigned
QuestionComposer duplicated the custom-draft onChange/onKeyDown pair
across its inline input and optionless textarea. The spec now anchors on
data-tool="todo_write", and the composer shares one draftCustom /
continueFromCustom handler pair (Enter continues, Shift+Enter stays a
newline, IME composition stays inert).