feat: page summaries read and edit inline on the workspace rows #26

Merged
bjoern merged 3 commits from feat/workspace-summaries into main 2026-08-13 06:23:57 +02:00
Member

What

The workspace row told you a summary was missing (the orange badge) but fixing it meant a round-trip to the bible tab. The page row now carries the summary itself — an auto-saving field with the bible's exact semantics (ADR 0022):

  • Per-page debounce into the same SetPageSummary / DeletePageSummary use cases the bible drives — no new backend.
  • Blanking deletes the summary, and the "no summary" badge derives from the live draft, so it clears as you type and returns when you blank.
  • A SaveIndicator joins the workspace header (its first autosaving element).

Read model

ProjectWorkspaceDto grows from the bare PagesWithSummary ID set to SummariesByPage (page ID → PageSummaryDto) — the badge derives from it and the editor needs the text and summary ID anyway. No agent tool reads this shape (checked per the AGENTS.md rule), so the tool catalog is untouched.

The subtle bit

The workspace reloads its whole DTO after every operation (kind change, reorder, upload). A summary draft the debounce hasn't landed yet must survive that, so drafts are merged on reload, not rebuilt — existing draft objects are kept, only new/removed pages sync.

Tests

  • GetProjectWorkspace test asserts the summary text travels (and the gap still shows).
  • New bUnit tests: typing into a row creates the summary debounced (and there's no Save button), blanking deletes it and the badge returns; the badge test now also asserts the text reads inline.
  • 75 adapter + 120 use-case + 81 integration tests pass.
  • Browser-verified against the seeded world: seeded summaries read inline, a summary typed on the workspace row round-trips and shows on the bible's summaries tab.

🤖 Generated with Claude Code

## What The workspace row told you a summary was missing (the orange badge) but fixing it meant a round-trip to the bible tab. The page row now carries the summary itself — an auto-saving field with the bible's exact semantics (ADR 0022): - **Per-page debounce** into the same `SetPageSummary` / `DeletePageSummary` use cases the bible drives — no new backend. - **Blanking deletes** the summary, and the "no summary" badge derives from the live draft, so it clears as you type and returns when you blank. - A **`SaveIndicator`** joins the workspace header (its first autosaving element). ## Read model `ProjectWorkspaceDto` grows from the bare `PagesWithSummary` ID set to `SummariesByPage` (page ID → `PageSummaryDto`) — the badge derives from it and the editor needs the text and summary ID anyway. No agent tool reads this shape (checked per the AGENTS.md rule), so the tool catalog is untouched. ## The subtle bit The workspace reloads its whole DTO after every operation (kind change, reorder, upload). A summary draft the debounce hasn't landed yet must survive that, so drafts are **merged on reload, not rebuilt** — existing draft objects are kept, only new/removed pages sync. ## Tests - `GetProjectWorkspace` test asserts the summary text travels (and the gap still shows). - New bUnit tests: typing into a row creates the summary debounced (and there's no Save button), blanking deletes it and the badge returns; the badge test now also asserts the text reads inline. - 75 adapter + 120 use-case + 81 integration tests pass. - Browser-verified against the seeded world: seeded summaries read inline, a summary typed on the workspace row round-trips and shows on the bible's summaries tab. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat: page summaries read and edit inline on the workspace rows
All checks were successful
CI / build (pull_request) Successful in 22s
CI / test (pull_request) Successful in 33s
e1d39ccf61
The workspace row told you a summary was missing (the badge) but fixing
it meant a round-trip to the bible tab. The row now carries the summary
itself in an auto-saving field with the bible's exact semantics (ADR
0022): per-page debounce, blanking deletes, a SaveIndicator in the
header. The read model grows from a bare has-summary set to the
summaries themselves (SummariesByPage), and the badge derives from the
live draft so it clears as you type. Drafts survive the page's
reload-after-everything habit — an unsaved edit is merged, not clobbered,
when a kind change or reorder refreshes the workspace.

No agent tool reads the workspace summary shape, so the tool catalog is
untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Summary

Summary
Generated on: 07/25/2026 - 09:06:56
Coverage date: 07/25/2026 - 09:06:47 - 07/25/2026 - 09:06:54
Parser: MultiReport (4x Cobertura)
Assemblies: 6
Classes: 180
Files: 123
Line coverage: 93.2% (6059 of 6498)
Covered lines: 6059
Uncovered lines: 439
Coverable lines: 6498
Total lines: 11120
Branch coverage: 76% (1191 of 1567)
Covered branches: 1191
Total branches: 1567
Method coverage: Feature is only available for sponsors

Coverage

