Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 77 additions & 15 deletions Sources/AgentSyncApp/SessionPickerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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")
}
Expand Down
48 changes: 48 additions & 0 deletions Sources/AgentSyncApp/SettingsView.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AgentSyncCore
import AppKit
import ServiceManagement
import SwiftUI

struct SettingsView: View {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<Bool> {
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(
Expand Down
Loading