Skip to content

cmux NEXT sidebar: chat history (Earlier) and pinning under the session tree - #22323

Merged
teamleaderleo merged 50 commits into
feat-cmux-nextfrom
feat/next-sidebar-chat-history
Oct 21, 2026
Merged

teamleaderleo merged 50 commits into
feat-cmux-nextfrom
feat/next-sidebar-chat-history

Conversation

@teamleaderleo

@teamleaderleo teamleaderleo commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Parity gap #6 in manaflow-ai/cmuxterm-hq#2569: the sidebar has no persistent session list. This builds on the session tree (#21814, behind sidebar.sessionTree, off by default) and stays behind the same flag. The flag default is unchanged.

What it does

With sidebar.sessionTree on:

  • Earlier: chats that no open workspace on this Mac shows (ended, closed, or never opened here) stay listed under the tree in a quiet, foldable Earlier block, newest first. It shows 20 at a time, with Show More for the next page. Each row has the chat's title and a faint age. There's no kind icon, and rows are compact.
  • Pinned: a row's right-click menu pins or unpins the chat. Pinned chats list above the workspaces in pin order. Pins and the Earlier fold persist in UserDefaults.
  • Resume: clicking a row resumes the chat through the app's shared Open Chat path (chatsOpener.open(key)).
  • Cost: it reads metadata only from acpmux's chat index (the Chats feed mirror). It never loads a transcript. The index is read lazily and stops once the page plus one more chat is found, so hundreds or thousands of chats cost one short pass. Rows are made only for the shown page.
  • A chat counts as live, and so stays out of Earlier, when an agent tab in an open workspace runs an acpmux session that continues it (the lane's new chat key, harness:sessionId, which mirrors acpmux's session_chat_key).
  • Filtering hides both blocks. There's no project or repo grouping (turned off in the 10-08 meeting).

Testing

  • SidebarChatHistoryTests (boundary tests): paging and Show More, the live and pinned exclusions, the fold, pin persistence across a fresh SidebarChatHistory on the same defaults, layout placement (Pinned above the first computer, Earlier under the last, nothing while filtering or with the tree off), and row click and menu actions through the real views.
  • Swift build and test run in CI. This Mac is shared and heavily loaded, so nothing was built locally.

Proof

No capture: Earlier and Pinned sit behind sidebar.sessionTree (off by default) and list only chats in acpmux's chat index. A capture slot's fresh HOME has no chats, so a capture would show an empty block. SidebarChatHistoryTests drives the real views.

🤖 Generated with Claude Code

Agent session: cosmic-gray


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Adds chat history and pinning to the session-tree sidebar, behind the same sidebar.sessionTree flag (default unchanged).

With the flag on, chats that no open workspace shows stay listed in a foldable Earlier block, newest first, 20 at a time with Show More; pinned chats list above the workspaces in pin order. A row's right-click menu pins or unpins a chat, and clicking a row resumes it through the shared Open Chat path. Pins and the Earlier fold persist in UserDefaults.

  • Pinning runs the registered sidebar.chat.togglePin action, so the row menu, the palette, and cmux action run share one handler; the menu row is retitled Pin or Unpin for its chat.
  • Reads metadata only from acpmux's chat index, lazily and stopping after one page, so hundreds or thousands of chats cost one short pass; no transcripts load.
  • A chat stays out of Earlier while an agent tab in an open workspace runs a session that continues it.
  • A group fold now lays the list out again while either history block shows, so opening or closing a group no longer shifts workspace rows under Earlier or leaves a gap.

Written for commit 6a733e1. Summary will update on new commits.

View guided diff Turn on auto-fix

teamleaderleo and others added 30 commits October 18, 2026 23:26
…(sidebar.sessionTree)