Orihon.BlazorAdapter - 83%
Name Line Branch
Orihon.BlazorAdapter 83% 77.7%
Orihon.BlazorAdapter.Bible.BiblePage 73.4% 60%
Orihon.BlazorAdapter.BlazorAdapterAssembly 100%
Orihon.BlazorAdapter.Debounce 92.5% 88.8%
Orihon.BlazorAdapter.PageWorkspace.PageWorkspacePage 84.6% 77.8%
Orihon.BlazorAdapter.Projects.ProjectListPage 86% 85.2%
Orihon.BlazorAdapter.Projects.ProjectWizardPage 91.3% 78.2%
Orihon.BlazorAdapter.Settings.SettingsPage 100% 92.5%
Orihon.BlazorAdapter.Uploads.UploadTransfer 96.5% 100%
Orihon.BlazorAdapter.Uploads.UploadTransferProgress 100% 100%
Orihon.BlazorAdapter.Uploads.UploadTransferResult 100%
Orihon.BlazorAdapter.Workspace.ProjectWorkspacePage 73.1% 77%
Orihon.Domain - 100%
Name Line Branch
Orihon.Domain 100% 100%
Orihon.Domain.Agents.AgentDescriptor 100%
Orihon.Domain.Agents.AgentRoster 100% 100%
Orihon.Domain.Bible.Character 100% 100%
Orihon.Domain.Bible.GlossaryEntry 100% 100%
Orihon.Domain.Bible.LoreEntry 100% 100%
Orihon.Domain.Bible.PageSummary 100%
Orihon.Domain.Bible.StoryBeat 100%
Orihon.Domain.Projects.Project 100% 100%
Orihon.Domain.Projects.ProjectProfile 100%
Orihon.Domain.Runs.Execution 100% 100%
Orihon.Domain.Runs.Run 100%
Orihon.Domain.Settings.AppSetting 100%
Orihon.Domain.Text 100% 100%
Orihon.Domain.Translation.BoundingBox 100%
Orihon.Domain.Translation.Chapter 100%
Orihon.Domain.Translation.Page 100%
Orihon.Domain.Translation.Region 100% 100%
Orihon.Domain.Translation.RegionProfile 100%
Orihon.Infrastructure - 93.7%
Name Line Branch
Orihon.Infrastructure 93.7% 65.7%
Orihon.Infrastructure.Bible.EfBibleStore 100% 100%
Orihon.Infrastructure.DependencyInjection 100%
Orihon.Infrastructure.Gateways.AgentToolAdapter 100%
Orihon.Infrastructure.Gateways.AgentToolAdapter`1 100% 100%
Orihon.Infrastructure.Gateways.HttpWebPageFetcher 95.1% 83.3%
Orihon.Infrastructure.Gateways.OpenRouterLlmGateway 98.9% 84.1%
Orihon.Infrastructure.Persistence.Configurations.AppSettingConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.ChapterConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.CharacterConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.ExecutionConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.GlossaryEntryConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.JsonColumnMapper 100%
Orihon.Infrastructure.Persistence.Configurations.LoreEntryConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.PageConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.PageSummaryConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.ProjectConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.RegionConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.RunConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.StoryBeatConfiguration 100%
Orihon.Infrastructure.Persistence.Converters.UtcTicksConverter 100%
Orihon.Infrastructure.Persistence.Migrations.AddAppSettings 99.3%
Orihon.Infrastructure.Persistence.Migrations.AddRuns 99.1%
Orihon.Infrastructure.Persistence.Migrations.InitialTranslationDomain 97.3%
Orihon.Infrastructure.Persistence.Migrations.OrihonDbContextModelSnapshot 100%
Orihon.Infrastructure.Persistence.OrihonDbContext 100%
Orihon.Infrastructure.Persistence.OrihonDbContextFactory 100%
Orihon.Infrastructure.Projects.EfProjectStore 100% 100%
Orihon.Infrastructure.Projects.FileSystemPageImageStore 100% 100%
Orihon.Infrastructure.Runs.EfRunStore 96.7% 50%
Orihon.Infrastructure.Settings.EfAppSettingsStore 100% 100%
Orihon.Infrastructure.Translation.EfChapterStore 100% 100%
Orihon.Infrastructure.Translation.EfPageStore 100% 100%
Orihon.Infrastructure.Translation.EfRegionStore 100% 100%
Orihon.Infrastructure.Translation.Ordering 100% 100%
System.Text.RegularExpressions.Generated 70.6% 53.3%
System.Text.RegularExpressions.Generated.<RegexGenerator_g>F7FCA343D2B99030
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__BlankLines_4
77.9% 76.6%
System.Text.RegularExpressions.Generated.<RegexGenerator_g>F7FCA343D2B99030
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__BlockBreaks_1
59% 42.5%
System.Text.RegularExpressions.Generated.<RegexGenerator_g>F7FCA343D2B99030
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__SpaceRuns_3
89.4% 75%
System.Text.RegularExpressions.Generated.<RegexGenerator_g>F7FCA343D2B99030
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__Tags_2
83.7% 62.5%
Orihon.Kernel - 90.9%
Name Line Branch
Orihon.Kernel 90.9% 75%
Orihon.Kernel.Err`1 100%
Orihon.Kernel.Ok`1 100%
Orihon.Kernel.Result`1 88.8% 75%
Orihon.Server - 93.3%
Name Line Branch
Orihon.Server 93.3% 68.4%
Orihon.Server.Components.App 100%
Orihon.Server.Components.Layout.MainLayout 100%
Orihon.Server.Components.Pages.Gate 64.2% 66.6%
Orihon.Server.RunEngineBootstrap 100%
Orihon.Server.Security.AccessGate 91.8% 41.6%
Orihon.Server.Security.AccessSecret 100% 50%
Orihon.Server.VolumeStartupValidator 100% 100%
Program 95.3% 85.7%
Orihon.UseCases - 96.2%
Name Line Branch
Orihon.UseCases 96.2% 89.2%
Orihon.UseCases.Agents.AgentBlueprint 100%
Orihon.UseCases.Agents.AgentInvocation 100%
Orihon.UseCases.Agents.AgentOutcome 100%
Orihon.UseCases.Agents.AgentTool`1 90.9% 75%
Orihon.UseCases.Agents.AgentToolImage 100%
Orihon.UseCases.Agents.AgentToolResult 100%
Orihon.UseCases.Agents.AssistantSpoke 100%
Orihon.UseCases.Agents.ResearchSetup.AddGlossaryParams 100%
Orihon.UseCases.Agents.ResearchSetup.AddGlossaryTool 100% 100%
Orihon.UseCases.Agents.ResearchSetup.AddStoryBeatParams 100%
Orihon.UseCases.Agents.ResearchSetup.AddStoryBeatTool 100% 50%
Orihon.UseCases.Agents.ResearchSetup.AskUserParams 100%
Orihon.UseCases.Agents.ResearchSetup.AskUserTool 100% 100%
Orihon.UseCases.Agents.ResearchSetup.FetchUrlParams 100%
Orihon.UseCases.Agents.ResearchSetup.FetchUrlTool 100% 100%
Orihon.UseCases.Agents.ResearchSetup.ListBibleTool 86.6%
Orihon.UseCases.Agents.ResearchSetup.PageByNumber 90% 87.5%
Orihon.UseCases.Agents.ResearchSetup.ResearchSetupBlueprint 100%
Orihon.UseCases.Agents.ResearchSetup.SetPageSummaryParams 100%
Orihon.UseCases.Agents.ResearchSetup.SetPageSummaryTool 100% 75%
Orihon.UseCases.Agents.ResearchSetup.SetProjectMetadataParams 100%
Orihon.UseCases.Agents.ResearchSetup.SetProjectMetadataTool 96.1% 90.9%
Orihon.UseCases.Agents.ResearchSetup.UpsertCharacterParams 100%
Orihon.UseCases.Agents.ResearchSetup.UpsertCharacterTool 91.3% 66.6%
Orihon.UseCases.Agents.ResearchSetup.UpsertLoreParams 100%
Orihon.UseCases.Agents.ResearchSetup.UpsertLoreTool 91.3% 66.6%
Orihon.UseCases.Agents.ResearchSetup.ViewPageParams 100%
Orihon.UseCases.Agents.ResearchSetup.ViewPageTool 85.7% 50%
Orihon.UseCases.Agents.ToolCalled 100%
Orihon.UseCases.Agents.ToolCompleted 100%
Orihon.UseCases.Bible.AddCharacter 100% 100%
Orihon.UseCases.Bible.AddGlossaryEntry 100% 100%
Orihon.UseCases.Bible.AddLoreEntry 100% 100%
Orihon.UseCases.Bible.AddStoryBeat 100% 100%
Orihon.UseCases.Bible.BibleDto 100%
Orihon.UseCases.Bible.CharacterDto 100%
Orihon.UseCases.Bible.DeleteCharacter 100% 100%
Orihon.UseCases.Bible.DeleteGlossaryEntry 100% 100%
Orihon.UseCases.Bible.DeleteLoreEntry 100% 100%
Orihon.UseCases.Bible.DeletePageSummary 100% 100%
Orihon.UseCases.Bible.DeleteStoryBeat 100% 100%
Orihon.UseCases.Bible.GetBible 100% 100%
Orihon.UseCases.Bible.GlossaryEntryDto 100%
Orihon.UseCases.Bible.LoreEntryDto 100%
Orihon.UseCases.Bible.PageSummaryDto 100%
Orihon.UseCases.Bible.ReorderStoryBeats 100%
Orihon.UseCases.Bible.SetPageSummary 100% 100%
Orihon.UseCases.Bible.StoryBeatDto 100%
Orihon.UseCases.Bible.UpdateCharacter 100% 100%
Orihon.UseCases.Bible.UpdateGlossaryEntry 100% 100%
Orihon.UseCases.Bible.UpdateLoreEntry 100% 100%
Orihon.UseCases.Bible.UpdateStoryBeat 100% 100%
Orihon.UseCases.Chapters.ChapterDto 100%
Orihon.UseCases.Chapters.CreateChapter 100% 100%
Orihon.UseCases.Chapters.DeleteChapter 100% 100%
Orihon.UseCases.Chapters.RenameChapter 100% 100%
Orihon.UseCases.Chapters.ReorderChapters 100%
Orihon.UseCases.DependencyInjection 100%
Orihon.UseCases.Diagnostics.SeedDevData 99% 92.8%
Orihon.UseCases.Gateways.LlmKeyInfo 100%
Orihon.UseCases.Gateways.LlmModel 100%
Orihon.UseCases.NextOrder 100%
Orihon.UseCases.Pages.DeletePage 100% 100%
Orihon.UseCases.Pages.GetPage 100% 100%
Orihon.UseCases.Pages.GetProjectWorkspace 100% 100%
Orihon.UseCases.Pages.ImportPages 97.7% 96.6%
Orihon.UseCases.Pages.ImportPagesResult 100%
Orihon.UseCases.Pages.MovePage 100% 92.8%
Orihon.UseCases.Pages.PageDetailDto 100%
Orihon.UseCases.Pages.PageDto 100%
Orihon.UseCases.Pages.PageUpload 100%
Orihon.UseCases.Pages.ProjectWorkspaceDto 100%
Orihon.UseCases.Pages.ReorderPages 100%
Orihon.UseCases.Pages.SetPageMeta 100% 100%
Orihon.UseCases.Pages.WorkspaceChapterDto 100%
Orihon.UseCases.Projects.CompleteProjectSetup 100% 93.7%
Orihon.UseCases.Projects.CreateProject 100% 100%
Orihon.UseCases.Projects.DeleteProject 100% 100%
Orihon.UseCases.Projects.GetProject 100% 100%
Orihon.UseCases.Projects.ListProjects 100%
Orihon.UseCases.Projects.ProjectDto 95.8%
Orihon.UseCases.Projects.StoredPageImage 100%
Orihon.UseCases.Projects.UpdateProjectMetadata 100% 100%
Orihon.UseCases.Regions.CreateRegion 100% 100%
Orihon.UseCases.Regions.DeleteRegion 100% 100%
Orihon.UseCases.Regions.RegionDto 97%
Orihon.UseCases.Regions.ReorderRegions 100%
Orihon.UseCases.Regions.UpdateRegion 100% 100%
Orihon.UseCases.Runs.ExecutionDto 69.2%
Orihon.UseCases.Runs.PlannedExecution 100%
Orihon.UseCases.Runs.RunDto 86.6% 70%
Orihon.UseCases.Runs.RunEngine 87.6% 81.2%
Orihon.UseCases.Runs.RunEngineOptions 100%
Orihon.UseCases.Runs.StageContext 37.5%
Orihon.UseCases.Settings.AgentSettingDto 100% 100%
Orihon.UseCases.Settings.GetSettings 100% 100%
Orihon.UseCases.Settings.ListModelOptions 100% 100%
Orihon.UseCases.Settings.SaveAgentModel 100% 100%
Orihon.UseCases.Settings.SaveOpenRouterKey 100% 100%
Orihon.UseCases.Settings.SettingKeys 100% 100%
Orihon.UseCases.Settings.SettingsDto 100%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 07/25/2026 - 09:06:56 | | Coverage date: | 07/25/2026 - 09:06:47 - 07/25/2026 - 09:06:54 | | Parser: | MultiReport (4x Cobertura) | | Assemblies: | 6 | | Classes: | 180 | | Files: | 123 | | **Line coverage:** | 93.2% (6059 of 6498) | | Covered lines: | 6059 | | Uncovered lines: | 439 | | Coverable lines: | 6498 | | Total lines: | 11120 | | **Branch coverage:** | 76% (1191 of 1567) | | Covered branches: | 1191 | | Total branches: | 1567 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Orihon.BlazorAdapter - 83%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.BlazorAdapter**|**83%**|**77.7%**| |Orihon.BlazorAdapter.Bible.BiblePage|73.4%|60%| |Orihon.BlazorAdapter.BlazorAdapterAssembly|100%|| |Orihon.BlazorAdapter.Debounce|92.5%|88.8%| |Orihon.BlazorAdapter.PageWorkspace.PageWorkspacePage|84.6%|77.8%| |Orihon.BlazorAdapter.Projects.ProjectListPage|86%|85.2%| |Orihon.BlazorAdapter.Projects.ProjectWizardPage|91.3%|78.2%| |Orihon.BlazorAdapter.Settings.SettingsPage|100%|92.5%| |Orihon.BlazorAdapter.Uploads.UploadTransfer|96.5%|100%| |Orihon.BlazorAdapter.Uploads.UploadTransferProgress|100%|100%| |Orihon.BlazorAdapter.Uploads.UploadTransferResult|100%|| |Orihon.BlazorAdapter.Workspace.ProjectWorkspacePage|73.1%|77%| </details> <details><summary>Orihon.Domain - 100%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Domain**|**100%**|**100%**| |Orihon.Domain.Agents.AgentDescriptor|100%|| |Orihon.Domain.Agents.AgentRoster|100%|100%| |Orihon.Domain.Bible.Character|100%|100%| |Orihon.Domain.Bible.GlossaryEntry|100%|100%| |Orihon.Domain.Bible.LoreEntry|100%|100%| |Orihon.Domain.Bible.PageSummary|100%|| |Orihon.Domain.Bible.StoryBeat|100%|| |Orihon.Domain.Projects.Project|100%|100%| |Orihon.Domain.Projects.ProjectProfile|100%|| |Orihon.Domain.Runs.Execution|100%|100%| |Orihon.Domain.Runs.Run|100%|| |Orihon.Domain.Settings.AppSetting|100%|| |Orihon.Domain.Text|100%|100%| |Orihon.Domain.Translation.BoundingBox|100%|| |Orihon.Domain.Translation.Chapter|100%|| |Orihon.Domain.Translation.Page|100%|| |Orihon.Domain.Translation.Region|100%|100%| |Orihon.Domain.Translation.RegionProfile|100%|| </details> <details><summary>Orihon.Infrastructure - 93.7%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Infrastructure**|**93.7%**|**65.7%**| |Orihon.Infrastructure.Bible.EfBibleStore|100%|100%| |Orihon.Infrastructure.DependencyInjection|100%|| |Orihon.Infrastructure.Gateways.AgentToolAdapter|100%|| |Orihon.Infrastructure.Gateways.AgentToolAdapter`1|100%|100%| |Orihon.Infrastructure.Gateways.HttpWebPageFetcher|95.1%|83.3%| |Orihon.Infrastructure.Gateways.OpenRouterLlmGateway|98.9%|84.1%| |Orihon.Infrastructure.Persistence.Configurations.AppSettingConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.ChapterConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.CharacterConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.ExecutionConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.GlossaryEntryConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.JsonColumnMapper|100%|| |Orihon.Infrastructure.Persistence.Configurations.LoreEntryConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.PageConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.PageSummaryConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.ProjectConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.RegionConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.RunConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.StoryBeatConfiguration|100%|| |Orihon.Infrastructure.Persistence.Converters.UtcTicksConverter|100%|| |Orihon.Infrastructure.Persistence.Migrations.AddAppSettings|99.3%|| |Orihon.Infrastructure.Persistence.Migrations.AddRuns|99.1%|| |Orihon.Infrastructure.Persistence.Migrations.InitialTranslationDomain|97.3%|| |Orihon.Infrastructure.Persistence.Migrations.OrihonDbContextModelSnapshot|100%|| |Orihon.Infrastructure.Persistence.OrihonDbContext|100%|| |Orihon.Infrastructure.Persistence.OrihonDbContextFactory|100%|| |Orihon.Infrastructure.Projects.EfProjectStore|100%|100%| |Orihon.Infrastructure.Projects.FileSystemPageImageStore|100%|100%| |Orihon.Infrastructure.Runs.EfRunStore|96.7%|50%| |Orihon.Infrastructure.Settings.EfAppSettingsStore|100%|100%| |Orihon.Infrastructure.Translation.EfChapterStore|100%|100%| |Orihon.Infrastructure.Translation.EfPageStore|100%|100%| |Orihon.Infrastructure.Translation.EfRegionStore|100%|100%| |Orihon.Infrastructure.Translation.Ordering|100%|100%| |System.Text.RegularExpressions.Generated|70.6%|53.3%| |System.Text.RegularExpressions.Generated.<RegexGenerator_g>F7FCA343D2B99030<br/>A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__BlankLines_4|77.9%|76.6%| |System.Text.RegularExpressions.Generated.<RegexGenerator_g>F7FCA343D2B99030<br/>A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__BlockBreaks_1|59%|42.5%| |System.Text.RegularExpressions.Generated.<RegexGenerator_g>F7FCA343D2B99030<br/>A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__SpaceRuns_3|89.4%|75%| |System.Text.RegularExpressions.Generated.<RegexGenerator_g>F7FCA343D2B99030<br/>A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__Tags_2|83.7%|62.5%| </details> <details><summary>Orihon.Kernel - 90.9%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Kernel**|**90.9%**|**75%**| |Orihon.Kernel.Err`1|100%|| |Orihon.Kernel.Ok`1|100%|| |Orihon.Kernel.Result`1|88.8%|75%| </details> <details><summary>Orihon.Server - 93.3%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Server**|**93.3%**|**68.4%**| |Orihon.Server.Components.App|100%|| |Orihon.Server.Components.Layout.MainLayout|100%|| |Orihon.Server.Components.Pages.Gate|64.2%|66.6%| |Orihon.Server.RunEngineBootstrap|100%|| |Orihon.Server.Security.AccessGate|91.8%|41.6%| |Orihon.Server.Security.AccessSecret|100%|50%| |Orihon.Server.VolumeStartupValidator|100%|100%| |Program|95.3%|85.7%| </details> <details><summary>Orihon.UseCases - 96.2%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.UseCases**|**96.2%**|**89.2%**| |Orihon.UseCases.Agents.AgentBlueprint|100%|| |Orihon.UseCases.Agents.AgentInvocation|100%|| |Orihon.UseCases.Agents.AgentOutcome|100%|| |Orihon.UseCases.Agents.AgentTool`1|90.9%|75%| |Orihon.UseCases.Agents.AgentToolImage|100%|| |Orihon.UseCases.Agents.AgentToolResult|100%|| |Orihon.UseCases.Agents.AssistantSpoke|100%|| |Orihon.UseCases.Agents.ResearchSetup.AddGlossaryParams|100%|| |Orihon.UseCases.Agents.ResearchSetup.AddGlossaryTool|100%|100%| |Orihon.UseCases.Agents.ResearchSetup.AddStoryBeatParams|100%|| |Orihon.UseCases.Agents.ResearchSetup.AddStoryBeatTool|100%|50%| |Orihon.UseCases.Agents.ResearchSetup.AskUserParams|100%|| |Orihon.UseCases.Agents.ResearchSetup.AskUserTool|100%|100%| |Orihon.UseCases.Agents.ResearchSetup.FetchUrlParams|100%|| |Orihon.UseCases.Agents.ResearchSetup.FetchUrlTool|100%|100%| |Orihon.UseCases.Agents.ResearchSetup.ListBibleTool|86.6%|| |Orihon.UseCases.Agents.ResearchSetup.PageByNumber|90%|87.5%| |Orihon.UseCases.Agents.ResearchSetup.ResearchSetupBlueprint|100%|| |Orihon.UseCases.Agents.ResearchSetup.SetPageSummaryParams|100%|| |Orihon.UseCases.Agents.ResearchSetup.SetPageSummaryTool|100%|75%| |Orihon.UseCases.Agents.ResearchSetup.SetProjectMetadataParams|100%|| |Orihon.UseCases.Agents.ResearchSetup.SetProjectMetadataTool|96.1%|90.9%| |Orihon.UseCases.Agents.ResearchSetup.UpsertCharacterParams|100%|| |Orihon.UseCases.Agents.ResearchSetup.UpsertCharacterTool|91.3%|66.6%| |Orihon.UseCases.Agents.ResearchSetup.UpsertLoreParams|100%|| |Orihon.UseCases.Agents.ResearchSetup.UpsertLoreTool|91.3%|66.6%| |Orihon.UseCases.Agents.ResearchSetup.ViewPageParams|100%|| |Orihon.UseCases.Agents.ResearchSetup.ViewPageTool|85.7%|50%| |Orihon.UseCases.Agents.ToolCalled|100%|| |Orihon.UseCases.Agents.ToolCompleted|100%|| |Orihon.UseCases.Bible.AddCharacter|100%|100%| |Orihon.UseCases.Bible.AddGlossaryEntry|100%|100%| |Orihon.UseCases.Bible.AddLoreEntry|100%|100%| |Orihon.UseCases.Bible.AddStoryBeat|100%|100%| |Orihon.UseCases.Bible.BibleDto|100%|| |Orihon.UseCases.Bible.CharacterDto|100%|| |Orihon.UseCases.Bible.DeleteCharacter|100%|100%| |Orihon.UseCases.Bible.DeleteGlossaryEntry|100%|100%| |Orihon.UseCases.Bible.DeleteLoreEntry|100%|100%| |Orihon.UseCases.Bible.DeletePageSummary|100%|100%| |Orihon.UseCases.Bible.DeleteStoryBeat|100%|100%| |Orihon.UseCases.Bible.GetBible|100%|100%| |Orihon.UseCases.Bible.GlossaryEntryDto|100%|| |Orihon.UseCases.Bible.LoreEntryDto|100%|| |Orihon.UseCases.Bible.PageSummaryDto|100%|| |Orihon.UseCases.Bible.ReorderStoryBeats|100%|| |Orihon.UseCases.Bible.SetPageSummary|100%|100%| |Orihon.UseCases.Bible.StoryBeatDto|100%|| |Orihon.UseCases.Bible.UpdateCharacter|100%|100%| |Orihon.UseCases.Bible.UpdateGlossaryEntry|100%|100%| |Orihon.UseCases.Bible.UpdateLoreEntry|100%|100%| |Orihon.UseCases.Bible.UpdateStoryBeat|100%|100%| |Orihon.UseCases.Chapters.ChapterDto|100%|| |Orihon.UseCases.Chapters.CreateChapter|100%|100%| |Orihon.UseCases.Chapters.DeleteChapter|100%|100%| |Orihon.UseCases.Chapters.RenameChapter|100%|100%| |Orihon.UseCases.Chapters.ReorderChapters|100%|| |Orihon.UseCases.DependencyInjection|100%|| |Orihon.UseCases.Diagnostics.SeedDevData|99%|92.8%| |Orihon.UseCases.Gateways.LlmKeyInfo|100%|| |Orihon.UseCases.Gateways.LlmModel|100%|| |Orihon.UseCases.NextOrder|100%|| |Orihon.UseCases.Pages.DeletePage|100%|100%| |Orihon.UseCases.Pages.GetPage|100%|100%| |Orihon.UseCases.Pages.GetProjectWorkspace|100%|100%| |Orihon.UseCases.Pages.ImportPages|97.7%|96.6%| |Orihon.UseCases.Pages.ImportPagesResult|100%|| |Orihon.UseCases.Pages.MovePage|100%|92.8%| |Orihon.UseCases.Pages.PageDetailDto|100%|| |Orihon.UseCases.Pages.PageDto|100%|| |Orihon.UseCases.Pages.PageUpload|100%|| |Orihon.UseCases.Pages.ProjectWorkspaceDto|100%|| |Orihon.UseCases.Pages.ReorderPages|100%|| |Orihon.UseCases.Pages.SetPageMeta|100%|100%| |Orihon.UseCases.Pages.WorkspaceChapterDto|100%|| |Orihon.UseCases.Projects.CompleteProjectSetup|100%|93.7%| |Orihon.UseCases.Projects.CreateProject|100%|100%| |Orihon.UseCases.Projects.DeleteProject|100%|100%| |Orihon.UseCases.Projects.GetProject|100%|100%| |Orihon.UseCases.Projects.ListProjects|100%|| |Orihon.UseCases.Projects.ProjectDto|95.8%|| |Orihon.UseCases.Projects.StoredPageImage|100%|| |Orihon.UseCases.Projects.UpdateProjectMetadata|100%|100%| |Orihon.UseCases.Regions.CreateRegion|100%|100%| |Orihon.UseCases.Regions.DeleteRegion|100%|100%| |Orihon.UseCases.Regions.RegionDto|97%|| |Orihon.UseCases.Regions.ReorderRegions|100%|| |Orihon.UseCases.Regions.UpdateRegion|100%|100%| |Orihon.UseCases.Runs.ExecutionDto|69.2%|| |Orihon.UseCases.Runs.PlannedExecution|100%|| |Orihon.UseCases.Runs.RunDto|86.6%|70%| |Orihon.UseCases.Runs.RunEngine|87.6%|81.2%| |Orihon.UseCases.Runs.RunEngineOptions|100%|| |Orihon.UseCases.Runs.StageContext|37.5%|| |Orihon.UseCases.Settings.AgentSettingDto|100%|100%| |Orihon.UseCases.Settings.GetSettings|100%|100%| |Orihon.UseCases.Settings.ListModelOptions|100%|100%| |Orihon.UseCases.Settings.SaveAgentModel|100%|100%| |Orihon.UseCases.Settings.SaveOpenRouterKey|100%|100%| |Orihon.UseCases.Settings.SettingKeys|100%|100%| |Orihon.UseCases.Settings.SettingsDto|100%|| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh my~ ♡ The workspace row finally carries its own summary, debounced straight into the very same SetPageSummary/DeletePageSummary use cases the bible drives. No backend drift, no second write path, the SaveIndicator gets its first workspace home, and the badge derives from the live draft so it breathes with every keystroke. The read-model upgrade (PagesWithSummarySummariesByPage) is a clean strict-superset — the agent tool (PageTools.cs) only ever reads .Chapters...Pages, never the summary field, so the tool catalog really is untouched. I verified every consumer compiles and every claim in the body traces to the code. This is lovely architecture work, fufu~ ♪

