Skip to content

M4-19 Sidebar column, space tree and forum post list

No Date Ruling Reason
D-M4-19-0 2026-10-18 Base int/m1-wave38 at 297ab0c, branch feat/M4-19-sidebar-post-list in its own worktree; not merged locally and not pushed, the PR lane lands it. The brief relies on no core rule or ruling that main has and the base lacks, so nothing is ported; the base names the spec chapter 01-produkt.md, and the sentence this ticket adds there is English. Differences to the brief's names, following the merged code: the engine's counters are catchUp.countsOfChannel (no separate UnreadCounter instance in the app); postListProvider takes the channel and a PostListFilter that carries the sort; the executors are registered through the registerExecutors hook of SyncEngine.create in bootstrap.dart. Instructions of the launcher; merged names win over the brief (D-W-100, D-W-114).
D-M4-19-1 2026-10-18 Data path: every provider of the sidebar and the post list reads local_store only (R-CL-03): spaces with user_space_states, channels per space, read states, the person's notification preferences, runs that have not ended, threads with their tags, forum tags, principals. The counters of a channel are the engine's values per thread (snapshot, read_state.updated, its own counting of message.*, 04 14), else the stored read state; muted is only the channel's own row with level mute and muted_until absent or in the future. The mapping to the row data of M4-07 is in small pure functions (sidebar_model.dart, forum_model.dart) with unit tests. Brief Design note 1; the app never derives a counter or a control from grants (15 1).
D-M4-19-2 2026-10-18 The order key lives in domain as FractionalIndex (between, isValid), the same rule as the server's fracindex of M1-52 with its normative vectors; the fake server validates putMySidebarSpace with it. A drag or "Move up/down" writes one key between the new neighbours; when the neighbours leave no room (equal keys, or a global key outside the index), every space gets a fresh key in the new order and each changed one is written. The local row changes at once; while a later write of the same space waits in the outbox, an earlier answer is not stored. One shared key rule, so a key the client sends never fails with 400.
D-M4-19-3 2026-10-18 "Waiting for me" shows the open and done posts with a run that the reader triggered in waiting_input, or with an unread mention of the reader; the tag filter keeps posts with at least one selected tag; filters last while the app runs. Brief Design note 3.
D-M4-19-4 2026-10-18 The collapse state of the Done section is kept per forum on this device in the local-only table forum_view_states (channel_id, done_collapsed), schema version 2 of local_store with its migration and survival tests; collapsed by default; not synchronized. 01 6.1 says so. 01 11 has no key for it, and no new personal setting key may be added.
D-M4-19-5 2026-10-18 Paging: the post list loads the first page of listChannelThreads (sort of the list) when it opens and the next ones when scrolled within 400 logical pixels of the end; pages go into threads. The Done label counts the loaded done posts that match the filters and reads "Done (n+)" while a further page may exist, because the API returns no total. A failed page keeps the cursor for the next try. Brief Design note 3.
D-M4-19-6 2026-10-18 The fake server lists a local identity provider in its discovery: E2 offers the login name only with a provider of kind local, so without it no desktop integration test could sign in (they also run on flutter-tester). The failure reason of DsThreadRow and the name and "New post" of the post list header wrap instead of overflowing a narrow column at text scale 2, which the goldens of this ticket showed. A test path that cannot sign in proves nothing; 01 8 text scaling.
D-M4-19-7 2026-10-18 The time of a post row is shown in the device's time zone: the local store does not hold users.time_zone, so the wall clock of this device decides "today", and the clock time or the day is formatted from it. No new local column for one label; the person's zone arrives with the settings of M4-25.
D-M4-19-8 2026-10-18 After the code review: (a) "New post" offers every tag of the forum, restricted ones too, and the server decides; (b) a createThread the server refused stays in the outbox and the post list shows it at the top with its reason, "Edit" (the New post dialog filled with it, then Outbox.edit), "Try again" (Outbox.retry) and "Discard" (Outbox.discard); (c) the sync engine keeps the head_seq of the last snapshot (SyncContext.countedUpToSeq) and does not count a message event at or below it, which the catch-up replays after ready and which the snapshot's counters already hold (D-M4-19-9); (d) after a failed page the post list loads again only when the person scrolls or presses "Try again" in the row it shows at the end of the open posts; (e) the created thread goes through CatchUpListener.storeConfirmedThread (version rule, title index, held channel); (f) the post list keeps its scroll offset per forum while the app runs, and the shell keeps the last route selection while a new one resolves. 15 1 principle 1; 04 13.2 item 5 (nothing is dropped silently); 04 14; no request loop; 14 6; opening a post must not lose the place in a long forum.
D-M4-19-9 2026-10-18 After the red team: the counter guard of D-M4-19-8(c) judges replayed events by their global seq against the snapshot's head_seq, not by thread_seq, so a replayed deletion or edit no longer cancels a mention the snapshot already holds; the route selection of /c/<id> watches the stored channel, so a forum opened by a link before the first snapshot shows its post list once the channel arrives; the generated domain tables are again byte for byte what their generators write. 04 14 (the snapshot holds the state at its head); 15 11; make codegen-check.
D-M4-19-10 2026-10-18 Class fix after the delta red team, replacing the guard of D-M4-19-8(c) and D-M4-19-9: the order of server values and own counting lives in one place, UnreadCounter (domain). Every server value holds the events up to a global seq (the snapshot its head_seq, a read_state.updated its own seq); every counted message keeps the seq of the event that counted it. An event or a server value at or below the seq a thread's values hold changes nothing (the snapshot seeds a new counter at its head). A newer server value drops the mentions counted from events at or below its seq, and the unread messages too where it delivers an unread count; a computed unread count (read_state.updated carries none) covers only the messages up to the stored last_message_seq and the read position, so messages counted above both stay counted. The sync engine only passes each event's seq; SyncContext.countedUpToSeq is gone. A seeded model test runs random histories of created, edited, deleted and read events with a random cursor, head and live tail through the engine against the server's exact values. The three findings (a replayed deletion, a replayed edit, a replayed read state changing a counter the snapshot already set) were instances of one class: a counter change judged without the position of the server value; a live read on another device also kept mentions counted before it. The class-fix red team found that a partial live read dropped unread messages its computed value does not hold (fixed with a regression test; the model reads partially and sends no unread count). 04 14.
D-M4-19-11 2026-10-19 Port onto main from the integration squash of int/m1-wave40 on top of M4-25: the ticket's own commits with INT-W40/2 (the sidebar column is the SidebarColumn of M4-19 with the settings entry and "Log out" in its footer, the column widths go through the settings repository of M4-25) and INT-W40/5 (postListLoadFailed is an exception of the term table check); the rulings stand in their own section. UnreadCounter.resetFrom joins the ordering by global seq of D-M4-19-10 with main's later fix of M4-08, which drops buffered mentions and unread messages at or below the new read boundary (GREATEST(last_read_seq, unread_floor_seq)): an entry is dropped when either rule drops it. The local identity provider of D-M4-19-6 is already in main's fake server (FakeWorld.localProviderId, from the port of M4-18), so the fake keeps main's field and its discovery entry and adds only the logo. The spec sentence of D-M4-19-4 is written into the English chapter 01, and comments name the English chapter files. The PR lane ports only the ticket and the integration fixes it needs; both counter fixes answer reproduced defects, and neither may be lost.
D-M4-19-12 2026-10-19 The integration tests forum_test.dart and sidebar_sync_test.dart of this ticket get their lines in apps/client/platform-tests.json, so the platform jobs run them on macOS and Windows. D-M4-18-19 asks a line for each new integration test file.
D-M4-19-13 2026-10-20 The goldens of the sidebar and the post list put their column at the top left of a cell of cellWidth (460), wider than the longest scenario label of alchemist, as D-M4-07-10 does for the component sheets; the goldens are recorded again. The cells took the width of their label, a fraction of a pixel wider than a whole number, so the last pixel column of the light goldens stayed transparent on the Linux runner and the CI comparison failed there (reproduced in a Linux amd64 container with Flutter 3.47.7, where the new goldens pass).
D-M4-19-14 2026-10-20 CatchUpListener.dispose, EphemeralStore.dispose and Outbox.close close their broadcast streams without waiting for the done event, as GatewayConnection.dispose does since D-M4-25-8. The counter provider of the sidebar listens to countChanges with await for, and a paused listener holds the done event of a broadcast stream: the engine never finished its dispose, the database stayed open, and the Windows runner could not delete the data folder after login_pending_confirm_test.dart (PathAccessException, errno 32). Same class as D-M4-25-8, so every broadcast close of the engine follows the rule. Test: test/catch_up/dispose_test.dart.
D-M4-19-15 2026-10-20 CatchUpListener.dispose no longer waits for the background reads: it marks itself disposed, queued reads do not start, and a running read that ends later fails on the closed database and is logged like every failed read. On the Windows runner pairing_test.dart still could not delete the data folder: after pairing, the read of getInstance behind the snapshot had no answer when the app was removed, and dispose waited for it without a bound, so the database stayed open (traced in a headless run). A read is best effort and runs again with the next snapshot. Test: test/catch_up/dispose_test.dart.
D-M4-19-16 2026-10-20 ForumPager watches syncEngineProvider: a new engine (a logout, a new login) starts the paging of every post list again. The pager kept its cursor and hasMore: false across a logout and a new login, so the post list of the new, empty database never loaded its first pages until a restart (CodeRabbit on PR #159, reproduced by test/forum/pager_test.dart).