Skip to content

M4-19: Sidebar, spaces, channels and post list

Status: ready | Depends on: M4-18 | Milestone: M4

Goal

After this ticket the first two columns of the desktop app show real data from the local database: the sidebar (01 5.1 D1, components.md 7) with the product mark and branding.product_name, the "Jump to" field and the "Running now" entry as placeholders that M4-23 wires, the spaces as collapsible tree nodes in the person's own order (collapse and drag-and-drop order synchronized across devices through user_space_states, 04 14), the channels of each space with their kind icon and the states of components.md 7 (read, unread, mentions, active run, muted, selected), unread and mention counters kept by the sync engine, and the own avatar with the settings entry at the bottom; and for forums the post list (components.md 6) with "New post", the filter chips Open, Waiting for me, Tags and Sort, rows with state icon, title, time, badges and tags, and the collapsed "Done (n)" section at the bottom (01 6.1). Selecting a channel opens the main thread (text channel) or the post list (forum) through the router of M4-18. Why: the sidebar is the entry to every conversation; its counters and states are what tells a person where agents work and where they are needed (01 1).

Read first

  • docs/PLAN.md: section 3; section 10 rows M4-18, M4-19, M4-20, M4-23, M4-33, M4-36.
  • docs/spec/01-product.md: 3.1 (structure and conversations), 4, 5.1 D1 and D2 (sidebar and post list columns), 6.1 "Done and reopening", 6.7 (operator in the channel header; costs), 7 (shortcuts named for the sidebar; M4-24 implements them), 8 (tree semantics, focus, text scaling), 11, 13 (R-PR-01).
  • docs/design/components.md: 6 "Thread list with status (forum)", 7 "Space and channel tree", 13 (review list).
  • docs/spec/03-data-model.md: spaces, channels (kind, forum_default_sort, read_only, default_notification_level, operator), threads (state, last_activity_at, title_source), thread_tags, forum_tags, user_space_states, read_states.
  • docs/spec/04-sync-protocol.md: 9 item 3 (sidebar.updated and read_state.updated carry the version of the personal sub-object), 14 (sidebar: last write wins per space; unread counting between snapshots).
  • docs/spec/15-client-architecture.md: 4 (StreamProvider per query), 6.1 (tables), 11 (paths /s/, /c/, /t/).
  • docs/api/openapi.yaml: getMySidebar, putMySidebarSpace (SidebarState, SidebarStatePut), listSpaces, listSpaceChannels, listChannelThreads (filters and sort parameters), listForumTags, createThread.
  • docs/DECISIONS.md: D-W-100, D-W-114; section 10.
  • Briefs: docs/briefs/M4-18-desktop-shell.md (columns, providers, router), docs/briefs/M4-07-core-components.md (DsSpaceRowData, DsChannelRowData, DsThreadRowData, rows), docs/briefs/M4-08-domain.md (ForumOrder, UnreadCounter), docs/briefs/M4-12-local-store.md (StructureDao, ThreadsDao, PersonalDao), docs/briefs/M4-15-sync-outbox.md (executor registry), docs/briefs/M4-17-fake-server.md (handler registry, FakeServer); for the server side docs/briefs/M1-52-chat-spaces.md (the fractional index fracindex with its normative vectors) and docs/briefs/M1-56-messaging-send.md (forum posts need a first message).

This ticket touches no API operation, no table of chapter 03, no action, no event and no configuration key.

Fixed requirements that apply

Owner's fixed build rules (apply verbatim):

  1. No MVP. New from scratch and directly in the final state; stages are only the order.
  2. Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
  3. Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
  4. Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
  5. Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.

Rules for every brief:

  1. Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
  2. No en dash and no em dash in any file you write; the text-rules pre-commit hook rejects them, and the ARB test of M4-06 rejects them in interface texts. Use a hyphen or rephrase.
  3. White label: examples and test data use example.com and the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. Interface texts come from packages/l10n, never as literals in widgets.
  4. No new configuration key and no new personal setting key (01 11 is normative and the server rejects unknown keys).
  5. Specification first: 01 6.1 says the collapse state of the Done section is "personal per forum" but 01 11 has no key for it; this ticket keeps it per device in the local database (Design note 4) and says so in 01 6.1 in the same change.