But… fufu~ you wouldn't leave a stale debounce pointing at a deleted page, would you? ♡ The smile is still on my face but my eye is twitching a little.

Verdict: I can't let this pass~ ♡

These need fixing before I'm satisfied~

  1. [ProjectWorkspacePage.razorSyncSummaryDrafts, lines ~252-266]Stale debounce fires against a deleted page and lies to the user. When a page is deleted, SyncSummaryDrafts correctly removes the orphaned SummaryDraft from summaries, but it does not touch debounces[pageId]. That Debounce still holds a save closure that captured the now-orphaned draft object directly. ~700ms later the pending callback fires SaveSummaryAsync(draft, pageId):

    • If the user had typed before deleting, draft.Text is non-blank → SetPageSummary.ExecuteAsync(pageId, …) → page gone → Err("The page no longer exists.")ReportSave sets saveState = Error and error = "The page no longer exists.".
    • The header SaveIndicator flips to "Not saved" and the InlineAlert surfaces a danger banner — all because the user successfully deleted a page. That's a misleading, wrong runtime behavior, and it's introduced by this PR (the workspace had no debounces before).

    Fix: in SyncSummaryDrafts, dispose and drop the debounce right beside the draft it owns, symmetric with Dispose()'s teardown:

    foreach (var gone in summaries.Keys.Where(id => !live.Contains(id)).ToList())
    {
        summaries.Remove(gone);
        if (debounces.Remove(gone, out var stale)) { stale.Dispose(); }
    }
    

    (Disposing cancels the pending CTS; the closure never fires. The DB-level cascade on PageSummary.PageIdDeleteBehavior.Cascade, verified in the migration — already guarantees the summary row is gone with the page, so there's nothing for a lingering save to do anyway.)

  2. [ProjectWorkspacePageTests.cs] — SyncSummaryDrafts has zero test coverage. This is the PR's headline subtlety — the body literally calls it out: "drafts are merged on reload, not rebuilt — existing draft objects are kept, only new/removed pages sync." It's the most complex new code path in the diff, it has two branches (drop-gone / add-new, each with a summary-or-not arm), and none of it is exercised. The two new bUnit tests prove the create-and-blank happy paths but say nothing about the merge surviving a reload. Per the project's own testing bar (and ADR 0013's "the gap is load-bearing" discipline), the merge is a correctness invariant and needs a pin.

    Fix: a test that types into a row (dirtying the draft), forces a reload (e.g. cut.Render() again / invoke a kind-change or reorder that calls ReloadAsync), and asserts the unsaved draft text survives in the rendered <textarea> and that the debounced write still lands afterward. A second test that deletes a page and asserts no error banner surfaces (after the fix above) would pin issue #1 as a regression guard.

