Skip to content

Group the sidebar conversations into collapsible sections - #1552

Open
Neckyserg wants to merge 10 commits into
CopilotKit:mainfrom
Neckyserg:feat/sidebar-sections
Open

Neckyserg wants to merge 10 commits into
CopilotKit:mainfrom
Neckyserg:feat/sidebar-sections

Conversation

@Neckyserg

Copy link
Copy Markdown

What this changes

The conversation sidebar lists its conversations in three collapsible sections, in this order:

  1. Group chats — a channel with more than one person in it (the reader included) or more than
    one Bot.
  2. Direct chats — one person, one Bot.
  3. Projects — folders the reader makes, renames, deletes, and files conversations into.

A conversation is drawn once: filing it in a folder takes it out of Group/Direct. The section fold
is saved on the account, not in the browser, so it follows a person across devices. The sidebar
search opens every section for as long as the box holds text, and the chosen fold comes back when
the box is cleared.

Worth doing because a flat list stops working somewhere around the tenth conversation, and because
everybody's list is mixed: a person cannot tell a one-to-one with a Bot from a company-wide channel
without opening it.

Where it runs

  • New state that outlives a request? Postgres, two new tables in 0089: projects
    (the folder, its owner, its order) and channel_projects (which conversation, in whose
    filing, which folder). Section fold goes into the existing users.preferences jsonb row.
    Nothing is held in a module-level Map or a closure.
  • What happens on the second replica? Both replicas read and write the same tables, so the
    sections, the folders and the fold are identical on both. The previous process answering a
    WebSocket is irrelevant here: nothing is pushed to a socket, the client refetches the roster.
  • Anything serialised? Filing a conversation is one upsert whose rule is an index, not a
    check: uniqueIndex channel_projects_one_folder_per_person_idx (channel_id, owner_user_id)
    plus the (project_id, channel_id) primary key, with onConflictDoUpdate onto the per-person
    key, so filing a conversation that is already filed moves it instead of colliding. Two
    simultaneous moves of the same conversation by the same person leave one row and the later
    write wins; neither leaves a duplicate, and neither has to read first. Rename and delete are
    single statements conditioned on owner_user_id, so a stranger's attempt changes nothing.
  • Anything fanned out to a browser? No. The fold and the folders are read with the roster
    on the next request; no new message type, no cross-process notification.
  • New listener, port, or schedule? None. Four routes on the existing API server:
    GET/POST /api/projects, PATCH/DELETE /api/projects/:id, PATCH /api/channels/:id/project.

Boundary and audit

  • Every acting call still goes through the gateway: server/src/projects/routes.ts resolves
    the caller and calls projects/store.ts, which is the only place that touches the tables.
    The permission model is unchanged — a channel route still resolves the caller in
    channel_memberships, and filing additionally requires the channel to be one the caller is
    in.
  • New refusals and new failures each write a row. No, and deliberately: this fork's
    channel routes do not audit refusals either (channel_memberships miss → 404 with no
    row), so adding a row for folder refusals alone would make folders the only audited object
    and misreport the policy as narrower than it is. If the gateway grows a refusal sink, these
    two routes should use it with the channel routes in one change.
  • Nothing new is trusted from the client that the server can resolve itself. A folder payload
    names conversations by id and never by title; the owner of a folder is taken from the
    session, never from the body; a stranger's folder answers 404 (not 403), so the route
    does not confirm that the folder exists.

Changelog

  • CHANGELOG.md under Unreleased: what the sections are, that the fold follows the account,
    and that upgrading runs 0089, which only adds tables and alters nothing.

Proof

Gates on a full checkout, Bun 1.3.14, all workspaces:

  • bun run typecheck — toolkit, recorder-extension, worker, server, app: exit 0.
    (server typecheck needs a raised heap; it OOMs at the default limit — see the note below.)
  • bun run lint (biome, error-on-warnings) and bunx biome format . — clean, 3025 files.
  • bun run typecheck:tests — clean against the repository's 223-error baseline.
  • bun test app/tests — 3358 pass, 38 skip, 0 fail, 446 files.
  • TEST_DATABASE_URL=… bun test server/tests/projects.integration.test.ts — 12 pass against real
    PostgreSQL (pgvector/pg17).

