diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneNavigation+Links.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneNavigation+Links.swift index b2ce02c76668..d3a090e6fc58 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneNavigation+Links.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneNavigation+Links.swift @@ -1,4 +1,4 @@ -import Foundation +public import AppKit extension AgentPaneSource { /// Whether `url` is the page document itself, the only one the pane's main frame shows: the @@ -30,3 +30,11 @@ extension AgentPaneNavigation { return true } } + +/// The modifiers of the link click being opened: `AgentPaneView.openURL` runs inside the click, so +/// the open can tell a Command-click from a plain one (ChatBeside). Nil outside a click (Open Link, +/// a page's own open). +// lint:allow namespace: the click's context, set and cleared around one open, read across modules. +@MainActor public enum AgentPaneLinkClick { + public internal(set) static var modifiers: NSEvent.ModifierFlags? +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneNavigation.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneNavigation.swift index 2ea85612ebfd..e79fb9701afe 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneNavigation.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneNavigation.swift @@ -24,7 +24,9 @@ final class AgentPaneNavigation: NSObject, WKNavigationDelegate { case .allow: decisionHandler(.allow) case .openOutside(let url): + AgentPaneLinkClick.modifiers = action.modifierFlags _ = Self.openOutside(url, gestures: view.model.transport.gestures, open: view.openURL) + AgentPaneLinkClick.modifiers = nil decisionHandler(.cancel) case .cancel: decisionHandler(.cancel) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentChatSplitToggles.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentChatSplitToggles.swift index 138f5e332371..cecda19795de 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentChatSplitToggles.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentChatSplitToggles.swift @@ -80,6 +80,9 @@ final class AgentChatSplitToggles { .filter { self.opened[$0].id != Self.splitDown } .compactMap { splits[$0] } .first { Self.beside(chat, $0, in: store) } + // A diff with no split of the header's beside the chat joins the pane a chat's clicks open in (ChatBeside). + ?? (id == Self.changes ? store.tab(id: chat).flatMap { store.pane(containing: $0.surface) } + .flatMap { ChatBeside.besidePane(of: $0, in: store)?.tabs.first } : nil) // Open mode adds one per click: keep the recent ones, enough to find the last split beside the chat. opened = Array(opened.suffix(Self.remembered - 1)) opened.append((id: id, baseline: Set(tabs.map(\.id)))) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabContent.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabContent.swift index 796ba78bc7b0..32eb224e4a72 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabContent.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabContent.swift @@ -1,6 +1,7 @@ import CmuxNextActions import AppKit import CmuxNextAgentPane +import CmuxNextCompat import CmuxNextWakeups import Foundation @@ -34,8 +35,17 @@ struct AgentTabContent { // Set on each show, so a tab moved to another pane opens files there. view.model.onOpenFile = { [weak pane] url, target in guard let pane else { return false } + // A file ref's page opens beside the chat (ChatBeside); Command-click, a new tab here. + if target == .tab, !ChatBeside.keepsOldPlace, ChatBeside(services: pane.services).open(.file(url), beside: key) { return true } return pane.services.registry.openAgentFile(path: url.path, target: target, pane: pane.paneKey) } + // A link, a PR's among them, opens in the in-app browser beside the chat; Command-click, the + // default browser, as every link did before. + view.openURL = { [weak pane] url in + if ChatBeside.keepsOldPlace || pane.map({ ChatBeside(services: $0.services).open(.url(url), beside: key) }) != true { + NSWorkspace.shared.openInBackground(url) + } + } view.model.onOpenPreview = { [weak pane] url in guard let pane else { return false } return pane.services.registry.openAgentPreview(url, pane: pane.paneKey) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/ChatBeside.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/ChatBeside.swift new file mode 100644 index 000000000000..8be8898d2c08 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/ChatBeside.swift @@ -0,0 +1,93 @@ +import AppKit +import CmuxNextActions +import CmuxNextAgentPane +import CmuxNextBridge +import CmuxNextDaemon + +/// Open beside (Leo 2026-10-14): what a click in a chat opens goes to the pane right of the chat, +/// the in-app way. The first click with no pane there opens one (a new column right of the chat, +/// as an agent's own browser does, AgentBesidePlacement); every later click adds a tab to that same +/// pane, never another split; a click on what a tab there already shows selects that tab. Keyboard +/// focus stays in the chat. Command-click keeps the old place (``keepsOldPlace``). +/// +/// The one entry point for the chat's surfaces: links and PR links (`AgentPaneView.openURL`) and +/// file refs (`onOpenFile`); the header's Changes diff joins the same pane (AgentChatSplitToggles). +@MainActor +struct ChatBeside { + /// What a click opens. + enum Entity: Equatable { + /// A web page, in the in-app browser. + case url(URL) + /// A file, on its file page. + case file(URL) + } + + let services: AppServices + + /// Whether the click that is being handled asks for the old place: Command held. A link click + /// carries its own modifiers; a file ref's arrives later, so the keys held now stand in. + static var keepsOldPlace: Bool { (AgentPaneLinkClick.modifiers ?? NSEvent.modifierFlags).contains(.command) } + + /// Opens `entity` beside chat tab `chat` (a provisional key resolves). False when nothing opened + /// or was selected (the chat is in no pane this app knows, or the page cannot go beside it); the + /// caller then opens it the old way. + @discardableResult + func open(_ entity: Entity, beside chat: String) -> Bool { + let chat = services.agentTabs.resolve(chat) + guard let (_, chatPane) = services.locateTab(chat) else { return false } + let beside = Self.besidePane(of: chatPane, in: services.daemon(for: chatPane).store) + // A tab of the pane right of the chat names it; with none, the chat's tab with a new column. + let target = beside.flatMap { $0.tabs.first?.id } + var invocation = ActionInvocation(target: ActionTargetRef(kind: .tab, id: target ?? chat), origin: .user) + invocation.besideCaller = true + invocation.newColumnBeside = target == nil + switch entity { + case .url(let url): + if let beside, let shown = beside.tabs.first(where: { Self.shows($0, url) }) { + // Shown already: select it there; the chat keeps focus. + guard let pane = services.paneController(for: beside) else { return false } + pane.select(StripTabID(shown.id)) + return true + } + invocation.arguments = ["url": .string(url.absoluteString)] + return services.registry.perform("openBrowser", invocation: invocation) + case .file(let url): + // A file the browser previews (an image, a PDF) keeps its old place. + guard let kind = FilePageOpener.kind(for: url) else { return false } + let pages = kind == .markdown ? services.viewers.markdownPages : services.viewers.editorPages + if let shown = beside.flatMap(services.paneController(for:)), pages.selectShown(url, in: shown) { return true } + // Only a store tab moves beside the chat; else the old place. + guard let pane = services.paneController(for: beside ?? chatPane), pages.opensStoreTabs(in: pane) else { return false } + let placement = invocation + return services.viewers.openFile(url, in: pane, userChose: true, + beside: { AgentBesidePlacement.placed(placement, in: pane, then: nil) }) == nil + } + } + + /// The first pane of the scrolling column right of `chat`'s column: where its clicks open. A dock + /// never takes them, and a chat in the last column has none (its first click makes one). + static func besidePane(of chat: PaneModel, in store: DaemonStore) -> PaneModel? { + guard let screen = store.workspace(containing: chat.handle)?.screens.first(where: { $0.panes.contains { $0 === chat } }), + let index = screen.columns.firstIndex(where: { $0.layout.paneIDs.contains(chat.handle) }), + index + 1 < screen.columns.count else { return nil } + let next = screen.columns[index + 1] + guard next.dock == nil, let first = next.layout.paneIDs.first else { return nil } + return screen.panes.first { $0.handle == first } + } + + /// Whether browser tab `tab` shows `url`: the same page at the same anchor (a link to another + /// line of a file opens its own tab), whatever the scheme's and host's case or a trailing slash. + static func shows(_ tab: TabModel, _ url: URL) -> Bool { + guard tab.kind == .browser, let shown = tab.url.flatMap(URL.init(string:)).flatMap(comparable) else { return false } + return shown == comparable(url) + } + + private static func comparable(_ url: URL) -> String? { + guard var parts = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil } + parts.scheme = parts.scheme?.lowercased() + parts.host = parts.host?.lowercased() + while parts.path.count > 1, parts.path.hasSuffix("/") { parts.path.removeLast() } + if parts.path.isEmpty, parts.host != nil { parts.path = "/" } + return parts.string + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugAgentPane.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugAgentPane.swift index 9c6d7e0f0827..de01d6cfb54b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugAgentPane.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugAgentPane.swift @@ -12,7 +12,7 @@ import WebKit /// one). Never changes app or window focus; `open_menu` moves focus inside /// the page to the menu's button, as a click does. /// -/// `action`: `seed_rows` (`count`, default 5000; `fixture: "worked-turn"` +/// `action`: `seed_rows` (`count`, default 5000; `fixture: "links"` seeds a reply with two links and a path; `fixture: "worked-turn"` /// seeds a turn that edits three files instead, for the changes view), `fling` (`seconds`, /// default 3; `nominal_ms`; `wait` returns the stats when the fling ends), /// `fling_stats`, `perf_stats` (`raw` adds every frame), `load_seed` (`turns`, default 5000: diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/Files/FilePageOpener.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/Files/FilePageOpener.swift index 11a880175f00..404445eb054c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/Files/FilePageOpener.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/Files/FilePageOpener.swift @@ -1,6 +1,7 @@ import AppKit import CmuxNextAgentPane import CmuxNextBrowser +import typealias CmuxNextDaemon.SurfaceID /// The file viewer seam (R89 ``FileOpening``) for the file pages: every file open (Open File..., /// the cmux picker, `file.open` and `cmux file open`, Finder drops on a file page, a followed link @@ -38,15 +39,16 @@ final class FilePageOpener: FileOpening { } /// `userChose` false for an agent's or a script's open (`cmux file open`): the document shows, - /// and is writable only inside a root the user chose. - func open(_ file: URL, in pane: PaneController?, userChose: Bool) -> String? { + /// and is writable only inside a root the user chose. `beside`: a chat's click opening the page + /// beside it (ChatBeside): the tab opens unfocused and `beside` places it. + func open(_ file: URL, in pane: PaneController?, userChose: Bool, beside: (@MainActor () -> @MainActor (SurfaceID) -> Void)? = nil) -> String? { guard let url = AgentPaneFileOpen.resolve(file.path) else { return FilePageStrings.notAFile } guard let pane = pane ?? services.windows.active?.focusedPane else { return FilePageStrings.noPane } switch Self.kind(for: url) { case .markdown?: - services.viewers.markdownPages.open(url, in: pane, focus: true, userChose: userChose) + services.viewers.markdownPages.open(url, in: pane, focus: beside == nil, userChose: userChose, beside: beside) case .editor?: - services.viewers.editorPages.open(url, in: pane, focus: true, userChose: userChose) + services.viewers.editorPages.open(url, in: pane, focus: beside == nil, userChose: userChose, beside: beside) case nil: guard AgentPaneFileOpen.showsInTab(url) else { return FilePageStrings.notAFile } pane.newBrowserTab(url: url, engine: Self.tabEngine(for: url)) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/Files/FilePageService+Beside.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/Files/FilePageService+Beside.swift new file mode 100644 index 000000000000..e8f7e6723ccd --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/Files/FilePageService+Beside.swift @@ -0,0 +1,31 @@ +import CmuxNextBridge +import CmuxNextDaemon +import Foundation + +/// A chat's click on a file opens its page beside the chat (ChatBeside): in the pane right of the +/// chat, where a tab that already shows the file is selected and no keyboard focus moves. +extension FilePageService { + /// Selects the tab of `pane` that shows `file`, moving no keyboard focus. False when no tab of + /// `pane` shows it. + func selectShown(_ file: URL, in pane: PaneController) -> Bool { + let real = file.standardizedFileURL.resolvingSymlinksInPath() + let pages = pane.services.pages + let keys = pages.tabIDs(in: pane.paneKey) + pane.pane.tabs.compactMap { pages.storeKey(of: $0.id) } + if let shown = keys.first(where: { self.file($0) == real }) { + pane.select(pages.stripID(showing: shown, in: pane) ?? StripTabID(shown)) + } else if let unshown = pane.pane.tabs.first(where: { $0.page == kind.page.rawValue && $0.pageRoute == real.path }) { + pane.select(StripTabID(unshown.id)) + } else { + return false + } + return true + } + + /// Whether a new tab of this page in `pane` is a store tab, the only kind a placement beside a + /// chat can move. A reconnecting daemon makes none, so the click takes the old path. + func opensStoreTabs(in pane: PaneController) -> Bool { + guard case .connected = pane.daemon.store.connectionState, + pane.daemon.supports(DaemonCapabilities.shared.pageTabs) else { return false } + return pane.services.windowController(showing: pane) != nil && pane.daemon.isLocal && pane.services.pages.keepsRoutes(pane.daemon) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/Files/FilePageService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/Files/FilePageService.swift index fde1e5a71ee6..41726b7e55ec 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/Files/FilePageService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/Files/FilePageService.swift @@ -6,6 +6,7 @@ import CmuxNextPages import CmuxNextSettings import Foundation import CmuxNextCompat +import typealias CmuxNextDaemon.SurfaceID /// Owns one file page's tabs (diff-host S6 `cmux.markdown`, S7 `cmux.editor`): pane tabs, one /// ``FilePageProvider`` and page view per tab, one shared look per page (its settings section, @@ -94,12 +95,15 @@ final class FilePageService: InternalPageProvider { func file(_ key: String) -> URL? { tabs[key]?.provider?.file ?? tabs[key]?.file } /// Opens `file` in a tab after `pane`'s selected tab, or selects the tab of `pane`'s window that - /// already shows it. A user run (`focus`) selects and focuses it. + /// already shows it. A user run (`focus`) selects and focuses it. `beside` makes the placement + /// of a new store tab beside a chat (ChatBeside, AgentBesidePlacement): that open always makes + /// a tab, as ChatBeside looks for one in the pane beside the chat itself (``selectShown(_:in:)``). @discardableResult - func open(_ file: URL, in pane: PaneController, focus: Bool, userChose: Bool = true, recoveredText: String? = nil) -> String { + func open(_ file: URL, in pane: PaneController, focus: Bool, userChose: Bool = true, recoveredText: String? = nil, + beside: (@MainActor () -> @MainActor (SurfaceID) -> Void)? = nil) -> String { let real = file.standardizedFileURL.resolvingSymlinksInPath() if userChose { services.viewers.recents.record(real, as: kind.recents) } - let key = show(in: pane, focus: focus, Tab(file: real, userChose: userChose, recoveredText: recoveredText)) { key in + let key = show(in: pane, focus: focus, Tab(file: real, userChose: userChose, recoveredText: recoveredText), beside: beside) { key in self.file(key) == real } // The user opened a file a tab already shows (perhaps one an agent opened): it is theirs now. @@ -161,9 +165,10 @@ final class FilePageService: InternalPageProvider { /// (page-tabs-v1), on its file as the route (page-tab-route-v1), so it /// moves like any daemon tab and Reopen Closed brings it back on that /// file; else as an app-only tab. - private func show(in pane: PaneController, focus: Bool, _ tab: Tab, matching: (String) -> Bool) -> String { + private func show(in pane: PaneController, focus: Bool, _ tab: Tab, beside: (@MainActor () -> @MainActor (SurfaceID) -> Void)? = nil, + matching: (String) -> Bool) -> String { let window = services.windowController(showing: pane) - for candidate in window?.content?.panes.values.map({ $0 }) ?? [pane] { + for candidate in beside != nil ? [] : window?.content?.panes.values.map({ $0 }) ?? [pane] { let keys = services.pages.tabIDs(in: candidate.paneKey) + candidate.pane.tabs.compactMap { services.pages.storeKey(of: $0.id) } if let shown = keys.first(where: { tabs[$0] != nil && matching($0) }) { if focus { reveal(shown, in: candidate) } @@ -185,7 +190,7 @@ final class FilePageService: InternalPageProvider { // A store tab only on this Mac's daemon that keeps the route: a file path of this Mac never // goes to another machine's daemon, and a tab without its route could not come back. if let window, pane.daemon.isLocal, services.pages.keepsRoutes(pane.daemon), - services.pages.openStoreTab(kind.page, in: pane, window: window, focus: focus, key: key) != nil { + services.pages.openStoreTab(kind.page, in: pane, window: window, focus: focus, key: key, created: beside?()) != nil { return key } services.pages.open(kind.page, in: pane.paneKey, of: pane.daemon.store, after: pane.stripModel.selectedID?.rawValue, diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Viewers/ViewerService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Viewers/ViewerService.swift index 533cfb451f4a..4ec6634bc30e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Viewers/ViewerService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Viewers/ViewerService.swift @@ -41,8 +41,9 @@ final class ViewerService { /// Opens `file` through ``fileOpener`` (the markdown page, a previewing tab, or the editor). /// `userChose` false for a page's or an agent's open. Returns why it did not open. - func openFile(_ file: URL, in pane: PaneController?, userChose: Bool) -> String? { - if let opener = fileOpener as? FilePageOpener { return opener.open(file, in: pane, userChose: userChose) } + func openFile(_ file: URL, in pane: PaneController?, userChose: Bool, + beside: (@MainActor () -> @MainActor (SurfaceID) -> Void)? = nil) -> String? { + if let opener = fileOpener as? FilePageOpener { return opener.open(file, in: pane, userChose: userChose, beside: beside) } return fileOpener.open(file, in: pane) } diff --git a/scripts/cmux-next/open-beside-e2e.py b/scripts/cmux-next/open-beside-e2e.py new file mode 100755 index 000000000000..4c1ebf8f1c04 --- /dev/null +++ b/scripts/cmux-next/open-beside-e2e.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Live check: what a click in a chat opens goes to one pane right of the chat (open beside). + +Leo 2026-10-14: a link, a PR link or a file ref clicked in a chat opens in a pane right of the +chat, the in-app way: the first click with no pane there makes one; each later click adds a tab +to that same pane, never another split; a click on what a tab there already shows selects that +tab. Before, a link opened in the default browser and a file ref in a new tab of the chat's own +pane. + +The script attaches to an app already running (a capture slot's `capture-host launch`, or a +tagged build) through its debug socket. It serves two pages on 127.0.0.1:47811 and writes +/tmp/cmux-open-beside/notes.md, opens a workspace with an agent page, seeds the reply of +`debug.agent_pane seed_rows fixture: "links"` (two links and that path), and clicks the links +and the path chip with `debug.agent_pane click` (a native click the pane takes as the user's), +reading the workspace's panes (`snapshot.get`) after each. It closes its workspace and never +launches or quits the app. + +Usage: open-beside-e2e.py --socket /tmp/cmux-debug-[-capslot].sock +Exit status 0 when every check passes. +""" +import argparse, http.server, json, os, socket, sys, threading, time + +parser = argparse.ArgumentParser() +parser.add_argument("--socket", required=True, help="the tagged app's debug socket") +parser.add_argument("--out", default="/tmp", help="where the end state's window snapshot goes") +opts = parser.parse_args() +ROWS = [] +CREATED = [] +PORT = 47811 # webviews linksTurn.ts +NOTES = "/tmp/cmux-open-beside/notes.md" + + +def rpc(method, params=None, timeout=30): + """One request on the app's control socket (line JSON).""" + try: + conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + conn.settimeout(timeout) + conn.connect(opts.socket) + conn.sendall((json.dumps({"id": 1, "method": method, "params": params or {}}) + "\n").encode()) + buf = b"" + while not buf.endswith(b"\n"): + chunk = conn.recv(1 << 20) + if not chunk: + break + buf += chunk + conn.close() + reply = json.loads(buf) + return reply.get("result") if reply.get("ok") else {"error": reply.get("error")} + except (OSError, ValueError) as error: + return {"error": str(error)} + + +def wait(check, seconds, step=0.25): + deadline = time.time() + seconds + while time.time() < deadline: + try: + value = check() + except Exception: # the app may not answer yet + value = None + if value: + return value + time.sleep(step) # test harness polling a live app + return None + + +def row(check, expected, observed, ok): + ROWS.append(ok) + print(f"{'PASS' if ok else 'FAIL'} | {check} | {expected} | {observed}", flush=True) + + +def topology(): + snap = rpc("snapshot.get") or {} + return (snap.get("topology") or snap) if isinstance(snap, dict) else {} + + +def panes_of(workspace): + """[(pane id, [tab dicts])] of the workspace's first screen, in the snapshot's order.""" + for ws in topology().get("workspaces", []): + if ws.get("id") == workspace: + return [(p.get("id"), p.get("tabs") or []) for s in (ws.get("screens") or [])[:1] for p in s.get("panes") or []] + return [] + + +def selected_url(workspace, pane): + """The URL of the tab `pane` selects ('' for none or a tab without one).""" + for ws in topology().get("workspaces", []): + if ws.get("id") != workspace: + continue + for p in (ws.get("screens") or [{}])[0].get("panes") or []: + if p.get("id") == pane: + return next((t.get("url") or "" for t in p.get("tabs") or [] if t.get("id") == p.get("selected_tab")), "") + return "" + + +def brief(workspace): + return [(pane[-6:], [t.get("kind") + (":" + t["url"].rsplit(":", 1)[-1] if t.get("url") else "") for t in tabs]) + for pane, tabs in panes_of(workspace)] + + +def pane_rpc(pane, action, **params): + return rpc("debug.agent_pane", {"action": action, "pane": pane, **params}, timeout=60) + + +def serve(): + """The two PR pages the links open.""" + class Page(http.server.BaseHTTPRequestHandler): + def do_GET(self): + body = f"{self.path}

{self.path}

".encode() + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + server = http.server.ThreadingHTTPServer(("127.0.0.1", PORT), Page) + threading.Thread(target=server.serve_forever, daemon=True).start() + return server + + +def new_workspace(): + """A new focused workspace; returns its id and its one pane.""" + before = {ws.get("id") for ws in topology().get("workspaces", [])} + rpc("action.run", {"action": "newTab", "args": {}, "focus": True}) + made = wait(lambda: [ws.get("id") for ws in topology().get("workspaces", []) if ws.get("id") not in before], 15) + if not made: + sys.exit("no workspace opened") + CREATED.append(made[0]) + pane = wait(lambda: (panes_of(made[0]) or [(None,)])[0][0], 10) + return made[0], pane + + +def chat_pane(workspace): + """The workspace's pane whose agent page answers.""" + for pane, _ in panes_of(workspace): + state = pane_rpc(pane, "chat_state") + if isinstance(state, dict) and "error" not in state: + return pane + return None + + +def beside(workspace, chat): + """The panes other than the chat's (the chat's pane is the one holding its agent tab).""" + return [(pane, tabs) for pane, tabs in panes_of(workspace) if pane != chat] + + +def browsers(workspace, chat): + return [t.get("url", "") for _, tabs in beside(workspace, chat) for t in tabs if t.get("kind") == "browser"] + + +def click(chat, selector): + reply = pane_rpc(chat, "click", selector=selector) + print("click", selector, json.dumps(reply)[:200], flush=True) + return reply + + +def main(): + if not wait(lambda: (rpc("debug.windows") or {}).get("windows"), 60): + sys.exit("the app does not answer on " + opts.socket) + server = serve() + os.makedirs(os.path.dirname(NOTES), exist_ok=True) + with open(NOTES, "w") as f: + f.write("# Notes\n\nOpened beside the chat.\n") + workspace, pane = new_workspace() + rpc("action.run", {"action": "newTab.page", "target": f"pane:{pane}", "focus": True}) + chat = wait(lambda: chat_pane(workspace), 60, 1) + if not chat: + sys.exit("no agent page answered") + print("seed:", json.dumps(pane_rpc(chat, "seed_rows", fixture="links"))[:200], flush=True) + time.sleep(2) # test harness: the reply draws and its path chip asks the host about its path + print("start:", brief(workspace), flush=True) + + # 1. The first link: a pane right of the chat with the page, in the in-app browser. + click(chat, f'a[href="http://127.0.0.1:{PORT}/pr/1"]') + first = wait(lambda: any(u.endswith("/pr/1") for u in browsers(workspace, chat)), 10) + row("a link opens in the in-app browser beside the chat", "a browser tab on /pr/1 outside the chat's pane", + f"panes {brief(workspace)}", bool(first)) + panes_after_first = len(panes_of(workspace)) + + # 2. The second link: a tab in that same pane, no new split. + click(chat, f'a[href="http://127.0.0.1:{PORT}/pr/2"]') + second = wait(lambda: any(u.endswith("/pr/2") for u in browsers(workspace, chat)), 10) + side = beside(workspace, chat) + holder = [p for p, tabs in side if any(t.get("url", "").endswith("/pr/2") for t in tabs)] + same = bool(second) and len(panes_of(workspace)) == panes_after_first and len(holder) == 1 and \ + any(t.get("url", "").endswith("/pr/1") for p, tabs in side if p == holder[0] for t in tabs) + row("a second link adds a tab to the same pane", f"{panes_after_first} panes; /pr/1 and /pr/2 in one pane", + f"panes {brief(workspace)}", same) + + # 3. The first link again: its tab is selected, no third tab. + tabs_before = len(browsers(workspace, chat)) + click(chat, f'a[href="http://127.0.0.1:{PORT}/pr/1"]') + time.sleep(1.5) # test harness: a wrong open would land by now + shown = wait(lambda: selected_url(workspace, holder[0]).endswith("/pr/1") if holder else False, 5) + again = tabs_before == 2 and len(browsers(workspace, chat)) == tabs_before and len(panes_of(workspace)) == panes_after_first + row("a link already open beside the chat selects its tab there, no other tab", + f"still 2 browser tabs, {panes_after_first} panes, /pr/1 selected", + f"panes {brief(workspace)} selected {selected_url(workspace, holder[0]) if holder else None!r}", again and bool(shown)) + + # 4. The file ref: its page in that same pane, even when the chat's own pane shows the file + # already (a Command-click opens it there): a page elsewhere never turns the click into a no-op. + chat_tab = next((t.get("id") for p, tabs in panes_of(workspace) if p == chat for t in tabs + if t.get("kind") == "conversation"), None) + rpc("action.run", {"action": "file.open", "args": {"path": NOTES}, "target": f"pane:{chat}", "wait": True}) + wait(lambda: sum(1 for p, tabs in panes_of(workspace) if p == chat for t in tabs if t.get("kind") == "page") > 0, 10) + rpc("action.run", {"action": "tab.focus", "target": f"tab:{chat_tab}", "focus": True}) + time.sleep(1) # test harness: the chat shows again before its click + pages_before = sum(1 for _, tabs in beside(workspace, chat) for t in tabs if t.get("kind") != "browser") + click(chat, f'[data-path="{NOTES}"]') + page = wait(lambda: sum(1 for _, tabs in beside(workspace, chat) for t in tabs if t.get("kind") != "browser") > pages_before, 10) + row("a file ref opens its page beside the chat, also when the chat's pane shows it", + "one more page tab outside the chat's pane, no new pane", + f"panes {brief(workspace)}", bool(page) and len(panes_of(workspace)) == panes_after_first) + + rpc("debug.window_snapshot", {"path": os.path.join(opts.out, "open-beside.png")}) + for ws in CREATED: + rpc("action.run", {"action": "closeWorkspace", "args": {"confirm": True}, "target": f"workspace:{ws}"}) + server.shutdown() + + +main() +print("\nRESULT", "PASS" if ROWS and all(ROWS) else "FAIL", f"({sum(ROWS)}/{len(ROWS)})") +sys.exit(0 if ROWS and all(ROWS) else 1) diff --git a/scripts/cmux-next/open-beside-realchat-e2e.py b/scripts/cmux-next/open-beside-realchat-e2e.py new file mode 100755 index 000000000000..90c6629d8cac --- /dev/null +++ b/scripts/cmux-next/open-beside-realchat-e2e.py @@ -0,0 +1,510 @@ +#!/usr/bin/env python3 +"""Live check: what a click in a REAL agent chat opens goes to one pane right of the chat. + +Same four checks as scripts/cmux-next/open-beside-e2e.py, but in a +chat tab bound to an acpmux session (snapshot.get topology tab `agent_session`), so the lone chat +docks left on the first click (ChatColumnPlacement.dockChat) and the pane right of it stays in +view. A fifth check reads that: after the first click the chat is in a shown left dock column and +the pane holding the page is on screen in the strip (debug.dock, debug.pane_chrome). + +The chat: the app must run without --showcase (showcase swaps acpmux for a mock host). The script +opens a chat (palette.newAgentChat), finds this build's acpmux home (`/.acpmux/builds/` +under the slot HOME or the account home), adds harness `fake` (acpmux's tests/fake_agent.py, +`--fake-agent`: next to this script, else the repo's copy) to its config.json, makes a session on it +(session/new, as chat-transfer-live.py does), shows it in the tab (`debug.agent_pane +select_session`) and seeds the links fixture into it. config.json is restored at the end. + +--record: frames of the window (debug.window_snapshot, ~4 fps) into --out as NNNN.png with +meta.json [{file, t, caption}]; the window is set to 1500x900 and clicks are 2.5 s apart. + +Usage: open-beside-realchat.py --socket SOCK [--out DIR] [--tag TAG] [--home DIR] [--record] +Exit status 0 when every check passes. +""" +import argparse, glob, http.server, json, os, re, socket, sys, threading, time + +parser = argparse.ArgumentParser() +parser.add_argument("--socket", required=True, help="the tagged app's debug socket") +parser.add_argument("--out", default="/tmp/open-beside-realchat", help="snapshots and frames go here") +parser.add_argument("--tag", default="", help="the build's tag (its acpmux home is .acpmux/builds/, the key ending in the tag)") +parser.add_argument("--home", action="append", default=[], help="a HOME the app may run with") +HERE = os.path.dirname(os.path.abspath(__file__)) +FAKE_AGENT = next((p for p in (os.path.join(HERE, "fake_agent.py"), + os.path.join(HERE, "..", "..", "cmux-tui", "crates", "acpmux", "tests", "fake_agent.py")) + if os.path.exists(p)), os.path.join(HERE, "fake_agent.py")) +parser.add_argument("--fake-agent", default=FAKE_AGENT, help="acpmux's tests/fake_agent.py (next to this script on a slot)") +parser.add_argument("--record", action="store_true") +opts = parser.parse_args() +os.makedirs(opts.out, exist_ok=True) +# A capture slot's root is the parent of --out; its HOME is /home. +if not opts.home: + opts.home = [os.path.join(os.path.dirname(os.path.abspath(opts.out)), "home"), os.path.expanduser("~")] +ROWS = [] +CREATED = [] +RESTORE = [] +PORT = 47811 # webviews linksTurn.ts +NOTES = "/tmp/cmux-open-beside/notes.md" +CAPTION = ["start"] +RECORDING = threading.Event() + + +def rpc(method, params=None, timeout=30): + """One request on the app's control socket (line JSON).""" + try: + conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + conn.settimeout(timeout) + conn.connect(opts.socket) + conn.sendall((json.dumps({"id": 1, "method": method, "params": params or {}}) + "\n").encode()) + buf = b"" + while not buf.endswith(b"\n"): + chunk = conn.recv(1 << 20) + if not chunk: + break + buf += chunk + conn.close() + reply = json.loads(buf) + return reply.get("result") if reply.get("ok") else {"error": reply.get("error")} + except (OSError, ValueError) as error: + return {"error": str(error)} + + +def wait(check, seconds, step=0.25): + deadline = time.time() + seconds + while time.time() < deadline: + try: + value = check() + except Exception: # the app may not answer yet + value = None + if value: + return value + time.sleep(step) + return None + + +def row(check, expected, observed, ok): + ROWS.append(ok) + print(f"{'PASS' if ok else 'FAIL'} | {check} | {expected} | {observed}", flush=True) + + +# ---- acpmux (as chat-transfer-live.py) ------------------------------------------------------- + +class Acpmux: + def __init__(self, path): + self.conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.conn.connect(path) + self.next, self.answers, self.lock = 0, {}, threading.Condition() + threading.Thread(target=self._read, daemon=True).start() + self.request("initialize", {"protocolVersion": 1, "clientInfo": {"name": "open-beside-realchat"}}) + + def _read(self): + buf = b"" + while True: + try: + chunk = self.conn.recv(1 << 20) + except OSError: + return + if not chunk: + return + buf += chunk + while b"\n" in buf: + line, buf = buf.split(b"\n", 1) + try: + message = json.loads(line) + except ValueError: + continue + if "id" in message and ("result" in message or "error" in message): + with self.lock: + self.answers[message["id"]] = message + self.lock.notify_all() + + def request(self, method, params, timeout=30): + with self.lock: + self.next += 1 + rid = self.next + self.conn.sendall((json.dumps({"jsonrpc": "2.0", "id": rid, "method": method, "params": params}) + "\n").encode()) + with self.lock: + self.lock.wait_for(lambda: rid in self.answers, timeout) + return self.answers.pop(rid, {"error": {"message": "no answer"}}) + + +def socket_of(home): + preferred = os.path.join(home, "acpmux.sock") + if len(preferred.encode()) < 96: + return preferred + h = 0xcbf29ce484222325 + for byte in home.encode(): + h = ((h ^ byte) * 0x100000001b3) & 0xFFFFFFFFFFFFFFFF + return f"/tmp/acpmux-{os.getuid()}/{h:016x}.sock" + + +def live(path): + try: + conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + conn.settimeout(2) + conn.connect(path) + conn.close() + return True + except OSError: + return False + + +def slug(tag): + return re.sub(r"[^a-z0-9]+", "-", tag.lower()).strip("-") + + +def acpmux_homes(): + """Build homes with a live daemon: any under the slot HOME (only this app runs there), and + under the account home only those naming --tag as a word. A build's home is + ~/.acpmux/builds/ (cx-is1kf.3, the key ends in the tag); older builds used ~/.acpmux/tags/.""" + found = {} + for index, user_home in enumerate(opts.home): + for home in glob.glob(os.path.join(user_home, ".acpmux", "builds", "*")) + \ + glob.glob(os.path.join(user_home, ".acpmux", "tags", "*")): + named = f"-{slug(os.path.basename(home))}-" + if index > 0 and not (opts.tag and f"-{slug(opts.tag)}-" in named): + continue + if live(socket_of(home)): + found[home] = socket_of(home) + return found + + +def write_config(path, text): + temp = path + ".open-beside-realchat" + with open(temp, "w") as f: + f.write(text) + os.replace(temp, path) + + +def restore_configs(): + for path, original, sock in RESTORE: + try: + if original is None: + os.remove(path) + else: + write_config(path, original) + Acpmux(sock).request("_acpmux/reload_config", {}, timeout=10) + print("restored", path, flush=True) + except OSError as error: + print("could not restore", path, error, flush=True) + + +def configure_fake(home, sock): + path = os.path.join(home, "config.json") + try: + with open(path) as f: + original = f.read() + except FileNotFoundError: + original = None + config = json.loads(original) if original is not None else {} + config.setdefault("harnesses", {})["fake"] = {"argv": ["/usr/bin/python3", opts.fake_agent]} + write_config(path, json.dumps(config, indent=2)) + RESTORE.append((path, original, sock)) + client = Acpmux(sock) + print("reload", home, json.dumps(client.request("_acpmux/reload_config", {}))[:200], flush=True) + return client + + +# ---- the app's panes --------------------------------------------------------------------------- + +def topology(): + snap = rpc("snapshot.get") or {} + return (snap.get("topology") or snap) if isinstance(snap, dict) else {} + + +def panes_of(workspace): + """[(pane id, [tab dicts])] of the workspace's first screen, in the snapshot's order.""" + for ws in topology().get("workspaces", []): + if ws.get("id") == workspace: + return [(p.get("id"), p.get("tabs") or []) for s in (ws.get("screens") or [])[:1] for p in s.get("panes") or []] + return [] + + +def selected_url(workspace, pane): + for ws in topology().get("workspaces", []): + if ws.get("id") != workspace: + continue + for p in (ws.get("screens") or [{}])[0].get("panes") or []: + if p.get("id") == pane: + return next((t.get("url") or "" for t in p.get("tabs") or [] if t.get("id") == p.get("selected_tab")), "") + return "" + + +def brief(workspace): + def tab(t): + label = t.get("kind") or "?" + if t.get("agent_session"): + label += "@" + str(t["agent_session"])[-8:] + if t.get("url"): + label += ":" + t["url"].rsplit(":", 1)[-1] + return label + return [(pane[-6:], [tab(t) for t in tabs]) for pane, tabs in panes_of(workspace)] + + +def pane_rpc(pane, action, **params): + return rpc("debug.agent_pane", {"action": action, "pane": pane, **params}, timeout=60) + + +SID = [None] + + +def chat_pane(workspace): + """The pane holding the tab bound to our session (it moves into a dock on the first click).""" + for pane, tabs in panes_of(workspace): + if any(t.get("agent_session") == SID[0] for t in tabs): + return pane + return None + + +def chat_tab(workspace): + for _, tabs in panes_of(workspace): + for t in tabs: + if t.get("agent_session") == SID[0]: + return t.get("id") + return None + + +def beside(workspace, chat): + return [(pane, tabs) for pane, tabs in panes_of(workspace) if pane != chat] + + +def browsers(workspace, chat): + return [t.get("url", "") for _, tabs in beside(workspace, chat) for t in tabs if t.get("kind") == "browser"] + + +def visibility(workspace, holder_url_suffix): + """(ok, detail): the chat's pane is in a shown left dock, and the pane holding the URL is a + strip pane at least 200 pt wide inside the strip's uncovered area.""" + chat = chat_pane(workspace) + holder = next((p for p, tabs in beside(workspace, chat) for t in tabs + if (t.get("url") or "").endswith(holder_url_suffix)), None) + dock = rpc("debug.dock") or {} + chrome = rpc("debug.pane_chrome") or {} + chat_dock = next((d for w in dock.get("windows", []) for d in ((w.get("screen") or {}).get("dock") or []) + if chat in (d.get("panes") or [])), None) + strip = next(((w.get("screen") or {}).get("strip") for w in dock.get("windows", []) if (w.get("screen") or {}).get("strip")), None) + def norm(r): + if isinstance(r, list) and len(r) == 4: + return {"x": r[0], "y": r[1], "width": r[2], "height": r[3]} + return r if isinstance(r, dict) else None + cells = {p.get("pane"): norm(p.get("cell")) for w in (chrome.get("windows") or []) for p in w.get("panes") or []} + if strip: + strip = dict(strip, uncovered_in_window=norm(strip.get("uncovered_in_window"))) + def shown_width(cell): + if not cell or not strip: + return 0 + area = strip.get("uncovered_in_window") or {} + left = max(cell["x"], area.get("x", 0)) + right = min(cell["x"] + cell["width"], area.get("x", 0) + area.get("width", 0)) + return max(0, right - left) + chat_cell = cells.get(chat) + holder_shown = shown_width(cells.get(holder)) + ok = bool(chat_dock and chat_dock.get("shown") and chat_dock.get("edge") == "left" + and chat_cell and chat_cell["width"] > 100 and chat_cell["x"] >= 0 and holder_shown >= 200) + detail = (f"chat {str(chat)[-6:]} dock={chat_dock and {k: chat_dock.get(k) for k in ('edge', 'mode', 'shown')}} " + f"cell={chat_cell}; holder {str(holder)[-6:]} cell={cells.get(holder)} shown_w={holder_shown:.0f}; " + f"strip={strip and strip.get('uncovered_in_window')}") + return ok, detail + + +def serve(): + class Page(http.server.BaseHTTPRequestHandler): + def do_GET(self): + body = f"{self.path}

{self.path}

".encode() + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + server = http.server.ThreadingHTTPServer(("127.0.0.1", PORT), Page) + threading.Thread(target=server.serve_forever, daemon=True).start() + return server + + +def new_workspace(): + before = {ws.get("id") for ws in topology().get("workspaces", [])} + rpc("action.run", {"action": "newTab", "args": {}, "focus": True}) + made = wait(lambda: [ws.get("id") for ws in topology().get("workspaces", []) if ws.get("id") not in before], 15) + if not made: + sys.exit("no workspace opened") + CREATED.append(made[0]) + pane = wait(lambda: (panes_of(made[0]) or [(None,)])[0][0], 10) + return made[0], pane + + +def recorder(): + """~4 fps window frames with the caption of the moment.""" + meta, start, index = [], time.time(), 0 + while RECORDING.is_set(): + began = time.time() + name = f"{index:04d}.png" + reply = rpc("debug.window_snapshot", {"path": os.path.join(opts.out, name)}, timeout=10) + if isinstance(reply, dict) and "error" not in reply: + meta.append({"file": name, "t": round(began - start, 2), "caption": CAPTION[0]}) + index += 1 + time.sleep(max(0.0, 0.25 - (time.time() - began))) + with open(os.path.join(opts.out, "meta.json"), "w") as f: + json.dump(meta, f, indent=1) + print("recorded", len(meta), "frames", flush=True) + + +def caption(text): + CAPTION[0] = text + print("caption:", text, flush=True) + + +def pace(): + if opts.record: + time.sleep(2.5) # recording: one step every 2.5 s, so each shows + + +def click(workspace, selector): + chat = wait(lambda: chat_pane(workspace), 10) + reply = pane_rpc(chat, "click", selector=selector) + print("click", str(chat)[-6:], selector, json.dumps(reply)[:200], flush=True) + return reply + + +def open_real_chat(workspace, pane): + """A chat tab in `pane` bound to a fake-harness acpmux session; returns its session id.""" + before = {t.get("id") for _, tabs in panes_of(workspace) for t in tabs} + print("before chat:", brief(workspace), flush=True) + print("newAgentChat:", json.dumps(rpc("action.run", {"action": "palette.newAgentChat", "target": f"pane:{pane}", "focus": True}))[:300], flush=True) + found = wait(lambda: acpmux_homes() or None, 60, 0.5) + print("acpmux homes:", found, "searched", opts.home, flush=True) + if not found: + sys.exit("no acpmux daemon came up (is the app running without --showcase?)") + home, sock = max(found.items(), key=lambda item: os.path.getmtime(item[0])) + client = configure_fake(home, sock) + os.makedirs(os.path.dirname(NOTES), exist_ok=True) + created = client.request("session/new", {"cwd": os.path.dirname(NOTES), "mcpServers": [], + "_meta": {"acpmux": {"harness": "fake"}}}) + sid = (created.get("result") or {}).get("sessionId") + print("session/new:", json.dumps(created)[:300], flush=True) + if not sid: + sys.exit("acpmux made no fake session") + client.request("session/prompt", {"sessionId": sid, "prompt": [{"type": "text", "text": "hi"}], + "_meta": {"acpmux": {"promptId": "p-hi"}}}, timeout=60) + SID[0] = sid + time.sleep(1) # test harness: the new chat tab draws its page + print("after newAgentChat:", brief(workspace), flush=True) + new = [p for p, tabs in panes_of(workspace) for t in tabs if t.get("id") not in before] + target = new[0] if new else pane + for attempt in range(3): + print("select_session:", json.dumps(pane_rpc(target, "select_session", session=sid))[:300], flush=True) + if wait(lambda: chat_pane(workspace), 20, 0.5): + break + print("not bound yet:", brief(workspace), flush=True) + if not chat_pane(workspace): + sys.exit("the chat tab never bound to the session") + # A lone chat: the only tab of its pane. + chat = chat_pane(workspace) + for p, tabs in panes_of(workspace): + for t in tabs: + if t.get("agent_session") != sid: + print("close", t.get("kind"), json.dumps(rpc("action.run", {"action": "closeTab", "target": f"tab:{t.get('id')}"}))[:200], flush=True) + wait(lambda: len(panes_of(workspace)) == 1 and len(panes_of(workspace)[0][1]) == 1, 10) + print("chat:", brief(workspace), flush=True) + return sid + + +def main(): + if not wait(lambda: (rpc("debug.windows") or {}).get("windows"), 60): + sys.exit("the app does not answer on " + opts.socket) + server = serve() + os.makedirs(os.path.dirname(NOTES), exist_ok=True) + with open(NOTES, "w") as f: + f.write("# Notes\n\nOpened beside the chat.\n") + if opts.record: + print("window_frame:", json.dumps(rpc("debug.window_frame", {"frame": [20, 60, 1500, 900]}))[:200], flush=True) + workspace, pane = new_workspace() + open_real_chat(workspace, pane) + chat = chat_pane(workspace) + print("seed:", json.dumps(pane_rpc(chat, "seed_rows", fixture="links"))[:200], flush=True) + time.sleep(2) # test harness: the reply draws and its path chip asks the host about its path + print("start:", brief(workspace), flush=True) + recording = None + if opts.record: + RECORDING.set() + recording = threading.Thread(target=recorder, daemon=True) + recording.start() + caption("A real agent chat (fake harness), alone in its workspace") + time.sleep(2.5) # recording: the start frame + + # 1. The first link. + caption("Click link /pr/1: it opens in the in-app browser beside the chat") + click(workspace, f'a[href="http://127.0.0.1:{PORT}/pr/1"]') + first = wait(lambda: any(u.endswith("/pr/1") for u in browsers(workspace, chat_pane(workspace))), 10) + row("a link opens in the in-app browser beside the chat", "a browser tab on /pr/1 outside the chat's pane", + f"panes {brief(workspace)}", bool(first)) + panes_after_first = len(panes_of(workspace)) + time.sleep(1) # test harness: the dock move settles + shown, detail = wait(lambda: (lambda r: r if r[0] else None)(visibility(workspace, "/pr/1")), 8) or visibility(workspace, "/pr/1") + row("the chat docks left and the pane beside it is in view", "chat in a shown left dock; /pr/1 pane >=200 pt in the strip", + detail, shown) + pace() + + # 2. The second link. + caption("Click link /pr/2: a second tab in the same pane, no new split") + click(workspace, f'a[href="http://127.0.0.1:{PORT}/pr/2"]') + second = wait(lambda: any(u.endswith("/pr/2") for u in browsers(workspace, chat_pane(workspace))), 10) + side = beside(workspace, chat_pane(workspace)) + holder = [p for p, tabs in side if any(t.get("url", "").endswith("/pr/2") for t in tabs)] + same = bool(second) and len(panes_of(workspace)) == panes_after_first and len(holder) == 1 and \ + any(t.get("url", "").endswith("/pr/1") for p, tabs in side if p == holder[0] for t in tabs) + row("a second link adds a tab to the same pane", f"{panes_after_first} panes; /pr/1 and /pr/2 in one pane", + f"panes {brief(workspace)}", same) + print("visible after 2:", visibility(workspace, "/pr/2"), flush=True) + pace() + + # 3. The first link again. + caption("Click link /pr/1 again: its tab is selected, no third tab") + tabs_before = len(browsers(workspace, chat_pane(workspace))) + click(workspace, f'a[href="http://127.0.0.1:{PORT}/pr/1"]') + time.sleep(1.5) # test harness: a wrong open would land by now + shown3 = wait(lambda: selected_url(workspace, holder[0]).endswith("/pr/1") if holder else False, 5) + again = tabs_before == 2 and len(browsers(workspace, chat_pane(workspace))) == tabs_before and len(panes_of(workspace)) == panes_after_first + row("a link already open beside the chat selects its tab there, no other tab", + f"still 2 browser tabs, {panes_after_first} panes, /pr/1 selected", + f"panes {brief(workspace)} selected {selected_url(workspace, holder[0]) if holder else None!r}", again and bool(shown3)) + print("visible after 3:", visibility(workspace, "/pr/1"), flush=True) + pace() + + # 4. The file ref, also when the chat's own pane shows the file. + caption("Click the file ref: its page opens in the same pane beside the chat") + chat = chat_pane(workspace) + tab = chat_tab(workspace) + rpc("action.run", {"action": "file.open", "args": {"path": NOTES}, "target": f"pane:{chat}", "wait": True}) + time.sleep(1) # test harness: the file's tab lands + print("after file.open:", brief(workspace), flush=True) + rpc("action.run", {"action": "tab.focus", "target": f"tab:{tab}", "focus": True}) + time.sleep(1) # test harness: the chat shows again before its click + pages_before = sum(1 for _, tabs in beside(workspace, chat_pane(workspace)) for t in tabs if t.get("kind") != "browser") + panes_before4 = len(panes_of(workspace)) + click(workspace, f'[data-path="{NOTES}"]') + page = wait(lambda: sum(1 for _, tabs in beside(workspace, chat_pane(workspace)) for t in tabs if t.get("kind") != "browser") > pages_before, 10) + row("a file ref opens its page beside the chat, also when the chat's pane shows it", + "one more page tab outside the chat's pane, no new pane", + f"panes {brief(workspace)}", bool(page) and len(panes_of(workspace)) == panes_before4) + print("visible after 4:", visibility(workspace, "/pr/1"), flush=True) + caption("Done") + time.sleep(2.5 if opts.record else 0.5) # recording: the end frame + + if recording: + RECORDING.clear() + recording.join(30) + rpc("debug.window_snapshot", {"path": os.path.join(opts.out, "open-beside.png")}) + for ws in CREATED: + rpc("action.run", {"action": "closeWorkspace", "args": {"confirm": True}, "target": f"workspace:{ws}"}) + server.shutdown() + + +try: + main() +finally: + RECORDING.clear() + restore_configs() +print("\nRESULT", "PASS" if ROWS and all(ROWS) else "FAIL", f"({sum(ROWS)}/{len(ROWS)})") +sys.exit(0 if ROWS and all(ROWS) else 1) diff --git a/webviews/src/agent-session/acpmux/debug.ts b/webviews/src/agent-session/acpmux/debug.ts index 8c4deb5b3605..392ffef2c2d6 100644 --- a/webviews/src/agent-session/acpmux/debug.ts +++ b/webviews/src/agent-session/acpmux/debug.ts @@ -6,6 +6,7 @@ import { openPicker, pickerLabels } from "./pickerOpeners"; import { syntheticRows } from "./synthetic"; import { runStream, type StreamOptions } from "./streamDebug"; import { loadRun, loadSeed, type LoadRunOptions } from "./loadDebug"; +import { linksTurnRows } from "./linksTurn"; import { workedTurnRows } from "./workedTurn"; import { acpWire, type AcpWireLog } from "./wire"; import { followTrace } from "./followTrace"; @@ -20,7 +21,7 @@ export type FlingOptions = { nominal_ms?: number; wait?: boolean }; export type AcpmuxDebug = { /// Replaces the transcript with `count` synthetic rows, or with the worked turn of - /// workedTurn.ts when `fixture` is "worked-turn". + /// workedTurn.ts when `fixture` is "worked-turn", linksTurn.ts when it is "links". seedRows(count?: number, fixture?: string): Promise>; startFling(seconds?: number, options?: FlingOptions): Promise>; flingStats(): Record; @@ -122,7 +123,11 @@ export function createAcpmuxDebug( async seedRows(count = 5000, fixture) { acpmuxPerf.enable(); const rows = - fixture === "worked-turn" ? workedTurnRows(Date.now() - 60_000) : syntheticRows(Math.max(3, Math.floor(count))); + fixture === "worked-turn" + ? workedTurnRows(Date.now() - 60_000) + : fixture === "links" + ? linksTurnRows(Date.now() - 60_000) + : syntheticRows(Math.max(3, Math.floor(count))); const start = performance.now(); const committed = acpmuxPerf.nextCommit(); host.replaceRows(rows); diff --git a/webviews/src/agent-session/acpmux/linksTurn.ts b/webviews/src/agent-session/acpmux/linksTurn.ts new file mode 100644 index 000000000000..366bbf3cfb65 --- /dev/null +++ b/webviews/src/agent-session/acpmux/linksTurn.ts @@ -0,0 +1,21 @@ +// A reply with two web links and a file path, for `debug.agent_pane seed_rows` with +// `fixture: "links"`: scripts/cmux-next/open-beside-e2e.py clicks them (open beside, ChatBeside). +// The links point at a loopback port the script serves; the path is a file it writes. +import type { AcpmuxRow } from "./model"; + +export const linksTurnPort = 47811; + +/// The prompt and the reply with the links, starting at `at`. +export function linksTurnRows(at: number): AcpmuxRow[] { + const base = `http://127.0.0.1:${linksTurnPort}`; + return [ + { id: "links-user", version: 1, at, kind: "user", text: "Where are the PRs?" }, + { + id: "links-reply", + version: 1, + at: at + 6_000, + kind: "assistant", + text: `Opened [PR one](${base}/pr/1) and [PR two](${base}/pr/2). The notes are in \`/tmp/cmux-open-beside/notes.md\`.`, + }, + ] as AcpmuxRow[]; +}