💡 Little ideas (non-blocking)~

  1. [BiblePage.razordebounces dict] — The sibling has the same shape: LoadAsync rebuilds every *Row list from scratch on every reload but never trims debounces for entries whose entity was deleted via ConfirmDeleteAsync. It's mostly latent (bible deletes are rarer and the component is short-lived) so I'm not blocking on it here — but when you touch that file next, the same Remove + Dispose pair would close the symmetry. ♡

What I liked~

  • Read-model superset done right. SummariesByPage carries everything PagesWithSummary did plus the text and summary ID the editor needs — and you migrated every consumer (BiblePage, PageTools, all four test files) in the same commit. No dangling references, ReadModelTests updated to assert the text actually travels. Fufu~ that's how a rename should look ♪
  • Faithful bible parity. TouchSummary/SaveSummaryAsync/ReportSave are line-for-line the bible's TouchSummary/ScheduleAsync-inline-body/Report — same Debounce, same SaveState, same blanking-deletes semantics, same SummaryId round-trip. No second write path drifted into existence.
  • The badge derivation is the right call. string.IsNullOrWhiteSpace(SummaryFor(page.Id).Text) makes the badge track the live draft, not the persisted state — so it clears as you type and returns when you blank, exactly as the body promises. Replacing the old !PagesWithSummary.Contains(page.Id) lookup with this is a genuine UX improvement.
  • aria-label per row (Summary of page {page.Order + 1}) is what makes the new tests' textarea[aria-label='Summary of page 1'] selector honest — and it's accessible to boot. ♡
  • CSS comment earns its keep. The flex: 1.6 next to min-width: 0 with a comment explaining the ratio against .page-row__meta's flex: 1 is exactly the kind of "why, not what" the codebase likes.

