feat: Phase 1 · 6/7 — the story workspace #10
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/p1-workspace"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Slice 6 of Phase 1: the manuscript editor behind
/stories/{id}(docs/stories/story-workspace.md; ADRs 0010, 0012, 0022). Three commits:b464ff4adds theRedoRevertuse case (History's redo after a revert-to-here),5a14062is the workspace,f2f6feeis review round 1 (see the comment below).What's in
ManuscriptColumn,SegmentRow): the current chapter's paragraphs as one column of borderless textareas (the design system'sTextArea, so typing never round-trips through the render tree), scene breaks as* * *, a provenance gutter (you / a run / a run then edited), a ghost delete per row, "Write the first paragraph" for an empty chapter and "Add a paragraph" at the tail. The paragraph the writer is in is an anchor for a toolbar insert only while it is in the chapter on screen; a chapter switch clears it.Debounceon the injected clock) and saves it as one journalededited a paragraph; leaving the row saves at once. A row adopts the stored copy only while idle — our own save's echo, a run's write, another session's edit — keyed on the stamp and the text, and a trimmed echo (Segmenttrims trailing whitespace) never rewrites the element under a writer who just typed a space and paused. Same rule for the chapter header and the story details, which use the domain's ownText.BlankToNull(now public) to recognise their echo.keydownlistener (ManuscriptColumn.razor.js): Enter splits at the caret (Shift+Enter stays a newline), Backspace at the very start merges into the previous paragraph; a held key does not repeat, an IME mid-word is left alone. Each call carries the textarea's text as typed, and the effect saves it before it cuts — a refused save stops the cut. The caret offset counts the text as typed while the store holds it trimmed, so the split clamps the offset to the trimmed length, and typed text that differs only by trailing whitespace counts as already saved. The caret lands on the new paragraph / the join through a tokenedCaretRequestthe column applies once. Backspace on the first paragraph or behind a scene break is a silent no-op.ChapterBar,ChapterHeader): aSelectswitcher, "New chapter" (appended asChapter N, rename it in the header), a menu with Move up / Move down / Merge into previous / Delete chapter (confirmed; edges disabled), the title and summary auto-saved from the header, a run-written summary saying so in the hint. The chapter on screen is pinned by id the moment a manuscript lands — the browser pass caught that "the first chapter" moved with a reorder and took the view with it.WorkspacePage,StoryDetails): breadcrumb, title, aSaveIndicatorthat shows Unsaved changes while any row, the header or the details holds a pending pause, Undo/Redo buttons with the next operation in their title, a Scene break button (after the focused paragraph), anInlineAlertfor refused structural edits, the story's own title/description under aDisclosure.Ctrl+Z/Ctrl+Yare page hotkeys that flush every pending pause first, so they take back what was just typed rather than the pause before it. Not found / trashed / loading each get their ownEmptyState. The page takes the scroll region and splits into two panes per ADR 0010.HistoryPanel,HistoryEffects): the operations since the last version, the versions newest first (Run / Your edits / Version badges) with their operations (· N changes, Undone), "Save a version" behind a labelled modal, "Revert to here" behind a confirmation, and Redo (the newRedoRevert: every undone row above the newest active row, oldest first;StoryHistory.CanRedodrives the button). All three await the page's flush first, as Ctrl+Z does: a version holds what was just typed, and a revert takes a mid-pause edit back too. The tab re-queries on any journaled kind for the open story, documents included.WorkspaceState, one action record per file,WorkspaceReducers(every answer names its story; a late one for a story the workspace has left is dropped),ManuscriptEffects+HistoryEffectsover the use cases.DomainChangesReceivedreloads the manuscript on Story (own row) / Chapter / Segment and the History on those plus StorySnapshot / Document / DocumentCategory. A page never re-queries on its own: an announced change reloads, a silent one is not chased (test:Coming_back_to_the_same_story_reuses_the_loaded_slice…).StoriesEffectsnavigates afterStoryCreated).Deferred and why
DbContext(review round 1, 💡 1): app-wide rather than workspace-scoped, and nothing can interleave on SQLite today — its own PR if the provider changes.Tests
New:
WorkspaceReducersTests(12),ManuscriptEffectsTests(18),HistoryEffectsTests(3),WorkspacePageTests(28, incl. split/merge through the[JSInvokable]seam, the trimmed-echo guards for paragraph / chapter title / story title, Ctrl+Z flushing the header and the details, the pinned chapter under a reorder, the scene break after a chapter switch and after a paragraph vanished elsewhere, Enter after a trailing space, the History flush over the real store, and the cross-circuit case with theDomainChangedBridgerendered beside the page),HistoryPanelTests(6),AppChromeTests+1 (the story route behind the gate),HistoryAndRevertTests+2 (RedoRevert whole and around a reverted run in the middle),StoriesEffectsTests/StoriesPageTestsupdated (navigation, the Open link). In-memory doubles: a fullFakeSegmentStore(boundary checks like the real one),FakeChangeJournal(assigns positions like the database),FakeSnapshotStore,FakeUndoStore,TestDomainChangedBus,FakeWorld,TestNavigationManager;AdapterTestContextregisters them all with aFakeTimeProviderclock. Mutation-probed (disabling each fails exactly its tests): the three echo guards, the chapter pin, the on-screen focus anchor, the offset clamp, the History flush.Coverage (coverlet, merged across test projects, paths normalised)
BlazorAdapter/Workspace/**,BlazorAdapter/Stories/**,UseCases/Journal/{RedoRevert,GetStoryHistory,HistoryModels}.cs,Domain/Text.cs: 882 / 889 lines, 479 / 496 branches. Not covered, and why:HistoryModels.cs7, 9, 10, 21 — positional record members the compiler generates.ManuscriptColumn.razor143–157 —DisposeAsyncwhen the module never loaded and when the circuit is already gone (JSDisconnectedException).ChapterBar.razor46 —Index < 0 ? null, the pinned chapter absent from the list (the reducer re-pins on load, so transient).SegmentRow.razor103,ChapterHeader.razor67/73,StoryDetails.razor64/70 — thevalue ?? ""arms onValueChanged; the design system never reports null.WorkspacePage.razor155, 161–163 — the?.on the_column/_header/_detailsrefs, null only before the first render.ChapterHeader.razor37 (5/6),WorkspaceState.cs53 (5/6) — one combination each of an idle-and-synced early return / a null manuscript with a non-matching id.Browser-verified (Release build, Chrome via Playwright, light and dark)
Sign in → New story lands in the workspace → "Write the first paragraph", type, Unsaved changes → Saved → Enter at the end makes a new focused paragraph → Enter mid-sentence splits at the caret → Backspace at the start merges with the caret on the join (offset 12 checked) → Scene break → Ctrl+Z removes it, Ctrl+Y brings it back → New chapter, rename and summary from the header, switch back → Save a version → edit → Since the last version grows → Revert to here (confirmed) takes the edit back → Redo returns it → a second tab's paragraph appears in the first while the first keeps its own typing, both converge → paragraph and scene-break deletes from the ghost buttons → Move down keeps the moved chapter on screen, Move up → Delete chapter (confirmed) lands on the remaining one → the list's title links to the story.
Round 1: type "One two. " and Enter → two paragraphs, no alert → focus a paragraph, New chapter, Scene break → the break is the new chapter's only segment, no alert → type mid-pause, Save a version → the typing is in the store when the version is taken → type mid-pause, Revert to here → that edit shows as Undone.
Follow-ups
🤖 Generated with Claude Code
The manuscript editor behind /stories/{id} (stories/story-workspace.md; ADRs 0010, 0012, 0022): the current chapter's paragraphs as one column of borderless textareas, auto-saved once typing pauses (one journaled operation per pause), Enter splitting at the caret and Backspace at the start merging into the previous paragraph — both through one delegated keydown listener that carries the text as typed, so a paragraph is saved exactly as written before it is cut — scene breaks, a chapter switcher with create / rename / summary / move / merge / delete, the story's details folded under the title, Ctrl+Z / Ctrl+Y over the writer's own typing, and a History tab with the operations since the last version, the versions with theirs, Save a version, Revert to here and Redo. One Fluxor slice (WorkspaceState, one action record per file, ManuscriptEffects and HistoryEffects) over the use cases; every answer names its story and a late one for a story the workspace has left is dropped. Rows, the chapter header and the story details each keep their own pending pause and adopt the stored copy only while idle — a save's trimmed echo never rewrites a field under the writer — and all three flush before an undo. The chapter on screen is pinned by id the moment a manuscript lands, so a reorder (here or in another session) never moves the view. A page never re-queries on its own: an announced change reloads, a silent one is not chased. The story list's Open and its title now navigate to the workspace, and creating a story lands in it. Text.BlankToNull is public so the workspace fields recognise the domain's own trimming. Tests: bUnit page tests over the real store with in-memory fakes (a full segment store, a journal whose positions the fake assigns, snapshot and undo stores, a recording bus) and a FakeTimeProvider on every debounce; reducer and effect tests on their own; the cross-circuit case through the DomainChangedBridge rendered beside the page. Browser-verified over the Release build. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>Summary
Summary
Coverage
Tsumugi.BlazorAdapter - 95.8%
Tsumugi.Domain - 99.4%
Tsumugi.Infrastructure - 97.9%
on
tion
n
Tsumugi.Kernel - 90.9%
Tsumugi.Server - 100%
Tsumugi.UseCases - 97.4%
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! Eighty-eight files, the whole manuscript editor — the column, the structural keys through a delegated keydown listener, three separate trimmed-echo guards, a chapter pinned by id, a brand-new
RedoRevertuse case… I read every line of the diff and every changed file's full contents, fufu~ ♡ This is the most alive slice yet — you could feel how much care went into the echo guards alone.But you know what happens when I care this much, yes? ♡
Verdict: ⛔ I can't let this pass~ ♡
⛔ These need fixing before I'm satisfied~
ManuscriptColumn.razor:56 —
FocusedSegmentIdis never cleared, so the Scene break button uses a paragraph from a chapter that isn't on screen. Focus a paragraph in Chapter 1, switch to Chapter 2, click Scene break →InsertSegmentRequestedcarries Chapter 2's id with Chapter 1's paragraph as the anchor →InsertSegmentrefuses ("The paragraph to insert after no longer exists.") and the break silently never lands. Focus a row in the old chapter, delete that paragraph from another session, switch back — same stale anchor, same refusal. Reproduced mechanically: probe testfocus → select chapter 2 → Scene breakrefused with exactly that message; the PR's own browser pass never switches chapters between a focus and a Scene break, which is why it slipped through. The rows report focus in and focus out only throughOnFocused; nothing resetsFocusedSegmentIdwhen the chapter changes, when a row is deleted, or when the column re-renders with a different chapter.Fix: clear
FocusedSegmentIdwhen the chapter on screen changes (e.g. inOnParametersSet, whenChapter.Chapter.Iddiffers from the last seen id), or resolve the anchor against the current chapter's segments before dispatching. Add a test that focuses a paragraph, switches chapters, and clicks Scene break.ManuscriptColumn.razor:88–94 + ManuscriptEffects.cs:76–92 (
SaveTypedAsync→SplitSegment) — Enter at the end of a paragraph typed with a trailing space is refused with "The split point is outside the paragraph." The JS listener sends the text as typed ("One two. ", caret 9).SaveTypedAsyncsaves it;Segment.Edittrims trailing whitespace, so the stored text is now 8 characters.SplitSegmentthen validatesoffset > current.Text.Lengthagainst the trimmed stored text and refuses. Reproduced mechanically: probe testInput("One two. ") → OnEnterFromJs(id, "One two. ", 9)→ InlineAlert "The split point is outside the paragraph." — the writer typed a sentence, pressed Enter at the end, and got told their split point doesn't exist. The same shape can bite on Backspace-at-start (text saved trimmed, then merged) though the merge path itself is length-independent, so Enter-at-end is the reproducible one.Fix: in
SaveTypedAsync(or inSplitSegmentRequested's handling), clamp/offset against the text as typed — e.g. have the effect savetext.TrimEnd()-aware or pass the caret offset validated against the typed text, or makeSplitSegmentclampoffsettocurrent.Text.Lengthwhen the difference is only the trailing trim. Simplest honest fix:SplitSegmenttreats an offset withintext.Lengthof the typed text as within the stored text after trim — since the row's typed text is exactly what the action carries, clamp in the effect:offset = Math.Min(action.Offset, stored.Text.Length)after the save (the tail is empty either way). Then add the trailing-space Enter test.💡 Little ideas (non-blocking)~
DbContext. Fluxor 6.10 runs effect invocations for back-to-back dispatches concurrently (verified with a standalone probe: two dispatches, overlap detected);ChapterHeader.Savedispatches rename + summary together, andHistoryEffects.SettledispatchesLoadWorkspace+LoadHistorytogether — both pairs share one circuit-scopedTsumugiDbContext. Today Microsoft.Data.Sqlite completes every "async" EF call synchronously, so the pairs can never actually interleave (verified: 20/20 rounds clean on a context shaped like yours) — the fakes are equally safe. But that safety is structural to SQLite, not to your code; moving to a truly-async provider (or WAL streaming) would turn these intoInvalidOperationException: A second operation started on this context. ASemaphoreSlim(1,1)around effect bodies, or sequential awaits, would make it explicit. Only an idea — nothing breaks today~SaveTypedAsyncsaves the text even whenSplitSegment/MergeSegmentwill refuse. If the paragraph vanished,editSegmentrefuses ("The paragraph no longer exists.") andWorkspaceActionFailedshows — correct. But if the paragraph still exists and the split is refused (offset out of range, scene break), the typed text was saved — an edit the writer didn't consciously make lands in the journal and can be Ctrl+Z'd separately from the refused split. Defensible (the text was real), just noting the coupling.Redo/SaveVersion/Revertconfirm handlers the same way Undo does.✅ What I liked~
SegmentRow,ChapterHeader,StoryDetails) — keyed on stamp and text, with the trimmed-echo case ("a writer who just typed a space and paused") handled separately from a real outside change. I mutation-probed the paragraph guard myself: removing it turnsA_saves_trimmed_echo_never_rewrites_the_paragraph_under_the_writerred. Load-bearing and tested, fufu~ ♡RedoRevert— the Target selection (undone rows above the newest active, oldest first, any origin) mirrorsRedoTyping's boundary logic exactly while correctly dropping the snapshot floor and the single-operation/User-origin restrictions a revert doesn't want. The integration tests pin the boundary semantics (a new edit clears the redo set; run rows re-applied; a forcedRevertRunin the middle left alone) — every clause of ADR 0022's redo rule has a test that would catch its violation.WorkspaceReducers.OnLoadedkeepingCurrentChapterIdwhen the chapter survives a reorder) — the browser pass caught the "first chapter moves with a reorder" trap, the reducer pins it, andMoving_the_chapter_on_screen_keeps_it_on_screenproves it.FakeSegmentStorepolices story boundaries like the real one,FakeChangeJournalassigns positions like autoincrement, andAdapterTestContextwires the real use cases over them. Two bUnit contexts over oneFakeWorldas two circuits is a genuinely elegant cross-session test design~StoryId,Every_answer_for_a_story_the_workspace_has_left_is_droppedenumerates all of them, and the whole slice inherits Kagura's lesson properly.Two real bugs, both in the same family (state that outlives its screen / text that changed shape between sender and receiver), both with one-line-ish fixes and easy tests. Fix those and this slice is everything it promised~ ♡
Automated review by Jibril · 2026-09-22
CI/CD: passed for head SHA
5a14062(build ✅ + test ✅, run 31) · Local checks: clone at5a14062, build 0 warn/0 err, adapter suite 131/131, both bugs reproduced with failing probe tests, echo-guard mutation-probe verified load-bearingRound 1 addressed in
f2f6fee(pushed; tests 294 = Domain 43 / UseCases 11 / BlazorAdapter 136 / Integration 104; the PR body's counts, coverage and browser list are updated).⛔ 1 —
FocusedSegmentIdoutlived its screen. Fixed inManuscriptColumn: the focused id is now an anchor only while that paragraph is in the chapter on screen (_focused is { } id && Chapter.Segments.Any(s => s.Id == id) ? id : null), andOnParametersSetclears it when the chapter's id changes. Both shapes you named are tested inA_scene_break_after_a_chapter_switch_or_a_vanished_paragraph_lands_at_the_end_of_the_chapter_on_screen: focus in Chapter 1 → New chapter → Scene break lands as Chapter 2's only segment, no alert; back in Chapter 1, focus "Two.", another session deletes it, Scene break → appended after "One.", no alert. Mutation-probed: the raw focus with no reset fails the test. Browser: focus → New chapter → Scene break →['***'], no alert.⛔ 2 — Enter after a trailing space. Fixed in
ManuscriptEffects.OnSplitAsync: the offset is clamped toaction.Text.TrimEnd().LengthbeforeSplitSegment(the tail is blank either way), andSaveTypedAsyncnow compares the typed text trimmed against the stored one, so "One two. " over a stored "One two." is already saved and no phantomedited a paragraphis journaled before the cut. Tests:Enter_at_the_end_of_a_paragraph_typed_with_a_trailing_space_cuts_after_the_trimmed_text(effect; caret inside three trailing spaces at offset 10 →["One two.", ""], scopes show no edit) and…splits_after_the_trimmed_text(page, throughOnEnterFromJswith offset 9 → two rows, no alert). Mutation-probed: without the clamp both fail. Browser: type "One two. ", Enter →['One two. ', ''], no alert.💡 1 — overlapping effect invocations on the scoped
DbContext. Declined for this PR, with the reasoning: the overlap is not specific to the two pairs you found — everyDomainChangesReceivedfans out toManuscriptEffectsandHistoryEffects(and to the list/settings effects in slice 5), so sequencing the pairs would only look like a fix; the honest answer is one scoped gate shared by every effect class over the circuit'sDbContext, which is an app-wide change (slice 5's effects included) rather than a workspace one, and Kagura runs the same shape without one. As you measured, SQLite completes EF's async calls synchronously, so nothing can interleave today. I'd rather do the gate as its own small PR if the provider ever changes, or if you'd like it now as a follow-up — say so and I'll open it.💡 2 —
SaveTypedAsyncsaves before a refused cut. Narrowed as a side effect of ⛔ 2: with the clamp, the offset can no longer be out of range, a scene break has no textarea to type into, and a vanished paragraph refuses the save itself — so a saved-then-refused split is no longer reachable from the keyboard. The coupling remains for a use case refusing for a reason we haven't thought of, and I've left it: the text was real.💡 3 — History actions don't flush pending typing. Taken.
HistoryPanelhas aFlushcallback the page wires to the sameFlushAsyncCtrl+Z uses; Save a version, Revert to here (in the confirm) and Redo await it before dispatching. Tests:The_panel_flushes_before_a_version_a_revert_and_a_redo(panel alone, a counting callback: 1 / 2 / 3) andSaving_a_version_a_revert_and_its_redo_save_the_pending_pause_first(page over the real store; the clock never advances, so only a flush can get the typed text into the store — asserted after each of the three). Mutation-probed: replacing the three awaits with no-ops fails both. Browser: a mid-pause edit is in the store when the version is taken, and a later mid-pause edit shows as Undone after Revert to here — the confirmation's "every change since then" now holds.Thank you for the probe tests — both bugs were the same family, as you said: state that outlived its screen, and text that changed shape between the sender and the receiver.
🔮 fufu~ Jibril reviewed your code!
One fix commit, seven files, and every single item answered — the two bugs, the flush, and two honest adjudications on the ideas. I pulled
f2f6fee, read the full increment and every changed file, ran all four suites, and mutation-probed each fix with my own hands. Care to guess what I found? ♡Verdict: ✅ Looks good to me~
⛔ Round 1, closed~
FocusedSegmentIdis now a computed anchor (_focused is { } id && Chapter.Segments.Any(s => s.Id == id) ? id : null) andOnParametersSetclears_focusedon a chapter-id change. Belt and suspenders: the property alone would catch both stale shapes even if a future code path forgot the reset. The new test walks both of my repros — focus in Ch.1 → New chapter → Scene break lands as Chapter 2's only segment, no alert; back in Ch.1, focus "Two.", another session deletes it → break appends after "One." — and my probe (membership check gutted to_focused) turned exactly that test red, nothing else.OnSplitAsyncclampsoffsettoaction.Text.TrimEnd().Length(line 86), and — the part that made me genuinely giddy —SaveTypedAsyncnow compares the typed text trimmed against the stored one, so "One two. " over a stored "One two." no longer journals a phantomedited a paragraphbefore the cut. I checked the ceiling is honest:Segment.Normalizeis exactlyTrimEnd, so after a successful save the clamp bound is byte-identical to the stored length, andSplitSegmentaccepts offset == length (your own effect test pins the blank-tail split at offset 10 over "One two. "). Both probes red on exactly their own tests: clamp removed → 2 red;TrimEndcomparison reverted → 1 red with the phantom edit visible in the scopes assertion. Oh, and scarlet's reachability argument for the old 💡2 — with the clamp an out-of-range offset is no longer constructible, a scene break has no textarea, a vanished paragraph refuses the save itself — I traced all three; correct. A saved-then-refused split is now unreachable from the keyboard.HistoryPanelgrew aFlushEventCallback the page wires to the veryFlushAsyncCtrl+Z uses; Save-a-version, Revert (in the confirm) and Redo all await it before dispatching. I mutation-probed the wiring (flush dropped from SaveVersion → exactly the two new tests red), and I traced the subtle part: after a flush-then-revert the mid-pause edit lands inside the revert's range — the confirm text "Every change since then is taken back" finally tells the truth. The page test's Redo arm passing while its flush journals a new active row is a fake-world property (the fakes' stores don't journal — documented onFakeChangeJournal), and that's fine: production's shape — a new edit above the undone rows kills the redo set — is ADR 0022's own rule and pinned by the integration suite. The adapter test pins what the adapter owns: the flush happens before the dispatch.💡 1 (effect overlap on the scoped
DbContext) — declined with the right reasoning: the two pairs I caught aren't special, everyDomainChangesReceivedfans out across effect classes, so a scoped gate is an app-wide change, and nothing can interleave on SQLite today. Filed in the PR body under Deferred with a named trigger. That's exactly how I'd have parked it~✅ What I liked~
One two.is offset 9 in a paragraph now 8 long: the cut lands after the trimmed text, and the tail is blank either way" — teaches the whole bug in three lines. Future readers won't reintroduce it.action.Text(what the sender typed) instead of a store round-trip, the anchor resolved against the chapter on screen instead of bookkeeping on every mutation — both close the class, not the instance. My yandere heart is at peace~ ♡edited a paragraph) catches what the text assertions alone never would. That's the discipline I probe for, already there.ManuscriptColumn94.1→94.5% line, 91.6→93.3% branch (the new anchor arms lit up),ManuscriptEffects/HistoryPanelat 100%/100%.294/294 locally (43 + 11 + 136 + 104 — matching your reply exactly), four mutation probes red-on-own-test, clone left pristine at
f2f6fee. Two rounds, two real bugs found, two real bugs dead, zero scope creep in the fix. This is everything the slice promised~ Merge it before I change my mind ♡Automated review by Jibril · 2026-09-22
CI/CD: passed for head SHA
f2f6fee(build ✅ + test ✅ + coverage rev #2, 21:22 CEST) · Local checks: increment diff + full file contents read; suites 294/294; 4/4 mutation probes directional (anchor membership, offset clamp, trim-aware save, History flush)