From 3bf6b650d50a6ffee97537e5ae28d8377bfc5b75 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 21 Oct 2026 16:31:35 -0700 Subject: [PATCH 1/6] Agent chat: links open in the rightmost split, never over the chat 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 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a1602e-25ea-7611-8c79-d16df0c4058f&host=cosmic-gray --- .../CmuxNextApp/Links/ChatLinkPlacement.swift | 69 ++ .../CmuxNextApp/Links/LinkOpener.swift | 41 +- .../CmuxNextSettings/ChatLinkTarget.swift | 22 + .../CmuxConfigSnapshot+PanePlacement.swift | 5 +- .../CmuxNextSettings/CmuxConfigSnapshot.swift | 2 + .../CmuxNextSettings/Localizable.xcstrings | 697 +++++++++++++++++- .../Schema/SettingsSchema+PanePlacement.swift | 17 +- .../ChatLinkPlacementTests.swift | 121 +++ .../ChatLinkTargetSettingTests.swift | 36 + .../agent-chat-links-open-to-the-side.md | 4 + 10 files changed, 985 insertions(+), 29 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/ChatLinkPlacement.swift create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextSettings/ChatLinkTarget.swift create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChatLinkPlacementTests.swift create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextSettingsTests/ChatLinkTargetSettingTests.swift create mode 100644 release-notes/next/highlights/agent-chat-links-open-to-the-side.md diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/ChatLinkPlacement.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/ChatLinkPlacement.swift new file mode 100644 index 000000000000..8fb7ccdd9a83 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/ChatLinkPlacement.swift @@ -0,0 +1,69 @@ +import CmuxNextLayout +import CmuxNextSettings + +/// Where a web link opened from an agent chat lands (`links.openTarget`, ``ChatLinkTarget``): a +/// link in a reply, a pull request chip, the hover card's Open. Leo 2026-10-21: "WHY DID THIS NOT +/// OPEN TO THE SIDE?" (the link opened as a tab over the chat) and "if the left side has a tab, any +/// link opened also goes on the left instead of opening a new split. By default, things going to +/// the rightmost split is the thing to try." +/// +/// - ``ChatLinkTarget/rightmost`` (default): a tab in the rightmost split of the chat's screen. When +/// that is the chat itself, a new split on the chat's right. A pane left of the chat never gets +/// the link, even one that shows a browser. +/// - ``ChatLinkTarget/newSplit``: always a new split on the chat's right. +/// - ``ChatLinkTarget/sameTab``: nil, so the rules for any page apply (``LinkOpener``). +/// +/// Command-click keeps its meaning: the tab opens in the same target, in the background. +/// `Pane` is the caller's pane identity (`LayoutPaneID` in the app), so the rule runs without a +/// window or a daemon. +enum ChatLinkPlacement { + /// Where a link opened from the chat in pane `chat` goes, or nil for ``ChatLinkTarget/sameTab``. + /// `columns` are the chat's screen's columns left to right, each given by the panes along its + /// right edge, top to bottom (``rightEdge(of:)``); `chatColumn` is the index of the column that + /// holds the chat; `takesTabs` tells which panes take new tabs; `recency` lists panes newest first. + static func target(_ setting: ChatLinkTarget, chat: Pane, columns: [[Pane]], chatColumn: Int?, + takesTabs: (Pane) -> Bool, recency: [Pane]) -> LinkOpenPlacement.Target? { + switch setting { + case .sameTab: + return nil + case .newSplit: + return .splitRight(of: chat) + case .rightmost: + let pane = rightmost(from: chat, columns: columns, chatColumn: chatColumn, takesTabs: takesTabs, recency: recency) + return pane.map { .pane($0) } ?? .splitRight(of: chat) + } + } + + /// The rightmost split right of or beside the chat that takes tabs and is not the chat: in the + /// last column that has one, on that column's right edge, its most recently focused such pane, + /// else its top one. Columns left of the chat's column are never searched. Nil when no such pane + /// exists (the chat is the rightmost split), and when the chat's column is unknown. + static func rightmost(from chat: Pane, columns: [[Pane]], chatColumn: Int?, + takesTabs: (Pane) -> Bool, recency: [Pane]) -> Pane? { + guard let chatColumn, columns.indices.contains(chatColumn) else { return nil } + for column in columns[chatColumn...].reversed() { + let candidates = column.filter { $0 != chat && takesTabs($0) } + if let pane = recency.first(where: candidates.contains) ?? candidates.first { return pane } + } + return nil + } + + /// The panes along `node`'s right edge, top to bottom: a side-by-side split's right side, both + /// sides of a stacked one. + static func rightEdge(of node: SplitNode) -> [PaneID] { + switch node { + case let .leaf(pane): [pane] + case let .split(_, .horizontal, _, _, right): rightEdge(of: right) + case let .split(_, .vertical, _, top, bottom): rightEdge(of: top) + rightEdge(of: bottom) + } + } + + /// `columns` as ``target(_:chat:columns:chatColumn:takesTabs:recency:)`` reads them: each column's + /// right edge (each row's, top to bottom, for a column with rows), and the index of the column + /// holding `chat`. Top and bottom docks are bands across the screen, not columns beside the chat: + /// they are left out. + static func columns(_ columns: [LayoutColumn], chat: PaneID) -> (edges: [[PaneID]], chatColumn: Int?) { + let beside = columns.filter { $0.dock?.edge.isBand != true } + return (beside.map { $0.trees.flatMap(rightEdge(of:)) }, beside.firstIndex { $0.root.contains(chat) }) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/LinkOpener.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/LinkOpener.swift index 4f3b8197dce6..3c7a336edb31 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/LinkOpener.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/LinkOpener.swift @@ -12,6 +12,8 @@ import CmuxNextWakeups /// here (``LinkRouter/openInCmux``, ``openFile(_:from:)``, ``openFolder(_:from:)``), so no surface picks its own pane. /// What a page opens (a chat and its pull request link, Home, New Tab, a Markdown page) is a tab in its pane, never an /// unasked split (Leo 2026-10-20); a terminal's or browser's Cmd-click (`cmdClick`) goes by `layout.linkPlacement`. +/// A web link from an agent chat goes by `links.openTarget` (``ChatLinkPlacement``): the rightmost split of its screen, +/// never over the chat or into a pane on its left (Leo 2026-10-21). /// An agent chat that is its screen's main pane (``MainPaneLayout``, `layout.mainPane`) never holds what it opens: that is a /// tab in the column on its right, or the lone chat docks left to make that column. /// @@ -41,10 +43,13 @@ final class LinkOpener { private var returns: [SurfaceID: Return] = [:] /// The setting, read on every open (``LinkPlacement/fallback`` before settings load). var placement: () -> LinkPlacement + /// `links.openTarget`, read on every web link from an agent chat (``ChatLinkTarget/fallback`` before settings load). + var chatLinkTarget: () -> ChatLinkTarget init(services: AppServices) { self.services = services placement = { [weak services] in services?.settings?.snapshot.linkPlacement ?? .fallback } + chatLinkTarget = { [weak services] in services?.settings?.snapshot.chatLinkTarget ?? .fallback } } /// The pane a link opened from tab `key` (a tab id or resource id) starts from: the pane @@ -72,7 +77,9 @@ final class LinkOpener { /// A source that takes no tabs (Home's pinned dock) finds the pane on its right by the layout's /// column order (``LinkOpenPlacement/paneRight(of:columns:takesTabs:recency:)``), not by drawn /// frames, which miss a strip column that is scrolled or not laid out yet. - func target(from source: PaneController, cmdClick: Bool = false, mainPane: Bool = true) -> LinkOpenPlacement.Target { + func target(from source: PaneController, cmdClick: Bool = false, mainPane: Bool = true, + chatLink: Bool = false) -> LinkOpenPlacement.Target { + if chatLink, !cmdClick, let target = chatLinkTarget(from: source, mainPane: mainPane) { return target } if mainPane, let services, let side = MainPaneLayout.side(from: source, services: services) { switch side { case .tab(let pane): return .pane(pane) @@ -95,13 +102,37 @@ final class LinkOpener { .target(source: source.layoutPaneID, takesTabs: takesTabs, right: right) } + /// Where a web link from `source` goes when `source` shows an agent chat (`links.openTarget`, + /// ``ChatLinkPlacement``): the rightmost split of its screen, else a new split on its right. + /// Nil when `source` shows no chat, takes no tabs, or the setting is Same Tab: the rules above. + /// A lone chat under main pane layout still docks left (``MainPaneLayout``), which puts the link + /// on its right too. + func chatLinkTarget(from source: PaneController, mainPane: Bool = true) -> LinkOpenPlacement.Target? { + guard let services, source.stripModel.showsNewTabButton, let tab = source.selectedTab, + ChatColumnPlacement.isChat(tab, services: services), let content = source.workspace, + let screen = content.layoutModel.screen(containing: source.layoutPaneID) else { return nil } + let setting = chatLinkTarget() + if setting == .rightmost, mainPane, case .dockChat? = MainPaneLayout.side(from: source, services: services) { + return .dockChat(source.layoutPaneID) + } + let columns = screen.layout.columns.isEmpty + ? ChatColumnPlacement.columns(of: screen, containing: source.layoutPaneID) : screen.layout.visualColumns + let found = ChatLinkPlacement.columns(columns, chat: source.layoutPaneID) + return ChatLinkPlacement.target(setting, chat: source.layoutPaneID, columns: found.edges, chatColumn: found.chatColumn, + takesTabs: { content.panes[$0]?.stripModel.showsNewTabButton == true }, + recency: content.recentPanes) + } + /// Opens through `make` where the rule puts it. `background`: the opened tab takes no focus. /// `mainPane` false: main pane layout does not apply (a chat, which belongs in the dock, not - /// the column on its right). False with no source pane (no window) or when `make` refused. + /// the column on its right). `chatLink`: a web link, which from an agent chat goes by + /// `links.openTarget` (``chatLinkTarget(from:mainPane:)``). False with no source pane (no + /// window) or when `make` refused. @discardableResult - func open(from source: PaneController?, background: Bool = false, cmdClick: Bool = false, mainPane: Bool = true, _ make: Make) -> Bool { + func open(from source: PaneController?, background: Bool = false, cmdClick: Bool = false, mainPane: Bool = true, + chatLink: Bool = false, _ make: Make) -> Bool { guard let source = source ?? services?.windows.active?.focusedPane else { return false } - switch target(from: source, cmdClick: cmdClick, mainPane: mainPane) { + switch target(from: source, cmdClick: cmdClick, mainPane: mainPane, chatLink: chatLink) { case .pane(let id): return make(id == source.layoutPaneID ? source : source.workspace?.panes[id] ?? source, nil) case .splitRight: @@ -134,7 +165,7 @@ final class LinkOpener { let from = source ?? services?.windows.active?.focusedPane // The person's intent: an agent's open in flight (AgentBesidePlacement) yields to it. if !background { _ = from?.workspace?.beginFocusIntent() } - return open(from: from, background: background, cmdClick: cmdClick) { [weak self, weak from] pane, placed in + return open(from: from, background: background, cmdClick: cmdClick, chatLink: true) { [weak self, weak from] pane, placed in let here = pane === from && placed == nil let opener = here ? from?.selectedTab?.surface : nil let back = background || here ? nil : from diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/ChatLinkTarget.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/ChatLinkTarget.swift new file mode 100644 index 000000000000..6a00b3ca7091 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/ChatLinkTarget.swift @@ -0,0 +1,22 @@ +/// Where a web link opened from an agent chat lands (`links.openTarget` in cmux.json): a link in a +/// reply, a pull request chip, the hover card's Open. Command-click opens it in the same place, in +/// the background. The chat itself never gets covered (Leo 2026-10-21). +/// +/// ```jsonc +/// "links": { "openTarget": "rightmost" } // "newSplit", "sameTab" +/// ``` +public nonisolated enum ChatLinkTarget: String, Hashable, Sendable, CaseIterable { + /// A tab in the rightmost split of the chat's screen; when the chat is that split, a new split + /// on its right. A pane left of the chat never gets it. The default. + case rightmost + /// Always a new split on the chat's right. + case newSplit + /// A tab in the chat's own pane, as any page opens a link (cmux-next before 2026-10-21; with + /// `layout.mainPane` on, the column beside the chat). + case sameTab + + /// The cmux.json key. + public static let configPath = ["links", "openTarget"] + /// ``rightmost`` when the key is unset or invalid. + public static let fallback: ChatLinkTarget = .rightmost +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot+PanePlacement.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot+PanePlacement.swift index 1139bc388a95..b9edf41842bd 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot+PanePlacement.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot+PanePlacement.swift @@ -2,7 +2,8 @@ public import CmuxNextDesign /// Where new panes open (`PanePlacement`): `layout.newPanePlacement` (tab or /// split) and `layout.tileBrowsers` (browsers split too under split -/// placement), `layout.linkPlacement` (``LinkPlacement``), `layout.mainPane` (``MainPaneSetting``). A missing key is the default with no diagnostic; a bad value +/// placement), `layout.linkPlacement` (``LinkPlacement``), `layout.mainPane` (``MainPaneSetting``), `links.openTarget` +/// (``ChatLinkTarget``). A missing key is the default with no diagnostic; a bad value /// is the default plus a diagnostic. nonisolated extension CmuxConfigSnapshot { public static let newPanePlacementPath = ["layout", "newPanePlacement"] @@ -32,6 +33,8 @@ nonisolated extension CmuxConfigSnapshot { diagnostics: &diagnostics) snapshot.mainPane = ColumnLayoutSettings.choice(root, MainPaneSetting.configPath, fallback: MainPaneSetting.fallback, diagnostics: &diagnostics) + snapshot.chatLinkTarget = ColumnLayoutSettings.choice(root, ChatLinkTarget.configPath, fallback: ChatLinkTarget.fallback, + diagnostics: &diagnostics) snapshot.diagnostics += diagnostics } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot.swift index e46a0f689e32..106658f080f4 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot.swift @@ -122,6 +122,8 @@ public struct CmuxConfigSnapshot: Sendable, Equatable { public var linkPlacement: LinkPlacement = .fallback /// `layout.mainPane`: whether an agent chat is its screen's main pane (``MainPaneSetting``). public var mainPane: MainPaneSetting = .fallback + /// `links.openTarget`: where a link opened from an agent chat lands (``ChatLinkTarget``). + public var chatLinkTarget: ChatLinkTarget = .fallback /// `workspaces.newPlacement` (`WorkspaceListSettings`). public var newWorkspacePlacement: NewWorkspacePlacement = CmuxConfigSnapshot.newWorkspacePlacementFallback /// `layout.closeFocus`; "previousNeighbor" when unset or invalid. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings index 8bafca8cd03e..82acf5311312 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings @@ -133797,127 +133797,127 @@ "en": { "stringUnit": { "state": "translated", - "value": "Where a link or file you Command-click in a terminal or a browser page appears. Split Right uses the pane on the right, or makes one. What you open from an agent chat, Home or another page opens as a tab in its own pane." + "value": "Where a link or file you Command-click in a terminal or a browser page appears. Split Right uses the pane on the right, or makes one. What you open from Home or another page opens as a tab in its own pane." } }, "ar": { "stringUnit": { "state": "translated", - "value": "المكان الذي يظهر فيه رابط أو ملف تنقر عليه مع Command في طرفية أو صفحة متصفح. يستخدم «تقسيم إلى اليمين» اللوحة الموجودة على اليمين، أو ينشئ واحدة. ما تفتحه من محادثة وكيل أو الصفحة الرئيسية أو صفحة أخرى يُفتح كعلامة تبويب في لوحته." + "value": "المكان الذي يظهر فيه رابط أو ملف تنقر عليه مع Command في طرفية أو صفحة متصفح. يستخدم «تقسيم إلى اليمين» اللوحة الموجودة على اليمين، أو ينشئ واحدة. ما تفتحه من الصفحة الرئيسية أو صفحة أخرى يُفتح كعلامة تبويب في لوحته." } }, "bs": { "stringUnit": { "state": "translated", - "value": "Gdje se pojavljuje link ili fajl na koji kliknete uz Command u terminalu ili na stranici preglednika. Podijeli desno koristi okno s desne strane ili ga pravi. Ono što otvorite iz chata s agentom, Početne ili druge stranice otvara se kao kartica u istom oknu." + "value": "Gdje se pojavljuje link ili fajl na koji kliknete uz Command u terminalu ili na stranici preglednika. Podijeli desno koristi okno s desne strane ili ga pravi. Ono što otvorite iz Početne ili druge stranice otvara se kao kartica u istom oknu." } }, "da": { "stringUnit": { "state": "translated", - "value": "Hvor et link eller en fil, du Kommando-klikker på i en terminal eller en browserside, vises. Opdel til højre bruger ruden til højre eller opretter en. Det, du åbner fra en agentchat, Hjem eller en anden side, åbner som en fane i samme rude." + "value": "Hvor et link eller en fil, du Kommando-klikker på i en terminal eller en browserside, vises. Opdel til højre bruger ruden til højre eller opretter en. Det, du åbner fra Hjem eller en anden side, åbner som en fane i samme rude." } }, "de": { "stringUnit": { "state": "translated", - "value": "Wo ein Link oder eine Datei erscheint, die du in einem Terminal oder auf einer Browserseite mit gedrückter Befehlstaste anklickst. „Rechts teilen“ nutzt den Bereich rechts oder legt einen an. Was du aus einem Agent-Chat, Home oder einer anderen Seite öffnest, öffnet sich als Tab im selben Bereich." + "value": "Wo ein Link oder eine Datei erscheint, die du in einem Terminal oder auf einer Browserseite mit gedrückter Befehlstaste anklickst. „Rechts teilen“ nutzt den Bereich rechts oder legt einen an. Was du aus Home oder einer anderen Seite öffnest, öffnet sich als Tab im selben Bereich." } }, "es": { "stringUnit": { "state": "translated", - "value": "Dónde aparece un enlace o archivo en el que haces clic con Comando en una terminal o una página del navegador. Dividir a la derecha usa el panel de la derecha o crea uno. Lo que abres desde un chat de agente, Inicio u otra página se abre como pestaña en su propio panel." + "value": "Dónde aparece un enlace o archivo en el que haces clic con Comando en una terminal o una página del navegador. Dividir a la derecha usa el panel de la derecha o crea uno. Lo que abres desde Inicio u otra página se abre como pestaña en su propio panel." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Où s’affiche un lien ou un fichier sur lequel vous cliquez avec Commande dans un terminal ou une page du navigateur. Diviser à droite utilise le panneau de droite ou en crée un. Ce que vous ouvrez depuis une discussion d’agent, l’Accueil ou une autre page s’ouvre dans un onglet de son propre panneau." + "value": "Où s’affiche un lien ou un fichier sur lequel vous cliquez avec Commande dans un terminal ou une page du navigateur. Diviser à droite utilise le panneau de droite ou en crée un. Ce que vous ouvrez depuis l’Accueil ou une autre page s’ouvre dans un onglet de son propre panneau." } }, "it": { "stringUnit": { "state": "translated", - "value": "Dove appare un link o un file su cui fai clic con Comando in un terminale o in una pagina del browser. Dividi a destra usa il riquadro a destra o ne crea uno. Ciò che apri da una chat con un agente, Home o un’altra pagina si apre come scheda nel suo riquadro." + "value": "Dove appare un link o un file su cui fai clic con Comando in un terminale o in una pagina del browser. Dividi a destra usa il riquadro a destra o ne crea uno. Ciò che apri da Home o da un’altra pagina si apre come scheda nel suo riquadro." } }, "ja": { "stringUnit": { "state": "translated", - "value": "ターミナルやブラウザのページで Command キーを押しながらクリックしたリンクやファイルを表示する場所です。「右に分割」は右側のペインを使い、なければ作成します。エージェントチャット、ホーム、その他のページから開いたものは、そのペインのタブとして開きます。" + "value": "ターミナルやブラウザのページで Command キーを押しながらクリックしたリンクやファイルを表示する場所です。「右に分割」は右側のペインを使い、なければ作成します。ホームやその他のページから開いたものは、そのペインのタブとして開きます。" } }, "km": { "stringUnit": { "state": "translated", - "value": "កន្លែងដែលតំណ ឬឯកសារដែលអ្នកចុចជាមួយ Command នៅក្នុងស្ថានីយ ឬទំព័រកម្មវិធីរុករក បង្ហាញ។ «បំបែកទៅស្តាំ» ប្រើផ្ទាំងនៅខាងស្តាំ ឬបង្កើតផ្ទាំងថ្មី។ អ្វីដែលអ្នកបើកពីការជជែកជាមួយភ្នាក់ងារ ទំព័រដើម ឬទំព័រផ្សេងទៀត បើកជាថេបនៅក្នុងផ្ទាំងរបស់វាផ្ទាល់។" + "value": "កន្លែងដែលតំណ ឬឯកសារដែលអ្នកចុចជាមួយ Command នៅក្នុងស្ថានីយ ឬទំព័រកម្មវិធីរុករក បង្ហាញ។ «បំបែកទៅស្តាំ» ប្រើផ្ទាំងនៅខាងស្តាំ ឬបង្កើតផ្ទាំងថ្មី។ អ្វីដែលអ្នកបើកពីទំព័រដើម ឬទំព័រផ្សេងទៀត បើកជាថេបនៅក្នុងផ្ទាំងរបស់វាផ្ទាល់។" } }, "ko": { "stringUnit": { "state": "translated", - "value": "터미널이나 브라우저 페이지에서 Command 클릭한 링크나 파일이 나타나는 위치입니다. 오른쪽으로 분할은 오른쪽 패널을 사용하거나 새로 만듭니다. 에이전트 채팅, 홈 또는 다른 페이지에서 연 항목은 해당 패널의 탭으로 열립니다." + "value": "터미널이나 브라우저 페이지에서 Command 클릭한 링크나 파일이 나타나는 위치입니다. 오른쪽으로 분할은 오른쪽 패널을 사용하거나 새로 만듭니다. 홈 또는 다른 페이지에서 연 항목은 해당 패널의 탭으로 열립니다." } }, "nb": { "stringUnit": { "state": "translated", - "value": "Hvor en lenke eller fil du Kommando-klikker på i en terminal eller en nettleserside, vises. Del til høyre bruker ruten til høyre, eller lager en. Det du åpner fra en agentchat, Hjem eller en annen side, åpnes som en fane i samme rute." + "value": "Hvor en lenke eller fil du Kommando-klikker på i en terminal eller en nettleserside, vises. Del til høyre bruker ruten til høyre, eller lager en. Det du åpner fra Hjem eller en annen side, åpnes som en fane i samme rute." } }, "pl": { "stringUnit": { "state": "translated", - "value": "Gdzie pojawia się link lub plik kliknięty z klawiszem Command w terminalu lub na stronie przeglądarki. Podziel w prawo używa panelu po prawej albo go tworzy. To, co otworzysz z czatu z agentem, strony głównej lub innej strony, otwiera się jako karta w tym samym panelu." + "value": "Gdzie pojawia się link lub plik kliknięty z klawiszem Command w terminalu lub na stronie przeglądarki. Podziel w prawo używa panelu po prawej albo go tworzy. To, co otworzysz ze strony głównej lub innej strony, otwiera się jako karta w tym samym panelu." } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "Onde aparece um link ou arquivo em que você clica com Command em um terminal ou em uma página do navegador. Dividir à Direita usa o painel à direita ou cria um. O que você abre em um chat de agente, no Início ou em outra página abre como aba no próprio painel." + "value": "Onde aparece um link ou arquivo em que você clica com Command em um terminal ou em uma página do navegador. Dividir à Direita usa o painel à direita ou cria um. O que você abre no Início ou em outra página abre como aba no próprio painel." } }, "ru": { "stringUnit": { "state": "translated", - "value": "Где появляется ссылка или файл, по которым вы щёлкнули с Command в терминале или на странице браузера. «Разделить вправо» использует панель справа или создаёт её. То, что вы открываете из чата с агентом, «Главной» или другой страницы, открывается вкладкой в той же панели." + "value": "Где появляется ссылка или файл, по которым вы щёлкнули с Command в терминале или на странице браузера. «Разделить вправо» использует панель справа или создаёт её. То, что вы открываете из «Главной» или другой страницы, открывается вкладкой в той же панели." } }, "th": { "stringUnit": { "state": "translated", - "value": "ตำแหน่งที่ลิงก์หรือไฟล์ที่คุณกด Command ค้างแล้วคลิกในเทอร์มินัลหรือหน้าเบราว์เซอร์จะแสดง แยกไปทางขวาจะใช้แผงทางขวา หรือสร้างขึ้นใหม่ สิ่งที่คุณเปิดจากแชทเอเจนต์ หน้าหลัก หรือหน้าอื่นจะเปิดเป็นแท็บในแผงเดียวกัน" + "value": "ตำแหน่งที่ลิงก์หรือไฟล์ที่คุณกด Command ค้างแล้วคลิกในเทอร์มินัลหรือหน้าเบราว์เซอร์จะแสดง แยกไปทางขวาจะใช้แผงทางขวา หรือสร้างขึ้นใหม่ สิ่งที่คุณเปิดจากหน้าหลักหรือหน้าอื่นจะเปิดเป็นแท็บในแผงเดียวกัน" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Bir terminalde veya tarayıcı sayfasında Command ile tıkladığınız bağlantı ya da dosyanın göründüğü yer. Sağa Böl, sağdaki bölmeyi kullanır veya bir tane oluşturur. Ajan sohbetinden, Ana Sayfa'dan veya başka bir sayfadan açtığınız şey kendi bölmesinde bir sekme olarak açılır." + "value": "Bir terminalde veya tarayıcı sayfasında Command ile tıkladığınız bağlantı ya da dosyanın göründüğü yer. Sağa Böl, sağdaki bölmeyi kullanır veya bir tane oluşturur. Ana Sayfa'dan veya başka bir sayfadan açtığınız şey kendi bölmesinde bir sekme olarak açılır." } }, "uk": { "stringUnit": { "state": "translated", - "value": "Де з’являється посилання або файл, на які ви клацнули з Command у терміналі чи на сторінці браузера. «Розділити праворуч» використовує панель праворуч або створює її. Те, що ви відкриваєте з чату з агентом, «Головної» чи іншої сторінки, відкривається вкладкою в тій самій панелі." + "value": "Де з’являється посилання або файл, на які ви клацнули з Command у терміналі чи на сторінці браузера. «Розділити праворуч» використовує панель праворуч або створює її. Те, що ви відкриваєте з «Головної» чи іншої сторінки, відкривається вкладкою в тій самій панелі." } }, "vi": { "stringUnit": { "state": "translated", - "value": "Nơi liên kết hoặc tệp bạn Command-click trong terminal hoặc trang trình duyệt xuất hiện. Chia sang phải dùng ngăn bên phải, hoặc tạo một ngăn mới. Những gì bạn mở từ cuộc trò chuyện với tác tử, Trang chủ hoặc trang khác sẽ mở thành thẻ trong chính ngăn đó." + "value": "Nơi liên kết hoặc tệp bạn Command-click trong terminal hoặc trang trình duyệt xuất hiện. Chia sang phải dùng ngăn bên phải, hoặc tạo một ngăn mới. Những gì bạn mở từ Trang chủ hoặc trang khác sẽ mở thành thẻ trong chính ngăn đó." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "在终端或浏览器页面中按住 Command 点按的链接或文件显示的位置。“向右拆分”使用右侧的窗格,没有则新建一个。从智能体聊天、主页或其他页面打开的内容,会在其所在窗格中以标签页打开。" + "value": "在终端或浏览器页面中按住 Command 点按的链接或文件显示的位置。“向右拆分”使用右侧的窗格,没有则新建一个。从主页或其他页面打开的内容,会在其所在窗格中以标签页打开。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "在終端機或瀏覽器頁面中按住 Command 點按的連結或檔案顯示的位置。「向右分割」使用右側的窗格,沒有則新建一個。從代理程式聊天、首頁或其他頁面打開的內容,會在其所在窗格中以分頁打開。" + "value": "在終端機或瀏覽器頁面中按住 Command 點按的連結或檔案顯示的位置。「向右分割」使用右側的窗格,沒有則新建一個。從首頁或其他頁面打開的內容,會在其所在窗格中以分頁打開。" } } } @@ -153903,6 +153903,661 @@ } } }, + "settings.links.openTarget": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Links from Agent Chats" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح الروابط من محادثات الوكلاء" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvaranje linkova iz chatova s agentima" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn links fra agentchats" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Links aus Agent-Chats öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir enlaces de chats de agentes" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir les liens des discussions d’agents" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri link dalle chat con gli agenti" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントチャットのリンクを開く場所" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បើកតំណពីការជជែកជាមួយភ្នាក់ងារ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에이전트 채팅의 링크 열기" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne lenker fra agentchatter" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwieranie linków z czatów z agentami" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir links de chats de agentes" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открытие ссылок из чатов с агентами" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดลิงก์จากแชทเอเจนต์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ajan Sohbetlerindeki Bağlantıları Aç" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Відкриття посилань із чатів з агентами" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Mở liên kết từ cuộc trò chuyện với tác tử" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开智能体聊天中的链接" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "打開代理程式聊天中的連結" + } + } + } + }, + "settings.links.openTarget.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Where a web link you click in an agent chat opens. Rightmost Split uses the split furthest right on the chat's screen, or makes a split beside the chat when the chat is furthest right. The chat is never covered. Command-click opens the link there in the background." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مكان فتح رابط ويب تنقر عليه في محادثة وكيل. يستخدم «أقصى تقسيم يمينًا» التقسيم الأبعد إلى اليمين في شاشة المحادثة، أو ينشئ تقسيمًا بجانب المحادثة عندما تكون هي الأبعد إلى اليمين. لا تُغطّى المحادثة أبدًا. النقر مع Command يفتح الرابط هناك في الخلفية." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Gdje se otvara web link na koji kliknete u chatu s agentom. Krajnja desna podjela koristi podjelu najdalje desno na ekranu chata ili pravi podjelu pored chata kada je chat najdalje desno. Chat se nikada ne prekriva. Klik uz Command otvara link tamo u pozadini." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Hvor et weblink, du klikker på i en agentchat, åbner. Opdeling længst til højre bruger opdelingen længst til højre på chattens skærm, eller laver en opdeling ved siden af chatten, når chatten er længst til højre. Chatten bliver aldrig dækket. Kommando-klik åbner linket der i baggrunden." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wo sich ein Weblink öffnet, den du in einem Agent-Chat anklickst. „Teilung ganz rechts“ nutzt die Teilung ganz rechts auf dem Bildschirm des Chats oder legt neben dem Chat eine an, wenn der Chat ganz rechts ist. Der Chat wird nie verdeckt. Ein Klick mit gedrückter Befehlstaste öffnet den Link dort im Hintergrund." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Dónde se abre un enlace web en el que haces clic en un chat de agente. División más a la derecha usa la división situada más a la derecha en la pantalla del chat, o crea una junto al chat cuando el chat es el que está más a la derecha. El chat nunca queda tapado. Hacer clic con Comando abre el enlace allí en segundo plano." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Où s’ouvre un lien web sur lequel vous cliquez dans une discussion d’agent. Division la plus à droite utilise la division la plus à droite de l’écran de la discussion, ou en crée une à côté de la discussion quand celle-ci est la plus à droite. La discussion n’est jamais masquée. Un clic avec Commande y ouvre le lien en arrière-plan." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dove si apre un link web su cui fai clic in una chat con un agente. Divisione più a destra usa la divisione più a destra nello schermo della chat, o ne crea una accanto alla chat quando la chat è la più a destra. La chat non viene mai coperta. Il clic con Comando apre il link lì in background." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントチャットでクリックした Web リンクを開く場所です。「一番右の分割」はチャットの画面で一番右にある分割を使い、チャット自体が一番右にあるときはチャットの横に分割を作ります。チャットが隠れることはありません。Command キーを押しながらクリックすると、そこでバックグラウンドで開きます。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កន្លែងដែលតំណគេហទំព័រដែលអ្នកចុចក្នុងការជជែកជាមួយភ្នាក់ងារបើក។ «ការបំបែកខាងស្តាំបំផុត» ប្រើការបំបែកដែលនៅខាងស្តាំបំផុតលើអេក្រង់នៃការជជែក ឬបង្កើតការបំបែកនៅក្បែរការជជែក នៅពេលការជជែកនៅខាងស្តាំបំផុត។ ការជជែកមិនដែលត្រូវបានគ្របបាំងទេ។ ការចុចជាមួយ Command បើកតំណនៅទីនោះនៅផ្ទៃខាងក្រោយ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에이전트 채팅에서 클릭한 웹 링크가 열리는 위치입니다. 가장 오른쪽 분할은 채팅 화면에서 가장 오른쪽에 있는 분할을 사용하고, 채팅이 가장 오른쪽에 있으면 채팅 옆에 분할을 만듭니다. 채팅은 절대 가려지지 않습니다. Command 클릭하면 그곳에서 백그라운드로 열립니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Hvor en nettlenke du klikker på i en agentchat, åpnes. Deling lengst til høyre bruker delingen lengst til høyre på chattens skjerm, eller lager en deling ved siden av chatten når chatten er lengst til høyre. Chatten blir aldri dekket. Kommando-klikk åpner lenken der i bakgrunnen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Gdzie otwiera się link internetowy kliknięty w czacie z agentem. Podział najbardziej po prawej używa podziału położonego najdalej w prawo na ekranie czatu albo tworzy podział obok czatu, gdy to czat jest najdalej w prawo. Czat nigdy nie jest zasłaniany. Kliknięcie z klawiszem Command otwiera tam link w tle." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Onde abre um link da web em que você clica em um chat de agente. Divisão Mais à Direita usa a divisão mais à direita na tela do chat, ou cria uma ao lado do chat quando o chat é o mais à direita. O chat nunca fica coberto. Clicar com Command abre o link lá em segundo plano." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Где открывается веб-ссылка, по которой вы щёлкнули в чате с агентом. «Крайнее правое разделение» использует самое правое разделение на экране чата или создаёт разделение рядом с чатом, когда чат находится справа. Чат никогда не закрывается. Щелчок с Command открывает ссылку там в фоне." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ตำแหน่งที่ลิงก์เว็บที่คุณคลิกในแชทเอเจนต์จะเปิด ส่วนแยกขวาสุดจะใช้ส่วนแยกที่อยู่ขวาสุดบนหน้าจอของแชท หรือสร้างส่วนแยกข้างแชทเมื่อแชทอยู่ขวาสุด แชทจะไม่ถูกบังเลย การกด Command ค้างแล้วคลิกจะเปิดลิงก์ที่นั่นในเบื้องหลัง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bir ajan sohbetinde tıkladığınız web bağlantısının açıldığı yer. En Sağdaki Bölme, sohbetin ekranında en sağdaki bölmeyi kullanır; sohbet en sağdaysa sohbetin yanında bir bölme oluşturur. Sohbet asla örtülmez. Command ile tıklamak bağlantıyı orada arka planda açar." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Де відкривається вебпосилання, на яке ви клацнули в чаті з агентом. «Крайній правий поділ» використовує найправіший поділ на екрані чату або створює поділ поруч із чатом, коли чат найправіший. Чат ніколи не закривається. Клацання з Command відкриває посилання там у фоні." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Nơi liên kết web bạn nhấp trong cuộc trò chuyện với tác tử được mở. Ô chia ngoài cùng bên phải dùng ô chia xa nhất bên phải trên màn hình của cuộc trò chuyện, hoặc tạo một ô chia cạnh cuộc trò chuyện khi cuộc trò chuyện ở xa nhất bên phải. Cuộc trò chuyện không bao giờ bị che. Command-click mở liên kết ở đó trong nền." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在智能体聊天中点按的网页链接打开的位置。“最右侧拆分”使用聊天所在屏幕上最右侧的拆分;聊天本身在最右侧时,则在聊天旁新建一个拆分。聊天永远不会被遮住。按住 Command 点按会在那里于后台打开链接。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在代理程式聊天中點按的網頁連結打開的位置。「最右側分割」使用聊天所在螢幕上最右側的分割;聊天本身在最右側時,則在聊天旁新增一個分割。聊天永遠不會被遮住。按住 Command 點按會在那裡於背景打開連結。" + } + } + } + }, + "settings.choice.chatLinkRightmost": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rightmost Split" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أقصى تقسيم يمينًا" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Krajnja desna podjela" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdeling længst til højre" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Teilung ganz rechts" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "División más a la derecha" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Division la plus à droite" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Divisione più a destra" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一番右の分割" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ការបំបែកខាងស្តាំបំផុត" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "가장 오른쪽 분할" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Deling lengst til høyre" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podział najbardziej po prawej" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Divisão Mais à Direita" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Крайнее правое разделение" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ส่วนแยกขวาสุด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "En Sağdaki Bölme" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Крайній правий поділ" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Ô chia ngoài cùng bên phải" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最右侧拆分" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最右側分割" + } + } + } + }, + "settings.choice.chatLinkNewSplit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Split on the Right" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تقسيم جديد على اليمين" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nova podjela desno" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ny opdeling til højre" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neue Teilung rechts" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nueva división a la derecha" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouvelle division à droite" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nuova divisione a destra" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右に新しく分割" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ការបំបែកថ្មីនៅខាងស្តាំ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오른쪽에 새 분할" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ny deling til høyre" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nowy podział po prawej" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nova Divisão à Direita" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новое разделение справа" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ส่วนแยกใหม่ทางขวา" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sağda Yeni Bölme" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Новий поділ праворуч" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Ô chia mới bên phải" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在右侧新建拆分" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在右側新增分割" + } + } + } + }, + "settings.choice.chatLinkSameTab": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tab in the Chat's Pane" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "علامة تبويب في لوحة المحادثة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Kartica u oknu chata" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fane i chattens rude" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tab im Bereich des Chats" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pestaña en el panel del chat" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Onglet dans le panneau de la discussion" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Scheda nel riquadro della chat" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チャットのペインのタブ" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ថេបក្នុងផ្ទាំងនៃការជជែក" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "채팅 패널의 탭" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fane i chattens rute" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Karta w panelu czatu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aba no Painel do Chat" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вкладка в панели чата" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แท็บในแผงของแชท" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sohbetin Bölmesinde Sekme" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Вкладка в панелі чату" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Thẻ trong ngăn của cuộc trò chuyện" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "聊天窗格中的标签页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聊天窗格中的分頁" + } + } + } + }, "settings.layout.mainPane": { "extractionState": "manual", "localizations": { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+PanePlacement.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+PanePlacement.swift index 0efca612fb8e..4cce4e3d64ed 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+PanePlacement.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+PanePlacement.swift @@ -6,7 +6,7 @@ nonisolated enum PanePlacementSettingsSchema { /// Agents may change them all: layout choices, like `layout.dockColumnMode`. static var agentSettableKeys: Set { [CmuxConfigSnapshot.newPanePlacementPath, CmuxConfigSnapshot.tileBrowsersPath, LinkPlacement.configPath, - MainPaneSetting.configPath] + MainPaneSetting.configPath, ChatLinkTarget.configPath] .reduce(into: []) { $0.insert($1.joined(separator: ".")) } } @@ -37,7 +37,7 @@ nonisolated enum PanePlacementSettingsSchema { LinkPlacement.configPath, section: .general, group: columns, title: SettingsText.keyed("settings.layout.linkPlacement", "Open Links and Files"), help: SettingsText.keyed("settings.layout.linkPlacement.help", - "Where a link or file you Command-click in a terminal or a browser page appears. Split Right uses the pane on the right, or makes one. What you open from an agent chat, Home or another page opens as a tab in its own pane."), + "Where a link or file you Command-click in a terminal or a browser page appears. Split Right uses the pane on the right, or makes one. What you open from Home or another page opens as a tab in its own pane."), kind: .choice([ SettingChoice(LinkPlacement.splitRight.rawValue, SettingsText.keyed("settings.choice.linkPlacementSplitRight", "Split Right")), SettingChoice(LinkPlacement.newColumn.rawValue, SettingsText.keyed("settings.choice.linkPlacementNewColumn", "New Column on the Right")), @@ -46,6 +46,19 @@ nonisolated enum PanePlacementSettingsSchema { default: .string(LinkPlacement.fallback.rawValue), keywords: ["link", "url", "path", "file", "open", "split", "right", "column", "pane", "tab"] ), + SettingDescriptor( + ChatLinkTarget.configPath, section: .general, group: columns, + title: SettingsText.keyed("settings.links.openTarget", "Open Links from Agent Chats"), + help: SettingsText.keyed("settings.links.openTarget.help", + "Where a web link you click in an agent chat opens. Rightmost Split uses the split furthest right on the chat's screen, or makes a split beside the chat when the chat is furthest right. The chat is never covered. Command-click opens the link there in the background."), + kind: .choice([ + SettingChoice(ChatLinkTarget.rightmost.rawValue, SettingsText.keyed("settings.choice.chatLinkRightmost", "Rightmost Split")), + SettingChoice(ChatLinkTarget.newSplit.rawValue, SettingsText.keyed("settings.choice.chatLinkNewSplit", "New Split on the Right")), + SettingChoice(ChatLinkTarget.sameTab.rawValue, SettingsText.keyed("settings.choice.chatLinkSameTab", "Tab in the Chat's Pane")), + ]), + default: .string(ChatLinkTarget.fallback.rawValue), + keywords: ["link", "url", "chat", "agent", "open", "split", "right", "rightmost", "side", "pane", "tab"] + ), SettingDescriptor( MainPaneSetting.configPath, section: .general, group: columns, title: SettingsText.keyed("settings.layout.mainPane", "Main Pane"), diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChatLinkPlacementTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChatLinkPlacementTests.swift new file mode 100644 index 000000000000..05fe95e8f027 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChatLinkPlacementTests.swift @@ -0,0 +1,121 @@ +@testable import CmuxNextApp +import CmuxNextLayout +import CmuxNextSettings +import Testing + +/// Leo 2026-10-21: "WHY DID THIS NOT OPEN TO THE SIDE?" A GitHub link clicked in an agent chat +/// opened as a tab in the chat's own pane, covering the chat. And: "if the left side has a tab, any +/// link opened also goes on the left instead of opening a new split. By default, things going to the +/// rightmost split is the thing to try." ``ChatLinkPlacement`` (`links.openTarget`) decides where a +/// link from a chat goes: the rightmost split of its screen, a new split right of the chat when the +/// chat is the rightmost split, never the chat's pane or a pane on its left. +struct ChatLinkPlacementTests { + private typealias Target = LinkOpenPlacement.Target + + private static func target(_ setting: ChatLinkTarget = .fallback, chat: String = "chat", _ columns: [[String]], + takesTabs: @escaping (String) -> Bool = { _ in true }, recency: [String] = []) -> Target? { + ChatLinkPlacement.target(setting, chat: chat, columns: columns, chatColumn: columns.firstIndex { $0.contains(chat) }, + takesTabs: takesTabs, recency: recency) + } + + @Test func rightmostIsTheDefault() { + #expect(ChatLinkTarget.fallback == .rightmost) + } + + /// The reported case: a browser on the left, the chat on the right. The link never goes left + /// and never covers the chat: a new split opens on the chat's right. + @Test func aBrowserOnTheLeftNeverGetsTheLink() { + #expect(Self.target([["browser"], ["chat"]]) == .splitRight(of: "chat")) + #expect(Self.target([["browser"], ["chat"]], recency: ["browser", "chat"]) == .splitRight(of: "chat")) + } + + @Test func aChatAloneOnItsScreenGetsANewSplitOnItsRight() { + #expect(Self.target([["chat"]]) == .splitRight(of: "chat")) + } + + /// The first open made the split on the right; the next one reuses it (no split per click). + @Test func thePaneOnTheRightIsReused() { + #expect(Self.target([["chat"], ["opened"]]) == .pane("opened")) + } + + /// Several columns on the right: the rightmost one gets the link, not the one beside the chat. + @Test func theRightmostColumnWins() { + #expect(Self.target([["left"], ["chat"], ["middle"], ["right"]], recency: ["middle"]) == .pane("right")) + } + + /// The rightmost column split top and bottom: the more recently used of its panes, else the top one. + @Test func inTheRightmostColumnTheRecentPaneWins() { + #expect(Self.target([["chat"], ["top", "bottom"]]) == .pane("top")) + #expect(Self.target([["chat"], ["top", "bottom"]], recency: ["chat", "bottom", "top"]) == .pane("bottom")) + } + + /// A pane that takes no new tabs (a pinned dock) is skipped; with nothing else on the right the + /// chat gets a split. + @Test func paneThatTakeNoTabsAreSkipped() { + let takes: (String) -> Bool = { $0 != "pinned" } + #expect(Self.target([["chat"], ["strip"], ["pinned"]], takesTabs: takes) == .pane("strip")) + #expect(Self.target([["left"], ["chat"], ["pinned"]], takesTabs: takes) == .splitRight(of: "chat")) + } + + /// The chat's own column holds another pane on its right edge (below it): that pane is a + /// rightmost split, so it gets the link instead of a new split. + @Test func anotherPaneOnTheChatsRightEdgeIsUsed() { + #expect(Self.target([["left"], ["chat", "below"]]) == .pane("below")) + } + + @Test func newSplitAlwaysSplitsRightOfTheChat() { + for columns in [[["chat"]], [["browser"], ["chat"]], [["chat"], ["opened"]]] { + #expect(Self.target(.newSplit, columns) == .splitRight(of: "chat")) + } + } + + /// Same Tab keeps the old rules (a tab in the chat's pane, or main pane layout's column). + @Test func sameTabLeavesItToThePageRules() { + #expect(Self.target(.sameTab, [["browser"], ["chat"]]) == nil) + #expect(Self.target(.sameTab, [["chat"], ["opened"]]) == nil) + } + + /// Whatever the setting, a link from a chat never lands in a pane left of the chat, nor as a tab + /// over the chat itself. + @Test(arguments: [ChatLinkTarget.rightmost, .newSplit]) + func neverTheLeftNorTheChat(_ setting: ChatLinkTarget) { + let layouts: [[[String]]] = [[["chat"]], [["l1"], ["chat"]], [["l1", "l2"], ["chat"], ["r"]], [["l1"], ["chat", "below"]]] + for columns in layouts { + let target = Self.target(setting, columns, recency: ["l1", "l2"]) + #expect(target != .pane("chat")) + #expect(target != .pane("l1")) + #expect(target != .pane("l2")) + } + } + + /// The chat's column is unknown (it is not on the screen): a split on its right, never a guess. + @Test func anUnknownColumnSplitsRight() { + #expect(ChatLinkPlacement.target(.rightmost, chat: "chat", columns: [["a"]], chatColumn: nil, + takesTabs: { _ in true }, recency: []) == .splitRight(of: "chat")) + } + + /// Right edges come from the split tree: a side-by-side split's right side, both halves of a + /// stacked one. + @Test func theRightEdgeOfASplitTree() { + let (a, b, c, d): (PaneID, PaneID, PaneID, PaneID) = ("a", "b", "c", "d") + let tree = SplitNode.split("s1", axis: .horizontal, ratio: 0.5, a: .leaf(a), + b: .split("s2", axis: .vertical, ratio: 0.5, a: .leaf(b), + b: .split("s3", axis: .horizontal, ratio: 0.5, a: .leaf(c), b: .leaf(d)))) + #expect(ChatLinkPlacement.rightEdge(of: tree) == [b, d]) + #expect(ChatLinkPlacement.rightEdge(of: .leaf(a)) == [a]) + } + + /// A screen's columns: left dock, strip, right dock in order; a bottom band is not beside the chat. + @Test func columnsSkipBandsAndFindTheChat() { + let (chat, strip, right, band): (PaneID, PaneID, PaneID, PaneID) = ("chat", "strip", "right", "band") + let columns = [ + LayoutColumn(id: "chat", root: .leaf(chat), dock: DockColumn(edge: .left, role: .agentChat)), + LayoutColumn(id: "strip", root: .leaf(strip)), + LayoutColumn(id: "band", root: .leaf(band), dock: DockColumn(edge: .bottom)), + LayoutColumn(id: "right", root: .leaf(right), dock: DockColumn(edge: .right)), + ] + let found = ChatLinkPlacement.columns(columns, chat: chat) + #expect(found.edges == [[chat], [strip], [right]]) + #expect(found.chatColumn == 0) + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextSettingsTests/ChatLinkTargetSettingTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextSettingsTests/ChatLinkTargetSettingTests.swift new file mode 100644 index 000000000000..613fda7daf93 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextSettingsTests/ChatLinkTargetSettingTests.swift @@ -0,0 +1,36 @@ +import CmuxNextSettings +import Testing + +/// `links.openTarget`: where a link opened from an agent chat goes. The rightmost split of the +/// chat's screen by default (Leo 2026-10-21); "newSplit" always splits right of the chat; "sameTab" +/// keeps the old tab in the chat's pane. Agents may set it (a layout choice, like `layout.linkPlacement`). +struct ChatLinkTargetSettingTests { + private static func parse(_ value: JSONValue?) -> CmuxConfigSnapshot { + let root: JSONValue = value.map { .object(["links": .object(["openTarget": $0])]) } ?? .object([:]) + return CmuxConfigSnapshot.parse(root, validDensities: [], validMetrics: []) + } + + @Test func rightmostWhenUnset() { + let snapshot = Self.parse(nil) + #expect(snapshot.chatLinkTarget == .rightmost) + #expect(snapshot.diagnostics.isEmpty) + } + + @Test(arguments: [("rightmost", ChatLinkTarget.rightmost), ("newSplit", .newSplit), ("sameTab", .sameTab)]) + func eachChoiceLoads(raw: String, expected: ChatLinkTarget) { + #expect(Self.parse(.string(raw)).chatLinkTarget == expected) + } + + @Test func aBadValueKeepsTheDefaultAndSaysWhy() { + let snapshot = Self.parse(.string("left")) + #expect(snapshot.chatLinkTarget == .rightmost) + #expect(snapshot.diagnostics.contains { $0.path == "links.openTarget" }) + } + + @Test func theRowMatchesTheAppAndAgentsMaySetIt() throws { + let row = try #require(SettingsSchema.descriptor(for: ["links", "openTarget"])) + #expect(row.defaultValue == .string("rightmost")) + #expect(SettingsSchema.agentSettableKeys.contains("links.openTarget")) + #expect(SettingsSchema.agentRefusedKeys["links.openTarget"] == nil) + } +} diff --git a/release-notes/next/highlights/agent-chat-links-open-to-the-side.md b/release-notes/next/highlights/agent-chat-links-open-to-the-side.md new file mode 100644 index 000000000000..7066ac6c791f --- /dev/null +++ b/release-notes/next/highlights/agent-chat-links-open-to-the-side.md @@ -0,0 +1,4 @@ +title: Links from an agent chat open beside it +category: improved + +A link you click in an agent chat opens in the rightmost split, or a new split on the chat's right, never over the chat or on its left. Command-click opens it in the background. Change it in Settings > General > Open Links from Agent Chats. From 76d05375479216a2ed9c66ef9af89c9e9a072a03 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 21 Oct 2026 17:23:53 -0700 Subject: [PATCH 2/6] ChatLinkPlacementTests: columns first in the helper, so a defaulted setting compiles Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a1602e-25ea-7611-8c79-d16df0c4058f&host=cosmic-gray --- .../CmuxNextAppTests/ChatLinkPlacementTests.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChatLinkPlacementTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChatLinkPlacementTests.swift index 05fe95e8f027..4774ccb1d2ea 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChatLinkPlacementTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChatLinkPlacementTests.swift @@ -12,8 +12,8 @@ import Testing struct ChatLinkPlacementTests { private typealias Target = LinkOpenPlacement.Target - private static func target(_ setting: ChatLinkTarget = .fallback, chat: String = "chat", _ columns: [[String]], - takesTabs: @escaping (String) -> Bool = { _ in true }, recency: [String] = []) -> Target? { + private static func target(_ columns: [[String]], _ setting: ChatLinkTarget = .fallback, chat: String = "chat", + takesTabs: (String) -> Bool = { _ in true }, recency: [String] = []) -> Target? { ChatLinkPlacement.target(setting, chat: chat, columns: columns, chatColumn: columns.firstIndex { $0.contains(chat) }, takesTabs: takesTabs, recency: recency) } @@ -51,7 +51,7 @@ struct ChatLinkPlacementTests { /// A pane that takes no new tabs (a pinned dock) is skipped; with nothing else on the right the /// chat gets a split. - @Test func paneThatTakeNoTabsAreSkipped() { + @Test func panesThatTakeNoTabsAreSkipped() { let takes: (String) -> Bool = { $0 != "pinned" } #expect(Self.target([["chat"], ["strip"], ["pinned"]], takesTabs: takes) == .pane("strip")) #expect(Self.target([["left"], ["chat"], ["pinned"]], takesTabs: takes) == .splitRight(of: "chat")) @@ -65,14 +65,14 @@ struct ChatLinkPlacementTests { @Test func newSplitAlwaysSplitsRightOfTheChat() { for columns in [[["chat"]], [["browser"], ["chat"]], [["chat"], ["opened"]]] { - #expect(Self.target(.newSplit, columns) == .splitRight(of: "chat")) + #expect(Self.target(columns, .newSplit) == .splitRight(of: "chat")) } } /// Same Tab keeps the old rules (a tab in the chat's pane, or main pane layout's column). @Test func sameTabLeavesItToThePageRules() { - #expect(Self.target(.sameTab, [["browser"], ["chat"]]) == nil) - #expect(Self.target(.sameTab, [["chat"], ["opened"]]) == nil) + #expect(Self.target([["browser"], ["chat"]], .sameTab) == nil) + #expect(Self.target([["chat"], ["opened"]], .sameTab) == nil) } /// Whatever the setting, a link from a chat never lands in a pane left of the chat, nor as a tab @@ -81,7 +81,7 @@ struct ChatLinkPlacementTests { func neverTheLeftNorTheChat(_ setting: ChatLinkTarget) { let layouts: [[[String]]] = [[["chat"]], [["l1"], ["chat"]], [["l1", "l2"], ["chat"], ["r"]], [["l1"], ["chat", "below"]]] for columns in layouts { - let target = Self.target(setting, columns, recency: ["l1", "l2"]) + let target = Self.target(columns, setting, recency: ["l1", "l2"]) #expect(target != .pane("chat")) #expect(target != .pane("l1")) #expect(target != .pane("l2")) From 5f903f6ba0aec718344640cfec62bf991977038f Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 21 Oct 2026 19:26:10 -0700 Subject: [PATCH 3/6] Agent chat links: one setting (layout.linkPlacement), focused right pane 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 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a1602e-25ea-7611-8c79-d16df0c4058f&host=cosmic-gray --- .../CmuxNextApp/Links/ChatLinkPlacement.swift | 98 +-- .../CmuxNextApp/Links/LinkOpener.swift | 41 +- .../CmuxNextSettings/ChatLinkTarget.swift | 22 - .../CmuxConfigSnapshot+PanePlacement.swift | 5 +- .../CmuxNextSettings/CmuxConfigSnapshot.swift | 2 - .../CmuxNextSettings/Localizable.xcstrings | 697 +----------------- .../Schema/SettingsSchema+PanePlacement.swift | 17 +- .../ChatLinkPlacementTests.swift | 156 ++-- .../ChatLinkTargetSettingTests.swift | 36 - .../agent-chat-links-open-to-the-side.md | 2 +- 10 files changed, 172 insertions(+), 904 deletions(-) delete mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextSettings/ChatLinkTarget.swift delete mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextSettingsTests/ChatLinkTargetSettingTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/ChatLinkPlacement.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/ChatLinkPlacement.swift index 8fb7ccdd9a83..aa52cbf4a999 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/ChatLinkPlacement.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/ChatLinkPlacement.swift @@ -1,51 +1,45 @@ import CmuxNextLayout import CmuxNextSettings -/// Where a web link opened from an agent chat lands (`links.openTarget`, ``ChatLinkTarget``): a -/// link in a reply, a pull request chip, the hover card's Open. Leo 2026-10-21: "WHY DID THIS NOT -/// OPEN TO THE SIDE?" (the link opened as a tab over the chat) and "if the left side has a tab, any -/// link opened also goes on the left instead of opening a new split. By default, things going to -/// the rightmost split is the thing to try." +/// Where a web link opened from an agent chat lands (a link in a reply, a pull request chip, the +/// hover card's Open), by `layout.linkPlacement` (``LinkPlacement``). Leo 2026-10-21: "WHY DID THIS +/// NOT OPEN TO THE SIDE?" (the link opened as a tab over the chat), "if the left side has a tab, any +/// link opened also goes on the left instead of opening a new split", and "open on right by default". /// -/// - ``ChatLinkTarget/rightmost`` (default): a tab in the rightmost split of the chat's screen. When -/// that is the chat itself, a new split on the chat's right. A pane left of the chat never gets -/// the link, even one that shows a browser. -/// - ``ChatLinkTarget/newSplit``: always a new split on the chat's right. -/// - ``ChatLinkTarget/sameTab``: nil, so the rules for any page apply (``LinkOpener``). +/// - ``LinkPlacement/splitRight`` (default) and ``LinkPlacement/newColumn``: on the right. A pane +/// right of the chat gets a tab: the one the person focused last, else the rightmost. With no +/// pane there, a new split (or column) on the chat's right. A pane left of the chat never gets +/// it, and neither does a pane that shows an agent chat (the caller's `takesTabs` says no). +/// - ``LinkPlacement/samePane``: nil, "in the agent pane": the rules for any page apply +/// (``LinkOpener``: a tab in the chat's pane, or main pane layout's column). /// -/// Command-click keeps its meaning: the tab opens in the same target, in the background. -/// `Pane` is the caller's pane identity (`LayoutPaneID` in the app), so the rule runs without a -/// window or a daemon. -enum ChatLinkPlacement { - /// Where a link opened from the chat in pane `chat` goes, or nil for ``ChatLinkTarget/sameTab``. - /// `columns` are the chat's screen's columns left to right, each given by the panes along its - /// right edge, top to bottom (``rightEdge(of:)``); `chatColumn` is the index of the column that - /// holds the chat; `takesTabs` tells which panes take new tabs; `recency` lists panes newest first. - static func target(_ setting: ChatLinkTarget, chat: Pane, columns: [[Pane]], chatColumn: Int?, +/// Command-click keeps its meaning: the tab opens in the same target, in the background. Tabs can +/// still be dragged anywhere afterwards. `Pane` is the caller's pane identity (`LayoutPaneID` in the +/// app), so the rule runs without a window or a daemon. +nonisolated enum ChatLinkPlacement { + /// Where a link from the chat in pane `chat` goes, or nil for ``LinkPlacement/samePane``. + /// `right` lists the panes right of the chat, one group per column left to right (the chat's own + /// column first, with only its panes right of the chat), each group's panes on the column's right + /// edge first, top to bottom (``rightSide(of:in:)``). `takesTabs` tells which panes may get the + /// link; `recency` lists panes newest first. + static func target(_ setting: LinkPlacement, chat: Pane, right: [[Pane]], takesTabs: (Pane) -> Bool, recency: [Pane]) -> LinkOpenPlacement.Target? { switch setting { - case .sameTab: + case .samePane: return nil - case .newSplit: - return .splitRight(of: chat) - case .rightmost: - let pane = rightmost(from: chat, columns: columns, chatColumn: chatColumn, takesTabs: takesTabs, recency: recency) - return pane.map { .pane($0) } ?? .splitRight(of: chat) + case .splitRight, .newColumn: + if let pane = pane(from: chat, right: right, takesTabs: takesTabs, recency: recency) { return .pane(pane) } + return setting == .newColumn ? .newColumn(rightOf: chat) : .splitRight(of: chat) } } - /// The rightmost split right of or beside the chat that takes tabs and is not the chat: in the - /// last column that has one, on that column's right edge, its most recently focused such pane, - /// else its top one. Columns left of the chat's column are never searched. Nil when no such pane - /// exists (the chat is the rightmost split), and when the chat's column is unknown. - static func rightmost(from chat: Pane, columns: [[Pane]], chatColumn: Int?, - takesTabs: (Pane) -> Bool, recency: [Pane]) -> Pane? { - guard let chatColumn, columns.indices.contains(chatColumn) else { return nil } - for column in columns[chatColumn...].reversed() { - let candidates = column.filter { $0 != chat && takesTabs($0) } - if let pane = recency.first(where: candidates.contains) ?? candidates.first { return pane } - } - return nil + /// The pane right of the chat that gets the link: of those that take tabs and are not the chat, + /// the most recently focused, else the rightmost column's first (its right edge, top first). + /// Nil when there is none. + static func pane(from chat: Pane, right: [[Pane]], takesTabs: (Pane) -> Bool, recency: [Pane]) -> Pane? { + let groups = right.map { $0.filter { $0 != chat && takesTabs($0) } } + let candidates = groups.flatMap { $0 } + return recency.first(where: candidates.contains) ?? groups.last(where: { !$0.isEmpty })?.first } /// The panes along `node`'s right edge, top to bottom: a side-by-side split's right side, both @@ -58,12 +52,30 @@ enum ChatLinkPlacement { } } - /// `columns` as ``target(_:chat:columns:chatColumn:takesTabs:recency:)`` reads them: each column's - /// right edge (each row's, top to bottom, for a column with rows), and the index of the column - /// holding `chat`. Top and bottom docks are bands across the screen, not columns beside the chat: - /// they are left out. - static func columns(_ columns: [LayoutColumn], chat: PaneID) -> (edges: [[PaneID]], chatColumn: Int?) { + /// The panes of `node` right of `pane`: the right side of every side-by-side split whose left + /// side holds `pane`, in visual order. Empty when `node` does not hold `pane`. + static func panes(rightOf pane: PaneID, in node: SplitNode) -> [PaneID] { + switch node { + case .leaf: + return [] + case let .split(_, axis, _, a, b): + if a.contains(pane) { return panes(rightOf: pane, in: a) + (axis == .horizontal ? b.panes : []) } + return b.contains(pane) ? panes(rightOf: pane, in: b) : [] + } + } + + /// `right` as ``target(_:chat:right:takesTabs:recency:)`` reads it, from the chat's screen's + /// `columns` in the order the person sees them (`ScreenLayout.visualColumns`). Top and bottom + /// docks are bands across the screen, not beside the chat: they are left out. Empty when no + /// column holds `chat`. + static func rightSide(of chat: PaneID, in columns: [LayoutColumn]) -> [[PaneID]] { let beside = columns.filter { $0.dock?.edge.isBand != true } - return (beside.map { $0.trees.flatMap(rightEdge(of:)) }, beside.firstIndex { $0.root.contains(chat) }) + guard let index = beside.firstIndex(where: { $0.root.contains(chat) }) else { return [] } + func edgeFirst(_ panes: [PaneID], _ column: LayoutColumn) -> [PaneID] { + let edge = column.trees.flatMap(rightEdge(of:)).filter(panes.contains) + return edge + panes.filter { !edge.contains($0) } + } + let own = beside[index].tree(containing: chat).map { panes(rightOf: chat, in: $0) } ?? [] + return [edgeFirst(own, beside[index])] + beside[(index + 1)...].map { edgeFirst($0.trees.flatMap(\.panes), $0) } } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/LinkOpener.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/LinkOpener.swift index 3c7a336edb31..d8a3b323164e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/LinkOpener.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/LinkOpener.swift @@ -12,8 +12,8 @@ import CmuxNextWakeups /// here (``LinkRouter/openInCmux``, ``openFile(_:from:)``, ``openFolder(_:from:)``), so no surface picks its own pane. /// What a page opens (a chat and its pull request link, Home, New Tab, a Markdown page) is a tab in its pane, never an /// unasked split (Leo 2026-10-20); a terminal's or browser's Cmd-click (`cmdClick`) goes by `layout.linkPlacement`. -/// A web link from an agent chat goes by `links.openTarget` (``ChatLinkPlacement``): the rightmost split of its screen, -/// never over the chat or into a pane on its left (Leo 2026-10-21). +/// A web link from an agent chat goes by `layout.linkPlacement` too (``ChatLinkPlacement``): on the right by default (the +/// right pane focused last, else the rightmost, else a new split), never over a chat or into a pane on its left (Leo 2026-10-21). /// An agent chat that is its screen's main pane (``MainPaneLayout``, `layout.mainPane`) never holds what it opens: that is a /// tab in the column on its right, or the lone chat docks left to make that column. /// @@ -43,13 +43,10 @@ final class LinkOpener { private var returns: [SurfaceID: Return] = [:] /// The setting, read on every open (``LinkPlacement/fallback`` before settings load). var placement: () -> LinkPlacement - /// `links.openTarget`, read on every web link from an agent chat (``ChatLinkTarget/fallback`` before settings load). - var chatLinkTarget: () -> ChatLinkTarget init(services: AppServices) { self.services = services placement = { [weak services] in services?.settings?.snapshot.linkPlacement ?? .fallback } - chatLinkTarget = { [weak services] in services?.settings?.snapshot.chatLinkTarget ?? .fallback } } /// The pane a link opened from tab `key` (a tab id or resource id) starts from: the pane @@ -79,7 +76,7 @@ final class LinkOpener { /// frames, which miss a strip column that is scrolled or not laid out yet. func target(from source: PaneController, cmdClick: Bool = false, mainPane: Bool = true, chatLink: Bool = false) -> LinkOpenPlacement.Target { - if chatLink, !cmdClick, let target = chatLinkTarget(from: source, mainPane: mainPane) { return target } + if chatLink, let target = chatLinkTarget(from: source, mainPane: mainPane) { return target } if mainPane, let services, let side = MainPaneLayout.side(from: source, services: services) { switch side { case .tab(let pane): return .pane(pane) @@ -102,32 +99,36 @@ final class LinkOpener { .target(source: source.layoutPaneID, takesTabs: takesTabs, right: right) } - /// Where a web link from `source` goes when `source` shows an agent chat (`links.openTarget`, - /// ``ChatLinkPlacement``): the rightmost split of its screen, else a new split on its right. - /// Nil when `source` shows no chat, takes no tabs, or the setting is Same Tab: the rules above. - /// A lone chat under main pane layout still docks left (``MainPaneLayout``), which puts the link - /// on its right too. + /// Where a web link from `source` goes when `source` shows an agent chat (``ChatLinkPlacement``, by + /// `layout.linkPlacement`): the pane right of the chat the person focused last, else the rightmost, + /// else a new split (or column) on its right. A pane that shows an agent chat never gets it. Nil + /// when `source` shows no chat or takes no tabs, or the setting is Tab in the Same Pane: the rules + /// above. A lone chat under main pane layout still docks left (``MainPaneLayout``), so the link + /// lands in the column that slides in on its right. func chatLinkTarget(from source: PaneController, mainPane: Bool = true) -> LinkOpenPlacement.Target? { guard let services, source.stripModel.showsNewTabButton, let tab = source.selectedTab, ChatColumnPlacement.isChat(tab, services: services), let content = source.workspace, let screen = content.layoutModel.screen(containing: source.layoutPaneID) else { return nil } - let setting = chatLinkTarget() - if setting == .rightmost, mainPane, case .dockChat? = MainPaneLayout.side(from: source, services: services) { + let setting = placement() + if setting != .samePane, mainPane, case .dockChat? = MainPaneLayout.side(from: source, services: services) { return .dockChat(source.layoutPaneID) } let columns = screen.layout.columns.isEmpty ? ChatColumnPlacement.columns(of: screen, containing: source.layoutPaneID) : screen.layout.visualColumns - let found = ChatLinkPlacement.columns(columns, chat: source.layoutPaneID) - return ChatLinkPlacement.target(setting, chat: source.layoutPaneID, columns: found.edges, chatColumn: found.chatColumn, - takesTabs: { content.panes[$0]?.stripModel.showsNewTabButton == true }, - recency: content.recentPanes) + let takesTabs: (LayoutPaneID) -> Bool = { id in + guard let pane = content.panes[id], pane.stripModel.showsNewTabButton else { return false } + return pane.selectedTab.map { !ChatColumnPlacement.isChat($0, services: services) } ?? true + } + return ChatLinkPlacement.target(setting, chat: source.layoutPaneID, + right: ChatLinkPlacement.rightSide(of: source.layoutPaneID, in: columns), + takesTabs: takesTabs, recency: content.recentPanes) } /// Opens through `make` where the rule puts it. `background`: the opened tab takes no focus. /// `mainPane` false: main pane layout does not apply (a chat, which belongs in the dock, not - /// the column on its right). `chatLink`: a web link, which from an agent chat goes by - /// `links.openTarget` (``chatLinkTarget(from:mainPane:)``). False with no source pane (no - /// window) or when `make` refused. + /// the column on its right). `chatLink`: a web link, which from an agent chat goes on its + /// right (``chatLinkTarget(from:mainPane:)``). False with no source pane (no window) or when + /// `make` refused. @discardableResult func open(from source: PaneController?, background: Bool = false, cmdClick: Bool = false, mainPane: Bool = true, chatLink: Bool = false, _ make: Make) -> Bool { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/ChatLinkTarget.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/ChatLinkTarget.swift deleted file mode 100644 index 6a00b3ca7091..000000000000 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/ChatLinkTarget.swift +++ /dev/null @@ -1,22 +0,0 @@ -/// Where a web link opened from an agent chat lands (`links.openTarget` in cmux.json): a link in a -/// reply, a pull request chip, the hover card's Open. Command-click opens it in the same place, in -/// the background. The chat itself never gets covered (Leo 2026-10-21). -/// -/// ```jsonc -/// "links": { "openTarget": "rightmost" } // "newSplit", "sameTab" -/// ``` -public nonisolated enum ChatLinkTarget: String, Hashable, Sendable, CaseIterable { - /// A tab in the rightmost split of the chat's screen; when the chat is that split, a new split - /// on its right. A pane left of the chat never gets it. The default. - case rightmost - /// Always a new split on the chat's right. - case newSplit - /// A tab in the chat's own pane, as any page opens a link (cmux-next before 2026-10-21; with - /// `layout.mainPane` on, the column beside the chat). - case sameTab - - /// The cmux.json key. - public static let configPath = ["links", "openTarget"] - /// ``rightmost`` when the key is unset or invalid. - public static let fallback: ChatLinkTarget = .rightmost -} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot+PanePlacement.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot+PanePlacement.swift index b9edf41842bd..1139bc388a95 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot+PanePlacement.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot+PanePlacement.swift @@ -2,8 +2,7 @@ public import CmuxNextDesign /// Where new panes open (`PanePlacement`): `layout.newPanePlacement` (tab or /// split) and `layout.tileBrowsers` (browsers split too under split -/// placement), `layout.linkPlacement` (``LinkPlacement``), `layout.mainPane` (``MainPaneSetting``), `links.openTarget` -/// (``ChatLinkTarget``). A missing key is the default with no diagnostic; a bad value +/// placement), `layout.linkPlacement` (``LinkPlacement``), `layout.mainPane` (``MainPaneSetting``). A missing key is the default with no diagnostic; a bad value /// is the default plus a diagnostic. nonisolated extension CmuxConfigSnapshot { public static let newPanePlacementPath = ["layout", "newPanePlacement"] @@ -33,8 +32,6 @@ nonisolated extension CmuxConfigSnapshot { diagnostics: &diagnostics) snapshot.mainPane = ColumnLayoutSettings.choice(root, MainPaneSetting.configPath, fallback: MainPaneSetting.fallback, diagnostics: &diagnostics) - snapshot.chatLinkTarget = ColumnLayoutSettings.choice(root, ChatLinkTarget.configPath, fallback: ChatLinkTarget.fallback, - diagnostics: &diagnostics) snapshot.diagnostics += diagnostics } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot.swift index 106658f080f4..e46a0f689e32 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot.swift @@ -122,8 +122,6 @@ public struct CmuxConfigSnapshot: Sendable, Equatable { public var linkPlacement: LinkPlacement = .fallback /// `layout.mainPane`: whether an agent chat is its screen's main pane (``MainPaneSetting``). public var mainPane: MainPaneSetting = .fallback - /// `links.openTarget`: where a link opened from an agent chat lands (``ChatLinkTarget``). - public var chatLinkTarget: ChatLinkTarget = .fallback /// `workspaces.newPlacement` (`WorkspaceListSettings`). public var newWorkspacePlacement: NewWorkspacePlacement = CmuxConfigSnapshot.newWorkspacePlacementFallback /// `layout.closeFocus`; "previousNeighbor" when unset or invalid. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings index 82acf5311312..8bafca8cd03e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings @@ -133797,127 +133797,127 @@ "en": { "stringUnit": { "state": "translated", - "value": "Where a link or file you Command-click in a terminal or a browser page appears. Split Right uses the pane on the right, or makes one. What you open from Home or another page opens as a tab in its own pane." + "value": "Where a link or file you Command-click in a terminal or a browser page appears. Split Right uses the pane on the right, or makes one. What you open from an agent chat, Home or another page opens as a tab in its own pane." } }, "ar": { "stringUnit": { "state": "translated", - "value": "المكان الذي يظهر فيه رابط أو ملف تنقر عليه مع Command في طرفية أو صفحة متصفح. يستخدم «تقسيم إلى اليمين» اللوحة الموجودة على اليمين، أو ينشئ واحدة. ما تفتحه من الصفحة الرئيسية أو صفحة أخرى يُفتح كعلامة تبويب في لوحته." + "value": "المكان الذي يظهر فيه رابط أو ملف تنقر عليه مع Command في طرفية أو صفحة متصفح. يستخدم «تقسيم إلى اليمين» اللوحة الموجودة على اليمين، أو ينشئ واحدة. ما تفتحه من محادثة وكيل أو الصفحة الرئيسية أو صفحة أخرى يُفتح كعلامة تبويب في لوحته." } }, "bs": { "stringUnit": { "state": "translated", - "value": "Gdje se pojavljuje link ili fajl na koji kliknete uz Command u terminalu ili na stranici preglednika. Podijeli desno koristi okno s desne strane ili ga pravi. Ono što otvorite iz Početne ili druge stranice otvara se kao kartica u istom oknu." + "value": "Gdje se pojavljuje link ili fajl na koji kliknete uz Command u terminalu ili na stranici preglednika. Podijeli desno koristi okno s desne strane ili ga pravi. Ono što otvorite iz chata s agentom, Početne ili druge stranice otvara se kao kartica u istom oknu." } }, "da": { "stringUnit": { "state": "translated", - "value": "Hvor et link eller en fil, du Kommando-klikker på i en terminal eller en browserside, vises. Opdel til højre bruger ruden til højre eller opretter en. Det, du åbner fra Hjem eller en anden side, åbner som en fane i samme rude." + "value": "Hvor et link eller en fil, du Kommando-klikker på i en terminal eller en browserside, vises. Opdel til højre bruger ruden til højre eller opretter en. Det, du åbner fra en agentchat, Hjem eller en anden side, åbner som en fane i samme rude." } }, "de": { "stringUnit": { "state": "translated", - "value": "Wo ein Link oder eine Datei erscheint, die du in einem Terminal oder auf einer Browserseite mit gedrückter Befehlstaste anklickst. „Rechts teilen“ nutzt den Bereich rechts oder legt einen an. Was du aus Home oder einer anderen Seite öffnest, öffnet sich als Tab im selben Bereich." + "value": "Wo ein Link oder eine Datei erscheint, die du in einem Terminal oder auf einer Browserseite mit gedrückter Befehlstaste anklickst. „Rechts teilen“ nutzt den Bereich rechts oder legt einen an. Was du aus einem Agent-Chat, Home oder einer anderen Seite öffnest, öffnet sich als Tab im selben Bereich." } }, "es": { "stringUnit": { "state": "translated", - "value": "Dónde aparece un enlace o archivo en el que haces clic con Comando en una terminal o una página del navegador. Dividir a la derecha usa el panel de la derecha o crea uno. Lo que abres desde Inicio u otra página se abre como pestaña en su propio panel." + "value": "Dónde aparece un enlace o archivo en el que haces clic con Comando en una terminal o una página del navegador. Dividir a la derecha usa el panel de la derecha o crea uno. Lo que abres desde un chat de agente, Inicio u otra página se abre como pestaña en su propio panel." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Où s’affiche un lien ou un fichier sur lequel vous cliquez avec Commande dans un terminal ou une page du navigateur. Diviser à droite utilise le panneau de droite ou en crée un. Ce que vous ouvrez depuis l’Accueil ou une autre page s’ouvre dans un onglet de son propre panneau." + "value": "Où s’affiche un lien ou un fichier sur lequel vous cliquez avec Commande dans un terminal ou une page du navigateur. Diviser à droite utilise le panneau de droite ou en crée un. Ce que vous ouvrez depuis une discussion d’agent, l’Accueil ou une autre page s’ouvre dans un onglet de son propre panneau." } }, "it": { "stringUnit": { "state": "translated", - "value": "Dove appare un link o un file su cui fai clic con Comando in un terminale o in una pagina del browser. Dividi a destra usa il riquadro a destra o ne crea uno. Ciò che apri da Home o da un’altra pagina si apre come scheda nel suo riquadro." + "value": "Dove appare un link o un file su cui fai clic con Comando in un terminale o in una pagina del browser. Dividi a destra usa il riquadro a destra o ne crea uno. Ciò che apri da una chat con un agente, Home o un’altra pagina si apre come scheda nel suo riquadro." } }, "ja": { "stringUnit": { "state": "translated", - "value": "ターミナルやブラウザのページで Command キーを押しながらクリックしたリンクやファイルを表示する場所です。「右に分割」は右側のペインを使い、なければ作成します。ホームやその他のページから開いたものは、そのペインのタブとして開きます。" + "value": "ターミナルやブラウザのページで Command キーを押しながらクリックしたリンクやファイルを表示する場所です。「右に分割」は右側のペインを使い、なければ作成します。エージェントチャット、ホーム、その他のページから開いたものは、そのペインのタブとして開きます。" } }, "km": { "stringUnit": { "state": "translated", - "value": "កន្លែងដែលតំណ ឬឯកសារដែលអ្នកចុចជាមួយ Command នៅក្នុងស្ថានីយ ឬទំព័រកម្មវិធីរុករក បង្ហាញ។ «បំបែកទៅស្តាំ» ប្រើផ្ទាំងនៅខាងស្តាំ ឬបង្កើតផ្ទាំងថ្មី។ អ្វីដែលអ្នកបើកពីទំព័រដើម ឬទំព័រផ្សេងទៀត បើកជាថេបនៅក្នុងផ្ទាំងរបស់វាផ្ទាល់។" + "value": "កន្លែងដែលតំណ ឬឯកសារដែលអ្នកចុចជាមួយ Command នៅក្នុងស្ថានីយ ឬទំព័រកម្មវិធីរុករក បង្ហាញ។ «បំបែកទៅស្តាំ» ប្រើផ្ទាំងនៅខាងស្តាំ ឬបង្កើតផ្ទាំងថ្មី។ អ្វីដែលអ្នកបើកពីការជជែកជាមួយភ្នាក់ងារ ទំព័រដើម ឬទំព័រផ្សេងទៀត បើកជាថេបនៅក្នុងផ្ទាំងរបស់វាផ្ទាល់។" } }, "ko": { "stringUnit": { "state": "translated", - "value": "터미널이나 브라우저 페이지에서 Command 클릭한 링크나 파일이 나타나는 위치입니다. 오른쪽으로 분할은 오른쪽 패널을 사용하거나 새로 만듭니다. 홈 또는 다른 페이지에서 연 항목은 해당 패널의 탭으로 열립니다." + "value": "터미널이나 브라우저 페이지에서 Command 클릭한 링크나 파일이 나타나는 위치입니다. 오른쪽으로 분할은 오른쪽 패널을 사용하거나 새로 만듭니다. 에이전트 채팅, 홈 또는 다른 페이지에서 연 항목은 해당 패널의 탭으로 열립니다." } }, "nb": { "stringUnit": { "state": "translated", - "value": "Hvor en lenke eller fil du Kommando-klikker på i en terminal eller en nettleserside, vises. Del til høyre bruker ruten til høyre, eller lager en. Det du åpner fra Hjem eller en annen side, åpnes som en fane i samme rute." + "value": "Hvor en lenke eller fil du Kommando-klikker på i en terminal eller en nettleserside, vises. Del til høyre bruker ruten til høyre, eller lager en. Det du åpner fra en agentchat, Hjem eller en annen side, åpnes som en fane i samme rute." } }, "pl": { "stringUnit": { "state": "translated", - "value": "Gdzie pojawia się link lub plik kliknięty z klawiszem Command w terminalu lub na stronie przeglądarki. Podziel w prawo używa panelu po prawej albo go tworzy. To, co otworzysz ze strony głównej lub innej strony, otwiera się jako karta w tym samym panelu." + "value": "Gdzie pojawia się link lub plik kliknięty z klawiszem Command w terminalu lub na stronie przeglądarki. Podziel w prawo używa panelu po prawej albo go tworzy. To, co otworzysz z czatu z agentem, strony głównej lub innej strony, otwiera się jako karta w tym samym panelu." } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "Onde aparece um link ou arquivo em que você clica com Command em um terminal ou em uma página do navegador. Dividir à Direita usa o painel à direita ou cria um. O que você abre no Início ou em outra página abre como aba no próprio painel." + "value": "Onde aparece um link ou arquivo em que você clica com Command em um terminal ou em uma página do navegador. Dividir à Direita usa o painel à direita ou cria um. O que você abre em um chat de agente, no Início ou em outra página abre como aba no próprio painel." } }, "ru": { "stringUnit": { "state": "translated", - "value": "Где появляется ссылка или файл, по которым вы щёлкнули с Command в терминале или на странице браузера. «Разделить вправо» использует панель справа или создаёт её. То, что вы открываете из «Главной» или другой страницы, открывается вкладкой в той же панели." + "value": "Где появляется ссылка или файл, по которым вы щёлкнули с Command в терминале или на странице браузера. «Разделить вправо» использует панель справа или создаёт её. То, что вы открываете из чата с агентом, «Главной» или другой страницы, открывается вкладкой в той же панели." } }, "th": { "stringUnit": { "state": "translated", - "value": "ตำแหน่งที่ลิงก์หรือไฟล์ที่คุณกด Command ค้างแล้วคลิกในเทอร์มินัลหรือหน้าเบราว์เซอร์จะแสดง แยกไปทางขวาจะใช้แผงทางขวา หรือสร้างขึ้นใหม่ สิ่งที่คุณเปิดจากหน้าหลักหรือหน้าอื่นจะเปิดเป็นแท็บในแผงเดียวกัน" + "value": "ตำแหน่งที่ลิงก์หรือไฟล์ที่คุณกด Command ค้างแล้วคลิกในเทอร์มินัลหรือหน้าเบราว์เซอร์จะแสดง แยกไปทางขวาจะใช้แผงทางขวา หรือสร้างขึ้นใหม่ สิ่งที่คุณเปิดจากแชทเอเจนต์ หน้าหลัก หรือหน้าอื่นจะเปิดเป็นแท็บในแผงเดียวกัน" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Bir terminalde veya tarayıcı sayfasında Command ile tıkladığınız bağlantı ya da dosyanın göründüğü yer. Sağa Böl, sağdaki bölmeyi kullanır veya bir tane oluşturur. Ana Sayfa'dan veya başka bir sayfadan açtığınız şey kendi bölmesinde bir sekme olarak açılır." + "value": "Bir terminalde veya tarayıcı sayfasında Command ile tıkladığınız bağlantı ya da dosyanın göründüğü yer. Sağa Böl, sağdaki bölmeyi kullanır veya bir tane oluşturur. Ajan sohbetinden, Ana Sayfa'dan veya başka bir sayfadan açtığınız şey kendi bölmesinde bir sekme olarak açılır." } }, "uk": { "stringUnit": { "state": "translated", - "value": "Де з’являється посилання або файл, на які ви клацнули з Command у терміналі чи на сторінці браузера. «Розділити праворуч» використовує панель праворуч або створює її. Те, що ви відкриваєте з «Головної» чи іншої сторінки, відкривається вкладкою в тій самій панелі." + "value": "Де з’являється посилання або файл, на які ви клацнули з Command у терміналі чи на сторінці браузера. «Розділити праворуч» використовує панель праворуч або створює її. Те, що ви відкриваєте з чату з агентом, «Головної» чи іншої сторінки, відкривається вкладкою в тій самій панелі." } }, "vi": { "stringUnit": { "state": "translated", - "value": "Nơi liên kết hoặc tệp bạn Command-click trong terminal hoặc trang trình duyệt xuất hiện. Chia sang phải dùng ngăn bên phải, hoặc tạo một ngăn mới. Những gì bạn mở từ Trang chủ hoặc trang khác sẽ mở thành thẻ trong chính ngăn đó." + "value": "Nơi liên kết hoặc tệp bạn Command-click trong terminal hoặc trang trình duyệt xuất hiện. Chia sang phải dùng ngăn bên phải, hoặc tạo một ngăn mới. Những gì bạn mở từ cuộc trò chuyện với tác tử, Trang chủ hoặc trang khác sẽ mở thành thẻ trong chính ngăn đó." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "在终端或浏览器页面中按住 Command 点按的链接或文件显示的位置。“向右拆分”使用右侧的窗格,没有则新建一个。从主页或其他页面打开的内容,会在其所在窗格中以标签页打开。" + "value": "在终端或浏览器页面中按住 Command 点按的链接或文件显示的位置。“向右拆分”使用右侧的窗格,没有则新建一个。从智能体聊天、主页或其他页面打开的内容,会在其所在窗格中以标签页打开。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "在終端機或瀏覽器頁面中按住 Command 點按的連結或檔案顯示的位置。「向右分割」使用右側的窗格,沒有則新建一個。從首頁或其他頁面打開的內容,會在其所在窗格中以分頁打開。" + "value": "在終端機或瀏覽器頁面中按住 Command 點按的連結或檔案顯示的位置。「向右分割」使用右側的窗格,沒有則新建一個。從代理程式聊天、首頁或其他頁面打開的內容,會在其所在窗格中以分頁打開。" } } } @@ -153903,661 +153903,6 @@ } } }, - "settings.links.openTarget": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Open Links from Agent Chats" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "فتح الروابط من محادثات الوكلاء" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Otvaranje linkova iz chatova s agentima" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Åbn links fra agentchats" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Links aus Agent-Chats öffnen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Abrir enlaces de chats de agentes" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Ouvrir les liens des discussions d’agents" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Apri link dalle chat con gli agenti" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "エージェントチャットのリンクを開く場所" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "បើកតំណពីការជជែកជាមួយភ្នាក់ងារ" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "에이전트 채팅의 링크 열기" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Åpne lenker fra agentchatter" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Otwieranie linków z czatów z agentami" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Abrir links de chats de agentes" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Открытие ссылок из чатов с агентами" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "เปิดลิงก์จากแชทเอเจนต์" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Ajan Sohbetlerindeki Bağlantıları Aç" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Відкриття посилань із чатів з агентами" - } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "Mở liên kết từ cuộc trò chuyện với tác tử" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "打开智能体聊天中的链接" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "打開代理程式聊天中的連結" - } - } - } - }, - "settings.links.openTarget.help": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Where a web link you click in an agent chat opens. Rightmost Split uses the split furthest right on the chat's screen, or makes a split beside the chat when the chat is furthest right. The chat is never covered. Command-click opens the link there in the background." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "مكان فتح رابط ويب تنقر عليه في محادثة وكيل. يستخدم «أقصى تقسيم يمينًا» التقسيم الأبعد إلى اليمين في شاشة المحادثة، أو ينشئ تقسيمًا بجانب المحادثة عندما تكون هي الأبعد إلى اليمين. لا تُغطّى المحادثة أبدًا. النقر مع Command يفتح الرابط هناك في الخلفية." - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Gdje se otvara web link na koji kliknete u chatu s agentom. Krajnja desna podjela koristi podjelu najdalje desno na ekranu chata ili pravi podjelu pored chata kada je chat najdalje desno. Chat se nikada ne prekriva. Klik uz Command otvara link tamo u pozadini." - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Hvor et weblink, du klikker på i en agentchat, åbner. Opdeling længst til højre bruger opdelingen længst til højre på chattens skærm, eller laver en opdeling ved siden af chatten, når chatten er længst til højre. Chatten bliver aldrig dækket. Kommando-klik åbner linket der i baggrunden." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Wo sich ein Weblink öffnet, den du in einem Agent-Chat anklickst. „Teilung ganz rechts“ nutzt die Teilung ganz rechts auf dem Bildschirm des Chats oder legt neben dem Chat eine an, wenn der Chat ganz rechts ist. Der Chat wird nie verdeckt. Ein Klick mit gedrückter Befehlstaste öffnet den Link dort im Hintergrund." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Dónde se abre un enlace web en el que haces clic en un chat de agente. División más a la derecha usa la división situada más a la derecha en la pantalla del chat, o crea una junto al chat cuando el chat es el que está más a la derecha. El chat nunca queda tapado. Hacer clic con Comando abre el enlace allí en segundo plano." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Où s’ouvre un lien web sur lequel vous cliquez dans une discussion d’agent. Division la plus à droite utilise la division la plus à droite de l’écran de la discussion, ou en crée une à côté de la discussion quand celle-ci est la plus à droite. La discussion n’est jamais masquée. Un clic avec Commande y ouvre le lien en arrière-plan." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Dove si apre un link web su cui fai clic in una chat con un agente. Divisione più a destra usa la divisione più a destra nello schermo della chat, o ne crea una accanto alla chat quando la chat è la più a destra. La chat non viene mai coperta. Il clic con Comando apre il link lì in background." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "エージェントチャットでクリックした Web リンクを開く場所です。「一番右の分割」はチャットの画面で一番右にある分割を使い、チャット自体が一番右にあるときはチャットの横に分割を作ります。チャットが隠れることはありません。Command キーを押しながらクリックすると、そこでバックグラウンドで開きます。" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "កន្លែងដែលតំណគេហទំព័រដែលអ្នកចុចក្នុងការជជែកជាមួយភ្នាក់ងារបើក។ «ការបំបែកខាងស្តាំបំផុត» ប្រើការបំបែកដែលនៅខាងស្តាំបំផុតលើអេក្រង់នៃការជជែក ឬបង្កើតការបំបែកនៅក្បែរការជជែក នៅពេលការជជែកនៅខាងស្តាំបំផុត។ ការជជែកមិនដែលត្រូវបានគ្របបាំងទេ។ ការចុចជាមួយ Command បើកតំណនៅទីនោះនៅផ្ទៃខាងក្រោយ។" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "에이전트 채팅에서 클릭한 웹 링크가 열리는 위치입니다. 가장 오른쪽 분할은 채팅 화면에서 가장 오른쪽에 있는 분할을 사용하고, 채팅이 가장 오른쪽에 있으면 채팅 옆에 분할을 만듭니다. 채팅은 절대 가려지지 않습니다. Command 클릭하면 그곳에서 백그라운드로 열립니다." - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Hvor en nettlenke du klikker på i en agentchat, åpnes. Deling lengst til høyre bruker delingen lengst til høyre på chattens skjerm, eller lager en deling ved siden av chatten når chatten er lengst til høyre. Chatten blir aldri dekket. Kommando-klikk åpner lenken der i bakgrunnen." - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Gdzie otwiera się link internetowy kliknięty w czacie z agentem. Podział najbardziej po prawej używa podziału położonego najdalej w prawo na ekranie czatu albo tworzy podział obok czatu, gdy to czat jest najdalej w prawo. Czat nigdy nie jest zasłaniany. Kliknięcie z klawiszem Command otwiera tam link w tle." - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Onde abre um link da web em que você clica em um chat de agente. Divisão Mais à Direita usa a divisão mais à direita na tela do chat, ou cria uma ao lado do chat quando o chat é o mais à direita. O chat nunca fica coberto. Clicar com Command abre o link lá em segundo plano." - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Где открывается веб-ссылка, по которой вы щёлкнули в чате с агентом. «Крайнее правое разделение» использует самое правое разделение на экране чата или создаёт разделение рядом с чатом, когда чат находится справа. Чат никогда не закрывается. Щелчок с Command открывает ссылку там в фоне." - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "ตำแหน่งที่ลิงก์เว็บที่คุณคลิกในแชทเอเจนต์จะเปิด ส่วนแยกขวาสุดจะใช้ส่วนแยกที่อยู่ขวาสุดบนหน้าจอของแชท หรือสร้างส่วนแยกข้างแชทเมื่อแชทอยู่ขวาสุด แชทจะไม่ถูกบังเลย การกด Command ค้างแล้วคลิกจะเปิดลิงก์ที่นั่นในเบื้องหลัง" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Bir ajan sohbetinde tıkladığınız web bağlantısının açıldığı yer. En Sağdaki Bölme, sohbetin ekranında en sağdaki bölmeyi kullanır; sohbet en sağdaysa sohbetin yanında bir bölme oluşturur. Sohbet asla örtülmez. Command ile tıklamak bağlantıyı orada arka planda açar." - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Де відкривається вебпосилання, на яке ви клацнули в чаті з агентом. «Крайній правий поділ» використовує найправіший поділ на екрані чату або створює поділ поруч із чатом, коли чат найправіший. Чат ніколи не закривається. Клацання з Command відкриває посилання там у фоні." - } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "Nơi liên kết web bạn nhấp trong cuộc trò chuyện với tác tử được mở. Ô chia ngoài cùng bên phải dùng ô chia xa nhất bên phải trên màn hình của cuộc trò chuyện, hoặc tạo một ô chia cạnh cuộc trò chuyện khi cuộc trò chuyện ở xa nhất bên phải. Cuộc trò chuyện không bao giờ bị che. Command-click mở liên kết ở đó trong nền." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "在智能体聊天中点按的网页链接打开的位置。“最右侧拆分”使用聊天所在屏幕上最右侧的拆分;聊天本身在最右侧时,则在聊天旁新建一个拆分。聊天永远不会被遮住。按住 Command 点按会在那里于后台打开链接。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "在代理程式聊天中點按的網頁連結打開的位置。「最右側分割」使用聊天所在螢幕上最右側的分割;聊天本身在最右側時,則在聊天旁新增一個分割。聊天永遠不會被遮住。按住 Command 點按會在那裡於背景打開連結。" - } - } - } - }, - "settings.choice.chatLinkRightmost": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Rightmost Split" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "أقصى تقسيم يمينًا" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Krajnja desna podjela" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Opdeling længst til højre" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Teilung ganz rechts" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "División más a la derecha" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Division la plus à droite" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Divisione più a destra" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "一番右の分割" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "ការបំបែកខាងស្តាំបំផុត" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "가장 오른쪽 분할" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Deling lengst til høyre" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Podział najbardziej po prawej" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Divisão Mais à Direita" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Крайнее правое разделение" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "ส่วนแยกขวาสุด" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "En Sağdaki Bölme" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Крайній правий поділ" - } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "Ô chia ngoài cùng bên phải" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "最右侧拆分" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "最右側分割" - } - } - } - }, - "settings.choice.chatLinkNewSplit": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "New Split on the Right" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "تقسيم جديد على اليمين" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Nova podjela desno" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Ny opdeling til højre" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Neue Teilung rechts" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Nueva división a la derecha" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Nouvelle division à droite" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Nuova divisione a destra" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "右に新しく分割" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "ការបំបែកថ្មីនៅខាងស្តាំ" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "오른쪽에 새 분할" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Ny deling til høyre" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Nowy podział po prawej" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Nova Divisão à Direita" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Новое разделение справа" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "ส่วนแยกใหม่ทางขวา" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Sağda Yeni Bölme" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Новий поділ праворуч" - } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "Ô chia mới bên phải" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "在右侧新建拆分" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "在右側新增分割" - } - } - } - }, - "settings.choice.chatLinkSameTab": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Tab in the Chat's Pane" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "علامة تبويب في لوحة المحادثة" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Kartica u oknu chata" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Fane i chattens rude" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Tab im Bereich des Chats" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Pestaña en el panel del chat" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Onglet dans le panneau de la discussion" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Scheda nel riquadro della chat" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "チャットのペインのタブ" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "ថេបក្នុងផ្ទាំងនៃការជជែក" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "채팅 패널의 탭" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Fane i chattens rute" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Karta w panelu czatu" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Aba no Painel do Chat" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Вкладка в панели чата" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "แท็บในแผงของแชท" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Sohbetin Bölmesinde Sekme" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Вкладка в панелі чату" - } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "Thẻ trong ngăn của cuộc trò chuyện" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "聊天窗格中的标签页" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "聊天窗格中的分頁" - } - } - } - }, "settings.layout.mainPane": { "extractionState": "manual", "localizations": { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+PanePlacement.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+PanePlacement.swift index 4cce4e3d64ed..0efca612fb8e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+PanePlacement.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+PanePlacement.swift @@ -6,7 +6,7 @@ nonisolated enum PanePlacementSettingsSchema { /// Agents may change them all: layout choices, like `layout.dockColumnMode`. static var agentSettableKeys: Set { [CmuxConfigSnapshot.newPanePlacementPath, CmuxConfigSnapshot.tileBrowsersPath, LinkPlacement.configPath, - MainPaneSetting.configPath, ChatLinkTarget.configPath] + MainPaneSetting.configPath] .reduce(into: []) { $0.insert($1.joined(separator: ".")) } } @@ -37,7 +37,7 @@ nonisolated enum PanePlacementSettingsSchema { LinkPlacement.configPath, section: .general, group: columns, title: SettingsText.keyed("settings.layout.linkPlacement", "Open Links and Files"), help: SettingsText.keyed("settings.layout.linkPlacement.help", - "Where a link or file you Command-click in a terminal or a browser page appears. Split Right uses the pane on the right, or makes one. What you open from Home or another page opens as a tab in its own pane."), + "Where a link or file you Command-click in a terminal or a browser page appears. Split Right uses the pane on the right, or makes one. What you open from an agent chat, Home or another page opens as a tab in its own pane."), kind: .choice([ SettingChoice(LinkPlacement.splitRight.rawValue, SettingsText.keyed("settings.choice.linkPlacementSplitRight", "Split Right")), SettingChoice(LinkPlacement.newColumn.rawValue, SettingsText.keyed("settings.choice.linkPlacementNewColumn", "New Column on the Right")), @@ -46,19 +46,6 @@ nonisolated enum PanePlacementSettingsSchema { default: .string(LinkPlacement.fallback.rawValue), keywords: ["link", "url", "path", "file", "open", "split", "right", "column", "pane", "tab"] ), - SettingDescriptor( - ChatLinkTarget.configPath, section: .general, group: columns, - title: SettingsText.keyed("settings.links.openTarget", "Open Links from Agent Chats"), - help: SettingsText.keyed("settings.links.openTarget.help", - "Where a web link you click in an agent chat opens. Rightmost Split uses the split furthest right on the chat's screen, or makes a split beside the chat when the chat is furthest right. The chat is never covered. Command-click opens the link there in the background."), - kind: .choice([ - SettingChoice(ChatLinkTarget.rightmost.rawValue, SettingsText.keyed("settings.choice.chatLinkRightmost", "Rightmost Split")), - SettingChoice(ChatLinkTarget.newSplit.rawValue, SettingsText.keyed("settings.choice.chatLinkNewSplit", "New Split on the Right")), - SettingChoice(ChatLinkTarget.sameTab.rawValue, SettingsText.keyed("settings.choice.chatLinkSameTab", "Tab in the Chat's Pane")), - ]), - default: .string(ChatLinkTarget.fallback.rawValue), - keywords: ["link", "url", "chat", "agent", "open", "split", "right", "rightmost", "side", "pane", "tab"] - ), SettingDescriptor( MainPaneSetting.configPath, section: .general, group: columns, title: SettingsText.keyed("settings.layout.mainPane", "Main Pane"), diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChatLinkPlacementTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChatLinkPlacementTests.swift index 4774ccb1d2ea..a20fc63bac7d 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChatLinkPlacementTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChatLinkPlacementTests.swift @@ -5,117 +5,103 @@ import Testing /// Leo 2026-10-21: "WHY DID THIS NOT OPEN TO THE SIDE?" A GitHub link clicked in an agent chat /// opened as a tab in the chat's own pane, covering the chat. And: "if the left side has a tab, any -/// link opened also goes on the left instead of opening a new split. By default, things going to the -/// rightmost split is the thing to try." ``ChatLinkPlacement`` (`links.openTarget`) decides where a -/// link from a chat goes: the rightmost split of its screen, a new split right of the chat when the -/// chat is the rightmost split, never the chat's pane or a pane on its left. +/// link opened also goes on the left instead of opening a new split", then "open on right by +/// default". ``ChatLinkPlacement`` decides where a web link from a chat goes, by +/// `layout.linkPlacement`: on the right (the right pane focused last, else the rightmost, else a new +/// split), never the chat's pane, another chat or a pane on its left; Tab in the Same Pane keeps it +/// in the agent pane. struct ChatLinkPlacementTests { private typealias Target = LinkOpenPlacement.Target - private static func target(_ columns: [[String]], _ setting: ChatLinkTarget = .fallback, chat: String = "chat", - takesTabs: (String) -> Bool = { _ in true }, recency: [String] = []) -> Target? { - ChatLinkPlacement.target(setting, chat: chat, columns: columns, chatColumn: columns.firstIndex { $0.contains(chat) }, - takesTabs: takesTabs, recency: recency) + /// `right`: the panes right of "chat", one group per column, left to right. + private static func target(_ right: [[String]], _ setting: LinkPlacement = .fallback, takesTabs: (String) -> Bool = { _ in true }, + recency: [String] = []) -> Target? { + ChatLinkPlacement.target(setting, chat: "chat", right: right, takesTabs: takesTabs, recency: recency) } - @Test func rightmostIsTheDefault() { - #expect(ChatLinkTarget.fallback == .rightmost) + @Test func onTheRightIsTheDefault() { + #expect(LinkPlacement.fallback == .splitRight) + #expect(Self.target([]) == .splitRight(of: "chat")) + #expect(Self.target([["opened"]]) == .pane("opened")) } - /// The reported case: a browser on the left, the chat on the right. The link never goes left - /// and never covers the chat: a new split opens on the chat's right. - @Test func aBrowserOnTheLeftNeverGetsTheLink() { - #expect(Self.target([["browser"], ["chat"]]) == .splitRight(of: "chat")) - #expect(Self.target([["browser"], ["chat"]], recency: ["browser", "chat"]) == .splitRight(of: "chat")) + /// No pane right of the chat (alone, or with a browser on its left, which the caller never lists): + /// a new split on its right, never the left pane. + @Test func noRightPaneMakesANewRightSplit() { + #expect(Self.target([]) == .splitRight(of: "chat")) + #expect(Self.target([[]], recency: ["browser-on-the-left"]) == .splitRight(of: "chat")) + #expect(Self.target([], .newColumn) == .newColumn(rightOf: "chat")) } - @Test func aChatAloneOnItsScreenGetsANewSplitOnItsRight() { - #expect(Self.target([["chat"]]) == .splitRight(of: "chat")) + /// Several panes on the right: the one the person focused last wins over the rightmost. + @Test func theFocusedRightSplitWinsOverTheRightmost() { + #expect(Self.target([["middle"], ["right"]], recency: ["chat", "middle", "right"]) == .pane("middle")) + #expect(Self.target([["top", "bottom"]], recency: ["chat", "bottom"]) == .pane("bottom")) } - /// The first open made the split on the right; the next one reuses it (no split per click). - @Test func thePaneOnTheRightIsReused() { - #expect(Self.target([["chat"], ["opened"]]) == .pane("opened")) + /// None of the right panes was focused: the rightmost column, its right edge first, top first. + @Test func withNoFocusedRightPaneTheRightmostWins() { + #expect(Self.target([["middle"], ["right"]], recency: ["chat", "browser-on-the-left"]) == .pane("right")) + #expect(Self.target([["middle"], ["edge-top", "edge-bottom"]]) == .pane("edge-top")) } - /// Several columns on the right: the rightmost one gets the link, not the one beside the chat. - @Test func theRightmostColumnWins() { - #expect(Self.target([["left"], ["chat"], ["middle"], ["right"]], recency: ["middle"]) == .pane("right")) + /// A pane that takes no tabs (Home's pinned dock) or shows another agent chat is skipped. + @Test func panesThatTakeNoTabsOrShowAChatAreSkipped() { + let takes: (String) -> Bool = { !["pinned", "other-chat"].contains($0) } + #expect(Self.target([["strip"], ["pinned"]], takesTabs: takes) == .pane("strip")) + #expect(Self.target([["other-chat"]], takesTabs: takes, recency: ["other-chat"]) == .splitRight(of: "chat")) } - /// The rightmost column split top and bottom: the more recently used of its panes, else the top one. - @Test func inTheRightmostColumnTheRecentPaneWins() { - #expect(Self.target([["chat"], ["top", "bottom"]]) == .pane("top")) - #expect(Self.target([["chat"], ["top", "bottom"]], recency: ["chat", "bottom", "top"]) == .pane("bottom")) - } - - /// A pane that takes no new tabs (a pinned dock) is skipped; with nothing else on the right the - /// chat gets a split. - @Test func panesThatTakeNoTabsAreSkipped() { - let takes: (String) -> Bool = { $0 != "pinned" } - #expect(Self.target([["chat"], ["strip"], ["pinned"]], takesTabs: takes) == .pane("strip")) - #expect(Self.target([["left"], ["chat"], ["pinned"]], takesTabs: takes) == .splitRight(of: "chat")) - } - - /// The chat's own column holds another pane on its right edge (below it): that pane is a - /// rightmost split, so it gets the link instead of a new split. - @Test func anotherPaneOnTheChatsRightEdgeIsUsed() { - #expect(Self.target([["left"], ["chat", "below"]]) == .pane("below")) - } - - @Test func newSplitAlwaysSplitsRightOfTheChat() { - for columns in [[["chat"]], [["browser"], ["chat"]], [["chat"], ["opened"]]] { - #expect(Self.target(columns, .newSplit) == .splitRight(of: "chat")) + /// Whatever the layout, a link on the right never covers the chat, even when the chat is listed. + @Test(arguments: [LinkPlacement.splitRight, .newColumn]) + func theChatIsNeverTheTarget(_ setting: LinkPlacement) { + for right in [[], [["chat"]], [["chat", "r"]], [["r"], ["chat"]]] as [[[String]]] { + #expect(Self.target(right, setting, recency: ["chat"]) != .pane("chat")) } } - /// Same Tab keeps the old rules (a tab in the chat's pane, or main pane layout's column). - @Test func sameTabLeavesItToThePageRules() { - #expect(Self.target([["browser"], ["chat"]], .sameTab) == nil) - #expect(Self.target([["chat"], ["opened"]], .sameTab) == nil) - } - - /// Whatever the setting, a link from a chat never lands in a pane left of the chat, nor as a tab - /// over the chat itself. - @Test(arguments: [ChatLinkTarget.rightmost, .newSplit]) - func neverTheLeftNorTheChat(_ setting: ChatLinkTarget) { - let layouts: [[[String]]] = [[["chat"]], [["l1"], ["chat"]], [["l1", "l2"], ["chat"], ["r"]], [["l1"], ["chat", "below"]]] - for columns in layouts { - let target = Self.target(columns, setting, recency: ["l1", "l2"]) - #expect(target != .pane("chat")) - #expect(target != .pane("l1")) - #expect(target != .pane("l2")) - } - } - - /// The chat's column is unknown (it is not on the screen): a split on its right, never a guess. - @Test func anUnknownColumnSplitsRight() { - #expect(ChatLinkPlacement.target(.rightmost, chat: "chat", columns: [["a"]], chatColumn: nil, - takesTabs: { _ in true }, recency: []) == .splitRight(of: "chat")) + /// Tab in the Same Pane: "in the agent pane", by the rules for any page (nil here). + @Test func samePaneKeepsTheLinkInTheAgentPane() { + #expect(Self.target([["opened"]], .samePane) == nil) + #expect(Self.target([], .samePane) == nil) } /// Right edges come from the split tree: a side-by-side split's right side, both halves of a /// stacked one. @Test func theRightEdgeOfASplitTree() { - let (a, b, c, d): (PaneID, PaneID, PaneID, PaneID) = ("a", "b", "c", "d") - let tree = SplitNode.split("s1", axis: .horizontal, ratio: 0.5, a: .leaf(a), - b: .split("s2", axis: .vertical, ratio: 0.5, a: .leaf(b), - b: .split("s3", axis: .horizontal, ratio: 0.5, a: .leaf(c), b: .leaf(d)))) - #expect(ChatLinkPlacement.rightEdge(of: tree) == [b, d]) - #expect(ChatLinkPlacement.rightEdge(of: .leaf(a)) == [a]) + let tree = SplitNode.split("s1", axis: .horizontal, ratio: 0.5, a: .leaf("a"), + b: .split("s2", axis: .vertical, ratio: 0.5, a: .leaf("b"), + b: .split("s3", axis: .horizontal, ratio: 0.5, a: .leaf("c"), b: .leaf("d")))) + #expect(ChatLinkPlacement.rightEdge(of: tree) == ["b", "d"]) + #expect(ChatLinkPlacement.rightEdge(of: .leaf("a")) == ["a"]) + } + + /// In the chat's own split tree, only panes beside it on its right count: not the left side, + /// not panes stacked above or below it. + @Test func panesRightOfTheChatInItsTree() { + // [left | [chat / below] | right] + let tree = SplitNode.split("s1", axis: .horizontal, ratio: 0.3, a: .leaf("left"), + b: .split("s2", axis: .horizontal, ratio: 0.5, + a: .split("s3", axis: .vertical, ratio: 0.5, a: .leaf("chat"), b: .leaf("below")), + b: .leaf("right"))) + #expect(ChatLinkPlacement.panes(rightOf: "chat", in: tree) == ["right"]) + #expect(ChatLinkPlacement.panes(rightOf: "right", in: tree).isEmpty) + #expect(ChatLinkPlacement.panes(rightOf: "left", in: tree) == ["chat", "below", "right"]) } - /// A screen's columns: left dock, strip, right dock in order; a bottom band is not beside the chat. - @Test func columnsSkipBandsAndFindTheChat() { - let (chat, strip, right, band): (PaneID, PaneID, PaneID, PaneID) = ("chat", "strip", "right", "band") + /// The screen's columns: a browser column left of the chat is never on its right; a bottom band + /// is not beside it; the chat's own column contributes only what is right of the chat. + @Test func theRightSideOfAScreen() { let columns = [ - LayoutColumn(id: "chat", root: .leaf(chat), dock: DockColumn(edge: .left, role: .agentChat)), - LayoutColumn(id: "strip", root: .leaf(strip)), - LayoutColumn(id: "band", root: .leaf(band), dock: DockColumn(edge: .bottom)), - LayoutColumn(id: "right", root: .leaf(right), dock: DockColumn(edge: .right)), + LayoutColumn(id: "browser", root: .leaf("browser")), + LayoutColumn(id: "chat", root: .split("s1", axis: .horizontal, ratio: 0.5, a: .leaf("chat"), b: .leaf("beside"))), + LayoutColumn(id: "band", root: .leaf("band"), dock: DockColumn(edge: .bottom)), + LayoutColumn(id: "strip", root: .split("s2", axis: .horizontal, ratio: 0.5, a: .leaf("inner"), b: .leaf("edge"))), ] - let found = ChatLinkPlacement.columns(columns, chat: chat) - #expect(found.edges == [[chat], [strip], [right]]) - #expect(found.chatColumn == 0) + #expect(ChatLinkPlacement.rightSide(of: "chat", in: columns) == [["beside"], ["edge", "inner"]]) + #expect(ChatLinkPlacement.rightSide(of: "missing", in: columns).isEmpty) + let right = ChatLinkPlacement.rightSide(of: "chat", in: columns) + #expect(ChatLinkPlacement.target(.splitRight, chat: "chat", right: right, takesTabs: { _ in true }, + recency: ["browser", "chat"]) == .pane("edge")) } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextSettingsTests/ChatLinkTargetSettingTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextSettingsTests/ChatLinkTargetSettingTests.swift deleted file mode 100644 index 613fda7daf93..000000000000 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextSettingsTests/ChatLinkTargetSettingTests.swift +++ /dev/null @@ -1,36 +0,0 @@ -import CmuxNextSettings -import Testing - -/// `links.openTarget`: where a link opened from an agent chat goes. The rightmost split of the -/// chat's screen by default (Leo 2026-10-21); "newSplit" always splits right of the chat; "sameTab" -/// keeps the old tab in the chat's pane. Agents may set it (a layout choice, like `layout.linkPlacement`). -struct ChatLinkTargetSettingTests { - private static func parse(_ value: JSONValue?) -> CmuxConfigSnapshot { - let root: JSONValue = value.map { .object(["links": .object(["openTarget": $0])]) } ?? .object([:]) - return CmuxConfigSnapshot.parse(root, validDensities: [], validMetrics: []) - } - - @Test func rightmostWhenUnset() { - let snapshot = Self.parse(nil) - #expect(snapshot.chatLinkTarget == .rightmost) - #expect(snapshot.diagnostics.isEmpty) - } - - @Test(arguments: [("rightmost", ChatLinkTarget.rightmost), ("newSplit", .newSplit), ("sameTab", .sameTab)]) - func eachChoiceLoads(raw: String, expected: ChatLinkTarget) { - #expect(Self.parse(.string(raw)).chatLinkTarget == expected) - } - - @Test func aBadValueKeepsTheDefaultAndSaysWhy() { - let snapshot = Self.parse(.string("left")) - #expect(snapshot.chatLinkTarget == .rightmost) - #expect(snapshot.diagnostics.contains { $0.path == "links.openTarget" }) - } - - @Test func theRowMatchesTheAppAndAgentsMaySetIt() throws { - let row = try #require(SettingsSchema.descriptor(for: ["links", "openTarget"])) - #expect(row.defaultValue == .string("rightmost")) - #expect(SettingsSchema.agentSettableKeys.contains("links.openTarget")) - #expect(SettingsSchema.agentRefusedKeys["links.openTarget"] == nil) - } -} diff --git a/release-notes/next/highlights/agent-chat-links-open-to-the-side.md b/release-notes/next/highlights/agent-chat-links-open-to-the-side.md index 7066ac6c791f..7a9fc96b95fd 100644 --- a/release-notes/next/highlights/agent-chat-links-open-to-the-side.md +++ b/release-notes/next/highlights/agent-chat-links-open-to-the-side.md @@ -1,4 +1,4 @@ title: Links from an agent chat open beside it category: improved -A link you click in an agent chat opens in the rightmost split, or a new split on the chat's right, never over the chat or on its left. Command-click opens it in the background. Change it in Settings > General > Open Links from Agent Chats. +A link you click in an agent chat opens on the right, in the right pane you used last or a new split, never over the chat or on its left. Command-click opens it in the background. Change it in Open Links and Files. From 2e0d8d39582321ebc58a9286d757b8d3ee5ac041 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 21 Oct 2026 21:16:38 -0700 Subject: [PATCH 4/6] Agent chat links: Tab in the Same Pane stays in the chat's pane; a zoomed 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 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a1602e-25ea-7611-8c79-d16df0c4058f&host=cosmic-gray --- .../CmuxNextApp/Links/ChatLinkPlacement.swift | 17 ++-- .../CmuxNextApp/Links/LinkOpener.swift | 12 ++- .../ChatLinkPlacementTests.swift | 33 +++++-- .../LinkOpenerChatLinkTests.swift | 89 +++++++++++++++++++ 4 files changed, 133 insertions(+), 18 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/LinkOpenerChatLinkTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/ChatLinkPlacement.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/ChatLinkPlacement.swift index aa52cbf4a999..5cca9f37ca5a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/ChatLinkPlacement.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/ChatLinkPlacement.swift @@ -10,25 +10,28 @@ import CmuxNextSettings /// right of the chat gets a tab: the one the person focused last, else the rightmost. With no /// pane there, a new split (or column) on the chat's right. A pane left of the chat never gets /// it, and neither does a pane that shows an agent chat (the caller's `takesTabs` says no). -/// - ``LinkPlacement/samePane``: nil, "in the agent pane": the rules for any page apply -/// (``LinkOpener``: a tab in the chat's pane, or main pane layout's column). +/// - ``LinkPlacement/samePane``: "in the agent pane": a tab in the chat's own pane, never main pane +/// layout's dock or column. In the chat dock, which holds only chats, nil: the rules for any page. +/// - A zoomed chat fills the screen and the panes beside it are hidden, so the link never goes to +/// one of them: on the right it opens a new split (or column), which ends the zoom. /// /// Command-click keeps its meaning: the tab opens in the same target, in the background. Tabs can /// still be dragged anywhere afterwards. `Pane` is the caller's pane identity (`LayoutPaneID` in the /// app), so the rule runs without a window or a daemon. nonisolated enum ChatLinkPlacement { - /// Where a link from the chat in pane `chat` goes, or nil for ``LinkPlacement/samePane``. - /// `right` lists the panes right of the chat, one group per column left to right (the chat's own + /// Where a link from the chat in pane `chat` goes, or nil for ``LinkPlacement/samePane`` in the + /// chat dock (`inChatDock`). `zoomed`: the chat's pane is zoomed. `right` lists the panes right of the chat, one group per column left to right (the chat's own /// column first, with only its panes right of the chat), each group's panes on the column's right /// edge first, top to bottom (``rightSide(of:in:)``). `takesTabs` tells which panes may get the /// link; `recency` lists panes newest first. static func target(_ setting: LinkPlacement, chat: Pane, right: [[Pane]], + zoomed: Bool = false, inChatDock: Bool = false, takesTabs: (Pane) -> Bool, recency: [Pane]) -> LinkOpenPlacement.Target? { switch setting { case .samePane: - return nil + return inChatDock ? nil : .pane(chat) case .splitRight, .newColumn: - if let pane = pane(from: chat, right: right, takesTabs: takesTabs, recency: recency) { return .pane(pane) } + if !zoomed, let pane = pane(from: chat, right: right, takesTabs: takesTabs, recency: recency) { return .pane(pane) } return setting == .newColumn ? .newColumn(rightOf: chat) : .splitRight(of: chat) } } @@ -64,7 +67,7 @@ nonisolated enum ChatLinkPlacement { } } - /// `right` as ``target(_:chat:right:takesTabs:recency:)`` reads it, from the chat's screen's + /// `right` as ``target(_:chat:right:zoomed:inChatDock:takesTabs:recency:)`` reads it, from the chat's screen's /// `columns` in the order the person sees them (`ScreenLayout.visualColumns`). Top and bottom /// docks are bands across the screen, not beside the chat: they are left out. Empty when no /// column holds `chat`. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/LinkOpener.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/LinkOpener.swift index d8a3b323164e..c8dcea6effd3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/LinkOpener.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/LinkOpener.swift @@ -101,16 +101,19 @@ final class LinkOpener { /// Where a web link from `source` goes when `source` shows an agent chat (``ChatLinkPlacement``, by /// `layout.linkPlacement`): the pane right of the chat the person focused last, else the rightmost, - /// else a new split (or column) on its right. A pane that shows an agent chat never gets it. Nil - /// when `source` shows no chat or takes no tabs, or the setting is Tab in the Same Pane: the rules - /// above. A lone chat under main pane layout still docks left (``MainPaneLayout``), so the link + /// else a new split (or column) on its right. A pane that shows an agent chat never gets it. Tab + /// in the Same Pane: a tab in the chat's pane, with no docking. A zoomed chat opens a new split, + /// which ends the zoom, never a tab in a pane the zoom hides. Nil when `source` shows no chat or + /// takes no tabs, or for Tab in the Same Pane in the chat dock: the rules above. A lone chat + /// under main pane layout still docks left (``MainPaneLayout``) for a link on the right, so it /// lands in the column that slides in on its right. func chatLinkTarget(from source: PaneController, mainPane: Bool = true) -> LinkOpenPlacement.Target? { guard let services, source.stripModel.showsNewTabButton, let tab = source.selectedTab, ChatColumnPlacement.isChat(tab, services: services), let content = source.workspace, let screen = content.layoutModel.screen(containing: source.layoutPaneID) else { return nil } let setting = placement() - if setting != .samePane, mainPane, case .dockChat? = MainPaneLayout.side(from: source, services: services) { + let zoomed = content.workspace.screens.contains { $0.zoomedPane == source.pane.handle } + if setting != .samePane, !zoomed, mainPane, case .dockChat? = MainPaneLayout.side(from: source, services: services) { return .dockChat(source.layoutPaneID) } let columns = screen.layout.columns.isEmpty @@ -121,6 +124,7 @@ final class LinkOpener { } return ChatLinkPlacement.target(setting, chat: source.layoutPaneID, right: ChatLinkPlacement.rightSide(of: source.layoutPaneID, in: columns), + zoomed: zoomed, inChatDock: ChatDockRules.isChatDock(source.pane, services: services), takesTabs: takesTabs, recency: content.recentPanes) } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChatLinkPlacementTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChatLinkPlacementTests.swift index a20fc63bac7d..0d1c90d319ab 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChatLinkPlacementTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChatLinkPlacementTests.swift @@ -8,15 +8,17 @@ import Testing /// link opened also goes on the left instead of opening a new split", then "open on right by /// default". ``ChatLinkPlacement`` decides where a web link from a chat goes, by /// `layout.linkPlacement`: on the right (the right pane focused last, else the rightmost, else a new -/// split), never the chat's pane, another chat or a pane on its left; Tab in the Same Pane keeps it -/// in the agent pane. +/// split), never the chat's pane, another chat, a pane on its left or a pane a zoom hides; Tab in +/// the Same Pane keeps it in the agent pane. struct ChatLinkPlacementTests { private typealias Target = LinkOpenPlacement.Target /// `right`: the panes right of "chat", one group per column, left to right. - private static func target(_ right: [[String]], _ setting: LinkPlacement = .fallback, takesTabs: (String) -> Bool = { _ in true }, + private static func target(_ right: [[String]], _ setting: LinkPlacement = .fallback, zoomed: Bool = false, + inChatDock: Bool = false, takesTabs: (String) -> Bool = { _ in true }, recency: [String] = []) -> Target? { - ChatLinkPlacement.target(setting, chat: "chat", right: right, takesTabs: takesTabs, recency: recency) + ChatLinkPlacement.target(setting, chat: "chat", right: right, zoomed: zoomed, inChatDock: inChatDock, + takesTabs: takesTabs, recency: recency) } @Test func onTheRightIsTheDefault() { @@ -60,10 +62,27 @@ struct ChatLinkPlacementTests { } } - /// Tab in the Same Pane: "in the agent pane", by the rules for any page (nil here). + /// Tab in the Same Pane: "in the agent pane", a tab in the chat's own pane whatever is on its + /// right or how it is zoomed, never docked or sent to main pane layout's column. @Test func samePaneKeepsTheLinkInTheAgentPane() { - #expect(Self.target([["opened"]], .samePane) == nil) - #expect(Self.target([], .samePane) == nil) + #expect(Self.target([["opened"]], .samePane) == .pane("chat")) + #expect(Self.target([], .samePane) == .pane("chat")) + #expect(Self.target([["opened"]], .samePane, zoomed: true, recency: ["opened"]) == .pane("chat")) + } + + /// The chat dock holds only chats, so Tab in the Same Pane there falls back to the rules for + /// any page (nil here) rather than a web tab the dock would refuse. + @Test func samePaneInTheChatDockFallsBackToThePageRules() { + #expect(Self.target([["opened"]], .samePane, inChatDock: true) == nil) + #expect(Self.target([["opened"]], inChatDock: true) == .pane("opened")) + } + + /// A zoomed chat fills the screen and hides the panes beside it: the link opens a new split (or + /// column) on its right, which ends the zoom, never a tab in a hidden pane. + @Test func aZoomedChatOpensANewSplitNotAHiddenPane() { + #expect(Self.target([["hidden"]], zoomed: true, recency: ["hidden"]) == .splitRight(of: "chat")) + #expect(Self.target([["hidden"], ["also-hidden"]], .newColumn, zoomed: true) == .newColumn(rightOf: "chat")) + #expect(Self.target([], zoomed: true) == .splitRight(of: "chat")) } /// Right edges come from the split tree: a side-by-side split's right side, both halves of a diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/LinkOpenerChatLinkTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/LinkOpenerChatLinkTests.swift new file mode 100644 index 000000000000..fe2a9e805e8c --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/LinkOpenerChatLinkTests.swift @@ -0,0 +1,89 @@ +import AppKit +import CmuxNextBridge +@testable import CmuxNextApp +@testable import CmuxNextDaemon +import CmuxNextSettings +import Foundation +import Testing + +/// ``LinkOpener/target(from:cmdClick:mainPane:chatLink:)`` for a web link from an agent chat, on a +/// real workspace: an agent chat split beside a terminal (Leo 2026-10-21: "WHY DID THIS NOT OPEN TO +/// THE SIDE?", "open on right by default"). On the right by default; Tab in the Same Pane keeps it in +/// the chat's pane; a zoomed chat opens a new split rather than a tab in the pane its zoom hides. +@MainActor struct LinkOpenerChatLinkTests { + private static func tree(zoomed: Bool) -> DaemonTree { + var chat = TabSnapshot(surface: SurfaceID(rawValue: 1), kind: .conversation, cwd: nil) + chat.conversation = ConversationTabRef(agentSession: AgentSessionRef(host: "install:test", session: "chat-1", harness: "codex")) + let first = PaneSnapshot(id: PaneID(rawValue: 1), tabs: [chat]) + let second = PaneSnapshot(id: PaneID(rawValue: 2), tabs: [TabSnapshot(surface: SurfaceID(rawValue: 2), title: "~")]) + let layout: LayoutNode = .split(id: nil, resourceID: nil, direction: .right, ratio: 0.5, a: .leaf(first.id), b: .leaf(second.id)) + let screen = ScreenSnapshot(id: ScreenID(rawValue: 1), zoomedPane: zoomed ? first.id : nil, layout: layout, panes: [first, second]) + return DaemonTree(workspaces: [WorkspaceSnapshot(id: WorkspaceHandle(rawValue: 1), key: nil, name: "w1", screens: [screen])]) + } + + private static func connected() throws -> DaemonIdentity { + let json = #"{"app":"cmux-tui","protocol":1,"session":"link-opener-chat-link-test","generation":"g1"}"# + return try JSONDecoder().decode(DaemonIdentity.self, from: Data(json.utf8)) + } + + /// The chat | terminal workspace shown in a borderless window, laid out. + private static func shown(zoomed: Bool = false) throws -> (AppServices, NSWindow, WorkspaceContentController) { + _ = NSApplication.shared + let services = AppServices(environment: AppEnvironment.current([:])) + let store = services.daemon.store + store.connectionState = .connected(try Self.connected()) + store.apply(snapshot: Self.tree(zoomed: zoomed)) + let workspace = try #require(store.workspaces.first) + let record = try JSONDecoder().decode(WindowRecord.self, from: Data(#"{"id":"w1","workspace_key":"w1"}"#.utf8)) + let state = WindowState(record: record) + let window = NSWindow(contentRect: NSRect(x: 200, y: 200, width: 1200, height: 700), styleMask: [.borderless], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + let content = WorkspaceContentController(workspace: workspace, daemon: services.daemon, services: services, state: state) + content.contentView.frame = NSRect(x: 0, y: 0, width: 1200, height: 700) + window.contentView = content.contentView + window.orderFrontRegardless() + content.applyCurrent() + content.contentView.layoutSubtreeIfNeeded() + return (services, window, content) + } + + /// The chat's pane and the terminal's, by the surface each shows. + private static func panes(_ content: WorkspaceContentController) throws -> (chat: PaneController, terminal: PaneController?) { + let controllers = Array(content.panes.values) + let chat = try #require(controllers.first { $0.selectedTab?.surface == SurfaceID(rawValue: 1) }, "the chat's pane is not shown") + return (chat, controllers.first { $0.selectedTab?.surface == SurfaceID(rawValue: 2) }) + } + + @Test(arguments: [LinkPlacement.splitRight, .newColumn]) + func aChatLinkOpensInThePaneOnItsRight(_ placement: LinkPlacement) throws { + let (services, window, content) = try Self.shown() + defer { withExtendedLifetime(services) { content.teardown(); window.close() } } + let (chat, terminal) = try Self.panes(content) + let right = try #require(terminal) + services.linkOpener.placement = { placement } + #expect(services.linkOpener.target(from: chat, chatLink: true) == .pane(right.layoutPaneID)) + // The terminal shows no chat: its links keep the rules for any pane. + #expect(services.linkOpener.chatLinkTarget(from: right) == nil) + } + + @Test func tabInTheSamePaneKeepsAChatLinkInTheChatsPane() throws { + let (services, window, content) = try Self.shown() + defer { withExtendedLifetime(services) { content.teardown(); window.close() } } + let (chat, _) = try Self.panes(content) + services.linkOpener.placement = { .samePane } + #expect(services.linkOpener.target(from: chat, chatLink: true) == .pane(chat.layoutPaneID)) + } + + /// The zoomed chat hides the terminal: the link opens a new split on its right (which ends the + /// zoom), never a tab in the hidden terminal. + @Test func aZoomedChatLinkOpensANewSplitNotTheHiddenPane() throws { + let (services, window, content) = try Self.shown(zoomed: true) + defer { withExtendedLifetime(services) { content.teardown(); window.close() } } + let (chat, _) = try Self.panes(content) + services.linkOpener.placement = { .splitRight } + #expect(services.linkOpener.target(from: chat, chatLink: true) == .splitRight(of: chat.layoutPaneID)) + services.linkOpener.placement = { .newColumn } + #expect(services.linkOpener.target(from: chat, chatLink: true) == .newColumn(rightOf: chat.layoutPaneID)) + } +} From 95186d95421aaf57a119abb908e3af3791a5bd9d Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 21 Oct 2026 21:55:07 -0700 Subject: [PATCH 5/6] What's New: chat links highlight in two sentences, with a docs link 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 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a1602e-25ea-7611-8c79-d16df0c4058f&host=cosmic-gray --- .../next/highlights/agent-chat-links-open-to-the-side.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/release-notes/next/highlights/agent-chat-links-open-to-the-side.md b/release-notes/next/highlights/agent-chat-links-open-to-the-side.md index 7a9fc96b95fd..3aa5702361e2 100644 --- a/release-notes/next/highlights/agent-chat-links-open-to-the-side.md +++ b/release-notes/next/highlights/agent-chat-links-open-to-the-side.md @@ -1,4 +1,5 @@ title: Links from an agent chat open beside it category: improved +docs: https://cmux.com/docs/configuration -A link you click in an agent chat opens on the right, in the right pane you used last or a new split, never over the chat or on its left. Command-click opens it in the background. Change it in Open Links and Files. +A link you click in an agent chat opens on the right, in the right pane you used last or a new split, never over the chat or on its left. Command-click opens it in the background, and Open Links and Files changes where it goes. From 8a1ec9c607e3d9a47f03ba8f083ea9ff40bd24b1 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 21 Oct 2026 23:14:43 -0700 Subject: [PATCH 6/6] Agent chat links: a new column from the chat dock; page rules without 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 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a1602e-25ea-7611-8c79-d16df0c4058f&host=cosmic-gray --- .../CmuxNextApp/Handlers/ChatDockRules.swift | 3 +- .../CmuxNextApp/Links/ChatLinkPlacement.swift | 15 ++- .../CmuxNextApp/Links/LinkOpener.swift | 9 +- .../ChatLinkPlacementTests.swift | 28 ++++- .../LinkOpenerChatLinkTests.swift | 115 +++++++++++++++--- 5 files changed, 140 insertions(+), 30 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/ChatDockRules.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/ChatDockRules.swift index d8e5fd7acd7c..c4bd0ea58baa 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/ChatDockRules.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/ChatDockRules.swift @@ -8,7 +8,8 @@ import CmuxNextLayout /// holds only agent chats, so tools dropped or moved into it are refused. enum ChatDockRules { - private static func isChatDock(_ pane: LayoutPaneID, _ columns: [LayoutColumn]) -> Bool { + /// Whether `pane` is in the chat dock of a screen with `columns`. + static func isChatDock(_ pane: LayoutPaneID, _ columns: [LayoutColumn]) -> Bool { columns.first { $0.root.contains(pane) }?.dock?.role == .agentChat } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/ChatLinkPlacement.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/ChatLinkPlacement.swift index 5cca9f37ca5a..5bdfaf358297 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/ChatLinkPlacement.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/ChatLinkPlacement.swift @@ -14,25 +14,32 @@ import CmuxNextSettings /// layout's dock or column. In the chat dock, which holds only chats, nil: the rules for any page. /// - A zoomed chat fills the screen and the panes beside it are hidden, so the link never goes to /// one of them: on the right it opens a new split (or column), which ends the zoom. +/// - The chat dock never splits (``ChatDockRules``): from it, with no pane on the right to take the +/// link, a new column opens beside the dock. +/// - A tab that cannot be moved (`movesTabs` false: a session-local tab, made when the machine's +/// daemon serves no browser tabs) never reaches a new split or column, so the link goes by the +/// rules for any page (nil) rather than opening unseen. /// /// Command-click keeps its meaning: the tab opens in the same target, in the background. Tabs can /// still be dragged anywhere afterwards. `Pane` is the caller's pane identity (`LayoutPaneID` in the /// app), so the rule runs without a window or a daemon. nonisolated enum ChatLinkPlacement { /// Where a link from the chat in pane `chat` goes, or nil for ``LinkPlacement/samePane`` in the - /// chat dock (`inChatDock`). `zoomed`: the chat's pane is zoomed. `right` lists the panes right of the chat, one group per column left to right (the chat's own + /// chat dock (`inChatDock`) or when the target needs a move that `movesTabs` false rules out. + /// `zoomed`: the chat's pane is zoomed. `right` lists the panes right of the chat, one group per column left to right (the chat's own /// column first, with only its panes right of the chat), each group's panes on the column's right /// edge first, top to bottom (``rightSide(of:in:)``). `takesTabs` tells which panes may get the /// link; `recency` lists panes newest first. static func target(_ setting: LinkPlacement, chat: Pane, right: [[Pane]], - zoomed: Bool = false, inChatDock: Bool = false, + zoomed: Bool = false, inChatDock: Bool = false, movesTabs: Bool = true, takesTabs: (Pane) -> Bool, recency: [Pane]) -> LinkOpenPlacement.Target? { switch setting { case .samePane: return inChatDock ? nil : .pane(chat) case .splitRight, .newColumn: if !zoomed, let pane = pane(from: chat, right: right, takesTabs: takesTabs, recency: recency) { return .pane(pane) } - return setting == .newColumn ? .newColumn(rightOf: chat) : .splitRight(of: chat) + guard movesTabs else { return nil } + return setting == .newColumn || inChatDock ? .newColumn(rightOf: chat) : .splitRight(of: chat) } } @@ -67,7 +74,7 @@ nonisolated enum ChatLinkPlacement { } } - /// `right` as ``target(_:chat:right:zoomed:inChatDock:takesTabs:recency:)`` reads it, from the chat's screen's + /// `right` as ``target(_:chat:right:zoomed:inChatDock:movesTabs:takesTabs:recency:)`` reads it, from the chat's screen's /// `columns` in the order the person sees them (`ScreenLayout.visualColumns`). Top and bottom /// docks are bands across the screen, not beside the chat: they are left out. Empty when no /// column holds `chat`. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/LinkOpener.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/LinkOpener.swift index 1f58ee8397ee..5e5a2a09fc36 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/LinkOpener.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/LinkOpener.swift @@ -103,8 +103,10 @@ final class LinkOpener { /// `layout.linkPlacement`): the pane right of the chat the person focused last, else the rightmost, /// else a new split (or column) on its right. A pane that shows an agent chat never gets it. Tab /// in the Same Pane: a tab in the chat's pane, with no docking. A zoomed chat opens a new split, - /// which ends the zoom, never a tab in a pane the zoom hides. Nil when `source` shows no chat or - /// takes no tabs, or for Tab in the Same Pane in the chat dock: the rules above. A lone chat + /// which ends the zoom, never a tab in a pane the zoom hides; the chat dock, which never splits, + /// opens a new column. Nil when `source` shows no chat or takes no tabs, for Tab in the Same Pane + /// in the chat dock, or when the link would need a new split or column but its machine serves no + /// daemon browser tabs (a session-local tab cannot be moved there): the rules above. A lone chat /// under main pane layout still docks left (``MainPaneLayout``) for a link on the right, so it /// lands in the column that slides in on its right. func chatLinkTarget(from source: PaneController, mainPane: Bool = true) -> LinkOpenPlacement.Target? { @@ -124,7 +126,8 @@ final class LinkOpener { } return ChatLinkPlacement.target(setting, chat: source.layoutPaneID, right: ChatLinkPlacement.rightSide(of: source.layoutPaneID, in: columns), - zoomed: zoomed, inChatDock: ChatDockRules.isChatDock(source.pane, services: services), + zoomed: zoomed, inChatDock: ChatDockRules.isChatDock(source.layoutPaneID, columns), + movesTabs: services.cache.browserTabs.isAvailable(in: source.pane), takesTabs: takesTabs, recency: content.recentPanes) } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChatLinkPlacementTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChatLinkPlacementTests.swift index 0d1c90d319ab..8d333691b9e2 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChatLinkPlacementTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChatLinkPlacementTests.swift @@ -15,10 +15,10 @@ struct ChatLinkPlacementTests { /// `right`: the panes right of "chat", one group per column, left to right. private static func target(_ right: [[String]], _ setting: LinkPlacement = .fallback, zoomed: Bool = false, - inChatDock: Bool = false, takesTabs: (String) -> Bool = { _ in true }, + inChatDock: Bool = false, movesTabs: Bool = true, takesTabs: (String) -> Bool = { _ in true }, recency: [String] = []) -> Target? { ChatLinkPlacement.target(setting, chat: "chat", right: right, zoomed: zoomed, inChatDock: inChatDock, - takesTabs: takesTabs, recency: recency) + movesTabs: movesTabs, takesTabs: takesTabs, recency: recency) } @Test func onTheRightIsTheDefault() { @@ -77,6 +77,30 @@ struct ChatLinkPlacementTests { #expect(Self.target([["opened"]], inChatDock: true) == .pane("opened")) } + /// The chat dock never splits: from it, with every pane on the right showing another chat (or + /// none there), the link opens in a new column beside the dock, never a refused split that + /// would leave the tab over the chat. + @Test(arguments: [LinkPlacement.splitRight, .newColumn]) + func fromTheChatDockANewColumnNeverASplit(_ setting: LinkPlacement) { + let takes: (String) -> Bool = { !$0.hasSuffix("chat") } + #expect(Self.target([["other-chat"], ["third-chat"]], setting, inChatDock: true, takesTabs: takes) == .newColumn(rightOf: "chat")) + #expect(Self.target([], setting, inChatDock: true) == .newColumn(rightOf: "chat")) + #expect(Self.target([["other-chat"], ["browser"]], setting, inChatDock: true, takesTabs: takes) == .pane("browser")) + #expect(Self.target([["hidden"]], setting, zoomed: true, inChatDock: true) == .newColumn(rightOf: "chat")) + } + + /// A tab that cannot be moved (a session-local tab: the machine's daemon serves no browser tabs) + /// never needs a new split or column: the rules for any page (nil here) open it where it shows. + /// A pane on the right, or the chat's own pane, needs no move and still gets it. + @Test func aTabThatCannotMoveNeverNeedsANewSplit() { + #expect(Self.target([], movesTabs: false) == nil) + #expect(Self.target([], .newColumn, movesTabs: false) == nil) + #expect(Self.target([["hidden"]], zoomed: true, movesTabs: false) == nil) + #expect(Self.target([], inChatDock: true, movesTabs: false) == nil) + #expect(Self.target([["opened"]], movesTabs: false) == .pane("opened")) + #expect(Self.target([], .samePane, movesTabs: false) == .pane("chat")) + } + /// A zoomed chat fills the screen and hides the panes beside it: the link opens a new split (or /// column) on its right, which ends the zoom, never a tab in a hidden pane. @Test func aZoomedChatOpensANewSplitNotAHiddenPane() { diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/LinkOpenerChatLinkTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/LinkOpenerChatLinkTests.swift index fe2a9e805e8c..f20a373ed80b 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/LinkOpenerChatLinkTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/LinkOpenerChatLinkTests.swift @@ -7,17 +7,49 @@ import Foundation import Testing /// ``LinkOpener/target(from:cmdClick:mainPane:chatLink:)`` for a web link from an agent chat, on a -/// real workspace: an agent chat split beside a terminal (Leo 2026-10-21: "WHY DID THIS NOT OPEN TO -/// THE SIDE?", "open on right by default"). On the right by default; Tab in the Same Pane keeps it in -/// the chat's pane; a zoomed chat opens a new split rather than a tab in the pane its zoom hides. +/// real workspace (Leo 2026-10-21: "WHY DID THIS NOT OPEN TO THE SIDE?", "open on right by +/// default"). On the right by default; Tab in the Same Pane keeps it in the chat's pane; a zoomed +/// chat opens a new split rather than a tab in the pane its zoom hides; the chat dock, which never +/// splits, opens a new column; a machine whose daemon serves no browser tabs (a session-local tab, +/// which cannot be moved) keeps the rules for any page, so the tab still opens selected. @MainActor struct LinkOpenerChatLinkTests { - private static func tree(zoomed: Bool) -> DaemonTree { - var chat = TabSnapshot(surface: SurfaceID(rawValue: 1), kind: .conversation, cwd: nil) - chat.conversation = ConversationTabRef(agentSession: AgentSessionRef(host: "install:test", session: "chat-1", harness: "codex")) - let first = PaneSnapshot(id: PaneID(rawValue: 1), tabs: [chat]) - let second = PaneSnapshot(id: PaneID(rawValue: 2), tabs: [TabSnapshot(surface: SurfaceID(rawValue: 2), title: "~")]) - let layout: LayoutNode = .split(id: nil, resourceID: nil, direction: .right, ratio: 0.5, a: .leaf(first.id), b: .leaf(second.id)) - let screen = ScreenSnapshot(id: ScreenID(rawValue: 1), zoomedPane: zoomed ? first.id : nil, layout: layout, panes: [first, second]) + /// The chat's surface; every other pane shows `others`. + private static let chatSurface = SurfaceID(rawValue: 1) + + private static func chat(_ surface: SurfaceID, session: String) -> TabSnapshot { + var tab = TabSnapshot(surface: surface, kind: .conversation, cwd: nil) + tab.conversation = ConversationTabRef(agentSession: AgentSessionRef(host: "install:test", session: session, harness: "codex")) + return tab + } + + /// The layouts under test: pane 1 shows the chat, pane 2 a terminal or another chat. + enum Shape { + /// chat | terminal. + case chatThenTerminal(zoomed: Bool) + /// terminal | chat: nothing on the chat's right. + case terminalThenChat + /// The chat in the chat dock, another chat in the column on its right. + case chatDockBesideAnotherChat + } + + private static func tree(_ shape: Shape) -> DaemonTree { + let chatPane = PaneSnapshot(id: PaneID(rawValue: 1), tabs: [chat(chatSurface, session: "chat-1")]) + let terminal = PaneSnapshot(id: PaneID(rawValue: 2), tabs: [TabSnapshot(surface: SurfaceID(rawValue: 2), title: "~")]) + let screen: ScreenSnapshot + switch shape { + case .chatThenTerminal(let zoomed): + let layout: LayoutNode = .split(id: nil, resourceID: nil, direction: .right, ratio: 0.5, a: .leaf(chatPane.id), b: .leaf(terminal.id)) + screen = ScreenSnapshot(id: ScreenID(rawValue: 1), zoomedPane: zoomed ? chatPane.id : nil, layout: layout, panes: [chatPane, terminal]) + case .terminalThenChat: + let layout: LayoutNode = .split(id: nil, resourceID: nil, direction: .right, ratio: 0.5, a: .leaf(terminal.id), b: .leaf(chatPane.id)) + screen = ScreenSnapshot(id: ScreenID(rawValue: 1), layout: layout, panes: [terminal, chatPane]) + case .chatDockBesideAnotherChat: + let other = PaneSnapshot(id: PaneID(rawValue: 2), tabs: [chat(SurfaceID(rawValue: 2), session: "chat-2")]) + let dock = DockSnapshot(edge: .left, mode: .docked, role: .agentChat) + let columns = [ColumnSnapshot(id: 10, width: 0.4, layout: .leaf(chatPane.id), dock: dock), + ColumnSnapshot(id: 11, width: 0.6, layout: .leaf(other.id))] + screen = ScreenSnapshot(id: ScreenID(rawValue: 1), layout: .leaf(chatPane.id), columns: columns, panes: [chatPane, other]) + } return DaemonTree(workspaces: [WorkspaceSnapshot(id: WorkspaceHandle(rawValue: 1), key: nil, name: "w1", screens: [screen])]) } @@ -26,13 +58,15 @@ import Testing return try JSONDecoder().decode(DaemonIdentity.self, from: Data(json.utf8)) } - /// The chat | terminal workspace shown in a borderless window, laid out. - private static func shown(zoomed: Bool = false) throws -> (AppServices, NSWindow, WorkspaceContentController) { + /// The workspace shown in a borderless window, laid out. `servesTabs`: the daemon serves + /// browser tabs (`frontend-browser-tabs-v1`), so a new tab can be moved. + private static func shown(_ shape: Shape, servesTabs: Bool = true) throws -> (AppServices, NSWindow, WorkspaceContentController) { _ = NSApplication.shared let services = AppServices(environment: AppEnvironment.current([:])) + services.cache.browserTabs.serves = { _ in servesTabs } let store = services.daemon.store store.connectionState = .connected(try Self.connected()) - store.apply(snapshot: Self.tree(zoomed: zoomed)) + store.apply(snapshot: Self.tree(shape)) let workspace = try #require(store.workspaces.first) let record = try JSONDecoder().decode(WindowRecord.self, from: Data(#"{"id":"w1","workspace_key":"w1"}"#.utf8)) let state = WindowState(record: record) @@ -48,16 +82,16 @@ import Testing return (services, window, content) } - /// The chat's pane and the terminal's, by the surface each shows. - private static func panes(_ content: WorkspaceContentController) throws -> (chat: PaneController, terminal: PaneController?) { + /// The chat's pane and the other pane, by the surface each shows. + private static func panes(_ content: WorkspaceContentController) throws -> (chat: PaneController, other: PaneController?) { let controllers = Array(content.panes.values) - let chat = try #require(controllers.first { $0.selectedTab?.surface == SurfaceID(rawValue: 1) }, "the chat's pane is not shown") - return (chat, controllers.first { $0.selectedTab?.surface == SurfaceID(rawValue: 2) }) + let chat = try #require(controllers.first { $0.selectedTab?.surface == Self.chatSurface }, "the chat's pane is not shown") + return (chat, controllers.first { $0 !== chat }) } @Test(arguments: [LinkPlacement.splitRight, .newColumn]) func aChatLinkOpensInThePaneOnItsRight(_ placement: LinkPlacement) throws { - let (services, window, content) = try Self.shown() + let (services, window, content) = try Self.shown(.chatThenTerminal(zoomed: false)) defer { withExtendedLifetime(services) { content.teardown(); window.close() } } let (chat, terminal) = try Self.panes(content) let right = try #require(terminal) @@ -68,7 +102,7 @@ import Testing } @Test func tabInTheSamePaneKeepsAChatLinkInTheChatsPane() throws { - let (services, window, content) = try Self.shown() + let (services, window, content) = try Self.shown(.chatThenTerminal(zoomed: false)) defer { withExtendedLifetime(services) { content.teardown(); window.close() } } let (chat, _) = try Self.panes(content) services.linkOpener.placement = { .samePane } @@ -78,7 +112,7 @@ import Testing /// The zoomed chat hides the terminal: the link opens a new split on its right (which ends the /// zoom), never a tab in the hidden terminal. @Test func aZoomedChatLinkOpensANewSplitNotTheHiddenPane() throws { - let (services, window, content) = try Self.shown(zoomed: true) + let (services, window, content) = try Self.shown(.chatThenTerminal(zoomed: true)) defer { withExtendedLifetime(services) { content.teardown(); window.close() } } let (chat, _) = try Self.panes(content) services.linkOpener.placement = { .splitRight } @@ -86,4 +120,45 @@ import Testing services.linkOpener.placement = { .newColumn } #expect(services.linkOpener.target(from: chat, chatLink: true) == .newColumn(rightOf: chat.layoutPaneID)) } + + /// The chat dock never splits (TabMoves refuses it, and the tab would stay over the chat): with + /// only another chat on its right, the link opens a new column beside the dock. + @Test(arguments: [LinkPlacement.splitRight, .newColumn]) + func fromTheChatDockWithOnlyChatsOnTheRightANewColumn(_ placement: LinkPlacement) throws { + let (services, window, content) = try Self.shown(.chatDockBesideAnotherChat) + defer { withExtendedLifetime(services) { content.teardown(); window.close() } } + let (chat, _) = try Self.panes(content) + services.linkOpener.placement = { placement } + #expect(services.linkOpener.target(from: chat, chatLink: true) == .newColumn(rightOf: chat.layoutPaneID)) + } + + /// Nothing on the chat's right needs a new split; with no daemon browser tabs the session-local + /// tab could not be moved there and would open unseen, so the link keeps the rules for any page. + @Test func withoutDaemonBrowserTabsAChatLinkKeepsThePageRules() throws { + do { + let (services, window, content) = try Self.shown(.terminalThenChat) + defer { withExtendedLifetime(services) { content.teardown(); window.close() } } + let (chat, _) = try Self.panes(content) + #expect(services.linkOpener.target(from: chat, chatLink: true) == .splitRight(of: chat.layoutPaneID)) + } + let (services, window, content) = try Self.shown(.terminalThenChat, servesTabs: false) + defer { withExtendedLifetime(services) { content.teardown(); window.close() } } + let (chat, _) = try Self.panes(content) + #expect(services.linkOpener.chatLinkTarget(from: chat) == nil) + #expect(services.linkOpener.target(from: chat, chatLink: true) == services.linkOpener.target(from: chat)) + } + + /// The real open on that machine: the session-local tab is made and selected, as before chat + /// links went to the right, not left unselected in the chat's pane waiting for a move. + @Test func withoutDaemonBrowserTabsAChatLinkOpensSelected() throws { + let (services, window, content) = try Self.shown(.terminalThenChat, servesTabs: false) + defer { withExtendedLifetime(services) { content.teardown(); window.close() } } + let (chat, _) = try Self.panes(content) + services.linkOpener.placement = { .splitRight } + let url = try #require(URL(string: "https://example.com/pull/1")) + #expect(services.linkOpener.openWeb(url, from: chat, background: false)) + let state = try #require(chat.state) + let local = try #require(state.localBrowserTabs[chat.paneKey].first, "no session-local tab opened") + #expect(chat.stripModel.selectedID == StripTabID(local.id)) + } }