The integration suite covers: owner rename and delete; a stranger refused on both, with the rows
still there afterwards; a non-member unable to file; a member unable to file into somebody else's
folder; a folder payload carrying ids and never titles; and deleting a folder returning its
conversations without disturbing filing that other people did.

The app tests cover the placement and fold rules on their own, and the rendered tree: headers with
titles and counts, the chevron asking for its own section, a folded section keeping its header,
per-folder folding, the move menu filing the conversation it was opened on, and a refused move said
on the row that asked.

Screenshots: not attached — this was built and gated headless (bun run typecheck, bun test) and
verified against a running container, not with a browser at hand. Running it is one command: the
compose file in the repository root, or scripts/start.sh.

Deployment note for whoever reviews: the change adds migration 0089; the container's migrate
service applies it on start, and a database that has run it does not go back — take the backup
first, as the changelog says.

Sergey Shabalin added 9 commits November 12, 2026 08:22
The sidebar sections added a required `sidebarSections` to UserPreferences, and
six test files spelled every field out by hand, so the new field was missing from
each of them and the typecheck failed instead of the tests. One fixture built on
DEFAULT_USER_PREFERENCES keeps them complete as fields are added.

Also drops two imports left unused by the section extraction.

typecheck (app, server) and lint are green.
The click that asks for a move also closes the menu, and the move's callbacks lived in that menu.
TanStack Query delivers `mutate`'s callbacks through the observer, and an unmounted observer has no
listeners left — so a refused move was reported to nobody and the row said nothing. Filing through
`mutateAsync` settles whether or not this component is still on screen, and `onProblem` belongs to
the row, which stays.
The rules a folder can be refused for live in the store's WHERE clauses and in the membership join
that fills a folder's channels, so a test against an in-memory stand-in would pass with every one of
those clauses deleted. This runs the real store and the real routers over a real database, and
checks each refusal twice: the status the browser gets, and the rows still in the database.

- a stranger's rename is 404, never 403 (which would confirm the folder) and never 500 (which is
  what an unguarded update on somebody else's row produces);
- a non-member cannot file somebody else's conversation, and a member cannot file into somebody
  else's folder;
- a folder names conversations by id only, and only while its owner can still open them;
- deleting a folder returns its conversations to the sections they came from, and leaves filing
  somebody else did exactly where it was.

`TEST_DATABASE_URL` is refused unless it names a dedicated database, so the run cannot reach a
developer's own rows.
Two levels, because they can break apart. `channel-sections.test.ts` holds the rule itself —
group by shape, a filed conversation drawn in its folder and nowhere else, an orphaned file
reference falling back instead of vanishing, the roster's order kept, a search opening what it
matched without writing the chosen fold away. `sidebar-conversation-sections.test.tsx` then proves
the answer reaches a rendered tree: headers that name their section and count, a chevron that asks
for the section it belongs to, a folded section that keeps its header and hides its rows, a folder
that folds on its own, and a menu row that files the conversation it was opened on.

Two neighbouring fixtures had to learn about the new reads rather than be patched around them:
the sidebar now reads its folders, so the pagination suite seeds that read empty and keeps counting
channel requests only, and the preferences suite's mocked save answers with every field the server
sends, `sidebarSections` included.
Says where a conversation is drawn and why, what a folder is (one person's filing, not a property of
the conversation), why the join table was chosen over a nullable `project_id` on `channels`, and what
each route may do to whom — including that a folder names conversations by id and never by title, so
it cannot become a second way to read one. Also records that the fold lives on the account rather
than in the browser, and that a search opens what it matched without overwriting the chosen fold.
`ChannelSummary` now carries `memberCount` and `projectId`, and `UserPreferences` carries
`sidebarSections`, so the fixtures that spell those objects out were missing the new fields and the
tests type-check gate said so. The values are the ones the store produces for those fixtures: one
member and no folder on a channel whose creator is its only member, and every section open on an
account that has folded nothing.
… the panel beside it

The right-click menu was the only way in, and a menu nobody is told about is
a menu nobody finds. A row now carries its channel so a folder's header takes
the drop, and the panel beside an open conversation (and a group's People
dialog) has a Move to project button with the same offers and the same
refusals as the menu.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant