Skip to content

Agent chat: links open on the right, never over the chat - #23908

Closed
teamleaderleo wants to merge 8 commits into
feat-cmux-nextfrom
chat-links-side
Closed

teamleaderleo wants to merge 8 commits into
feat-cmux-nextfrom
chat-links-side

Conversation

@teamleaderleo

@teamleaderleo teamleaderleo commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Clicking a GitHub link in an agent chat opened it as a new tab in the chat's own pane, covering the chat. With a browser in a pane on the left, links could land there instead of beside the chat.

A web link from an agent chat (a reply's link, a pull request chip, the hover card's Open) now follows the existing Open Links and Files setting (layout.linkPlacement), the one key app-wide for where links open:

  • Split Right (the default, so "on the right" for everyone) and New Column on the Right: the link opens in a pane right of the chat: the one the person focused last, else the rightmost (its right edge, top first). With no pane on the right, a new split (or column) opens on the chat's right. A pane left of the chat never gets the link, even one showing a browser, and neither does a pane showing another agent chat, so no chat is ever covered.
  • Tab in the Same Pane: "in the agent pane": a tab in the chat's own pane. Main pane layout never docks the chat or sends the link to its column under this setting. In the chat dock, which holds only chats, the rules for any page apply.

A zoomed chat fills the screen and hides the panes beside it, so its link never goes to one of them: on the right it opens a new split (or column), which ends the zoom (the daemon clears zoomed_pane on a new split), so the tab is always visible. A link from a hidden chat (another pane zoomed) is not a visible click and keeps the old rules.

From the chat dock, which never splits, a link with no pane on the right to take it (every one shows another chat) opens a new column beside the dock. On a machine whose daemon serves no browser tabs (frontend-browser-tabs-v1), the link's tab is session-local and cannot be moved, so a link that would need a new split or column keeps the rules for any page and opens selected, as on base; a pane on the right, or the chat's own pane, needs no move and still gets it.

Command-click still opens a background tab in the same place, without focusing it. Tabs can be dragged anywhere afterwards; nothing moves them back. Under main pane layout, a chat alone on its screen still docks left, so the link lands in the column that slides in on its right.

How it works: ChatLinkPlacement is one pure rule over the screen's columns in visual order (top and bottom dock bands left out; in the chat's own split tree only panes beside it on its right count). LinkOpener.openWeb, which every chat link already goes through (AgentPaneView.openURL → LinkRouter.open(from:) → openInCmux), asks it whenever the source pane shows an agent chat. Terminal and browser Cmd-clicks, Home, Markdown/file pages and New Tab picks keep their rules, and the links.pagesFollowPlacement Debug tunable is untouched for them. Opens with no source view (Help menu, update notes) start from the focused pane, so when that pane is a chat they follow this rule too.

Not in this PR: Cmd-T and the + button are handled in #23882 (projects-d7), which wires them through ChatLinkPlacement once this lands. File and folder links from a chat (openFile, openFolder) also keep their rules. The layout.linkPlacement help text still says what a chat opens goes in its pane; #23886 rewrites that string, so this PR leaves the catalog alone to avoid a conflict.

Overlap: #23886 (teamleaderleo, reverts #22822) makes every link follow layout.linkPlacement and owns the setting and its text; this PR adds no setting. #23032 (austinywang) changes how the split is made; this PR changes which pane is chosen. Both touch LinkOpener.target in different lines; #23886 also edits LinkOpener.swift, and whichever lands second takes the conflict.

