Skip to content

feat(web): new threads follow the sidebar project filter - #162

Merged
incognitojam merged 5 commits into
mainfrom
t3code/new-thread-follows-project-filter
Aug 16, 2026
Merged

feat(web): new threads follow the sidebar project filter#162
incognitojam merged 5 commits into
mainfrom
t3code/new-thread-follows-project-filter

Conversation

@incognitojam

@incognitojam incognitojam commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Note

Selecting a project in the sidebar filter now decides where the sidebar's New thread button puts the next thread, instead of asking. ⇧⌘N still starts a thread beside the one you have open, and ⌘N still opens the chooser, so a filter never boxes you in. Also fixes command palette shortcut labels that have pointed at the wrong command since pingdotgg#4026.

Before / after

Sidebar New thread, filtered to orbital-freight. The button used to open the project chooser and advertise ⇧⌘O plus a shift+click hint; it now simply creates in the filtered project.

Before After
Tooltip reading New thread (Shift Cmd O) with a second line offering Shift+click for the current project Tooltip reading New thread with no shortcut and no second line

Command palette, same state. The direct-create row advertised the picker's shortcut and the picker advertised none.

Before After
Palette rows: New thread in orbital-freight with Shift Cmd O, New thread in... with no shortcut Palette rows: New thread in orbital-freight with Shift Cmd N, New thread in... with Shift Cmd O

Screenshots use a synthetic three-project fixture.

The problem

The sidebar's project filter narrowed the thread list but had no say in where new threads went. With a project selected, New thread still opened the project chooser, and creation resolved through active thread → active draft → first project, ignoring the filter entirely. Mobile has done the right thing here for a while (resolveDraftProjectSelection returns preserve on an explicit filter); web hadn't caught up.

Which project a new thread lands in

The filter's precedence depends on which affordance is asking. resolveThreadActionProjectRef takes an explicit origin for this, defaulting to "contextual" so a caller that doesn't think about it gets the conservative behavior.

Entry point Precedence Why
Sidebar New thread Filter → viewed thread → default The button belongs to the filtered list, so the new draft always appears in the list you're looking at. One behavior per state — shift+click does not divert it
⇧⌘N chat.newLocal, palette New thread in X Viewed thread → filter → default "Start another one right here." The filter beating the default means that with nothing open you land in the project on screen, not the top of the project list
⌘N / ⇧⌘O chat.new Asks, whenever there is more than one project Unchanged. A filter does not suppress the chooser — that is what keeps a deliberate pick available

So with a thread open in A and the filter on B, the button gives you B and ⇧⌘N gives you A. Both doors stay open without clearing the filter first.

Within a filtered group the member you're already in still wins, so a group spanning environments — the same project local and remote — doesn't snap back to its representative.

The filter moved from useState in AppSidebarLayout into a small store. It had to: the new-thread shortcuts live in the _chat route and the command palette mounts at the root, both outside the sidebar tree. That also retires the lifted-above-the-mobile-sheet workaround, and leaves persistence as a self-contained follow-up — the reset guard below is the part that had to land first, since persisting a filter that clears itself on every load would be worse than not persisting it.

Shortcut labels

No keybindings changed — packages/shared/src/keybindings.ts is untouched. What changed is which shortcut each affordance advertises. Defaults: chat.new⌘N and ⇧⌘O; chat.newLocal⇧⌘N.

Affordance State What it does Label before Label after
Sidebar New thread 1 project Creates directly ⇧⌘O ⇧⌘O
Sidebar New thread 2+ projects, no filter Opens picker ⇧⌘O, plus "in current project: Shift+click (⇧⌘N)" unchanged
Sidebar New thread 2+ projects, filtered Creates in filtered project (was: opens picker) ⇧⌘O, plus shift line (none), shift line dropped
Palette New thread in X Legacy sidebar, or 1 project Creates directly ⇧⌘O ⇧⌘O
Palette New thread in X Default sidebar, 2+ projects Creates directly ⇧⌘O ⇧⌘N
Palette New thread in... Default sidebar, 2+ projects Opens picker (none) ⇧⌘O
Palette New thread in... Legacy sidebar, or 1 project Opens picker (none) (none)

While filtered, the button names no shortcut: chat.new still opens the chooser and chat.newLocal follows the thread on screen, so neither is an honest twin.

Pre-existing bug (the ❌ rows)

action:new-thread has carried shortcutCommand: "chat.new" since the palette landed in pingdotgg#1103, when chat.new did create directly. pingdotgg#4026 (Sidebar v2 beta) made chat.new open the picker with the new sidebar and 2+ projects, and the label was never updated — so the direct-create item has advertised the picker's shortcut ever since, while the picker itself advertised nothing. Not a regression from this branch; it just lives in the code this branch touches.

Not changed

chat.new has two default bindings, mod+n and mod+shift+o. Label resolution walks the list backwards so later bindings shadow earlier ones — which is what lets a user override beat a default — so ⌘N works but is never shown anywhere in the UI. Surfacing it means reordering those two lines, a real keybinding change, so it's left alone here.