Product requirements implemented here:

  1. Spaces collapsible and sortable per person; collapse and order are synchronized across devices through putMySidebarSpace and sidebar.updated (components.md 7, 04 14); the global order changes only in the administration.
  2. Channel rows show read, unread (bold with a bar), mentions (counter), active run (dot), muted and selected; the label is ", unread, mentions, run active"; the tree has the roles and states of components.md 7.
  3. Unread and mention counters come from the snapshot, listMyReadStates and read_state.updated, and between them from the client's own counting of message.* events (04 14); the app never derives them from grants.
  4. The forum post list sorts by last_activity_at or creation (forum_default_sort), offers the filters Open, Waiting for me, Tags and Sort, and shows done posts in a collapsed "Done (n)" section in color.text.muted (components.md 6, 01 6.1).
  5. Rows of the post list: state icon (active run, otherwise mention), title bold when unread, time, "Waiting for you", operator agent, mention counter, tags, failure reason; label ", <State>, <Time>, <n> unread" (components.md 6).</li> <li>The sidebar shows <code>branding.product_name</code> and the logo of the instance, never a literal name.</li> </ol> <h2 id="scope">Scope<a class="headerlink" href="#scope" title="Permanent link">¶</a></h2> <p>Create in <code>apps/client/lib/</code>:</p> <ul> <li><code>sidebar/</code> (<code>sidebar_column.dart</code>, <code>space_tree.dart</code>, <code>space_node.dart</code>, <code>channel_entry.dart</code>, <code>sidebar_header.dart</code>, <code>sidebar_footer.dart</code>, <code>sidebar_order.dart</code> for drag and drop and the <code>sort_key</code> computation, Design note 2), <code>sidebar/providers.dart</code> (Riverpod providers over the DAOs: spaces with states, channels per space with counters and run state).</li> <li><code>forum/</code> (<code>post_list_column.dart</code>, <code>post_list_header.dart</code> with the filter chips, <code>post_row.dart</code> mapping to <code>DsThreadRowData</code>, <code>done_section.dart</code>, <code>new_post_dialog.dart</code> for the title, first message and tags of a new post, <code>providers.dart</code>).</li> <li><code>packages/domain/lib/src/rules/fractional_index.dart</code> with its tests (Design note 2).</li> <li><code>sync/sidebar_ops.dart</code> (outbox executor for <code>putMySidebarSpace</code>) and <code>sync/thread_ops.dart</code> (executor for <code>createThread</code>), registered in the bootstrap of M4-18.</li> <li>Fake server handlers for <code>getMySidebar</code>, <code>putMySidebarSpace</code> (with the <code>fracindex</code> validation), <code>listForumTags</code>, <code>createThread</code> (with the forum rule) and <code>getBrandingLogo</code> added through the handler registry of M4-17 (in <code>packages/sync_engine/lib/src/testing/rest/</code>) if M4-17 does not have them.</li> <li>Tests under <code>test/sidebar/</code>, <code>test/forum/</code>, goldens under <code>test/goldens/sidebar/</code> and <code>test/goldens/forum/</code>, integration tests under <code>integration_test/</code> (Tests).</li> <li>ARB keys for every text of the sidebar and the post list in <code>packages/l10n</code>.</li> </ul> <p>Change:</p> <ul> <li><code>packages/local_store</code>: a local-only table <code>forum_view_states</code> (<code>channel_id</code>, <code>done_collapsed</code>) in the next free schema version with its migration test and the survival test of M4-12 extended, only if no existing local table fits (Design note 4).</li> <li><code>docs/spec/01-product.md</code> 6.1 (the Done collapse state is kept per device), <code>apps/client/README.md</code> (sidebar and post list).</li> <li><code>docs/DECISIONS.md</code> section 10: rows <code>D-M4-19-<n></code>; root <code>CHANGELOG.md</code> under <code>[Unreleased]</code>.</li> </ul> <h2 id="out-of-scope">Out of scope<a class="headerlink" href="#out-of-scope" title="Permanent link">¶</a></h2> <ul> <li>The thread view, the main thread of a text channel and the message input (M4-20); the channel header with operator and topic (M4-20).</li> <li>"Running now", the quick switcher, search and mentions (M4-23); keyboard shortcuts for channel navigation (M4-24, which uses the focus and selection API of this ticket).</li> <li>The channel menu with notification level and export (M4-33); the group "Hosts" (M4-36); administration of spaces and channels (M6 console and M4-26 for people only).</li> <li>Direct messages (later, 01 4).</li> </ul> <h2 id="boundaries">Boundaries<a class="headerlink" href="#boundaries" title="Permanent link">¶</a></h2> <ul> <li>Work in a dedicated git worktree on the branch <code>feat/M4-19-sidebar-post-list</code>; one writer per worktree.</li> <li>Conventional Commits with the package or component as scope (for example <code>feat(client): ...</code>, <code>feat(sync-engine): ...</code>, <code>build(gates): ...</code>, <code>docs(spec): ...</code>) and a DCO sign-off on every commit (<code>git commit -s</code>); squash into meaningful commits before the merge (<code>docs/quality/push-discipline.md</code>).</li> <li>Never push to <code>main</code>, never tag and never create remote repositories; the only remote steps are the branch push and the pull request of <code>AGENTS.md</code>. Never publish a Dart package (<code>publish_to: none</code> in every <code>pubspec.yaml</code>).</li> <li>Do not modify files outside the scope above, except the CHANGELOG, README and <code>docs/DECISIONS.md</code> entries named in this brief.</li> <li>Follow the host rules supplied by the launcher (ports, machine rules).</li> <li>Tests bind only <code>127.0.0.1</code> with port 0 and make no network call; fakes replace the server unless a section below names a container. Temporary directories only via <code>mktemp -d</code> (Dart <code>Directory.systemTemp.createTemp</code>, pytest <code>tmp_path</code>), removed at the end. Stop only processes you started, by PID.</li> <li>Never read or print secrets. No network calls except package downloads for pinned tools and pub packages and what the gates of <code>make check</code> do themselves.</li> <li>The Flutter SDK at the version pinned in the root <code>pubspec.yaml</code> must be on <code>PATH</code> (and Go and Docker where a section names them); without them the gates fail by design (R-QA-02), and the ticket stops with <code>Verdict: BLOCKED <tool> missing</code> instead of skipping.</li> <li>Never lower a threshold, disable a gate, delete a test, raise <code>repo.max_file_kb</code> or extend <code>quality/licenses/allowed.txt</code>; never exclude hand-written code from a lint, format, coverage or doc gate.</li> <li>The product name appears only at the places of 02 "Code layout" ("Places that carry the product name"); the app shows <code>branding.product_name</code> from the instance, never a literal.</li> <li>No permission logic in the client (15 1 principle 1): controls follow server responses, snapshots and events, never an own derivation from grants.</li> <li>If the core contract (chapters 00, 03, 04, 05) or <code>docs/api/</code> lacks something this ticket needs, stop with <code>Verdict: BLOCKED <reason></code> instead of building a deviation (P-11).</li> <li>This brief was written before milestone M1 closed (D-W-100, extended to M4 by D-W-114). Where the merged code of an earlier ticket names a package, type, function, file, provider or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with <code>Verdict: BLOCKED <reason></code>.</li> </ul> <h2 id="design-notes">Design notes<a class="headerlink" href="#design-notes" title="Permanent link">¶</a></h2> <ol> <li> <p><strong>Data path.</strong> Providers read from <code>local_store</code> only (R-CL-03): <code>spacesWithStateProvider</code> joins <code>spaces</code> and <code>user_space_states</code> and sorts by the personal <code>sort_key</code> (absent: the global order of the space); <code>channelsOfSpaceProvider(spaceId)</code> with counters from <code>read_states</code> and the <code>UnreadCounter</code> values the engine stores, run state from <code>runs</code> with an active state, muted from <code>notification_preferences</code> (the channel's own row with level <code>mute</code> and <code>muted_until</code> absent or in the future; inherited levels are shown by M4-33); <code>postListProvider(channelId, filter, sort)</code> over <code>threads</code> and <code>thread_tags</code> with <code>ForumOrder</code> of M4-08. Mapping to the view models of M4-07 happens in small pure functions with unit tests.</p> </li> <li> <p><strong>Personal order.</strong> Drag and drop of a space computes a new <code>sort_key</code> with <code>KeyBetween(previous, next)</code> of the fractional index that the server validates (<code>fracindex</code> of M1-52: digits <code>0-9A-Za-z</code> in ASCII order, an integer part with a head letter, no trailing <code>0</code> in the fraction, the smallest integer invalid); implement it in <code>packages/domain</code> as <code>FractionalIndex.between</code> and test it with the normative vectors of M1-52 (<code>("", "") -> "a0"</code>, <code>("a0", "") -> "a1"</code>, <code>("", "a0") -> "Zz"</code>, <code>("a0", "a1") -> "a0V"</code>) and its property tests, so a key the client sends never fails <code>putMySidebarSpace</code> with <code>400</code>. The write goes to the outbox as <code>putMySidebarSpace</code> with <code>sort_key</code>; collapse as <code>putMySidebarSpace</code> with <code>collapsed</code>. The local row updates at once; the own echo is ignored by <code>updated_by_device_id</code>, and a later change from another device wins by version (04 14). Ruling for docs/DECISIONS.md: the shared key rule in <code>domain</code>.</p> </li> <li> <p><strong>Post list.</strong> Header: forum name, "New post" (primary) opening a dialog for the title, the first message and tags (<code>createThread</code> with <code>title</code>, <code>first_message</code> carrying its own <code>client_msg_id</code>, and <code>tag_ids</code>, through the outbox; a forum post without a first message is refused by the server, <code>ThreadCreate</code>), chips Open (default), Waiting for me (ruling for docs/DECISIONS.md: threads with a run that the person triggered in <code>waiting_input</code>, or an unread mention of the person), Tags (multi-select from <code>forum_tags</code>), Sort (last activity, creation; default from <code>forum_default_sort</code>). The list is virtualized; long forums load further pages through <code>listChannelThreads</code> when scrolled near the end (the cursor of the API), stored in <code>threads</code>. Done section: "Done (n)" with the count of the loaded closed threads matching the filters (<code>listChannelThreads</code> returns no total; while more pages exist the label reads "Done (n+)"), collapsed by default.</p> </li> <li> <p><strong>Done collapse per device.</strong> The collapse state of the Done section is kept per forum on this device in a local-only table (<code>forum_view_states</code>) or in an existing local-only table that fits; it is not synchronized because 01 11 has no key for it. Ruling for docs/DECISIONS.md and a sentence in 01 6.1.</p> </li> <li> <p><strong>Selection and router.</strong> Clicking or pressing Enter on a channel navigates to <code>/c/<id></code>; a text channel opens its main thread in the history column, a forum fills the post list column; a post navigates to <code>/t/<id></code>. The selected row follows the route. A space row toggles with click, Enter, arrow left and right (components.md 7). The sidebar exposes <code>SidebarNavigation</code> (next or previous channel, next or previous unread channel) as a controller for M4-24.</p> </li> <li> <p><strong>Rulings for docs/DECISIONS.md</strong> (<code>D-M4-19-<n></code>): data path and mapping (note 1); the order key in <code>domain</code> (note 2); "Waiting for me" (note 3); the Done state per device (note 4); paging of the post list (note 3).</p> </li> </ol> <h2 id="tests-write-them-first">Tests (write them first)<a class="headerlink" href="#tests-write-them-first" title="Permanent link">¶</a></h2> <p>Widget and golden tests (<code>flutter test</code>, in-memory <code>local_store</code> filled by fixtures, fake clock):</p> <ul> <li><code>test/sidebar/space_tree_test.dart</code>: personal order from <code>sort_key</code> with fallback to the global order; collapse writes one outbox entry and updates the row at once; tree semantics (roles, expanded and collapsed states); the channel label with unread, mentions and run.</li> <li><code>packages/domain/test/fractional_index_test.dart</code>: the normative vectors of M1-52, keys between neighbours, at the start and the end, validity, byte order, and repeated inserts at the end keep short keys.</li> <li><code>test/sidebar/counters_test.dart</code>: a <code>message.created</code> from another person raises unread; one mentioning me raises mentions; <code>read_state.updated</code> resets them.</li> <li><code>test/forum/post_list_test.dart</code>: sort by activity and creation; filters Open, Waiting for me and Tags; the Done section counts and collapses, and its state survives a restart on this device; row labels.</li> <li><code>test/goldens/sidebar/*</code> and <code>test/goldens/forum/*</code>: the sidebar with three spaces in every channel state, and the post list with every row state and the Done section expanded, light and dark, densities <code>comfortable</code> and <code>compact</code>, text scaling 1 and 2, <code>de</code> and <code>en</code>.</li> </ul> <p>Integration tests (<code>integration_test/</code>, against <code>FakeServer</code>):</p> <ul> <li><code>sidebar_sync_test.dart</code>: two simulated devices of Alex: reordering and collapsing on one device appears on the other through <code>sidebar.updated</code> (personal order across devices); unread and mention counters follow messages from Sam.</li> <li><code>forum_test.dart</code>: create a post, see it at the top, close it from the fake, see it move to Done; reopen through a new message.</li> </ul> <p>Commands that must pass, from the repository root:</p> <ul> <li><code>cd apps/client && flutter analyze --fatal-infos && flutter test --coverage</code></li> <li>on macOS and on Windows: <code>make apps-platform</code>; elsewhere it prints its SKIP line</li> <li><code>make codegen-check</code>, <code>make arch</code>, <code>make lint</code>, <code>make fmt-check</code>, <code>make coverage</code></li> <li><code>make check</code></li> </ul> <h2 id="acceptance-criteria">Acceptance criteria<a class="headerlink" href="#acceptance-criteria" title="Permanent link">¶</a></h2> <ul class="task-list"> <li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> Widget and integration tests with the fake server are green; the personal order and collapse state synchronize across two simulated devices.</li> <li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> Spaces, channels and the post list show the states and labels of components.md 6 and 7; counters follow 04 14.</li> <li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> The post list sorts, filters and shows the Done section as 01 6.1 says; its collapse state is kept per device and 01 6.1 says so.</li> <li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> Goldens in light and dark, two densities, text scaling 1 and 2, <code>de</code> and <code>en</code>.</li> <li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> Rulings recorded.</li> <li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> <code>make check</code> is green.</li> </ul> <h2 id="definition-of-done">Definition of done<a class="headerlink" href="#definition-of-done" title="Permanent link">¶</a></h2> <ul> <li>Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why and cite 01, 04 or components.md.</li> <li><code>apps/client/README.md</code> (English) as in Scope.</li> <li>01 6.1 updated; rulings of Design note 6 in <code>docs/DECISIONS.md</code> section 10.</li> <li>Root <code>CHANGELOG.md</code> entry under 'Unreleased'.</li> <li>An ADR only if you take an architecture decision beyond this brief (MADR 4 as in <code>docs/adr/0000-template-madr-4.md</code>, headings and text in English, next free number, <code>make adr-index</code>).</li> <li><code>make check</code> green.</li> <li>An independent code review over the diff, then the red team gate (<code>docs/quality/red-team-gate.md</code>, verdict <code>JA</code> or <code>NEIN</code>); on <code>NEIN</code> fix with a test per finding and have the delta checked again. Record both verdicts.</li> <li>Land the change as <code>AGENTS.md</code> ("How work lands on <code>main</code>") describes: a pull request from this branch, squash-merged only when review, red team and <code>make check</code>, <code>ci-ok</code> and the review threads are green; then <code>git worktree remove</code>.</li> </ul> <h2 id="report">Report<a class="headerlink" href="#report" title="Permanent link">¶</a></h2> <p>Write to <code>{{REPORT_PATH}}</code>:</p> <ol> <li>First line exactly <code>Verdict: DONE</code> or <code>Verdict: BLOCKED <reason></code>.</li> <li>Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of <code>make check</code>.</li> <li>Review and red team verdicts and the findings fixed.</li> <li>Where the integration tests ran, the order key algorithm with its length bound, and whether a local table was added for the Done state (with its schema version).</li> <li>What is not proven (for example a platform test that ran only in CI, or a check done by hand).</li> <li>Follow-ups that could not be done in this scope, each with its reason, every difference to the names of this brief (D-W-100, D-W-114), and spec gaps found.</li> </ol> <p>Then write <code>{{DONE_PATH}}</code> with <code>exit=0 sha=<sha></code> or <code>exit=1 <reason></code>.</p> </article> </div> <script>var target=document.getElementById(location.hash.slice(1));target&&target.name&&(target.checked=target.name.startsWith("__tabbed_"))</script> </div> <button type="button" class="md-top md-icon" data-md-component="top" hidden> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13 20h-2V8l-5.5 5.5-1.42-1.42L12 4.16l7.92 7.92-1.42 1.42L13 8z"/></svg> Back to top </button> </main> <footer class="md-footer"> <div class="md-footer-meta md-typeset"> <div class="md-footer-meta__inner md-grid"> <div class="md-copyright"> Made with <a href="https://squidfunk.github.io/mkdocs-material/" target="_blank" rel="noopener"> Material for MkDocs </a> </div> </div> </div> </footer> </div> <div class="md-dialog" data-md-component="dialog"> <div class="md-dialog__inner md-typeset"></div> </div> <script id="__config" type="application/json">{"annotate": null, "base": "../..", "features": ["content.code.copy", "navigation.indexes", "navigation.sections", "navigation.top", "search.highlight", "toc.follow"], "search": "../../assets/javascripts/workers/search.2c215733.min.js", "tags": null, "translations": {"clipboard.copied": "Copied to clipboard", "clipboard.copy": "Copy to clipboard", "search.result.more.one": "1 more on this page", "search.result.more.other": "# more on this page", "search.result.none": "No matching documents", "search.result.one": "1 matching document", "search.result.other": "# matching documents", "search.result.placeholder": "Type to start searching", "search.result.term.missing": "Missing", "select.version": "Select version"}, "version": null}</script> <script src="../../assets/javascripts/bundle.d7400e89.min.js"></script> </body> </html>