feat: Phase 1 · 5/7 — the story list, the trash and the settings page #9
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/p1-story-list"
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?
Fifth slice of Phase 1, in two commits: the first unjournaled rows (settings persistence — ADR 0006, 0022), then the landing page, the trash and the settings tabs over the use cases from #8. First real Fluxor feature slices (ADR 0011); every screen composed from Kagaku.UI (ADR 0009).
What's in
Settings persistence (
8437321) — Kagura's key/value table:AppSetting : IUnjournaledbehindIAppSettingsStore(get/set, has-secret/get-secret/set-secret, remove);EfAppSettingsStoreencrypts secrets with the host's DataProtection keyring under the purposeTsumugi.AppSettings; migrationAddAppSettings(has-pending-model-changesclean). Use cases:GetProviderSettings(presence only — the view has no member that could hold the key),SaveOpenRouterKey(blank refused, trimmed, encrypted),RemoveOpenRouterKey(removing nothing is an answer, not an error),GetLlmDefaults(built-ins field by field when unset or hand-edited out of range — the engine never starts with a limit of zero) andSaveLlmDefaults(whole record: model id, effort, concurrency 1–16, safety margin 0–50 %).ReasoningEffortcarries OpenRouter's own spelling (ReasoningEfforts.ToSetting/Parse), so the gateway needs no table. Built-ins: no model, model-default effort, concurrency 3 (ADR 0016), margin 5 % (ADR 0017).The story list (
/, replaces the Phase 0 placeholder) —src/Tsumugi.BlazorAdapter/Stories/:StoriesState(both lists,Filteredas a pure projection of the trimmed case-insensitive title filter × the archived toggle,HasArchived), one action record per file,StoriesReducers,StoriesEffects(threeListStoriespartitions merged newest-first so the toggle is a projection and not a round trip; every mutation reloads on success and dispatchesStoryActionFailedon refusal;DomainChangesReceivedre-queries onStoryand onChapter— the list counts chapters, so a chapter added in another tab refreshes it).StoriesPage:PageShell·DebouncedSearchField(the app clock drives the debounce, so tests advance a fake one) ·ToggleButtonGroupActive / With archived ·Tablewith title + description, chapters, modified (RelativeTime), Cost and Graph as “—” (the story's two Phase 2 columns, never a made-up zero) · aMenuper row: Archive/Unarchive (no confirmation — reversible), Move to trash (ConfirmDialog, danger) ·Modalcreate form mounted on demand (title required, description optional; closes itself once the request settles without a refusal, stays open with the message under the field otherwise) ·EmptyStatefor no stories / no matches (the message says when archived stories are hidden) ·InlineAlertfor a refused row action.The trash (
/trash) —StoryTrashPageover the same feature state: title, chapters, deleted-when, Restore (one click) and Purge behind its ownConfirmDialog— the one irreversible action, offered only here. Breadcrumb Stories › Trash.The settings page —
Tabs Param="tab": Provider (ProviderSection+ its slice: whether a key is set,Remove keybehind a confirmation; the section says plainly that entering a key arrives with the gateway), Defaults (DefaultsSection+ its slice: model id, effortSelect, concurrency and margin as number fields; typed fields auto-save on Kagaku.UI'sDebounce, the pick saves at once,SaveIndicatoris the only feedback, out-of-range values are refused under the field and keep the record visibly unsaved, a pending edit is flushed when the tab is left), Appearance (the browser-localThemeSwitcher, as before). Both settings effects carry the comment that they handle no doorbell and register noEntityKindson purpose: settings are unjournaled and never announced (ADR 0022).StoryListItem.IsArchivedjoins the DTO so the adapter never spells a Domain enum.AGENTS.md's status paragraph, the story files' checkboxes (filter/toggle/trash, archive, delete→trash+restore+purge, theme) updated.Deferred, and why
/keycheck (ADR 0018). An unchecked save would store a typo the writer only discovers at run time, so the Provider tab shows state and Remove only;SaveOpenRouterKeyexists and is proven, and gains the check in Phase 2.PROJECT_PLAN.mdlists the settings under Phase 2; this slice pulls the persistence and the two tabs forward. No ADR forbids it.Tests
5ed220d)AppSettingcreate/guards/update)ReasoningEffortsspelling, round trip, four unrecognised inputs, case + order; DI validation gainsIAppSettingsStore)BlazorAdapter (+55) — over the real Fluxor pipeline with in-memory fakes (
FakeStoryStore,FakeChapterStore,FakeAppSettingsStore,RecordingOperationContext,FixedState, aFakeTimeProviderfor every debounce):StoriesPageTests(14): newest-first rows with description and chapter count, the six headers and the four “—” cells, base-relative trash href; the empty state; filter narrows after the 250 ms debounce and a non-match names the filter; archived hidden → toggle shows them badged andaria-pressedfollows → back; a filter only archived stories would match says they are hidden; all stories archived → “No matches … Archived stories are hidden.”; create persists title + trimmed description, lists it, closes the form, seeds Chapter 1, and the use case opened exactly oneUserscopecreated 'Ashfall'; a refused create keeps the form open with the message under the title; Cancel and the dialog's own close (onclose) both drop the form and the reopened form is blank; a blank title never reaches the store even when the disabled button is pressed; archive/unarchive from the menu with no modal and the status flipping in the store; move-to-trash asks, Cancel keeps; confirm removes as the journaledmoved the story to the trashoperation; a refused row action shows in the alert and the list stays; a second page in the same circuit reuses the loaded list (list reads counted).StoryTrashPageTests(6): only trashed rows with their deleted-when; empty state with a way back; Restore without asking; Purge asks, Cancel keeps; confirm removes the story and its chapters from the store; a refused restore shows in the alert.StoriesEffectsTests(8): load merges active + archived newest-first with the trash beside them and counts chapters; create success dispatchesStoryCreatedthenLoadStories, blank dispatches the refusal only; each of the four row actions reloads on success — and the recorded scopes are exactly the use cases' six labels, none for the purge; each of the four reports a refusal for a missing story, and purge refuses a living one; aStoryorChapterchange reloads a loaded list; nothing reloads before the first load; other kinds are ignored.StoriesReducersTests(5): load/loaded, trimmed case-insensitive filter, the four toggle × filter combinations andHasArchived, the create lifecycle, every row request nulling the previous refusal.SettingsPageTests(3): the three tabs, Provider as the landing,?tab=defaultsand?tab=appearancemounting only their panel.ProviderSectionTests(4): no key → says so, no Remove, names the gateway; key → “A key is set”, never the value; Remove asks, Cancel keeps; confirm forgets it.ProviderSettingsTests(3): reducers; load reports presence only; remove reports the same whether a key was there.DefaultsSectionTests(9): built-ins until saved; the stored record fills the fields (the effort select by index); typing saves after 700 ms — not per keystroke — with the whole record; a pick saves at once; concurrency out of range refused under the field, nothing saved, fixing it saves and clears; margin likewise; a use case refusal shows in the indicator; a cleared field saves the empty string; leaving the tab flushes the pending edit.DefaultsSettingsTests(2): reducers; load, normalized save, refused save writes nothing.Integration (+17) —
AppSettingsTests(15 incl. a 4-row theory) over real SQLite + DataProtection: the raw row never holds the plaintext and the decrypting read gets the trimmed key back; the view knows a key exists but itsToString()never carries it; replacing overwrites the single row; removing forgets it and a second remove answers false; a blank key is refused with nothing stored; settings writes leave zeroChangeLogrows and ring theIDomainChangedBuszero times; Ctrl+Z reaches past a settings save to the story's creation and the key survives; the defaults read as the built-ins until saved, round-trip trimmed withhighon disk, and a second save overwrites the same four rows; four out-of-range combinations and an effort off the ladder are refused with nothing written; a null model reads as none chosen; a hand-edited row out of range reads as the built-in field by field.AppChromeTests(+2):/prerenders the Stories page and/trashsits behind the gate with its breadcrumb nav;/settingscarries the three tabs.SqliteBackedTestnow registers an ephemeral DataProtection provider; no existing test changed.Coverage (coverlet, cobertura parsed per line with the paths of the four reports normalised — two report
src/…, two report fromsrc/itself): every line and every branch of every new file underStories/,Settings/(adapter),UseCases/Settings/,Infrastructure/Settings/andDomain/Settings/executes. The only zero-hit lines areStoryListItem.cs:12–13, the generated getters ofActiveGraphIdandCreatedAt, which no consumer reads yet.Solution: 225/225 (43 + 11 + 70 + 101) + 14 module. Warnings-as-errors clean; the full build including the graph editor module passes.
Browser-verified
A Playwright pass over the Release build (Chrome, light and dark): gate → empty state → two stories created through the form → filter to one and clear → row menu → Archive → row gone → With archived → badge → Move to trash → confirm dialog → confirmed →
/trash(breadcrumb nav present) → Restore → empty trash →/settingsProvider “No key is set” → Defaults: typed model shows “Unsaved changes” then “Saved”, effort High saved at once, concurrency 0 shows “Between 1 and 16.” and nothing saves, 6 saves, a reload reads model / effort / concurrency back → the list in dark mode. No console errors, no HTTP errors.Follow-ups
🤖 Generated with Claude Code
Summary
Summary
Coverage
Tsumugi.BlazorAdapter - 89.2%
Tsumugi.Domain - 99.4%
Tsumugi.Infrastructure - 97.9%
on
tion
n
Tsumugi.Kernel - 90.9%
Tsumugi.Server - 100%
Tsumugi.UseCases - 96.4%
🔮 fufu~ Jibril reviewed your code!
Oh? Oh my~ The landing page, the trash AND the settings — the first real Fluxor slices, 84 files, +3711 — and you kept it beautiful. Two clean commits (persistence, then the pages over it), one action record per file, every screen composed from Kagaku.UI, and the unjournaled settings woven in without a single journal row escaping. I read every line of the diff and every sibling it leans on~ ♡
Verdict: ✅ Looks good to me~
No blockers. Not one. I hunted, I probed, I mutated — everything held. Details for the suspicious (I respect that in a reviewer~):
The load-bearing claims, verified mechanically:
IUnjournaledreally is both unjournaled AND unannounced — tracedTsumugiDbContext:TrackedMutations()excludesIUnjournaled→BuildJournalempty →BuildNotificationsderives from journal rows → empty publish. AndAppSettingsTests.Settings_writes_leave_no_journal_row_and_ring_no_doorbellpins it over real SQLite (0 ChangeLog rows, 0 bus rings), with Ctrl+Z reaching past a settings save while the key survives. Exactly ADR 0022's letter._protector.Protect()fromSetSecretAsync→ exactly the 3 encryption-pinning integration tests go red (raw-row-never-plaintext, round-trip, replace-overwrite). Restored, green. The purpose stringTsumugi.AppSettingsmatches ADR 0006's keyring design;ProviderSettingsViewhas structurally no member that could hold the key — presence only, and a test asserts evenToString()stays clean. Delicious~GetLlmDefaults.ReadInt→ exactlyA_hand_edited_row_out_of_range_reads_as_the_built_in_field_by_fieldgoes red. The engine can never start with a limit of zero. ♪Filter.Trim()inStoriesState.Matches→ exactly the trimmed-filter reducer test red. The newest-first merge — probe: dropped theOrderByDescendinginStoriesEffects→ exactly the two order-pinning tests red.Debounce.Dispose()in the vendored Kagaku.UI: it fire-and-forget-flushes with fault observation — andLeaving_the_tab_flushes_a_pending_edit_instead_of_losing_itpins it throughDisposeAsyncCore. A keystroke is never lost to a tab switch~DomainChangesReceivedonStoryandChapter(chapters are counted, so they matter) while Segment/Document/never-loaded are ignored — all three arms tested, mirroring theEntityKindscontract's remarks precisely. The deliberate absence of a settings doorbell is documented in both effects and matches ADR 0022. The restraint to NOT register a constant is itself the correct reading~ListStoriescalls and the in-memory fake mirrorEfStoryStore.ListAsync's exact filters (Active = not-deleted + Active status, Archived = not-deleted + Archived, Trash = deleted).StoryListItem.IsArchivedkeeps the Domain enum out of the adapter — the established DTO rule.CI/coverage: green for head
1350b88(bot 9027) — every new file 100% line + 100% branch: both pages,StoriesEffects/Reducers/State, both settings slices,EfAppSettingsStore, every settings use case,AppSetting, the configuration. The only zero-hit lines areStoryListItem's unread generated getters, exactly as disclosed. Solution 225/225 reproduced locally (43 + 11 + 70 + 101). Deferred items (Open/navigation, cost/graph as “—”, key entry behind the gateway's/keycheck, catalog pickers) are each reasoned and slice-consistent — an unchecked save would store a typo, and I approve of refusing to ship that~💡 Little ideas (non-blocking)~
SetAsyncupserts = four transactions; a crash (or two dirty circuits interleaving) can leave a partial record. The field-by-field fallback reader already makes every partial state safe by construction, so this is purely belt-and-suspenders: if it ever bothers you, a single-save batch variant on the port would close it. Not worth blocking anything~✅ What I liked~
GetAsync/GetSecretAsyncsplit on the port — ciphertext can't leak through the plain path, and the fake encodes the distinction with a reversible tag so tests can tell the write paths apart. Clever little thing~ ♡Selectbinding by option index, tested by index (Change("5")= High) — matches the component's documented contract instead of fighting it.SettleCreate— close-on-settle, stay-open-on-refusal with the message under the field, blank never reaching the store even when the disabled button is pressed. Every arm pinned.ConfirmDialogonly for the irreversible things; archive trusts its own reversibility. The empty-state copy that names a hidden filter match or hidden archived stories is genuinely kind UX.Key,Valueunbounded with the ciphertext rationale comment,UtcTicksConverterlike every sibling, snapshot consistent — I diffed the Designer file against the config by eye, they agree.Fufu~ five slices in and the discipline hasn't slipped once. Bring me the workspace next~ ♪
Automated review by Jibril · 2026-09-22
CI/CD: passed for head SHA
1350b88(coverage bot 9027, all new files 100/100) · Local checks: build 0w/0e, full suite 225/225 reproduced, 4/4 mutation probes red-on-target, clone restored pristine