Surfaces

  • Entry points: sidebar button, chat.new / chat.newLocal, and the palette, where contextualProjectRef feeds both the picker's ordering and the "New thread in X" label.
  • Clients: web; desktop inherits. Mobile already behaved this way and is untouched — its only sidebar new-thread affordance is per-project group headers, which carry an explicit project.
  • Legacy sidebar: the palette's label rule mirrors the route's !legacySidebarEnabled && projectGroups.length > 1 check, so legacy users (where chat.new still creates directly at any project count) keep the old labels.
  • Reverse state: All projects clears the filter and restores the chooser.
  • Docs: docs/user/thread-sidebar.md gains a "Filtering by project" section — the filter wasn't documented at all. docs/user/keybindings.md claimed a new thread inherits "the project you were in", which the filter now outranks when nothing is open.

Review

An independent review pass (oracle) approved the split precedence — it argued a uniform "filter always wins" breaks chat.newLocal and a uniform "context always wins" creates drafts with no visible row — and found two bugs, both fixed here:

  • Shift+click was captured by the filter. It created in the filtered project like a plain click, leaving the button two ways to reach the filter and none to reach the thread on screen. Resolved by giving the button one behavior per state instead of a modifier that diverts it; chat.newLocal is the escape.
  • The filter healed itself too eagerly. The reset effect cleared any scope whose group it could not resolve, but an empty group list means projects have not loaded yet. Every remount or reconnect could drop the filter — the exact thing moving it into a store was meant to prevent, and a guaranteed "resets on reload" once it is persisted. shouldClearProjectScope now waits while the group list is empty. This one predates the branch.

Verification

vp test run on chatThreadActions.test.ts, Sidebar.logic.test.ts, and CommandPalette.logic.test.ts — 135 pass, including new cases for filter precedence per origin, the filter beating only the default for contextual commands, staying on the contextual group member, and the reset guard waiting on unloaded projects. Web typecheck and targeted lint clean.


Written by an agent (Claude Code, claude-opus-5).

Picking a project in the sidebar filter narrowed the list but had no say in
where the next thread went, so the new thread button still asked which project
you wanted and creation fell back to the active thread's project.

The filter now outranks the viewed thread when resolving a new thread's
project, and the button creates in place instead of opening the picker. Within
the filtered group the contextual member still wins, so a group spanning
environments does not snap back to its representative. chat.new keeps opening
the chooser — chat.newLocal is already the "create in the current project"
command — and the tooltip follows whichever of the two the button matches.

Also corrects the command palette's new-thread shortcut labels, which have
advertised the picker's shortcut on the direct-create item since pingdotgg#4026 made
chat.new open a picker.
Making chat.newLocal honor the sidebar filter left no way to start a thread
beside your current work while the sidebar was narrowed elsewhere, which is
the whole point of the command.

The filter's precedence now depends on which affordance is asking. The
sidebar button belongs to the filtered list, so the filter beats the thread
you are viewing and the new draft always shows up in the list you are looking
at. chat.newLocal and its palette twin are the other way round: the thread you
are viewing wins, and the filter only beats the fallback default so that with
nothing open they still land in the project on screen.

The two can now resolve differently, so the button's tooltip names
chat.newLocal only while both land in the same project.
… healing early

Two findings from an independent review of this branch.

Shift+click is the mouse twin of chat.newLocal, but a project filter captured
it along with the plain click, so the button had two ways to reach the
filtered project and none to reach the thread on screen. It now resolves
contextually, and the tooltip's shift+click line reappears whenever the
modifier changes where the thread lands rather than only while the picker is
in play.

The filter's self-healing reset cleared any scope whose group it could not
find, and an empty group list means projects have not arrived yet rather than
that the filter is stale. Every remount or reconnect could silently drop the
filter, which is exactly what moving it into a store was meant to prevent, and
would have become "resets on every reload" once the filter is persisted.
Shift+click resolving differently from a plain click meant the button's rule
could not be stated without narrating an invisible mode, and the divergence
only ever showed up while the filter pointed away from the thread on screen.
The button now always follows the filter; chat.newLocal remains the way to
start a thread beside the one you are viewing.

That removes the tooltip branch describing the split and newThreadOriginsAgree
with it. While filtered, the button names no shortcut, because chat.new still
opens the chooser and chat.newLocal follows the thread on screen.
@incognitojam
incognitojam force-pushed the t3code/new-thread-follows-project-filter branch from b82e2cf to c63b9bd Compare August 16, 2026 09:22
@incognitojam
incognitojam enabled auto-merge (squash) August 16, 2026 09:23
@incognitojam
incognitojam merged commit efe5411 into main Aug 16, 2026
8 of 9 checks passed
@incognitojam
incognitojam deleted the t3code/new-thread-follows-project-filter branch August 16, 2026 09:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant