Skip to content

v1.7.2 - Show Mode, In-Session Chat & Follow-the-Host

Choose a tag to compare

@github-actions github-actions released this 25 May 14:38
· 9 commits to main since this release

[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/call with a structured error (read-only midi://* 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 hello and sessionWelcome frame now carries an appVersion field. 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 missing sessionWelcome frame 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. New LiveSession::Mode { Edit, Show } enum (header-only src/collab/SessionMode.h so it's unit-testable without dragging the LanLiveSession dep tree in). Mode is locked at session start - the host picks via a mode-picker QMessageBox before startHosting{,Wan} runs, the joiner adopts via a new sessionWelcome frame that's the host's first response to the joiner's hello. Strict hat-passing: only the current presenter can broadcast hatTransferred, host-side validator rejects any hunks frame from a non-presenter machineId with an unauthorisedHunkDropped diagnostic signal. Three frame types added: requestHat, hatTransferred(reason: transfer | host-takeover), hatRejected. Race-resolution rule per §15.2: if a requestHat arrives 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 == nullptr check) 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::handleToolsCall returns a structured "Local editor is in Show Mode (viewer)..." error; the Edit / Tools / Midi menus + the entire toolbar widget are disabled via menuAction()->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::getItem listing currently-connected peers with their display names from connectedPeerInfo()), Yield the hat (presenter-only, non-host; sends a yieldHat frame that the host validates against _presenterMachineId and turns into a regular hatTransferred(host, reason="yield") broadcast), Request the hat (viewer-only; sends a requestHat frame, status-bar acknowledges), Take the hat (host-only; visible when hostCanTakeHat() == 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 a hatRejected frame surfaced as a status-bar toast. The refreshCollabMenu lambda dynamically shows/hides each action based on isPresenter() / role / hostCanTakeHat().
  • Phase 9.9e - Show-mode status indicators - the existing _statusLiveSessionLabel now 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 new LanLiveSession::sessionModeChanged signal so the indicator updates AFTER sessionWelcome has been processed (the original joined signal fires too early, when _mode is still the Edit default).
  • Phase 9.9f - Follow-the-host viewState - added during testing. New viewState wire frame sent by the presenter and re-broadcast by the host. Throttled to one frame per 250 ms in LanLiveSession (_viewStateThrottle QTimer 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's toolTip() display name, and an array of selected-event identity tuples (tick, channel, line, type). Viewer applies via a new MatrixWidget::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::setVisibleSilent which correctly updates the ChannelVisibilityManager singleton, 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-only LanLiveSession::switchSessionMode(SessionMode) API + matching client-side handleClientSessionModeSwitch that updates _mode + _presenterMachineId and emits sessionModeChanged so 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 own MidiPlayer::play() / stop(). Server-side validator gates on sender == presenterMachineId (same rule as hunks + viewState). _applyingRemotePlayback re-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 chat wire 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 via broadcastExcept so the sender doesn't see their own message echoed (already appended optimistically locally). New src/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 to lowerTabWidget; 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 - appVersion field added to both hello (joiner → host) and sessionWelcome (host → joiner) frames; the encoder reads it from the MIDIEDITOR_RELEASE_VERSION_STRING_DEF compile constant. Three signals: LanLiveSession::peerVersionMismatch(peerName, peerMachineId, peerVer, ourVer), hostVersionMismatch(hostVer, ourVer), legacyHostDetected(). The "legacy host" path triggers when ANY frame other than sessionWelcome arrives first on a freshly-joined client (v1.7.2+ hosts always ship sessionWelcome as their first reply to hello, so absence is a reliable pre-1.7.2 signal). MainWindow surfaces the warnings as modal QMessageBox::information popups for the blind-generation case (peer has no appVersion field, 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, onSyncTick early-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 startHostingWan retry 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 captures SessionMode retryMode = _mode before leaveSession() 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::getItem over 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 in src/tool/Tool.cpp so the GUI layer (specifically MainWindow) can trigger a viewState broadcast whenever the local tool changes, without polluting the tool module with QObject / GUI dependencies. Plain function pointer; MainWindow registers a static lambda that calls broadcastLocalViewState().
  • MatrixWidget::applyZoom, currentScaleX/Y, visibleEndMs/Line, fitToFocus - public zoom-state accessors + the fit-to-focus geometry calculator. applyZoom runs calcSizes() so subsequent scroll calls see the new scale; fitToFocus clamps 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_mode extended 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 - requestHat field shape + decode round-trip, hatTransferred field shape + empty-reason-defaults-to-transfer + host-takeover reason + decode of missing-reason (legacy → "transfer"), hatRejected shape + 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 to 1.7.2.
  • README.md - version bumped to 1.7.2.
  • src/collab/SessionMode.h (new) - header-only LiveSession::Mode enum + wire encode/decode for sessionWelcome, requestHat, hatTransferred, hatRejected, yieldHat, chat, viewState (incl. the ViewportState struct and SelectedEventId tuple), playback, and sessionModeSwitch. 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 _viewStateThrottle QTimer + _lastChatMsBySender rate-limit table. encodeHello + encodeSessionWelcome now carry appVersion. startHosting{,Wan} take an optional SessionMode parameter (default Edit).
  • src/gui/collab/CollabChatWidget.{h,cpp} (new) - the chat panel widget.
  • src/gui/MatrixWidget.{h,cpp} - new setEditingLocked, currentScaleX/Y, applyZoom, visibleEndMs/Line, fitToFocus. Mouse press / move / release suppressed when _editingLocked is true. The lock co-exists with the existing enabled flag (which gates everything including piano-key preview).
  • src/gui/MidiPilotWidget.{h,cpp} - new setShowModeLocked(bool) toggles the input field's enable-state and placeholder text; the four other setEnabled(true) re-enable sites (request finished / errored, model-incapable, agent finished / errored) now respect _showModeLocked.
  • src/gui/MainWindow.{h,cpp} - mode picker QMessageBox before startHosting{,Wan}; the applyShowModeLock lambda 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 in lowerTabWidget with unread badge + theme-agnostic / glyph-toggle blink (width-stable, works in QSS-themed builds where setTabTextColor has no effect); version-mismatch warnings (modal for blind generation, delayed status-bar for version-diff); broadcastLocalViewState + applyRemoteViewState for the follow-the-host machinery, wired to scrollChanged / actionFinished / cursorPositionChanged / sessionModeChanged / the Tool::setToolChangedCallback hook. play() / stop() broadcast the playback trigger (presenter-only), playbackTriggerReceived connects to a viewer-side handler that seeks + starts / stops the local player with an _applyingRemotePlayback re-entry guard.
  • src/ai/McpServer.cpp - handleToolsCall returns 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} - new setHiddenSilent(bool).
  • src/midi/MidiChannel.{h,cpp} - new setVisibleSilent(bool) that ALSO updates the ChannelVisibilityManager singleton (not just the _visible mirror).
  • src/tool/Selection.{h,cpp} - new setSelectionSilent(QList<MidiEvent*>) that bypasses the protocol-step recording.
  • src/tool/Tool.{h,cpp} - new setToolChangedCallback(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.html overview gained a "Session features" section linking to both. manual/collab-lan.html + manual/collab-wan.html got a small callout pointing at the mode picker + chat. manual/docs-index.html gained 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).