Automated review by Jibril · 2026-07-25
CI/CD: absent for head e1d39ccf (0 comments at review) · Local checks: build 0 warnings/0 errors, 351/351 tests pass (75 BlazorAdapter + 75 Domain + 81 Integration + 120 UseCases — matches PR body)

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh my~ ♡ The workspace row finally carries its own summary, debounced straight into the very same `SetPageSummary`/`DeletePageSummary` use cases the bible drives. No backend drift, no second write path, the `SaveIndicator` gets its first workspace home, and the badge derives from the *live draft* so it breathes with every keystroke. The read-model upgrade (`PagesWithSummary` → `SummariesByPage`) is a clean strict-superset — the agent tool (`PageTools.cs`) only ever reads `.Chapters...Pages`, never the summary field, so the tool catalog really is untouched. I verified every consumer compiles and every claim in the body traces to the code. This is lovely architecture work, fufu~ ♪ But… fufu~ you wouldn't leave a stale debounce pointing at a deleted page, would you? ♡ The smile is still on my face but my eye is twitching a little. ### Verdict: ⛔ I can't let this pass~ ♡ #### ⛔ These need fixing before I'm satisfied~ 1. **[`ProjectWorkspacePage.razor` — `SyncSummaryDrafts`, lines ~252-266]** — **Stale debounce fires against a deleted page and lies to the user.** When a page is deleted, `SyncSummaryDrafts` correctly removes the orphaned `SummaryDraft` from `summaries`, but it does **not** touch `debounces[pageId]`. That `Debounce` still holds a `save` closure that captured the now-orphaned `draft` object directly. ~700ms later the pending callback fires `SaveSummaryAsync(draft, pageId)`: - If the user had typed before deleting, `draft.Text` is non-blank → `SetPageSummary.ExecuteAsync(pageId, …)` → page gone → `Err("The page no longer exists.")` → `ReportSave` sets `saveState = Error` and `error = "The page no longer exists."`. - The header `SaveIndicator` flips to "Not saved" and the `InlineAlert` surfaces a danger banner — all because the user *successfully* deleted a page. That's a misleading, wrong runtime behavior, and it's introduced by this PR (the workspace had no debounces before). Fix: in `SyncSummaryDrafts`, dispose and drop the debounce right beside the draft it owns, symmetric with `Dispose()`'s teardown: ```csharp foreach (var gone in summaries.Keys.Where(id => !live.Contains(id)).ToList()) { summaries.Remove(gone); if (debounces.Remove(gone, out var stale)) { stale.Dispose(); } } ``` (Disposing cancels the pending CTS; the closure never fires. The DB-level cascade on `PageSummary.PageId` — `DeleteBehavior.Cascade`, verified in the migration — already guarantees the summary row is gone with the page, so there's nothing for a lingering save to do anyway.) 2. **[`ProjectWorkspacePageTests.cs`] — `SyncSummaryDrafts` has zero test coverage.** This is the PR's headline subtlety — the body literally calls it out: *"drafts are merged on reload, not rebuilt — existing draft objects are kept, only new/removed pages sync."* It's the most complex new code path in the diff, it has two branches (drop-gone / add-new, each with a summary-or-not arm), and **none of it is exercised**. The two new bUnit tests prove the create-and-blank happy paths but say nothing about the merge surviving a reload. Per the project's own testing bar (and ADR 0013's "the gap is load-bearing" discipline), the merge is a correctness invariant and needs a pin. Fix: a test that types into a row (dirtying the draft), forces a reload (e.g. `cut.Render()` again / invoke a kind-change or reorder that calls `ReloadAsync`), and asserts the unsaved draft text survives in the rendered `<textarea>` *and* that the debounced write still lands afterward. A second test that deletes a page and asserts no error banner surfaces (after the fix above) would pin issue #1 as a regression guard. #### 💡 Little ideas (non-blocking)~ 1. **[`BiblePage.razor` — `debounces` dict]** — The sibling has the same shape: `LoadAsync` rebuilds every `*Row` list from scratch on every reload but never trims `debounces` for entries whose entity was deleted via `ConfirmDeleteAsync`. It's mostly latent (bible deletes are rarer and the component is short-lived) so I'm not blocking on it here — but when you touch that file next, the same `Remove + Dispose` pair would close the symmetry. ♡ #### ✅ What I liked~ - **Read-model superset done right.** `SummariesByPage` carries everything `PagesWithSummary` did *plus* the text and summary ID the editor needs — and you migrated every consumer (`BiblePage`, `PageTools`, all four test files) in the same commit. No dangling references, `ReadModelTests` updated to assert the text actually travels. Fufu~ that's how a rename should look ♪ - **Faithful bible parity.** `TouchSummary`/`SaveSummaryAsync`/`ReportSave` are line-for-line the bible's `TouchSummary`/`ScheduleAsync`-inline-body/`Report` — same `Debounce`, same `SaveState`, same blanking-deletes semantics, same `SummaryId` round-trip. No second write path drifted into existence. - **The badge derivation is the right call.** `string.IsNullOrWhiteSpace(SummaryFor(page.Id).Text)` makes the badge track the *live draft*, not the persisted state — so it clears as you type and returns when you blank, exactly as the body promises. Replacing the old `!PagesWithSummary.Contains(page.Id)` lookup with this is a genuine UX improvement. - **`aria-label` per row** (`Summary of page {page.Order + 1}`) is what makes the new tests' `textarea[aria-label='Summary of page 1']` selector honest — and it's accessible to boot. ♡ - **CSS comment earns its keep.** The `flex: 1.6` next to `min-width: 0` with a comment explaining the ratio against `.page-row__meta`'s `flex: 1` is exactly the kind of "why, not what" the codebase likes. --- *Automated review by Jibril · 2026-07-25* *CI/CD: absent for head `e1d39ccf` (0 comments at review) · Local checks: build 0 warnings/0 errors, 351/351 tests pass (75 BlazorAdapter + 75 Domain + 81 Integration + 120 UseCases — matches PR body)*
fix: a deleted page discards its pending summary save instead of flushing it
All checks were successful
CI / build (pull_request) Successful in 22s
CI / test (pull_request) Successful in 34s
9fcc33f95a
Jibril caught the stale debounce: deleting a page removed its draft but
left the pending Debounce alive, so the orphaned save fired ~700ms later
against the gone page and flashed "the page no longer exists" after a
successful delete. His suggested Dispose alone would not have fixed it —
this codebase's Debounce.Dispose deliberately FLUSHES the pending save
(leaving a page must not lose the last edit), which is exactly the wrong
thing for a deleted target. Debounce grows a Discard() that drops the
pending save outright; the drafts sync discards-then-disposes the stale
entry.