Testing

  • ChatLinkPlacementTests (CmuxNextAppTests): the default opens on the right; no right pane gives a new right split (and a new column under New Column), never the left pane; the focused right split wins over the rightmost; with none focused the rightmost wins, right edge first; panes that take no tabs or show another chat are skipped; the chat is never the target for either right setting; Same Pane is a tab in the chat's pane, even zoomed, and in the chat dock falls back to the page rules; a zoomed chat opens a new split (or column), never a tab in a pane its zoom hides; the right edge of a split tree; panes right of the chat in its own tree; the right side of a screen with a browser column on the left and a bottom band.
  • LinkOpenerChatLinkTests (CmuxNextAppTests): LinkOpener.target(from:chatLink:) on a real workspace (an agent chat split beside a terminal, through WorkspaceContentController): Split Right and New Column give the terminal's pane; the terminal's own links get no chat rule; Tab in the Same Pane gives the chat's pane; with the chat zoomed, a new split (or column) of the chat, not the hidden terminal; from the chat dock with only another chat on its right, a new column; with no daemon browser tabs, the page rules' target, and a real openWeb makes the session-local tab and selects it.
  • Fleet focused suites (package-test-lane.sh suite, ChatLinkPlacementTests,LinkOpenerChatLinkTests,LinkOpenPlacementTests,LinkOpenerTests,LinkPlacementSettingTests,MainPaneLayoutTests) at 8a1ec9c: queued on the light pool; not yet run, so the new tests have not compiled or run anywhere yet.
  • cmux-ci compile-check --app at 8a1ec9c: passed (65 s, job 5f581b8e1a210e5030ffab9f). It compiles the app, not the tests.
  • release-notary's scripts/whats-new/test_digest.py (the What's New highlight) passes at the head; it failed at 5f903f6 on this PR's highlight (more than 2 sentences, no docs link), fixed in 95186d9.
  • Other reds at 5f903f6, not from this change: check-objc-selectors.py fails on CEFFileDragRelay.swift:162 (untouched here); test_issue_1138_sidebar_pr_polling.py times out in a 12 s bash timing check of the shell integration (no shell files change here).
  • No strings change in this revision. The earlier links.openTarget key and its strings are removed again.

Observed / Not run

Not run: a click in a live chat in a built app. LinkOpenerChatLinkTests exercises LinkOpener.target for a chat source on a real workspace, but not the main pane dock branch or the daemon move that makes the split.

Changelog

Changed: A link clicked in an agent chat opens on the right (the right pane you used last, or a new split) instead of covering the chat; Open Links and Files set to Tab in the Same Pane keeps it in the chat's pane

Proof

No capture: the change is which pane a chat link opens in; no capture scene drives a link click in a live agent chat yet. The placement is covered by the unit tests above.

Checklist

  • Behavior changes have added or updated tests, or Testing says why not
  • UI, settings, menu, schema, help-text or user-facing docs change: no strings change in this revision

🤖 Generated with Claude Code

Agent session: cosmic-gray

