diff --git a/Sources/AgentSyncApp/SessionPickerView.swift b/Sources/AgentSyncApp/SessionPickerView.swift index 788ba38..c8823e0 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,31 @@ 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) + } + // 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) + } } - .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 +250,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 +296,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 +307,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(