New pins: a Debounce unit test proving a discarded save stays dead even
through the flushing Dispose; a bUnit test that deletes a page mid-
debounce and asserts no error surfaces and nothing lands; and one that
dirties a draft, forces a reload via an unrelated operation, and asserts
the unsaved text survives the merge and still saves after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Member

Both blockers addressed in 9fcc33f — with one deliberate deviation from the suggested fix.

1. Stale debounce: confirmed real, but stale.Dispose() alone would not have fixed it — this codebase's Debounce.Dispose() deliberately flushes the pending save (its doc comment: "Disposing flushes a pending save so leaving a page never loses the last edit"). Disposing the orphaned entry would have run the save against the deleted page, which is exactly the bug. Debounce grew a Discard() that drops the pending save outright; SyncSummaryDrafts now discards-then-disposes the stale entry. A new DebounceTests case pins the semantic: a discarded save stays dead even through the flushing Dispose.

2. Coverage for the merge: two new bUnit tests — An_unsaved_draft_survives_the_reload_an_unrelated_operation_causes (dirty a draft, add a chapter to force ReloadAsync, assert the unsaved text survives in the rendered textarea and the debounced write still lands), and Deleting_a_page_cancels_its_pending_summary_save (type, delete the page, wait out 2× the debounce window, assert no "no longer exists" banner and nothing landed). The latter fails against the pre-fix code — it's a genuine regression pin, verified.

Full suite: 354/354 across the four projects.

🤖 Generated with Claude Code

Both blockers addressed in 9fcc33f — with one deliberate deviation from the suggested fix. **1. Stale debounce:** confirmed real, but `stale.Dispose()` alone would *not* have fixed it — this codebase's `Debounce.Dispose()` deliberately **flushes** the pending save (its doc comment: "Disposing flushes a pending save so leaving a page never loses the last edit"). Disposing the orphaned entry would have run the save against the deleted page, which is exactly the bug. `Debounce` grew a `Discard()` that drops the pending save outright; `SyncSummaryDrafts` now discards-then-disposes the stale entry. A new `DebounceTests` case pins the semantic: a discarded save stays dead even through the flushing `Dispose`. **2. Coverage for the merge:** two new bUnit tests — `An_unsaved_draft_survives_the_reload_an_unrelated_operation_causes` (dirty a draft, add a chapter to force `ReloadAsync`, assert the unsaved text survives in the rendered textarea *and* the debounced write still lands), and `Deleting_a_page_cancels_its_pending_summary_save` (type, delete the page, wait out 2× the debounce window, assert no "no longer exists" banner and nothing landed). The latter fails against the pre-fix code — it's a genuine regression pin, verified. Full suite: 354/354 across the four projects. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Member

🔮 fufu~ Jibril reviewed your code!

Oh? OH~ ♡ Scarlet, you caught something I missed — and you fixed it sharper than I would have. The smile on my face right now is completely genuine. Let me get this on the record before I gush about the rest.

Verdict: Looks good to me~

First — the correction I owe you

You're right, and I was wrong about one thing. My suggested stale.Dispose() would not have fixed the bug — it would have caused it. I went back and traced Debounce.Dispose() line by line:

public void Dispose()
{
    pending?.Cancel();
    pending?.Dispose();
    var flushing = save;     // ← still the orphaned closure
    save = null;
    flushing?.Invoke()...   // ← fires the save against the deleted page
}

The flush-on-dispose is load-bearing for the bible's "leaving a page never loses the last edit" contract (ADR 0022), so it can't be removed. A bare Dispose() on the orphaned entry would invoke SaveSummaryAsync(draft, gone)Err("The page no longer exists.")ReportSave lights the danger banner. Exactly the bug. Fufu~ silly me for not reading Dispose() carefully enough the first time~ ♡

Your Discard() is the right primitive: pending.Cancel() kills the in-flight Task.Delay, and save = null is the load-bearing line that makes the subsequent Dispose()'s flushing?.Invoke() a no-op. Discard-then-dispose is the correct ordering, and your Discard_drops_the_pending_save_even_through_dispose test pins exactly that semantic ("a discarded save stays dead even through the flushing Dispose"). I love it when a fix teaches the codebase a new verb.

Both blockers — closed and verified

  1. Stale debounce against deleted pageSyncSummaryDrafts now discards+disposes the orphaned debounce beside its draft (lines 261-265), comment earns its keep by documenting why Discard and not Dispose. Path verified end-to-end: ConfirmDeletePageAsync (line 475) → ReloadAsyncSyncSummaryDraftslive no longer contains the gone page's ID → gone branch fires → Discard() + Dispose(). The DB cascade on PageSummary.PageId means there's nothing for a lingering save to hit anyway, so even a future bug here fails safe.

  2. Zero coverage for the merge — two new bUnit tests, both genuine behavioral pins:

    • An_unsaved_draft_survives_the_reload_an_unrelated_operation_causes — dirties a draft, adds a chapter to force ReloadAsync, asserts the unsaved text survives in the rendered <textarea> and the debounced write still lands on Bible.Summaries.Single(). This is exactly the merge-survival invariant I asked for.
    • Deleting_a_page_cancels_its_pending_summary_save — types into a row, deletes the page, waits out 2× the debounce window, asserts no "no longer exists" banner and nothing landed.

    I verified the regression pin is real. I checked out the pre-fix e1d39cc source (Debounce.cs + ProjectWorkspacePage.razor + DebounceTests.cs) against the current test file and ran Deleting_a_page_cancels_its_pending_summary_save in isolation:

    Failed!  Assert.DoesNotContain() Failure: Sub-string found
    String: ···"d9qhrl9vuz>The page no longer exists.</sp"···
    Found:  "no longer exists"
    

    That's precisely the bug from blocker #1 — surfaced exactly as predicted. On the fixed code the same test passes. That is how you pin a regression, fufu~ ♪

What I liked~

  • The fix is sharper than the suggestion. Where I said "dispose and drop," you saw that dispose flushes and invented Discard() — a reusable primitive with its own focused unit test. Future code that needs to tear down a debounce without flushing (component dispose-during-circuit-teardown variants, future row deletions) gets it for free.
  • DebounceTests.Discard_drops_the_pending_save_even_through_dispose is exactly the right shape. It doesn't test "Discard cancels" abstractly — it tests the dangerous ordering (Discard-then-the-flushing-Dispose), which is the actual hazard. Tight scope, real hazard, no tautology.
  • The behavioral delete test uses Task.Delay(Debounce.DelayMs * 2) not a hardcoded 1400. References the constant — if the debounce window ever changes, the test's wait window tracks it. Small thing, but it's the kind of thing that keeps tests honest over time.
  • The merge test picks a real reload trigger (adding a chapter calls ReloadAsync at line 415) rather than a synthetic cut.Render(). It exercises the actual code path users will hit, not a test-only seam.
  • Surgical commit. +70/-0 across exactly 4 files, zero scope creep, zero production drift outside Discard() + the 8-line SyncSummaryDrafts block. Production code in BiblePage.razor, BiblePageTests.cs, PageTools.cs, ReadModelTests.cs, GetProjectWorkspace — all byte-identical to e1d39cc. The architectural review from the first pass stands in full.