A web link clicked in an agent chat (a reply's link, a pull request chip,
the hover card's Open) opened as a tab in the chat's own pane, covering the
chat, and with a browser pane on the left it could land there instead.

New setting links.openTarget (Settings > General > Open Links from Agent
Chats): rightmost (default) opens it in the rightmost split of the chat's
screen, or a new split on the chat's right when the chat is the rightmost
split; newSplit always splits right of the chat; sameTab keeps the old
rules. A pane left of the chat never gets the link. Command-click still
opens a background tab in the same target.

The decision is one pure rule (ChatLinkPlacement) that LinkOpener.openWeb
asks for every web link whose source pane shows an agent chat, so every
chat entrypoint shares it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a1602e-25ea-7611-8c79-d16df0c4058f&host=cosmic-gray
@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

cmux status (one comment, edited in place; each line is updated by the job that owns it)

Review 5e1d683: HQ feed card · gallery before/after runs · checks

App of this head (app.zip, kept 1 day)

gh workflow run reload-build.yml -R manaflow-ai/cmux --ref feat-cmux-next -f tag=pr23908 -f ref=chat-links-side -f platform=macos -f backend_mode=local

red: cmux-next checks (god files, concurrency, crash safety, l10n); base red: the same failure on the base 1f4437e; does not block

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

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

@github-actions github-actions Bot added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Oct 22, 2026
@teamleaderleo

teamleaderleo commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator Author

Owned by nx-chat on big-red · took over from session 01a1602e

@blacksmith-sh

blacksmith-sh Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Found 13 test failures on Blacksmith runners:

Failures

Test View Logs
/tmp/tmp4bdya0iw/summary-fail.json (live):/summary-fail.json (live): View Logs
/tmp/tmpodvufc4e/summary-missing.json (live):/summary-missing.json (live): View Logs
[new_workspaces] debug.new_workspace timed 0 of 50 new workspace rows/
[new_workspaces] debug.new_workspace timed 0 of 50 new workspace rows
View Logs
[new_workspaces] the window draws no frames/[new_workspaces] the window draws no frames View Logs
[tabs] debug.tab_switch timed 0 of 40 switches/
[tabs] debug.tab_switch timed 0 of 40 switches
View Logs
[tabs] the window draws no frames/[tabs] the window draws no frames View Logs
convention violations found (ERROR lines above)./
convention violations found (ERROR lines above).
View Logs
nightly cmux-tui resolve/nightly cmux-tui resolve View Logs
test_every_submodule_free_checkout_on_an_owned_runner_resets_submodules (main.Reuse
dWorkspaceSubmodules.test_every_submodule_free_checkout_on_an_owned_runner_resets_submo
dules)/
test_every_submodule_free_checkout_on_an_owned_runner_resets_submodules (main.Reuse
dWorkspaceSubmodules.test_every_submodule_free_checkout_on_an_owned_runner_resets_submo
dules)
View Logs
test_every_submodule_free_checkout_on_an_owned_runner_resets_submodules (main.Reuse
dWorkspaceSubmodules.test_every_submodule_free_checkout_on_an_owned_runner_resets_submo
dules) (job='web-bundles')/
test_every_submodule_free_checkout_on_an_owned_runner_resets_submodules (main.Reuse
dWorkspaceSubmodules.test_every_submodule_free_checkout_on_an_owned_runner_resets_submo
dules) (job='web-bundles')
View Logs

...and 3 more test failures. View all on Blacksmith: run 39725119788, run 39725119849

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

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Review (claude subagent, correctness first): SHIP at 76d0537

I found nothing that blocks this. The rule holds its two promises for every layout I traced: the link never lands in a pane left of the chat, and never in the chat's own pane. That covers .splits screens through the implicit column, columns with rows, left and right docks, top and bottom bands, Home's pinned dock (skipped because it takes no tabs) and zoomed screens (the zoomed layout maps to .splits(.leaf), so the link splits right and the daemon's tab move clears the zoom). Right edges in the chat's own column always touch the column's right boundary, so they are never left of the chat. Cmd-click from a chat comes in as background, not cmdClick, so it does follow the chat rule. Terminal Cmd-click (cmdClick: true) and browser Cmd-click (open(cmdClick:)) are untouched.

Findings, most severe first. None of them blocks.

  1. Medium: rightmost can cover a different chat. LinkOpener.swift:122 (takesTabs), ChatLinkPlacement.rightmost. With two chats side by side ([chat A | chat B], for example after forking a conversation to the right), a link from A opens as a selected tab in B's pane and covers B. A link from B splits right, so the two behave differently. Suggested fix: treat a pane whose selected tab is an agent chat (ChatColumnPlacement.isChat) as one that takes no tabs, so the link falls through to the next candidate or to .splitRight.
  2. Medium: the default flow changes when the strip has several columns. ChatLinkPlacement.swift:43-48. layout.mainPane is on by default. Before this PR, a chat in the agent chat dock sent links to the most recently focused strip pane (MainPaneLayout.side, which returns .tab). Now they go to the last strip column, or to a right dock, and that column may be scrolled off screen. A plain click scrolls the strip there. A Cmd-click lands where nobody sees it. If the chat is itself a strip column ([chat, X, Y, Z]), revealing Z can scroll the chat off screen. Suggested fix: limit the search to columns that are visible, or keep recency within the strip. Otherwise, confirm this is intended.
  3. Low: the wiring has no tests. The tests cover the pure rule only. Nothing exercises LinkOpener.chatLinkTarget(from:mainPane:): the isChat gate, the !cmdClick exclusion, the .dockChat precedence, or the choice between visualColumns and the implicit column. A test at the LinkOpener level (the chatLinkTarget closure can be stubbed) would catch a regression there.
  4. Low: some opens without a source change too. Any openWeb with no source pane falls back to the focused pane: the Help menu, the update sheet, AppActionContext.open, the fallback in WorkspaceHandlers.openInNewWorkspace, and the static route in AgentPaneView. When that pane shows a chat, these opens now follow links.openTarget too. That is probably fine, but the PR body should say so.
  5. Low (copy): files from a chat lost their mention. The settings.layout.linkPlacement.help text no longer mentions agent chats. File links clicked in a chat still use the old rule for pages (openFile has no chatLink), so with layout.mainPane off they still open as a tab in the chat's pane. Either keep chats in that help text or route file opens from chats through the new rule too.
  6. Nit: ChatLinkPlacement is a pure rule but inherits @MainActor from uiSwiftSettings. REVIEW.md section 11 suggests marking such types nonisolated. LinkOpenPlacement follows the same pattern, so this is optional.

Checked and fine:

  • Compiles as far as I can tell by reading. PaneID resolves to CmuxNextLayout in both new files, and no @_exported import brings in CmuxNextDaemon. The .split(_, .horizontal, …) patterns are exhaustive. The chatLinkTarget property and method overload resolves the way first and first(where:) do. map { .pane($0) } ?? … already appears elsewhere in the repo.
  • Tests: I traced every #expect against the implementation and each one should pass.
  • Settings and localization:
    • Choice parsing, diagnostics and agentSettableKeys are all in place.
    • The new row sits in the columns group, which SettingsPageLayout places by group.
    • All 5 new keys and the edited help text have all 21 locales, marked translated, with no English copied into other locales.
    • The en values match the Swift defaults exactly.
    • The generated schema and MDM files were not hand-edited; the autofix regenerates them.

Not run: no build or tests (this was a read-only review on a Mac). The CI status for this head was not available to me yet.

…ane first

Drop links.openTarget: a web link from an agent chat now follows the
existing Open Links and Files setting (layout.linkPlacement), so there is
one key app-wide. Split Right (the default) and New Column put it on the
right: the pane right of the chat the person focused last, else the
rightmost, else a new split (or column) on the chat's right. Tab in the
Same Pane keeps it in the agent pane. A pane that shows another agent
chat is never the target, nor any pane left of the chat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a1602e-25ea-7611-8c79-d16df0c4058f&host=cosmic-gray
@teamleaderleo teamleaderleo changed the title Agent chat: links open in the rightmost split, never over the chat Agent chat: links open on the right, never over the chat Oct 22, 2026
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Review (claude subagent, correctness first): SHIP at 5f903f6

Read the diff against c07d400. The rule does what the brief asks. Split Right and New Column send a link to the right pane focused last, else the rightmost one (right edge, top first), else a new split or column. Same Pane returns nil, so the old rules apply. A lone chat under main pane layout still gets .dockChat, and Cmd-click keeps the same target in the background.

Checked:

  • Layouts. On an implicit .splits screen, ChatColumnPlacement.columns gives one column, and panes(rightOf:) keeps only panes beside the chat on its right. Panes stacked above or below it, or on its left, are left out. Columns with rows go through tree(containing:) and trees. Bands are filtered out. A left agent-chat dock with a strip works. A pinned Home dock is excluded by showsNewTabButton, and other chats are excluded by isChat on the selected tab. A zoomed screen maps to .splits(.leaf), so it falls back to a new split; if another pane is zoomed, screen(containing:) is nil and the old rules apply.
  • Recency. Stale or closed panes in recentPanes don't matter, because only the current right-side candidates count.
  • Wiring. chatLinkTarget runs only for openWeb from a pane whose selected tab is a chat. Terminals, browsers, pages, openFile and openFolder are unchanged. MainPaneLayout.side is never evaluated twice. In openWeb, focus and return-focus follow the same paths terminal Cmd-click already uses.
  • Compile risk. Bare PaneID with only CmuxNextLayout imported has a precedent (DockSectionModel.swift). LinkPlacement is nonisolated. recency.first(where: candidates.contains) mirrors paneRight. The switch-expression patterns are exhaustive. String-literal IDs work because LayoutIdentifier is ExpressibleByStringLiteral.
  • Tests. I traced every #expect in ChatLinkPlacementTests by hand and all pass against the implementation.

Not blocking:

  1. The links.pagesFollowPlacement help and doc (AgentPaneTunables+PageLinks.swift:4-10) still list "an agent chat". Chat web links no longer read that setting; chat file links still do.
  2. With a chat in the left agent-chat dock and no right pane in recency, the fallback is now the rightmost pane: possibly a right dock that takes tabs, or a strip column scrolled out of view. Before, it was the first strip pane. That matches the rule, but the MainPaneLayout doc says "the column on its right".
  3. A chat inside a top or bottom band gets a split of its band, because the band is filtered out of the columns.
  4. No test covers the LinkOpener wiring: the takesTabs closure, .dockChat taking precedence, and openWeb passing chatLink: true. Only the pure rule is tested.

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

cmux-next crash-class rules / cmux-next crash-class rules is red on 13 of 13 fresh feat-cmux-next PR heads in the last 2:00:00, so it is most likely a base red, not this PR's. The base-reds lane has it; a base merge picks up the fix once it lands.

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

cmux-next fast gates / post cmux-next/fast-gates is red on 17 of 21 fresh feat-cmux-next PR heads in the last 2:00:00, so it is most likely a base red, not this PR's. The base-reds lane has it; a base merge picks up the fix once it lands.

teamleaderleo and others added 2 commits October 21, 2026 21:16
…omed chat splits

Codex review of 5f903f6: with layout.linkPlacement set to Tab in the Same
Pane a chat link still went through main pane layout and could dock the
chat; now it is a tab in the chat's own pane (the chat dock, which holds
only chats, keeps the rules for any page). A zoomed chat ignores the panes
its zoom hides and opens a new split (or column) on its right, which ends
the zoom. Adds pure tests and LinkOpenerChatLinkTests on a real
chat | terminal workspace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a1602e-25ea-7611-8c79-d16df0c4058f&host=cosmic-gray
release-notary's test_the_repository_highlights_validate refused it:
more than 2 sentences and no tryIt action or docs link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a1602e-25ea-7611-8c79-d16df0c4058f&host=cosmic-gray
… daemon browser tabs

Codex review of 95186d9:
- From the chat dock with only chats on its right the link asked for a
  split, which TabMoves refuses there, and the tab stayed over the chat.
  The chat dock now gets a new column beside it.
- A machine whose daemon serves no browser tabs makes a session-local tab
  that never reports a surface, so a link needing a new split or column
  opened unselected. Those links now keep the rules for any page (base's
  target), so the tab opens selected; a pane on the right or the chat's own
  pane needs no move and still gets it.

The chat dock check reads the screen's columns the rule already has
(ChatDockRules.isChatDock(_:_:)), not a window lookup. Pure tests and
LinkOpenerChatLinkTests cover both, including a real session-local open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a1602e-25ea-7611-8c79-d16df0c4058f&host=cosmic-gray
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Orphaned: no activity for 13 h, and no lane holds a lease on it. The orphan sweep could not merge feat-cmux-next in: conflicts in Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/LinkOpener.swift. Listed for a lane to resolve.

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Stale (13d, conflicts); rebuild fresh from feat-cmux-next if still wanted

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Already on feat-cmux-next at 7828506 (links/file opens on the right); this stale PR is already closed.

This branch was previously deployed

1 inactive deployment
ci-routing-agent — 8a1ec9c6 Deployed Oct 22, 2026 by teamleaderleo
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:normal Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant