## What changed - add App and PetCore portable Agent Pet Maker skill management, with fd-relative ownership and race-safe install/uninstall - improve Pi no-model failure projection, session bubble message selection, child-session filtering, onboarding rendering, and related App behavior - delete verified PR source branches with exact leases after merge while dispatching exact-main CI first - document direct/train multi-agent delivery, explicit add/commit/clean/push checks, post-merge worktree audits, and exact-SHA release proof ## Validation - full `cargo test -p petcore` - Swift package suite: 734 tests - Pi connector privacy tests - promotion/release Python tests and actionlint - `./script/validate_pre_push.sh` - independent specification and safety reviews: no remaining blockers This PR changes the protected CI control plane, so it is intended for full CI followed by trusted exact-head squash merge. --------- Co-authored-by: gd-ops <gd-ops@xmfunny.com>
963 lines
33 KiB
Swift
963 lines
33 KiB
Swift
import AgentPetCompanionCore
|
|
import MetalKit
|
|
import SwiftUI
|
|
|
|
struct OnboardingView: View {
|
|
@EnvironmentObject private var store: AppStore
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
@State private var selectedPetID: String?
|
|
@State private var demoSequence = OnboardingDemoSequence()
|
|
@State private var demoRunID = 0
|
|
@State private var pendingRepairSource: AgentSource?
|
|
@State private var confirmingRepairAll = false
|
|
|
|
private var progress: OnboardingProgress {
|
|
store.onboarding?.progress ?? OnboardingProgress()
|
|
}
|
|
|
|
private var connectionPresentations: [AgentConnectionProductPresentation] {
|
|
AgentConnectionsCatalog.sources.map { source in
|
|
AgentConnectionProductPresentation(
|
|
source: source,
|
|
status: store.connections.first { $0.source == source },
|
|
operationState: store.connectionOperationState
|
|
)
|
|
}
|
|
}
|
|
|
|
private var connectionState: OnboardingConnectionSceneState {
|
|
let visible = connectionPresentations.compactMap { presentation in
|
|
OnboardingAgentPresentation(
|
|
source: presentation.source,
|
|
health: presentation.health,
|
|
primaryAction: presentation.primaryAction
|
|
)
|
|
}
|
|
if visible.isEmpty {
|
|
return .checking
|
|
}
|
|
if visible.allSatisfy({ $0.health == .unavailable }) {
|
|
return .noAgents
|
|
}
|
|
return .agents(visible)
|
|
}
|
|
|
|
private var flowPresentation: OnboardingFlowPresentation {
|
|
OnboardingFlowPresentation(
|
|
progress: progress,
|
|
availability: store.onboardingAvailability,
|
|
pets: store.onboardingCompanionCandidates,
|
|
selectedPetID: selectedPetID,
|
|
unavailablePetIDs: Set(
|
|
store.onboardingCompanionCandidates.compactMap { pet in
|
|
store.petAssetWarningIndex[pet.id] == nil ? nil : pet.id
|
|
}
|
|
),
|
|
connectionState: connectionState,
|
|
demoSequence: demoSequence
|
|
)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
topBar
|
|
Divider()
|
|
|
|
ScrollView {
|
|
VStack(
|
|
alignment: .leading,
|
|
spacing: SharedProductComponentLayout.pageSpacing
|
|
) {
|
|
if store.onboardingAvailability == .serviceUnavailable {
|
|
serviceUnavailableBanner
|
|
}
|
|
if let failure = store.onboardingOperationFailure {
|
|
operationFailureBanner(failure)
|
|
}
|
|
scene
|
|
}
|
|
.frame(maxWidth: 920, alignment: .topLeading)
|
|
.padding(28)
|
|
.frame(maxWidth: .infinity, alignment: .top)
|
|
}
|
|
|
|
Divider()
|
|
bottomBar
|
|
}
|
|
.frame(
|
|
minWidth: ControlCenterShellPolicy.supportedMinimumWindowWidth,
|
|
minHeight: ControlCenterShellPolicy.supportedMinimumWindowHeight
|
|
)
|
|
.background(Color(nsColor: .windowBackgroundColor))
|
|
.accessibilityElement(children: .contain)
|
|
.accessibilityIdentifier("onboarding.root")
|
|
.onAppear(perform: synchronizePetSelection)
|
|
.onChange(of: store.onboardingCompanionCandidates.map(\.id)) {
|
|
synchronizePetSelection()
|
|
}
|
|
.onChange(of: flowPresentation.unavailablePetIDs) {
|
|
synchronizePetSelection()
|
|
}
|
|
.onChange(of: progress.stage) {
|
|
if progress.stage == .demo {
|
|
demoRunID &+= 1
|
|
} else {
|
|
demoSequence.reset()
|
|
}
|
|
}
|
|
.task(id: sceneTaskIdentity) {
|
|
await prepareCurrentScene()
|
|
}
|
|
.confirmationDialog(
|
|
repairConfirmationTitle,
|
|
isPresented: repairConfirmationPresented,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(APCLocalization.text(.connectionsWriteRepair)) {
|
|
confirmPendingRepair()
|
|
}
|
|
Button(APCLocalization.text(.commonCancel), role: .cancel) {
|
|
pendingRepairSource = nil
|
|
}
|
|
} message: {
|
|
Text(APCLocalization.text(.onboardingRepairConfirmationDetail))
|
|
}
|
|
.confirmationDialog(
|
|
APCLocalization.text(.connectionsConfirmRepairAll),
|
|
isPresented: $confirmingRepairAll,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(APCLocalization.format(
|
|
.connectionsRepairCountFormat,
|
|
repairableSources.count
|
|
)) {
|
|
confirmRepairAll()
|
|
}
|
|
Button(APCLocalization.text(.commonCancel), role: .cancel) {}
|
|
} message: {
|
|
Text(AgentConnectionsPresentation.managedRepairConfirmationMessage(
|
|
for: repairableStatuses
|
|
))
|
|
}
|
|
}
|
|
|
|
private var topBar: some View {
|
|
HStack(spacing: 16) {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(APCLocalization.text(.onboardingTitle))
|
|
.font(.title2.weight(.semibold))
|
|
Text(APCLocalization.format(
|
|
.onboardingProgressFormat,
|
|
currentSceneNumber
|
|
))
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
store.dismissOnboardingForCurrentLaunch()
|
|
} label: {
|
|
Label(
|
|
APCLocalization.text(.onboardingClose),
|
|
systemImage: "xmark"
|
|
)
|
|
.labelStyle(.iconOnly)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.help(APCLocalization.text(.onboardingClose))
|
|
.accessibilityLabel(APCLocalization.text(.onboardingClose))
|
|
.accessibilityIdentifier("onboarding.close")
|
|
}
|
|
.padding(.horizontal, 24)
|
|
.padding(.vertical, 16)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var scene: some View {
|
|
switch progress.stage {
|
|
case .choosePet:
|
|
choosePetScene
|
|
case .connectAgents:
|
|
connectAgentsScene
|
|
case .demo:
|
|
demoScene
|
|
case .completed, .skipped:
|
|
EmptyView()
|
|
}
|
|
}
|
|
|
|
private var choosePetScene: some View {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
ProductPageHeader(
|
|
identity: ProductComponentIdentity(scope: "onboarding", instance: "choose-pet"),
|
|
title: APCLocalization.text(.onboardingChooseTitle),
|
|
summary: APCLocalization.text(.onboardingChooseDetail)
|
|
)
|
|
|
|
if flowPresentation.pets.isEmpty {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Label(
|
|
APCLocalization.text(.onboardingPetsUnavailableTitle),
|
|
systemImage: "shippingbox"
|
|
)
|
|
.font(.headline)
|
|
Text(APCLocalization.text(.onboardingPetsUnavailableDetail))
|
|
.foregroundStyle(.secondary)
|
|
|
|
ViewThatFits(in: .horizontal) {
|
|
HStack(spacing: 10) {
|
|
restoreIncludedCompanionsButton
|
|
onboardingDiagnosticsButton
|
|
}
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
restoreIncludedCompanionsButton
|
|
onboardingDiagnosticsButton
|
|
}
|
|
}
|
|
|
|
if store.includedCompanionRestoreState == .failed {
|
|
Text(APCLocalization.text(.onboardingPetsRestoreFailed))
|
|
.font(.callout)
|
|
.foregroundStyle(.red)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
.padding(20)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
APCDesign.panel,
|
|
in: RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
)
|
|
} else {
|
|
LazyVGrid(
|
|
columns: [
|
|
GridItem(.adaptive(minimum: 260, maximum: 420), spacing: 16),
|
|
],
|
|
spacing: 16
|
|
) {
|
|
ForEach(flowPresentation.pets) { pet in
|
|
bundledPetCard(pet)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.accessibilityIdentifier("onboarding.scene.choose-pet")
|
|
}
|
|
|
|
private var restoreIncludedCompanionsButton: some View {
|
|
Button {
|
|
store.restoreIncludedCompanions()
|
|
} label: {
|
|
Label(
|
|
APCLocalization.text(
|
|
store.includedCompanionRestoreState == .restoring
|
|
? .onboardingPetsRestoring
|
|
: .onboardingPetsRestore
|
|
),
|
|
systemImage: "arrow.clockwise"
|
|
)
|
|
}
|
|
.apcClearGlassButtonStyle(prominent: true)
|
|
.disabled(
|
|
store.includedCompanionRestoreState == .restoring
|
|
|| store.onboardingAvailability != .ready
|
|
)
|
|
.accessibilityIdentifier("onboarding.pets.restore")
|
|
}
|
|
|
|
private var onboardingDiagnosticsButton: some View {
|
|
Button {
|
|
openDiagnosticsFromOnboarding()
|
|
} label: {
|
|
Label(
|
|
APCLocalization.text(.assetRecoveryDiagnostics),
|
|
systemImage: "stethoscope"
|
|
)
|
|
}
|
|
.apcClearGlassButtonStyle()
|
|
.accessibilityIdentifier("onboarding.pets.diagnostics")
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func bundledPetCard(_ pet: PetSummary) -> some View {
|
|
let selected = pet.id == flowPresentation.selectedPetID
|
|
if store.petAssetWarningIndex[pet.id] != nil {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
PetAssetRecoveryCard(
|
|
pet: pet,
|
|
state: store.petAssetRepairState(for: pet.id),
|
|
onRepair: { store.repairPetAssets(pet) },
|
|
onOpenDiagnostics: openDiagnosticsFromOnboarding
|
|
)
|
|
petIdentity(pet, selected: false)
|
|
}
|
|
.padding(14)
|
|
.background(
|
|
APCDesign.panel,
|
|
in: RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
.stroke(APCDesign.stroke, lineWidth: 1)
|
|
}
|
|
.accessibilityElement(children: .contain)
|
|
.accessibilityIdentifier("onboarding.pet.\(pet.id)")
|
|
} else {
|
|
Button {
|
|
selectedPetID = pet.id
|
|
} label: {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
PetPreviewStage(
|
|
identity: ProductComponentIdentity(
|
|
scope: "onboarding-pet",
|
|
instance: pet.id
|
|
),
|
|
accessibilityLabel: pet.name,
|
|
minimumHeight: 250
|
|
) {
|
|
PetLibraryAnimationPreview(pet: pet)
|
|
}
|
|
|
|
petIdentity(pet, selected: selected)
|
|
}
|
|
.padding(14)
|
|
.background(
|
|
APCDesign.panel,
|
|
in: RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
.stroke(
|
|
selected ? APCDesign.accent : APCDesign.stroke,
|
|
lineWidth: selected ? 2 : 1
|
|
)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(pet.name)
|
|
.accessibilityValue(
|
|
selected
|
|
? APCLocalization.text(.controlSelected)
|
|
: APCLocalization.text(.controlUnselected)
|
|
)
|
|
.accessibilityIdentifier("onboarding.pet.\(pet.id)")
|
|
}
|
|
}
|
|
|
|
private func petIdentity(
|
|
_ pet: PetSummary,
|
|
selected: Bool
|
|
) -> some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(pet.name)
|
|
.font(.headline)
|
|
Text(pet.style)
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
if selected {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(APCDesign.accent)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var connectAgentsScene: some View {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
ProductPageHeader(
|
|
identity: ProductComponentIdentity(
|
|
scope: "onboarding",
|
|
instance: "connect-agents"
|
|
),
|
|
title: APCLocalization.text(.onboardingConnectTitle),
|
|
summary: APCLocalization.text(.onboardingConnectDetail)
|
|
)
|
|
|
|
if connectionState == .checking {
|
|
HStack(spacing: 12) {
|
|
ProgressView()
|
|
Text(APCLocalization.text(.onboardingConnectChecking))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(20)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
} else {
|
|
if connectionPresentations
|
|
.filter({ $0.health != .checking })
|
|
.allSatisfy({ $0.health == .unavailable }) {
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
Text(APCLocalization.text(.onboardingNoAgentsTitle))
|
|
.font(.headline)
|
|
Text(APCLocalization.text(.onboardingNoAgentsDetail))
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
if !repairableSources.isEmpty {
|
|
HStack {
|
|
Spacer()
|
|
Button {
|
|
confirmingRepairAll = true
|
|
} label: {
|
|
Label(
|
|
APCLocalization.text(.connectionsRepairAll),
|
|
systemImage: "wand.and.stars"
|
|
)
|
|
}
|
|
.apcClearGlassButtonStyle()
|
|
.disabled(
|
|
!store.canStartConnectionOperation
|
|
|| store.onboardingMutationInFlight
|
|
)
|
|
.accessibilityHint(APCLocalization.text(
|
|
store.canStartConnectionOperation
|
|
&& !store.onboardingMutationInFlight
|
|
? .connectionsPrimaryRepairHint
|
|
: .connectionsBusyHint
|
|
))
|
|
.accessibilityIdentifier(
|
|
"onboarding.connections.setup-all"
|
|
)
|
|
}
|
|
}
|
|
|
|
VStack(spacing: 12) {
|
|
ForEach(
|
|
connectionPresentations.filter { $0.health != .checking },
|
|
id: \.source
|
|
) { presentation in
|
|
onboardingAgentRow(presentation)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.accessibilityIdentifier("onboarding.scene.connect-agents")
|
|
}
|
|
|
|
private func onboardingAgentRow(
|
|
_ presentation: AgentConnectionProductPresentation
|
|
) -> some View {
|
|
AgentHealthRow(
|
|
identity: ProductComponentIdentity(
|
|
scope: "onboarding-agent",
|
|
instance: presentation.source.rawValue
|
|
),
|
|
agentTitle: presentation.source.title,
|
|
agentSummary: AgentConnectionsPresentation.healthSummary(
|
|
for: presentation,
|
|
operationState: store.connectionOperationState
|
|
),
|
|
health: presentation.health,
|
|
healthTitle: AgentConnectionsPresentation.healthTitle(
|
|
for: presentation
|
|
),
|
|
taskVerification:
|
|
AgentConnectionsPresentation.attentionReason(for: presentation) == nil
|
|
? presentation.taskVerification
|
|
: nil,
|
|
taskVerificationTitle:
|
|
AgentConnectionsPresentation.taskVerificationTitle(
|
|
presentation.taskVerification
|
|
),
|
|
taskVerificationDetail:
|
|
AgentConnectionsPresentation.taskVerificationDetail(
|
|
presentation.taskVerification
|
|
),
|
|
primaryAction: AgentConnectionsPresentation.primaryActionPresentation(
|
|
for: presentation,
|
|
busy: !store.canStartConnectionOperation
|
|
|| store.onboardingMutationInFlight
|
|
)
|
|
) { action in
|
|
performConnectionAction(action, for: presentation)
|
|
}
|
|
}
|
|
|
|
private var demoScene: some View {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
ProductPageHeader(
|
|
identity: ProductComponentIdentity(scope: "onboarding", instance: "demo"),
|
|
title: APCLocalization.text(.onboardingDemoTitle),
|
|
summary: APCLocalization.text(.onboardingDemoDetail)
|
|
)
|
|
|
|
Label(
|
|
APCLocalization.text(.onboardingDemoLocalLabel),
|
|
systemImage: "play.square.stack"
|
|
)
|
|
.font(.callout.weight(.semibold))
|
|
.foregroundStyle(APCDesign.accent)
|
|
.accessibilityIdentifier("onboarding.demo.local")
|
|
|
|
PetPreviewStage(
|
|
identity: ProductComponentIdentity(scope: "onboarding", instance: "demo"),
|
|
accessibilityLabel: demoPhaseTitle,
|
|
minimumHeight: 300
|
|
) {
|
|
ZStack(alignment: .bottom) {
|
|
if let pet = store.activePet ?? flowPresentation.pets.first {
|
|
OnboardingPetAnimationPreview(
|
|
pet: pet,
|
|
assetWarning: store.petAssetWarningIndex[pet.id],
|
|
lifecycle: demoSequence.phase.lifecycleState
|
|
)
|
|
}
|
|
|
|
VStack(spacing: 5) {
|
|
Text(demoPhaseTitle)
|
|
.font(.title3.weight(.semibold))
|
|
Text(demoPhaseDetail)
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.padding(.horizontal, 18)
|
|
.padding(.vertical, 12)
|
|
.apcClearGlass(in: Capsule())
|
|
.padding(18)
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 300, maxHeight: 360)
|
|
}
|
|
.accessibilityIdentifier("onboarding.demo.phase")
|
|
|
|
HStack {
|
|
Button {
|
|
demoRunID &+= 1
|
|
} label: {
|
|
Label(
|
|
APCLocalization.text(.onboardingDemoReplay),
|
|
systemImage: "arrow.counterclockwise"
|
|
)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.accessibilityIdentifier("onboarding.demo.restart")
|
|
|
|
Spacer()
|
|
|
|
if demoSequence.isComplete {
|
|
Text(APCLocalization.text(.onboardingDemoInvitation))
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.trailing)
|
|
}
|
|
}
|
|
}
|
|
.accessibilityIdentifier("onboarding.scene.demo")
|
|
}
|
|
|
|
private var serviceUnavailableBanner: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Image(systemName: "network.slash")
|
|
.foregroundStyle(APCDesign.destructive)
|
|
.accessibilityHidden(true)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(APCLocalization.text(.onboardingServiceUnavailableTitle))
|
|
.font(.headline)
|
|
Text(APCLocalization.text(.onboardingServiceUnavailableDetail))
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
Button(APCLocalization.text(.commonRetry)) {
|
|
store.retryOnboardingService()
|
|
}
|
|
.accessibilityIdentifier("onboarding.service.retry")
|
|
}
|
|
.padding(16)
|
|
.background(
|
|
APCDesign.destructive.opacity(0.08),
|
|
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
)
|
|
}
|
|
|
|
private func operationFailureBanner(
|
|
_ failure: OnboardingOperationFailure
|
|
) -> some View {
|
|
Label(onboardingFailureText(failure), systemImage: "exclamationmark.triangle.fill")
|
|
.font(.callout)
|
|
.foregroundStyle(APCDesign.destructive)
|
|
.padding(14)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
APCDesign.destructive.opacity(0.08),
|
|
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
)
|
|
.accessibilityIdentifier("onboarding.operation.failure")
|
|
}
|
|
|
|
private var bottomBar: some View {
|
|
HStack(spacing: 12) {
|
|
if flowPresentation.allowsSkip {
|
|
Button(APCLocalization.text(.onboardingSkip)) {
|
|
Task {
|
|
_ = await store.advanceOnboarding(to: .skipped)
|
|
}
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.disabled(store.onboardingMutationInFlight)
|
|
.accessibilityIdentifier("onboarding.skip")
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if let primary = flowPresentation.primaryAction {
|
|
Button {
|
|
performPrimaryAction(primary)
|
|
} label: {
|
|
if store.onboardingMutationInFlight {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
} else {
|
|
Text(primaryActionTitle(primary))
|
|
}
|
|
}
|
|
.apcClearGlassButtonStyle(prominent: true)
|
|
.disabled(store.onboardingMutationInFlight)
|
|
.accessibilityLabel(primaryActionTitle(primary))
|
|
.accessibilityIdentifier(
|
|
"onboarding.primary.\(progress.stage.rawValue)"
|
|
)
|
|
}
|
|
}
|
|
.padding(.horizontal, 24)
|
|
.padding(.vertical, 16)
|
|
}
|
|
|
|
private var currentSceneNumber: Int {
|
|
switch progress.stage {
|
|
case .choosePet: 1
|
|
case .connectAgents: 2
|
|
case .demo, .completed, .skipped: 3
|
|
}
|
|
}
|
|
|
|
private var sceneTaskIdentity: String {
|
|
"\(progress.stage.rawValue):\(demoRunID)"
|
|
}
|
|
|
|
private func prepareCurrentScene() async {
|
|
switch progress.stage {
|
|
case .choosePet:
|
|
return
|
|
case .connectAgents:
|
|
guard store.onboardingAvailability == .ready,
|
|
store.connections.isEmpty,
|
|
store.canStartConnectionOperation
|
|
else { return }
|
|
store.checkAllConnections()
|
|
case .demo:
|
|
await runLocalDemo()
|
|
case .completed, .skipped:
|
|
return
|
|
}
|
|
}
|
|
|
|
private func runLocalDemo() async {
|
|
demoSequence.reset()
|
|
for _ in 0..<3 {
|
|
do {
|
|
try await Task.sleep(
|
|
for: reduceMotion ? .milliseconds(700) : .milliseconds(1_250)
|
|
)
|
|
} catch {
|
|
return
|
|
}
|
|
guard !Task.isCancelled, progress.stage == .demo else { return }
|
|
if reduceMotion {
|
|
var transaction = Transaction()
|
|
transaction.disablesAnimations = true
|
|
_ = withTransaction(transaction) {
|
|
demoSequence.advance()
|
|
}
|
|
} else {
|
|
_ = withAnimation(.easeInOut(duration: 0.25)) {
|
|
demoSequence.advance()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func synchronizePetSelection() {
|
|
let candidates = store.onboardingCompanionCandidates.filter {
|
|
store.petAssetWarningIndex[$0.id] == nil
|
|
}
|
|
if let selectedPetID,
|
|
candidates.contains(where: { $0.id == selectedPetID }) {
|
|
return
|
|
}
|
|
selectedPetID = candidates.first(where: \.active)?.id
|
|
?? candidates.first?.id
|
|
}
|
|
|
|
private func openDiagnosticsFromOnboarding() {
|
|
store.dismissOnboardingForCurrentLaunch()
|
|
store.selection = .diagnostics
|
|
}
|
|
|
|
private func performPrimaryAction(_ action: OnboardingPrimaryAction) {
|
|
switch action {
|
|
case .confirmPet:
|
|
guard let selectedPetID,
|
|
let pet = flowPresentation.pets.first(where: {
|
|
$0.id == selectedPetID
|
|
})
|
|
else { return }
|
|
Task {
|
|
_ = await store.confirmOnboardingPet(pet)
|
|
}
|
|
case .continueToDemo:
|
|
Task {
|
|
_ = await store.advanceOnboarding(to: .demo)
|
|
}
|
|
case .finish:
|
|
Task {
|
|
_ = await store.advanceOnboarding(to: .completed)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func performConnectionAction(
|
|
_ action: AgentConnectionPrimaryAction,
|
|
for presentation: AgentConnectionProductPresentation
|
|
) {
|
|
switch action {
|
|
case .connect, .repair:
|
|
guard presentation.canRepairManagedConnector,
|
|
presentation.primaryAction == action
|
|
else { return }
|
|
pendingRepairSource = presentation.source
|
|
case .verify:
|
|
guard store.canStartConnectionOperation else { return }
|
|
store.checkConnection(presentation.source)
|
|
case .retry:
|
|
store.retryConnectionOperation()
|
|
case .unavailable:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func confirmPendingRepair() {
|
|
guard let source = pendingRepairSource else { return }
|
|
defer { pendingRepairSource = nil }
|
|
let current = AgentConnectionProductPresentation(
|
|
source: source,
|
|
status: store.connections.first { $0.source == source },
|
|
operationState: store.connectionOperationState
|
|
)
|
|
guard current.canRepairManagedConnector,
|
|
current.primaryAction == .connect || current.primaryAction == .repair
|
|
else { return }
|
|
store.repairConnection(source)
|
|
}
|
|
|
|
private func confirmRepairAll() {
|
|
let sources = repairableSources
|
|
guard !sources.isEmpty,
|
|
store.canStartConnectionOperation,
|
|
!store.onboardingMutationInFlight else {
|
|
return
|
|
}
|
|
store.repairConnections(sources)
|
|
}
|
|
|
|
private var repairableStatuses: [AgentConnectionStatus] {
|
|
AgentConnectionsPresentation.repairableStatuses(
|
|
from: store.connections
|
|
)
|
|
}
|
|
|
|
private var repairableSources: [AgentSource] {
|
|
repairableStatuses.map(\.source)
|
|
}
|
|
|
|
private var repairConfirmationPresented: Binding<Bool> {
|
|
Binding(
|
|
get: { pendingRepairSource != nil },
|
|
set: { presented in
|
|
if !presented {
|
|
pendingRepairSource = nil
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
private var repairConfirmationTitle: String {
|
|
guard let source = pendingRepairSource else { return "" }
|
|
return APCLocalization.format(
|
|
.connectionsConfirmRepairFormat,
|
|
source.title
|
|
)
|
|
}
|
|
|
|
private func primaryActionTitle(_ action: OnboardingPrimaryAction) -> String {
|
|
switch action {
|
|
case .confirmPet:
|
|
APCLocalization.text(.onboardingChooseConfirm)
|
|
case .continueToDemo:
|
|
APCLocalization.text(.onboardingConnectContinue)
|
|
case .finish:
|
|
APCLocalization.text(.onboardingFinish)
|
|
}
|
|
}
|
|
|
|
private var demoPhaseTitle: String {
|
|
switch demoSequence.phase {
|
|
case .thinking:
|
|
APCLocalization.text(.onboardingDemoThinking)
|
|
case .working:
|
|
APCLocalization.text(.onboardingDemoWorking)
|
|
case .needsAttention:
|
|
APCLocalization.text(.onboardingDemoNeedsAttention)
|
|
case .done:
|
|
APCLocalization.text(.onboardingDemoDone)
|
|
}
|
|
}
|
|
|
|
private var demoPhaseDetail: String {
|
|
switch demoSequence.phase {
|
|
case .thinking:
|
|
APCLocalization.text(.onboardingDemoThinkingDetail)
|
|
case .working:
|
|
APCLocalization.text(.onboardingDemoWorkingDetail)
|
|
case .needsAttention:
|
|
APCLocalization.text(.onboardingDemoNeedsAttentionDetail)
|
|
case .done:
|
|
APCLocalization.text(.onboardingDemoDoneDetail)
|
|
}
|
|
}
|
|
|
|
private func onboardingFailureText(
|
|
_ failure: OnboardingOperationFailure
|
|
) -> String {
|
|
let key: APCLocalizationKey = switch failure {
|
|
case .serviceUnavailable:
|
|
.onboardingFailureService
|
|
case .petActivation:
|
|
.onboardingFailurePetActivation
|
|
case .revisionConflict:
|
|
.onboardingFailureRevisionConflict
|
|
case .requestRejected:
|
|
.onboardingFailureRequest
|
|
}
|
|
return APCLocalization.text(key)
|
|
}
|
|
}
|
|
|
|
/// The demo renderer is intentionally scoped to this view. It consumes only
|
|
/// the selected pet and local phase reducer and has no store or transport
|
|
/// reference.
|
|
private struct OnboardingPetAnimationPreview: View {
|
|
let pet: PetSummary
|
|
let assetWarning: PetAssetWarning?
|
|
let lifecycle: ProductLifecycleState
|
|
|
|
@State private var contentState: PetPreviewContentState
|
|
|
|
init(
|
|
pet: PetSummary,
|
|
assetWarning: PetAssetWarning?,
|
|
lifecycle: ProductLifecycleState
|
|
) {
|
|
self.pet = pet
|
|
self.assetWarning = assetWarning
|
|
self.lifecycle = lifecycle
|
|
_contentState = State(initialValue: PetPreviewContentState(
|
|
selectedIdentity: PetPreviewRenderIdentity(
|
|
pet: pet,
|
|
stateName: lifecycle.rawValue,
|
|
assetWarning: assetWarning
|
|
)
|
|
))
|
|
}
|
|
|
|
var body: some View {
|
|
let identity = PetPreviewRenderIdentity(
|
|
pet: pet,
|
|
stateName: lifecycle.rawValue,
|
|
assetWarning: assetWarning
|
|
)
|
|
let rendererHasContent = contentState.hasPresentedContent(for: identity)
|
|
ZStack {
|
|
if PetLibraryPreviewPolicy.canRender(
|
|
assetWarning: assetWarning
|
|
) {
|
|
OnboardingPetMetalView(
|
|
pet: pet,
|
|
stateName: lifecycle.rawValue
|
|
) { hasContent in
|
|
contentState.receive(hasContent: hasContent, for: identity)
|
|
}
|
|
.id(identity)
|
|
}
|
|
|
|
if !rendererHasContent {
|
|
Color(nsColor: .textBackgroundColor)
|
|
.overlay {
|
|
PetActionFallbackImage(
|
|
pet: pet,
|
|
stateName: lifecycle.rawValue,
|
|
assetWarning: assetWarning,
|
|
fallbackScale: 0.44
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.transaction { transaction in
|
|
transaction.animation = nil
|
|
}
|
|
.clipped()
|
|
.allowsHitTesting(false)
|
|
.onChange(of: identity) { _, nextIdentity in
|
|
contentState.select(nextIdentity)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct OnboardingPetMetalView: NSViewRepresentable {
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
let pet: PetSummary
|
|
let stateName: String
|
|
let onRendererContentChanged: @MainActor (Bool) -> Void
|
|
|
|
func makeCoordinator() -> PetMetalFrameRenderer {
|
|
PetMetalFrameRenderer()
|
|
}
|
|
|
|
@MainActor
|
|
func makeNSView(context: Context) -> MTKView {
|
|
context.coordinator.makeView()
|
|
}
|
|
|
|
@MainActor
|
|
func updateNSView(_ view: MTKView, context: Context) {
|
|
context.coordinator.configure(
|
|
view: view,
|
|
pet: pet,
|
|
stateName: stateName,
|
|
stateEntryID: "onboarding-local-demo:\(pet.id):\(stateName)",
|
|
active: true,
|
|
reduceMotion: reduceMotion,
|
|
onVisualEnvelopeChanged: { _ in },
|
|
onFrameContentChanged: onRendererContentChanged
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
static func dismantleNSView(
|
|
_ view: MTKView,
|
|
coordinator: PetMetalFrameRenderer
|
|
) {
|
|
coordinator.suspendPipeline()
|
|
view.isPaused = true
|
|
view.delegate = nil
|
|
}
|
|
}
|