v1.7.2 - Show Mode, In-Session Chat & Follow-the-Host
[1.7.2] - 2026-05-25 - Show Mode, In-Session Chat & Follow-the-Host
Summary
- Show Mode is now a first-class session type. When you start a LAN or WAN session a mode picker asks Edit (everyone can edit, original behaviour) or Show (one peer at a time holds "the hat" = editing rights). Use cases: tutorial streams, MIDI code review, classroom teaching, AI walkthroughs. Strict request-and-approve hat passing, host-side hunk validation against the current presenter, and a 30 s heartbeat deadline after which the host can reclaim the hat from a silent presenter.
- In-session chat side-channel - a "Chat" tab in the Collaboration sidebar that's only visible while a Live session is active. Lightweight: plain UTF-8 text, host-side 4 KB cap + 200 ms-per-sender rate limit, 500-message in-memory scrollback, no persistence. Unread badge + tab-text blink when messages arrive on another tab. Same wire transport as MIDI hunks (no new connection).
- Follow-the-host viewport sync - viewers see exactly what the presenter is looking at, regardless of window size or zoom. Local matrix auto-fits to the presenter's visible region (~5% padding) so a viewer with a different resolution doesn't snap to the top of the piano roll. Track + channel visibility, edit cursor, active tool name, and the presenter's selected notes all mirror onto every viewer's screen. Playback start / stop is also synced - when the presenter presses Play or Stop, every viewer's player follows at the presenter's cursor position (each peer through their own audio output).
- Show-Mode UI lock for viewers - the matrix accepts no tool input, MidiPilot's input field is disabled with an explanatory placeholder, the MCP server rejects
tools/callwith a structured error (read-onlymidi://*resources stay reachable), and the Edit / Tools / Midi menus + the toolbar widget are greyed out. View-only Playback / Help / chat / scroll / Piano-key preview stay live. - Version-mismatch warnings - every
helloandsessionWelcomeframe now carries anappVersionfield. v1.7.2 hosts detect older joiners (pre-1.7.2 → missing field) and surface a modal popup naming the peer + the features the older build won't see. v1.7.2 joiners detect older hosts via the missingsessionWelcomeframe and show the symmetric warning. Future-version mismatches (1.7.2 ↔ 1.8.0 etc.) appear as a delayed status-bar tip on both sides. Lesson learned: every wire protocol should carry a version field from v1.0 - the v1.7.0 omission means v1.7.0 / v1.7.1 are forever the "blind generation". - Hat-pass UI - new menu entries Pass the hat to..., Yield the hat (gives the hat back to the host without picking a specific successor), Request the hat (viewers), and Take the hat (host privilege after the silence deadline). Incoming hat requests show as a modal accept/decline prompt on the presenter; failed transfers (target peer disconnected) bounce back as a status-bar toast. Status-bar carries a 🎩 PRESENTING or 👀 Watching
<name>indicator while in Show mode. - Mid-session mode toggle (host-only) - new Collab menu entry Switch to Show mode / Switch to Edit mode (shortcut Ctrl+Shift+M) lets the host flip the active session between modes without leaving and re-starting. Use case: two peers co-editing in Edit mode, one wants to demo something briefly - host toggles into Show, becomes the presenter, demos / passes the hat as needed, then toggles back to Edit and everyone resumes co-editing. The Show-mode UI lock (matrix, MidiPilot, MCP, menus, toolbar) engages and disengages live on every peer as the host flips.
- Test harness extended -
test_session_mode(46 cases) covers the entire Show-Mode + chat + version-mismatch + viewState + playback + mode-switch + yield-hat wire vocabulary, including UTF-8 multibyte chat payloads and legacy-frame fallbacks for every frame type. Full suite: 42/42 green (41 from v1.7.1 + the new SessionMode test executable).
Full Changelog - Show Mode, Chat & Follow-the-Host
New Features
- Phase 9.9 - Show Mode (§15.2) - session-wide editing-rights model added to
LanLiveSession. NewLiveSession::Mode { Edit, Show }enum (header-onlysrc/collab/SessionMode.hso it's unit-testable without dragging the LanLiveSession dep tree in). Mode is locked at session start - the host picks via a mode-pickerQMessageBoxbeforestartHosting{,Wan}runs, the joiner adopts via a newsessionWelcomeframe that's the host's first response to the joiner'shello. Strict hat-passing: only the current presenter can broadcasthatTransferred, host-side validator rejects anyhunksframe from a non-presenter machineId with anunauthorisedHunkDroppeddiagnostic signal. Three frame types added:requestHat,hatTransferred(reason: transfer | host-takeover),hatRejected. Race-resolution rule per §15.2: if arequestHatarrives at the host AFTER the requester became the presenter (transfer crossed on the wire), the request is silently dropped. Host-takeover privilege is sender-locked (fromPeer == nullptrcheck) so a remote peer can't ship{reason: "host-takeover"}to escalate. - Phase 9.9c - Show-Mode viewer lock (§15.2) - in Show mode + viewer state:
MatrixWidget::setEditingLocked(true)suppresses tool-driven mouse press/move/release (piano-key preview + scrolling stay live, per design);MidiPilotWidget::setShowModeLocked(true)disables the input field with an explanatory placeholder (chat history stays visible so AI walkthroughs are readable);McpServer::handleToolsCallreturns a structured"Local editor is in Show Mode (viewer)..."error; the Edit / Tools / Midi menus + the entire toolbar widget are disabled viamenuAction()->setEnabled(false)and_toolbarWidget->setEnabled(false). Playback menu stays enabled so Space-bar play/pause works for local-only preview. - Phase 9.9d - Hat-pass UI - new actions in the Collab menu: Pass the hat to... (presenter-only; opens a
QInputDialog::getItemlisting currently-connected peers with their display names fromconnectedPeerInfo()), Yield the hat (presenter-only, non-host; sends ayieldHatframe that the host validates against_presenterMachineIdand turns into a regularhatTransferred(host, reason="yield")broadcast), Request the hat (viewer-only; sends arequestHatframe, status-bar acknowledges), Take the hat (host-only; visible whenhostCanTakeHat() == true, i.e. the presenter is no longer in the connected peer list). Presenter receives a modal Accept/Decline prompt on incoming requests. Transfer to a disconnected target returns ahatRejectedframe surfaced as a status-bar toast. TherefreshCollabMenulambda dynamically shows/hides each action based onisPresenter()/ role /hostCanTakeHat(). - Phase 9.9e - Show-mode status indicators - the existing
_statusLiveSessionLabelnow appends🎩 PRESENTING(presenter) or👀 Watching <name>(viewer) while in Show mode. Successful hat transfers + the host-takeover variant emit transient status-bar toasts ("Hat is now held by Alice", "Host took the hat (presenter was silent)"). All driven from the newLanLiveSession::sessionModeChangedsignal so the indicator updates AFTERsessionWelcomehas been processed (the originaljoinedsignal fires too early, when_modeis still the Edit default). - Phase 9.9f - Follow-the-host viewState - added during testing. New
viewStatewire frame sent by the presenter and re-broadcast by the host. Throttled to one frame per 250 ms inLanLiveSession(_viewStateThrottleQTimer coalesces a rapid drag-scroll burst into a single broadcast). Payload: viewport (startMs / startLine / scaleX / scaleY / focusEndMs + focusEndLine), track + channel visibility arrays, presenter's cursorTick, active tool'stoolTip()display name, and an array of selected-event identity tuples(tick, channel, line, type). Viewer applies via a newMatrixWidget::fitToFocus(startMs, endMs, startLine, endLine)method that derives the LOCAL scaleX/scaleY needed to fit the presenter's visible region into the viewer's pixel geometry with ~5 % padding, then centres the focus. This sidesteps the clamp-to-top problem that 1:1 scroll mirroring hit whenever the viewer's window was wider than the presenter's. Visibility + selection apply through new silent setters (MidiTrack::setHiddenSilent,MidiChannel::setVisibleSilentwhich correctly updates theChannelVisibilityManagersingleton,Selection::setSelectionSilent) so the viewer's undo history stays clean. Backward-compat: the focus-extents fields are -1 when absent, and the decoder falls back to the original 1:1 scroll mirroring. - Phase 9.9h - Mid-session mode toggle - new
sessionModeSwitch {mode, presenterMachineId}wire frame; host-onlyLanLiveSession::switchSessionMode(SessionMode)API + matching client-sidehandleClientSessionModeSwitchthat updates_mode+_presenterMachineIdand emitssessionModeChangedso the existing GUI lock machinery flips synchronously. MainWindow's Collab menu gains a single toggle action whose text reads "Switch to Show mode" or "Switch to Edit mode" depending on the current state; visible only when hosting. Edit → Show makes the host the initial presenter; Show → Edit clears the presenter pointer and releases every viewer's lock. - Phase 9.9g - Playback trigger sync - new
playback {action: "start"|"stop", tickPosition: N}wire frame. Presenter's Play / Stop click → broadcast to every viewer; viewer seeks to the carried tick and triggers its ownMidiPlayer::play()/stop(). Server-side validator gates onsender == presenterMachineId(same rule as hunks + viewState)._applyingRemotePlaybackre-entry flag in MainWindow stops the viewer's local play/stop from re-broadcasting back to the presenter. Deliberately a one-shot trigger: no wall-clock alignment, no continuous re-sync, no playhead-position broadcast (initial latency 50-200 ms and longer-playback drift accepted as good-enough for tutorial / demo use). Each peer plays through whatever audio output is configured locally; an un-configured viewer gets the editor's existing "configure your MIDI output" dialog on first trigger. - Phase 9.11 - In-session chat side-channel (§15.3) - new
chatwire frame:{sender, displayName, text, timestamp}. Host validates 4 KB UTF-8 cap + 200 ms-per-sender rate limit (per-machineId table in_lastChatMsBySender), then re-broadcasts viabroadcastExceptso the sender doesn't see their own message echoed (already appended optimistically locally). Newsrc/gui/collab/CollabChatWidget.{h,cpp}(QTextEdit scrollback + QLineEdit input + Send button); 500-message in-memory cap with the oldest block trimmed when exceeded; per-sender colour styling (own messages in accent blue, others in neutral); local-timezone HH:mm timestamp formatting; defensive HTML-escape on every message text so nothing in the wire can inject formatting. Tab added tolowerTabWidget; visible only while in an active session, auto-cleared on session end (no persistence per §15.3). Unread badge ("Chat (3)") on the tab title when the user is on another tab, plus a 600 ms●/○glyph-toggle blink for visual emphasis (width-stable across themes). Wire-format unit-tested for UTF-8 round-trip, type-field shape, decode of a frame with missing reason field, and the kChatTextMaxBytes / kChatRateLimitMsPerSender constants. - v1.7.2 §15.4 - Version-mismatch detection -
appVersionfield added to bothhello(joiner → host) andsessionWelcome(host → joiner) frames; the encoder reads it from theMIDIEDITOR_RELEASE_VERSION_STRING_DEFcompile constant. Three signals:LanLiveSession::peerVersionMismatch(peerName, peerMachineId, peerVer, ourVer),hostVersionMismatch(hostVer, ourVer),legacyHostDetected(). The "legacy host" path triggers when ANY frame other thansessionWelcomearrives first on a freshly-joined client (v1.7.2+ hosts always shipsessionWelcomeas their first reply tohello, so absence is a reliable pre-1.7.2 signal). MainWindow surfaces the warnings as modalQMessageBox::informationpopups for the blind-generation case (peer has noappVersionfield, can't see our warnings either) and as status-bar tips with a 600 ms delay for matched-but-different versions (delay lets the immediate post-hello statusMessage emits land first so our warning isn't instantly overwritten). The lesson is now documented in a personal memory: every wire protocol should carry a version field from v1.0.
Improvements
- Mode-aware sync tick (§15.2) - in Show mode + viewer state,
onSyncTickearly-returns without broadcasting any local hunks, BUT still advances_lastSyncedSnapshot/_lastBroadcastEndTick. Otherwise a viewer's accumulated local edits during the lock period would dump as one giant catch-up diff on the next hat-take. Combined with the existing host-side hunk validator, this is defence-in-depth: even if a buggy / older viewer's matrix lock fails, their broadcasts are dropped at both the sender and the receiver. - WAN auto-reconnect preserves Show-mode selection - the existing
startHostingWanretry path captured the file but not the mode, so a network blip on a Show-mode session silently downgraded to Edit on reconnect. The retry lambda now capturesSessionMode retryMode = _modebeforeleaveSession()and re-passes it through. connectedPeerInfo()accessor - returns(machineId, displayName)pairs for the host's currently-connected peers. Used by the Pass the hat to... picker (QInputDialog::getItemover the labels) so the host doesn't have to type a machineId, plus by the status-bar 👀 Watching<name>lookup when the presenter is a remote peer.Tool::setToolChangedCallback- lightweight static-callback hook insrc/tool/Tool.cppso the GUI layer (specificallyMainWindow) can trigger aviewStatebroadcast whenever the local tool changes, without polluting the tool module with QObject / GUI dependencies. Plain function pointer; MainWindow registers a static lambda that callsbroadcastLocalViewState().MatrixWidget::applyZoom,currentScaleX/Y,visibleEndMs/Line,fitToFocus- public zoom-state accessors + the fit-to-focus geometry calculator.applyZoomrunscalcSizes()so subsequent scroll calls see the new scale;fitToFocusclamps required scale to[0.05, 20.0](X) and[0.10, 10.0](Y) so an extreme presenter zoom can't push the viewer into an unusable state.- Stale-v1.7.1 leftover fixed in Planning/09_COLLABORATION.md - the "post-MVP" subsection still said "the v1.7.1 ship is just mode + hat-passing + UI lock"; corrected to v1.7.2 to match the actual ship.
Test Harness
-
TEST-1.7.2-001 -
test_session_modeextended to 46 cases (was 14 in v1.7.1):- SessionMode enum - wire-string round-trip, case-insensitive parsing, unknown-value fallback to Edit (forward-compatibility), null-out-pointer safety in the decoder.
- sessionWelcome JSON - type field presence, mode + presenter round-trip, empty-presenter handling, decode of frame with missing fields (defaults to Edit), null-pointer decode, appVersion present-when-supplied / omitted-when-empty / round-trip / legacy-decode-as-empty-string.
- Hat-pass frames -
requestHatfield shape + decode round-trip,hatTransferredfield shape + empty-reason-defaults-to-transfer + host-takeover reason + decode of missing-reason (legacy → "transfer"),hatRejectedshape + decode. - Chat frame - type + field shape, UTF-8 multibyte round-trip (German Umlaute + CJK + emoji), multi-line text round-trip, wire-serialization end-to-end, kChatTextMaxBytes / kChatRateLimitMsPerSender constants pinned at 4096 / 200 so a future regression would fail the build.
- viewState frame - type + sender shape, focus-extents round-trip, legacy-absent-fields decode to safe defaults (focusEndMs/Line = -1, scaleX/Y = 1.0), selection tuple list round-trip, empty selection omitted from wire to save bytes.
- playback frame - start + stop shape with carried tick position, decode round-trip, absent-tickPosition decodes to -1 sentinel, null-out-pointer safety.
- sessionModeSwitch frame - host-only mid-session toggle: type + fields, Edit clears presenter pointer, decode round-trip.
- yieldHat frame - empty-payload type marker (host infers yielder from peer-link machineId).
Full suite: 42/42 green (41 from v1.7.1 + the new SessionMode test executable).
Files Modified
CMakeLists.txt- version bumped to1.7.2.README.md- version bumped to1.7.2.src/collab/SessionMode.h(new) - header-onlyLiveSession::Modeenum + wire encode/decode forsessionWelcome,requestHat,hatTransferred,hatRejected,yieldHat,chat,viewState(incl. theViewportStatestruct andSelectedEventIdtuple),playback, andsessionModeSwitch. Pure JSON helpers, no QObject - keeps the unit-test build minimal.src/collab/LanLiveSession.{h,cpp}- public API additions:mode(),presenterMachineId(),isPresenter(),connectedPeerInfo(),requestHat(),transferHatTo(),hostTakeHat(),yieldHat(),hostCanTakeHat(),sendChatMessage(),broadcastViewState(),broadcastPlayback(),switchSessionMode(). New signals:sessionModeChanged,hatRequested,hatTransferred,hatTransferRejected,unauthorisedHunkDropped,chatMessageReceived,chatMessageDropped,viewStateReceived,playbackTriggerReceived,peerVersionMismatch,hostVersionMismatch,legacyHostDetected. New private handlers + a_viewStateThrottleQTimer +_lastChatMsBySenderrate-limit table.encodeHello+encodeSessionWelcomenow carryappVersion.startHosting{,Wan}take an optionalSessionModeparameter (defaultEdit).src/gui/collab/CollabChatWidget.{h,cpp}(new) - the chat panel widget.src/gui/MatrixWidget.{h,cpp}- newsetEditingLocked,currentScaleX/Y,applyZoom,visibleEndMs/Line,fitToFocus. Mouse press / move / release suppressed when_editingLockedis true. The lock co-exists with the existingenabledflag (which gates everything including piano-key preview).src/gui/MidiPilotWidget.{h,cpp}- newsetShowModeLocked(bool)toggles the input field's enable-state and placeholder text; the four othersetEnabled(true)re-enable sites (request finished / errored, model-incapable, agent finished / errored) now respect_showModeLocked.src/gui/MainWindow.{h,cpp}- mode pickerQMessageBoxbeforestartHosting{,Wan}; theapplyShowModeLocklambda toggles matrix lock + MidiPilot lock + the Edit/Tools/Midi menus + the toolbar; status-bar 🎩 / 👀 indicator; Pass the hat to / Request the hat / Take the hat menu actions; hat-request modal + transfer-rejected toast; Chat tab integration inlowerTabWidgetwith unread badge + theme-agnostic●/○glyph-toggle blink (width-stable, works in QSS-themed builds wheresetTabTextColorhas no effect); version-mismatch warnings (modal for blind generation, delayed status-bar for version-diff);broadcastLocalViewState+applyRemoteViewStatefor the follow-the-host machinery, wired toscrollChanged/actionFinished/cursorPositionChanged/sessionModeChanged/ theTool::setToolChangedCallbackhook.play()/stop()broadcast the playback trigger (presenter-only),playbackTriggerReceivedconnects to a viewer-side handler that seeks + starts / stops the local player with an_applyingRemotePlaybackre-entry guard.src/ai/McpServer.cpp-handleToolsCallreturns a structured"Local editor is in Show Mode (viewer); editing is locked..."error when the local peer is a viewer.src/midi/MidiTrack.{h,cpp}- newsetHiddenSilent(bool).src/midi/MidiChannel.{h,cpp}- newsetVisibleSilent(bool)that ALSO updates theChannelVisibilityManagersingleton (not just the_visiblemirror).src/tool/Selection.{h,cpp}- newsetSelectionSilent(QList<MidiEvent*>)that bypasses the protocol-step recording.src/tool/Tool.{h,cpp}- newsetToolChangedCallback(ToolChangedFn)static hook.tests/CMakeLists.txt,tests/test_session_mode.cpp(new) - the wire-vocabulary test executable.Planning/02_ROADMAP.md,Planning/09_COLLABORATION.md- sub-phase status updates, §15.2 design refresh with the 8 gap-fixes (hello extension, server-side hunk validation, MidiPilot/MCP lock, race-resolution, audio-not-synced), §15.3 chat side-channel design, stale-v1.7.1 leftover correction.manual/collab-show-mode.html(new) +manual/collab-chat.html(new) - dedicated pages for the two new features, with placeholder slots replaced by real screenshots once captured.manual/collaboration.htmloverview gained a "Session features" section linking to both.manual/collab-lan.html+manual/collab-wan.htmlgot a small callout pointing at the mode picker + chat.manual/docs-index.htmlgained section-cards for both.manual/navigation.js"Collaboration" sidebar group now includes Show Mode + In-session Chat.manual/index.html"What's New" rewritten for v1.7.2. dedash.py applied across the whole manual (78 em / en dash replacements in 5 files for consistent ASCII hyphens).