Skip to content

Draw the sidebar with folders, and drag channels into them - #401

Merged
sivert-io merged 2 commits into
mainfrom
claude/sidebar-folders-client
Sep 5, 2026
Merged

Draw the sidebar with folders, and drag channels into them#401
sivert-io merged 2 commits into
mainfrom
claude/sidebar-folders-client

Conversation

@sivert-io

@sivert-io sivert-io commented Sep 5, 2026

Copy link
Copy Markdown
Member

Second of three, on top of Gryt-chat/server#137. The mobile half follows.

The gesture

axis="y" pins the row to the column, but the pointer isn't pinned, and
info.offset.x still reports where it went. So the row stays in line while the
cursor decides the depth.

  • right past 24px, into the folder above
  • left past 24px, back out to the top level
  • anything between, keep the folder it already had

That last one is what makes dragging a channel up and down inside its own folder
work. Without it, every reorder within a folder would eject the thing being
reordered.

The row draws itself indented the moment it crosses the threshold, before the
drop, so the folder it's about to join is visible rather than guessed at.

Folders as accordions

Clicking a folder opens and shuts it and does nothing else, so it never steals
the selection from the channel you're reading. Collapse state is per server, per
device, in localStorage. Two people looking at the same sidebar can have
different folders open, and an operator collapsing one shouldn't fold it up for
everybody.

What a shut folder says

You asked whether the folder should light up when the channel you're in is
inside it. Yes, and the reason is already in this file. renderChannel carries
this:

Ghost unless it is the one you are in. Every row was a plain <Button>, which
is the filled accent one, so the whole list read as selected and the channel
you were actually in was invisible — "you are here" was the one thing the list
stopped saying.

Collapsing a folder removes that row from the DOM, so the same failure comes
back another way. The folder carries it instead: accent fill when it holds the
open channel, and the unread dot and mention count of its hidden children rolled
onto it. Without the rollup, collapsing a folder would quietly mute everything
in it, which somebody would do by accident and then not be able to explain.

All of that applies only while shut. Open, each child says it for itself, and a
lit folder above already-lit rows says the same thing twice.

sidebarTree.ts

Flatten, drop resolution and payload building are a module rather than more of
ChannelList, because they're the part with cases in them and the component is
already 468 lines.

The failures worth pinning are the ones that drop a row rather than misplace it,
since a channel is only on screen if a row is drawn for it:

  • an orphan whose folder is gone goes to the top level
  • a parent that isn't a folder is the same case
  • a child of a collapsed folder was never drawn, so it isn't in the visible
    order, and it has to be put back into the payload after its folder or the
    server would renumber it out of the folder

What to look at

resolveDropParent, and the rule that a top-level non-folder row ends the run.
Without it, a channel dropped below an unrelated channel would silently join a
folder further up the list.

What I checked

tsc -b and eslint clean. check-sidebar-tree.mjs has 18 assertions across 13
cases, wired into CI, covering the flatten, all three drag outcomes, and both
payload traps.

The whole client CI set passes apart from test:updater-bridge and
test:embedded-version, the two already broken on main and confirmed failing on
a clean checkout without this branch.

I did get this running against a live server in the end. Creating a folder from
the right-click menu works, and it lands in SQLite as a folder row. Children
render indented 14px under their parent. Collapsing hides them and persists to
gryt_sidebar_collapsed:<host>. A collapsed folder holding the open channel
takes bg-gryt-accent and paints #968ff8 on #0c0a20.

Two things I couldn't check there. The drag gesture doesn't respond to synthetic
pointer events, so resolveDropParent is still only covered by its unit tests.
And the preview pane freezes requestAnimationFrame while it's hidden, so exit
animations never finish and rows stay on screen after a collapse. That's the
harness rather than the code, and it sent me chasing a bug that wasn't there.

🤖 Generated with Claude Code

A folder is a row you click to open and shut. A channel goes in by being dragged
right past a threshold, and all the way left brings it back out. Straight up and
down keeps whatever folder it was already in, so reordering inside a folder does
not throw things out of it.

Reorder used to send a bare list of ids. One drag now changes the order and the
folder together, so it sends both, with every entry naming its folder outright.

The flatten-and-drop logic is its own module because it is the part with cases
in it. An orphan whose folder is gone goes to the top level rather than
disappearing, and a child of a collapsed folder is put back into the payload
after its folder, so collapsing one and dragging something else does not empty
it.

A shut folder carries what its children cannot say for themselves, which is the
selected state when the open channel is inside it plus the unread dot and
mention count rolled up. renderChannel already carries the note about "you are
here" being the one thing the list stopped saying, and closing a folder around
the open channel is that failure by another route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Set how loud a server, a folder or a channel is

Three scopes, most specific first: a channel's own setting beats its folder's,
which beats the server's, which falls back to hearing everything. Muting a
server therefore quietens it without overruling a channel somebody has already
had an opinion about.

Kept on the device and never sent anywhere. A list of the channels somebody has
muted is a list of what they are avoiding, and the server has no use for it:
every notification this gates is produced by the client, from events the server
was going to send regardless. The cost is that it does not follow you to another
machine, which is worth saying plainly.

"Only mentions" is a real level rather than a quieter way of saying none.
chat:new carries no mention flag, so the sound for that level comes off
mention:new, which the client already receives to keep its counts. Two listeners
on one event, answering different questions.

Unread is marked whatever the level says. Muting a channel is about not being
interrupted, not about pretending nothing happened there.

The menu is no longer manage-only. How loud a channel is belongs to the person
hearing it, so somebody who cannot rearrange the sidebar still gets the
notification choice and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Sort the notificationPrefs export

simple-import-sort wanted it above singletonHook, and CI runs eslint with
--max-warnings 0, so the branch was failing lint on this one line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@sivert-io
sivert-io merged commit 507d77b into main Sep 5, 2026
2 checks passed
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