Skip to content

Give each connected character its own capture pane - #16

Merged
HarryCordewener merged 4 commits into
mainfrom
fix/spawn-window-per-session
Jul 31, 2026
Merged

Give each connected character its own capture pane#16
HarryCordewener merged 4 commits into
mainfrom
fix/spawn-window-per-session

Conversation

@HarryCordewener

@HarryCordewener HarryCordewener commented Jul 31, 2026

Copy link
Copy Markdown
Member

The report

"When 2 connections both receive a matching regex match, one wins and gets 2 redirects, instead of each connection getting their own redirect to their own pane."

Reproduced first

Two characters on one world, both connected over their own transport, both assigned a trigger set that captures ^<Public> to a Public window. Against 9e6f242 the workspace came back holding one window:

id=main             owner=Convergence.Ann  title=Ann
id=char:Convergence.Bob  owner=Convergence.Bob  title=Bob
id=spawn:Public     owner=Convergence.Ann  title=Public
lines of spawn:Public:
  <Public> Ann says, "first"
  <Public> Bob says, "second"

One pane, owned by Ann, holding both characters' channel. And because BuildRailWindows filters window rows to the active character, Bob's own channel was not merely mixed in — it was filed under Ann, so it was invisible from Bob and reachable only from the character it did not belong to.

The cause

Workspace.SpawnWindowId was $"spawn:{target}". No owner in it, so one window per workspace per target: the first session to match created it with its own session key on it, and every later session's lines were appended to somebody else's pane.

The id

spawn:{owner.Length}:{owner}:{target}, and spawn:-:{target} for a window nobody owns.

Why the length prefix, and not a plain join. A session key and a target are both user-controlled free text and either may hold a colon — a world or character can be called a:b, and a trigger's SpawnTarget is whatever was typed on F2. spawn:{owner}:{target} spells ("a", "b:c") and ("a:b", "c") identically, which is this very defect one shape rarer. Reading the digits up to the first colon, taking exactly that many characters as the owner and consuming one more colon is a total, reversible encoding: distinct pairs cannot produce equal ids, whatever is in them. - is used for "no owner" rather than an empty length because it is not a decimal digit, so the two arms can never be confused. Pinned by an exhaustive cross-product over deliberately awkward names (SpawnWindowIdTests.DistinctPairsAlwaysGiveDistinctIds), not by a table of examples.

Why an encoding and not a digest. This string is a dictionary key, a value in config.json, and the filename stem of a RestoreLog file. A hash would be unambiguous too and would make all three unreadable to whoever has to look at them, for no property a reversible encoding lacks. The file name's own collision handling is untouched: RestoreLog stores the full id in each file's header and refuses a file whose header names a different window, so a CRC-32 clash on the stem costs one window's log rather than mixing two.

Windows with no owner keep sharing one bucket per target, deliberately. A live WorldSession always has a key (its world's name when it has no character), so an unowned spawn window cannot come from a connection at all — the web view is Auxiliary and never routed here. And the rail lists an unowned window under every character precisely because it belongs to none, so two of them for one target would draw two identical rows under everybody with nothing in the client able to tell them apart or route between them. Null is one identity, "nobody", not an unknown owner.

Nothing on screen changed

Every surface that names a window reads WorkspaceWindow.Title. The tab strip (TabTitles), the sidebar (RailWindowLabel) and the ⌃P catalog all do; the only thing anywhere deriving a display name from an id was a test helper (RailClickTests.SpawnTitle), now asking the app for the title instead.

Checked against rendered frames rather than by reading the markup: twelve snapshot views involving the demo's Chat spawn window — default, spawn, split, timestamps-toggled, connections, rail-long, move, drag, focus, collapsed, prefix-panel, scrollback — are byte-identical between 9e6f242 and this branch.

Migration — both halves, because the two stores are joined only by this id

config.json (ConfigurationMigrator, v4 → v5). Every saved spawn id is rewritten, in lastSession.windows and in every pane's tabs array, taking the owner from the sessionKey that WorkspaceWindowState has persisted all along — so the rewrite is a lookup, not a guess.

Migrated rather than adopted or dropped. Adoption — leaving spawn:Public alone for whichever session claims the target — cannot work: nothing the running client now produces equals it, so the pane would come back and then sit there for ever with nothing writing to it while its channel filled a second pane beside it. That failure is reproduced in the falsification below (two :Chat windows). Dropping avoids the orphan and throws away a pane the user had, plus — through the id the restore log is keyed by — the text in it.

The decision is made by schema version and never by looking at the id. A target is free text and can be made to look like a v5 id, so classifying by shape would eventually mis-file one and produce exactly the orphan this step exists to prevent. Everything under a document that says version 4 is v4, by construction.

The restore log (SharpMUTermApp.CarryLegacySpawnLogsOver). At the launch that reads it, a log filed under a pre-v5 id is copied onto the id its pane now has and the old file is dropped — so it happens once, and the next launch has nothing to map and cannot replay the same lines twice. Here shape is safe to judge by, and the test is all three of: the id does not parse as a current one, it names no window this workspace holds, and exactly one live spawn window claims its target. Anything short of that is left alone, which costs nothing: a log the workspace cannot place is buffered under its own id exactly as it always has been, so its pane refills if that channel ever speaks again.

SpawnWindowIdUpgradeTests drives a genuine v4 document through ConfigurationStore.Deserialize beside a restore log written under spawn:Chat, and asserts the pane comes back in its pane, called Chat, owned by Corvid, holding the previous session's conversation — and that the live capture then writes to that same pane rather than a second one.

Falsification

Three, each breaking one half and watching the right tests fail:

  1. The fix itself. The five reproduction tests were written first and were red on 9e6f242: "Expected to contain spawn:15:Convergence.Ann:Public, but the item was not found", and Ann's pane empty.
  2. Config migration off (MigrateV4ToV5 returns immediately). 6 of 8 Core migration tests fail, and in the Tui suite TheUpgradedPaneIsTheOneTheLiveCaptureWritesTo fails with "Expected to be 1 but found 2" — the orphan pane beside the live one, exactly the failure mode this design rejects.
  3. Restore-log carry-over off (CarryLegacySpawnLogsOver returns empty). The pane comes back — and empty: "Expected to contain Rivane: anyone up for the crypt run? but received """. The upgrade would have cost the user their scrollback.

Also fixed on the way

TryReadSpawnWindowId's bounds check was written owner.Length < length + 1, which wraps negative for a declared length of int.MaxValue and then indexes off the end. spawn:2147483647:abc:x in a hand-edited config threw IndexOutOfRangeException out of a reader whose whole contract is to answer false. Second commit, with the case pinned.

Verification

dotnet build SharpMUTerm.slnx clean apart from the two known TUnitAssertions0005 warnings in TabExpansionTests, and all five suites green:

Suite Baseline Now
Core 804 830
Graphics 83 83
Scripting 42 42
Web 37 37
Tui 1337 1347

XDG_CONFIG_HOME was redirected for every run and UserDirectoryGuard passed; ~/.config/SharpMUTerm/ was not written by this work.

Two notes for the reviewer

This branch carries 9e6f242 ("Take SharpConsoleUI from NuGet, always"), which is not on origin/main. It was main locally when this work started and it is still unpushed, so it appears here as a third commit. It is not mine.

Expected conflicts with #14 (feat/tab-jump-alt-digit). Both touch Workspace.cs#14 adds WorkspaceWindow.Sequence and Workspace.PlacedWindows; this rewrites RouteSpawn and replaces SpawnWindowId. The two are in different members and the textual conflict should be small. The one to watch is RouteSpawn, which is where a new window is registered: if #14 assigns a window sequence there, both edits land on the same few lines. WorkspaceWindow.cs is untouched here. The test-side edits are one-line call-site updates (SpawnWindowId("Chat")DemoScene.ChatWindowId or SpawnWindowId(owner, target)) across the rail/pane suites #14 also rewrites, so expect a handful of trivial conflicts there.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GpL7Ht6sLBsSEtVNsYcXMM

Summary by CodeRabbit

  • New Features

    • Multiple characters can now capture the same target and maintain separate, isolated windows for each character.
  • Refactor

    • SharpConsoleUI now exclusively uses the NuGet package distribution; local source references are no longer supported.
  • Chores

    • Configuration schema updated with automatic migration of spawn window identifiers to support per-session routing.

HarryCordewener and others added 2 commits July 31, 2026 10:07
Two connected characters whose triggers matched the same pattern shared one
capture pane. `Workspace.SpawnWindowId` was `$"spawn:{target}"` — no owner in
it — so the first session to match created the window *with its own session key
on it* and every later session's lines were appended to a pane somebody else
owned. That is worse than a mixed-up channel: the rail draws window rows for the
active character only, so the second character's own channel was filed under the
first and was invisible from the character it belonged to.

The id now carries the owner, length-prefixed:
`spawn:{owner.Length}:{owner}:{target}`, or `spawn:-:{target}` for a window
nobody owns. The length is what makes it unambiguous rather than merely usually
fine — a session key and a target are both user-controlled free text and either
may hold a colon, so `spawn:{owner}:{target}` spells ("a", "b:c") and ("a:b",
"c") identically, which is this very defect one shape rarer. Reading the digits,
taking exactly that many characters and consuming one more colon is total and
reversible, so distinct pairs cannot collide whatever is in them. It is an
encoding and not a digest because this string is a dictionary key, a value in
config.json and the stem of a RestoreLog file name, and all three want to stay
readable.

Windows with no owner keep sharing one bucket per target, deliberately: a live
WorldSession always has a key (its world's name when it has no character), so an
unowned spawn window cannot come from a connection at all, and the rail lists an
unowned window under *every* character — two of them for one target would draw
two identical rows everywhere with nothing able to tell them apart. Null is one
identity, "nobody", not an unknown.

Nothing on screen changes. Every surface that names a window reads
`WorkspaceWindow.Title`; the one place that derived a display name from an id
was a test helper, now asking the app for the title instead.

Migration, both halves, because the two stores are joined only by this id:

- `ConfigurationMigrator` v4 → v5 rewrites every saved spawn id in
  `lastSession.windows` and in every pane's `tabs`, taking the owner from the
  `sessionKey` that state has always persisted. Migrated rather than adopted or
  dropped: adoption leaves a pane no id the client produces can ever reach, and
  dropping throws away a pane the user had. The decision is made by schema
  version and never by looking at the id, because a target is free text and can
  be made to look like anything.
- `SharpMUTermApp.CarryLegacySpawnLogsOver` moves the restore log's content onto
  the id its pane now has, at the launch that reads it, and drops the old file —
  so it happens once and cannot replay the same lines twice. A log no pane
  claims is left exactly where it was, as it always has been.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpL7Ht6sLBsSEtVNsYcXMM
…ds check

`owner.Length < length + 1` passes for a declared length of int.MaxValue — the
addition wraps negative — and the next line then indexes off the end of the
string. `spawn:2147483647:abc:x` in a hand-edited config or a corrupt restore
log file name threw IndexOutOfRangeException out of a reader whose whole
contract is to answer false. Written as `owner.Length <= length`, which says the
same thing and cannot wrap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpL7Ht6sLBsSEtVNsYcXMM
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 34 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: c1340174-6194-4e06-87ed-702db889862e

📥 Commits

Reviewing files that changed from the base of the PR and between 6774e90 and 52d9c2b.

📒 Files selected for processing (10)
  • src/SharpMUTerm.Core/Workspace/Workspace.cs
  • src/SharpMUTerm.Tui/DemoScene.cs
  • src/SharpMUTerm.Tui/SharpMUTermApp.cs
  • tests/SharpMUTerm.Core.Tests/Configuration/SpawnWindowIdMigrationTests.cs
  • tests/SharpMUTerm.Core.Tests/Workspace/WindowNumberingTests.cs
  • tests/SharpMUTerm.Core.Tests/Workspace/WorkspaceStateTests.cs
  • tests/SharpMUTerm.Tui.Tests/FocusIndicationTests.cs
  • tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs
  • tests/SharpMUTerm.Tui.Tests/SpawnWindowPerSessionTests.cs
  • tests/SharpMUTerm.Tui.Tests/WindowJumpTests.cs

Walkthrough

The change introduces session-qualified spawn window IDs, updates routing and restore behavior, migrates persisted version 4 configurations and logs, and adds coverage for multi-session isolation. SharpConsoleUI now uses the pinned package only.

Changes

Session-aware spawn windows

Layer / File(s) Summary
SharpConsoleUI package policy
CLAUDE.md, src/SharpMUTerm.Tui/SharpMUTerm.Tui.csproj
The project uses SharpConsoleUI 2.5.14 without sibling source detection or a build switch.
Spawn ID contract and routing
src/SharpMUTerm.Core/Workspace/Workspace.cs, tests/SharpMUTerm.Core.Tests/Workspace/*
Spawn IDs encode session ownership and target with reversible parsing. Routing isolates owned windows and reuses unowned windows per target.
Configuration schema migration
src/SharpMUTerm.Core/Configuration/*, tests/SharpMUTerm.Core.Tests/Configuration/*
Schema version 5 migrates persisted spawn IDs and nested layout tabs while handling malformed and unrelated entries.
Application restore and routing integration
src/SharpMUTerm.Tui/DemoScene.cs, src/SharpMUTerm.Tui/SharpMUTermApp.cs
Demo references, live capture routing, restore replay, and legacy restore-log handling use session-qualified IDs.
Session-aware end-to-end validation
tests/SharpMUTerm.Tui.Tests/*
Tests cover pane restoration, log migration, shared-target isolation, rail visibility, reconnect reuse, and updated Chat window references.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CharacterSession
  participant SharpMUTermApp
  participant Workspace
  participant RestoreLogFiles
  CharacterSession->>SharpMUTermApp: capture target output
  SharpMUTermApp->>Workspace: route by session key and target
  Workspace-->>SharpMUTermApp: return session-qualified window
  SharpMUTermApp->>RestoreLogFiles: append restored content
  SharpMUTermApp-->>CharacterSession: display isolated capture pane
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 72.60% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the PR's main change: assigning each connected character a separate capture pane.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/SharpMUTerm.Tui.Tests/SpawnWindowPerSessionTests.cs`:
- Around line 133-143: Update the assertions in SpawnWindowPerSessionTests to
inspect only the Public window’s tab and rail row. Filter PaneTabTitles and
RailLines to the entries associated with Public before asserting the display
name, and remove the collection-wide DoesNotContain(Bob) checks; leave the
Public presence assertions intact.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 15a3d26f-2869-4d0a-9b6b-63f528664645

📥 Commits

Reviewing files that changed from the base of the PR and between 04b99a8 and 6774e90.

📒 Files selected for processing (26)
  • CLAUDE.md
  • src/SharpMUTerm.Core/Configuration/AppConfiguration.cs
  • src/SharpMUTerm.Core/Configuration/ConfigurationMigrator.cs
  • src/SharpMUTerm.Core/Workspace/Workspace.cs
  • src/SharpMUTerm.Tui/DemoScene.cs
  • src/SharpMUTerm.Tui/SharpMUTerm.Tui.csproj
  • src/SharpMUTerm.Tui/SharpMUTermApp.cs
  • tests/SharpMUTerm.Core.Tests/Configuration/ConfigurationTests.cs
  • tests/SharpMUTerm.Core.Tests/Configuration/SpawnWindowIdMigrationTests.cs
  • tests/SharpMUTerm.Core.Tests/Workspace/SpawnWindowIdTests.cs
  • tests/SharpMUTerm.Core.Tests/Workspace/WorkspaceStateTests.cs
  • tests/SharpMUTerm.Core.Tests/Workspace/WorkspaceTests.cs
  • tests/SharpMUTerm.Tui.Tests/FocusIndicationTests.cs
  • tests/SharpMUTerm.Tui.Tests/HistorySearchEndToEndTests.cs
  • tests/SharpMUTerm.Tui.Tests/InputAreaEndToEndTests.cs
  • tests/SharpMUTerm.Tui.Tests/NawsPaneReportTests.cs
  • tests/SharpMUTerm.Tui.Tests/PanePrefixEndToEndTests.cs
  • tests/SharpMUTerm.Tui.Tests/PaneResizeEndToEndTests.cs
  • tests/SharpMUTerm.Tui.Tests/PaneTabCloseTests.cs
  • tests/SharpMUTerm.Tui.Tests/PrefixWhichKeyTests.cs
  • tests/SharpMUTerm.Tui.Tests/RailClickTests.cs
  • tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs
  • tests/SharpMUTerm.Tui.Tests/RestoreLogEndToEndTests.cs
  • tests/SharpMUTerm.Tui.Tests/SessionStateAccountingTests.cs
  • tests/SharpMUTerm.Tui.Tests/SpawnWindowIdUpgradeTests.cs
  • tests/SharpMUTerm.Tui.Tests/SpawnWindowPerSessionTests.cs

Comment thread tests/SharpMUTerm.Tui.Tests/SpawnWindowPerSessionTests.cs Outdated
#14 landed first, so this carries its rail rewrite, its window numbering
and the removal of CapturePattern. The merge was textually clean and
semantically not: SpawnWindowId takes an owner now, so #14's new tests
called it with one argument, and its migration fixture asserted on a field
#14 deleted.

The review item on BothPanesAreStillCalledPublic was right that a
frame-wide DoesNotContain is the wrong shape - it can pass for reasons
unrelated to its claim - though not for the stated reason: the constant is
the session key 'Convergence.Bob', not 'Bob', so the collections it
warned about would not have matched it.

Scoping the assertion found what the frame-wide one had been missing. A
spawn tab reads 'Ann - Public (1)': owner label, name, unread count, in
markup. So the test now reads that tab specifically, through StripMarkup,
and pins the distinction that actually matters - the character's name is on
the strip deliberately, so two characters capturing one target can be told
apart, and the session key must never be. It still fails on the shared id:
4 of 5, including this one.
@HarryCordewener
HarryCordewener merged commit 54832b2 into main Jul 31, 2026
3 checks passed
@HarryCordewener
HarryCordewener deleted the fix/spawn-window-per-session branch July 31, 2026 16:55
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.

1 participant