diff --git a/Packages/macOS/CmuxNext/Package.swift b/Packages/macOS/CmuxNext/Package.swift index 93c82035b9ff..8cf7d98f6905 100644 --- a/Packages/macOS/CmuxNext/Package.swift +++ b/Packages/macOS/CmuxNext/Package.swift @@ -452,11 +452,11 @@ let package = Package( dependencies: ["CmuxNextImportSuggestions", "CmuxNextBrowserImport"], swiftSettings: daemonSwiftSettings ), - // The cards' center and surfaces: the scan schedule, the job runner, the `import.*` - // actions, the agent pages' push and the Chief front page's row. The App supplies `ImportCardsHost`. + // The cards' center: the scan schedule, the job runner and the `import.*` actions, for + // Settings › Import (no Home or New Tab suggestion). The App supplies `ImportCardsHost`. .target( name: "CmuxNextImportCards", - dependencies: ["CmuxNextActions", "CmuxNextAgentPane", "CmuxNextBrowserImport", "CmuxNextCompat", "CmuxNextDesign", + dependencies: ["CmuxNextActions", "CmuxNextBrowserImport", "CmuxNextCompat", "CmuxNextDesign", "CmuxNextImportSuggestions", "CmuxNextOnboarding", "CmuxNextSettings"], swiftSettings: uiSwiftSettings ), diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/ImportActionCatalog.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/ImportActionCatalog.swift index 4d1415eeae97..62c7d44db83a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/ImportActionCatalog.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/ImportActionCatalog.swift @@ -1,5 +1,5 @@ // Import suggestion cards (CmuxNextImportCards): what this Mac has that cmux can bring over, -// on Home, New Tab and Settings › Import. Titles live in ImportActions.xcstrings. The CLI and +// listed in Settings › Import (never on Home or New Tab). Titles live in ImportActions.xcstrings. The CLI and // agents list the cards, press a card's button, and raise a card for a site another browser is // signed into (`cmux import suggest --browser chrome --site github.com --reason …`): a raised card // reads nothing until the person clicks Import on it. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift index 47372022809c..4e1ca318b3a3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift @@ -33,7 +33,7 @@ public nonisolated struct AgentPageOps { var methods = Dictionary(uniqueKeysWithValues: [ "pane.checkpointAvailability", "pane.framePacing", "pane.painted", "pane.renderRate", "pane.saveLog", "pane.saveFile", "pane.showContextUsage", "pane.showDictation", "pane.setDisplay", "pane.edit", "pane.setQuickActions", "tab.open", "run.attach", "tab.typeAhead", "tab.jump", - "newTab.remember", "newTab.setTemplate", "newTab.editWidgets", "nowPlaying.command", "importCards.act", "newTab.inputReady", "newTab.touched", "newTab.context", "newTab.warm", "shortcut.edit", "action.run", "file.open", "browser.open", + "newTab.remember", "newTab.setTemplate", "newTab.editWidgets", "nowPlaying.command", "newTab.inputReady", "newTab.touched", "newTab.context", "newTab.warm", "shortcut.edit", "action.run", "file.open", "browser.open", "project.list", "project.icon", "project.browse", "workspace.chooseFolder", "workspace.useFolder", "chat.folder.choose", "chat.attach.choose", "chat.attach.dropped", "app.action", "chats.open", "chats.openInTerminal", "chats.watch", "chats.page", "quick.dismiss", "quick.openInWindow", "quick.startInBackground", "quick.setOnTop", "quick.setCollapsed", "quick.floatingState", "quick.context", "pane.action", "pane.tabState", "pane.tabRowTools", "chat.toolCard.respond", "chat.upload", "chat.sideChat", "chat.subagentPane", "chat.processTerminal", "chat.openTab", "chat.offer.open", "chat.asks.open", "chat.newHere", "chat.unsentDraft", "chat.scrollAnchor", "chat.scrollAnchor.get", "chat.tail", "chat.tail.get", "chat.draftAttachments", "chat.draftAttachments.get", "chat.asks.pending", "shell.run", "shell.read", "shell.stop", "harness.update", "harness.restart", diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneBridge.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneBridge.swift index f0fe9a396140..c20e397250ad 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneBridge.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneBridge.swift @@ -95,7 +95,6 @@ final class AgentPaneBridge: NSObject, WKScriptMessageHandlerWithReply { AgentPaneDeviceChat.push(view.deviceChats, to: view) AgentPaneClosedItem.push(view.recentlyClosed, to: view) AgentPaneNowPlaying.shared.push(to: view) - AgentPaneImportCards.shared.push(to: view) } return view.model } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneImportCards.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneImportCards.swift deleted file mode 100644 index 530ed5bdcfde..000000000000 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneImportCards.swift +++ /dev/null @@ -1,71 +0,0 @@ -public import CmuxNextSettings -public import Foundation - -/// The import suggestion cards on the New Tab page: what this Mac has that cmux can bring -/// over (CmuxNextImportCards' `ImportSuggestionsCenter` sets ``cards``) and a card's button -/// back to it. App-wide like ``AgentPaneNowPlaying``: each page gets the cards at load and -/// handshake and again on every change. Not an AgentPaneView extension: that type is at its size limit. -@MainActor -public final class AgentPaneImportCards { - public static let shared = AgentPaneImportCards() - - /// One deck; the App uses `shared`, a test its own. - init() {} - - /// The New Tab list as the page draws it (`{title, cards: [...]}`, every text already - /// localized); null when there is nothing to show or the cards are off. - public var cards: JSONValue = .null { - didSet { - guard cards != oldValue else { return } - for view in views.allObjects { send(to: view) } - } - } - /// Runs a card's button (`importCards.act {id, action}`). - public var onAction: ((_ id: String, _ action: String) -> Void)? - private let views = NSHashTable.weakObjects() - - /// A page's button: a card id and one of the card actions, a short lowercase ASCII word. - public nonisolated struct Act: Hashable, Sendable { - public var id: String - public var action: String - - init?(_ params: [String: Any]?) { - guard let id = params?["id"] as? String, let action = params?["action"] as? String, - (1...200).contains(id.count), (1...16).contains(action.count), - action.unicodeScalars.allSatisfy({ (97...122).contains($0.value) }) else { return nil } - self.id = id - self.action = action - } - } - - /// The cards, read now, as a page host event; `view` then also gets every change. - func event(for view: AgentPaneView) -> AgentPageEvent { - views.add(view) - return .importCards(cards) - } - - /// Pushes the cards, read now, to `view`'s page; `view` then also gets every change. - func push(to view: AgentPaneView) { - views.add(view) - send(to: view) - } - - private func send(to view: AgentPaneView) { - let event = AgentPageEvent.importCards(cards) - view.deliver([event], scripts: ["window.cmuxAcpmuxBridge?.applyImportCards?.(\(event.value.compactText));"]) - } - - /// Runs a page's button; false when the App has not wired the cards. - func perform(_ act: Act) -> Bool { - guard let onAction else { return false } - onAction(act.id, act.action) - return true - } -} - -extension AgentPageEvent { - /// The New Tab page's import cards (`importCards`); null while there are none. - static func importCards(_ cards: JSONValue) -> AgentPageEvent { - AgentPageEvent(kind: "importCards", value: cards) - } -} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift index dd6b9dbbc264..776d427a1b7f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift @@ -306,7 +306,6 @@ public final class AgentPaneModel { case .rememberNewTab(let pick): guard let onRememberNewTab else { return Self.unsupported("newTab.remember") }; onRememberNewTab(pick); return AgentPaneReply.success() case .editNewTab(let edit): guard let onEditNewTab else { return Self.unsupported(edit.method) }; onEditNewTab(edit); return AgentPaneReply.success() case .nowPlaying(let command): return AgentPaneNowPlaying.shared.perform(command) ? AgentPaneReply.success() : Self.unsupported("nowPlaying.command") - case .importCard(let act): return AgentPaneImportCards.shared.perform(act) ? AgentPaneReply.success() : Self.unsupported("importCards.act") case .runAction(let id): return runAction(id) case .jump(let target, let id): guard newTab != nil, let onJump else { return Self.unsupported("tab.jump") }; onJump(target, id); return AgentPaneReply.success() case .chooseFolder where remoteChat: return Self.unsupported("workspace.chooseFolder") diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPanePageHost.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPanePageHost.swift index efb46fa10816..b8f9f6c0dfc2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPanePageHost.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPanePageHost.swift @@ -33,7 +33,6 @@ enum AgentPanePageHost { events.append(.deviceChats(AgentPaneDeviceChat.shown(view.deviceChats, in: view))) events.append(view.recentlyClosed.event) events.append(AgentPaneNowPlaying.shared.event(for: view)) - events.append(AgentPaneImportCards.shared.event(for: view)) events.append(.editedFiles(view.editedFiles)) events.append(.composer(view.model.composer)) events.append(.sidebarPreviewVariant(view.model.sidebarPreviewVariant)) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift index 28518026a706..b2af0d04fae1 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift @@ -182,8 +182,6 @@ extension AgentPaneRequest { self = AgentPaneNewTabEdit(widgetParams: params).map(AgentPaneRequest.editNewTab) ?? .unsupported(method) case "nowPlaying.command": if let command = AgentPaneNowPlaying.Command(params) { self = .nowPlaying(command) } else { self = .unsupported(method) } - case "importCards.act": - if let act = AgentPaneImportCards.Act(params) { self = .importCard(act) } else { self = .unsupported(method) } case "newTab.remember": // One input (R86): the agent pick, and the model picked on it in `folder`. if let agent = params?["agent"] as? String, !agent.isEmpty, agent.count <= 128 { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+SafeDescription.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+SafeDescription.swift index a4e2815451ed..5ec50e99f9de 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+SafeDescription.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+SafeDescription.swift @@ -46,7 +46,6 @@ extension AgentPaneRequest: CustomStringConvertible, CustomDebugStringConvertibl case .listProjects: "listProjects" case .projectIcon: "projectIcon" case .nowPlaying: "nowPlaying" - case .importCard: "importCard" case .openChat: "openChat" case .openChatInTerminal: "openChatInTerminal" case .watchChat: "watchChat" diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift index 79787a3f5529..a1cfcddfcd6d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift @@ -62,8 +62,6 @@ public nonisolated enum AgentPaneRequest: Equatable, Sendable { case editNewTab(AgentPaneNewTabEdit) /// The New Tab page player's control (`nowPlaying.command {command, seconds}`). case nowPlaying(AgentPaneNowPlaying.Command) - /// An import card's button on the New Tab page (`importCards.act {id, action}`). - case importCard(AgentPaneImportCards.Act) /// The new tab page got its first user input (`newTab.touched`); a /// touched page is never recycled into the prewarm pool. case touched diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/OnboardingHandlers.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/OnboardingHandlers.swift index e0b7af6c3836..2507d49735f5 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/OnboardingHandlers.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/OnboardingHandlers.swift @@ -54,7 +54,7 @@ enum OnboardingHandlers { track: { registry.track($0) }, destination: ChromiumCookieDestination { writes, profile in try await cef.importCookies(writes, into: profile) })) registry.bind("browser.importCookies", run: { invocation in try cookieImports.run(invocation) }) - // Import suggestion cards (Home, New Tab, Settings › Import): the same import run as the window's. + // Import suggestion cards (Settings › Import only): the same import run as the window's. ImportSuggestionsCenter.shared.bind(into: registry, host: importCardsHost(services)) // Every entrypoint previews the exact snapshot and waits for the // person's answer before any browser, SSH host or agent is restored. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeHostView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeHostView.swift index 54bf8bf30422..c2aef289ac9d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeHostView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeHostView.swift @@ -9,7 +9,6 @@ import CmuxNextDesign import CmuxNextHome import CmuxNextHomeEngine import CmuxNextHostedChief -import CmuxNextImportCards import CmuxNextPages import CmuxNextWakeups @@ -230,12 +229,6 @@ final class HomeHostView: NSView { } // The first-run panel's agent rows open the agent's tab like a jump (Search Tabs). transcript.onFirstRunAgent = { [weak services] tab in _ = services?.revealTab(tab) } - // What this Mac can bring over (Claude Code's sessions, a browser's history) is one row on - // the Chief's front page, under its action rows: never a strip over the conversation - // (Leo 2026-10-21, nightly 331e2e9). Not Now dismisses it; Show All opens Settings › Import. - let suggestion = ImportSuggestionRowView() - suggestion.onChange = { [weak transcript] in transcript?.firstRunAccessoryChanged() } - transcript.setFirstRunAccessory(suggestion) // task-owner: lives as long as this view; event-driven (Observation): // the user's agents for the first-run glance, only while it can show // (the Chief with no messages yet): otherwise no tab is read, so tab diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeFirstRunView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeFirstRunView.swift index f700317b363f..89b77f298dde 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeFirstRunView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeFirstRunView.swift @@ -15,8 +15,7 @@ public enum HomeFirstRunAction: Sendable, Equatable { /// The empty Chief conversation's first-run panel, the Chief's front page: the Chief's face /// and what it does (`HomeFirstRunHero`), then things to do now, each a row with its shortcut /// (start an agent, open a terminal) and the Chief pet's Show / Hide, then the user's agents -/// and the host's row under them (Home's import suggestion), then the user's agents at a -/// glance, most urgent first, each opening its tab, then the "Switching" card with the +/// at a glance, most urgent first, each opening its tab, then the "Switching" card with the /// core switching keys (`HomeFirstRunSwitchCard`, hidden for good by its Hide button). Hidden /// once the conversation has a message (`HomeNativeTranscriptView.updateFirstRun`). A short /// pane drops agent rows first, then the hero. @@ -44,26 +43,6 @@ final class HomeFirstRunView: NSView { private var scale: CGFloat = 1 private var colors: Colors? private lazy var headerWidth = agentsHeader.widthAnchor.constraint(equalToConstant: Self.maxWidth) - /// The host's row under the action rows (Home's import suggestion, `ImportSuggestionRowView`), - /// at the column's width; out of the layout while hidden. It sizes its own height and tells - /// the host when it shows or hides (`HomeNativeTranscriptView.firstRunAccessoryChanged`). - var accessory: NSView? { - didSet { - guard accessory !== oldValue else { return } - accessoryWidth?.isActive = false - accessoryWidth = nil - oldValue?.removeFromSuperview() - if let accessory { - accessory.translatesAutoresizingMaskIntoConstraints = false - let width = accessory.widthAnchor.constraint(equalToConstant: Self.maxWidth) - width.isActive = true - accessoryWidth = width - } - arrange() - needsLayout = true - } - } - private var accessoryWidth: NSLayoutConstraint? /// Called when the user picks open a terminal or start an agent. var onAction: (HomeFirstRunAction) -> Void = { _ in } /// Called with an agent's tab id when the user picks its row. @@ -142,8 +121,7 @@ final class HomeFirstRunView: NSView { private func arrange() { for view in stack.arrangedSubviews { stack.removeArrangedSubview(view); view.removeFromSuperview() } let listed: [NSView] = agentRows.isEmpty ? [] : [agentsHeader] + agentRows - let hosted: [NSView] = accessory.map { [$0] } ?? [] - for view in [hero] + actionRows + hosted + listed + [switchCard] as [NSView] { stack.addArrangedSubview(view) } + for view in [hero] + actionRows + listed + [switchCard] as [NSView] { stack.addArrangedSubview(view) } applySpacing() } @@ -156,9 +134,7 @@ final class HomeFirstRunView: NSView { /// stack's layout). private func fitAgentRows() { let row = HomeFirstRunRow.height * scale, gap = 6 * scale, group = 14 * scale - // Home's import suggestion sits under the action rows and goes with them. let actions = CGFloat(actionRows.count) * row + CGFloat(actionRows.count - 1) * gap - + (accessory.map { $0.isHidden ? 0 : group + $0.fittingSize.height } ?? 0) let actionsFit = actions <= bounds.height if stack.isHidden != !actionsFit { stack.isHidden = !actionsFit } // The card's keys are set (Hide clears them); it shows when it fits under the rows. @@ -189,7 +165,6 @@ final class HomeFirstRunView: NSView { private func applySpacing() { stack.spacing = 6 * scale stack.setCustomSpacing(14 * scale, after: pet) - if let accessory { stack.setCustomSpacing(14 * scale, after: accessory) } stack.setCustomSpacing(Self.heroGap * scale, after: hero) } @@ -262,7 +237,6 @@ final class HomeFirstRunView: NSView { let width = max(0, min(Self.maxWidth * scale, bounds.width - 48 * scale)) for row in rows { row.width = width } if headerWidth.constant != width { headerWidth.constant = width } - if let accessoryWidth, accessoryWidth.constant != width { accessoryWidth.constant = width } if cardWidth.constant != width { cardWidth.constant = width } if heroWidth.constant != width { heroWidth.constant = width } let heroTall = hero.height(for: width) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeNativeTranscriptView+FirstRun.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeNativeTranscriptView+FirstRun.swift index 6d1c740e2db3..1327f0da99ea 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeNativeTranscriptView+FirstRun.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHome/NativeTranscript/HomeNativeTranscriptView+FirstRun.swift @@ -39,18 +39,6 @@ extension HomeNativeTranscriptView { } } - /// A row the host puts on the Chief's front page under its action rows (Home's import - /// suggestion, `ImportSuggestionRowView`), in the panel's own layout so it never covers the - /// conversation; nil removes it. - public func setFirstRunAccessory(_ view: NSView?) { - firstRun.accessory = view - } - - /// The front page's host row showed, hid or changed size: the panel fits its rows again. - public func firstRunAccessoryChanged() { - firstRun.needsLayout = true - } - /// The user's agents for the first-run panel's glance (the host maps /// them from the tab status the strip and the sidebar draw); empty lists /// none. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextImportCards/ImportCardJSON.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextImportCards/ImportCardJSON.swift index dc6717c1afe6..8afac1f0457d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextImportCards/ImportCardJSON.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextImportCards/ImportCardJSON.swift @@ -2,9 +2,9 @@ import AppKit import CmuxNextImportSuggestions import CmuxNextSettings -/// A card list as the web surfaces read it (New Tab's `importCards` push, Settings' host lists -/// `import_suggestions`, `import.list`): the Codable cards, each with its source app's icon as -/// a small `data:image/png` URL (a cmux page's CSP allows `img-src data:` only). +/// A card list as the web surfaces read it (Settings' host lists `import_suggestions`, +/// `import.list`): the Codable cards, each with its source app's icon as a small +/// `data:image/png` URL (a cmux page's CSP allows `img-src data:` only). @MainActor struct ImportCardJSON { /// Null for an empty list: the surface draws nothing and keeps no room. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextImportCards/ImportCardsActions.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextImportCards/ImportCardsActions.swift index 26a555254a15..ac6ddf7e42cb 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextImportCards/ImportCardsActions.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextImportCards/ImportCardsActions.swift @@ -13,7 +13,7 @@ extension ImportSuggestionsCenter { // Every card Settings › Import lists, as JSON text (`cmux import list`). registry.bind("import.list", unavailable: refusedWhileOff, invoke: { [weak self, weak registry] _ in guard let self, let registry else { return } - let text = ImportCardJSON.text(list(for: .settings)) + let text = ImportCardJSON.text(list()) registry.track(result: Task { .success(["cards": .string(text)]) }) }) registry.bind("import.card", unavailable: refusedWhileOff, invoke: { [weak self, weak registry] invocation in diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextImportCards/ImportSuggestionRowView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextImportCards/ImportSuggestionRowView.swift deleted file mode 100644 index fca36959e57a..000000000000 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextImportCards/ImportSuggestionRowView.swift +++ /dev/null @@ -1,234 +0,0 @@ -public import AppKit -import CmuxNextDesign -import CmuxNextImportSuggestions -import Observation - -/// Home's import suggestion: one compact row on the Chief's front page (the empty Chief -/// conversation's first-run panel), never over a conversation. It shows the first card of the -/// Home list: the source app's icon, its name and what was found ("Claude Code", "1,002 -/// sessions"), its button and Not Now; Show All opens Settings › Import when there are more. A -/// running import shows its status and a thin progress line. Hidden when there is nothing to -/// suggest. (Leo 2026-10-21 on nightly 331e2e9: the floating strip of cards it replaces sat over -/// the chat, which showed through and around it.) -@MainActor -public final class ImportSuggestionRowView: NSView { - /// The row's height: one line of controls on the front page. - static let height: CGFloat = 40 - /// The source app's icon side. - static let iconSide: CGFloat = 20 - - /// The sidebar cards' glass veil, never an opaque card. - let surface = CardSurfaceView(cornerRadius: 8) - let icon = NSImageView() - let titleLabel = NSTextField(labelWithString: "") - let factsLabel = NSTextField(labelWithString: "") - /// The shown card's buttons: its primary, then Not Now (or Done). More Options… stays in - /// Settings › Import, behind Show All. - private(set) var actions: [NSButton] = [] - let showAll = NSButton(title: "", target: nil, action: nil) - private let buttons = NSStackView() - private var progress: ImportProgressBar? - private var drawn: ImportCardList? - private var card: ImportCard? - private let press: (String, ImportCardAction) -> Void - private let openAll: () -> Void - /// Called after the row shows, hides or changes: the panel holding it lays out again. - public var onChange: () -> Void = {} - - /// A row that follows `center`'s Home list and runs its buttons there. - public convenience init(center: ImportSuggestionsCenter = .shared) { - self.init(press: { [weak center] id, action in try? center?.perform(id, action) }, - showAll: { [weak center] in center?.openSettings() }) - follow(center) - } - - init(press: @escaping (String, ImportCardAction) -> Void, showAll: @escaping () -> Void) { - self.press = press - openAll = showAll - super.init(frame: .zero) - setAccessibilityIdentifier("cmux.home.importSuggestion") - setAccessibilityElement(true) - setAccessibilityRole(.group) - build() - isHidden = true - } - - @available(*, unavailable) - required init?(coder: NSCoder) { nil } - - public override var intrinsicContentSize: NSSize { NSSize(width: NSView.noIntrinsicMetric, height: Self.height) } - - public override func viewDidChangeEffectiveAppearance() { - super.viewDidChangeEffectiveAppearance() - applyColors() - } - - private func build() { - surface.translatesAutoresizingMaskIntoConstraints = false - addSubview(surface) - icon.imageScaling = .scaleProportionallyUpOrDown - icon.translatesAutoresizingMaskIntoConstraints = false - for (label, font) in [(titleLabel, Typography.bodyEmphasized), (factsLabel, Typography.caption)] { - label.font = font - label.lineBreakMode = .byTruncatingTail - label.maximumNumberOfLines = 1 - label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - label.setContentHuggingPriority(.defaultHigh, for: .horizontal) - } - // The name keeps its room before what was found does. - titleLabel.setContentCompressionResistancePriority(NSLayoutConstraint.Priority(300), for: .horizontal) - showAll.isBordered = false - showAll.font = Typography.body - showAll.target = self - showAll.action = #selector(openSettings) - showAll.title = ImportSuggestionStrings.showAll - buttons.orientation = .horizontal - buttons.spacing = Metrics.space3 - buttons.setHuggingPriority(.required, for: .horizontal) - buttons.setContentCompressionResistancePriority(.required, for: .horizontal) - let spacer = NSView() - spacer.setContentHuggingPriority(.fittingSizeCompression, for: .horizontal) - let line = NSStackView(views: [icon, titleLabel, factsLabel, spacer, buttons]) - line.orientation = .horizontal - line.alignment = .centerY - line.spacing = Metrics.space2 - line.translatesAutoresizingMaskIntoConstraints = false - addSubview(line) - let inset = Metrics.space3 - NSLayoutConstraint.activate([ - heightAnchor.constraint(equalToConstant: Self.height), - surface.leadingAnchor.constraint(equalTo: leadingAnchor), surface.trailingAnchor.constraint(equalTo: trailingAnchor), - surface.topAnchor.constraint(equalTo: topAnchor), surface.bottomAnchor.constraint(equalTo: bottomAnchor), - icon.widthAnchor.constraint(equalToConstant: Self.iconSide), icon.heightAnchor.constraint(equalToConstant: Self.iconSide), - line.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset), - line.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset), - line.centerYAnchor.constraint(equalTo: centerYAnchor), - ]) - } - - private func follow(_ center: ImportSuggestionsCenter) { - let list = withObservationTracking { - center.list(for: .home) - } onChange: { [weak self, weak center] in - Task { @MainActor in - guard let center else { return } - self?.follow(center) - } - } - show(list) - } - - /// Shows the first card of `list`, or hides the row when it has none. - func show(_ list: ImportCardList) { - guard list != drawn else { return } - drawn = list - defer { onChange() } - guard let first = list.cards.first else { - card = nil - isHidden = true - return - } - card = first - isHidden = false - setAccessibilityLabel(first.title) - icon.image = first.app.flatMap(ImportAppIcons.image) ?? NSImage(systemSymbolName: "square.and.arrow.down", accessibilityDescription: nil) - titleLabel.stringValue = first.title - // A running, paused, finished or failed import says where it is instead of what was found. - factsLabel.stringValue = first.status ?? first.facts - factsLabel.themedToolTip = first.benefit - for button in buttons.arrangedSubviews { buttons.removeArrangedSubview(button); button.removeFromSuperview() } - let shown = (first.primary.map { [$0] } ?? []) + first.secondary.filter { $0.action != .more && $0.action != .restore } - actions = shown.enumerated().map { index, item in - let button = NSButton(title: item.title, target: self, action: #selector(pressed(_:))) - button.identifier = NSUserInterfaceItemIdentifier(item.action.rawValue) - button.isBordered = false - button.font = index == 0 && first.primary != nil ? Typography.bodyEmphasized : Typography.body - button.setContentCompressionResistancePriority(.required, for: .horizontal) - buttons.addArrangedSubview(button) - return button - } - showAll.isHidden = list.cards.count < 2 - buttons.addArrangedSubview(showAll) - showProgress(first) - applyColors() - needsLayout = true - } - - private func showProgress(_ card: ImportCard) { - progress?.removeFromSuperview() - progress = nil - guard let value = card.progress, card.state == .running || card.state == .paused else { return } - let bar = ImportProgressBar(progress: value) - bar.translatesAutoresizingMaskIntoConstraints = false - addSubview(bar) - let inset = Metrics.space3 - NSLayoutConstraint.activate([ - bar.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset), - bar.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset), - bar.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -3), - ]) - progress = bar - } - - /// The theme's text tiers; the card's own button in the theme's accent. - private func applyColors() { - let (primary, secondary, tertiary, accent) = performWithTheme { - (Palette.textPrimary, Palette.textSecondary, Palette.textTertiary, Palette.accent) - } - icon.contentTintColor = secondary - titleLabel.textColor = primary - factsLabel.textColor = tertiary - for (index, button) in actions.enumerated() { - button.contentTintColor = index == 0 && card?.primary != nil ? accent : secondary - } - showAll.contentTintColor = secondary - } - - @objc private func pressed(_ sender: NSButton) { - guard let id = card?.id, let raw = sender.identifier?.rawValue, let action = ImportCardAction(rawValue: raw) else { return } - press(id, action) - } - - @objc private func openSettings() { openAll() } -} - -/// A running or paused import's share done: a 2 pt track with the accent fill, drawn in one -/// frame (no animation), as the web cards draw it. Not NSProgressIndicator: its bar style is -/// the system's, not the theme's, and the lint keeps it for spinners. -@MainActor -final class ImportProgressBar: NSView { - private let progress: Double - private let fill = CALayer() - - init(progress: Double) { - self.progress = min(1, max(0, progress)) - super.init(frame: .zero) - wantsLayer = true - layer?.cornerRadius = 1 - layer?.masksToBounds = true - layer?.addSublayer(fill) - setAccessibilityElement(true) - setAccessibilityRole(.progressIndicator) - setAccessibilityValue(NSNumber(value: self.progress)) - heightAnchor.constraint(equalToConstant: 2).isActive = true - } - - @available(*, unavailable) - required init?(coder: NSCoder) { nil } - - override var wantsUpdateLayer: Bool { true } - - override func updateLayer() { - let (track, accent) = performWithTheme { (Palette.separator.cgColor, Palette.accent.cgColor) } - layer?.backgroundColor = track - fill.backgroundColor = accent - } - - override func layout() { - super.layout() - CATransaction.begin() - CATransaction.setDisableActions(true) - fill.frame = CGRect(x: 0, y: 0, width: bounds.width * progress, height: bounds.height) - CATransaction.commit() - } -} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextImportCards/ImportSuggestionsCenter.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextImportCards/ImportSuggestionsCenter.swift index 5aa45335b369..28ce7b6e07e6 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextImportCards/ImportSuggestionsCenter.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextImportCards/ImportSuggestionsCenter.swift @@ -1,6 +1,5 @@ public import AppKit public import CmuxNextActions -import CmuxNextAgentPane public import CmuxNextBrowserImport import CmuxNextCompat import CmuxNextDesign @@ -14,8 +13,8 @@ import Observation /// ``rescanInterval``), keeps the person's dismissals and the Chief's /// requests, and runs each card's import as a job saved after every step, so /// a quit or crash leaves it paused for Resume (or resumes it at launch when -/// no prompt is left). Home, New Tab (through `AgentPaneImportCards`) and -/// Settings › Import draw what ``list(for:)`` returns. +/// no prompt is left). Settings › Import draws what ``list()`` returns; Home and +/// New Tab show no suggestion (Leo 2026-10-21: "yes to both", removing it from both). @MainActor @Observable public final class ImportSuggestionsCenter { @@ -71,13 +70,6 @@ public final class ImportSuggestionsCenter { started = true restoreJobs() state.expire(now: now()) - AgentPaneImportCards.shared.onAction = { [weak self] id, action in - guard let action = ImportCardAction(rawValue: action) else { return } - // A New Tab page's button: a first-party control, never the person's own gesture, so a - // start asks natively first and a cookie request still gets its consent panel. - try? self?.perform(id, action, caller: ActionInvocation(arguments: [:], origin: .page)) - } - follow() activation = NotificationCenter.default.addObserver(forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in Task { @MainActor in self?.rescanIfStale() } @@ -149,16 +141,16 @@ public final class ImportSuggestionsCenter { "terminal.iterm2": "com.googlecode.iterm2", "terminal.ghostty": "com.mitchellh.ghostty", "cmux.classic": "com.cmuxterm.app", ] - /// The cards `surface` draws now; empty while the cards are off. - public func list(for surface: ImportCardDeck.Surface) -> ImportCardList { + /// The cards Settings › Import lists now; empty while the cards are off. + public func list() -> ImportCardList { guard isEnabled else { return .empty } - return deck.list(for: surface, now: now()) + return deck.list() } /// Settings › Import's list for the page's host lists (`import_suggestions`), with its empty text; null while the cards are off. public var settingsJSON: JSONValue { guard isEnabled else { return .null } - var value = ImportCardJSON.value(list(for: .settings), keepEmpty: true) + var value = ImportCardJSON.value(list(), keepEmpty: true) if case .object(var members) = value { members["empty"] = .string(ImportSuggestionStrings.settingsEmpty) members["scanning"] = .bool(isScanning) @@ -167,18 +159,9 @@ public final class ImportSuggestionsCenter { return value } - /// Pushes the New Tab cards to every agent page on each change of what they show. - private func follow() { - withObservationTracking { - AgentPaneImportCards.shared.cards = ImportCardJSON.value(list(for: .newTab)) - } onChange: { [weak self] in - Task { @MainActor in self?.follow() } - } - } - // MARK: Buttons - /// Runs a card's button (Home's row, a page, `import.card`). `caller` is the run that + /// Runs a card's button (Settings › Import's page, `import.card`). `caller` is the run that /// pressed it, the person's own click when nil. Only that click starts an import at once: any /// other caller's Import, Resume or Try Again asks the person in a native dialog first (page /// script cannot answer it), and an agent's cookie request always gets @@ -252,7 +235,7 @@ public final class ImportSuggestionsCenter { } else { guard let job = ledger.job(id), job.phase != .running else { throw ImportCardsFailure.noSuchCard(id) } } - guard let card = deck.cards(for: .settings, now: now()).first(where: { $0.id == id }), card.kind.startReadsAnotherApp else { return nil } + guard let card = deck.cards().first(where: { $0.id == id }), card.kind.startReadsAnotherApp else { return nil } let confirm = CmuxDialogButton(id: "confirm", title: ImportSuggestionStrings.confirmButton(action), role: .default) return CmuxDialogSpec(title: ImportSuggestionStrings.confirmTitle(card.title), lines: [card.facts, card.benefit].filter { !$0.isEmpty }, buttons: [.cancel(), confirm], identifier: "cmux.dialog.importCard") diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextImportSuggestions/ImportCard.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextImportSuggestions/ImportCard.swift index ad8fc2ccb99e..145d8b845c42 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextImportSuggestions/ImportCard.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextImportSuggestions/ImportCard.swift @@ -33,8 +33,7 @@ extension ImportSourceKind { nonisolated public var startReadsAnotherApp: Bool { self == .browser || self == .iterm2 } } -/// One card as every surface draws it (Home, New Tab, Settings › Import): -/// every text is already in the person's language. +/// One card as Settings › Import draws it: every text is already in the person's language. public nonisolated struct ImportCard: Codable, Sendable, Equatable, Identifiable { public nonisolated enum State: String, Codable, Sendable { case ready, running, paused, done, failed diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextImportSuggestions/ImportCardDeck.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextImportSuggestions/ImportCardDeck.swift index 17a9bfff3c39..9ab97a66f407 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextImportSuggestions/ImportCardDeck.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextImportSuggestions/ImportCardDeck.swift @@ -1,28 +1,10 @@ public import CmuxNextBrowserImport public import Foundation -/// Which cards a surface shows, from what the scan found, what the person -/// dismissed, the Chief's requests and the jobs. Pure, so every rule here is -/// a unit test. +/// The cards Settings › Import lists, from what the scan found, what the person +/// dismissed, the Chief's requests and the jobs: everything found, dismissed ones +/// too. Pure, so every rule here is a unit test. Home and New Tab show no cards. public nonisolated struct ImportCardDeck: Sendable { - public nonisolated enum Surface: String, Sendable, CaseIterable { - /// Home and New Tab: only a Chief's sign-in request, which answers a sign-in the person - /// just ran into. Nothing pops up by itself: what a scan found and the imports the person - /// started stay in Settings › Import. - case home, newTab - /// Settings › Import: everything found, dismissed ones too. - case settings - - /// The most cards the surface draws. - var limit: Int { - switch self { - case .home: 4 - case .newTab: 3 - case .settings: .max - } - } - } - public var findings: [ImportFinding] public var state: ImportSuggestionState public var jobs: ImportJobLedger @@ -36,12 +18,11 @@ public nonisolated struct ImportCardDeck: Sendable { self.apps = apps } - public func list(for surface: Surface, now: Date) -> ImportCardList { - ImportCardList(title: surface == .settings ? ImportSuggestionStrings.settingsTitle : ImportSuggestionStrings.listTitle, - cards: cards(for: surface, now: now)) + public func list() -> ImportCardList { + ImportCardList(title: ImportSuggestionStrings.settingsTitle, cards: cards()) } - public func cards(for surface: Surface, now: Date) -> [ImportCard] { + public func cards() -> [ImportCard] { var cards: [ImportCard] = [] var placed = Set() // The Chief's requests lead: they answer something the person just ran into. @@ -49,19 +30,17 @@ public nonisolated struct ImportCardDeck: Sendable { cards.append(card(for: request)) placed.insert(request.id) } - // Everything else is an offer nobody asked for: only Settings › Import lists it. - guard surface == .settings else { return Array(cards.prefix(surface.limit)) } // Jobs: running and paused first, then failed, then finished. let order: [ImportJob.Phase: Int] = [.running: 0, .paused: 1, .failed: 2, .done: 3] for job in jobs.jobs.sorted(by: { (order[$0.phase] ?? 9, $1.updatedAt) < (order[$1.phase] ?? 9, $0.updatedAt) }) { guard !placed.contains(job.id) else { continue } placed.insert(job.id) - cards.append(card(for: job, finding: findings.first { $0.id == job.id }, surface: surface)) + cards.append(card(for: job, finding: findings.first { $0.id == job.id })) } for finding in findings where !placed.contains(finding.id) { - cards.append(card(for: finding, dismissed: state.hides(finding), surface: surface)) + cards.append(card(for: finding, dismissed: state.hides(finding))) } - return Array(cards.prefix(surface.limit)) + return cards } // MARK: Cards @@ -113,7 +92,7 @@ public nonisolated struct ImportCardDeck: Sendable { } } - func card(for finding: ImportFinding, dismissed: Bool, surface: Surface) -> ImportCard { + func card(for finding: ImportFinding, dismissed: Bool) -> ImportCard { var primary: ImportCard.Button? = finding.offersNothingNew ? .init(.start, ImportSuggestionStrings.importAgain) : Self.startButton(finding.kind) var secondary: [ImportCard.Button] = [] var state = ImportCard.State.ready @@ -142,7 +121,7 @@ public nonisolated struct ImportCardDeck: Sendable { primary: .init(.start, ImportSuggestionStrings.importButton), secondary: [.init(.dismiss, ImportSuggestionStrings.notNow)]) } - func card(for job: ImportJob, finding: ImportFinding?, surface: Surface) -> ImportCard { + func card(for job: ImportJob, finding: ImportFinding?) -> ImportCard { let name = finding?.name ?? job.browser?.displayName ?? job.kind.displayName let title = job.site.map { ImportSuggestionStrings.requestTitle(browser: name, site: $0) } ?? name let factsText = finding.map { self.facts($0) } ?? "" @@ -175,7 +154,7 @@ public nonisolated struct ImportCardDeck: Sendable { case .claudeCode, .codex: card.primary = .init(.open, ImportSuggestionStrings.showChats) case .iterm2: card.primary = .init(.open, ImportSuggestionStrings.openAppearance) // Settings › Import runs it again while Classic has a saved session; the ledger brings nothing twice. - case .cmuxClassic: card.primary = surface == .settings && finding != nil ? .init(.start, ImportSuggestionStrings.importAgain) : nil + case .cmuxClassic: card.primary = finding != nil ? .init(.start, ImportSuggestionStrings.importAgain) : nil case .browser, .ghostty: card.primary = nil } card.secondary = [.init(.hide, ImportSuggestionStrings.done)] diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextImportSuggestions/ImportSuggestionStrings.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextImportSuggestions/ImportSuggestionStrings.swift index 178a52c7e7ec..3490a6a700c9 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextImportSuggestions/ImportSuggestionStrings.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextImportSuggestions/ImportSuggestionStrings.swift @@ -5,7 +5,6 @@ import Foundation public nonisolated struct ImportSuggestionStrings: Sendable { public init() {} - public static var listTitle: String { String(localized: "importSuggestions.list.title", defaultValue: "Bring your stuff to cmux", table: "ImportSuggestions", bundle: .module) } public static var settingsTitle: String { String(localized: "importSuggestions.settings.title", defaultValue: "Found on This Mac", table: "ImportSuggestions", bundle: .module) } // MARK: Facts @@ -165,7 +164,6 @@ public nonisolated struct ImportSuggestionStrings: Sendable { String(localized: "importSuggestions.settings.empty", defaultValue: "Nothing to bring over right now. cmux looks again when you come back to it.", table: "ImportSuggestions", bundle: .module) } - public static var showAll: String { String(localized: "importSuggestions.button.showAll", defaultValue: "Show All", table: "ImportSuggestions", bundle: .module) } /// `import.card` start, resume or try again from an agent, a script or another client. public static var personOnly: String { String(localized: "importSuggestions.refusal.personOnly", defaultValue: "Only you can start an import: press its button in cmux.", diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextImportSuggestions/Resources/ImportSuggestions.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextImportSuggestions/Resources/ImportSuggestions.xcstrings index a3d6f4ba2f9b..a0100a9e84f2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextImportSuggestions/Resources/ImportSuggestions.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextImportSuggestions/Resources/ImportSuggestions.xcstrings @@ -3014,137 +3014,6 @@ } } }, - "importSuggestions.button.showAll" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "عرض الكل" - } - }, - "bs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Prikaži sve" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vis alle" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Alle anzeigen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Show All" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mostrar todo" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tout afficher" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mostra tutto" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "すべて表示" - } - }, - "km" : { - "stringUnit" : { - "state" : "translated", - "value" : "បង្ហាញទាំងអស់" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "모두 보기" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vis alle" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pokaż wszystko" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mostrar tudo" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Показать все" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "แสดงทั้งหมด" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tümünü Göster" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Показати все" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hiện tất cả" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "全部显示" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "全部顯示" - } - } - } - }, "importSuggestions.button.showChats" : { "extractionState" : "manual", "localizations" : { @@ -7635,137 +7504,6 @@ } } }, - "importSuggestions.list.title" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "انقل أغراضك إلى cmux" - } - }, - "bs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Prenesite svoje stvari u cmux" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tag dine ting med til cmux" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bring deine Sachen zu cmux" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bring your stuff to cmux" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trae tus cosas a cmux" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Apportez vos affaires dans cmux" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Porta le tue cose in cmux" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "cmux に持ってくる" - } - }, - "km" : { - "stringUnit" : { - "state" : "translated", - "value" : "នាំយករបស់របររបស់អ្នកមក cmux" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "cmux로 가져오기" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ta med deg tingene dine til cmux" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Przenieś swoje rzeczy do cmux" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Traga suas coisas para o cmux" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Перенесите свои данные в cmux" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "นำข้อมูลของคุณมาที่ cmux" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verilerinizi cmux'a getirin" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Перенесіть свої дані в cmux" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mang dữ liệu của bạn sang cmux" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "把你的东西带到 cmux" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "把你的東西帶到 cmux" - } - } - } - }, "importSuggestions.refusal.invalidSite" : { "extractionState" : "manual", "localizations" : { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextOnboarding/Model/BrowserImportTunables.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextOnboarding/Model/BrowserImportTunables.swift index 655bb10b32f9..3848f400e151 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextOnboarding/Model/BrowserImportTunables.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextOnboarding/Model/BrowserImportTunables.swift @@ -15,11 +15,11 @@ public nonisolated struct BrowserImportTunables { help: "Import from Browser reads a browser's profiles only after you pick its folder in the open panel. Off reads every browser's folder on Find Browsers.", default: true, code: "BrowserImportTunables.folderGrant") - /// On (the default): Home, New Tab and Settings › Import show cards for what this Mac has - /// that cmux can bring over (CmuxNextImportCards). Off: no cards and no scan. + /// On (the default): Settings › Import shows cards for what this Mac has that cmux can bring + /// over (CmuxNextImportCards). Off: no cards and no scan. Home and New Tab never show them. public static let suggestionCards = Tunable.toggle( "browserImport.suggestionCards", section, "Show import suggestion cards", - help: "Home, New Tab and Settings › Import suggest what to bring over from other browsers, agents and terminals on this Mac. Off hides the cards and stops the scan.", + help: "Settings › Import suggests what to bring over from other browsers, agents and terminals on this Mac. Off hides the cards and stops the scan.", default: true, code: "BrowserImportTunables.suggestionCards") public static var all: [TunableDescriptor] { [folderGrant.descriptor, suggestionCards.descriptor] } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneImportCardsTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneImportCardsTests.swift deleted file mode 100644 index 08f936ccd152..000000000000 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneImportCardsTests.swift +++ /dev/null @@ -1,37 +0,0 @@ -@testable import CmuxNextAgentPane -import Foundation -import Testing - -/// The New Tab import cards' button: the page posts `importCards.act {id, action}`; only a short -/// card id and a lowercase action word reach the App. -@MainActor -struct AgentPaneImportCardsTests { - @Test func aCardButtonDecodesAndAMalformedOneIsRefused() { - #expect(AgentPaneRequest(body: ["method": "importCards.act", "params": ["id": "browser.chrome", "action": "start"]]) - == .importCard(AgentPaneImportCards.Act(["id": "browser.chrome", "action": "start"])!)) - for params: [String: Any] in [ - [:], - ["id": "browser.chrome"], - ["id": "", "action": "start"], - ["id": String(repeating: "x", count: 201), "action": "start"], - ["id": "browser.chrome", "action": "Start"], - ["id": "browser.chrome", "action": "start now"], - ["id": "browser.chrome", "action": "émettre"], - ["id": "browser.chrome", "action": String(repeating: "a", count: 17)], - ["id": 3, "action": "start"], - ] { - #expect(AgentPaneRequest(body: ["method": "importCards.act", "params": params]) == .unsupported("importCards.act")) - } - #expect(AgentPageOps.all.contains(AgentPageOps.namespace + "importCards.act")) - } - - @Test func aButtonRunsOnlyOnceTheAppWiredTheCards() { - let cards = AgentPaneImportCards() - let act = AgentPaneImportCards.Act(["id": "agents.codex", "action": "dismiss"])! - #expect(!cards.perform(act)) - var pressed: [String] = [] - cards.onAction = { id, action in pressed.append("\(id) \(action)") } - #expect(cards.perform(act)) - #expect(pressed == ["agents.codex dismiss"]) - } -} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ClassicMigrationTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ClassicMigrationTests.swift index 6c7eb7c3f46e..d803dca2838b 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ClassicMigrationTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ClassicMigrationTests.swift @@ -228,7 +228,7 @@ struct ClassicMigrationTests { for _ in 0..<2 { try center.perform("cmux.classic", .start) await center.settled("cmux.classic") - runs.statuses.append(center.list(for: .settings).cards.first { $0.id == "cmux.classic" }?.status) + runs.statuses.append(center.list().cards.first { $0.id == "cmux.classic" }?.status) } } let first = try #require(runs.opened.first ?? nil) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeImportSuggestionRowTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeImportSuggestionRowTests.swift deleted file mode 100644 index 67f59a46f48d..000000000000 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeImportSuggestionRowTests.swift +++ /dev/null @@ -1,136 +0,0 @@ -import AppKit -import CmuxNextDesign -@testable import CmuxNextHome -@testable import CmuxNextImportCards -import CmuxNextImportSuggestions -import Testing - -/// Leo 2026-10-21 on nightly 331e2e9 ("WHAT IS THIS"): Home's import cards ("Bring your stuff to -/// cmux", a Claude Code card and a Codex card) floated over the Chief's transcript at a fixed -/// 80 pt from the column's top, so the chat showed through and around them. The suggestion is -/// now one compact glass row on the Chief's front page (the empty Chief conversation's first-run -/// panel), in the panel's own layout, never over a conversation. -@MainActor @Suite(.serialized) struct HomeImportSuggestionRowTests { - static func card(_ id: String, kind: ImportSourceKind, title: String, facts: String) -> ImportCard { - ImportCard(id: id, kind: kind, app: nil, title: title, facts: facts, benefit: "Your chats show in cmux.", - state: .ready, primary: .init(.start, "Show Chats"), - secondary: [.init(.more, "More Options…"), .init(.dismiss, "Not Now")]) - } - - static let two = ImportCardList(title: "Bring your stuff to cmux", cards: [ - card("claudeCode", kind: .claudeCode, title: "Claude Code", facts: "1,002 sessions"), - card("codex", kind: .codex, title: "Codex", facts: "1,118 threads"), - ]) - - static func row(pressed: @escaping (String, ImportCardAction) -> Void = { _, _ in }, - showAll: @escaping () -> Void = {}) -> ImportSuggestionRowView { - let row = ImportSuggestionRowView(press: pressed, showAll: showAll) - row.frame = NSRect(x: 0, y: 0, width: HomeFirstRunView.maxWidth, height: ImportSuggestionRowView.height) - return row - } - - /// Two suggestions are one row: the first one's name and facts, its button and Not Now, and - /// Show All for the rest. No heading, no second card, no benefit paragraph. - @Test func twoSuggestionsAreOneCompactRow() { - let row = Self.row() - row.show(Self.two) - row.layoutSubtreeIfNeeded() - #expect(!row.isHidden) - #expect(row.fittingSize.height <= ImportSuggestionRowView.height) - #expect(row.titleLabel.stringValue == "Claude Code") - #expect(row.factsLabel.stringValue == "1,002 sessions") - #expect(row.actions.map(\.title) == ["Show Chats", "Not Now"]) - #expect(!row.showAll.isHidden) - let texts = row.descendantTexts - #expect(!texts.contains { $0.contains("Codex") || $0.contains("Bring your stuff") || $0.contains("Your chats") }) - } - - /// One suggestion has nothing more to show: no Show All. - @Test func oneSuggestionHasNoShowAll() { - let row = Self.row() - row.show(ImportCardList(title: Self.two.title, cards: [Self.two.cards[0]])) - #expect(row.showAll.isHidden) - } - - /// Not Now on the row dismisses the suggestion it shows; its button starts it; Show All opens - /// Settings › Import. - @Test func theRowsButtonsRunTheShownSuggestionsActions() { - var pressed: [String] = [] - var settings = 0 - let row = Self.row(pressed: { pressed.append("\($0):\($1.rawValue)") }, showAll: { settings += 1 }) - row.show(Self.two) - for button in row.actions { button.performClick(nil) } - row.showAll.performClick(nil) - #expect(pressed == ["claudeCode:start", "claudeCode:dismiss"]) - #expect(settings == 1) - } - - /// Nothing to suggest: the row is gone and says it changed, so its panel lays out without it. - @Test func nothingToSuggestHidesTheRow() { - let row = Self.row() - var changes = 0 - row.onChange = { changes += 1 } - row.show(Self.two) - row.show(.empty) - #expect(row.isHidden) - #expect(changes == 2) - } - - /// The row is the sidebar cards' glass veil, never an opaque card. - @Test func theRowIsGlass() { - let row = Self.row() - #expect(row.surface is CardSurfaceView) - } - - /// The Chief's front page holds the row in its own column, under the action rows and inside - /// the panel: nothing floats over the conversation's top. - @Test func theFrontPageHoldsTheRowUnderItsActions() { - let panel = HomeFirstRunView(frame: NSRect(x: 0, y: 0, width: 600, height: 700)) - let row = Self.row() - row.show(Self.two) - panel.accessory = row - panel.layoutSubtreeIfNeeded() - #expect(row.superview != nil) - #expect(row.isDescendant(of: panel)) - let place = panel.convert(row.bounds, from: row) - let pet = panel.convert(panel.pet.bounds, from: panel.pet) - #expect(place.minY >= pet.maxY) - #expect(panel.bounds.contains(place)) - #expect(abs(place.width - pet.width) < 1) - #expect(abs(place.minX - pet.minX) < 1) - } - - /// A hidden row leaves no gap: the panel is laid out as with no row at all. - @Test func aHiddenRowTakesNoSpace() { - let plain = HomeFirstRunView(frame: NSRect(x: 0, y: 0, width: 600, height: 700)) - plain.layoutSubtreeIfNeeded() - let panel = HomeFirstRunView(frame: NSRect(x: 0, y: 0, width: 600, height: 700)) - let row = Self.row() - row.show(.empty) - panel.accessory = row - panel.layoutSubtreeIfNeeded() - let top = { (view: HomeFirstRunView) in view.convert(view.agent.bounds, from: view.agent).minY } - #expect(abs(top(plain) - top(panel)) < 1) - } - - /// The row goes when the host takes it away. - @Test func clearingTheAccessoryRemovesTheRow() { - let panel = HomeFirstRunView(frame: NSRect(x: 0, y: 0, width: 600, height: 700)) - let row = Self.row() - row.show(Self.two) - panel.accessory = row - panel.accessory = nil - #expect(row.superview == nil) - } -} - -extension NSView { - /// Every label's and button's text under this view. - fileprivate var descendantTexts: [String] { - var texts: [String] = [] - if let field = self as? NSTextField { texts.append(field.stringValue) } - if let button = self as? NSButton, !button.isHidden { texts.append(button.title) } - for child in subviews where !child.isHidden { texts += child.descendantTexts } - return texts - } -} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ImportSuggestionRemovedTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ImportSuggestionRemovedTests.swift new file mode 100644 index 000000000000..1b611e7e8071 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ImportSuggestionRemovedTests.swift @@ -0,0 +1,73 @@ +import AppKit +import CmuxNextActions +@testable import CmuxNextAgentPane +@testable import CmuxNextApp +import CmuxHomeCore +@testable import CmuxNextHome +@testable import CmuxNextImportCards +import Foundation +import Testing + +/// Leo 2026-10-21: "yeah the bring your stuff to cmux is extrmeely questionable idk why that's +/// there + why they went to the chief thingy", and "yes to both" to removing it from the New Tab +/// and the Chief completely. The import suggestion is gone from the Chief's front page and the New +/// Tab page; importing lives in Settings › Import and the palette's Import from Other Apps…. +@MainActor @Suite(.serialized) struct ImportSuggestionRemovedTests { + /// The empty Chief conversation's front page holds no import suggestion row and no import card, + /// shown or hidden. + @Test func theChiefFrontPageHasNoImportSuggestion() { + _ = NSApplication.shared + let services = AppServices(environment: AppEnvironment.current([:])) + let view = HomeHostView(services: services, conversation: ConversationID("conv_no_import_suggestion").rawValue) + view.frame = NSRect(x: 0, y: 0, width: 900, height: 700) + view.layoutSubtreeIfNeeded() + let page = view.transcript.firstRun.everyDescendant + #expect(!page.contains { $0.accessibilityIdentifier() == "cmux.home.importSuggestion" }, "the front page holds the import row") + #expect(!page.contains { String(describing: type(of: $0)).contains("Import") }, "the front page holds an import view") + #expect(!page.contains { $0.texts.contains { $0.localizedCaseInsensitiveContains("Bring your stuff") } }) + } + + /// The New Tab page gets no import cards: the agent page has no `importCards.act` op, and a + /// page that still posts one is refused like any unknown request. + @Test func theNewTabHasNoImportCards() { + #expect(!AgentPageOps.all.contains(AgentPageOps.namespace + "importCards.act")) + #expect(AgentPaneRequest(body: ["method": "importCards.act", "params": ["id": "agents.codex", "action": "start"]]) + == .unsupported("importCards.act")) + } + + /// Import stays in the command palette: Import from Other Apps… is a palette action, and it + /// opens Settings › Import. + @Test func thePalettesImportCommandStillOpensSettingsImport() throws { + let registry = ActionRegistry(catalog: ActionCatalog.all, aliases: ActionCatalog.legacyAliases) + let descriptor = try #require(registry.descriptor(for: "import.open")) + #expect(descriptor.surfaces.contains(.palette)) + let suite = "ImportSuggestionRemovedTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let ledger = FileManager.default.temporaryDirectory.appending(path: "import-jobs-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: ledger) } + var routes: [String] = [] + let center = ImportSuggestionsCenter(defaults: defaults, ledgerURL: ledger) + center.bind(into: registry, host: ImportCardsHost(runImport: { _, _, _ in throw CancellationError() }, + importedProfiles: { [] }, + openSettings: { routes.append($0) }, + importClassic: { _ in nil })) + #expect(registry.isBound("import.open")) + #expect(registry.perform("import.open", invocation: ActionInvocation(origin: .user))) + #expect(routes == ["#/settings/import"]) + } +} + +extension NSView { + /// This view and every view under it, hidden ones too. + fileprivate var everyDescendant: [NSView] { + [self] + subviews.flatMap(\.everyDescendant) + } + + /// The view's own label or button text. + fileprivate var texts: [String] { + if let field = self as? NSTextField { return [field.stringValue] } + if let button = self as? NSButton { return [button.title] } + return [] + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ImportSuggestionsCenterClassicTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ImportSuggestionsCenterClassicTests.swift index cb0f02a7ae72..4e5dfc1f0556 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ImportSuggestionsCenterClassicTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ImportSuggestionsCenterClassicTests.swift @@ -70,7 +70,7 @@ struct ImportSuggestionsCenterClassicTests { } private static func card(_ center: ImportSuggestionsCenter) -> ImportCard? { - center.list(for: .settings).cards.first { $0.id == id } + center.list().cards.first { $0.id == id } } @Test func importRunsAsAJobWhoseProgressMovesThenSaysHowManyCameOver() async throws { diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextImportSuggestionsTests/ImportCardDeckTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextImportSuggestionsTests/ImportCardDeckTests.swift index d248544cb60f..85f60ae7e53e 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextImportSuggestionsTests/ImportCardDeckTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextImportSuggestionsTests/ImportCardDeckTests.swift @@ -3,7 +3,7 @@ import CmuxNextBrowserImport import Foundation import Testing -/// Which cards each surface shows, dismissals that stick until something new +/// The cards Settings › Import lists, dismissals that stick until something new /// appears, the Chief's requests, and jobs that survive a relaunch. struct ImportCardDeckTests { static let now = Date(timeIntervalSince1970: 1_800_000_000) @@ -23,30 +23,12 @@ struct ImportCardDeckTests { ImportCardDeck(findings: findings, state: state, jobs: jobs) } - /// Nothing pops up on Home or New Tab by itself: what a scan found and the imports the person - /// started wait in Settings › Import. - @Test(arguments: [ImportCardDeck.Surface.home, .newTab]) - func homeAndNewTabNeverSuggestWhatAScanFound(_ surface: ImportCardDeck.Surface) { - let iterm = ImportFinding(id: "terminal.iterm2", kind: .iterm2, name: "iTerm2", count: 1) - let codex = ImportFinding(id: "agents.codex", kind: .codex, name: "Codex", count: 17_021) - var ledger = ImportJobLedger() - var paused = ImportJob(finding: Self.chrome, now: Self.now) - paused.paused(.interrupted, now: Self.now) - ledger.put(paused) - #expect(Self.deck([Self.chrome], jobs: ledger).cards(for: surface, now: Self.now).isEmpty) - let deck = Self.deck([Self.chrome, Self.safari, Self.ghostty, Self.claude, iterm, codex]) - #expect(deck.cards(for: surface, now: Self.now).isEmpty) - #expect(deck.list(for: surface, now: Self.now).cards.isEmpty) - let settings = deck.cards(for: .settings, now: Self.now) - #expect(settings.map(\.id) == ["browser.chrome", "browser.safari", "terminal.ghostty", "agents.claudeCode", "terminal.iterm2", "agents.codex"]) - #expect(settings[0].primary?.action == .start) - #expect(settings[0].secondary.map(\.action) == [.more, .dismiss]) - #expect(!settings[0].facts.isEmpty && !settings[0].benefit.isEmpty) - } - @Test func settingsListsEverythingFound() { - let cards = Self.deck([Self.chrome, Self.safari, Self.ghostty]).cards(for: .settings, now: Self.now) + let cards = Self.deck([Self.chrome, Self.safari, Self.ghostty]).cards() #expect(cards.map(\.id) == ["browser.chrome", "browser.safari", "terminal.ghostty"]) + #expect(cards[0].primary?.action == .start) + #expect(cards[0].secondary.map(\.action) == [.more, .dismiss]) + #expect(!cards[0].facts.isEmpty && !cards[0].benefit.isEmpty) #expect(cards[1].state == .info && cards[1].primary?.action == .open) #expect(cards[2].state == .info && cards[2].secondary.isEmpty) } @@ -55,22 +37,22 @@ struct ImportCardDeckTests { var state = ImportSuggestionState() state.dismiss(Self.chrome) state.dismiss(Self.claude) - #expect(Self.deck([Self.chrome, Self.claude], state: state).cards(for: .settings, now: Self.now).allSatisfy { $0.dismissed }) - // More sessions is ordinary use: still gone. + let dismissed = { (findings: [ImportFinding]) in Self.deck(findings, state: state).cards().filter(\.dismissed).map(\.id) } + #expect(dismissed([Self.chrome, Self.claude]) == ["browser.chrome", "agents.claudeCode"]) + // More sessions is ordinary use: still dismissed. var grown = Self.claude grown.count = 900 // A new Chrome profile is something new: back. var newProfile = Self.chrome newProfile.profiles.append(.init(directoryName: "Profile 2", kinds: [.history])) - let cards = Self.deck([newProfile, grown], state: state).cards(for: .settings, now: Self.now) - #expect(cards.filter { !$0.dismissed }.map(\.id) == ["browser.chrome"]) - // A profile imported elsewhere (Chrome holds less now) is not new: still gone. + #expect(dismissed([newProfile, grown]) == ["agents.claudeCode"]) + // A profile imported elsewhere (Chrome holds less now) is not new: still dismissed. var fewer = Self.chrome fewer.profiles = Array(fewer.profiles.prefix(1)) fewer.profiles[0].kinds = Array(fewer.profiles[0].kinds.prefix(1)) - #expect(Self.deck([fewer], state: state).cards(for: .settings, now: Self.now).allSatisfy { $0.dismissed }) - // Settings keeps the dismissed one, with Show Again. - let settings = Self.deck([Self.claude], state: state).cards(for: .settings, now: Self.now) + #expect(dismissed([fewer]) == ["browser.chrome"]) + // A dismissed card stays listed, with Show Again. + let settings = Self.deck([Self.claude], state: state).cards() #expect(settings.first?.dismissed == true) #expect(settings.first?.secondary.map(\.action) == [.restore]) } @@ -79,9 +61,8 @@ struct ImportCardDeckTests { var state = ImportSuggestionState() let site = try #require(ImportRequest.site("https://GitHub.com/login")) state.raise(ImportRequest(browser: .chrome, site: site, reason: "The page asks to sign in.", requestedAt: Self.now)) - let cards = Self.deck([Self.chrome], state: state).cards(for: .home, now: Self.now) - // Only the request: it answers a sign-in the person just ran into; Chrome's own card waits in Settings. - #expect(cards.map(\.id) == ["request.cookies.chrome.github.com"]) + let cards = Self.deck([Self.chrome], state: state).cards() + #expect(cards.map(\.id) == ["request.cookies.chrome.github.com", "browser.chrome"]) #expect(cards[0].reason == "The page asks to sign in.") #expect(cards[0].title.contains("github.com")) } @@ -122,52 +103,43 @@ struct ImportCardDeckTests { #expect(legacy.raisedBy == nil && legacy.agentSession == nil && legacy.terminal == nil) } - @Test func surfacesCapTheirCards() { - var state = ImportSuggestionState() - for index in 0..<8 { - state.raise(ImportRequest(browser: .chrome, site: "site\(index).com", reason: "", requestedAt: Self.now)) - } - #expect(Self.deck([], state: state).cards(for: .newTab, now: Self.now).count == 3) - #expect(Self.deck([], state: state).cards(for: .home, now: Self.now).count == 4) - #expect(Self.deck([], state: state).cards(for: .settings, now: Self.now).count == ImportSuggestionState.maximumRequests) + @Test func settingsListsEveryCard() { + let many = (0..<8).map { ImportFinding(id: "agents.\($0)", kind: .codex, name: "Codex", count: 1) } + #expect(Self.deck(many).cards().count == 8) } - /// Everything classic saved is already here: Home and New Tab have nothing one click brings, - /// while Settings › Import keeps the card so the person can run the import again. + /// Everything classic saved is already here: Settings › Import keeps the card so the person + /// can run the import again. @Test func settingsKeepsClassicOnceEverythingCameOver() throws { let deck = Self.deck([Self.classicAllHere]) - #expect(deck.cards(for: .home, now: Self.now).isEmpty) - #expect(deck.cards(for: .newTab, now: Self.now).isEmpty) - let card = try #require(deck.cards(for: .settings, now: Self.now).first) + let card = try #require(deck.cards().first) #expect(card.id == "cmux.classic" && card.state == .ready) #expect(card.primary?.action == .start && card.primary?.title == "Import Again") #expect(card.facts != ImportSuggestionStrings.workspaces(0) && !card.facts.isEmpty && !card.benefit.isEmpty) - // Workspaces still to bring: the one-click card, in Settings only (Home pops up nothing). + // Workspaces still to bring: the one-click card. var more = Self.classicAllHere more.count = 2 - #expect(Self.deck([more]).cards(for: .home, now: Self.now).isEmpty) - let offered = try #require(Self.deck([more]).cards(for: .settings, now: Self.now).first) - #expect(offered.primary?.title == ImportSuggestionStrings.importWorkspaces) + let first = try #require(Self.deck([more]).cards().first) + #expect(first.primary?.title == ImportSuggestionStrings.importWorkspaces) } /// A finished classic import stays offered again in Settings › Import, with what it brought. - /// Home and New Tab show nothing for it. @Test func aFinishedClassicImportOffersItAgainInSettings() throws { var job = ImportJob(finding: Self.classicAllHere, now: Self.now) job.importedClassic(0, now: Self.now) var ledger = ImportJobLedger() ledger.put(job) let deck = Self.deck([Self.classicAllHere], jobs: ledger) - let settings = deck.cards(for: .settings, now: Self.now).first + let settings = deck.cards().first #expect(settings?.state == .done && settings?.primary?.action == .start && settings?.primary?.title == "Import Again") #expect(settings?.status == ImportSuggestionStrings.importedSessions(0)) - #expect(deck.cards(for: .home, now: Self.now).isEmpty) - #expect(deck.cards(for: .newTab, now: Self.now).isEmpty) job.importedClassic(2, now: Self.now) ledger.put(job) - #expect(Self.deck([Self.classicAllHere], jobs: ledger).cards(for: .home, now: Self.now).isEmpty) - let done = try #require(Self.deck([Self.classicAllHere], jobs: ledger).cards(for: .settings, now: Self.now).first) - #expect(done.state == .done && done.status == ImportSuggestionStrings.importedSessions(2)) + let again = try #require(Self.deck([Self.classicAllHere], jobs: ledger).cards().first) + #expect(again.state == .done && again.primary?.title == "Import Again" && again.status == ImportSuggestionStrings.importedSessions(2)) + // Classic's saved session is gone: nothing to run again. + let gone = try #require(Self.deck([], jobs: ledger).cards().first) + #expect(gone.state == .done && gone.primary == nil) } /// Resume and Try Again start over from 0, not at the fraction the cut-off run had reached. @@ -202,11 +174,11 @@ struct ImportCardDeckTests { #expect(job.phase == .done && job.counts.history == 12) var ledger = ImportJobLedger() ledger.put(job) - // A finished import leaves Home and New Tab at once; Settings › Import keeps it. - #expect(Self.deck([], jobs: ledger).cards(for: .home, now: Self.now).isEmpty) - #expect(Self.deck([Self.chrome], jobs: ledger).cards(for: .newTab, now: Self.now).isEmpty) - let card = Self.deck([], jobs: ledger).cards(for: .settings, now: Self.now) + let card = Self.deck([], jobs: ledger).cards() #expect(card.first?.state == .done && card.first?.secondary.map(\.action) == [.hide]) + // The browser's own Import card does not come back beside its finished job. + let listed = Self.deck([Self.chrome], jobs: ledger).cards() + #expect(listed.map(\.id) == ["browser.chrome"] && listed.first?.state == .done) } @Test func onlyStartResumeAndTryAgainReadAnotherAppsData() { @@ -232,7 +204,7 @@ struct ImportCardDeckTests { // Chrome's cookies need a Keychain prompt, so it waits for Resume; Firefox's need none. #expect(!chromeAfter.resumesQuietly) #expect(relaunched.job("browser.firefox")?.resumesQuietly == true) - let card = try #require(Self.deck([], jobs: relaunched).cards(for: .settings, now: Self.now).first { $0.id == "browser.chrome" }) + let card = try #require(Self.deck([], jobs: relaunched).cards().first { $0.id == "browser.chrome" }) #expect(card.state == .paused && card.primary?.action == .resume) } diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 0584bdddfc2f..90ada9b7dd6d 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -49,8 +49,6 @@ import { FOCUS_LOCATION_EVENT, NewTabPage, newTabHost, type NewTabHost } from ". import { setDeviceChats } from "./newtab/deviceChats"; import { setRecentlyClosed } from "./newtab/recentlyClosed"; import { setNowPlaying } from "./newtab/nowPlaying"; -import { setImportCards } from "./importCards"; -import { NewTabImportCards } from "./NewTabImportCards"; import { NewTabScreen, type NewTabScreenActions } from "./newtab/NewTabScreen"; import { HistoryScreen } from "./newtab/HistoryScreen"; import type { AllChatsPage } from "./newtab/AllChatsList"; @@ -489,8 +487,6 @@ declare global { applyRecentlyClosed?(items: unknown): void; /// What plays, for the New Tab page's player (newtab/nowPlaying.ts); null while nothing does. applyNowPlaying?(track: unknown): void; - /// The New Tab page's import suggestion cards (importCards.ts); null while there are none. - applyImportCards?(cards: unknown): void; /// Scrolls to a turn a `cmux://session/#turn-` link names (links.ts), once its row /// renders; gives up quietly after a few seconds. revealTurn?(turnId: string): void; @@ -5768,9 +5764,6 @@ function AcpmuxPane() { applyNowPlaying(track) { setNowPlaying(track); }, - applyImportCards(cards) { - setImportCards(cards); - }, revealTurn(turnId) { revealRef.current(turnId); }, @@ -8171,14 +8164,7 @@ function AcpmuxPane() { accessory={} dictating={isWaitingForWords(dictation.state)} dictationField={newTabDictationField} - notice={ - <> - - void callNative("importCards.act", { id, action }).catch(() => undefined)} - /> - - } + notice={} // The host answers only for a page on this Mac; another machine's keeps the refusal. {...(!quick ? { localPaths } : {})} template={screenTemplate(shownTemplate(newTab))} diff --git a/webviews/src/agent-session/acpmux/NewTabImportCards.tsx b/webviews/src/agent-session/acpmux/NewTabImportCards.tsx deleted file mode 100644 index a661148bd50c..000000000000 --- a/webviews/src/agent-session/acpmux/NewTabImportCards.tsx +++ /dev/null @@ -1,15 +0,0 @@ -// The New Tab page's import cards, in the page's notice slot (App.tsx): only a Chief's sign-in -// requests (the host never pops up what a scan found; that stays in Settings > Import). -// A button goes back as `importCards.act {id, action}`. -import { ImportCards } from "../../ui/ImportCards"; -import { useImportCards } from "./importCards"; - -export function NewTabImportCards({ onAction }: { onAction: (id: string, action: string) => void }) { - const list = useImportCards(); - if (!list || list.cards.length === 0) return null; - return ( -
- -
- ); -} diff --git a/webviews/src/agent-session/acpmux/importCards.ts b/webviews/src/agent-session/acpmux/importCards.ts deleted file mode 100644 index 6bd738765760..000000000000 --- a/webviews/src/agent-session/acpmux/importCards.ts +++ /dev/null @@ -1,29 +0,0 @@ -// The import suggestion cards for the New Tab page, as the host pushes them (`importCards`, -// CmuxNextAgentPane AgentPaneImportCards): a few cards for what this Mac has that cmux can bring -// over, or null while there are none or the cards are off (Debug Settings -// `browserImport.suggestionCards`). A module store like newtab/nowPlaying.ts, so a push that -// arrives before the screen mounts is kept. -import { useSyncExternalStore } from "react"; -import { parseImportCards, type ImportCardList } from "../../ui/ImportCards"; - -let list: ImportCardList | null = null; -const listeners = new Set<() => void>(); - -/// Replaces the cards from a host push; anything malformed clears them. -export function setImportCards(value: unknown): void { - list = parseImportCards(value); - for (const listener of listeners) listener(); -} - -export function importCards(): ImportCardList | null { - return list; -} - -function subscribe(listener: () => void): () => void { - listeners.add(listener); - return () => listeners.delete(listener); -} - -export function useImportCards(): ImportCardList | null { - return useSyncExternalStore(subscribe, importCards, importCards); -} diff --git a/webviews/src/agent-session/acpmux/pageHost.ts b/webviews/src/agent-session/acpmux/pageHost.ts index 3e12b1e752fa..37f795e622cc 100644 --- a/webviews/src/agent-session/acpmux/pageHost.ts +++ b/webviews/src/agent-session/acpmux/pageHost.ts @@ -13,7 +13,6 @@ import { setDeviceChats } from "./newtab/deviceChats"; import { setToolCards } from "./toolCards/toolCardStore"; import { setRecentlyClosed } from "./newtab/recentlyClosed"; import { setNowPlaying } from "./newtab/nowPlaying"; -import { setImportCards } from "./importCards"; import { setHeaderState } from "./header/headerState"; import { setTabRow } from "./header/tabRow"; import { setFloatingState } from "./floatingState"; @@ -110,9 +109,6 @@ export function applyHostEvent(event: HostEvent): void { case "nowPlaying": // Direct, like recentlyClosed: a push before the New Tab screen mounts is kept. return setNowPlaying(event.value); - case "importCards": - // Direct, like nowPlaying: a push before the New Tab screen mounts is kept. - return setImportCards(event.value); case "toolCards": // Direct, like deviceChats: a push before the chat mounts is kept. return setToolCards(event.value); diff --git a/webviews/src/dev-host/host.ts b/webviews/src/dev-host/host.ts index 1cc080bd1ad8..07c45b8cde6e 100644 --- a/webviews/src/dev-host/host.ts +++ b/webviews/src/dev-host/host.ts @@ -192,7 +192,6 @@ export function devHostReply(host: DevHostParams, request: Request): DevHostRepl case "newTab.setTemplate": case "newTab.editWidgets": case "nowPlaying.command": - case "importCards.act": case "newTab.touched": case "newTab.warm": case "shortcut.edit": diff --git a/webviews/src/pages/settings/components/ImportSuggestionsCard.tsx b/webviews/src/pages/settings/components/ImportSuggestionsCard.tsx index 6d681f9922c2..3d11051f1794 100644 --- a/webviews/src/pages/settings/components/ImportSuggestionsCard.tsx +++ b/webviews/src/pages/settings/components/ImportSuggestionsCard.tsx @@ -12,11 +12,7 @@ export function ImportSuggestionsCard() { if (!list) return null; return (
- void store.runAction("import.card", { id, action })} - /> + void store.runAction("import.card", { id, action })} />
); } diff --git a/webviews/src/ui/ImportCards.gallery.tsx b/webviews/src/ui/ImportCards.gallery.tsx index 14acf72a90f2..f985b57b234e 100644 --- a/webviews/src/ui/ImportCards.gallery.tsx +++ b/webviews/src/ui/ImportCards.gallery.tsx @@ -4,7 +4,7 @@ import { componentEntry } from "../gallery/format"; import type { Play } from "../gallery/play"; import { ImportCards, type ImportCardData, type ImportCardList } from "./ImportCards"; -type Props = { list: ImportCardList; layout: "row" | "list" }; +type Props = { list: ImportCardList }; const notNow = { action: "dismiss", title: "Not Now" }; const more = { action: "more", title: "More Options…" }; @@ -137,7 +137,6 @@ const failed: ImportCardData = { secondary: [{ action: "hide", title: "Not Now" }], }; -const newTab = (cards: ImportCardData[]): ImportCardList => ({ title: "Bring your stuff to cmux", cards }); const settings = (cards: ImportCardData[]): ImportCardList => ({ title: "Found on This Mac", cards, @@ -145,18 +144,13 @@ const settings = (cards: ImportCardData[]): ImportCardList => ({ }); /// The cards with the host stood in: Not Now takes a card away, as the host's next push would. -function GalleryImportCards({ list, layout }: Props) { +function GalleryImportCards({ list }: Props) { const [cards, setCards] = useState(list.cards); const [pressed, setPressed] = useState(""); return ( -
+
{ setPressed(`${id} ${action}`); if (action === "dismiss" || action === "hide") setCards((current) => current.filter((c) => c.id !== id)); @@ -185,35 +179,35 @@ export default componentEntry({ load: async () => GalleryImportCards, styles: () => import("./ui.css"), variants: { - "new-tab": { - note: "New Tab: the three cards one click imports, each with what was found and what you get.", - props: { list: newTab([chrome, claude, codex]), layout: "row" }, - height: 260, + settings: { + note: "Settings › Import lists everything found: done, failed, info cards and a dismissed one with Show Again.", + props: { list: settings([done, failed, safari, ghostty, iterm]) }, + height: 820, + }, + "settings-ready": { + note: "Settings › Import: what one click imports, each with what was found and what you get.", + props: { list: settings([chrome, claude, codex]) }, + height: 560, }, - "new-tab-request": { + "settings-request": { note: "Chief raised a card for a sign-in wall: it leads, with Chief's reason in its words.", - props: { list: newTab([request, chrome, claude]), layout: "row" }, - height: 300, + props: { list: settings([request, chrome, claude]) }, + height: 600, }, - "new-tab-running": { + "settings-running": { note: "An import runs in the background with its progress; a paused one offers Resume.", - props: { list: newTab([running, paused, claude]), layout: "row" }, - height: 260, + props: { list: settings([running, paused, claude]) }, + height: 560, }, - "new-tab-dismiss": { + "settings-dismiss": { note: "Play: Not Now takes the Codex card away; it comes back only when something new appears.", - props: { list: newTab([chrome, claude, codex]), layout: "row" }, - height: 260, + props: { list: settings([chrome, claude, codex]) }, + height: 560, play: dismissOne, }, - settings: { - note: "Settings › Import lists everything found: done, failed, info cards and a dismissed one with Show Again.", - props: { list: settings([done, failed, safari, ghostty, iterm]), layout: "list" }, - height: 820, - }, "settings-empty": { note: "Settings › Import with nothing found.", - props: { list: settings([]), layout: "list" }, + props: { list: settings([]) }, height: 120, }, }, diff --git a/webviews/src/ui/ImportCards.tsx b/webviews/src/ui/ImportCards.tsx index 18f9dfdfc097..5206e945fa3d 100644 --- a/webviews/src/ui/ImportCards.tsx +++ b/webviews/src/ui/ImportCards.tsx @@ -1,7 +1,7 @@ // Import suggestion cards (Packages/macOS/CmuxNext CmuxNextImportCards): what this Mac has that cmux // can bring over. The host builds every card with its text already localized (ImportCardDeck) and -// pushes the list; this draws it the same way on New Tab (a row of a few) and in Settings › Import -// (every card, dismissed ones dimmed). A button press goes back as the card id and its action. +// sends the list; this draws it in Settings › Import (every card, dismissed ones dimmed). Home and +// New Tab show no import suggestion. A button press goes back as the card id and its action. import "./importCards.css"; export type ImportCardButton = { action: string; title: string }; @@ -157,20 +157,18 @@ function Card({ card, onAction }: { card: ImportCardData; onAction: (id: string, ); } -/// The cards: `row` lays a few side by side (New Tab), `list` stacks every card (Settings › Import). +/// The cards, stacked (Settings › Import). export function ImportCards({ list, - layout, onAction, showTitle = true, }: { list: ImportCardList; - layout: "row" | "list"; onAction: (id: string, action: string) => void; showTitle?: boolean; }) { return ( -
+
{showTitle && list.title &&

{list.title}

} {list.cards.length === 0 && list.empty ? (

{list.empty}

diff --git a/webviews/src/ui/importCards.css b/webviews/src/ui/importCards.css index 77c0ac7156b7..f3bfa1ac2698 100644 --- a/webviews/src/ui/importCards.css +++ b/webviews/src/ui/importCards.css @@ -1,6 +1,6 @@ -/* Import suggestion cards (ImportCards.tsx). Colors come from the page's theme: the agent page's - --agent-* tokens on New Tab, the --cmux-* tokens in Settings, so the cards follow light, dark - and custom themes. No animation: a card changes in one frame. */ +/* Import suggestion cards (ImportCards.tsx), in Settings › Import. Colors come from the page's + theme (--cmux-* tokens), so the cards follow light, dark and custom themes. No animation: a card + changes in one frame. */ .import-cards { --import-text: var(--agent-text, var(--cmux-text, CanvasText)); --import-dim: color-mix(in srgb, var(--import-text) 64%, transparent); @@ -39,10 +39,6 @@ min-width: 0; } -.import-cards[data-layout="row"] .import-cards-grid { - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); -} - .import-card { display: grid; align-content: start; diff --git a/webviews/test/gallery-coverage.allowlist.json b/webviews/test/gallery-coverage.allowlist.json index 7c2a8dc0900e..e1d8c3dcdfb2 100644 --- a/webviews/test/gallery-coverage.allowlist.json +++ b/webviews/test/gallery-coverage.allowlist.json @@ -22,7 +22,6 @@ "agent-session/acpmux/ComposerPickers.tsx#SlashIcon", "agent-session/acpmux/ComposerPickers.tsx#StopIcon", "agent-session/acpmux/Keycap.tsx#Keycap", - "agent-session/acpmux/NewTabImportCards.tsx#NewTabImportCards", "agent-session/acpmux/ProjectBadge.tsx#ProjectBadge", "agent-session/acpmux/SwitchNotice.tsx#SwitchNotice", "agent-session/acpmux/changeIcons.tsx#AlertCircle",