Skip to content
Closed
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
175 changes: 175 additions & 0 deletions Convos/Contacts/ContactDetailView.swift

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

accessibilityLabel: "Share \(contactDisplayName)",

The variantRow displays selectedVariantLabel, but selectedAgentVariantId is only updated after a successful variant-change call and is never initialized from the contact's existing variantStamp on open. So when you first open a profile for an agent already on a non-default variant, the row shows "Default variant", and the picker sheet marks the default option as selected. The row and picker reflect the wrong variant until the user changes it. Initialize selectedAgentVariantId from variantStamp (e.g. in .onAppear or when the contact is set) so the existing variant is reflected on first display.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @Convos/Contacts/ContactDetailView.swift around line 1047:

The `variantRow` displays `selectedVariantLabel`, but `selectedAgentVariantId` is only updated after a successful variant-change call and is never initialized from the contact's existing `variantStamp` on open. So when you first open a profile for an agent already on a non-default variant, the row shows "Default variant", and the picker sheet marks the default option as selected. The row and picker reflect the wrong variant until the user changes it. Initialize `selectedAgentVariantId` from `variantStamp` (e.g. in `.onAppear` or when the contact is set) so the existing variant is reflected on first display.

Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ struct ContactDetailView: View {
@State private var presentingSendMessageError: Bool = false
@State private var sendMessageErrorMessage: String?
@State private var presentingNewConvo: NewConversationViewModel?
@State private var presentingAgentVariantPicker: Bool = false
/// Existing conversation pushed onto the host navigation stack when the
/// user taps a row in the "Convos with you" sections.
@State private var pushedConversation: NewConversationViewModel?
Expand All @@ -97,6 +98,10 @@ struct ContactDetailView: View {
/// The agent template's description, resolved on appear for template-backed
/// agents (it isn't stored on the contact). Rendered under the name.
@State private var agentDescription: String?
@State private var agentVariants: [ConvosAPI.AgentVariant] = []
@State private var selectedAgentVariantId: String?
@State private var isUpdatingAgentVariant: Bool = false
@State private var agentVariantErrorMessage: String?
@State private var navState: ContactCardNavigatorImpl = .init()
@State private var navigator: ContactCardCollector?

Expand Down Expand Up @@ -149,6 +154,7 @@ struct ContactDetailView: View {
.task(id: contact.inboxId) { await syncBlockedState() }
.task(id: contact.agentTemplateId) { await observeAgentTemplateConversations() }
.task(id: contact.agentTemplateId) { await loadAgentDescription() }
.task(id: contact.agentInstanceId) { await loadAgentVariants() }
.onAppear {
ensureNavigator()
navState.markScreenAppeared()
Expand Down Expand Up @@ -184,6 +190,23 @@ struct ContactDetailView: View {
)
.background(.colorBackgroundSurfaceless)
}
.sheet(isPresented: $presentingAgentVariantPicker) {
AgentVariantPickerSheet(
variants: agentVariants,
selectedVariantId: selectedAgentVariantId,
isUpdating: isUpdatingAgentVariant,
onSelect: updateAgentVariant(variantId:),
onClear: { updateAgentVariant(variantId: nil) }
)
.presentationDetents([.medium, .large])
}
.alert("Variant update failed", isPresented: agentVariantErrorBinding) {
Button("OK", role: .cancel) {
agentVariantErrorMessage = nil
}
} message: {
Text(agentVariantErrorMessage ?? "")
}
.navigationDestination(item: $pushedConversation) { vm in
pushedConversationView(vm)
}
Expand Down Expand Up @@ -238,6 +261,21 @@ struct ContactDetailView: View {
agentDescription = info?.descriptionText
}

private func loadAgentVariants() async {
guard ConfigManager.shared.currentEnvironment.isInternalBuild,
contact.agentInstanceId != nil,
let session else {
agentVariants = []
return
}
do {
agentVariants = try await session.listAgentVariants()
} catch {
Log.warning("Failed to load agent variants: \(error.localizedDescription)")
agentVariants = []
}
}

/// Agent "code card" share flow: a QR encoding the template's published
/// URL with the system share sheet behind it (mirrors the conversation
/// "Convos code"). Replaces the plain share sheet so the toolbar button
Expand Down Expand Up @@ -356,12 +394,16 @@ struct ContactDetailView: View {
contactDisplayName: contact.resolvedDisplayName,
agentEmail: contact.agentEmail,
agentInstanceId: contact.agentInstanceId,
showVariantPicker: showsAgentVariantPicker,
selectedVariantLabel: selectedAgentVariantLabel,
isUpdatingVariant: isUpdatingAgentVariant,
showsInstanceIdRow: showsInstanceIdRow,
agentAttestation: contact.agentAttestation,
agentVerification: contact.agentVerification,
agentTemplateConversations: agentTemplateConversations,
onSelectConversation: handleSelectAgentTemplateConversation,
onSendMessage: isAgentTemplate ? handleChatWithAgentTemplate : handleSendMessage,
onPickVariant: { presentingAgentVariantPicker = true },
onShare: { presentingAgentShareSheet = true },
onRemove: handleRemoveTap,
onToggleBlock: handleBlockTap
Expand Down Expand Up @@ -413,6 +455,31 @@ struct ContactDetailView: View {
ConfigManager.shared.currentEnvironment.isInternalBuild
}

private var showsAgentVariantPicker: Bool {
ConfigManager.shared.currentEnvironment.isInternalBuild
&& contact.agentInstanceId != nil
&& !agentVariants.isEmpty
}

private var selectedAgentVariantLabel: String {
guard let selectedAgentVariantId,
let variant = agentVariants.first(where: { $0.slug == selectedAgentVariantId }) else {
return "Default variant"
}
return variant.label
}

private var agentVariantErrorBinding: Binding<Bool> {
Binding(
get: { agentVariantErrorMessage != nil },
set: { isPresented in
if !isPresented {
agentVariantErrorMessage = nil
}
}
)
}

/// Pill rendered below the subtitle. "You" for the current user's
/// own card, the agent role label for verified agents, otherwise
/// nothing. The top padding is inside the builder so it doesn't
Expand Down Expand Up @@ -571,6 +638,27 @@ struct ContactDetailView: View {
onRemove?()
}

private func updateAgentVariant(variantId: String?) {
guard let session,
let instanceId = contact.agentInstanceId,
!isUpdatingAgentVariant else { return }
Task { @MainActor in
isUpdatingAgentVariant = true
defer { isUpdatingAgentVariant = false }
do {
let response = try await session.updateAgentVariant(
instanceId: instanceId,
variantId: variantId
)
selectedAgentVariantId = response.variantId
presentingAgentVariantPicker = false
} catch {
Log.error("Failed to update agent variant \(instanceId): \(error.localizedDescription)")
agentVariantErrorMessage = "We couldn't update this agent's variant. Please try again."
}
}
}

private func applyBlockChange(block: Bool) {
guard !isApplyingBlockChange else { return }
isApplyingBlockChange = true
Expand Down Expand Up @@ -767,6 +855,74 @@ private struct ContactDetailSubtitle: View {

// MARK: - Action stack

private struct AgentVariantPickerSheet: View {
let variants: [ConvosAPI.AgentVariant]
let selectedVariantId: String?
let isUpdating: Bool
let onSelect: (String) -> Void
let onClear: () -> Void

@Environment(\.dismiss) private var dismiss: DismissAction

var body: some View {
NavigationStack {
List {
Button(action: onClear) {
variantRow(
title: "Default variant",
subtitle: "Use the standard agent profile",
isSelected: selectedVariantId == nil
)
}
.disabled(isUpdating)

ForEach(variants) { variant in
Button(action: { onSelect(variant.slug) }) {
variantRow(
title: variant.label,
subtitle: variant.whatToTest,
isSelected: selectedVariantId == variant.slug
)
}
.disabled(isUpdating)
}
}
.navigationTitle("Variant")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") {
dismiss()
}
}
}
}
}

private func variantRow(title: String, subtitle: String, isSelected: Bool) -> some View {
HStack(spacing: DesignConstants.Spacing.step3x) {
VStack(alignment: .leading, spacing: DesignConstants.Spacing.step1x) {
Text(title)
.font(.body.weight(.medium))
.foregroundStyle(.colorTextPrimary)
Text(subtitle)
.font(.footnote)
.foregroundStyle(.colorTextSecondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: DesignConstants.Spacing.step2x)
if isUpdating && isSelected {
ProgressView()
} else if isSelected {
Image(systemName: "checkmark")
.font(.body.weight(.semibold))
.foregroundStyle(.colorTextPrimary)
}
}
.contentShape(Rectangle())
}
}

/// Renders the "Convos with you" sections (template-backed agents only),
/// then the chat CTA ("New chat" for agents, "Chat" for human members),
/// Share, Remove, and Block - in that order. The chat CTA is the
Expand All @@ -793,6 +949,9 @@ private struct ContactDetailActions: View {
/// Always plumbed through when the contact has one. Display gate
/// is `showsInstanceIdRow`, not nullability of this field.
let agentInstanceId: String?
let showVariantPicker: Bool
let selectedVariantLabel: String
let isUpdatingVariant: Bool
/// True on Dev/Local builds. Hides the row on production.
let showsInstanceIdRow: Bool
/// Agent's published attestation signature (`nil` when it joined without
Expand All @@ -808,6 +967,7 @@ private struct ContactDetailActions: View {
/// Called with the conversation when a "Convos with you" row is tapped.
let onSelectConversation: (Conversation) -> Void
let onSendMessage: () -> Void
let onPickVariant: () -> Void
let onShare: () -> Void
let onRemove: () -> Void
let onToggleBlock: () -> Void
Expand All @@ -823,6 +983,9 @@ private struct ContactDetailActions: View {
if showChat {
chatButton
}
if showVariantPicker {
variantRow
}
if showShare {
shareRow
}
Expand Down Expand Up @@ -887,6 +1050,18 @@ private struct ContactDetailActions: View {
)
}

private var variantRow: some View {
ContactDetailActionRow(
label: "Variant",
footer: selectedVariantLabel,
color: .colorTextPrimary,
isDisabled: isUpdatingVariant,
accessibilityLabel: "Change agent variant",
accessibilityIdentifier: "contact-detail-agent-variant",
action: onPickVariant
)
}

private var removeRow: some View {
ContactDetailActionRow(
label: "Remove",
Expand Down
35 changes: 35 additions & 0 deletions ConvosCore/Sources/ConvosCore/API/ConvosAPIClient+Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,41 @@ public enum ConvosAPI {
public static let agentBuilder: AgentJoinOptions = AgentJoinOptions(onboarding: "agent-builder")
}

public struct AgentVariantUpdateRequest: Encodable, Sendable {
public let variantId: String?

public init(variantId: String?) {
self.variantId = variantId
}

public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
if let variantId {
try container.encode(variantId, forKey: .variantId)
} else {
try container.encodeNil(forKey: .variantId)
}
}

private enum CodingKeys: String, CodingKey {
case variantId
}
}

public struct AgentVariantUpdateResponse: Codable, Sendable {
public let success: Bool
public let instanceId: String
public let variantId: String?
public let applied: String

public init(success: Bool, instanceId: String, variantId: String?, applied: String) {
self.success = success
self.instanceId = instanceId
self.variantId = variantId
self.applied = applied
}
}

public struct AgentJoinResponse: Codable {
public let success: Bool
public let joined: Bool
Expand Down
10 changes: 10 additions & 0 deletions ConvosCore/Sources/ConvosCore/API/ConvosAPIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ public protocol ConvosAPIClientProtocol: AnyObject, Sendable {
/// the poll hit the default worker, which has no record of the instance.
func getAgentJoinStatus(instanceId: String, variantId: String?) async throws -> ConvosAPI.AgentJoinStatusResponse

func updateAgentVariant(instanceId: String, variantId: String?) async throws -> ConvosAPI.AgentVariantUpdateResponse

// Agent templates
/// Public detail fetch for a published agent template, keyed by its
/// template id (UUID) or hashed url slug (e.g. `gandalf.felpl`). Backs the
Expand Down Expand Up @@ -944,6 +946,14 @@ final class ConvosAPIClient: ConvosAPIClientProtocol, Sendable {
return try await performRequest(request)
}

func updateAgentVariant(instanceId: String, variantId: String?) async throws -> ConvosAPI.AgentVariantUpdateResponse {
let encoded = instanceId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? instanceId
var request = try authenticatedRequest(for: "v2/agents/\(encoded)/variant", method: "PATCH")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(ConvosAPI.AgentVariantUpdateRequest(variantId: variantId))
return try await performRequest(request)
}

// MARK: - Agent templates

func getAgentTemplate(idOrUrlSlug: String) async throws -> ConvosAPI.AgentTemplate {
Expand Down
9 changes: 9 additions & 0 deletions ConvosCore/Sources/ConvosCore/API/MockAPIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,15 @@ final class MockAPIClient: ConvosAPIClientProtocol, Sendable {
)
}

func updateAgentVariant(instanceId: String, variantId: String?) async throws -> ConvosAPI.AgentVariantUpdateResponse {
.init(
success: true,
instanceId: instanceId,
variantId: variantId,
applied: "profile_metadata"
)
}

func getAgentTemplate(idOrUrlSlug: String) async throws -> ConvosAPI.AgentTemplate {
.init(
id: UUID().uuidString,
Expand Down
10 changes: 10 additions & 0 deletions ConvosCore/Sources/ConvosCore/Sessions/SessionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1472,6 +1472,16 @@ extension SessionManager {

// MARK: - Agent-template repository factory

extension SessionManager {
public func listAgentVariants() async throws -> [ConvosAPI.AgentVariant] {
try await apiClient.getAgentVariants()
}

public func updateAgentVariant(instanceId: String, variantId: String?) async throws -> ConvosAPI.AgentVariantUpdateResponse {
try await apiClient.updateAgentVariant(instanceId: instanceId, variantId: variantId)
}
}

extension SessionManager {
public func agentTemplateRepository() -> any AgentTemplateRepositoryProtocol {
agentTemplateRepositoryInstance
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ public protocol SessionManagerProtocol: AnyObject, Sendable {
forceErrorCode: Int?
) async throws -> ConvosAPI.AgentJoinResponse

func listAgentVariants() async throws -> [ConvosAPI.AgentVariant]
func updateAgentVariant(instanceId: String, variantId: String?) async throws -> ConvosAPI.AgentVariantUpdateResponse

/// Opportunistic foreground republish of the user's timezone across every
/// agent conversation (agent-timezone Channel B refresh). Throttled so a
/// conversation is only republished when the device timezone changed since
Expand Down Expand Up @@ -307,4 +310,17 @@ extension SessionManagerProtocol {
) async throws -> ConvosAPI.AgentJoinResponse {
try await addAgentToConversation(conversationId: conversationId, templateId: templateId, options: nil, forceErrorCode: nil)
}

public func listAgentVariants() async throws -> [ConvosAPI.AgentVariant] {
[]
}

public func updateAgentVariant(instanceId: String, variantId: String?) async throws -> ConvosAPI.AgentVariantUpdateResponse {
ConvosAPI.AgentVariantUpdateResponse(
success: true,
instanceId: instanceId,
variantId: variantId,
applied: "profile_metadata"
)
}
}
Loading
Loading