Skip to content

feat: presentation mode — a per-viewer, display-only privacy screen (#1803) - #1991

Merged
rbuergi merged 13 commits into
mainfrom
feat/presentation-mode
Aug 21, 2026
Merged

rbuergi merged 13 commits into
mainfrom
feat/presentation-mode

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1803. Prerequisite for Systemorph/MeshWeaver.Plugins#976.

The problem

Sharing your screen shares the whole portal. The home catalog lists every space you can read,
"last accessed" names whatever you touched this morning, pinned tiles sit at the top, link-preview
cards carry names and logos, and the moment you type @ the suggestions offer up node names.
#1803 also says why the obvious workarounds fail: renaming a node's display fields is global
(every user sees the renamed tile, and it needs a restore step), and stripping the tile regions out
of the home body loses the functionality.

What ships

Presentation mode — a per-user, display-only privacy screen. Mark a node from its node menu
(Hide in presentation mode), flip Presentation mode on from the user menu before you share,
and everything you marked leaves your own tiles, cards and completions. A glasses glyph in the
header says the screen is up. Turn it off and everything is back — no restore step.

The four design decisions

1. What "hidden" means — display-only, and the code says so

PresentationScreen is a pure, immutable predicate over PATHS: no permission, no
AccessContext, no node read, no query, and nothing in it reachable from the permission evaluator.
Hides(path) means do not paint this; it says nothing about whether the viewer may read the node,
and no caller may use it to decide a read, a write or a route.

🚨 It must never become an access-control mechanism. A second permission system that can
disagree with the real one is worse than none, and the one that disagrees quietly is the one that
ships a bug nobody can see. So the claim is pinned by a test rather than by review —
MarkingGrantsNothingAndDeniesNothing marks a space with the mode ON, against a live mesh, and
asserts all three:

assertion
permitted GetEffectivePermissions(space, alice) is bit-identical before and after
reachable GetMeshNodeStream(space) and its child still resolve
searchable a mesh query path:Acme for alice still returns it

Two facts make an item disappear, and a mark on its own hides nothing while the mode is off.
That is what makes the whole thing reversible in one click, and what keeps a stale mark from
quietly removing something from a user's home forever.

Marking a space covers its subtree, by path segment. The path IS the name: listing
Acme/Q3-Renewal under "last edited" would leak exactly what marking Acme was meant to keep off
the screen. Acme never hides AcmeCorp.

2. Where the marking lives — the viewer's own profile

User.HiddenPaths and User.PresentationMode, beside PinnedPaths, on the viewer's node —
never on the node being marked. That is the whole point of #1803's complaint about the rename
workaround, and AMarkedSpaceIsHiddenForTheMarkerOnly pins the negative: Bob is presenting too,
with his own marks, and Alice's mark is invisible to him.

Reading it follows the display-time seam, with one deliberate difference. WHO is asking comes
off the live AccessContext — request-scoped first, then per-circuit — resolved once on the
render turn
and passed down as a value, exactly like ViewerZoneId() / ViewerLocale(). The
VALUE, unlike the time zone, is read live off the viewer's own User node: a time zone changes
about never, so a snapshot taken when the circuit opened is always right, but a presentation toggle
is flipped in the seconds before a screen share, and a context snapshot would still say "off" at
exactly that moment. A stale zone is a cosmetic lag; a stale screen is the leak. ScreenOf binds to
GetMeshNodeStream(viewer) — the shared handle the portal chrome already holds open — so the
toggle is instant and costs no new machinery.

3. Where the filter goes — where a surface PAINTS, never in the query

Surface Where
Spaces tab (= the former Spaces / Last Read / Last Edited tabs), search results, node catalogs, tree levels, graph navigator MeshSearchView — one filter over the results every MeshSearchControl renders
Pinned tab BuildPinnedItems — before the query string is built
Shared with me tab BuildHome / BuildCatalog — same
Apps tab BuildApps — same. A ~/ entry is a system AREA, not a mesh path, so it passes through
@ completions ChatCompletionOrchestrator.Screened (one seam for every producer) + MeshNodeAutocompleteProvider

Three of the home's four tabs filter earlier than the paint, and that exception is the interesting
part: a pinned path, a shared-band target and an installed app path are all interpolated into the
control's query string
, which the search view exposes in its options editor and carries in the
hq= parameter of "open in search". A marked name reaching the address bar mid-presentation is the
leak, whether or not a card for it is ever drawn. A tab the screen empties is dropped, not shown
empty — a tab labelled "Pinned" with nothing under it says something by itself. Everywhere else the query is deliberately left alone — a -path:Acme clause would put the
same name in the same URL and start turning this into decision 1's second permission system.
TheCatalogQueryItselfIsNeverRewritten pins that.

Nothing paints before the screen is known. MeshSearchView starts its result source on the
first screen emission, so a marked item cannot flash. Anonymous / system / hub callers resolve
synchronously to Off, so those views start in the same turn they always did.

A faulting profile stream HOLDS the last known screen and logs it, rather than resetting to
neutral. Resetting mid-presentation is the leak the feature exists to prevent; refusing to emit at
all would hang every surface waiting on it. Both legs are unit-tested against a faulting source,
including the per-subscription isolation of the latch.

4. What the viewer sees

A glasses glyph in the portal header, present only while the mode is on (language-neutral icon +
translated tooltip), and the toggle in the user menu showing Presentation mode · On/Off. The node
menu entry flips between Hide and Show live, and shows the mark even while the mode is off —
that is how you curate the list before you start presenting. Marking is not gated on any
permission of the target
: you are editing your own profile, not that node.

Non-vacuity

Two control runs on the same machine, each neutering one half of the change:

control result
PresentationScreen.Hides() forced to false 19 of 40 fail — every screen, surface and live-mesh test
Hides() restored; the three surface call sites handed PresentationScreen.Off instead of the viewer's screen 5 fail (pinned ×3, shared band, OG cards) while every pure screen test stays green

The second is the one that matters: it discriminates "the predicate works" from "the surface is
actually wired to it".

Suites run (local, Release)

suite result
MeshWeaver.Graph.Test (full) 1433 passed, 0 failed — includes every node-menu test, so joining the screen into the menu's reactive stream does not stall it
MeshWeaver.Autocomplete.Test 155 passed
MeshWeaver.Documentation.Test — link integrity passed
MeshWeaver.Messaging.Hub.Test — localization 56 passed
React i18n drift guard verified key-for-key and value-for-value against the server catalogs (990 keys each, en + de)

New tests: 44 across PresentationScreenTest (the pure screen, the two writes, the resolver's
fault behaviour, the completion projection), PresentationSurfacesTest (every named surface, both
polarities), and PresentationScreenMeshTest (live mesh: per-user isolation, grants/denies nothing,
the toggle without a reload, a viewer with no profile yet).

Deliberately out of scope — the follow-ups

  • #1922, the session projection. This is its prerequisite: the moment you let someone follow
    your portal is exactly the moment you want your confidential names off it, so shipping remote
    control first would mean the first person to use it shares their engagement list. The share's
    lifecycle — explicitly initiated, per-share, with the initiator seeing who is watching and the
    privacy screen engaging for its duration — is designed but not built.
  • Link-preview / OG cards. MeshWeaver.OgCard left the platform in Import, OgCard and their samples leave the platform #1975 while this was in
    flight, so that surface is no longer in this repo. It now lives in MeshWeaver.Plugins at
    src/MeshWeaver.OgCard/OgCardLayoutArea.cs, with tests in src/MeshWeaver.OgCard.Test/ — and it
    builds via ProjectReference to $(MeshWeaverRoot)/src/MeshWeaver.Graph, not a NuGet package,
    so PresentationScreen and host.ViewerScreen() are available there the moment this merges. No
    release needed; the follow-up is small.
    The rule to carry across: a screened target is dropped, not redacted — a card whose title
    reads "hidden" still tells the room that something is being hidden. Concretely that was
    Render → filter the parsed targets → drop the whole area when none survive, rather than
    substituting a placeholder card.
  • The React client's MeshSearch renderer does not apply the screen yet.
  • The strict variant Feature: Stealth / presentation mode — temporarily hide selected spaces, tiles and OG cards #1803 mentions (hide all recent-activity regions wholesale) is not built.
  • No keyboard shortcut for the toggle; it is the user-menu entry plus the header indicator.

Doc/GUI/PresentationMode carries the design, the rules for adding a surface, and that same
not-yet-covered list.

🤖 Generated with Claude Code

rbuergi and others added 2 commits August 21, 2026 15:54
…1803)

