From 68a942e3b5c7b21f8d5ffaf5541c0147562a2266 Mon Sep 17 00:00:00 2001 From: Yoav Farhi Date: Sun, 25 Oct 2026 12:36:48 +0200 Subject: [PATCH 1/2] Add launch at login and keyboard navigation in the picker Settings gains a "Launch Continuo at login" toggle backed by SMAppService.mainApp. Its state is read back from the system rather than stored in UserDefaults, and the footer surfaces approval-required and registration errors with a shortcut to Login Items. The session picker focuses search on open; up/down move a shared highlight (pointer hover moves it too) and Return opens the highlighted session. The top result is highlighted by default, Spotlight-style. Co-Authored-By: Claude Opus 5.5 --- Sources/AgentSyncApp/SessionPickerView.swift | 90 ++++++++++++++++---- Sources/AgentSyncApp/SettingsView.swift | 48 +++++++++++ 2 files changed, 123 insertions(+), 15 deletions(-) diff --git a/Sources/AgentSyncApp/SessionPickerView.swift b/Sources/AgentSyncApp/SessionPickerView.swift index 788ba38..1e0c94f 100644 --- a/Sources/AgentSyncApp/SessionPickerView.swift +++ b/Sources/AgentSyncApp/SessionPickerView.swift @@ -5,6 +5,9 @@ struct SessionPickerView: View { @ObservedObject var model: AppModel @State private var query = "" @State private var selectedItem: SessionItem? + /// Row the arrow keys and pointer last landed on; Return opens it. + @State private var highlightedID: SessionItem.ID? + @FocusState private var isSearchFocused: Bool /// Six most recent conversations by default; searching reaches the whole /// scanned set. Every query word must match something — title, project, or @@ -28,6 +31,31 @@ struct SessionPickerView: View { } } + /// Like Spotlight, the top result stays highlighted until the user moves, + /// so typing a query and pressing Return opens the best match. + private var highlightedItem: SessionItem? { + let sessions = filteredSessions + return sessions.first { $0.id == highlightedID } ?? sessions.first + } + + private func moveHighlight(by offset: Int) { + let sessions = filteredSessions + guard !sessions.isEmpty else { + return + } + let current = highlightedItem.flatMap { item in + sessions.firstIndex { $0.id == item.id } + } ?? 0 + highlightedID = sessions[min(max(current + offset, 0), sessions.count - 1)].id + } + + private func openHighlighted() { + guard model.launchingID == nil, let item = highlightedItem else { + return + } + selectedItem = item + } + var body: some View { VStack(spacing: 0) { if let item = selectedItem { @@ -77,6 +105,24 @@ struct SessionPickerView: View { .foregroundStyle(.secondary) TextField("Search sessions", text: $query) .textFieldStyle(.plain) + .focused($isSearchFocused) + .onSubmit(openHighlighted) + .onKeyPress(.upArrow) { + moveHighlight(by: -1) + return .handled + } + .onKeyPress(.downArrow) { + moveHighlight(by: 1) + return .handled + } + .onChange(of: query) { + highlightedID = nil + } + // Also runs when returning from the continue view, which + // rebuilds the header. + .onAppear { + isSearchFocused = true + } } .padding(.horizontal, 8) .padding(.vertical, 5) @@ -135,19 +181,29 @@ struct SessionPickerView: View { .padding(.top, 8) .padding(.bottom, 2) - ScrollView { - LazyVStack(spacing: 1) { - ForEach(filteredSessions) { item in - SessionRow( - item: item, - isLaunching: model.launchingID == item.id, - isDisabled: model.launchingID != nil - ) { - selectedItem = item + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 1) { + ForEach(filteredSessions) { item in + SessionRow( + item: item, + isHighlighted: highlightedItem?.id == item.id, + isLaunching: model.launchingID == item.id, + isDisabled: model.launchingID != nil, + onHover: { highlightedID = item.id } + ) { + selectedItem = item + } + .id(item.id) } } + .padding(6) + } + .onChange(of: highlightedID) { + if let highlightedID { + proxy.scrollTo(highlightedID) + } } - .padding(6) } // A ScrollView has no intrinsic height, and the MenuBarExtra window // sizes to ideal height — without an explicit frame it collapses to @@ -192,12 +248,12 @@ struct SessionPickerView: View { private struct SessionRow: View { var item: SessionItem + var isHighlighted: Bool var isLaunching: Bool var isDisabled: Bool + var onHover: () -> Void var onSelect: () -> Void - @State private var isHovering = false - var body: some View { Button(action: onSelect) { HStack(spacing: 10) { @@ -238,7 +294,7 @@ private struct SessionRow: View { Image(systemName: "chevron.right") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.tertiary) - .opacity(isHovering ? 1 : 0.35) + .opacity(isHighlighted ? 1 : 0.35) } } .padding(.horizontal, 8) @@ -249,10 +305,14 @@ private struct SessionRow: View { .disabled(isDisabled) .background( RoundedRectangle(cornerRadius: 7) - .fill(isHovering && !isDisabled ? AnyShapeStyle(.quaternary.opacity(0.7)) : AnyShapeStyle(.clear)) + .fill(isHighlighted && !isDisabled ? AnyShapeStyle(.quaternary.opacity(0.7)) : AnyShapeStyle(.clear)) ) + // Pointer and arrow keys share one highlight, as in Spotlight; leaving + // a row keeps it highlighted so Return still has a target. .onHover { hovering in - isHovering = hovering + if hovering { + onHover() + } } .help("Choose where to continue this conversation") } diff --git a/Sources/AgentSyncApp/SettingsView.swift b/Sources/AgentSyncApp/SettingsView.swift index 5173361..6b491fe 100644 --- a/Sources/AgentSyncApp/SettingsView.swift +++ b/Sources/AgentSyncApp/SettingsView.swift @@ -1,5 +1,6 @@ import AgentSyncCore import AppKit +import ServiceManagement import SwiftUI struct SettingsView: View { @@ -46,9 +47,29 @@ private struct GeneralSettingsTab: View { @State private var supersetStatus = TerminalLauncher.supersetConnectionStatus @State private var setupInstructionsTerminal: TerminalApp? @State private var advancedExpanded = false + @State private var loginItemStatus = SMAppService.mainApp.status + @State private var loginItemError: String? var body: some View { Form { + Section { + Toggle("Launch Continuo at login", isOn: launchAtLoginBinding) + } footer: { + if let loginItemError { + Text(loginItemError) + } else if loginItemStatus == .requiresApproval { + HStack(spacing: 4) { + Text("Allow Continuo under Login Items to finish turning this on.") + Button("Open Login Items…") { + SMAppService.openSystemSettingsLoginItems() + } + .buttonStyle(.link) + } + } else { + Text("Keeps Continuo in the menu bar after you restart your Mac.") + } + } + Section { Toggle("Open the picker with ⌥⌘S", isOn: $hotkeyEnabled) .onChange(of: hotkeyEnabled) { @@ -165,12 +186,39 @@ private struct GeneralSettingsTab: View { .onAppear { cmuxStatus = TerminalLauncher.cmuxConnectionStatus supersetStatus = TerminalLauncher.supersetConnectionStatus + loginItemStatus = SMAppService.mainApp.status + } + // The settings window is reused, so `onAppear` doesn't rerun when it + // reopens; pick up approvals made in System Settings meanwhile. + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + loginItemStatus = SMAppService.mainApp.status } .alert(item: $setupInstructionsTerminal) { terminal in setupAlert(for: terminal) } } + /// The system owns login-item state — users can also change it in System + /// Settings — so it is read back from `SMAppService` after every change + /// rather than mirrored into UserDefaults. + private var launchAtLoginBinding: Binding { + Binding { + loginItemStatus == .enabled || loginItemStatus == .requiresApproval + } set: { enabled in + loginItemError = nil + do { + if enabled { + try SMAppService.mainApp.register() + } else { + try SMAppService.mainApp.unregister() + } + } catch { + loginItemError = "Couldn’t update the login item: \(error.localizedDescription)" + } + loginItemStatus = SMAppService.mainApp.status + } + } + private func setupAlert(for terminal: TerminalApp) -> Alert { guard terminal == .superset else { return Alert( From 5654e251bf786185e00d85f31990a8323d3617c2 Mon Sep 17 00:00:00 2001 From: Yoav Farhi Date: Sun, 25 Oct 2026 12:46:09 +0200 Subject: [PATCH 2/2] Scroll to the effective highlight after query edits A query edit resets highlightedID to nil, which falls back to the top result; the scroll handler skipped nil and left that row off-screen. Co-Authored-By: Claude Opus 5.5 --- Sources/AgentSyncApp/SessionPickerView.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Sources/AgentSyncApp/SessionPickerView.swift b/Sources/AgentSyncApp/SessionPickerView.swift index 1e0c94f..c8823e0 100644 --- a/Sources/AgentSyncApp/SessionPickerView.swift +++ b/Sources/AgentSyncApp/SessionPickerView.swift @@ -199,9 +199,11 @@ struct SessionPickerView: View { } .padding(6) } - .onChange(of: highlightedID) { - if let highlightedID { - proxy.scrollTo(highlightedID) + // Track the effective row, not `highlightedID`: a query edit + // resets that to nil, which falls back to the top result. + .onChange(of: highlightedItem?.id) { _, id in + if let id { + proxy.scrollTo(id) } } }