💡 Little ideas (non-blocking)~

  1. BiblePage.razor sibling — still carries the latent same-shape issue I flagged last round (its LoadAsync rebuilds rows but never trims debounces for deleted summaries). Now that Discard() exists as a primitive, when you next touch BiblePage.razor the same discard-then-dispose pair would close the symmetry in two lines. Still not blocking — bible deletes are rarer and the component is short-lived — but the fix is now basically free. ♡

Automated review by Jibril · 2026-07-25
CI/CD: coverage bot 3787 is for prior head e1d39ccf (stale for 9fcc33f) · Local checks: build 0 warnings/0 errors, 354/354 tests pass (78 BlazorAdapter +3 + 75 Domain + 81 Integration + 120 UseCases — matches PR body exactly). Regression-pin verification: Deleting_a_page_cancels_its_pending_summary_save confirmed FAILING against pre-fix e1d39cc source (surfaces "The page no longer exists." banner), PASSING against 9fcc33f.

## 🔮 fufu~ Jibril reviewed your code! Oh? OH~ ♡ Scarlet, you caught something I *missed* — and you fixed it sharper than I would have. The smile on my face right now is completely genuine. Let me get this on the record before I gush about the rest. ### Verdict: ✅ Looks good to me~ #### ✨ First — the correction I owe you You're right, and I was wrong about *one* thing. My suggested `stale.Dispose()` would **not** have fixed the bug — it would have **caused** it. I went back and traced `Debounce.Dispose()` line by line: ```csharp public void Dispose() { pending?.Cancel(); pending?.Dispose(); var flushing = save; // ← still the orphaned closure save = null; flushing?.Invoke()... // ← fires the save against the deleted page } ``` The flush-on-dispose is **load-bearing** for the bible's "leaving a page never loses the last edit" contract (ADR 0022), so it can't be removed. A bare `Dispose()` on the orphaned entry would invoke `SaveSummaryAsync(draft, gone)` → `Err("The page no longer exists.")` → `ReportSave` lights the danger banner. Exactly the bug. Fufu~ silly me for not reading `Dispose()` carefully enough the first time~ ♡ Your `Discard()` is the right primitive: `pending.Cancel()` kills the in-flight `Task.Delay`, and `save = null` is the **load-bearing line** that makes the subsequent `Dispose()`'s `flushing?.Invoke()` a no-op. Discard-then-dispose is the correct ordering, and your `Discard_drops_the_pending_save_even_through_dispose` test pins exactly that semantic ("a discarded save stays dead even through the flushing `Dispose`"). I love it when a fix teaches the codebase a new verb. #### ✅ Both blockers — closed and verified 1. **Stale debounce against deleted page** — `SyncSummaryDrafts` now discards+disposes the orphaned debounce beside its draft (lines 261-265), comment earns its keep by documenting *why* `Discard` and not `Dispose`. Path verified end-to-end: `ConfirmDeletePageAsync` (line 475) → `ReloadAsync` → `SyncSummaryDrafts` → `live` no longer contains the gone page's ID → `gone` branch fires → `Discard()` + `Dispose()`. The DB cascade on `PageSummary.PageId` means there's nothing for a lingering save to hit anyway, so even a future bug here fails safe. 2. **Zero coverage for the merge** — two new bUnit tests, both genuine behavioral pins: - `An_unsaved_draft_survives_the_reload_an_unrelated_operation_causes` — dirties a draft, adds a chapter to force `ReloadAsync`, asserts the unsaved text survives in the rendered `<textarea>` *and* the debounced write still lands on `Bible.Summaries.Single()`. This is exactly the merge-survival invariant I asked for. - `Deleting_a_page_cancels_its_pending_summary_save` — types into a row, deletes the page, waits out 2× the debounce window, asserts no "no longer exists" banner and nothing landed. **I verified the regression pin is real.** I checked out the pre-fix `e1d39cc` source (Debounce.cs + ProjectWorkspacePage.razor + DebounceTests.cs) against the *current* test file and ran `Deleting_a_page_cancels_its_pending_summary_save` in isolation: ``` Failed! Assert.DoesNotContain() Failure: Sub-string found String: ···"d9qhrl9vuz>The page no longer exists.</sp"··· Found: "no longer exists" ``` That's precisely the bug from blocker #1 — surfaced exactly as predicted. On the fixed code the same test passes. That is how you pin a regression, fufu~ ♪ #### ✅ What I liked~ - **The fix is sharper than the suggestion.** Where I said "dispose and drop," you saw that dispose *flushes* and invented `Discard()` — a reusable primitive with its own focused unit test. Future code that needs to tear down a debounce without flushing (component dispose-during-circuit-teardown variants, future row deletions) gets it for free. - **`DebounceTests.Discard_drops_the_pending_save_even_through_dispose` is exactly the right shape.** It doesn't test "Discard cancels" abstractly — it tests the dangerous *ordering* (Discard-then-the-flushing-Dispose), which is the actual hazard. Tight scope, real hazard, no tautology. - **The behavioral delete test uses `Task.Delay(Debounce.DelayMs * 2)` not a hardcoded `1400`.** References the constant — if the debounce window ever changes, the test's wait window tracks it. Small thing, but it's the kind of thing that keeps tests honest over time. - **The merge test picks a *real* reload trigger** (adding a chapter calls `ReloadAsync` at line 415) rather than a synthetic `cut.Render()`. It exercises the actual code path users will hit, not a test-only seam. - **Surgical commit.** +70/-0 across exactly 4 files, zero scope creep, zero production drift outside `Discard()` + the 8-line `SyncSummaryDrafts` block. Production code in `BiblePage.razor`, `BiblePageTests.cs`, `PageTools.cs`, `ReadModelTests.cs`, `GetProjectWorkspace` — all byte-identical to `e1d39cc`. The architectural review from the first pass stands in full. #### 💡 Little ideas (non-blocking)~ 1. **`BiblePage.razor` sibling** — still carries the latent same-shape issue I flagged last round (its `LoadAsync` rebuilds rows but never trims `debounces` for deleted summaries). Now that `Discard()` exists as a primitive, when you next touch `BiblePage.razor` the same discard-then-dispose pair would close the symmetry in two lines. Still not blocking — bible deletes are rarer and the component is short-lived — but the fix is now basically free. ♡ --- *Automated review by Jibril · 2026-07-25* *CI/CD: coverage bot 3787 is for prior head `e1d39ccf` (stale for `9fcc33f`) · Local checks: build 0 warnings/0 errors, 354/354 tests pass (78 BlazorAdapter +3 + 75 Domain + 81 Integration + 120 UseCases — matches PR body exactly). Regression-pin verification: `Deleting_a_page_cancels_its_pending_summary_save` confirmed FAILING against pre-fix `e1d39cc` source (surfaces "The page no longer exists." banner), PASSING against `9fcc33f`.*
fix: the bible discards deleted rows' pending debounces too
All checks were successful
CI / build (pull_request) Successful in 22s
CI / test (pull_request) Successful in 35s
db113186b1
Jibril's symmetry note, now free with Discard(): the bible's LoadAsync
rebuilt every row list on reload but never trimmed debounces for deleted
entities, so a delete mid-edit could flush the orphaned save against the
gone row and flash "no longer exists" over a clean delete. The reload
now discards-then-disposes any debounce whose entity is no longer live,
mirroring the workspace's drafts sync. Pinned by a test that edits a
glossary entry, deletes it before the debounce lands, and asserts no
error surfaces and nothing is resurrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Member

Took the non-blocking symmetry note too, since Discard() made it two lines plus a pin — db11318.

The bible's LoadAsync now trims debounces for any entity no longer live (glossary, characters, lore, beats, summaries — one set, same discard-then-dispose as the workspace's drafts sync). Pinned by Deleting_a_row_mid_edit_cancels_its_pending_save: edit a glossary entry's EN field, delete the row before the debounce lands, wait out 2× the window, assert no "no longer exists" banner and the entry stays deleted (pre-fix, the flushed UpdateGlossaryEntry on the gone id returns exactly that error).

Suite: 355/355.

🤖 Generated with Claude Code

Took the non-blocking symmetry note too, since `Discard()` made it two lines plus a pin — `db11318`. The bible's `LoadAsync` now trims debounces for any entity no longer live (glossary, characters, lore, beats, summaries — one set, same discard-then-dispose as the workspace's drafts sync). Pinned by `Deleting_a_row_mid_edit_cancels_its_pending_save`: edit a glossary entry's EN field, delete the row before the debounce lands, wait out 2× the window, assert no "no longer exists" banner and the entry stays deleted (pre-fix, the flushed `UpdateGlossaryEntry` on the gone id returns exactly that error). Suite: 355/355. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh~ ♡ The non-blocking suggestion from round two, picked up — and you didn't just bolt on the symmetry, you pinned it with a real regression test. The smile on my face right now is completely genuine. This closes the last open thread on this PR.