Sharing a screen shares the whole portal: the home catalog lists every space
the viewer can read, "last accessed" names whatever they touched this morning,
pinned tiles sit at the top, link-preview cards carry names and logos, and `@`
completions offer node names as you type. #1803 asks for a quick toggle that
takes the confidential ones off those surfaces — and notes why the workarounds
fail: renaming display fields is GLOBAL, and stripping the tile regions loses
the functionality.

The model, in two facts, both on the VIEWER's own profile and never on the node:

  User.HiddenPaths     the marks         — set from the node menu, per node
  User.PresentationMode the toggle       — set from the user menu

`PresentationScreen` is the one place they become a rendering decision: a pure,
immutable predicate over PATHS. A mark on its own hides NOTHING while the mode
is off, which is what makes the whole thing reversible in one click with no
restore step. Marking a space covers its subtree, by segment — the path IS the
name, so listing `Acme/Q3-Renewal` would leak exactly what marking `Acme` was
meant to keep off the screen.

🚨 It is display-only, and the code says so. It grants nothing and denies
nothing: no permission, no AccessContext, no query, nothing reachable from the
permission evaluator. A marked node stays readable, stays reachable by URL,
stays in the viewer's own search results, and is unchanged for every other user.
`MarkingGrantsNothingAndDeniesNothing` pins all three against a live mesh.

