diff --git a/docs-ai/053-agent-profiles/000-plan.md b/docs-ai/053-agent-profiles/000-plan.md index 0aabd9b2..3893be0a 100644 --- a/docs-ai/053-agent-profiles/000-plan.md +++ b/docs-ai/053-agent-profiles/000-plan.md @@ -330,6 +330,9 @@ agent 列表不变。 ## Amendments +- Updated 2026-07-31: profile launcher 图标的持久化、fallback 与 surface 范围决策 — see + [003-profile-icons.md](003-profile-icons.md). + - 2026-07-30 — Review 四轮(P2):palette 的 Launch Agent 条目工厂改用与 launch action 相同的 action-target 解析(selectedTerminalWorktree ?? Canvas 聚焦卡), Canvas 模式下条目不再缺席;补 Canvas 聚焦卡回归测试。 diff --git a/docs-ai/053-agent-profiles/003-profile-icons.md b/docs-ai/053-agent-profiles/003-profile-icons.md new file mode 100644 index 00000000..a91516d9 --- /dev/null +++ b/docs-ai/053-agent-profiles/003-profile-icons.md @@ -0,0 +1,66 @@ +# 053.003 — Agent Profile 图标 + +| | | +| --- | --- | +| **状态** | Implemented | +| **日期** | 2026-07-31 | +| **相关** | [000-plan](000-plan.md) · [001-action](001-action.md) · `docs/components/agent-profiles.md` | + +## Context + +Agent Profile 是用户在 Settings、工具栏 Agents popover 与 Command Palette 中选择的 +命名 preset。现有行只显示通用启动图标,无法在多个同 runtime profile 之间建立视觉 +识别;但 profile 未设置图标时不应丢失 Claude Code / Codex 的品牌识别。 + +## Scope + +- 为 `AgentProfile` 增加可选 SF Symbol override;旧 JSON 解码为 `nil`。 +- Settings → Agents 的编辑器提供与 repository appearance 同样的预览 tile 与图标菜单, + 复用 `TabIconPickerView` 的 preset / 任意 SF Symbol 输入能力。 +- 在 Settings profile 列表、toolbar Agents popover 和 Command Palette 的 profile launch + 条目显示 resolved profile icon。 +- 未设置 override 时,以上 surface 使用 runtime 的 `CommandIconMap` 品牌 asset,找不到 + asset 时回退其 SF Symbol。 + +## Design decisions + +1. 持久化值为可选 SF Symbol 名称,而不是 `RepositoryIconSource`。Profile 是全局设置, + 没有 repository-root 作为用户图片生命周期与导入目录的自然所有者;支持图片会增加 + 资产存储与清理契约,却没有本功能需要。 +2. 图标属于 profile launcher 的身份,不改变已运行 terminal pane 或 Active Agents 的 + 进程识别 icon。后两者表示实际检测到的 CLI,不应因 profile 后续编辑或删除而改变。 +3. `nil` 不是通用 placeholder,而是 runtime 品牌 icon。清除 override 立即恢复 Claude + Code / Codex 的既有品牌识别。 + +## Implementation shape + +| Concern | Planned owner | +| --- | --- | +| Persistence / migration | `supacode/Domain/AgentProfile/AgentProfile.swift` | +| Icon resolution and rendering | shared profile-icon view beside Settings / launcher surfaces | +| Picker state and mutation | `AgentProfileEditorFeature` + `AgentProfileEditorView` | +| Settings list | `AgentProfilesSettingsView` | +| Toolbar launcher | `AgentsToolbarButton` + `WorktreeDetailView` | +| Command Palette launcher | `CommandPaletteItem` + `CommandPaletteOverlayView` | + +## Result + +- `AgentProfile.icon` persists an optional SF Symbol; legacy records decode it as `nil`. +- `AgentProfileIconResolver` maps an override to an SF Symbol and `nil` to the existing + `CommandIconMap` runtime brand asset with a `sparkles` safety fallback. +- Settings now has the repository-style icon preview menu and `TabIconPickerView`; the + profile list and repository Default Agent Profile picker render the resolved icon. +- Toolbar Agents launch rows and Command Palette launch rows carry the same resolved + profile source. Live panes and Active Agents retain detected process-brand icons. + +## Verification + +- Legacy decode keeps `icon == nil`; edited profile persists an override. +- Reducer test covers setting and clearing the override. +- Launcher and palette factories carry the resolved custom icon and runtime fallback. +- Build the app, run changed-file checks, then manually inspect Settings and the Agents popover. + +## Non-goals + +- Per-profile colors, imported bitmap/SVG assets, or agent-specific image storage. +- Replacing live terminal / Active Agents process-brand icons. diff --git a/docs/components/agent-profiles.md b/docs/components/agent-profiles.md index fac24026..31714c61 100644 --- a/docs/components/agent-profiles.md +++ b/docs/components/agent-profiles.md @@ -11,11 +11,12 @@ ## What a profile is -A profile is a named preset for one runtime: display name, optional model and -reasoning effort (both accept free text with per-runtime suggestions), execution -mode (Standard / Unrestricted), and a launch placement (New Tab or New Split -with a direction). By default a profile is **argv-only**: launching it is -exactly like typing `claude`/`codex` with those flags yourself — same login, +A profile is a named preset for one runtime: display name, optional custom SF +Symbol icon, optional model and reasoning effort (both accept free text with +per-runtime suggestions), execution mode (Standard / Unrestricted), and a +launch placement (New Tab or New Split with a direction). By default a profile +is **argv-only**: launching it is exactly like typing `claude`/`codex` with +those flags yourself — same login, same skills, same session history. The same runtime can have any number of profiles. @@ -46,6 +47,12 @@ sidebar remains available. Adding a profile opens the same editor immediately. Changing another Settings sidebar section leaves the editor and opens that section's root. +The editor's **Icon** preview opens an SF Symbol picker. A custom symbol appears +where Prowl presents the launch preset: the Settings list, repository Default +Agent Profile picker, toolbar Agents popover, and Command Palette. Clearing it +restores the runtime's Claude Code / Codex brand icon. Live panes and Active +Agents retain the icon of the process Prowl actually detects. + Changing a profile's **Agent** resets its Model, Reasoning Effort, and Extra Arguments to the new runtime defaults. Those values are runtime-specific; add new values after choosing the destination agent. diff --git a/supacode/Domain/AgentProfile/AgentProfile.swift b/supacode/Domain/AgentProfile/AgentProfile.swift index c33e3240..adf2a0bc 100644 --- a/supacode/Domain/AgentProfile/AgentProfile.swift +++ b/supacode/Domain/AgentProfile/AgentProfile.swift @@ -42,6 +42,8 @@ nonisolated struct AgentProfile: Codable, Equatable, Sendable, Identifiable { var name: String var isEnabled: Bool var runtime: AgentProfileRuntime + /// User-selected SF Symbol override; nil uses the runtime brand icon. + var icon: String? var model: String? var reasoningEffort: String? var executionMode: AgentExecutionMode @@ -55,6 +57,7 @@ nonisolated struct AgentProfile: Codable, Equatable, Sendable, Identifiable { name: String, isEnabled: Bool = true, runtime: AgentProfileRuntime, + icon: String? = nil, model: String? = nil, reasoningEffort: String? = nil, executionMode: AgentExecutionMode = .standard, @@ -67,6 +70,7 @@ nonisolated struct AgentProfile: Codable, Equatable, Sendable, Identifiable { self.name = name self.isEnabled = isEnabled self.runtime = runtime + self.icon = icon self.model = model self.reasoningEffort = reasoningEffort self.executionMode = executionMode @@ -77,7 +81,7 @@ nonisolated struct AgentProfile: Codable, Equatable, Sendable, Identifiable { } private enum CodingKeys: String, CodingKey { - case id, name, isEnabled, runtime, model, reasoningEffort, executionMode + case id, name, isEnabled, runtime, icon, model, reasoningEffort, executionMode case placement, splitDirection, extraArguments, bindsDedicatedHome } @@ -87,6 +91,7 @@ nonisolated struct AgentProfile: Codable, Equatable, Sendable, Identifiable { name = try container.decode(String.self, forKey: .name) isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true runtime = try container.decode(AgentProfileRuntime.self, forKey: .runtime) + icon = try container.decodeIfPresent(String.self, forKey: .icon) model = try container.decodeIfPresent(String.self, forKey: .model) reasoningEffort = try container.decodeIfPresent(String.self, forKey: .reasoningEffort) executionMode = diff --git a/supacode/Features/CommandPalette/CommandPaletteItem.swift b/supacode/Features/CommandPalette/CommandPaletteItem.swift index cbbf3775..0dcf5d70 100644 --- a/supacode/Features/CommandPalette/CommandPaletteItem.swift +++ b/supacode/Features/CommandPalette/CommandPaletteItem.swift @@ -21,6 +21,7 @@ struct CommandPaletteItem: Identifiable, Equatable { let category: Category let keywords: [String] let defaultSuggestion: Bool + let agentProfileIconSource: AgentProfileIconSource? init( id: String, @@ -30,7 +31,8 @@ struct CommandPaletteItem: Identifiable, Equatable { category: Category, defaultSuggestion: Bool, keywords: [String] = [], - priorityTier: Int = defaultPriorityTier + priorityTier: Int = defaultPriorityTier, + agentProfileIconSource: AgentProfileIconSource? = nil ) { self.id = id self.title = title @@ -40,6 +42,7 @@ struct CommandPaletteItem: Identifiable, Equatable { self.defaultSuggestion = defaultSuggestion self.keywords = keywords self.priorityTier = priorityTier + self.agentProfileIconSource = agentProfileIconSource } enum Kind: Equatable { diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 85c8de78..8f5bbce5 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -575,7 +575,8 @@ func agentProfileLaunchItems( kind: .launchAgentProfile(profile.id), category: .terminal, defaultSuggestion: false, - keywords: ["launch", "agent", "profile", "start", profile.name] + keywords: ["launch", "agent", "profile", "start", profile.name], + agentProfileIconSource: profile.iconSource ) } } diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index 8d47580a..b500e13f 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -664,7 +664,11 @@ private struct CommandPaletteRowView: View { var body: some View { Button(action: activate) { HStack(spacing: 8) { - if let leadingIcon { + if let iconSource = row.agentProfileIconSource { + AgentProfileIconImage(source: iconSource, pointSize: 15) + .foregroundStyle(emphasis ? .primary : .secondary) + .frame(width: 16, height: 16, alignment: .center) + } else if let leadingIcon { Image(systemName: leadingIcon) .foregroundStyle(emphasis ? .primary : .secondary) .font(.subheadline.weight(.medium)) diff --git a/supacode/Features/Repositories/Views/AgentsToolbarButton.swift b/supacode/Features/Repositories/Views/AgentsToolbarButton.swift index aef41ea0..d74e2c47 100644 --- a/supacode/Features/Repositories/Views/AgentsToolbarButton.swift +++ b/supacode/Features/Repositories/Views/AgentsToolbarButton.swift @@ -19,6 +19,7 @@ struct AgentsLauncherItem: Equatable, Identifiable { let id: AgentProfile.ID let name: String let runtimeName: String + let iconSource: AgentProfileIconSource let isRecommended: Bool /// Why the row is disabled ("Claude Code is not installed"); nil = launchable. let unavailableReason: String? @@ -163,6 +164,7 @@ private struct AgentsPopoverContent: View { subtitle: item.unavailableReason ?? "New agent in this worktree · \(item.runtimeName)", systemImage: "play.circle", + iconSource: item.iconSource, isEnabled: item.unavailableReason == nil, action: { onLaunchProfile(item.id) } ) @@ -184,6 +186,7 @@ private struct AgentsPopoverRow: View { let title: String let subtitle: String let systemImage: String + var iconSource: AgentProfileIconSource? var isEnabled: Bool = true let action: () -> Void @State private var isHovered = false @@ -191,10 +194,14 @@ private struct AgentsPopoverRow: View { var body: some View { Button(action: action) { HStack(alignment: .top, spacing: 8) { - Image(systemName: systemImage) - .frame(width: 16) - .padding(.top, 2) - .accessibilityHidden(true) + if let iconSource { + AgentProfileIconImage(source: iconSource, pointSize: 16) + .frame(width: 16) + } else { + Image(systemName: systemImage) + .frame(width: 16) + .accessibilityHidden(true) + } VStack(alignment: .leading, spacing: 2) { Text(title) Text(subtitle) diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 4a6dd9d2..d2a69f77 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -443,6 +443,7 @@ struct WorktreeDetailView: View { id: profile.id, name: profile.name, runtimeName: runtimeName, + iconSource: profile.iconSource, isRecommended: profile.id == recommendedID, unavailableReason: installed ? nil : "\(runtimeName) is not installed" ) diff --git a/supacode/Features/Settings/Reducer/AgentProfileEditorFeature.swift b/supacode/Features/Settings/Reducer/AgentProfileEditorFeature.swift index cb858235..afed38c8 100644 --- a/supacode/Features/Settings/Reducer/AgentProfileEditorFeature.swift +++ b/supacode/Features/Settings/Reducer/AgentProfileEditorFeature.swift @@ -26,6 +26,7 @@ struct AgentProfileEditorFeature { enum Action: BindableAction { case task case runtimeChanged(AgentProfileRuntime) + case setIcon(String?) case binding(BindingAction) case removeTapped case revealProfileFiles @@ -69,6 +70,10 @@ struct AgentProfileEditorFeature { refreshHomeStatus(&state) return .send(.delegate(.profileEdited(state.profile))) + case .setIcon(let icon): + guard state.profile.icon != icon else { return .none } + state.profile.icon = icon + return .send(.delegate(.profileEdited(state.profile))) case .binding: // `.unrestricted` is never applied silently: the change reverts until // the user explicitly confirms it (docs-ai 053). diff --git a/supacode/Features/Settings/Views/AgentProfileEditorView.swift b/supacode/Features/Settings/Views/AgentProfileEditorView.swift index 8f838152..74811489 100644 --- a/supacode/Features/Settings/Views/AgentProfileEditorView.swift +++ b/supacode/Features/Settings/Views/AgentProfileEditorView.swift @@ -5,6 +5,8 @@ import SwiftUI /// owns the alert presentation because its state lives with this destination. struct AgentProfileEditorView: View { @Bindable var store: StoreOf + @State private var isIconPickerPresented = false + @State private var isHoveringIconTile = false var body: some View { Form { @@ -17,6 +19,19 @@ struct AgentProfileEditorView: View { .navigationTitle(store.profile.name) .task { store.send(.task) } .alert($store.scope(state: \.alert, action: \.alert)) + .sheet(isPresented: $isIconPickerPresented) { + TabIconPickerView( + initialIcon: store.profile.icon, + defaultIcon: "sparkles", + title: "Agent Icon", + subtitle: "Pick a preset or enter any SF Symbol name. Clearing restores the runtime brand icon.", + onApply: { icon in + store.send(.setIcon(icon)) + isIconPickerPresented = false + }, + onCancel: { isIconPickerPresented = false } + ) + } } private var profileSection: some View { @@ -33,6 +48,7 @@ struct AgentProfileEditorView: View { Text(AgentRuntimeAdapterRegistry.displayName(for: runtime.agent)).tag(runtime) } } + iconRow suggestedTextRow( title: "Model", prompt: "Runtime default", @@ -135,6 +151,59 @@ struct AgentProfileEditorView: View { } } + private var iconRow: some View { + HStack(alignment: .center, spacing: 12) { + iconMenu + VStack(alignment: .leading, spacing: 4) { + Text("Icon") + .font(.headline) + Text( + store.profile.icon == nil + ? "\(AgentRuntimeAdapterRegistry.displayName(for: store.profile.runtime.agent)) brand icon" + : "Custom SF Symbol" + ) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + } + + private var iconMenu: some View { + Menu { + Button("Choose Symbol…") { + isIconPickerPresented = true + } + if store.profile.icon != nil { + Divider() + Button("Clear Icon", role: .destructive) { + store.send(.setIcon(nil)) + } + } + } label: { + iconPreviewTile + } + .buttonStyle(.plain) + .menuIndicator(.hidden) + .fixedSize() + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color.accentColor.opacity(isHoveringIconTile ? 0.65 : 0), lineWidth: 1.5) + } + .onHover { isHoveringIconTile = $0 } + .pointerStyle(.link) + .animation(.easeOut(duration: 0.12), value: isHoveringIconTile) + .help("Click the icon preview to choose an SF Symbol") + } + + private var iconPreviewTile: some View { + AgentProfileIconImage(source: store.profile.iconSource, pointSize: 22) + .frame(width: 40, height: 40) + .background(Color.secondary.opacity(0.12), in: .rect(cornerRadius: 8)) + .contentShape(.rect(cornerRadius: 8)) + .accessibilityLabel("Agent icon picker") + } + private func optionalTextRow( title: String, prompt: String, diff --git a/supacode/Features/Settings/Views/AgentProfileIconImage.swift b/supacode/Features/Settings/Views/AgentProfileIconImage.swift new file mode 100644 index 00000000..70d03097 --- /dev/null +++ b/supacode/Features/Settings/Views/AgentProfileIconImage.swift @@ -0,0 +1,43 @@ +import SwiftUI + +/// The persisted override and its runtime fallback. Keeping this value separate +/// from `TabIconSource` leaves `AgentProfile` free of terminal-view concerns. +struct AgentProfileIconSource: Equatable, Hashable, Sendable { + let overrideSymbol: String? + let runtime: AgentProfileRuntime + + init(profile: AgentProfile) { + overrideSymbol = profile.icon + runtime = profile.runtime + } +} + +extension AgentProfile { + var iconSource: AgentProfileIconSource { + AgentProfileIconSource(profile: self) + } +} +enum AgentProfileIconResolver { + static func source(for iconSource: AgentProfileIconSource) -> TabIconSource { + if let overrideSymbol = iconSource.overrideSymbol?.trimmingCharacters(in: .whitespacesAndNewlines), + !overrideSymbol.isEmpty + { + return TabIconSource(systemSymbol: overrideSymbol) + } + + return CommandIconMap.iconForFirstToken(iconSource.runtime.agent.iconLookupToken) + ?? TabIconSource(systemSymbol: "sparkles") + } +} + +struct AgentProfileIconImage: View { + let source: AgentProfileIconSource + let pointSize: CGFloat + + var body: some View { + TabIconImage( + rawName: AgentProfileIconResolver.source(for: source).storageString, + pointSize: pointSize + ) + } +} diff --git a/supacode/Features/Settings/Views/AgentProfilesSettingsView.swift b/supacode/Features/Settings/Views/AgentProfilesSettingsView.swift index a77c9ca5..c5c3325c 100644 --- a/supacode/Features/Settings/Views/AgentProfilesSettingsView.swift +++ b/supacode/Features/Settings/Views/AgentProfilesSettingsView.swift @@ -80,6 +80,8 @@ struct AgentProfilesSettingsView: View { private func profileLabel(_ profile: AgentProfile) -> some View { HStack(spacing: 8) { + AgentProfileIconImage(source: profile.iconSource, pointSize: 16) + .frame(width: 16, height: 16) Text(profile.name) if profile.bindsDedicatedHome { Image(systemName: "person.crop.circle.badge.checkmark") diff --git a/supacode/Features/Settings/Views/RepositorySettingsView.swift b/supacode/Features/Settings/Views/RepositorySettingsView.swift index eec6b735..5c6fd1b0 100644 --- a/supacode/Features/Settings/Views/RepositorySettingsView.swift +++ b/supacode/Features/Settings/Views/RepositorySettingsView.swift @@ -266,7 +266,13 @@ struct RepositorySettingsView: View { ) { Text("None").tag(AgentProfile.ID?.none) ForEach(globalSettings.agentProfiles.filter(\.isEnabled)) { profile in - Text(profile.name).tag(AgentProfile.ID?.some(profile.id)) + Label { + Text(profile.name) + } icon: { + AgentProfileIconImage(source: profile.iconSource, pointSize: 14) + .frame(width: 14, height: 14) + } + .tag(AgentProfile.ID?.some(profile.id)) } } } header: { diff --git a/supacodeTests/AgentProfileEditorFeatureTests.swift b/supacodeTests/AgentProfileEditorFeatureTests.swift index d44bfd54..356de61c 100644 --- a/supacodeTests/AgentProfileEditorFeatureTests.swift +++ b/supacodeTests/AgentProfileEditorFeatureTests.swift @@ -40,6 +40,23 @@ struct AgentProfileEditorFeatureTests { await store.receive(\.delegate.profileEdited) } + @Test(.dependencies) func settingProfileIconDelegatesTheEditedProfile() async { + let profile = AgentProfile(name: "Codex", runtime: .codex) + let store = TestStore(initialState: AgentProfileEditorFeature.State(profile: profile)) { + AgentProfileEditorFeature() + } + + await store.send(.setIcon("wand.and.stars")) { + $0.profile.icon = "wand.and.stars" + } + await store.receive(\.delegate.profileEdited) + + await store.send(.setIcon(nil)) { + $0.profile.icon = nil + } + await store.receive(\.delegate.profileEdited) + } + @Test(.dependencies) func unrestrictedRequiresExplicitConfirmation() async { let profile = AgentProfile(name: "Codex", runtime: .codex) let storage = SettingsTestStorage() diff --git a/supacodeTests/AgentProfileTests.swift b/supacodeTests/AgentProfileTests.swift index 1ef37e77..9fd6be13 100644 --- a/supacodeTests/AgentProfileTests.swift +++ b/supacodeTests/AgentProfileTests.swift @@ -42,6 +42,7 @@ struct AgentProfileTests { #expect(decoded.isEnabled) #expect(decoded.model == nil) #expect(decoded.reasoningEffort == nil) + #expect(decoded.icon == nil) #expect(decoded.executionMode == .standard) #expect(decoded.placement == .tab) #expect(decoded.splitDirection == .right) @@ -49,6 +50,18 @@ struct AgentProfileTests { #expect(!decoded.bindsDedicatedHome) } + @Test func profileIconUsesCustomSymbolThenRuntimeBrandFallback() throws { + let custom = AgentProfile(name: "Codex · Work", runtime: .codex, icon: "wand.and.stars") + #expect( + AgentProfileIconResolver.source(for: custom.iconSource) + == TabIconSource(systemSymbol: "wand.and.stars") + ) + + let fallback = AgentProfile(name: "Claude Code", runtime: .claude) + let expected = try #require(CommandIconMap.iconForFirstToken(fallback.runtime.agent.iconLookupToken)) + #expect(AgentProfileIconResolver.source(for: fallback.iconSource) == expected) + } + // MARK: - Recommendation @Test func recommendationPrefersDesignationOverMemoryAndOrder() { diff --git a/supacodeTests/AppFeatureAgentProfileTests.swift b/supacodeTests/AppFeatureAgentProfileTests.swift index 1a154ea3..c18ae863 100644 --- a/supacodeTests/AppFeatureAgentProfileTests.swift +++ b/supacodeTests/AppFeatureAgentProfileTests.swift @@ -108,7 +108,7 @@ struct AppFeatureAgentProfileTests { $0.repositoryLocalSettingsStorage = localStorage.storage } operation: { let first = AgentProfile(name: "First", runtime: .codex) - let second = AgentProfile(name: "Second", runtime: .claude) + let second = AgentProfile(name: "Second", runtime: .claude, icon: "wand.and.stars") let disabled = AgentProfile(name: "Hidden", isEnabled: false, runtime: .claude) @Shared(.userGlobalSettings) var settings $settings.withLock { $0.agentProfiles = [first, second, disabled] } @@ -121,6 +121,8 @@ struct AppFeatureAgentProfileTests { #expect(items.first?.kind == .launchAgentProfile(second.id)) #expect(items.first?.subtitle?.hasPrefix("Recommended") == true) #expect(items.last?.subtitle?.hasPrefix("Recommended") == false) + #expect(items.first?.agentProfileIconSource == second.iconSource) + #expect(items.last?.agentProfileIconSource == first.iconSource) } }