Claude desktop's Code tab, behind a setting that is off by default: a
workspace whose agent another workspace's agent started (acpmux spawnedBy)
lists under it on a thin guide line, each row carries its pull requests
(the branch PR of each session folder, as the chat header's chip reads it)
in a green, orange or purple badge, and the first rows show their
Command-number keycaps. The Command-1...9 order follows the drawn order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…idebar-session-nesting

# Conflicts:
#	Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings
- regenerated webviews/src/pages/settings/generated/strings.json

Autofix-Run: https://github.com/manaflow-ai/cmux/actions/runs/39176969610
CmuxNextApp may not grow: the feed moves to CmuxNextAgentLanes (the app
passes its workspaces), and the gh pull request reader moves next to its
model in CmuxNextAgentPane so the chat chip and the sidebar share it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The moved gh reader's process holder carries its reviewed crash-allow
(only the thread-safe isRunning and terminate() cross threads), and the
sidebar asks each folder's pull request at most every five minutes, so
it never stalls the chat header's chip on the two shared gh slots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Generated only: the fleet settings export (cmux-ci step
b05e67a26082185e11778f94, run-swift-testing-suites.sh export-settings at
7ad7c4e; CmuxNextSettings is unchanged since), the defaults snapshot
(defaults_guard.py snapshot) and the settings page strings (gen-strings.mjs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…idebar-session-nesting

# Conflicts:
#	schemas/settings-export/settings-schema.sources.sha256
#	schemas/settings/settings-schema.json
…er the base merge

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…merge

Generated only: the fleet settings export (cmux-ci step
69f7e53cebcd649429577bcd, run-swift-testing-suites.sh export-settings at
215bf61; CmuxNextSettings is unchanged since), the defaults snapshot
(defaults_guard.py snapshot) and the settings page strings (gen-strings.mjs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…idebar-session-nesting

# Conflicts:
#	Packages/macOS/CmuxNext/Sources/CmuxNextAgentActivity/AgentActivityLane.swift
…idebar-session-nesting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e red: it no longer has raw font sizes)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…idebar-session-nesting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…idebar-session-nesting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The base merge in 08374a3 left both try #require(AgentActivityLane.of(...))
calls without their closing parenthesis, so CmuxNextAppTests did not build
(fast tier: "the tests do not build").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…openAppLink (base red: CmuxNextAppTests did not build)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- regenerated docs/mdm/com.manaflow.cmux.json
- regenerated docs/mdm/com.manaflow.cmux.plist
- regenerated docs/mdm/managed-preferences.md
- regenerated schemas/settings-export/settings-schema.sources.sha256
- regenerated schemas/settings/settings-schema.json
- regenerated Packages/macOS/CmuxNext/ci-target-graph.json
- regenerated web/data/cmux-shortcuts.generated.json
- regenerated cmux-tui/crates/cmux-app-host/generated/cmux-app.d.ts
- regenerated schemas/defaults/cmux-next.json

Autofix-Run: https://github.com/manaflow-ai/cmux/actions/runs/39215245191
# Conflicts:
#	Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayoutMetrics.swift
#	docs/mdm/com.manaflow.cmux.json
#	docs/mdm/com.manaflow.cmux.plist
#	docs/mdm/managed-preferences.md
#	schemas/defaults/cmux-next.json
#	schemas/settings-export/settings-schema.sources.sha256
#	schemas/settings/settings-schema.json
…ounts them all (#21823)

* acpmux: test that a session lists the pull requests gh pr create opened

Red: pullRequests is absent, so the summary has [] (no detector yet).

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

* acpmux: record the pull requests each session opens; the sidebar session tree counts them

A tool call whose command runs `gh pr create` is remembered until it ends; the
github.com pull URLs its output prints join SessionMeta.pull_requests, once each,
and SessionSummary exposes them as optional pullRequests. CmuxNext parses them on
AgentActivityLane and SidebarSessionTreeFeed reads each with `gh pr view <url>`
beside the folder's branch PR, counting each pull request once.

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

* acpmux: count only a shell call whose whole command is gh pr create, and only the URL it prints

Review fixes for #21823: a grep or echo mentioning gh pr create, or a chained
gh pr create && gh pr view 9, no longer records links. The command is parsed
(quotes, $( ), heredocs) and must be gh pr create after cd steps and env
prefixes; only the last output line that is just a pull URL is kept, preferring
rawOutput.stdout when the harness separates it.

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

* acpmux: a failed gh pr create records no pull request

gh exits 1 when the branch already has a pull request and prints its URL;
that call opened nothing. Review nit from coord-2 on #21823.

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

* test(acpmux): a gh pr create URL counts only once its call completes

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

* acpmux: a gh pr create URL counts only when its call completes

A harness that streams gh's "already exists: URL" in an in_progress update before the failed one
recorded that pull request; the URL is now held per call and recorded on completed only.

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

* test(next): AgentActivityLane.of takes no title

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

* test(acpmux): gh pr create counts after && steps and with 2>&1

Red: a bare & splits `2>&1` and `&>file` into two commands, and only cd may
precede gh, so the usual `git push && gh pr create --fill 2>&1` is never
recorded.

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

* acpmux: gh pr create counts after && steps and with 2>&1

A redirect's & (2>&1, >&2, &>file) no longer splits the command, and steps
joined to gh by && may precede it, so `git push && gh pr create --fill 2>&1`
records its pull request. ;, ||, | and & before gh still don't count.

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

* cmux NEXT sidebar: pull request hover card on the session tree's PR badge (#21959)

* cmux NEXT sidebar: pull request hover card on the session tree's PR badge

Hovering a row's pull request badge lists each pull request its sessions opened
(acpmux pullRequests plus the folder's branch PR), newest first: title, a state
pill (Conflict / Checks failed / Checks passing / Draft / Merged / Closed),
owner/repo #N and +adds -dels; a click opens it in the focused pane's browser.
It scrolls past eight lines. The badge gains a red failing status. Live state
comes from one batched gh api graphql read per refresh (aliased pullRequest
fields, 50 per query) instead of one gh pr view per URL. Debug Settings toggle
sidebar.pullRequestCard, on by default; native gallery entry with conflict,
checks-failed, mixed, all-merged and empty fixtures; strings in 21 locales.

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

* cmux NEXT sidebar: pull request card review fixes

A failed GraphQL read keeps the last answers (only a null alias clears one);
the query pins --hostname github.com and other hosts fall back to gh pr view
per URL; the card follows its badge when the row is reused or its pull
requests change; a hidden badge never makes the panel and resets its hover;
the workspace card and the pull request card dismiss each other; rows take
first mouse; same-moment pull requests list by number.

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

* cmux NEXT sidebar: pull request card opens only PR pages; settled PRs are read once; failing reads back off

The card opens a URL only when it is https://github.com (or the user's GH_HOST)
/<owner>/<repo>/pull/<n> with nothing else. A merged or closed PR's URL is not
asked again; a URL whose read failed waits freshness doubled per failure, up to
16x. Failed checks show red before a conflict. Review fixes from coord-2.

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

* cmux NEXT sidebar: import Foundation for the PR item host check (ProcessInfo, URLComponents)

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

* test(next): the PR store re-reads open PRs on its own, re-asks closed ones, and keeps answers on partial GraphQL errors

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

* cmux NEXT sidebar: re-read open PRs every five minutes while shown, re-ask closed ones, keep answers on partial GraphQL errors

Review findings on #21814/#21959: a badge never refreshed once its session went idle, so CI that
failed later stayed green; a reopened PR never came back; an SSO or resource-limit error nulled
good answers.

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

* test(next): only an open pull request keeps the sidebar's recheck

Red: a folder never settles, so with the tree on the store re-reads every
folder every five minutes, branches with no pull request included.

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

* fix(next): the sidebar rechecks only open pull requests, and forgets unshown ones

- The five-minute recheck stays armed only while the tree shows an open or
  draft pull request. A folder never settled, so before this every session
  folder was re-read with gh every five minutes, branches without a pull
  request included.
- Answers, read times and failure counts for folders and URLs the tree no
  longer shows are dropped on each pass instead of growing for the app's life.
- The card's state pill uses Metrics.chipCornerRadius, not a capsule.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…er the merge

The merge kept stale copies of the shortcuts export, the devbox rebake list
and the agent-session spec, and a second openAppLink in the test host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…aletteKey.toggleActions)

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

Copy link
Copy Markdown
Collaborator Author

Bead cx-qx5ic created for this PR by the cmux coordinator.

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Owned by orphan-sweep on cosmic-gray · session

teamleaderleo and others added 2 commits October 21, 2026 01:34
#21814 (the session tree this was stacked on) landed as bab4a82, so its
files take feat-cmux-next's version; only this PR's own commit (72a582c,
chat history and pinning) is applied on top. AgentActivityLane keeps base's
unread/project fields beside the new chat key.

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

Copy link
Copy Markdown
Collaborator Author

Review (claude-opus review subagent): BLOCK at 883eff5

The feature logic holds up. Three reds on this head come from this PR's own files, so it can't land as it stands. All three are small fixes.

Blocking (this PR's own reds)

  1. Sources/CmuxNextSidebar/Views/SidebarChatHistoryView.swift:44: Motion.animate(.move, in: list, { ... }) doesn't compile on the current base. f35023e ("master no-animation switch and per-area motion switches") removed the uncategorized Motion statics, and every animation now goes through a category gate. The cmux-next generated files and fast tier jobs fail with type 'Motion' has no member 'animate' / cannot infer contextual base in reference to member 'move', and check-motion flags the same line. Fix: Motion.sidebar.animate(.move, in: list, { ... }), as SidebarLayout+Viewport.swift:80 and SidebarPriorityPanelView.swift:108 do. This error comes from the base merge, not from the original commit.
  2. Sources/CmuxNextSidebar/Views/SidebarChatHistoryRow.swift (SidebarChatHistoryMenuAction.item): NSMenuItem(title:action: #selector(runAction)...) is an inline menu row. scripts/cmux-next/check-menu-actions.py fails cmux-next guards / defaults-snapshot on it ("1 inline menu row(s), 0 allowed"). Pin and Unpin should be registered actions built with ActionRegistry.makeContextMenu (they would also become reachable from the palette, CLI and MCP), or go through the list's existing contextMenuProvider path with a new SidebarContextTarget. Raising the baseline is not allowed: the script says "never add a file".
  3. PR capture check fails: the description has no real capture. This is a visible sidebar UI change, so it needs one (with sidebar.sessionTree on: Pinned above the workspaces, Earlier folded and unfolded, Show More).

The other failures in cmux-next checks (custom-scrollbar CSS, check-switch-fetches, the HomeThoughtHeadline convention error) are outside this diff. They look like base reds and are not counted here.

What I checked and found correct

  • Chat key: AgentActivityLane.chatKey matches acpmux session_chat_key (chats/activity.rs:42): family claude maps to AdapterKind::ClaudeCode (id() = claude-code), codex to codex, and other families get no key. chats/mod.rs:417 formats the key as {harness.id()}:{session_id}. session_summary (hub/views.rs) emits family and agentSessionId. The merge with base's unread/project lane fields is clean, and chat: is the last defaulted init parameter.
  • Paging and laziness: AcpmuxChatsStore.newestFirst is a lazy compactMap/filter over orderedKeys. SidebarChatHistoryState.make stops at page + 1 non-skipped chat (the test checks reads == page + skipped + 1). Pinned chats are O(1) lookups. No transcript is read. When the setting is off, the ObservationStream closure returns before it reads chats, the lanes or the workspaces, so it costs nothing and tracks nothing. ChatsFeed.observe adds no new connection: keepCurrent() already holds one.
  • Rebuild cost: the feed re-runs on any lane, workspace or chat-index change (the session tree feed does the same). Each pass is O(tabs + page + pins), and history.state is only written when the value differs. SidebarHistoryChat has no preview or attention, so activity churn doesn't reload the sidebar.
  • Layout: the history is nil while filtering (SidebarChatHistoryPlacement(filtering ? nil : ...)) and nil with the tree off (o.chatHistory is only set inside if let tree). With no history the height doesn't change (tested). Pinned goes in once, above the first machine section. Earlier goes after the last machine, and Pinned falls back to the end when no machine is placed. Rows never hit-test in the gaps (tested). The views size with the same minimal metrics the layout uses (o.minimal ? base.minimal : base). shift(by:) is wired into the close-scroll anchor.
  • Pin persistence: UserDefaults keys cmux.next.sidebar.sessionTree.pinnedChats and .earlierFolded. Pins keep their order, re-pinning does nothing, and the page limit resets each launch. Covered by tests.
  • APIs on the base all exist: ObservationStream, AgentLaneStore.shared.lanes, tab.agentSession (.host/.session), services.chatsOpener.open(_:), ChatsFeed.observe(_:_:), SidebarChatsDesign.age(from:to:), Strings.pinned, SidebarStyle.*, setString/setColor/setTint, Motion.withoutAnimation. The one exception is Motion.animate (item 1).
  • Localization: all 4 keys (sidebar.chatHistory.earlier/pin/unpin/showMore) are translated in all 21 locales, the same set as sidebar.section.pinned.
  • The flag stays off by default: everything sits behind DesignSettings.shared.sidebarSections.sessionTree, and this PR doesn't touch it.

Non-blocking

  • SidebarChatHistoryView.show uses Strings.pinned ("Pinned"), the same text as the workspace Pinned section's header. A user with both pinned workspaces and pinned chats sees two "Pinned" headers. Consider "Pinned Chats", or folding the chat pins into that section.
  • A pinned chat that is also live shows in Pinned and in the tree. That may be intended. If not, filter live out of state.pinned.
  • A pin whose chat leaves the index is hidden but stays in UserDefaults forever. That's harmless but grows over time, so consider pruning it once the index is ready.
  • The age labels are computed when show runs and only refresh on the next state change. That's fine for "calm", but an idle sidebar can show stale ages.
  • Remote workspaces' agent tabs are skipped (ref.host == localHost). The index is this Mac's, so that's right today; a short comment would help whoever adds per-machine indexes.

Still sensible against the current base: yes. It builds on the merged session tree (#21814) and the existing Open Chat path, and it adds no new connection or poller.

teamleaderleo and others added 2 commits October 21, 2026 02:08
…arlier animates through Motion.sidebar

- SidebarChatHistoryView placed the blocks with Motion.animate(.move, in:),
  which base removed (every animation names its category now); it uses
  Motion.sidebar.animate, like the rest of the sidebar.
- A history row's Pin/Unpin menu ran its own selector, which
  check-menu-actions.py rejects. The row's menu now comes from
  ActionRegistry.makeContextMenu(for: .sidebarItem, target: sidebar-item:<chat
  key>, entries: [sidebar.chat.togglePin]), retitled Pin or Unpin for the
  row's chat. SidebarChatHistoryFeed.bind binds the action to the history's
  pins; the App binds it where it starts the feed. New action title in all
  21 locales; generated action exports are left to the autofix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cfc-663f-7650-b9f7-64d198c6b88b&host=cosmic-gray
The public @observable class's conformance needs the module imported
publicly (fleet app compile, job bea228bd3cfa).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cfc-663f-7650-b9f7-64d198c6b88b&host=cosmic-gray
@teamleaderleo teamleaderleo added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Oct 21, 2026
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Review (claude-opus review subagent): BLOCK at 983c18a

The three items from the 883eff5 review are fixed, and the action wiring is correct. One bug in the feature's own layout path remains: folding a workspace group leaves the Earlier and Pinned blocks where they were. That is the only blocker, and the fix is a few lines.

Blocking

  1. A group fold doesn't move the chat history blocks. SidebarChatHistoryViews.place is called only from the full apply (SidebarListView+Layout.swift:235). Base's spliced fold path never calls it: SidebarListView+Fold.swift:13 (foldGroup) goes to applyFold, and SidebarLayout+Fold.swift:100 (replaceRows(..., shiftingFollowingBy:)) shifts the rows below the group and totalHeight, but not earlierChatsY or pinnedChatsY. setGroupCollapsed doesn't move structureRevision, and the daemon's echo of the same fold is an unchanged set (SidebarModel.sections setter, guard newValue != storedSections), so no full layout follows to correct it. With sidebar.sessionTree on, opening a group therefore pushes the last workspace rows into the Earlier block: the rows overlap, and a click there can hit a history row and resume a chat instead of selecting the workspace. Closing a group leaves a hole above Earlier, and totalHeight shrinks under it, so its bottom can be clipped. Both last until some unrelated change triggers a full apply. The view comment ("They scroll with the rows and move with them") is true for scrolling and full applies, but not for folds.
    Fix, either way works:

    • Simplest: in foldGroup's guard, fall back to reload(animated: true) while displayed.pinnedChatsY != nil || displayed.earlierChatsY != nil.
    • Keeps the cheap fold: shift earlierChatsY and pinnedChatsY in replaceRows when they lie below the spliced range, then call chatHistory.place(displayed, ..., animated: animate) in applyFold.

    Add a test either way: history present, then SidebarLayout.fold on a group above Earlier; expect earlierChatsY to move by the height change, or expect the fold to return false.

Answers to the specific questions

  • Compile risk. Static checks are clean. public import Observation is required for the public @Observable conformance, and it is present. SidebarChatHistoryMenu is public in a public import AppKit file. Under defaultIsolation(MainActor), its non-Sendable static closure var is main-actor isolated, and bind forms the closure on the main actor. These all exist with the signatures used: ActionRegistry.bind(_:isEnabled:invoke:), makeContextMenu(for:target:entries:implied:arguments:), ContextMenuEntry.action, ActionRegistry.menuRun(of:), perform(_:invocation:), ActionRegistry(catalog:aliases:), ActionCatalog.all/legacyAliases (public), AgentActivityLane.of(summary:), and ChatsOpenCoordinator.open(_:inTerminal:replacing:). swiftc -parse passes on every changed Swift file. CI evidence is partial. On 084555e, cmux-next generated files compiled all four new CmuxNextSidebar files without error, then stopped on PageEnginePolicyTests.swift:43 (a base red, fixed on base by f5bbdfe / fix(next): the CmuxNext package compiles again — three one-line breaks on base #23281, which is not in this branch). So CI has not yet compiled SidebarChatHistoryFeed.swift, ChatsFeed+Start.swift or the tests. A base merge that picks up fix(next): the CmuxNext package compiles again — three one-line breaks on base #23281 would let CI check them. That is not a blocker on its own.
  • The menu runs the registered handler on the right target. The row's menu is registry.makeContextMenu(for: .sidebarItem, target: sidebar-item:<chat key>, entries: [.action(sidebar.chat.togglePin)]). Its item's ActionMenuPayload carries that target, and ActionMenuTarget runs it through registry.perform. ActionTargetRef(parsing:) splits on the first colon, so sidebar-item:claude-code:abc round-trips for cmux action run. The retitle (SidebarChatHistoryView.swift:129) sets Pin or Unpin from history.isPinned(key) each time the menu opens, and the test checks both titles and the run. This matches the chief.openSubagent / ChiefPaneTools precedent. check-menu-actions.py passes (38 baselined, none new), and so does check-motion.sh.
  • The surface plan satisfies the contract tests. Palette-exempt with a reason that isn't paletteInternal, and listed in everyActionIsInThePaletteUnlessExempt (.requires is empty, so the paletteOpen equivalence holds). No placements plus a contextMenuExemption, so it is consistent for declarationsAreConsistent, everyTargetKindMenuShowsItsActions and generatedMenusShowExactlyThePlacements. On the reason: .liveInput ("a selected row in a panel") is the closest existing fit. .focusMove would be wrong for a pin toggle. .noTargetSurface says the object has no right-click menu, and this one has one. Acceptable as is.
  • Generated exports. plans/cmux-next/action-surfaces.json and actions.md don't list the new action yet. exportIsFresh/reportIsFresh only run with CMUX_CHECK_GENERATED_EXPORTS/CMUX_UPDATE_ACTION_SURFACES. On feat-cmux-next the PR autofix regenerates them, and the check-action-surfaces.sh step in cmux-next.yml is continue-on-error. Not a failing gate.
  • Localization. action.sidebar.chat.togglePin and the four sidebar.chatHistory.* keys each have all 21 locales. check-l10n.sh reports 0 errors (7776 keys against 7771 on the merged base: the 5 new ones).
  • Merge. git diff fd27504c204c 983c18a5 is exactly this PR's 20 files and is byte-identical to the PR-vs-merge-base diff, so nothing from base was dropped. It also merges cleanly with the current feat-cmux-next (15c4ff4).

Non-blocking

  • SidebarListView+Drag.swift:476: the drag path re-lays out with the drop gap (which moves earlierChatsY) but doesn't place the history either, so during a drag the rows under the gap slide into the Earlier header. It's brief and corrected on drop, but the fix for item 1 could cover it too.
  • SidebarChatHistoryFeed.swift:65: a run with no target, or with a target that isn't a sidebar-item, returns silently. Base just made chief.openSubagent refuse visibly in this case (9a8b323, cmux-next Chief: chief.openSubagent focuses, and refuses visibly with no target #23108: registry.refuse / refuseNotFound). Doing the same here keeps cmux action run sidebar.chat.togglePin from reporting success while doing nothing. The handler also pins any string, including keys the index doesn't have.
  • SidebarChatHistoryView.swift:125-130: the per-target title could use the registry's mechanism, ActionTargetTitles.set(togglePinAction, in: registry) { ... } in bind, instead of rewriting the first item in the view. Pin/Unpin Tab and Mute/Unmute already work that way, and it would keep the view free of menu-item surgery.
  • CLI .exempt(.guiOnly): pinning a chat by key is scriptable, so a sidebar chat pin verb could be offered later. The exemption is defensible for now.
  • Still open from the last review, none blocking: two "Pinned" headers (SidebarChatHistoryView.swift:108 reuses Strings.pinned); a pinned chat that is also live is listed twice; pins of chats that left the index are never pruned; age labels refresh only when the list changes.
  • FYI, outside this diff: base's SidebarModel.listOptions(), which the spliced fold uses, carries no sessionTree, so a fold rebuilds the group's members without the tree's nesting order.

Capture (not part of the code verdict): the description still has no capture ("Screenshot to follow"). pr-capture-check was superseded on the last run. This is visible UI, so it needs one when a capture slot with chats in the index is available.

Guards run locally on the exported head and on the merged base: check-l10n, check-motion, check-concurrency, check-menu-actions.py, check-module-resources and check-swift-type-scale pass on both. check-scrollbars fails identically on both (a base red).

@blacksmith-sh

This comment has been minimized.

…n all-static enum

The conventions check refused the namespace enum SidebarChatHistoryMenu. The menu
factory is now an instance hook on the history the rows already read, like onOpen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cfc-663f-7650-b9f7-64d198c6b88b&host=cosmic-gray
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Review (claude-opus review subagent): BLOCK at 1ca6506

The new menu hook is correct and the 883eff5 blockers stay fixed. The one blocker from the 983c18a review is still open: neither commit since then (9f75c7f, the base merge, and 1ca6506, the hook move) touches the fold path.

Blocking

  1. Folding a workspace group still leaves the Earlier and Pinned blocks where they were. Same bug as in the 983c18a review, unchanged at this head:
    • SidebarChatHistoryViews.place is still called only from the full apply (Sources/CmuxNextSidebar/Views/SidebarListView+Layout.swift:235).
    • foldGroup (Sources/CmuxNextSidebar/Views/SidebarListView+Fold.swift:13) splices the fold through displayed.fold, then replaceRows(..., shiftingFollowingBy:) (Sources/CmuxNextSidebar/Logic/SidebarLayout+Fold.swift:100, Logic/SidebarLayout.swift:46). That moves the rows below the group and totalHeight, but not earlierChatsY or pinnedChatsY, and applyFold never calls chatHistory.place.
    • Result, with sidebar.sessionTree on: opening a group above Earlier pushes workspace rows under the Earlier block, and a click there can resume a chat instead of selecting the workspace. Closing a group leaves a gap above Earlier, and the shrunken totalHeight can clip the bottom of the block. Both last until some unrelated change triggers a full layout.
    • Either fix works. The simplest is to fall back to reload(animated: true) in foldGroup's guard while displayed.earlierChatsY != nil || displayed.pinnedChatsY != nil. To keep the cheap fold instead, shift those two y values in replaceRows when they lie below the spliced range, and call chatHistory.place(displayed, ...) from applyFold. Add a test either way: with history present, fold a group above Earlier, then expect earlierChatsY to move by the height change (or expect the fold to return false).

The earlier blockers

The new hook (1ca6506)

  • Retain cycles: none. history.makeMenu captures registry weakly and reads the static togglePinAction. It doesn't capture history. The registry's bound handler captures history weakly. The row's makeMenu captures the view weakly. SidebarChatHistory doesn't own the registry, and the registry doesn't own the history.
  • MainActor: SidebarChatHistory is @Observable @MainActor, the property is @ObservationIgnored public var makeMenu: (@MainActor (String) -> NSMenu?)?, and it is assigned in SidebarChatHistoryFeed.bind (the class is @MainActor) and called from the row's menu on the main thread. That is consistent, and it matches onOpen. @ObservationIgnored is correct, so setting the hook triggers no re-layout.
  • Tests: nothing references SidebarChatHistoryMenu any more. earlierMakesRowsForThePageOnlyAndRowsOpenAndPin binds into its own history and sets view.history = history, so the menu comes from that instance. This is better than the old static: a test no longer leaves a process-wide hook behind for other suites. Production wiring is consistent: ChatsFeed+Start.swift:27 binds into services.registry with the default .shared, and the view defaults to .shared too.
  • Minor: Model/SidebarChatHistory.swift now has public import AppKit for NSMenu, which makes it the only file in CmuxNextSidebar/Model that imports AppKit. That's fine (the module is AppKit already), and I mention it only in case you'd rather keep the model UI-free.

Generated files, strings, registry

  • The PR carries no bot-only or generated files: no ci-target-graph.json, no settings exports, no tests/test-execution.toml. Its 20 files are its own sources, tests and two xcstrings catalogs.
  • plans/cmux-next/action-surfaces.json and plans/cmux-next/actions.md have no sidebar.chat.togglePin rows at this head (they aren't on base either). The PR autofix should add them, so this doesn't block.
  • xcstrings: both catalogs are pure additions against the merge base 4a6ef5b (git diff --numstat: +132/-0 and +524/-0, one hunk each). SidebarSectionActions.xcstrings gains action.sidebar.chat.togglePin (73 to 74 keys), and the sidebar Localizable.xcstrings gains sidebar.chatHistory.{earlier,pin,unpin,showMore} (163 to 167 keys). All five have 21 locales.
  • test-execution.toml: the PR adds no entries, and needs none. The registry covers the Python tests under tests/, and the new Swift suite lives in the existing CmuxNextAppTests target.

Should-fix (not blocking)

Checked against: merge base 4a6ef5b and current feat-cmux-next d99aceb. GitHub reports the PR MERGEABLE. CI on this head was pending with 0 failures when I looked. I ran no builds or tests here.

…ry blocks show

The fold splice moved the rows but not pinnedChatsY/earlierChatsY, so opening a group
pushed workspace rows under Earlier and closing one left a gap. With either block placed,
SidebarLayout.fold now declines and the list takes its full layout. Test included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cfc-663f-7650-b9f7-64d198c6b88b&host=cosmic-gray
@cursor

cursor Bot commented Oct 21, 2026

Copy link
Copy Markdown

Bugbot needs on-demand usage enabled

Bugbot uses usage-based billing for this team and requires on-demand usage to be enabled.

A team admin can enable on-demand usage in the Cursor dashboard.

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Review (claude-opus review subagent): SHIP at 6a733e1

6a733e1 is one commit on top of 1ca6506, which I reviewed in the comment above. It fixes the one blocker left from that review: a group fold no longer leaves the Earlier and Pinned blocks behind. Nothing else in the code changed, so the rest of the 1ca6506 review still holds.

The fold fix

  • SidebarLayout.fold (Logic/SidebarLayout+Fold.swift:31) now returns false when pinnedChatsY or earlierChatsY is set. The check sits right after the first guard, before any row is touched, so the layout is unchanged when it returns false. That keeps the in-place contract foldGroup relies on.
  • fold has one caller, foldGroup (Views/SidebarListView+Fold.swift:24). On false it calls reload(animated: true), which runs the full apply, and the full apply calls chatHistory.place (SidebarListView+Layout.swift:235). Opening or closing a group therefore re-places both blocks and fixes totalHeight, so rows can't slide under Earlier and no gap is left on close. folding wraps fold and returns nil in the same case.
  • With the tree off, with no pins, or with an empty Earlier, both y values stay nil (SidebarChatHistoryPlacement places nothing when a block's height is 0). The cheap spliced fold is unchanged there.
  • Test SidebarLayoutFoldTests.chatHistoryLaysTheListOutAgain: the history is laid out under a machine section, it checks earlierChatsY != nil, then expects folding on g1 to return nil. It would fail without the new guard: the same fixture folds g1 by splicing in foldMatchesAFullLayoutAtEveryStep, and setting sessionTree with empty parents doesn't change the member count. SidebarSessionTreeState() and SidebarChatHistoryState.make(newest:chat:live:pinned:limit:folded:) match their declarations. The Foundation import is for Date.distantPast.

Still true from the 1ca6506 review

  • The menu hook is fine: no retain cycle, and the MainActor typing is consistent. No test references the old enum.
  • The PR carries no generated or bot-only files. Outside Swift it changes only the two xcstrings catalogs, and both are still pure additions (5 new keys, 21 locales each).
  • plans/cmux-next/action-surfaces.json and actions.md still lack the sidebar.chat.togglePin rows. The autofix adds them, so this doesn't block.
  • No test-execution.toml entries are needed.

Should-fix (not blocking)

  • The PR body still has "Stacked on cmux NEXT sidebar: session tree with PR badges and keycaps (sidebar.sessionTree, off by default) #21814's branch ..." (line 3) and "Screenshot to follow ..." (line 22) when I read it after this push. A bot may have rewritten the body. The No capture: line is there, which is what the capture gate needs.
  • Cost: while the history shows, every group fold now runs a full layout instead of the splice, so the fold work grows with the number of rows again for session-tree users with pins or Earlier chats. That's fine while the flag is off by default. Before the tree goes on by default, consider the cheap version: shift the two y values in replaceRows and call place from applyFold.
  • Carried over, none blocking: the drag gap doesn't move the history blocks, togglePin returns silently on a bad target, there are two "Pinned" headers, a pinned chat that is also live is listed twice, and pins of chats that left the index are never pruned.

CI on this head was pending with 0 failures when I looked. I ran no builds or tests here.

@teamleaderleo teamleaderleo added the ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds label Oct 21, 2026
@teamleaderleo
teamleaderleo merged commit efe95ba into feat-cmux-next Oct 21, 2026
@teamleaderleo
teamleaderleo deleted the feat/next-sidebar-chat-history branch October 21, 2026 14:01
teamleaderleo added a commit that referenced this pull request Oct 21, 2026
Two conflicts, both additive:
- WorkspaceRowContent keeps base's Priority `chip` (#23390) beside the tree's
  `treeFold` and `rollUp`.
- SidebarView+Observation keeps base's chat history state (#22323) and reads
  the tree as `SidebarSessionTree.shared.drawn` (live state plus the folds).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15d2c-88e4-7fc2-b58e-9a55abdaced8&host=cosmic-gray
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

teamleaderleo added a commit that referenced this pull request Oct 22, 2026
…yle everywhere else

sidebar.chat.togglePin (#22323) kept "Pin or Unpin Chat" while every other
toggle reads X/Y; it is keyboard-only, so Keyboard Shortcuts settings showed
it. All 21 locales follow their Pin/Unpin Chat in Priority… wording; the row's
right-click menu still says Pin or Unpin by the chat's state.
plans/cmux-next/action-surfaces.json is generated; PR autofix regenerates it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15bd2-60d2-70e1-8179-d3aa717c8828&host=cosmic-gray
teamleaderleo added a commit that referenced this pull request Oct 22, 2026
…est covers every action (#23722)

* test(cmux-next): every action title, keyboard-only too, uses one toggle style (red)

PaletteMenuVocabularyTests checked the X/Y toggle style only on palette
actions, so the keyboard-only sidebar.chat.togglePin kept "Pin or Unpin Chat",
which Keyboard Shortcuts settings shows. The toggle rule now covers every
catalog action; red on that title.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15bd2-60d2-70e1-8179-d3aa717c8828&host=cosmic-gray

* cmux NEXT sidebar: Pin or Unpin Chat is Pin/Unpin Chat, the toggle style everywhere else

sidebar.chat.togglePin (#22323) kept "Pin or Unpin Chat" while every other
toggle reads X/Y; it is keyboard-only, so Keyboard Shortcuts settings showed
it. All 21 locales follow their Pin/Unpin Chat in Priority… wording; the row's
right-click menu still says Pin or Unpin by the chat's state.
plans/cmux-next/action-surfaces.json is generated; PR autofix regenerates it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15bd2-60d2-70e1-8179-d3aa717c8828&host=cosmic-gray

* test(cmux-next): the toggle style holds after a title's prefix too (red)

Palette: Show or Hide Actions and Developer Tools: Start or Stop Performance
Recording slipped past the start-anchored pattern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15bd2-60d2-70e1-8179-d3aa717c8828&host=huge-blue

* cmux NEXT palette: Show/Hide Actions and Start/Stop Performance Recording, the toggle style

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15bd2-60d2-70e1-8179-d3aa717c8828&host=huge-blue

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds risk:normal Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant