Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import Foundation

public enum GatewayReplacementReconciliation: Equatable, Sendable {
case unchanged
case restarted
case deferred
}

/// Restarts a loaded bundled gateway once after its containing app is replaced.
///
/// launchd can keep the old executable image alive when an app is replaced at the same path. That
/// stale process can no longer authenticate newly signed nested XPC services. The helper is located
/// only at the fixed bundle-relative path, and the LaunchAgent must name that exact path before it
/// can be restarted. Full helper authentication still happens at every execution boundary; this
/// marker only avoids an unnecessary restart on every ordinary app launch.
public struct GatewayReplacementReconciler: Sendable {
public typealias GatewayLocator = @Sendable () -> URL?
public typealias StatusQuery = @Sendable (_ expectedGateway: URL) async -> GatewayServiceStatus
public typealias Restarter = @Sendable (_ expectedGateway: URL) async throws -> Void
public typealias FingerprintProvider = @Sendable (_ gateway: URL) -> String?
public typealias MarkerReader = @Sendable () -> String?
public typealias MarkerWriter = @Sendable (_ fingerprint: String) async -> Void

private static let markerKey = "Wayfinder.GatewayInstalledFingerprint"

private let locateGateway: GatewayLocator
private let statusQuery: StatusQuery
private let restart: Restarter
private let fingerprintProvider: FingerprintProvider
private let markerReader: MarkerReader
private let markerWriter: MarkerWriter

public init(
appBundleURL: URL = Bundle.main.bundleURL,
service: GatewayServiceController = GatewayServiceController()
) {
self.locateGateway = {
let gateway = GatewayToolResolver.bundledHelperURL(in: appBundleURL)
return FileManager.default.isExecutableFile(atPath: gateway.path) ? gateway : nil
}
self.statusQuery = { await service.status(expectedGateway: $0) }
self.restart = { try await service.restart(expectedGateway: $0) }
self.fingerprintProvider = { Self.installationFingerprint(gateway: $0) }
self.markerReader = { UserDefaults.standard.string(forKey: Self.markerKey) }
self.markerWriter = { UserDefaults.standard.set($0, forKey: Self.markerKey) }
}

init(
locateGateway: @escaping GatewayLocator,
statusQuery: @escaping StatusQuery,
restart: @escaping Restarter,
fingerprintProvider: @escaping FingerprintProvider,
markerReader: @escaping MarkerReader,
markerWriter: @escaping MarkerWriter
) {
self.locateGateway = locateGateway
self.statusQuery = statusQuery
self.restart = restart
self.fingerprintProvider = fingerprintProvider
self.markerReader = markerReader
self.markerWriter = markerWriter
}

public func reconcile() async -> GatewayReplacementReconciliation {
guard let gateway = locateGateway(),
let fingerprint = fingerprintProvider(gateway) else {
return .deferred
}
if markerReader() == fingerprint {
return .unchanged
}

let status = await statusQuery(gateway)
guard status.installed,
status.loaded,
status.launchConfiguration.usesGateway(at: gateway) else {
return .deferred
}

do {
try await restart(gateway)
await markerWriter(fingerprint)
return .restarted
} catch {
return .deferred
}
}

static func installationFingerprint(
appBundleURL: URL = Bundle.main.bundleURL,
gateway: URL
) -> String? {
guard let values = try? gateway.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]),
let fileSize = values.fileSize,
let modified = values.contentModificationDate else {
return nil
}
let info = Bundle(url: appBundleURL)?.infoDictionary ?? Bundle.main.infoDictionary ?? [:]
let version = info["CFBundleShortVersionString"] as? String ?? "unknown"
let build = info["CFBundleVersion"] as? String ?? "unknown"
let signing: String
switch AppleFoundationModelsProductReadiness.current(appBundleURL: appBundleURL) {
case .ready(let teamIdentifier):
signing = teamIdentifier
case .incompleteOrInvalid:
signing = "untrusted"
}
let modifiedNanoseconds = Int64(modified.timeIntervalSince1970 * 1_000_000_000)
return [version, build, signing, String(fileSize), String(modifiedNanoseconds)]
.joined(separator: "|")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,19 @@ public struct ChatConversationView: View {
@Binding var selectedTurnID: UUID?
let canRetry: Bool
let onRetry: () -> Void
let onOpenRouting: (ChatTurn) -> Void
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var isNearBottom = true

public init(
turns: [ChatTurn],
selectedTurnID: Binding<UUID?>,
canRetry: Bool = false,
onRetry: @escaping () -> Void = {},
onOpenRouting: @escaping (ChatTurn) -> Void = { _ in }
onRetry: @escaping () -> Void = {}
) {
self.turns = turns
self._selectedTurnID = selectedTurnID
self.canRetry = canRetry
self.onRetry = onRetry
self.onOpenRouting = onOpenRouting
}

public var body: some View {
Expand All @@ -37,13 +34,8 @@ public struct ChatConversationView: View {
ChatTurnHistoryRow(
turn: turn,
isLast: index == turns.count - 1,
isSelected: selectedTurnID == turn.id,
canRetry: canRetry && index == turns.count - 1,
onRetry: onRetry,
onShowRouting: {
selectedTurnID = turn.id
onOpenRouting(turn)
}
onRetry: onRetry
)
.id(turn.id)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,19 @@ import SwiftUI
public struct ChatTurnHistoryRow: View {
let turn: ChatTurn
let isLast: Bool
let isSelected: Bool
let canRetry: Bool
let onRetry: () -> Void
let onShowRouting: () -> Void

public init(
turn: ChatTurn,
isLast: Bool,
isSelected: Bool,
canRetry: Bool,
onRetry: @escaping () -> Void,
onShowRouting: @escaping () -> Void
onRetry: @escaping () -> Void
) {
self.turn = turn
self.isLast = isLast
self.isSelected = isSelected
self.canRetry = canRetry
self.onRetry = onRetry
self.onShowRouting = onShowRouting
}

public var body: some View {
Expand All @@ -30,11 +24,10 @@ public struct ChatTurnHistoryRow: View {

if let response = turn.response {
AssistantTurnResponse(
turn: turn,
response: response,
isSelected: isSelected,
canRetry: canRetry,
onRetry: onRetry,
onShowRouting: onShowRouting
onRetry: onRetry
)
} else {
PendingRouteStrip()
Expand Down Expand Up @@ -70,11 +63,10 @@ public struct ChatTurnHistoryRow: View {
}

private struct AssistantTurnResponse: View {
let turn: ChatTurn
let response: ChatMessage
let isSelected: Bool
let canRetry: Bool
let onRetry: () -> Void
let onShowRouting: () -> Void

var body: some View {
VStack(alignment: .leading, spacing: 12) {
Expand Down Expand Up @@ -132,9 +124,8 @@ private struct AssistantTurnResponse: View {

if let decision = response.decision {
RoutingReceiptButton(
decision: decision,
isSelected: isSelected,
action: onShowRouting
turn: turn,
decision: decision
)
}
}
Expand All @@ -144,31 +135,43 @@ private struct AssistantTurnResponse: View {
}

private struct RoutingReceiptButton: View {
let turn: ChatTurn
let decision: RoutingDecision
let isSelected: Bool
let action: () -> Void
@State private var showsRoutingDetails = false

var body: some View {
Button(action: action) {
Button {
showsRoutingDetails.toggle()
} label: {
HStack(spacing: 6) {
Image(systemName: decision.route.symbolName)
.foregroundStyle(decision.route.accentColor)
Text(decision.routeSummary)
.fontWeight(.semibold)
Text("Routing details")
.foregroundStyle(ChatWorkspaceChrome.secondaryText)
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
Image(systemName: "info.circle")
.font(.caption.weight(.semibold))
.foregroundStyle(ChatWorkspaceChrome.tertiaryText)
}
.font(.caption)
.padding(.vertical, 3)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(isSelected ? decision.route.accentColor : .primary)
.foregroundStyle(decision.route.accentColor)
.accessibilityLabel("\(decision.routeSummary). Show routing details.")
.help("Show this turn in the routing inspector")
.help("Show routing details")
.onHover { hovering in
if hovering {
showsRoutingDetails = true
}
}
.popover(isPresented: $showsRoutingDetails, arrowEdge: .bottom) {
RoutingOutputsPanel(
turn: turn,
onClose: { showsRoutingDetails = false }
)
.frame(width: 400, height: 560)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,12 @@ enum ChatWorkspaceChrome {
static let sidebarMinimumWidth: CGFloat = 210
static let sidebarWidth: CGFloat = 220
static let sidebarMaximumWidth: CGFloat = 260
static let inspectorMinimumWidth: CGFloat = 260
static let inspectorWidth: CGFloat = 296
static let inspectorMaximumWidth: CGFloat = 340
static let conversationWidth: CGFloat = 760
static let composerWidth: CGFloat = 780
static let initialWindowWidth: CGFloat = 1_180
static let initialWindowHeight: CGFloat = 720
static let minimumWindowWidth: CGFloat = 900
static let minimumWindowHeight: CGFloat = 580
static let showsInspectorByDefault = false
}

public enum ChatRouteFilter: String, CaseIterable, Identifiable {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ public struct WayfinderChatWindow: View {
@State private var routeFilter: ChatRouteFilter = .all
@State private var searchText = ""
@State private var columnVisibility: NavigationSplitViewVisibility = .all
@State private var showsInspector = ChatWorkspaceChrome.showsInspectorByDefault
@State private var followsLatestTurn = true
@State private var searchFocusRequest = 0

Expand Down Expand Up @@ -41,10 +40,8 @@ public struct WayfinderChatWindow: View {
ChatToolbar(
title: chatTitle(in: turns),
showsSidebar: showsSidebar,
showsInspector: showsInspector,
onToggleSidebar: toggleSidebar,
onFocusSearch: focusSearch,
onToggleInspector: { showsInspector.toggle() },
canRetry: appState.canRetryChat,
canClear: appState.canClearChat,
onRetry: retryLastTurn,
Expand All @@ -55,11 +52,7 @@ public struct WayfinderChatWindow: View {
turns: workspace.transcriptTurns,
selectedTurnID: $selectedTurnID,
canRetry: appState.canRetryChat,
onRetry: retryLastTurn,
onOpenRouting: { turn in
selectedTurnID = turn.id
showsInspector = true
}
onRetry: retryLastTurn
)
ChatComposerView(
draft: $appState.chatDraft,
Expand All @@ -75,17 +68,6 @@ public struct WayfinderChatWindow: View {
)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.inspector(isPresented: $showsInspector) {
RoutingOutputsPanel(
turn: selectedTurn(in: turns),
onClose: { showsInspector = false }
)
.inspectorColumnWidth(
min: ChatWorkspaceChrome.inspectorMinimumWidth,
ideal: ChatWorkspaceChrome.inspectorWidth,
max: ChatWorkspaceChrome.inspectorMaximumWidth
)
}
}
.navigationSplitViewStyle(.prominentDetail)
.frame(
Expand Down Expand Up @@ -121,7 +103,11 @@ public struct WayfinderChatWindow: View {
}

private func toggleSidebar() {
columnVisibility = showsSidebar ? .detailOnly : .all
if showsSidebar {
columnVisibility = .detailOnly
} else {
columnVisibility = .all
}
}

private func focusSearch() {
Expand Down Expand Up @@ -149,21 +135,13 @@ public struct WayfinderChatWindow: View {
return firstPrompt
}

private func selectedTurn(in turns: [ChatTurn]) -> ChatTurn? {
guard let selectedTurnID else {
return nil
}
return turns.first { $0.id == selectedTurnID }
}
}

private struct ChatToolbar: View {
let title: String
let showsSidebar: Bool
let showsInspector: Bool
let onToggleSidebar: () -> Void
let onFocusSearch: () -> Void
let onToggleInspector: () -> Void
let canRetry: Bool
let canClear: Bool
let onRetry: () -> Void
Expand Down Expand Up @@ -197,13 +175,6 @@ private struct ChatToolbar: View {
.keyboardShortcut("r", modifiers: .command)
.help("Retry the last response")
}
Button(action: onToggleInspector) {
Image(systemName: "sidebar.right")
.symbolVariant(showsInspector ? .fill : .none)
}
.accessibilityLabel(showsInspector ? "Hide routing inspector" : "Show routing inspector")
.keyboardShortcut("i", modifiers: [.command, .control])
.help(showsInspector ? "Hide routing inspector" : "Show routing inspector")
Button(action: onClear) {
Image(systemName: "square.and.pencil")
}
Expand Down
Loading
Loading