Resolution follows the display-time seam: WHO is asking comes off the live
AccessContext, resolved ONCE on the render turn and passed down as a value. The
VALUE, unlike the time zone, is read live off the viewer's own User node —
because a toggle is flipped in the seconds before a screen share, and a context
snapshot taken at circuit open would still say "off" at exactly that moment. A
stale zone is a cosmetic lag; a stale screen is the leak.

Surfaces: the MeshSearch family (home catalog — which is where the former
Spaces / Last Read / Last Edited tabs live — plus pinned tiles, "Shared with
me", node catalogs, tree levels, graph navigator), OG cards, and both `@`
completion paths. Pinned tiles and the shared band filter BEFORE the query is
built, because their paths are interpolated into the control's query string and
travel into the `hq=` URL; everywhere else the query is deliberately left alone
— a `-path:` clause would leak the same name and start turning this into a
second permission system.

Nothing paints before the screen is known: MeshSearchView starts its result
source on the first screen emission, so a marked item cannot flash. A faulting
profile stream HOLDS the last known screen rather than resetting to neutral,
and says so in the log — never widen a screen on a fault.

Follow-ups, deliberately out: the React client's renderer, the strict variant
that hides recent-activity regions wholesale, a keyboard shortcut, and #1922's
session projection, which this is the prerequisite for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The screen is joined into the node menu's reactive stream, so a viewer whose
User node does not exist yet must resolve — promptly, and to the neutral screen.
If that ever stopped emitting the menu would simply never render, with nothing
in any log to say why: a CombineLatest stalls silently when one leg never
produces a value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 21, 2026 14:02
The header indicator sits left of the avatar and needs the container to be a
flex row to stay on its optical line. A no-op when the indicator is absent —
the container then has a single child.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a per-viewer “presentation mode” privacy screen that hides user-marked paths from display-only surfaces (tiles, OG cards, search views, and @ completions) without affecting access control, node reachability, or query results—aligning with issue #1803 and acting as a prerequisite for #1922.

Changes:

  • Introduces PresentationScreen + profile-backed preferences (User.HiddenPaths, User.PresentationMode) and resolver helpers to project a viewer’s live screen state.
  • Wires the screen into key UI surfaces (Blazor MeshSearchView, home catalog/pins/shared band, OG cards, node menu marking) and completion pipelines.
  • Adds localization keys (server + React mirror) plus comprehensive unit/integration tests and docs.

Reviewed changes

Copilot reviewed 26 out of 26 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
test/MeshWeaver.Graph.Test/PresentationSurfacesTest.cs Tests that named UI surfaces drop hidden items (including query-string construction cases).
test/MeshWeaver.Graph.Test/PresentationScreenTest.cs Tests core predicate semantics, normalization, preference transforms, resolver fault behavior, and completion-path projection.
test/MeshWeaver.Graph.Test/PresentationScreenMeshTest.cs Live-mesh integration tests for per-user isolation and “grants/denies nothing” invariants.
src/MeshWeaver.OgCard/OgCardLayoutArea.cs Applies viewer screen to OG-card targets and rebuilds card grid when the screen changes.
src/MeshWeaver.Messaging.Hub/Localization/strings.en.json Adds presentation-mode localized strings (EN).
src/MeshWeaver.Messaging.Hub/Localization/strings.de.json Adds presentation-mode localized strings (DE).
src/MeshWeaver.Mesh.Contract/Security/User.cs Adds HiddenPaths + PresentationMode fields to the user profile contract with display-only semantics.
src/MeshWeaver.Mesh.Contract/Security/PresentationScreenExtensions.cs Adds the resolver seam to obtain a viewer’s live PresentationScreen from profile streams.
src/MeshWeaver.Mesh.Contract/Security/PresentationScreen.cs Implements the immutable, display-only predicate (Hides, Filter, Retain).
src/MeshWeaver.Mesh.Contract/Security/PresentationPreference.cs Adds pure transforms for applying marks and toggling mode on a user profile.
src/MeshWeaver.Mesh.Contract/MeshWeaver.Mesh.Contract.csproj Grants test assembly access to internals for resolver fault-path testing.
src/MeshWeaver.Hosting/MeshWeaver.Hosting.csproj Grants test assembly access to internals for completion projection tests.
src/MeshWeaver.Hosting/Completion/ChatCompletionOrchestrator.cs Applies presentation screen filtering at a single seam across completion producers.
src/MeshWeaver.Graph/UserActivityLayoutAreas.cs Filters pinned/shared-band surfaces at query-construction time and threads screen into home catalog rendering.
src/MeshWeaver.Graph/PresentationLayoutArea.cs Adds node-menu “Hide/Show in presentation mode” actions that write to the viewer’s own profile.
src/MeshWeaver.Graph/MeshNodeLayoutAreas.cs Registers presentation layout areas alongside existing node menu actions.
src/MeshWeaver.Graph/MeshNodeAutocompleteProvider.cs Applies viewer screen to mesh-node autocomplete results.
src/MeshWeaver.Graph/Configuration/NodeMenuItemsExtensions.cs Joins viewer screen into the reactive node-menu provider so the hide/show entry flips instantly.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-21-presentation-mode.md Adds a “What’s New” entry for the feature.
src/MeshWeaver.Documentation/Data/GUI/PresentationMode.md Adds detailed design/usage docs and rules for adding new screened surfaces.
src/MeshWeaver.Blazor/Components/MeshSearchView.razor.cs Gates first paint on screen resolution and filters painted results/tree levels/navigator below-rail.
src/MeshWeaver.Blazor.Portal/Components/UserProfile.razor.css Styles the header indicator for active presentation mode.
src/MeshWeaver.Blazor.Portal/Components/UserProfile.razor.cs Subscribes to the live viewer screen and implements the toggle write to the user profile.
src/MeshWeaver.Blazor.Portal/Components/UserProfile.razor Adds header indicator + user-menu toggle UI wired to localization keys.
clients/react/src/i18n/strings.en.json Mirrors new localization keys into the React catalog (EN).
clients/react/src/i18n/strings.de.json Mirrors new localization keys into the React catalog (DE).

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/MeshWeaver.Hosting/Completion/ChatCompletionOrchestrator.cs Outdated
@github-actions

github-actions Bot commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

1 232 tests  +5   1 228 ✅ +5   11m 39s ⏱️ +55s
    9 suites ±0       4 💤 ±0 
    9 files   ±0       0 ❌ ±0 

Results for commit ae6a31e. ± Comparison against base commit 9ee895b.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 215 tests  ±0   1 214 ✅ ±0   5m 10s ⏱️ - 1m 45s
   11 suites ±0       1 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit ae6a31e. ± Comparison against base commit 9ee895b.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 790 tests  +3   1 682 ✅ +3   9m 3s ⏱️ +54s
   10 suites ±0     108 💤 ±0 
   10 files   ±0       0 ❌ ±0 

Results for commit ae6a31e. ± Comparison against base commit 9ee895b.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   10 files  ±0     10 suites  ±0   5m 7s ⏱️ -25s
1 709 tests +3  1 709 ✅ +3  0 💤 ±0  0 ❌ ±0 
2 180 runs  +7  2 180 ✅ +7  0 💤 ±0  0 ❌ ±0 

Results for commit ae6a31e. ± Comparison against base commit 9ee895b.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

3 076 tests  +77   3 076 ✅ +77   8m 10s ⏱️ -5s
    9 suites ± 0       0 💤 ± 0 
    9 files   ± 0       0 ❌ ± 0 

Results for commit ae6a31e. ± Comparison against base commit 9ee895b.

This pull request removes 1 and adds 78 tests. Note that renamed tests count towards both.
MeshWeaver.Graph.Test.HomeTabsTest ‑ Apps_SystemTile_ThreadsDockTile_TargetsTheViewersChatArea
Memex.Portal.Shared.Test.PendingModuleActivationHealthCheckTest ‑ ALandingSetsIt_AndTheSurfaceSeesIt_WithoutAnInMemoryHandoff
Memex.Portal.Shared.Test.PendingModuleActivationHealthCheckTest ‑ APendingActivation_IsReportedDegraded_AndNamesTheModules
Memex.Portal.Shared.Test.PendingModuleActivationHealthCheckTest ‑ AnUnreadableOrAbsentSidecar_IsHealthy_NeverAnInventedPendingRestart
Memex.Portal.Shared.Test.PendingModuleActivationHealthCheckTest ‑ NoPendingActivation_IsHealthy
MeshWeaver.Graph.Test.CompileSourceOrderTest ‑ CollectCompileSources_OrdersByPath_WhateverOrderTheSnapshotDelivered
MeshWeaver.Graph.Test.CompileSourceOrderTest ‑ TheCombinedCompileUnit_IsIdentical_UnderAnyDeliveryOrder
MeshWeaver.Graph.Test.GeneratedInputIdentityTest ‑ ACollidingGeneratorFileName_AggregatesEveryIdentity_OrderIndependently
MeshWeaver.Graph.Test.GeneratedInputIdentityTest ‑ AGeneratorAssemblyBodyChange_MovesTheKey_EvenWhenTheTextIsIdentical
MeshWeaver.Graph.Test.GeneratedInputIdentityTest ‑ AGeneratorBodyChange_ThatAltersTheGeneratedText_MovesTheKey
MeshWeaver.Graph.Test.GeneratedInputIdentityTest ‑ AReferenceSurfaceChange_MovesTheKey
…

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

2 039 tests  ±0   1 846 ✅ ±0   9m 0s ⏱️ -4s
   10 suites ±0     193 💤 ±0 
   10 files   ±0       0 ❌ ±0 

Results for commit ae6a31e. ± Comparison against base commit 9ee895b.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

    59 files  ± 0      59 suites  ±0   48m 11s ⏱️ -31s
11 061 tests +88  10 755 ✅ +88  306 💤 ±0  0 ❌ ±0 
11 532 runs  +92  11 226 ✅ +92  306 💤 ±0  0 ❌ ±0 

Results for commit ae6a31e. ± Comparison against base commit 9ee895b.

This pull request removes 1 and adds 89 tests. Note that renamed tests count towards both.
MeshWeaver.Graph.Test.HomeTabsTest ‑ Apps_SystemTile_ThreadsDockTile_TargetsTheViewersChatArea
Memex.Portal.Shared.Test.PendingModuleActivationHealthCheckTest ‑ ALandingSetsIt_AndTheSurfaceSeesIt_WithoutAnInMemoryHandoff
Memex.Portal.Shared.Test.PendingModuleActivationHealthCheckTest ‑ APendingActivation_IsReportedDegraded_AndNamesTheModules
Memex.Portal.Shared.Test.PendingModuleActivationHealthCheckTest ‑ AnUnreadableOrAbsentSidecar_IsHealthy_NeverAnInventedPendingRestart
Memex.Portal.Shared.Test.PendingModuleActivationHealthCheckTest ‑ NoPendingActivation_IsHealthy
MeshWeaver.Content.Test.MarkdownFileParserTest ‑ FrontMatter_BindsANodeTypeInEitherCasing(key: \"NodeType\")
MeshWeaver.Content.Test.MarkdownFileParserTest ‑ FrontMatter_BindsANodeTypeInEitherCasing(key: \"nodeType\")
MeshWeaver.Content.Test.MarkdownFileParserTest ‑ FrontMatter_WithNoNodeType_StillDefaultsToMarkdown
MeshWeaver.Graph.Test.CompileSourceOrderTest ‑ CollectCompileSources_OrdersByPath_WhateverOrderTheSnapshotDelivered
MeshWeaver.Graph.Test.CompileSourceOrderTest ‑ TheCombinedCompileUnit_IsIdentical_UnderAnyDeliveryOrder
MeshWeaver.Graph.Test.GeneratedInputIdentityTest ‑ ACollidingGeneratorFileName_AggregatesEveryIdentity_OrderIndependently
…

♻️ This comment has been updated with latest results.

rbuergi and others added 3 commits August 21, 2026 17:47
# Conflicts:
#	src/MeshWeaver.Graph/UserActivityLayoutAreas.cs
Copilot's review on #1991, and it is right. The completion pipeline's fast-path
tested `screen == PresentationScreen.Off`, but a viewer who has MARKED things
and then turned the mode OFF holds a screen that is not `Off` by reference and
must still hide nothing. Filter was correctly a no-op for them — but the branch
also drops emptied categories, so a legitimately empty completion group was
being suppressed for someone who is not presenting at all.

`PresentationScreen.HidesAnything` is the question that was actually meant
(Active AND something marked); Hides and Filter now ask it too.

The projection is extracted as `ChatCompletionOrchestrator.Screened(batches,
screen)` so the hides-nothing leg is EXECUTABLE by a test rather than only
inferable from a predicate — that leg is exactly the one that was wrong, and a
test asserting the predicate alone would not have caught it. Control: restoring
`== Off` fails Completions_AreUntouchedWhenTheScreenHidesNothing and nothing
else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	src/MeshWeaver.Graph/UserActivityLayoutAreas.cs
@rbuergi

rbuergi commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

CI run 32490339312 was red on two things. One was mine; one was not.

1. MenuAccessControlTest ×3 — mine, fixed

Menu_Admin_ShowsAllItems, Menu_Editor_ShowsCreateItems, Menu_ReadOnlyUser_ShowsOnlyUnrestrictedItems assert the node menu's label set exactly, and this PR adds one entry to it. The product behaviour is right and the expectation was stale: "Hide in presentation mode" carries RequiredPermission = None and therefore appears for a Viewer too — for the same reason Pin does, and it is the reason worth stating in the test rather than in a commit message:

it writes the VIEWER's own profile (User.HiddenPaths); the node being marked is untouched, so no role of that node can gate it. If this ever starts depending on a role, that is the feature turning into a second access-control system and the bug is in the product.

Expected sets updated (and HaveCount(13) → 14), with a PresentationItem constant carrying that note. 9/9 green locally, twice — before and after merging main.

2. ThreadTokenUsageTest.CancelledRound_RecordsTokensConsumedBeforeCancel_OnCellAndThread — not mine

Evidence, not assertion:

check result
Does anything in MeshWeaver.AI / MeshWeaver.AI.Test name a symbol this PR adds or changes? no — grep -rlE "PresentationScreen|PresentationPreference|PresentationLayoutArea|ChatCompletionOrchestrator|BuildPinnedItems|BuildHome|MeshNodeAutocompleteProvider|OgCard" over both trees returns nothing
What actually failed? ThreadTokenUsageTest.cs:262 — WaitForUsage(threadPath, "usage_cancel_model", …, 10_000), i.e. a 10-second wall-clock wait that "emitted nothing at all". It ran out of time; it did not read a wrong value
Is that class given the box? No. ThreadTokenUsageTest carries no [Collection], so under AI.Test's parallelizeTestCollections: true, maxParallelThreads: 4 it runs 4-wide on a 4-vCPU runner. 8807a6d8c ("Give the five machine-hungry AI.Test classes the box, and parallelise the rest", 2026-08-10) serialised only AgentChatClientDeadlockTest, CrossHubPatchAtomicityTest, DelegationDeadlockTest, InboxToolIntegrationTest, PatchWorkspaceAckTest into ConcurrencyStressCollection
Does that commit describe this failure mode? Yes, verbatim: "a handful of classes deliberately saturate the machine and then judge the result on a wall clock"
Does it pass on this branch? 10/10, 9s locally

So: the same family as the reclassification, one class over — a wall-clock-bounded AI.Test class left out of the concurrency-stress collection. That is a real gap in the test, not noise, and the fix belongs in its own PR: either add ThreadTokenUsageTest to ConcurrencyStressCollection, or make that wait event-driven. Not widening the 10s bound, which would be the band-aid. I have deliberately not touched it here.

For the record, neither of the two candidates raised out-of-band matches this run: no Plugins compile against this PR gate failed (the failing jobs were shard 3 of MeshWeaver Build and Test only), and this PR never touches OpenGraphPreviewService — only OgCardLayoutArea.cs, with its InternalsVisibleTo unchanged.

Also in this push

  • Copilot's review finding, fixed — 3a6658eba. It was right. See the thread for the mechanism; short version: the fast-path tested screen == PresentationScreen.Off, but marks-with-mode-off is a different value that must also hide nothing. Filter was already a no-op there, so no item was wrongly dropped — but the same branch's empty-batch drop was not, suppressing a legitimately empty completion category for a viewer who is not presenting. Now gated on PresentationScreen.HidesAnything, with the projection extracted as ChatCompletionOrchestrator.Screened(batches, screen) so the hides-nothing leg is executed by a test rather than inferred from a predicate. Control: restoring == Off fails exactly one test.
  • Two merges from main, which reworked this feature's largest surface underneath it: the home is now a tabbed page (Shared with me / Pinned / Apps / Spaces), and BuildApps gained nodeOwnerId and ~/ system-area entries. The screen is re-applied to the new shape — and the tabbed home makes the rule sharper, not weaker: Shared-with-me, Pinned and Apps each interpolate the viewer's paths into their control's query string, so all three are screened before the query is built, while the Spaces list stays a generic query filtered where its results are painted. A ~/ entry names no node and is passed through untouched. New tests: a tab whose entire content is marked disappears (an empty tab labelled "Pinned" would itself say something), and the Apps query drops a marked app.
  • Revert-check after both merges: git diff origin/main...HEAD --name-only is exactly this PR's own files.

rbuergi and others added 2 commits August 21, 2026 18:01
main turned the home into tabs (Shared with me / Pinned / Apps / Spaces) while
this was in flight, and the tabbed shape makes the rule SHARPER rather than
weaker: three of the four tabs interpolate the viewer's own paths into their
control's query string, so all three are screened before the query is built,
while Spaces stays a generic query filtered where its results are painted.

Also records the two things the review turned up: a tab the screen empties is
dropped rather than shown empty, and "ask HidesAnything, never == Off" is now
rule zero for a new surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BuildApps says in a comment that a "~/" entry names no node and is therefore
never screened. A comment is not a test. Marks both the real app and the system
area, and asserts what is left is the system-area dock rather than the "no apps"
placeholder — so the pass-through is a fact, not an intention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	src/MeshWeaver.OgCard/OgCardLayoutArea.cs
@rbuergi

rbuergi commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

MeshWeaver.OgCard left the platform under this PR — the OG-card surface is out, and said so

#1975 ("Import, OgCard and their samples leave the platform") landed on main while this was in flight, deleting src/MeshWeaver.OgCard from the repo. This PR screened OgCardLayoutArea, so that change came out with it rather than being resurrected:

  • OgCardLayoutArea.cs — merge resolved as deleted (main's side).
  • OgCards_DropAMarkedTarget_AndKeepExternalPages / OgCards_AreUntouchedWhenTheModeIsOff — removed with the surface they covered.
  • The design doc, the What's New entry, this PR's body and the test class's own summary no longer claim it. It is now written down as a follow-up in whichever repo owns OgCard, carrying the one rule worth keeping: a screened target is dropped, not redacted — a card titled "hidden" still tells the room something is being hidden.

Claiming a privacy surface the code no longer has is worse than not having it, which is why this is a correction to the prose as much as to the diff.

Local after the merge: Graph.Test presentation + home 95/95 (96 before, minus the two OG-card tests), doc link-integrity green, and the revert-check (git diff origin/main...HEAD --name-only) is 26 files, all this PR's own.

Also filed #2001 for the unrelated ThreadTokenUsageTest failure diagnosed above, so it can be closed rather than re-attributed the next time it reddens someone's PR.

rbuergi and others added 2 commits August 21, 2026 18:14
"Import, OgCard and their samples leave the platform" landed on main while this
was in flight and deleted src/MeshWeaver.OgCard, so the screening this PR added
to OgCardLayoutArea went with it rather than being resurrected: the file's
merge is resolved as deleted, and its two tests are removed with the surface
they covered.

Claiming a privacy surface the code no longer has is worse than not having it,
so the design doc, the What's New entry and the test class's own summary now
say so, and the follow-up is recorded where the other gaps are — carrying the
one rule worth keeping across the move: a screened target is DROPPED, not
redacted, because a card titled "hidden" still tells the room that something is
being hidden.

Also refreshes the pathless-item example in Filter_KeepsItemsThatAreNotNodes,
which named an OG card; it now names a "~/" system area, which is in this repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The HidesAnything rule arrived last chronologically and was numbered 0, which
reads as an afterthought bolted onto a finished list. Renumbered 1..5 in the
order you actually hit them when writing a surface: resolve once, do not paint
early, ask the right predicate, never widen on a fault, never consult it for
anything but rendering. No content change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rbuergi

rbuergi commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Handoff notes — things that are not in the PR description

1. The riskiest line, and it is deliberate. MeshSearchView gates its first paint on the viewer's presentation screen (_screenResolved), and DefaultNodeMenuProvider CombineLatests it. Both therefore depend on the viewer's own User node stream emitting. There is no timeout on that wait, on purpose — a timeout would fall back to the neutral screen, i.e. fail open, which is the leak the feature exists to prevent. An anonymous/system/hub caller resolves synchronously, so only signed-in viewers wait at all.

The consequence to know: if catalogs or node menus ever show universal spinners, look here first. That symptom would mean the viewer's own profile stream is dead — a portal-level outage the spinner is honestly reporting — not a bug in this feature. The fix would be upstream, never a timeout here.

2. That fault path is already confirmed in the wild, not just unit-tested. CI run 32490339312 exercised it by accident: MenuAccessControlTest's TestUser has no User node, the stream errored with DeliveryFailureException: No node found at 'TestUser', LastKnownOnFault held Off and logged it, and the menu still rendered all 14 items. That is stronger evidence than AViewerWithNoProfileYetStillGetsAScreen and it exists only in a CI log, so it is recorded here.

3. Expect a new warning line in test output. Presentation screen: the profile stream for {viewer} faulted; holding the last known screen fires once per menu render for any viewer without a User node — which in fixtures is most test users. It is honest signal about a real anomaly, deliberately not swallowed. Do not mistake it for a new defect.

4. Known untested surface, bounded. There is no bunit in this repo, so the ~3 wiring points inside MeshSearchView (SubscribeToViewerScreen → StartResultSource, the ApplyResults gate, the _screen.Filter calls) are the only code in this change with no test. Everything else — the screen, both writes, the resolver's fault behaviour, every builder, the completion projection — is pure and pinned, including the live-mesh half. That gap is a deliberate proportionality call, not an oversight.

5. Two tests assert reference equality on PresentationScreen.Off (BeSameAs). For(false, []) returns the singleton by design, so if anyone makes For always allocate, those break for a cosmetic reason rather than a real one.

6. The home page is under active construction. Three merges reshaped it under this PR in about an hour (tabbed home; BuildApps gaining nodeOwnerId + ~/ system areas; the OgCard shed). The screen is threaded through BuildHome → BuildSharedWithMe / BuildPinnedItems / BuildApps / BuildCatalog, so any new home tab that interpolates viewer paths into a query string needs the same treatment — that is rule 3 in Doc/GUI/PresentationMode, and it is the one a future tab is most likely to miss.

Closes an unbounded await this feature introduced in the broadest possible
place. DefaultNodeMenuProvider joined the viewer's screen into its CombineLatest,
so every node-menu render in the process began awaiting a subscription to the
VIEWER's own User node — a node that need not exist (a test identity, a caller
mid-onboarding, a virtual user). A stream that ERRORS is answered by
LastKnownOnFault; a stream that merely never produces is not, and the join then
stalls silently and forever, OUTSIDE any test's methodTimeout. The symptom is a
wall-clock hang with no failing test to point at.

The menu never needed the wait. There the screen decides only WHICH OF TWO
LABELS the presentation entry carries — Hide vs Show — for the node the viewer
is already looking at. A briefly wrong label is cosmetic; a menu that never
renders is not. The tile surfaces keep their gate, because painting early THERE
is the leak the feature exists to prevent.

So the rule is now named rather than inline: ViewerScreen().Seeded() where the
screen only labels, plain ViewerScreen() where painting early would leak. "This
viewer has no profile" is a defined, screened-safe answer — they have marked
nothing — and it belongs in the stream as a VALUE, never as something to wait
for. Deliberately NOT a timeout: a timeout on a privacy screen falls back to the
neutral value, i.e. fails OPEN, which is the one direction that must not happen
silently.

Pinned by ASeededScreen_RendersEvenWhenTheProfileStreamNeverProduces, which
drives a genuinely never-producing source and asserts on the EMISSION LIST — so
it catches a hang without being able to hang itself. Control: removing the seed
fails it (and its sibling) in 32ms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rbuergi

rbuergi commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Shard 2 exit=124: #890 is ruled out. The exposure is mine, and it is closed — without a timeout.

#890: ruled out by evidence, not elimination

Its signature is absent from the failing job log (96837340415), on all four markers:

grep hits
GetConsolidatedTypeParameters 0
Cci.MetadataWriter 0
NullReferenceException 0
canary 0

What the log does and does not say

MeshWeaver.Graph.Test started 16:42:33 and hit the cap at 16:50:32 having emitted no test results at all. methodTimeout is 30 s, so an 8-minute wall clock is necessarily outside a test method — fixture, class init or disposal, exactly as the marker classifies it.

It cannot tell us which test hung: the last console output is the module-packing tests, but my tests emit no console output, so silence is what they look like either way.

Four comparisons that bound it

tree Graph.Test
main @ 9d345a2a 65 s, 1408 tests, exit=0
this branch @ c03bcc163 (pre-merge, same test files) exit=0
this branch @ fd8be497 (post-merge) exit=124, no results
merged tree, locally, ×3 1455/1455 in ~53 s

So it is not my +45 tests tipping a near-cap suite (main has 7 minutes of headroom), and it is not deterministic. Today's two main failures are shard 4, a different shape.

(A local run did stall mid-investigation and I nearly reported it as a reproduction — it was not. ps showed my host gone and another session's MeshWeaver.Markdown.Test at 62% CPU on the same box. Contended-machine timing is not evidence; recording it so nobody re-derives it.)

The real finding

DefaultNodeMenuProvider joined the viewer's screen into its CombineLatest, so every node-menu render in the process began awaiting a subscription to the viewer's own User node — a node that need not exist. A stream that errors is answered by LastKnownOnFault (observed working in run 32490339312); a stream that merely never produces is not, and the join then stalls silently and forever, outside any method timeout. That is precisely the shape flagged, in the broadest place it could have been, and the menu never needed the wait: there the screen decides only which of two labels the entry carries for the node the viewer is already looking at.

Fixed by making the absence a value, not a wait — ViewerScreen().Seeded(), per the directive, and explicitly not a timeout: a timeout on a privacy screen falls back to the neutral value, i.e. fails open, the one direction that must not happen silently. The tile surfaces keep their gate, because painting early there is the actual leak. The gate-vs-seed distinction is now rule 2 in Doc/GUI/PresentationMode rather than an inline .StartWith someone could delete.

Pinned by ASeededScreen_RendersEvenWhenTheProfileStreamNeverProduces, which drives a genuinely never-producing source and asserts on the emission list — so it catches a hang without being able to hang itself. Control: removing the seed fails it and its sibling in 32 ms.

Honest bottom line

I can prove #890 is not it. I can prove my change introduced this unbounded await and that it is now gone. I cannot prove it was the cause of that specific 8-minute hang — it did not reproduce in three full local runs, and the pre-merge tree with the same tests was green. If shard 2 times out again on ed068f3b, the menu is no longer a candidate and the next place to look is the compile/packaging machinery that owns the last console line (cf. #612, a non-#890 exit=124 in this same suite).

Local after the fix: presentation suite 63/63, MenuAccessControlTest 9/9 (and 1 s instead of 2 s — the seed removed a real wait).

…menu gate

Caught with a stack dump of a locally reproduced hang, so this is mechanism
rather than inference:

  SemaphoreSlim.Wait()
  System.Reactive…ObservableImpl.GetEnumerator.MoveNext()
  System.Linq.Enumerable.ToArray
  PresentationScreenTest.Completions_DropAnEmptiedCategory_OnlyWhilePresenting()

.ToEnumerable() is a BLOCKING bridge — the same shape as .Wait(), which this
codebase bans everywhere else. It parks the calling thread on a semaphore until
the source produces, so when the source schedules its own work onto that same
thread the test self-deadlocks. Which thread it lands on depends on the
scheduler ToObservable() picks, which is why it is intermittent: one full local
run passed with this exact test present, two later ones wedged for 31 and 15
minutes at ~0% CPU.

🚨 And xUnit's methodTimeout CANNOT abort a thread parked in a native wait, so
instead of a 30s test failure you get an unbounded HOST wedge — exit=124 with no
failing test to point at. That is precisely the CI symptom, and it is why the
30s bound looked like it had been bypassed.

Both call sites now subscribe and collect, on ImmediateScheduler so delivery is
synchronous and scheduler-independent — the pattern the rest of this file
already used.

Also commits the tile-surface annotations held back earlier: PinnedAreaView and
CatalogAreaView carry an explicit "NOT .Seeded() — deliberately", pointing at
rule 2. The menu now reads as the pattern when it is the exception, and the
flash seeding them would reintroduce is a leak rather than a cosmetic wobble.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rbuergi
rbuergi merged commit 5469445 into main Aug 21, 2026
31 checks passed
@rbuergi
rbuergi deleted the feat/presentation-mode branch August 28, 2026 08:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Stealth / presentation mode — temporarily hide selected spaces, tiles and OG cards

2 participants