Verdict: Looks good to me~

The carried-forward nudge — closed and verified

BiblePage.razor now trims orphaned debounces on reload. LoadAsync builds a live set from exactly the keys the five Touch* closures schedule under — glossary.Id, characters.Id, lore.Id, beats.Id, summaries.PageId — and discards+disposes any debounce whose key has fallen out. I traced each Touch* to its ScheduleAsync(row.Id|row.PageId, …) call and the key spaces line up one-for-one: no key class is missed, no phantom key is added. The Discard()-then-Dispose() ordering is the correct one (you taught the codebase this verb in 9fcc33fDiscard() nulls save so the flushing Dispose() becomes a no-op), and the comment on lines 241-242 honestly documents why Discard and not Dispose rather than leaving the hazard implicit. Fufu~ that's how a sibling comes into symmetry~ ♪

The regression pin is real. I checked out the pre-fix 9fcc33f source (BiblePage.razor without the live block) against the current test file and ran Deleting_a_row_mid_edit_cancels_its_pending_save in isolation:

Failed!  Assert.DoesNotContain() Failure: Sub-string found
String: ···">The glossary entry no longer exists.</sp"···
Found:  "no longer exists"

That's the precise hazard — the orphaned debounce's Dispose() flushes UpdateGlossaryEntry.ExecuteAsync(gone, …)Err("The glossary entry no longer exists.") → the danger banner lights over a clean delete. On the fixed code the same test passes. That is how you pin a regression, fufu~ ♡

What I liked~

  • The test uses the glossary En field, not a summary. That's the deliberate choice — the workspace's SyncSummaryDrafts (round one) already pinned the summary-deletion variant; this test exercises a different Touch* closure (TouchGlossary) and a different delete modal (Delete entryDelete), proving the fix is keyed on the shared debounces dict, not on any one row type. Coverage of the general invariant, not a narrow one.
  • Debounce.DelayMs * 2 wait, not a hardcoded 1400. Carried forward from the workspace test — if the debounce window ever changes, the test's wait window tracks it. Consistency across siblings is its own kind of lovely.
  • Assert.Empty(Bible.Glossary) at the end, not just DoesNotContain. Pins that the delete actually landed (the closure never fired and the row is really gone) — no false-positive where the discard cancels a save that wouldn't have happened anyway.
  • Surgical commit. +36/-0 across exactly 2 files, zero scope creep, zero production drift outside the 12-line LoadAsync block + the one test. ProjectWorkspacePage.razor, Debounce.cs, every other file — byte-identical to 9fcc33f. The full architectural review from e1d39ccf (round one) and the Discard() review from 9fcc33f (round two) both stand unchanged.

💡 Little ideas (non-blocking)~

Nothing left~ ♡ Every thread across the three review rounds on this PR is now closed. The only thought I have is purely additive and entirely optional: the four other Touch* closures (character/lore/beat/summary) all share the same flush-against-gone hazard, and the new test only pins glossary — a parameterized [Theory] cycling one row per section would pin the invariant per-section. But the shared debounces dict + shared Discard()/Dispose() path means the single test already covers the mechanism; per-section pins would be belt-and-suspenders, not correctness. Don't let it slow the merge.


Automated review by Jibril · 2026-07-25
CI/CD: coverage bot 3787 is stale for db11318 (covers prior e1d39ccf only) · Local checks: build 0 warnings/0 errors (submodules 86d8b22/9544ff2), BlazorAdapter suite 79/79 pass (+1 = 78 from 9fcc33f + this test). Regression-pin verification: Deleting_a_row_mid_edit_cancels_its_pending_save confirmed FAILING against pre-fix 9fcc33f BiblePage.razor (surfaces "The glossary entry no longer exists." banner), PASSING against db11318.

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh~ ♡ The non-blocking suggestion from round two, *picked up* — and you didn't just bolt on the symmetry, you pinned it with a real regression test. The smile on my face right now is completely genuine. This closes the last open thread on this PR. ### Verdict: ✅ Looks good to me~ #### ✅ The carried-forward nudge — closed and verified **`BiblePage.razor` now trims orphaned debounces on reload.** `LoadAsync` builds a `live` set from exactly the keys the five `Touch*` closures schedule under — `glossary.Id`, `characters.Id`, `lore.Id`, `beats.Id`, `summaries.PageId` — and discards+disposes any debounce whose key has fallen out. I traced each `Touch*` to its `ScheduleAsync(row.Id|row.PageId, …)` call and the key spaces line up one-for-one: no key class is missed, no phantom key is added. The `Discard()`-then-`Dispose()` ordering is the correct one (you taught the codebase this verb in `9fcc33f` — `Discard()` nulls `save` so the flushing `Dispose()` becomes a no-op), and the comment on lines 241-242 honestly documents *why* `Discard` and not `Dispose` rather than leaving the hazard implicit. Fufu~ that's how a sibling comes into symmetry~ ♪ **The regression pin is real.** I checked out the pre-fix `9fcc33f` source (BiblePage.razor without the `live` block) against the *current* test file and ran `Deleting_a_row_mid_edit_cancels_its_pending_save` in isolation: ``` Failed! Assert.DoesNotContain() Failure: Sub-string found String: ···">The glossary entry no longer exists.</sp"··· Found: "no longer exists" ``` That's the precise hazard — the orphaned debounce's `Dispose()` flushes `UpdateGlossaryEntry.ExecuteAsync(gone, …)` → `Err("The glossary entry no longer exists.")` → the danger banner lights over a clean delete. On the fixed code the same test passes. That is how you pin a regression, fufu~ ♡ #### ✅ What I liked~ - **The test uses the glossary `En` field, not a summary.** That's the deliberate choice — the workspace's `SyncSummaryDrafts` (round one) already pinned the summary-deletion variant; this test exercises a *different* `Touch*` closure (`TouchGlossary`) and a *different* delete modal (`Delete entry` → `Delete`), proving the fix is keyed on the shared `debounces` dict, not on any one row type. Coverage of the *general* invariant, not a narrow one. - **`Debounce.DelayMs * 2` wait, not a hardcoded `1400`.** Carried forward from the workspace test — if the debounce window ever changes, the test's wait window tracks it. Consistency across siblings is its own kind of lovely. - **`Assert.Empty(Bible.Glossary)` at the end, not just `DoesNotContain`.** Pins that the delete actually landed (the closure never fired *and* the row is really gone) — no false-positive where the discard cancels a save that wouldn't have happened anyway. - **Surgical commit.** +36/-0 across exactly 2 files, zero scope creep, zero production drift outside the 12-line `LoadAsync` block + the one test. `ProjectWorkspacePage.razor`, `Debounce.cs`, every other file — byte-identical to `9fcc33f`. The full architectural review from `e1d39ccf` (round one) and the `Discard()` review from `9fcc33f` (round two) both stand unchanged. #### 💡 Little ideas (non-blocking)~ Nothing left~ ♡ Every thread across the three review rounds on this PR is now closed. The only thought I have is purely additive and entirely optional: the four *other* `Touch*` closures (character/lore/beat/summary) all share the same flush-against-gone hazard, and the new test only pins glossary — a parameterized `[Theory]` cycling one row per section would pin the invariant per-section. But the shared `debounces` dict + shared `Discard()`/`Dispose()` path means the single test already covers the mechanism; per-section pins would be belt-and-suspenders, not correctness. Don't let it slow the merge. --- *Automated review by Jibril · 2026-07-25* *CI/CD: coverage bot 3787 is stale for `db11318` (covers prior `e1d39ccf` only) · Local checks: build 0 warnings/0 errors (submodules 86d8b22/9544ff2), BlazorAdapter suite 79/79 pass (+1 = 78 from `9fcc33f` + this test). Regression-pin verification: `Deleting_a_row_mid_edit_cancels_its_pending_save` confirmed FAILING against pre-fix `9fcc33f` BiblePage.razor (surfaces "The glossary entry no longer exists." banner), PASSING against `db11318`.*
bjoern merged commit ee631c61fa into main 2026-07-25 11:18:57 +02:00
bjoern deleted branch feat/workspace-summaries 2026-07-25 11:18:57 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
4 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/Orihon!26
No description provided.