From 6d79fd98c1caa5d79b6b6d8a0f84722696e416d3 Mon Sep 17 00:00:00 2001
From: Arturo Carretero Calvo <10163049+ArtCC@users.noreply.github.com>
Date: Tue, 15 Sep 2026 20:26:20 +0200
Subject: [PATCH 1/7] Update app versioning and startup behaviors
- Bump marketing version to 1.7.10 in README and Xcode project settings
- Add iCloud key-value store entitlements to macOS and iOS targets
- Register the `openclient` URL scheme and BG task identifiers in Info.plist
- Refactor macOS chat commands to use the focused conversation list view model
- Prevent duplicate initial model loads in `ModelsViewModel` and reset after app data changes
- Update tests for audio playback injection, repeated model load handling, and reset fencing
- Mark the image attachment mock as `nonisolated` for concurrent test execution
- Remove the weak capture in the share extension app launch task
- Add Xcode Cloud shared manifest for the main app target
---
README.md | 2 +-
ShareExtension/App/ShareViewController.swift | 4 +--
.../openclient-llm-macOS.entitlements | 2 ++
openclient-llm-macOS/Views/AppCommands.swift | 14 ++++----
.../Chat/ChatViewModelTests+TTS.swift | 18 ++++++----
.../Features/Chat/ChatViewModelTests.swift | 4 +++
.../Launch/ResetAppDataUseCaseTests.swift | 8 ++---
.../Models/ModelsViewModelTests.swift | 14 ++++++++
.../MockPrepareImageAttachmentUseCase.swift | 4 +--
openclient-llm.xcodeproj/project.pbxproj | 24 ++++++-------
.../xcshareddata/xcodecloud/manifest.json | 9 +++++
openclient-llm/Resources/Info.plist | 34 ++++++++-----------
.../Resources/openclient-llm.entitlements | 2 ++
.../Managers/CloudContainerProvider.swift | 2 +-
.../Chat/Views/ConversationListView.swift | 7 +---
.../Models/ViewModels/ModelsViewModel.swift | 8 ++++-
16 files changed, 93 insertions(+), 63 deletions(-)
create mode 100644 openclient-llm.xcodeproj/xcshareddata/xcodecloud/manifest.json
diff --git a/README.md b/README.md
index 922f199e..dae78fad 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
-
+
OpenClient connects directly to the AI server you configure, without an OpenClient-hosted proxy or subscription.
diff --git a/ShareExtension/App/ShareViewController.swift b/ShareExtension/App/ShareViewController.swift
index 6a70485c..5d7678e1 100644
--- a/ShareExtension/App/ShareViewController.swift
+++ b/ShareExtension/App/ShareViewController.swift
@@ -27,8 +27,8 @@ final class ShareViewController: SLComposeServiceViewController {
try? ShareExtensionStore.save(item)
context?.completeRequest(returningItems: []) { _ in
guard let appURL = URL(string: "openclient://share") else { return }
- Task { @MainActor [weak self] in
- self?.openContainingApp(url: appURL)
+ Task { @MainActor in
+ self.openContainingApp(url: appURL)
}
}
}
diff --git a/openclient-llm-macOS/Resources/openclient-llm-macOS.entitlements b/openclient-llm-macOS/Resources/openclient-llm-macOS.entitlements
index 8e3d3507..0b2d329a 100644
--- a/openclient-llm-macOS/Resources/openclient-llm-macOS.entitlements
+++ b/openclient-llm-macOS/Resources/openclient-llm-macOS.entitlements
@@ -16,6 +16,8 @@
iCloud.com.artcc.openclient-llm
+ com.apple.developer.ubiquity-kvstore-identifier
+ $(TeamIdentifierPrefix)$(CFBundleIdentifier)
com.apple.security.application-groups
group.com.artcc.openclient-llm
diff --git a/openclient-llm-macOS/Views/AppCommands.swift b/openclient-llm-macOS/Views/AppCommands.swift
index 71eedf77..27fcc8bd 100644
--- a/openclient-llm-macOS/Views/AppCommands.swift
+++ b/openclient-llm-macOS/Views/AppCommands.swift
@@ -11,24 +11,23 @@ import SwiftUI
struct AppCommands: Commands {
// MARK: - Properties
- @FocusedValue(\.newChatAction) private var newChatAction
- @FocusedValue(\.newPrivateChatAction) private var newPrivateChatAction
+ @FocusedValue(\.conversationListViewModel) private var conversationListViewModel
// MARK: - View
var body: some Commands {
CommandGroup(replacing: .newItem) {
Button(String(localized: "New Chat")) {
- newChatAction?()
+ conversationListViewModel?.send(.newConversationTapped)
}
.keyboardShortcut("n", modifiers: .command)
- .disabled(newChatAction == nil)
+ .disabled(conversationListViewModel == nil)
Button(String(localized: "New Private Chat")) {
- newPrivateChatAction?()
+ conversationListViewModel?.send(.newPrivateConversationTapped)
}
.keyboardShortcut("n", modifiers: [.command, .shift])
- .disabled(newPrivateChatAction == nil)
+ .disabled(conversationListViewModel == nil)
Divider()
}
@@ -38,6 +37,5 @@ struct AppCommands: Commands {
// MARK: - FocusedValues
extension FocusedValues {
- @Entry var newChatAction: (() -> Void)?
- @Entry var newPrivateChatAction: (() -> Void)?
+ @Entry var conversationListViewModel: ConversationListViewModel?
}
diff --git a/openclient-llm-test/Features/Chat/ChatViewModelTests+TTS.swift b/openclient-llm-test/Features/Chat/ChatViewModelTests+TTS.swift
index 89ac91b1..fef3de47 100644
--- a/openclient-llm-test/Features/Chat/ChatViewModelTests+TTS.swift
+++ b/openclient-llm-test/Features/Chat/ChatViewModelTests+TTS.swift
@@ -28,7 +28,8 @@ extension ChatViewModelTests {
saveConversationUseCase: mockSaveConversation,
synthesizeSpeechUseCase: mockSynthesize,
getChatPreferencesUseCase: mockGetChatPreferences,
- getConversationStartersUseCase: mockGetConversationStarters
+ getConversationStartersUseCase: mockGetConversationStarters,
+ playAudioUseCase: mockPlayAudio
)
sut.send(.viewAppeared)
@@ -59,7 +60,8 @@ extension ChatViewModelTests {
saveConversationUseCase: mockSaveConversation,
synthesizeSpeechUseCase: mockSynthesize,
getChatPreferencesUseCase: mockGetChatPreferences,
- getConversationStartersUseCase: mockGetConversationStarters
+ getConversationStartersUseCase: mockGetConversationStarters,
+ playAudioUseCase: mockPlayAudio
)
sut.send(.viewAppeared)
@@ -93,7 +95,8 @@ extension ChatViewModelTests {
saveConversationUseCase: mockSaveConversation,
synthesizeSpeechUseCase: mockSynthesize,
getChatPreferencesUseCase: mockGetChatPreferences,
- getConversationStartersUseCase: mockGetConversationStarters
+ getConversationStartersUseCase: mockGetConversationStarters,
+ playAudioUseCase: mockPlayAudio
)
sut.send(.viewAppeared)
@@ -124,7 +127,8 @@ extension ChatViewModelTests {
saveConversationUseCase: mockSaveConversation,
synthesizeSpeechUseCase: mockSynthesize,
getChatPreferencesUseCase: mockGetChatPreferences,
- getConversationStartersUseCase: mockGetConversationStarters
+ getConversationStartersUseCase: mockGetConversationStarters,
+ playAudioUseCase: mockPlayAudio
)
sut.send(.viewAppeared)
@@ -161,7 +165,8 @@ extension ChatViewModelTests {
saveConversationUseCase: mockSaveConversation,
synthesizeSpeechUseCase: mockSynthesize,
getChatPreferencesUseCase: mockGetChatPreferences,
- getConversationStartersUseCase: mockGetConversationStarters
+ getConversationStartersUseCase: mockGetConversationStarters,
+ playAudioUseCase: mockPlayAudio
)
sut.send(.viewAppeared)
@@ -199,7 +204,8 @@ extension ChatViewModelTests {
saveConversationUseCase: mockSaveConversation,
synthesizeSpeechUseCase: mockSynthesize,
getChatPreferencesUseCase: mockGetChatPreferences,
- getConversationStartersUseCase: mockGetConversationStarters
+ getConversationStartersUseCase: mockGetConversationStarters,
+ playAudioUseCase: mockPlayAudio
)
sut.send(.viewAppeared)
diff --git a/openclient-llm-test/Features/Chat/ChatViewModelTests.swift b/openclient-llm-test/Features/Chat/ChatViewModelTests.swift
index 488b8f67..e920642b 100644
--- a/openclient-llm-test/Features/Chat/ChatViewModelTests.swift
+++ b/openclient-llm-test/Features/Chat/ChatViewModelTests.swift
@@ -24,6 +24,7 @@ final class ChatViewModelTests: XCTestCase {
var mockSaveSelectedModel: MockSaveSelectedModelUseCase!
var mockSetWebSearchEnabled: MockSetWebSearchEnabledUseCase!
var mockResolveAudioModelIds: MockResolveAudioModelIdsUseCase!
+ var mockPlayAudio: MockPlayAudioUseCase!
var mockGetUserProfileContext: MockGetUserProfileContextUseCase!
var mockGetMemoryContext: MockGetMemoryContextUseCase!
var mockGetConversationStarters: MockGetConversationStartersUseCase!
@@ -50,6 +51,7 @@ final class ChatViewModelTests: XCTestCase {
mockSaveSelectedModel = MockSaveSelectedModelUseCase()
mockSetWebSearchEnabled = MockSetWebSearchEnabledUseCase()
mockResolveAudioModelIds = MockResolveAudioModelIdsUseCase()
+ mockPlayAudio = MockPlayAudioUseCase()
mockGetUserProfileContext = MockGetUserProfileContextUseCase()
mockGetMemoryContext = MockGetMemoryContextUseCase()
mockGetConversationStarters = MockGetConversationStartersUseCase()
@@ -78,6 +80,7 @@ final class ChatViewModelTests: XCTestCase {
getUserProfileContextUseCase: mockGetUserProfileContext,
getMemoryContextUseCase: mockGetMemoryContext,
getConversationStartersUseCase: mockGetConversationStarters,
+ playAudioUseCase: mockPlayAudio,
streamingBackgroundUseCase: mockStreamingBackground,
notifyStreamingCompletedUseCase: mockNotifyStreamingCompleted,
compactConversationUseCase: mockCompactConversation
@@ -96,6 +99,7 @@ final class ChatViewModelTests: XCTestCase {
mockSaveSelectedModel = nil
mockSetWebSearchEnabled = nil
mockResolveAudioModelIds = nil
+ mockPlayAudio = nil
mockGetUserProfileContext = nil
mockGetMemoryContext = nil
mockGetConversationStarters = nil
diff --git a/openclient-llm-test/Features/Launch/ResetAppDataUseCaseTests.swift b/openclient-llm-test/Features/Launch/ResetAppDataUseCaseTests.swift
index b4b6b4b9..3419fbb9 100644
--- a/openclient-llm-test/Features/Launch/ResetAppDataUseCaseTests.swift
+++ b/openclient-llm-test/Features/Launch/ResetAppDataUseCaseTests.swift
@@ -148,19 +148,17 @@ final class ResetAppDataUseCaseTests: XCTestCase {
func test_fence_nestedCategoryOperation_executesWithoutDeadlock() async throws {
// Given
- mockSettingsManager.deleteAllCalled = false
let operationGate = try XCTUnwrap(categoryOperationGate)
- let settingsManager = try XCTUnwrap(mockSettingsManager)
// When
- try await operationGate.fence {
+ let didExecute = try await operationGate.fence {
try await operationGate.perform {
- settingsManager.deleteAllCalled = true
+ true
}
}
// Then
- XCTAssertTrue(mockSettingsManager.deleteAllCalled)
+ XCTAssertTrue(didExecute)
}
func test_execute_profileCloudOperationInFlight_waitsBeforeResettingData() async throws {
diff --git a/openclient-llm-test/Features/Models/ModelsViewModelTests.swift b/openclient-llm-test/Features/Models/ModelsViewModelTests.swift
index 49df97d7..2adfb724 100644
--- a/openclient-llm-test/Features/Models/ModelsViewModelTests.swift
+++ b/openclient-llm-test/Features/Models/ModelsViewModelTests.swift
@@ -82,6 +82,20 @@ final class ModelsViewModelTests: XCTestCase {
XCTAssertNotNil(loadedState.errorMessage)
}
+ func test_send_viewAppeared_repeatedly_fetchesModelsOnce() async throws {
+ // Given
+ var executeCount = 0
+ mockFetchModels.onExecute = { executeCount += 1 }
+
+ // When
+ sut.send(.viewAppeared)
+ sut.send(.viewAppeared)
+ try await Task.sleep(for: .milliseconds(100))
+
+ // Then
+ XCTAssertEqual(executeCount, 1)
+ }
+
// MARK: - Tests — refreshTapped
func test_send_refreshTapped_reloadsModels() async throws {
diff --git a/openclient-llm-test/Mocks/MockPrepareImageAttachmentUseCase.swift b/openclient-llm-test/Mocks/MockPrepareImageAttachmentUseCase.swift
index 994b2ee6..44088a23 100644
--- a/openclient-llm-test/Mocks/MockPrepareImageAttachmentUseCase.swift
+++ b/openclient-llm-test/Mocks/MockPrepareImageAttachmentUseCase.swift
@@ -9,8 +9,8 @@
import Foundation
@testable import openclient_llm
-// Safety: Only used within serialized @MainActor test methods.
-final class MockPrepareImageAttachmentUseCase: PrepareImageAttachmentUseCaseProtocol, @unchecked Sendable {
+// Safety: Tests configure `result` before each awaited execution and never mutate it while `execute` is running.
+nonisolated final class MockPrepareImageAttachmentUseCase: PrepareImageAttachmentUseCaseProtocol, @unchecked Sendable {
// MARK: - Properties
var result: Result?
diff --git a/openclient-llm.xcodeproj/project.pbxproj b/openclient-llm.xcodeproj/project.pbxproj
index 47758514..ca560948 100644
--- a/openclient-llm.xcodeproj/project.pbxproj
+++ b/openclient-llm.xcodeproj/project.pbxproj
@@ -792,7 +792,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 26.0;
- MARKETING_VERSION = 1.7.5;
+ MARKETING_VERSION = 1.7.10;
PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm.macos-widgets";
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -839,7 +839,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 26.0;
- MARKETING_VERSION = 1.7.5;
+ MARKETING_VERSION = 1.7.10;
PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm.macos-widgets";
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -907,7 +907,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 27.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
@@ -969,7 +969,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 27.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
@@ -1015,7 +1015,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 1.7.5;
+ MARKETING_VERSION = 1.7.10;
PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -1064,7 +1064,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 1.7.5;
+ MARKETING_VERSION = 1.7.10;
PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -1123,7 +1123,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 26.0;
- MARKETING_VERSION = 1.7.5;
+ MARKETING_VERSION = 1.7.10;
PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm";
PRODUCT_NAME = OpenClient;
REGISTER_APP_GROUPS = YES;
@@ -1180,7 +1180,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 26.0;
- MARKETING_VERSION = 1.7.5;
+ MARKETING_VERSION = 1.7.10;
PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm";
PRODUCT_NAME = OpenClient;
REGISTER_APP_GROUPS = YES;
@@ -1258,7 +1258,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 1.7.5;
+ MARKETING_VERSION = 1.7.10;
PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm.ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -1289,7 +1289,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 1.7.5;
+ MARKETING_VERSION = 1.7.10;
PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm.ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -1322,7 +1322,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 1.7.5;
+ MARKETING_VERSION = 1.7.10;
PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm.widgets";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -1355,7 +1355,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 1.7.5;
+ MARKETING_VERSION = 1.7.10;
PRODUCT_BUNDLE_IDENTIFIER = "com.artcc.openclient-llm.widgets";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
diff --git a/openclient-llm.xcodeproj/xcshareddata/xcodecloud/manifest.json b/openclient-llm.xcodeproj/xcshareddata/xcodecloud/manifest.json
new file mode 100644
index 00000000..03936953
--- /dev/null
+++ b/openclient-llm.xcodeproj/xcshareddata/xcodecloud/manifest.json
@@ -0,0 +1,9 @@
+{
+ "id" : "e1ddf089-c526-4ba6-a3fe-f2b17221e288",
+ "targets" : [
+ {
+ "id" : "3BC047B8-924D-4C73-8AB4-7E876C31042B",
+ "name" : "openclient-llm"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/openclient-llm/Resources/Info.plist b/openclient-llm/Resources/Info.plist
index ede4118e..75bb9dc7 100644
--- a/openclient-llm/Resources/Info.plist
+++ b/openclient-llm/Resources/Info.plist
@@ -2,15 +2,26 @@
+ BGTaskSchedulerPermittedIdentifiers
+
+ $(PRODUCT_BUNDLE_IDENTIFIER).streaming.*
+
+ CFBundleURLTypes
+
+
+ CFBundleURLName
+ com.artcc.openclient-llm
+ CFBundleURLSchemes
+
+ openclient
+
+
+
NSAppTransportSecurity
NSAllowsArbitraryLoads
- NSMicrophoneUsageDescription
- OpenClient uses the microphone to record audio for transcription by the AI model.
- NSSpeechRecognitionUsageDescription
- OpenClient uses speech recognition to transcribe your voice locally on device.
UIAppFonts
Poppins-Black.ttf
@@ -28,26 +39,11 @@
processing
remote-notification
- BGTaskSchedulerPermittedIdentifiers
-
- $(PRODUCT_BUNDLE_IDENTIFIER).streaming.*
-
VOTICE_API_KEY
$(VOTICE_API_KEY)
VOTICE_API_SECRET
$(VOTICE_API_SECRET)
VOTICE_APP_ID
$(VOTICE_APP_ID)
- CFBundleURLTypes
-
-
- CFBundleURLName
- com.artcc.openclient-llm
- CFBundleURLSchemes
-
- openclient
-
-
-
diff --git a/openclient-llm/Resources/openclient-llm.entitlements b/openclient-llm/Resources/openclient-llm.entitlements
index fcf521cc..83ccb30e 100644
--- a/openclient-llm/Resources/openclient-llm.entitlements
+++ b/openclient-llm/Resources/openclient-llm.entitlements
@@ -16,6 +16,8 @@
iCloud.com.artcc.openclient-llm
+ com.apple.developer.ubiquity-kvstore-identifier
+ $(TeamIdentifierPrefix)$(CFBundleIdentifier)
com.apple.security.application-groups
group.com.artcc.openclient-llm
diff --git a/openclient-llm/Shared/Core/Managers/CloudContainerProvider.swift b/openclient-llm/Shared/Core/Managers/CloudContainerProvider.swift
index cdb64ff6..60a997bc 100644
--- a/openclient-llm/Shared/Core/Managers/CloudContainerProvider.swift
+++ b/openclient-llm/Shared/Core/Managers/CloudContainerProvider.swift
@@ -16,7 +16,7 @@ nonisolated protocol CloudContainerProviding: Sendable {
func currentSession() -> CloudSyncSession?
}
-extension CloudContainerProviding {
+nonisolated extension CloudContainerProviding {
func currentSession() -> CloudSyncSession? {
guard isAvailable(),
let firstIdentity = identityData(),
diff --git a/openclient-llm/Shared/Features/Chat/Views/ConversationListView.swift b/openclient-llm/Shared/Features/Chat/Views/ConversationListView.swift
index d56e4b00..5f6a34b3 100644
--- a/openclient-llm/Shared/Features/Chat/Views/ConversationListView.swift
+++ b/openclient-llm/Shared/Features/Chat/Views/ConversationListView.swift
@@ -60,12 +60,7 @@ struct ConversationListView: View {
}
.navigationTitle(String(localized: "Chats"))
#if os(macOS)
- .focusedSceneValue(\.newChatAction) {
- viewModel.send(.newConversationTapped)
- }
- .focusedSceneValue(\.newPrivateChatAction) {
- viewModel.send(.newPrivateConversationTapped)
- }
+ .focusedSceneValue(\.conversationListViewModel, viewModel)
.task(id: macSearchRequestID) {
guard macSearchRequestID > 0 else { return }
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
diff --git a/openclient-llm/Shared/Features/Models/ViewModels/ModelsViewModel.swift b/openclient-llm/Shared/Features/Models/ViewModels/ModelsViewModel.swift
index e58d5532..6afc1fbc 100644
--- a/openclient-llm/Shared/Features/Models/ViewModels/ModelsViewModel.swift
+++ b/openclient-llm/Shared/Features/Models/ViewModels/ModelsViewModel.swift
@@ -75,6 +75,7 @@ final class ModelsViewModel {
private let fetchModelsUseCase: FetchModelsUseCaseProtocol
private let settingsManager: SettingsManagerProtocol
private var errorDismissTask: Task?
+ private var hasStartedInitialLoad = false
// MARK: - Init
@@ -125,6 +126,8 @@ final class ModelsViewModel {
private extension ModelsViewModel {
func loadModels() {
+ guard !hasStartedInitialLoad else { return }
+ hasStartedInitialLoad = true
state = .loading
Task {
@@ -287,7 +290,10 @@ private extension ModelsViewModel {
.notifications(named: .appDataDidReset)
for await _ in notifications {
guard let self else { return }
- await MainActor.run { self.loadModels() }
+ await MainActor.run {
+ self.hasStartedInitialLoad = false
+ self.loadModels()
+ }
}
}
}
From c67442f74116d398dbfd081808dbd5447c5b28c9 Mon Sep 17 00:00:00 2001
From: Arturo Carretero Calvo <10163049+ArtCC@users.noreply.github.com>
Date: Tue, 15 Sep 2026 20:33:41 +0200
Subject: [PATCH 2/7] Update release notes for Xcode 27 compatibility
- Add changelog entry for build 1.7.10-build-112
- Note compatibility with Xcode 27 and iOS/macOS 27 SDKs
- Document fix for repeated Models screen data requests on SwiftUI task restarts
- Refresh TestFlight notes in English and Spanish to reflect the new release
---
CHANGELOG.md | 11 +++++++++++
TestFlight/WhatToTest.en-US.txt | 12 ++----------
TestFlight/WhatToTest.es-ES.txt | 12 ++----------
3 files changed, 15 insertions(+), 20 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 48f53943..26774de5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
+## [1.7.10-build-112] - 2026-09-15
+
+### Changed
+
+- Build compatibility with Xcode 27 and the iOS 27 and macOS 27 SDKs
+
+### Fixed
+
+- The Models screen no longer repeatedly requests model data when its SwiftUI task restarts
+- **Minor bug fixes**
+
## [1.7.5-build-110] - 2026-09-15
### Added
diff --git a/TestFlight/WhatToTest.en-US.txt b/TestFlight/WhatToTest.en-US.txt
index d8262ddb..b544872b 100644
--- a/TestFlight/WhatToTest.en-US.txt
+++ b/TestFlight/WhatToTest.en-US.txt
@@ -1,15 +1,7 @@
Hi there! We've got some great new features for you in this update.
-• Your favorite chat model just got some talented teammates! Models that support tools can now call on specialists to analyze photos or create images, without switching models or starting another chat. Ask about a picture, dream up something new, or revisit an image from earlier in the conversation. It all happens in the same chat, and you can see which specialist is helping along the way.
-• You're in control of the team. Choose your vision and image generation specialists in the new Image and Vision section in Models. Use one compatible model for both jobs, mix and match, or choose None to turn off either kind of help. If your chat model already understands or creates images, it keeps doing that job itself.
-• Your assistant's toolbox is now in your hands! Open the new Tools section in Settings to see what each built-in tool does and turn it on or off. Everything is listed alphabetically in your language, and OpenClient remembers your choices.
-• Got a picture you'd like to change? Image-generation models with vision now accept one or more reference images for editing on iPhone, iPad, and Mac. Trying again keeps your original prompt and references, so you don't have to set everything up from scratch.
-• We've given your creations some extra care, too. Images stay in the conversation if you stop a reply after they arrive, and regenerating just the text keeps the image without requesting another generation. We've improved image-only replies, image format handling, and references in long chats and imported backups. If an analysis request is too large for your specialist, clearer guidance helps you adjust it. Private chats still keep images only for the session.
-• Your conversations just got easier to read. We've brought your messages and the model's replies closer together, with each reply's time, details, and actions in a compact row. Less empty space, more conversation in view.
-• Better formatting for your replies! Markdown tables now display correctly even without separators at their outer edges. Footnotes appear as numbered notes at the end of the message, and escaped characters are handled correctly inside tables, too.
-• Small details make a difference, too. You can now see subscripts and superscripts, like H₂O or x², even inside links and alongside bold or italic text. Their size follows your text size on iPhone, iPad, and Mac.
-• Finding a chat on iPad is now more convenient. Search directly from the top of the sidebar and see matching conversations on the right as you type. Open a result to jump into that chat and dismiss the keyboard; your search stays there for later. Prefer the top bar? The search button is still available there, too.
-• Voice setup is a little easier as well. If you haven't selected a model, or your previous choice is no longer available, text-to-speech picks the first available model and speech-to-text selects Apple speech recognition. Both work even when there's only one option. We've also included smaller fixes and improvements to keep things running smoothly.
+• OpenClient is now ready for Xcode 27 and the latest iOS 27 and macOS 27 SDKs, keeping your experience up to date across iPhone, iPad, and Mac.
+• Minor bug fixes and improvements for a smoother experience.
Thanks for your continued support and for helping us build the best possible LLM client together.
diff --git a/TestFlight/WhatToTest.es-ES.txt b/TestFlight/WhatToTest.es-ES.txt
index 1a090c32..bfc9ab90 100644
--- a/TestFlight/WhatToTest.es-ES.txt
+++ b/TestFlight/WhatToTest.es-ES.txt
@@ -1,15 +1,7 @@
¡Hola! Esta actualización viene cargada de novedades.
-• ¡Tu modelo de chat favorito ahora tiene nuevos compañeros de equipo! Los modelos compatibles con herramientas pueden recurrir a especialistas para analizar fotos o crear imágenes, sin cambiar de modelo ni iniciar otro chat. Pregunta por una imagen, crea algo nuevo o vuelve a una imagen anterior de la conversación. Todo ocurre en el mismo chat y podrás ver qué especialista está ayudando en cada momento.
-• Tú decides quién forma parte del equipo. Elige tus especialistas de visión y generación de imágenes en la nueva sección Imagen y visión de Modelos. Puedes usar un mismo modelo compatible para ambas tareas, combinar modelos distintos o seleccionar Ninguno para desactivar cualquiera de estas funciones. Si tu modelo de chat ya puede interpretar o crear imágenes, seguirá haciéndolo por sí mismo.
-• ¡Ahora tú controlas las herramientas de tu asistente! Abre la nueva sección Herramientas de Ajustes para ver qué hace cada herramienta integrada y activarla o desactivarla. Todas aparecen ordenadas alfabéticamente en tu idioma y OpenClient recuerda tus preferencias.
-• ¿Quieres modificar una imagen? Los modelos de generación de imágenes con capacidad de visión ahora pueden utilizar una o varias imágenes de referencia para editarlas en iPhone, iPad y Mac. Si vuelves a intentarlo, se conservan tanto el prompt original como las imágenes de referencia, para que no tengas que configurarlo todo de nuevo.
-• También hemos mejorado el tratamiento de las imágenes generadas. Las imágenes permanecen en la conversación si detienes una respuesta después de que se hayan generado, y regenerar solo el texto conserva la imagen sin solicitar una nueva generación. También hemos mejorado las respuestas que contienen únicamente imágenes, la gestión de formatos y las referencias en conversaciones largas y copias de seguridad importadas. Si una solicitud de análisis es demasiado grande para tu especialista, recibirás indicaciones más claras para poder ajustarla. Los chats privados siguen conservando las imágenes únicamente durante la sesión.
-• Tus conversaciones, más cómodas de leer. Hemos acercado tus mensajes y las respuestas del modelo, y reunido la hora, los detalles y las acciones de cada respuesta en una fila compacta. Menos espacio vacío y más conversación a la vista.
-• ¡Mejor formato para las respuestas! Las tablas de Markdown ahora se muestran bien aunque no incluyan separadores en los extremos. Las notas al pie aparecen numeradas al final del mensaje y los caracteres escapados se respetan también dentro de las tablas.
-• Los pequeños detalles también cuentan. Ya puedes ver subíndices y superíndices, como H₂O o x², incluso en enlaces y junto a negrita o cursiva. Su tamaño se adapta al del texto en iPhone, iPad y Mac.
-• Encontrar un chat en iPad es ahora más cómodo. Busca directamente desde la parte superior del menú lateral y verás las conversaciones coincidentes a la derecha mientras escribes. Abre un resultado para entrar en ese chat y cerrar el teclado; la búsqueda se conserva para retomarla después. ¿Prefieres la barra superior? La lupa sigue disponible también ahí.
-• La configuración de voz también es ahora más sencilla. Si no has seleccionado ningún modelo o el que usabas ya no está disponible, la conversión de texto a voz seleccionará el primer modelo disponible y la conversión de voz a texto utilizará el reconocimiento de voz de Apple. Ambas funciones también funcionan cuando solo hay una opción disponible. Además, hemos incluido pequeñas correcciones y mejoras para que todo siga funcionando como debe.
+• OpenClient ya está preparado para Xcode 27 y los últimos SDK de iOS 27 y macOS 27, para ofrecerte una experiencia actualizada en iPhone, iPad y Mac.
+• Pequeñas correcciones de errores y mejoras para disfrutar de una experiencia más fluida.
Gracias por seguir apoyándonos y por ayudarnos a crear juntos el mejor cliente posible para modelos de lenguaje.
From 296b4ecbe3d7ff20b01b25983c501e8eadfaeda8 Mon Sep 17 00:00:00 2001
From: Arturo Carretero Calvo <10163049+ArtCC@users.noreply.github.com>
Date: Tue, 15 Sep 2026 21:12:58 +0200
Subject: [PATCH 3/7] Clarify Xcode verification and architecture guidance
- Update xcode-verify setup to source Secrets.xcconfig from README and preserve signing and test timeout flags in fallback xcodebuild commands
- Rewrite AGENTS.md to reflect current repository orchestration, validation, and architecture guidance
- Add updated project structure entries for Chat tools, Settings use cases, Tips, and test support folders in ARCHITECTURE.md
- Remove obsolete copilot attribution from README
- Narrow and refresh the agent tool-calling specification for current chat routing, registry, transcript, and MCP behavior
---
.opencode/skills/xcode-verify/SKILL.md | 8 +-
AGENTS.md | 245 ++----
ARCHITECTURE.md | 6 +-
README.md | 2 -
specs/agent-tool-calling.instructions.md | 754 +++---------------
specs/architecture.instructions.md | 172 ++--
specs/changelog.instructions.md | 79 +-
specs/chat-visual-style.instructions.md | 374 ++-------
specs/code-style.instructions.md | 442 ++--------
specs/concurrency.instructions.md | 459 ++---------
...conversation-backup-format.instructions.md | 126 ++-
specs/design-ui.instructions.md | 701 +---------------
specs/icloud-sync.instructions.md | 265 +++---
specs/litellm-api.instructions.md | 315 ++------
specs/readme.instructions.md | 26 +-
specs/roadmap-completed.instructions.md | 157 ----
specs/security.instructions.md | 236 ++----
specs/swiftui-multiplatform.instructions.md | 305 ++-----
specs/testing.instructions.md | 193 ++---
specs/version.instructions.md | 119 +--
specs/web-browsing.instructions.md | 157 +---
21 files changed, 968 insertions(+), 4173 deletions(-)
delete mode 100644 specs/roadmap-completed.instructions.md
diff --git a/.opencode/skills/xcode-verify/SKILL.md b/.opencode/skills/xcode-verify/SKILL.md
index 3b794dab..16a548e6 100644
--- a/.opencode/skills/xcode-verify/SKILL.md
+++ b/.opencode/skills/xcode-verify/SKILL.md
@@ -12,11 +12,13 @@ for that requested operation.
## Setup
1. Before the first XcodeBuildMCP build or test call, use `session_show_defaults`.
-2. Before building, create `Secrets.xcconfig` from the template in `AGENTS.md` if it is missing; never overwrite it.
+2. Before building, create `Secrets.xcconfig` from the setup template in `README.md` if it is missing; never overwrite it.
3. Prefer XcodeBuildMCP and use the project, scheme, and simulator defaults from `.xcodebuildmcp/config.yaml`. Only repair
defaults that are missing or wrong.
-4. Disable code signing for builds and tests. Add the test timeout arguments documented in `AGENTS.md` for test runs.
-5. If an MCP request fails or times out, use the complete `xcodebuild` fallback from `AGENTS.md`.
+4. Disable code signing for builds and tests with `CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO`. For tests, add
+ `-test-timeouts-enabled YES -maximum-test-execution-time-allowance 120`.
+5. If an MCP request fails or times out, use `xcodebuild` with the current project, scheme, and destination from the
+ session defaults or `.xcodebuildmcp/config.yaml`; preserve the same signing and timeout arguments.
## Operations
diff --git a/AGENTS.md b/AGENTS.md
index 39f86ccb..5495b4ac 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,199 +1,86 @@
# AGENTS.md
-This file is the project-wide operating guide. It resolves facts that would otherwise require reading multiple configuration files; focused implementation rules live in `specs/`.
-
-## Instruction Order
-
-1. Read this file before changing the project.
-2. Read every specification relevant to the requested work before editing.
-3. If instructions conflict, follow repository-specific instructions over general guidance. When two project instructions conflict, stop and ask for clarification.
-4. Keep this file limited to durable, project-wide facts. Put focused or evolving implementation rules in `specs/`.
-
-## Workspace Boundaries
-
-- Inspect files and directories only within this repository workspace.
-- The only exception is a document, image, log, or other artifact outside the workspace that the user explicitly provides.
-- Consult official online documentation for external APIs and framework behavior; use Apple's online developer documentation
- for Apple platforms and frameworks.
-- Do not inspect installed Apple SDKs or frameworks, `DerivedData`, Xcode caches, system libraries, package caches, or
- similar locations outside the repository to infer implementation details.
-- If an exceptional task appears to require inspecting any such external location, explain exactly why it is needed and
- obtain the user's explicit permission before accessing it.
+Project-wide orchestration guide. Read this file before changing the repository, then read every specification relevant to the task. Focused specifications are living project contracts and must remain aligned with the implementation.
+
+## Source Of Truth
+
+Use this precedence according to the kind of information involved:
+
+1. The user's explicit request and constraints for the current task.
+2. Repository configuration and manifests for configured facts, including `openclient-llm.xcodeproj/project.pbxproj`,
+ `.swiftlint.yml`, plists, entitlements, schemes, and CI workflows.
+3. The focused specification for intended behavior, invariants, compatibility, and implementation constraints; a more
+ specific spec takes precedence over this overview.
+4. Compiled source code and tests as evidence of the current implementation and established local conventions.
+5. This file for project-wide orchestration.
+6. Descriptive documentation and examples, which must not override configuration, specifications, or implementation.
+
+Repository instructions take precedence over generic agent guidance. A disagreement between a spec and the code is project
+drift, not permission to ignore the spec. Determine whether the task changes the contract or restores the implementation;
+then update both in the same change. If intent remains unclear, stop and ask for clarification.
+
+## Operating Rules
+
+- Work only inside this repository. The only exception is an artifact outside it that the user explicitly provides.
+- Do not inspect installed SDKs, `DerivedData`, caches, system libraries, package caches, or other external paths to infer
+ implementation details. Prefer official online documentation for external APIs, especially Apple frameworks.
+- Read directly related files and all applicable specs before editing.
+- Preserve the architecture, conventions, and nearby implementation patterns unless the task explicitly changes them.
+- Keep applicable specs synchronized whenever behavior, compatibility, architecture, or a documented invariant changes.
+ Do not leave a known mismatch for a later documentation pass.
+- Do not discard, overwrite, or reformat unrelated user changes. Limit edits to the requested scope.
+- Do not add or update dependencies without explicit permission. Read package declarations from the Xcode project rather
+ than duplicating their inventory here.
+- Ask before builds, tests, SwiftLint, formatters, type checks, or other validation commands. Use the smallest relevant
+ validation once authorized.
+- Use the project `xcode-verify` skill for Xcode validation; it owns setup and fallback details, while
+ `.xcodebuildmcp/config.yaml` owns the default project, scheme, and simulator selection.
+- Do not commit, push, amend, or perform destructive Git operations unless explicitly requested.
+- Run `git diff --check` before reporting implementation work complete, unless the user forbids Git commands.
## Specifications
-Each specification must use the `.instructions.md` suffix and start with YAML front matter containing a `description`. Add an `applyTo` pattern when the scope can be expressed by file path. When adding or removing a specification, update this table in the same change.
+Specifications use the `.instructions.md` suffix and valid YAML front matter with a `description`; add `applyTo` when the
+scope is expressible by path. Update this table when adding or removing a spec.
| File | Read when |
|---|---|
| `agent-tool-calling.instructions.md` | Implementing tool calling, tool UI, or the agent loop. |
-| `architecture.instructions.md` | Creating Swift files, features, or changing layer boundaries. |
+| `architecture.instructions.md` | Creating Swift files, features, targets, or changing layer boundaries. |
| `changelog.instructions.md` | Updating `CHANGELOG.md`. |
| `chat-visual-style.instructions.md` | Designing chat-specific SwiftUI. |
| `code-style.instructions.md` | Writing or reviewing Swift style. |
-| `concurrency.instructions.md` | Working with async code, isolation, or `Sendable`. |
-| `conversation-backup-format.instructions.md` | Exporting, importing, restoring, validating, or versioning conversation backups. |
-| `design-ui.instructions.md` | Designing general SwiftUI UI, accessibility, haptics, or animation. |
-| `icloud-sync.instructions.md` | Implementing or changing iCloud synchronization, storage, conflict resolution, or cloud data management. |
+| `concurrency.instructions.md` | Working with async code, isolation, tasks, or `Sendable`. |
+| `conversation-backup-format.instructions.md` | Changing conversation backup export, import, validation, or versioning. |
+| `design-ui.instructions.md` | Designing general SwiftUI, accessibility, haptics, or animation. |
+| `icloud-sync.instructions.md` | Changing iCloud synchronization, storage, conflicts, or cloud data management. |
| `litellm-api.instructions.md` | Changing LiteLLM/OpenAI-compatible API integration. |
| `readme.instructions.md` | Updating `README.md`. |
-| `roadmap-completed.instructions.md` | Reviewing completed roadmap work. |
-| `security.instructions.md` | Handling sensitive data, user input, credentials, or security review. |
+| `security.instructions.md` | Handling sensitive data, input, credentials, networking, or security review. |
| `swiftui-multiplatform.instructions.md` | Building shared iOS, iPadOS, or macOS SwiftUI. |
-| `testing.instructions.md` | Adding or changing tests and mocks. |
-| `version.instructions.md` | Adding changelog entries, choosing a release version/build, or updating TestFlight release notes. |
+| `testing.instructions.md` | Adding or changing tests, fixtures, or mocks. |
+| `version.instructions.md` | Changing release versions, build metadata, or TestFlight notes. |
| `web-browsing.instructions.md` | Implementing web search or browsing features. |
-## Platform & Services
-
-- Target Swift 6+ with SwiftUI on iOS, iPadOS, and macOS. Minimum deployment is iOS 26 and macOS 26.
-- The app connects to a self-hosted LiteLLM server through its OpenAI-compatible API. The base URL is user-configurable.
-- Store credentials in `KeychainManager`; store non-sensitive settings in `SettingsManager`.
-
-## Build & Run
-
-```bash
-# Build iOS scheme (default)
-xcodebuild build -project openclient-llm.xcodeproj -scheme openclient-llm -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max'
-
-# Build macOS scheme
-xcodebuild build -project openclient-llm.xcodeproj -scheme openclient-llm-macOS -destination 'platform=macOS'
-```
-
-- Use `.xcodeproj` (not `.xcworkspace`). The three SPM packages are SwiftLintPlugins, VoticeSDK, and ConfettiSwiftUI.
-- SwiftLint runs on the iOS and macOS app builds. `.swiftlint.yml` sets line-length warning/error limits to
- 120/150, function-body limits to 50/80, type-body limits to 300/400, and file-length limits to 500/650;
- force unwraps and force casts are errors.
-- CI skips code signing: append `CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO` to `xcodebuild` commands.
-- VS Code + XcodeBuildMCP is supported (config at `.xcodebuildmcp/config.yaml`).
-- **You must create a `Secrets.xcconfig` before building.** Copy the template from CI:
-
-```bash
-cat > Secrets.xcconfig << 'EOF'
-VOTICE_API_KEY =
-VOTICE_API_SECRET =
-VOTICE_APP_ID =
-EOF
-```
-
-## Concurrency (critical)
-
-The iOS and macOS app targets set `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`; shared code therefore inherits
-main-actor isolation when compiled into either app. The test, Share Extension, `WidgetsExtension-iOS`, and
-`WidgetsExtension-macOS` targets do not set it.
-
-- `@MainActor` annotations on ViewModels are redundant but kept for documentation.
-- All test classes **must** be `@MainActor` — otherwise they cannot access `@MainActor`-isolated types synchronously.
-- Use `nonisolated` only when a declaration must run or be constructed outside `MainActor` and its dependencies and
- transferred values are safe across isolation boundaries. Common cases include shared DTOs, parsing helpers, constants
- required by nonisolated protocols, and genuinely background processing. Do not use it for UI-bound state.
-- `@unchecked Sendable` requires a documented safety invariant comment — never use without justification.
- - Production wrappers: `// Safety: is thread-safe per Apple documentation. All stored properties are immutable (\`let\`).`
- - Test mocks: `// Safety: Only used within serialized @MainActor test methods.`
-- No `ObservableObject` / `@Published` — use `@Observable` macro everywhere.
-
-## Test commands
-
-```bash
-# Run all tests (iOS scheme)
-xcodebuild test -project openclient-llm.xcodeproj -scheme openclient-llm -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' -test-timeouts-enabled YES -maximum-test-execution-time-allowance 120 CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO
-
-# Run a single test class
-xcodebuild test -project openclient-llm.xcodeproj -scheme openclient-llm -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' -only-testing:openclient-llm-test/ChatViewModelTests CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO
-```
-
-Tests live in `openclient-llm-test/`, linked to the iOS target. They are unit and in-process integration tests; there
-are no UI tests or current tests that call a real LiteLLM/Ollama server.
-
-### Test conventions
-
-- Naming: `test___()` (e.g. `test_fetchModels_serverUnavailable_returnsEmpty()`).
-- Import: `@testable import openclient_llm`.
-- Structure: Given-When-Then with `// Given` / `// When` / `// Then` comments.
-- Mocks live in `openclient-llm-test/Mocks/`, named `MockXxx`, protocol-based.
-- Test classes mirror feature folders: `Features/Chat/` → `Features/Chat/ChatViewModelTests.swift`.
-- Add isolated tests for UseCases, Repositories, and ViewModels. Use protocols and mocks for dependencies.
-
-## Project Structure And Targets
-
-| Target | Purpose |
-|---|---|
-| `openclient-llm` | iOS app + all shared code |
-| `openclient-llm-macOS` | macOS app (macOS-only UI; references `Shared/` from iOS target) |
-| `openclient-llm-test` | Unit tests (linked to iOS target) |
-| `ShareExtension` | iOS Share Extension (does NOT link Shared code; uses App Group) |
-| `WidgetsExtension-iOS` | Native iOS/iPadOS WidgetKit extension sourced from `WidgetsShared/` |
-| `WidgetsExtension-macOS` | Native macOS WidgetKit extension sourced from `WidgetsShared/` |
-
-- Shared business logic lives in `openclient-llm/Shared/` and is referenced by both app targets.
-- Platform-specific UI goes in each target's own folder. Use `#if os(iOS)` / `#if os(macOS)` only when the difference is small.
-- `ShareExtension` does not link `Shared/`; it has extension-local payload/store types compatible with the main app.
-- `WidgetsShared/` contains the widget views, providers, intents, controls, App Group models, and resources compiled by both
- widget extensions. The platform extension folders retain only their own plist and entitlements.
-- Neither widget extension links the shared feature layer. `AppGroupStore`, `WidgetConversation`, and `WidgetControlStore`
- are compiled into both apps and both widget extensions.
-- App Group: `group.com.artcc.openclient-llm`
-
-## Architecture: Event/State ViewModels
-
-ViewModels use `@Observable`, explicit `@MainActor`, event-driven `send(_:)` input, and screen-specific state. Most use
-the following Event/State shape; `HomeViewModel` instead exposes several focused observable properties:
-
-```swift
-@Observable
-@MainActor
-final class FeatureViewModel {
- enum Event { case viewAppeared }
- enum State: Equatable { case loading; case loaded(LoadedState) }
- struct LoadedState: Equatable { /* screen data */ }
-
- private(set) var state: State
- init(state: State = .loading) { self.state = state }
- func send(_ event: Event) { /* switch on event */ }
-}
-```
-
-- Root screen views generally own `@Observable` ViewModels with `@State`. Custom initialization and internal visibility are
- allowed for split `Type+Concern.swift` implementations; child views may receive the same ViewModel and use `@Bindable`
- when bindings are required.
-- ViewModels primarily coordinate UseCases, but some also inject Managers directly for settings, memory, cloud sync,
- user profile, and app-wide routing state. Preserve the local pattern instead of adding pass-through UseCases.
-- ViewModel `send(_:)` is the primary UI event entry point. Preserve established explicit methods such as awaitable refresh
- APIs where the surrounding feature already uses them. Asynchronous ownership and state mutation stay inside the ViewModel.
-- Typical data flow is View → ViewModel → UseCase → Repository → APIClient/LocalStorage. Managers are transversal
- services coordinated by UseCases, ViewModels, repositories, and app entry points where the implementation requires it.
-
-## File conventions
-
-- Every `.swift` file starts with the boilerplate copyright header (see any existing file).
-- One public type per file, named after the type.
-- Use `// MARK: -` sections where they improve navigation. Common sections are `Properties`, `Init`, a meaningful public
- section such as `View` or `Input functions`, and `Private` near the bottom; do not force sections into small files.
-- Primary SwiftUI screens and reusable visual components need preview coverage, either in the same file or a dedicated
- `Type+Previews.swift` file. Platform adapters and infrastructure-only views may rely on a composed parent preview.
-- Never initialize optional stored properties with `= nil` (optionals default to nil).
-- Localize all user-facing source strings. Use `String(localized:)` when an API requires `String`; direct localized literals
- are valid for APIs taking `LocalizedStringKey` or `LocalizedStringResource`. Never manually edit `Localizable.xcstrings`.
-- Write localized source strings in English only; translations are maintained manually by the project author.
-- SwiftLint configuration: warnings/errors are 120/150 lines for line length, 50/80 for function bodies, 300/400 for
- type bodies, and 500/650 for files. `force_unwrapping` and `force_cast` are errors.
-- External SPM packages are SwiftLintPlugins, VoticeSDK, and ConfettiSwiftUI; do not add others without a concrete need.
-
-## Git workflow
-
-- Branch from `develop`, open PRs targeting `develop`.
-- Commit messages: imperative style ("Add chat streaming support"), reference related issues with `Closes #N`.
-- Do NOT commit `Secrets.xcconfig` (gitignored; contains Votice API keys).
-- Values from `Secrets.xcconfig` are compiled into the client app and are recoverable from a distributed bundle. Treat them
- as client configuration, not as confidential server-side secrets; never place a privileged credential there.
-- Release workflows derive tag and artifact labels from the first numeric `CHANGELOG.md` header. The deployment process
- increments the published build number automatically, so the checked-in `CURRENT_PROJECT_VERSION` does not need to match
- the changelog build suffix. The standard release tag is `v`; the macOS DMG tag is `v-macos`.
+## Architecture At A Glance
+
+- The app uses SwiftUI and connects to a user-configurable LiteLLM/OpenAI-compatible server.
+- Shared app code belongs in `openclient-llm/Shared/` and is compiled by the iOS/iPadOS and macOS app targets.
+- Platform-only app code belongs in `openclient-llm/` or `openclient-llm-macOS/` as appropriate.
+- `ShareExtension` is standalone and does not link the shared feature layer. `WidgetsShared/` is compiled by both widget
+ extensions; widget targets likewise do not link the shared feature layer.
+- Cross-target App Group models and stores must retain intentional target membership and compatible persisted formats.
+- The usual flow is View -> ViewModel -> UseCase -> Repository -> APIClient/local storage, with Managers for transversal
+ services. Do not add pass-through layers solely to satisfy the diagram.
+- ViewModels use `@Observable`, remain explicitly `@MainActor`, own asynchronous state mutation, and generally receive UI
+ events through `send(_:)`. Do not introduce `ObservableObject` or `@Published`.
+- `SettingsManager` is the app-facing settings facade: its credential and authorization-scope APIs delegate to
+ `KeychainManager`, while non-sensitive preferences use `UserDefaults`. Preserve that storage boundary.
## Change Completion
-- After completing implementation work, **always ask the user** before compiling, checking SwiftLint, or running tests. Never do these automatically.
-- When the user wants to verify: run the smallest relevant test set after a focused change; run the full iOS suite after shared-code changes.
-- Build both iOS and macOS after changing shared SwiftUI or shared business logic.
-- Run `git diff --check` before reporting completion.
-- Do not include generated files, `Secrets.xcconfig`, or unrelated working-tree changes in a commit.
+- Keep user-facing source strings in English and localize them. Do not edit `Localizable.xcstrings` manually.
+- Follow `.swiftlint.yml` rather than copying its numeric thresholds into guidance.
+- Verify target membership when creating or moving source files.
+- Update structural documentation when intentionally changing targets, top-level ownership, or platform strategy.
+- Report what changed, what was not validated, and any remaining risks.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 37e61e88..0f44daf5 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -22,6 +22,7 @@ openclient-llm/ # iOS target
│ │ ├── Chat/
│ │ │ ├── Models/
│ │ │ ├── Repositories/
+│ │ │ ├── Tools/
│ │ │ ├── UseCases/
│ │ │ ├── ViewModels/
│ │ │ └── Views/
@@ -57,6 +58,7 @@ openclient-llm/ # iOS target
│ │ │ └── Views/
│ │ ├── Settings/
│ │ │ ├── Models/
+│ │ │ ├── UseCases/
│ │ │ ├── ViewModels/
│ │ │ └── Views/
│ │ ├── Shortcuts/ # AppIntents and AppShortcutsProvider
@@ -72,6 +74,7 @@ openclient-llm/ # iOS target
│ │ ├── Models/ # App-side share payload and other core models
│ │ ├── Networking/
│ │ │ └── Models/
+│ │ ├── Tips/
│ │ ├── Utils/
│ │ └── Views/
│ └── Resources/
@@ -130,7 +133,8 @@ openclient-llm-test/ # Unit tests
│ ├── Onboarding/
│ ├── PromptTemplates/
│ └── Settings/
-└── Mocks/ # MockXxx per protocol, @unchecked Sendable
+├── Mocks/ # Reusable protocol-backed test doubles
+└── Support/ # Shared test harnesses and infrastructure
```
The Xcode project contains six native targets: `openclient-llm`, `openclient-llm-macOS`,
diff --git a/README.md b/README.md
index dae78fad..1b23aee9 100644
--- a/README.md
+++ b/README.md
@@ -116,8 +116,6 @@ servers that provide the endpoints used by your selected features; point the app
| WidgetKit | Native iOS, iPadOS, and macOS widgets plus a New Chat system control |
| Votice | In-app feedback & feature requests |
-This project was developed entirely with Xcode, Visual Studio Code and GitHub Copilot (with Claude Opus / Sonnet 4.6).
-
## Architecture
The project follows **MVVM + UseCase + Repository + Manager** with Swift strict concurrency and `async/await`. Code is organized by feature under `Shared/`, shared across iOS and macOS targets. Platform-specific UI lives in each target's own folder.
diff --git a/specs/agent-tool-calling.instructions.md b/specs/agent-tool-calling.instructions.md
index 5f450c3e..97f85a21 100644
--- a/specs/agent-tool-calling.instructions.md
+++ b/specs/agent-tool-calling.instructions.md
@@ -1,652 +1,114 @@
---
-description: "Use when implementing agent mode, tool/function calling, building the agentic loop, registering tools, parsing tool_calls responses, or showing tool execution UI in chat."
-applyTo: "**/*.swift"
+description: "Use when changing automatic agent routing, tool registration, tool-call transcripts, agent limits, MCP authorization, or delegated image tools."
+applyTo: "openclient-llm/Shared/Features/Chat/**/*.swift"
---
-# Agent Mode — Tool Calling Integration
-
-## Overview
-
-Agent mode allows the LLM to call **tools** (functions) that the app executes locally, then returns results to the model for continued reasoning. This enables multi-step workflows where the model can search the web, perform calculations, or interact with external services.
-
-## OpenAI-Compatible Tool Calling Protocol
-
-LiteLLM exposes the standard OpenAI tool calling format. The protocol works identically whether the backend is OpenAI, Anthropic, Ollama, or any other provider.
-
-### Tool Definition Format
-
-Tools are defined as JSON in the `tools` array of the chat completions request:
-
-```json
-{
- "model": "gpt-4",
- "messages": [...],
- "tools": [
- {
- "type": "function",
- "function": {
- "name": "web_search",
- "description": "Search the web for current information",
- "parameters": {
- "type": "object",
- "properties": {
- "query": {
- "type": "string",
- "description": "The search query"
- }
- },
- "required": ["query"]
- }
- }
- }
- ],
- "tool_choice": "auto"
-}
-```
-
-### `tool_choice` Values
-
-| Value | Behavior |
-|-------|----------|
-| `"auto"` | Model decides whether to call tools (default) |
-| `"none"` | Model will not call any tools |
-| `"required"` | Model must call at least one tool |
-| `{"type": "function", "function": {"name": "..."}}` | Force a specific tool |
-
-## The Agentic Loop
-
-The core of agent mode is a **request → tool_calls → execute → result → continue** loop:
-
-```
-┌─────────────────────────────────────────────────────────┐
-│ 1. Send messages + tools to /chat/completions │
-│ │
-│ 2. Response has finish_reason = "tool_calls"? │
-│ ├── YES → Parse tool_calls, execute each tool │
-│ │ Append assistant message (with tool_calls) │
-│ │ Append tool result messages (role: "tool") │
-│ │ → Go back to step 1 │
-│ └── NO → finish_reason = "stop" │
-│ Display final response to user │
-└─────────────────────────────────────────────────────────┘
-```
-
-### Step-by-Step
-
-**Step 1 — Send request with tools**
-
-```json
-POST /chat/completions
-{
- "model": "gpt-4",
- "messages": [
- {"role": "user", "content": "What's the latest news about Swift?"}
- ],
- "tools": [/* tool definitions */],
- "tool_choice": "auto"
-}
-```
-
-**Step 2 — Model responds with tool_calls**
-
-```json
-{
- "choices": [{
- "finish_reason": "tool_calls",
- "message": {
- "role": "assistant",
- "content": null,
- "tool_calls": [
- {
- "id": "call_abc123",
- "type": "function",
- "function": {
- "name": "web_search",
- "arguments": "{\"query\": \"Swift programming language latest news 2026\"}"
- }
- }
- ]
- }
- }]
-}
-```
-
-Key fields:
-- `finish_reason`: `"tool_calls"` indicates the model wants to call tools (not `"stop"`)
-- `tool_calls[].id`: Unique ID that must be referenced in the tool result
-- `tool_calls[].function.name`: Which tool to execute
-- `tool_calls[].function.arguments`: JSON string with arguments (must be parsed)
-
-**Step 3 — Execute tools and send results back**
-
-Append the assistant message (with tool_calls) and tool results to the conversation:
-
-```json
-POST /chat/completions
-{
- "model": "gpt-4",
- "messages": [
- {"role": "user", "content": "What's the latest news about Swift?"},
- {
- "role": "assistant",
- "content": null,
- "tool_calls": [
- {
- "id": "call_abc123",
- "type": "function",
- "function": {
- "name": "web_search",
- "arguments": "{\"query\": \"Swift programming language latest news 2026\"}"
- }
- }
- ]
- },
- {
- "role": "tool",
- "tool_call_id": "call_abc123",
- "name": "web_search",
- "content": "1. Swift 6.1 released with improved concurrency... 2. ..."
- }
- ],
- "tools": [/* same tool definitions */]
-}
-```
-
-**Step 4 — Model generates final response (or calls more tools)**
-
-```json
-{
- "choices": [{
- "finish_reason": "stop",
- "message": {
- "role": "assistant",
- "content": "Here are the latest news about Swift:\n\n1. Swift 6.1..."
- }
- }]
-}
-```
-
-If `finish_reason` is `"tool_calls"` again, repeat steps 2-3. If `"stop"`, display the response.
-
-### Parallel Tool Calls
-
-Some models can request multiple tool calls in a single response:
-
-```json
-"tool_calls": [
- {"id": "call_1", "function": {"name": "web_search", "arguments": "..."}},
- {"id": "call_2", "function": {"name": "web_search", "arguments": "..."}}
-]
-```
-
-- `AgentStreamUseCase` executes accepted calls concurrently with a throwing task group, then restores model-request order when constructing tool messages.
-- Send ALL results back in one request, each with its matching `tool_call_id`
-- The current loop does not consult `supports_parallel_function_calling`; it concurrently executes multiple calls whenever the model returns them. Treat that as current behavior when changing capability handling.
-
-### Loop Safety
-
-- **Maximum iterations**: Cap the loop at 15 iterations to prevent infinite loops
-- **Tool-call budget**: Cap the total number of calls at 20 and a single tool round at 8 calls.
-- **Final-response round**: On the fifteenth iteration, after exhausting the total tool-call budget, or after rejecting excess calls from a round, send the next request without tools to force a final response.
-- **Tool-result budget**: Rebuild the request context after every tool round and bound each result from the remaining input budget so tool output cannot consume the final-response space.
-- **Continuous context**: Preserve the latest complete user turn and its assistant/tool messages atomically; never skip a recent turn to include an older one.
-- **Transcript persistence**: Emit and persist every assistant `tool_calls` message and matching tool result before continuing the loop.
-- **Timeout**: The base active-time limit is 300 seconds plus `AgentToolContext.additionalExecutionTime` (default `.zero`).
- `ChatViewModel` adds 600 seconds when the principal supports native image generation or the turn's initial registry
- advertises `generate_image`, for 900 seconds total. Vision-only delegation does not add this allowance.
- Pause the timer during user authorization.
-- **User cancellation**: Allow the user to stop the loop at any point
-- **Error in tool execution**: Return error message as tool content, let the model handle it
-
-## Model Compatibility
-
-### Checking Support
-
-The `GET /model/info` endpoint provides capability flags:
-
-```json
-{
- "model_info": {
- "supports_function_calling": true,
- "supports_parallel_function_calling": true
- }
-}
-```
-
-- Route every message for a model with `supports_function_calling: true` through `AgentStreamUseCase`; agent routing is automatic and is not controlled by a separate agent-mode toggle.
-- Preserve `supports_parallel_function_calling` as model metadata. The current agent loop does not use it to gate concurrent local execution.
-- Models without function calling use regular streaming. The web-search control is unavailable for those models.
-
-### Provider Notes
-
-| Provider | Tool Calling | Parallel | Notes |
-|----------|-------------|----------|-------|
-| OpenAI (GPT-4, GPT-4o) | ✅ | ✅ | Full support |
-| Anthropic (Claude 3.5+) | ✅ | ✅ | Uses OpenAI format via LiteLLM |
-| Ollama (Llama 3.1+, Qwen 2.5+) | ✅ | ❌ | Depends on model; check model_info |
-| Groq | ✅ | ✅ | Full support |
-| Mistral | ✅ | ✅ | Full support |
-
-LiteLLM Ollama models must use the `ollama_chat/` adapter for agent routing. The legacy `ollama/` adapter
-forces JSON output through `/api/generate`; the model repository removes `.functionCalling` for that route so regular chat
-streaming is used instead.
-
-## Implementation Architecture
-
-### Models
-
-```swift
-// Extend the existing ChatMessage and API models
-
-// Tool call in assistant response
-struct ToolCall: Codable, Sendable, Equatable, Identifiable {
- let id: String
- let type: String // Always "function"
- let function: ToolCallFunction
-}
-
-struct ToolCallFunction: Codable, Sendable, Equatable {
- let name: String
- let arguments: String // JSON string, must be parsed
-}
-
-// Tool result message (role: "tool")
-// Extend ChatMessage.Role to include .tool
-// Add toolCallId property to ChatMessage for role == .tool
-// Add toolCalls property to ChatMessage for assistant messages with tool calls
-```
-
-### Tool Registry
-
-```swift
-// Shared/Features/Chat/Models/ChatTool.swift
-
-nonisolated struct ToolExecutionResult: Sendable {
- let text: String
- let searchResults: [LiteLLMSearchResult]?
- let images: [GeneratedImage]
-
- init(text: String, searchResults: [LiteLLMSearchResult]? = nil, images: [GeneratedImage] = []) {
- self.text = text
- self.searchResults = searchResults
- self.images = images
- }
-}
-
-protocol ChatToolProtocol: Sendable {
- var definition: ToolDefinition { get }
- var isAvailableForAdvertisement: Bool { get }
- func execute(arguments: String) async throws -> ToolExecutionResult
-}
-
-extension ChatToolProtocol {
- var isAvailableForAdvertisement: Bool { true }
-}
-
-struct ToolDefinition: Codable, Sendable {
- let type: String // "function"
- let function: ToolFunctionDefinition
-}
-
-struct ToolFunctionDefinition: Codable, Sendable {
- let name: String
- let description: String
- let parameters: ToolParameters
-}
-```
-
-The chat registry includes enabled built-in tools, subject to their existing requirements: `get_current_datetime`,
-`save_memory` and `delete_memory` outside Private Chat, and `web_search` while web search is enabled. Image tools retain
-the conditions below. Each built-in is wrapped in `ConfiguredBuiltInTool` before MCP tools are appended. When MCP tools
-are configured on the LiteLLM server and enabled by the user, each enabled tool is wrapped in an `MCPTool` instance
-(conforming to `ChatToolProtocol`) and added to the registry. `ToolRegistry.execute` returns an "Unknown tool" result
-rather than throwing when a name is not registered.
-
-`ToolRegistry.definitions` filters all tools through `isAvailableForAdvertisement` on every access, in addition to MCP
-availability checks. Image tools are conditionally added by `ChatViewModel+ImageTools`; their availability is not static
-for the lifetime of a turn.
-
-### Image And Vision Delegation
-
-- Models has an **Image and Vision** section with independent optional `Vision` and `Image Generation` defaults,
- persisted as `selectedVisionModelId` and `selectedImageGenerationModelId`. Neither changes the principal chat model.
-- `None` disables delegation for that role only. Missing or ineligible selections remain stored and visible as unavailable;
- never silently choose another specialist or another transport. A failed native operation does not trigger delegation.
-- Vision specialists need `.vision` and a chat-compatible mode (`.chat`, `.completion`, or `.unknown`). Image specialists
- are dedicated `.imageGeneration` models or chat-compatible models with `.imageGeneration` capability.
-- One dual-capability chat model can be selected for both roles. The defaults are independent, not mutually exclusive.
-- Prioritize each native capability independently. `supportsNativeVision` checks `.vision`;
- `supportsNativeImageGeneration` checks dedicated image mode or `.imageGeneration` capability.
-- LiteLLM `supported_output_modalities` containing `image` adds generation capability without changing the model's mode.
- Vision alone never implies generation support; missing output metadata does not infer a dual-capability model.
-
-| Tool | Conditions for advertisement |
-|---|---|
-| `analyze_images` | Principal has `.functionCalling`, lacks native vision, a selected eligible vision specialist is present in the current catalog, and the conversation/turn has image attachments. |
-| `list_image_attachments` | Same availability as `analyze_images`; lists existing image UUIDs locally without a specialist request. |
-| `generate_image` | Principal has `.functionCalling`, lacks native image generation, and a selected eligible dedicated or chat image specialist is present in the current catalog. No source attachment is required; a reused turn with an existing generation result does not advertise another attempt. |
-
-Models without function calling never receive these tools. A principal with both native capabilities receives neither,
-even when specialist defaults are configured. Delegation is automatic once configured, without MCP approval prompts;
-the Models section warns that additional provider requests may incur costs.
-
-### Image References And Context
-
-- Keep original attachment UUIDs, metadata, and data in conversation state and normal persistence. Delegation does not
- replace stored images with descriptions or remove them from the visible conversation.
-- For principals without native vision, `ImageAttachmentContext.messagesForModel` projects image attachments into textual
- `image_attachment_ids` references. Only canonical UUIDs enter this reference JSON, never names, paths, MIME types,
- URLs, or bytes. References explicitly distinguish uninspected images from actual analysis results.
-- Apply the projection across history, including earlier user and assistant images, not just the latest user message.
- Use projected messages before context budgeting/usage estimation and before automatic compaction. Native-vision
- requests retain multimodal attachments; PDF text extraction stays on its existing path.
-- Build the tool's attachment inventory from original history before request budgeting or compaction drops old turns.
- Never embed the inventory as an `attachment_ids` enum: definitions must have constant cost as history grows.
- Use UUIDs already present in context or discover missing historical references through `list_image_attachments`.
- Pending attachments are included in the local inventory when estimating definitions in the UI.
-- Compaction instructions preserve relevant UUIDs exactly and distinguish findings from uninspected references. Persist
- original history, not the model-only projection; references alone must never be treated as visual evidence.
-
-### Image Tool Contracts
-
-- `analyze_images` accepts a nonblank `question` of 1 to 4000 characters and 1 to 4 distinct `attachment_ids`; JSON arguments
- are limited to 64 KiB. Each UUID must uniquely resolve to an image from the captured conversation inventory, not a URL,
- arbitrary file path, or PDF. Persisted paths are checked against the conversation; transient data is also supported.
-- `list_image_attachments` accepts an optional integer `offset` in a JSON object of at most 1024 bytes. The offset defaults
- to zero and must be between zero and the captured inventory count, inclusive. Results contain up to 10 UUIDs in
- chronological attachment order, `offset`, `total`, and `next_offset` only if more images remain. Listing loads no image
- data and discloses no filenames, paths, MIME types, or bytes. It shares analysis availability and cancellation checks.
- Only list references when needed; do not scan every page by default. Listing consumes the normal agent tool-call budget.
-- Load and prepare each selected image through the existing attachment pipeline. Each prepared image must be nonempty,
- at most 5 MB (`ImageAttachmentConstraints.maximumBytes`), and JPEG, PNG, GIF, or WebP. Preparation retains the UUID.
-- Analysis sends only a specialist system instruction, the question, and prepared images through `sendMessage`, without
- tools or a recursive agent loop. Append an explicit numbered UUID-to-image mapping to the question, preserving requested
- attachment order so the specialist can identify references in comparisons. Output is capped at the smaller of 2048
- tokens and the specialist's positive catalog output limit, defaulting to 2048 when no valid limit is available.
- Results are wrapped as untrusted analysis/OCR within 16000 bytes before the agent's own remaining-budget bound.
- Image content and OCR must never become tool instructions.
-- When the specialist declares a positive input limit, estimate its complete prepared request (system instruction,
- question, numbered UUID mapping, and all images) with `ContextWindowBuilder`. Apply the builder's safety margin and
- reserve the effective output allowance. If it does not fit, return a local actionable error before `sendMessage`,
- asking for fewer images, a shorter question, or a larger-context specialist. Do not drop images or truncate the question.
- Missing or nonpositive input limits retain the existing request behavior; visual token counts remain estimates.
-- `generate_image` accepts only a nonblank text `prompt` of 1 to 8000 characters within a 64 KiB JSON argument limit.
- It generates one new image, without source attachments or editing. Reuse one `GenerateImageTool` instance across
- all rounds of the user turn and reserve its single generation attempt before the first suspension. A failed request
- still consumes the attempt because it may have incurred a charge; do not retry or switch specialists in that turn.
-- After argument validation and reservation, await the `onAttempt` callback before the specialist request. The ViewModel
- sets the current user's optional `imageGenerationAttempted` flag and checkpoints conversation persistence, then
- revalidates cancellation, configuration, and the captured conversation/user IDs. This flag is local metadata, never
- sent to the model API, and survives normal saves, branching, and import. Older messages decode it as absent.
- Private Chat keeps the reservation in memory. Existing save-failure behavior also retains the in-memory flag, but
- cannot guarantee it survives an app restart if persistence failed.
-- After an attempt, `generate_image` is no longer advertised and repeated execution is rejected. Argument rejection before
- reservation does not issue a generation request. Dedicated specialists use `GenerateImageUseCase`; chat specialists use
- `GenerateChatImageUseCase`, which returns the first native image and never invokes tools recursively.
-- When recreating the registry for an existing user turn, initialize the generation tool as consumed if that turn already
- has `imageGenerationAttempted == true` on its user message or a `generate_image` tool result, including an error result.
- Keep it registered to reject explicit repeated calls,
- but omit it from advertised definitions and the additional generation timeout. Use the request's latest user turn, not
- results from older turns. A new user message, explicit edit-and-resend, or the native-generation restart path starts a
- fresh turn and clears the reservation; merely regenerating text or selecting another specialist does not reset it.
-- Both tools check cancellation and availability before execution, around preparation where applicable, and after the
- specialist request, including failure paths. A stale response must not be published as a successful result.
-- Availability captures the principal, specialist, and model-catalog authorization scope, then rechecks current selected
- IDs, conversation identity, catalog presence/eligibility, principal capabilities, and endpoint/credential scope.
- Specialist API clients capture
- the endpoint and credential pair rather than rereading mutable settings during requests. The loop's
- `isConfigurationCurrent` check and server-change cancellation provide an additional boundary against cross-endpoint data.
-
-### Generated Image Delivery
-
-- `ToolExecutionResult.images` is a typed, out-of-band `[GeneratedImage]` channel. Return only bounded textual status or
- analysis to the principal; never embed generated image bytes, base64, or data URLs in tool text, hidden tool transcripts,
- or model-facing tool results. Text truncation, including a zero-byte budget and untrusted-result wrapping, preserves images.
-- Emit `.generatedImage(GeneratedImage)` as soon as a tool returns, before collecting sibling task-group results, so a
- later sibling failure cannot discard an already completed image. Native final chat images use the same typed event.
- Keep legacy `.image(Data)` support; typed delivery preserves the actual decoded MIME type.
-- Attach images to the visible assistant message, not hidden tool-call messages. Checkpoint normal conversation persistence
- after every image event and `.transcriptAppended`, without waiting for final text. Image-only answers remain presentable,
- and already received attachments survive subsequent failure or cancellation. Private Chat retains them only in memory.
-- The loop continues with the textual tool result to produce the principal's response. Do not invent image URLs or claim
- the principal inspected a generated image merely because generation succeeded.
-- Regenerating text with a generation reservation or `generate_image` result in the latest user turn retains its images,
- even when cancellation occurred after image delivery but before sibling tools completed and the transcript was saved,
- before the replacement response starts, including on failure or cancellation. Results from older turns do not retain
- unrelated images. If the selected principal supports native generation, restart that latest turn from the user message
- instead: remove its reused assistant/tool messages and regenerate natively without accumulating earlier images.
-- `.usage` aggregates only principal completion usage; `.promptUsage` calibrates the principal context. Specialist analysis
- usage and specialist generation usage are not added to that model's tokens or priced as its consumption. Additional
- provider charges can exist without being represented by the principal's usage counters.
-
-### Agentic UseCase
-
-```swift
-// Shared/Features/Chat/UseCases/AgentStreamUseCase.swift
-
-nonisolated struct AgentToolContext: Sendable {
- let toolRegistry: ToolRegistry
- let additionalExecutionTime: Duration
- let isConfigurationCurrent: @MainActor @Sendable () -> Bool
-
- init(
- toolRegistry: ToolRegistry,
- additionalExecutionTime: Duration = .zero,
- isConfigurationCurrent: @escaping @MainActor @Sendable () -> Bool = { true }
- ) {
- self.toolRegistry = toolRegistry
- self.additionalExecutionTime = additionalExecutionTime
- self.isConfigurationCurrent = isConfigurationCurrent
- }
-}
-
-protocol AgentStreamUseCaseProtocol: Sendable {
- func execute(
- messages: [ChatMessage],
- model: String,
- parameters: ModelParameters,
- contextWindowTokens: Int?,
- toolContext: AgentToolContext
- ) -> AsyncThrowingStream
-}
-
-enum AgentEvent: Sendable {
- case token(String)
- case reasoning(String)
- case toolCallStarted(ToolCall)
- case toolCallCompleted(toolCallId: String, result: String, searchResults: [LiteLLMSearchResult]?)
- case transcriptAppended([ChatMessage])
- case usage(TokenUsage)
- case promptUsage(Int?)
- case image(Data)
- case generatedImage(GeneratedImage)
- case completed
-}
-```
-
-The use case manages non-streaming completion requests for the full loop, then emits final content and reasoning in locally
-paced, adaptively sized chunks for the existing streaming UI. Chunk count bounds requested pacing sleeps to approximately six
-seconds per reasoning or answer field, and the request timeout pauses once valid final content has arrived. Failures terminate
-the `AsyncThrowingStream`; there is no `.error` event. Assistant
-tool-call messages and matching tool messages are emitted through `.transcriptAppended` and persisted before the next round.
-
-Agent transcript messages remain in `ChatViewModel` for context and persistence, but presentation snapshots must exclude
-`.tool` messages and hidden assistant tool-call messages. Never retain raw tool-result payloads in SwiftUI view values.
-
-### ViewModel Integration
-
-`ChatViewModel.streamWithWebSearch` uses `AgentStreamUseCase` whenever the selected model has `.functionCalling`, whether or not web search is enabled. It uses `StreamMessageUseCase` only for models without that capability. Web search changes the registry contents; it does not select agent routing.
-
-## Streaming Considerations
-
-Tool calling and streaming can interact in two ways:
-
-### Current Behavior
-
-- Agent rounds use non-streaming chat completions.
-- Final content and reasoning are chunked locally into `AgentEvent` values.
-- Tools remain present after a normal tool round, allowing multiple rounds. They are omitted only when forcing a final response because of iteration or tool-call safeguards, or when an empty/`{}` model response requires a final retry.
-
-### Streaming Tool Calls (Advanced)
-
-- LiteLLM streams tool call deltas: `delta.tool_calls[0].function.arguments` builds up incrementally
-- Must accumulate argument fragments before parsing JSON
-- More complex but provides real-time feedback
-
-Do not implement streamed tool-call deltas unless the repository and event contract are deliberately changed and covered by tests.
-
-## UI Design
-
-### Automatic Agent Routing
-
-- There is no separate agent-mode toggle.
-- Function-calling models automatically receive the registry of enabled, eligible tools.
-- The globe control independently adds or removes `web_search` and requires both a configured search tool and a function-calling model.
-
-### Built-in Tool Settings
-
-- Settings has a dedicated **Tools** section, independent of the existing MCP section. `ToolsView` lists the seven
- `BuiltInTool` entries with a localized display name, technical name, description, and a switch matching Memory rows.
-- Built-ins can only be enabled or disabled; they cannot be edited or deleted. No user-created tools are implemented yet.
-- `SettingsManager` stores independent local preferences under `builtInToolEnabled.`. Missing values
- default to enabled, preserving existing behavior. App data reset clears these preferences.
-- `ConfiguredBuiltInTool` checks the current preference for advertisement and immediately before execution, including
- calls authorized before a setting changed. It preserves each tool's dynamic advertisement and execution requirements.
- An already-started operation is not cancelled solely because its preference changes.
-- Changes publish `builtInToolSettingsDidChange`; chat refreshes context usage and web-search availability. The globe
- is unavailable while the built-in search tool is disabled, without erasing the saved chat search preference.
-- System-prompt guidance omits disabled built-in instructions and requires the model to use only currently advertised tools.
-- Enabling a tool never bypasses Private Chat, model capabilities, attachment requirements, specialist selections, or
- generation limits. Disabling memory tools does not remove saved memories from context; disabling image tools does
- not disable a model's native image capabilities. MCP settings and authorization are independent.
-
-### Tool Execution Feedback
-
-During an agentic loop, show the user what's happening:
-
-```
-┌─────────────────────────────────────────────┐
-│ 🔍 Searching the web... │
-│ "Swift programming latest news 2026" │
-│ ✅ Found 5 results │
-│ │
-│ 🤖 Generating response... │
-│ Based on the search results, here are... │
-└─────────────────────────────────────────────┘
-```
-
-- Show each tool call as a collapsible step in the message
-- Use icons: 🔍 for search, ⚙️ for tools, ✅ for completed
-- Allow expanding to see tool arguments and results
-- Show a "thinking" indicator during each loop iteration
-
-### Message Display
-
-- Assistant messages with `tool_calls` should show a "Used tools" indicator
-- Tool result messages are internal — don't display them directly, but show a summary
-- The final assistant message displays normally with the grounded response
-
-## Conversation Persistence
-
-When persisting conversations with tool calling:
-
-- Store `tool_calls` array in assistant messages (already `Codable`)
-- Store tool result messages with `role: "tool"` and `tool_call_id`
-- On reload, the full message history (including tool results) must be preserved
-- Do NOT resend tools array when loading historical conversations (no new tool calls on old messages)
-
-## Error Handling
-
-- **Invalid JSON in arguments**: Return error to model as tool result, let it retry
-- **Tool execution failure**: Return error description as tool content
-- **Model doesn't support tools**: Fall back to regular chat (no tools parameter)
-- **Loop stuck**: The final iteration omits tools; another tool-call response throws `AgentStreamError.iterationLimitReached`, while an invalid forced-final response throws `.invalidResponse`.
-- **Network error during tool execution**: Show error, allow retry
-- **Unknown tool name**: Return "Unknown tool" as result, model can self-correct
-
-## Security
-
-- **Argument validation**: Parse and validate tool arguments before execution
-- **No arbitrary code execution**: Tools are predefined, no dynamic tool loading
-- **Rate limiting**: Apply rate limits to tool executions (especially web search)
-- **Content sanitization**: Sanitize tool results before injecting into messages
-- **Tool scope**: Function-calling models receive enabled built-in datetime and, outside Private Chat, enabled memory
- tools automatically. Web search additionally requires explicit opt-in through its globe toggle.
-- **Image scope**: Specialist defaults authorize only the eligible image tools described above; preserve native priority,
- per-turn generation limits, captured configuration, UUID-only analysis inputs, and untrusted-result boundaries.
-
-## Relationship with Web Browsing
-
-Web search (`web_search`) is the **first and primary tool** in the agent system:
-
-- A model with `.functionCalling` always uses the agent loop. When web search is ON, `web_search` joins the default registry and executes through `/v1/search/{search_tool_name}`.
-- Web search cannot be enabled unless the model has `.functionCalling` and a search tool name is configured; an unavailable globe is shown in red.
-- When web search is OFF, function-calling models still use the agent loop with datetime, eligible memory/image tools,
- and enabled MCP tools. Models without `.functionCalling` use regular streaming.
+# Agent Tool Calling
+
+## Routing
+
+- A dedicated `.imageGeneration` model uses the dedicated image flow.
+- Every other model with `.functionCalling` uses `AgentStreamUseCase` automatically. There is no separate agent-mode
+ setting; web search only changes registry contents.
+- Models without `.functionCalling` use regular chat streaming and receive no tools.
+- Agent rounds use non-streaming chat completions. Final reasoning, text, and native images are emitted as `AgentEvent`s;
+ text is paced locally for the streaming UI.
+
+## Tool Protocol And Transcript
+
+- Advertise OpenAI-compatible function definitions with `tools` and `tool_choice: "auto"`.
+- Treat `tool_calls` as authoritative structured calls. Each call requires a unique ID, function name, and JSON argument
+ string. Do not infer calls from plain assistant text.
+- Before returning results, append the assistant message containing the complete `tool_calls` array with null API content.
+ Append one `role: "tool"` message per call with the matching `tool_call_id`, tool name, and textual result.
+- Preserve call order in the transcript even when accepted calls execute concurrently. Return validation failures, denied
+ calls, unknown tools, execution failures, and budget rejections as matching tool messages so the protocol remains valid.
+- Append each complete assistant/tool transcript to the loop context and emit a persistence checkpoint before issuing the
+ next completion request. Emit generated images immediately so the consumer can checkpoint them as they arrive. Private
+ Chat retains checkpoints only in memory.
+- Persist `toolCalls`, `toolCallId`, and `toolName` in conversation history. Hide internal tool messages and assistant
+ tool-call messages from normal chat presentation without removing them from model context or persistence.
+- Tool text is model-facing; typed data such as `searchResults` and `GeneratedImage` travels separately in
+ `ToolExecutionResult` and `AgentEvent`.
+
+## Loop Safety And Completion
+
+- Cap an execution at 15 completion rounds, 20 accepted tool calls in total, and 8 accepted calls in one round.
+- Rebudget context after each tool round. Preserve the latest complete user turn atomically and bound every textual tool
+ result to the remaining input budget. Typed images and search-source metadata must survive text truncation.
+- The last round, an exhausted total call budget, or excess calls in a round forces the next completion without tools.
+ A tool call in that forced-final response is an iteration-limit failure.
+- An empty response or literal `{}` gets one forced-final retry without tools. Another invalid final response fails.
+- A presentable response may contain text, reasoning, or native images. Emit `.completed` only after such a final response.
+- The agent has a bounded active-time budget, extended only for image generation. Pause that budget while MCP approval is
+ pending. Cancellation must terminate model requests, authorization, tool work, and stale event publication.
+- Capture the endpoint/credential authorization scope for a run. Revalidate it around every model or tool request and
+ cancel the run when server configuration changes.
+
+## Registry
+
+- `ToolRegistry` contains eligible built-ins first and enabled, currently discovered MCP tools afterward. Definitions are
+ filtered dynamically, and execution rechecks availability and configuration.
+- Built-ins are `get_current_datetime`, `save_memory`, `delete_memory`, `web_search`, `analyze_images`,
+ `list_image_attachments`, and `generate_image`. Wrap them in `ConfiguredBuiltInTool` so local enablement is checked both
+ when advertising and when executing.
+- Memory tools are unavailable in Private Chat. Search and image tools retain the prerequisites defined in their focused
+ specifications. Enabling a built-in never bypasses those prerequisites.
+- A registry lookup for an unknown name returns a bounded textual result rather than breaking the transcript.
## MCP Tools
-MCP (Model Context Protocol) tools are external tools provided by MCP servers configured on the user's LiteLLM backend. They are discovered, persisted, and executed through a dedicated client-side pipeline that integrates transparently with the agent loop.
-
-### Discovery
-
-- `FetchMCPToolsUseCase.execute()` never throws and returns an `MCPDiscoveryResult`. It calls `GET /v1/mcp/server`, then concurrently calls `GET /mcp-rest/tools/list?server_id=X` for each server using one captured endpoint/credential pair.
-- Discovered tools are stored in `ChatViewModel.LoadedState.availableMCPTools`. MCP discovery starts after the initial model state is available and can be refreshed independently.
-- A top-level failure returns an empty result with an error. Partial failures retain prior tools for management visibility but mark their servers failed, so those tools are neither configurable nor advertised until a refresh succeeds.
-
-### Tool Definition Conversion
-
-Each supported `MCPToolInfo` is wrapped in an `MCPTool` that conforms to `ChatToolProtocol`. `MCPTool.toolParameters(from:)` converts the recursive `MCPJSONSchema` into `ToolParameters`:
-
-- Object-root schemas are forwarded intact, including provider-specific annotations and standard constraints such as
- defaults, numeric bounds, lengths, and unions.
-- The decoded structural subset is validated locally before authorization and execution; the MCP server remains
- authoritative for constraints the client does not interpret.
-- A malformed schema, provider-specific non-object placeholder, or non-object root remains visible for management but is
- not advertised or executable.
-
-### Execution
-
-- `MCPTool.execute(arguments:)` delegates to `MCPRepository.executeTool()` → `APIClient.callMCPTool()` → `POST /mcp-rest/tools/call`.
-- The request body includes `server_id`, `name` (the original un-prefixed tool name), and `arguments` parsed from the JSON string the LLM produced.
-- The response `content` items are joined and returned as a `ToolExecutionResult`.
-
-### User Management
-
-- An MCP antenna icon next to the web search globe opens the `MCPToolsSheet`.
-- The sheet lists every discovered tool with a toggle; toggling a tool persists the `enabledMCPToolIds` set via `SettingsManager`.
-- Server detail includes bulk controls directly below the enable-all switch to update availability or permission for every
- configurable tool with one persisted state change.
-- The `ChatViewModel+Agent.makeToolRegistry()` method reads the enabled set and only injects activated `MCPTool` instances.
-- MCP descriptions are carried by formal tool definitions. The custom agent system prompt does not duplicate MCP tools, so
- disabling a tool removes its advertisement from subsequent rounds of an active response.
-- A corresponding MCP section in Settings allows the same tool management and re-fetch from a Settings context.
-
-### Authorization
-
-- Every enabled MCP tool defaults to `ask`; built-in tools and web search remain automatic.
-- Policies are `alwaysAllow`, `ask`, and `deny`. A denied enabled tool remains advertised and returns an ordered synthetic
- denial result, while a disabled or stale-configuration tool is omitted from definitions.
-- Bulk permission changes update every configurable tool in the selected server and emit one settings notification.
-- Policy identity includes the normalized endpoint, an opaque credential scope stored in Keychain, server/tool identity,
- description, and the complete canonical input schema. Changing any of them invalidates the prior policy.
-- Calls requiring approval are presented as one batch before any tool in that round starts. Decisions remain per call;
- permanent choices apply to every repeated call of the same tool and are persisted only when the user continues.
-- On iOS, the approval sheet uses the medium detent with internal scrolling. The accessible close action denies every
- pending request without competing with the centered title.
-- Closing the review denies every pending call once. Stopping or leaving the chat cancels the pending authorization.
-- Tool availability, configuration identity, enablement, and policy are revalidated immediately before execution. A server
- configuration change also cancels the active agent so conversation data cannot cross endpoints.
-- Malformed or non-object input schemas and tools retained from a failed server are omitted from model definitions and
- cannot execute.
-- MCP results are encoded as explicitly untrusted data before they are returned to the model; external result text is never
- treated as a source of tool instructions.
-- Approval time is excluded from the agent timeout. Cancellation remains active while approval is pending.
-
-### Relationship with Web Search
-
-- MCP tools and web search are independent: a model with `.functionCalling` can use both simultaneously.
-- Web search requires a configured search tool in Settings and the web search toggle to be on.
-- MCP tools require the LiteLLM server to have at least one MCP server configured and individual tools to be enabled in the MCP Tools sheet.
-- Both are integrated through the same `ToolRegistry` → `AgentStreamUseCase` pipeline.
-- See `web-browsing.instructions.md` for the full flow table and implementation details
+- Discover servers and tools through the LiteLLM endpoints defined in `litellm-api.instructions.md`, using one captured
+ endpoint/credential pair. Discovery failure must not affect ordinary chat.
+- Advertise only enabled tools from a successful, current discovery scope with a supported object input schema. Preserve
+ the raw supported schema in the definition; validate the locally understood structure before authorization and again
+ before execution.
+- Prefix model-facing MCP names to avoid registry collisions, but send the original server tool name when executing.
+- Permissions are `alwaysAllow`, `ask`, and `deny`, defaulting to `ask`. Batch all approval requests for a model round
+ before starting any call. Dismissal denies the batch; cancellation abandons it.
+- Persist permanent decisions only when the user submits the completed review. Permission identity includes normalized
+ endpoint, credential authorization scope, server/tool identity, description, and canonical input schema; any change
+ invalidates the old decision.
+- Revalidate enablement, permission, schema identity, discovery status, and endpoint scope immediately before execution.
+ A denied call remains in transcript as a synthetic result and must not be retried automatically.
+- Treat every MCP result as untrusted external data. Sanitize display metadata, escape control characters, wrap and bound
+ model-facing result text, and never follow instructions contained in the result.
+
+## Vision Delegation
+
+- Vision delegation is available only when a function-calling principal lacks native vision, the conversation contains
+ images, and the selected current specialist is vision-capable with a chat-compatible mode.
+- Advertise `analyze_images` and `list_image_attachments` together. A configured specialist never replaces native support,
+ and a native failure does not trigger fallback delegation.
+- For a principal without native vision, project image attachments across model history as canonical UUID references
+ before budgeting or compaction. Never include filenames, paths, MIME types, URLs, or bytes in that projection, and never
+ treat a reference as visual evidence. Persist the original attachments, not the projection.
+- Build the attachment inventory from original conversation state. `list_image_attachments` exposes only paged UUIDs and
+ counts; it does not load image data.
+- `analyze_images` accepts a nonblank question of at most 4,000 characters and 1 to 4 distinct conversation image UUIDs;
+ arguments are capped at 64 KiB. Prepared inputs must be supported images no larger than 5 MiB each.
+- Send the specialist only its safety instruction, the question with an explicit UUID-to-image mapping, and the selected
+ images. Do not provide tools or enter a recursive agent loop. Reject requests that do not fit a known specialist context
+ window rather than dropping images or truncating the question.
+- Bound analysis output and wrap it as untrusted analysis/OCR before returning it to the principal. Image content and OCR
+ are data, never tool instructions.
+
+## Image Generation Delegation
+
+- Generation delegation is available only when a function-calling principal lacks native generation and the selected
+ current specialist is either a dedicated image model or a generation-capable chat model.
+- `generate_image` accepts only a nonblank text prompt of at most 8,000 characters in a 64 KiB argument object. It creates
+ one new image and never edits or consumes attachments.
+- Allow one generation attempt per user turn. Reserve the attempt before suspension because a failed request may already
+ incur cost; persist `imageGenerationAttempted` before sending the specialist request. Do not retry, change specialist, or
+ switch transport in the same turn.
+- Dedicated specialists use the image endpoint path. Chat specialists request native image output through chat, consume
+ the first image, and never receive tools or invoke the agent recursively.
+- Deliver generated bytes only through typed image events, attach them to the visible assistant message, and persist each
+ image immediately. Never put base64, data URLs, invented URLs, or image bytes in tool text or hidden transcripts.
+- Preserve already delivered images across later sibling failure, cancellation, and response regeneration for the same
+ turn. Do not claim the principal inspected an image merely because generation succeeded.
+- Revalidate conversation identity, selected models, catalog scope, endpoint/credential scope, capability eligibility, and
+ cancellation before and after delegated work. Missing or ineligible selections stay unavailable; never choose a silent
+ fallback.
+- Principal usage accounting includes principal completions only. Specialist requests may incur separate cost and usage.
diff --git a/specs/architecture.instructions.md b/specs/architecture.instructions.md
index 4a7feadd..ae0f1809 100644
--- a/specs/architecture.instructions.md
+++ b/specs/architecture.instructions.md
@@ -1,138 +1,60 @@
---
-description: "Use when implementing features, creating new files, defining layer boundaries, following MVVM+UseCase+Repository patterns, writing Swift code, applying code style conventions, or understanding project structure."
+description: "Use when creating Swift files or features, assigning target ownership, or changing View, ViewModel, UseCase, Repository, Manager, networking, or storage boundaries."
applyTo: "**/*.swift"
---
-# OpenClient Architecture
+# Architecture
-## Current Project Layout
+## Target Ownership
-The Xcode project currently has **six native targets**, all backed by File System Synchronized Groups:
+- Put code shared by the iOS/iPadOS and macOS apps in `openclient-llm/Shared/`.
+- Put genuinely platform-specific app code in the corresponding app target directory. Use conditional compilation only
+ for small platform differences inside otherwise shared code.
+- Keep `ShareExtension` independent from the shared feature layer. Maintain compatible transfer types at its App Group
+ boundary rather than coupling the extension to app-only code.
+- Put widget code shared across platforms in `WidgetsShared/`. Widget extensions must not depend on the shared app feature
+ layer.
+- Treat files intentionally compiled into several apps or extensions as cross-target contracts. Verify target membership,
+ platform availability, persistence compatibility, and extension-safe APIs when changing them.
+- Read target membership, deployment settings, build settings, and package dependencies from the Xcode project. Do not
+ infer them from folder names or duplicate inventories in guidance.
-```text
-openclient-llm/ # iOS/iPadOS app target
-├── App/ # iOS app and scene delegates
-├── Resources/ # iOS plist, entitlements, test plan
-└── Shared/ # Compiled by both app targets
- ├── Core/
- │ ├── Extensions/
- │ ├── Managers/
- │ ├── Models/
- │ ├── Networking/
- │ ├── Utils/
- │ └── Views/
- ├── Features/ # Feature-owned Models/Repositories/UseCases/ViewModels/Views as needed
- └── Resources/ # Shared assets, localization, icon, and Poppins fonts
-
-openclient-llm-macOS/ # macOS app target
-├── App/
-├── Resources/
-└── Views/ # macOS-only menu bar and command UI
-
-openclient-llm-test/ # iOS-hosted XCTest unit test target
-├── Core/
-├── Features/
-└── Mocks/
-
-ShareExtension/ # Standalone iOS Share Extension
-└── App/Models/ # Duplicates its small App Group transfer model/store intentionally
+## Layering
-WidgetsShared/ # Sources and resources shared by both WidgetKit extensions
-├── App/ # Widgets, controls, intents, App Group models, and shared @main bundle
-└── Resources/ # Shared widget assets
-
-WidgetsExtension-iOS/ # Native iOS/iPadOS WidgetKit extension
-└── Resources/ # iOS plist and Data Protection/App Group entitlements
-
-WidgetsExtension-macOS/ # Native macOS WidgetKit extension
-└── Resources/ # macOS plist and App Group entitlements
-```
-
-The macOS target includes the synchronized `openclient-llm` group as well as its own group. Shared views therefore live in
-`openclient-llm/Shared/Features/.../Views`, not in a separate iOS `Views/` directory. `ShareExtension`,
-`WidgetsExtension-iOS`, and `WidgetsExtension-macOS` do not link the shared feature layer; they communicate through
-`group.com.artcc.openclient-llm` and deep links. Through synchronized-group membership exceptions, `AppGroupStore.swift`,
-`WidgetConversation.swift`, and `WidgetControlStore.swift` are compiled into both apps and both widget extensions.
-
-Feature folders are pragmatic rather than uniform. Create only the subfolders a feature needs. `Shortcuts`, for example,
-currently contains intents directly, while larger features use several layer folders.
-
-## Current Layering
-
-The dominant flow is:
+The usual dependency direction is:
```text
View -> ViewModel -> UseCase -> Repository -> APIClient / local storage
- \----> Manager
+ \-----------------------> Manager
```
-- Views own `@Observable` ViewModels with `@State`, render state, and send events.
-- ViewModels are explicit `@MainActor` classes and generally expose a nested `Event`, `State`, and `LoadedState`.
-- UseCases represent operations, but some are thin adapters over Managers or `APIClient` rather than Repository clients.
-- Repositories handle network mapping, attachments, and conversation persistence where that abstraction is useful.
-- Managers provide settings, keychain, cloud, audio, App Group, notification, and system-service integration.
-- `APIClient` is the OpenAI-compatible networking and streaming boundary.
-
-Current code does **not** enforce a pure ViewModel-to-UseCase boundary. Several ViewModels inject Managers directly,
-including settings, memory, profile, cloud sync, shortcuts, sharing, and URL-scheme services. Treat that as current
-implementation, not as evidence that every new dependency should bypass a UseCase.
-
-## Preferred Rules For New Work
-
-- Preserve the existing View -> ViewModel -> UseCase -> Repository/Manager flow when it adds a meaningful business or
- test seam. Do not add a pass-through UseCase solely to satisfy a diagram.
-- Views must not perform persistence, networking, or business decisions.
-- Prefer protocol-backed dependencies and initializer injection for testable boundaries.
-- A ViewModel may use a Manager directly when it represents UI-facing state or a system service and a UseCase would only
- forward the same call. Follow the nearest feature's established pattern.
-- Keep `LogManager` available as a static diagnostic utility at any layer.
-- Put code used by both apps under `openclient-llm/Shared/`. Put genuinely platform-only app code in the corresponding
- target directory. Use `#if os(iOS)` or `#if os(macOS)` for small differences inside otherwise shared views.
-- Do not move extension/widget code into Shared unless target membership and extension constraints are deliberately
- changed.
-- Use `@Observable`, not `ObservableObject` or `@Published`, and keep explicit `@MainActor` on ViewModels.
-- Prefer `async`/`await` and native throwing APIs. `Result` remains appropriate for configurable test doubles and stored
- outcomes.
-
-## ViewModel Shape
-
-Use the Event/State shape for screen ViewModels, while allowing feature-specific states and synchronous or asynchronous
-event handling:
-
-```swift
-@Observable
-@MainActor
-final class FeatureViewModel {
- enum Event {
- case viewAppeared
- }
-
- enum State: Equatable {
- case loading
- case loaded(LoadedState)
- }
-
- struct LoadedState: Equatable {}
-
- private(set) var state: State = .loading
-
- func send(_ event: Event) {
- switch event {
- case .viewAppeared:
- state = .loaded(.init())
- }
- }
-}
-```
-
-Extensions such as `ChatViewModel+Streaming.swift` are an established way to split a large feature while retaining one
-ViewModel type. Do not force every type or helper into this template.
-
-## Maintenance
-
-- File System Synchronized Groups usually discover new files automatically, but verify target inclusion and platform
- compilation when adding files under a shared group.
-- Update `ARCHITECTURE.md` when targets, top-level directories, feature modules, layer ownership, or platform strategy
- change. It is a structural overview, not an inventory that must list every source file.
-- Keep detailed style, concurrency, testing, and SwiftUI rules in their focused specifications rather than duplicating
- them here.
+- Views render state and emit events. They do not perform persistence, networking, or business decisions.
+- ViewModels coordinate screen behavior and own UI state. Use `@Observable`, keep explicit `@MainActor`, and prefer
+ `send(_:)` as the UI event entry point while preserving established awaitable APIs where needed.
+- UseCases represent meaningful operations or business rules. Do not create a pass-through UseCase only to satisfy the
+ nominal layer sequence.
+- Repositories own data access, mapping, and persistence abstractions where those boundaries add value.
+- Managers provide transversal settings, credentials, sync, routing, device, and SDK services. A ViewModel may depend on a
+ Manager directly when it represents UI-facing state or a system service and a UseCase would only forward the call.
+- `APIClient` is the networking and streaming boundary. Feature-specific request and response mapping belongs near the
+ repository or feature that owns the contract.
+- Prefer protocol-backed dependencies and initializer injection at useful test seams.
+- Keep asynchronous ownership and state mutation in the ViewModel rather than starting unowned work from Views.
+
+## Feature Structure
+
+- Organize feature code by ownership, creating only the Models, Repositories, UseCases, ViewModels, and Views folders the
+ feature actually needs.
+- Follow the nearest feature when choosing between a single file and a cohesive `Type+Concern.swift` split.
+- Avoid moving code into `Core` merely because it is reusable once; promote it only when it has stable cross-feature
+ ownership.
+- Preserve persisted formats, deep links, App Group identifiers, and extension contracts unless migration and compatibility
+ are explicitly part of the task.
+
+## Structural Changes
+
+- New Swift files require the standard repository header and correct target inclusion.
+- Update `ARCHITECTURE.md` when targets, top-level ownership, layer responsibilities, or platform strategy intentionally
+ change. It is an overview, not a source inventory.
+- Apply `concurrency.instructions.md`, `code-style.instructions.md`, and platform/UI specs in addition to this file when
+ their scopes are involved.
diff --git a/specs/changelog.instructions.md b/specs/changelog.instructions.md
index 173dce0a..be3bc50c 100644
--- a/specs/changelog.instructions.md
+++ b/specs/changelog.instructions.md
@@ -9,50 +9,23 @@ applyTo: "**/CHANGELOG.md"
The changelog follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-### Version header
-
-```markdown
-## [MAJOR.MINOR.PATCH-build-N] - YYYY-MM-DD
-```
-
-- `MAJOR.MINOR.PATCH` follows SemVer
-- Released headers append `-build-N` inside the brackets (e.g. `## [1.4.5-build-57] - 2026-07-15`). Treat this as the project version format even though the build suffix is not SemVer build metadata.
-- Date is ISO 8601 (e.g. `2026-04-03`)
-- Unreleased work goes under `## [Unreleased]` at the top
-
-### Sections (in order, omit empty ones)
-
-```markdown
-### Added
-### Changed
-### Deprecated
-### Removed
-### Fixed
-### Security
-```
+- Released headers use `## [MAJOR.MINOR.PATCH-build-N] - YYYY-MM-DD`. `MAJOR.MINOR.PATCH` follows SemVer; the build
+ suffix is the project's release format, not SemVer build metadata.
+- Each published build has its own section, including consecutive builds with the same marketing version.
+- Use `## [Unreleased]` only when the user explicitly requests work not assigned to a build. When present, keep it above
+ numbered releases.
+- Within each release, use Keep a Changelog sections in this order and omit empty ones: `Added`, `Changed`,
+ `Deprecated`, `Removed`, `Fixed`, `Security`.
+- Dates use ISO 8601.
## Entry Style
-- Prefer one entry per bullet (`-`). Existing releases sometimes use nested bullets for a single grouped feature such as widget variants; preserve that historical structure and use it only when it materially improves clarity.
+- Use one concise sentence per bullet and one logical change per entry.
- Start with a noun or past-tense verb describing what changed, not who changed it
- Be specific: include the affected type, file, or feature name where helpful
- Do not mention PR numbers, commit hashes, or author names
-- Keep entries concise — one sentence max
- Group related entries under the same section, not by file or layer
-
-**Good:**
-```
-- Pull-to-refresh in the Models screen (iOS/iPadOS)
-- `LogManager` debug logging system with emoji-differentiated log levels — only active in DEBUG builds
-- Keychain queries updated to include `kSecUseDataProtectionKeychain: true` on all operations
-```
-
-**Bad:**
-```
-- Fixed a bug
-- Updated some files
-- Refactored ChatViewModel (see PR #42)
-```
+- Do not introduce nested bullets; preserve them only where they already exist in historical releases
## What to Document
@@ -75,34 +48,20 @@ The changelog follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) a
## When to Update
-Update `CHANGELOG.md` when:
-- A feature is fully implemented and tested
-- A bug fix is confirmed working
-- A breaking change is introduced
-
-Do **not** update the changelog speculatively or mid-implementation.
+Update `CHANGELOG.md` only when a feature or breaking change is implemented, or a bug fix is confirmed. Do not add
+speculative or mid-implementation entries.
## Unreleased Section
-Use `## [Unreleased]` for changes not yet assigned to a build number:
-
-```markdown
-## [Unreleased]
-
-### Added
-- ...
-
-### Fixed
-- ...
-```
-
-When a build is released, replace `[Unreleased]` with the version + date.
+When publishing entries already recorded under `Unreleased`, move them into a new numbered section. Retain an empty
+`Unreleased` heading only if the user wants to keep that workflow. Never replace, merge into, or rename a historical
+numbered section.
## Rules
- Do not rewrite historical entries merely for tone, punctuation, formatting, or to match newer conventions.
-- Narrow historical corrections are allowed when current code or an authoritative release artifact proves an entry factually wrong (for example, storage location, endpoint, default value, or shipped widget behavior). Keep the original release placement, change only the inaccurate wording, and do not recast unreleased work as shipped.
-- Never group multiple distinct changes into a single bullet
-- The most recent version always appears at the top
+- Narrow historical corrections are allowed only when current code or an authoritative release artifact proves an entry
+ factually wrong. Keep its release placement and change only the inaccurate wording.
+- Numbered releases appear newest first, below `Unreleased` when present.
- Keep the introductory paragraph (Keep a Changelog + SemVer links) unchanged
-- Historical sections contain style and factual inconsistencies. Do not copy an inconsistency into a new release, and do not perform a bulk cleanup while making an unrelated changelog update.
+- Do not copy historical inconsistencies into new entries or clean them up as part of unrelated work.
diff --git a/specs/chat-visual-style.instructions.md b/specs/chat-visual-style.instructions.md
index a0e3127a..c78d3456 100644
--- a/specs/chat-visual-style.instructions.md
+++ b/specs/chat-visual-style.instructions.md
@@ -1,298 +1,82 @@
---
-description: "Use when designing chat interfaces, message layouts, input bars, empty states, streaming indicators, suggestion chips, or any conversational AI UI patterns."
-applyTo: "**/*.swift"
+description: "Use when changing OpenClient chat messages, attachments, composer, streaming presentation, Markdown, actions, or scrolling."
+applyTo: "openclient-llm/Shared/Features/Chat/Views/**/*.swift"
---
-# Chat App Visual Style Guide
-
-## Design Philosophy
-
-Modern, clean conversational interface inspired by leading AI chat applications. Focus on content readability, minimal chrome, and clear role differentiation between user and assistant messages.
-
-## Message Layout
-
-### General Principles
-
-- Messages flow vertically in a scrollable container
-- Clear visual distinction between user and assistant roles
-- Content-first: minimize decorative elements around message text
-- Generous spacing between messages for readability (12-16pt)
-- New message rows currently use an opacity transition. Do not document or add slide motion unless the interaction is
- deliberately redesigned.
-
-### User Messages
-
-- Right-aligned with left margin (minimum 60pt spacer on leading side)
-- Accent-tinted regular glass background
-- Rounded container with moderate corner radius (16-20pt)
-- Standard body text with fixed white foreground on the accent-tinted glass
-- No avatar needed — right alignment is sufficient to identify role
-
-### Assistant Messages
-
-- Left-aligned, extending to near full width (small trailing margin, minimum 40pt)
-- **No bubble background** — plain text directly on the view background
-- Small role indicator icon on the leading edge (avatar)
-- Standard body text with primary foreground color
-- Markdown rendering for formatted content (bold, italic, inline code, links)
-
-### Role Indicators (Avatars)
-
-- Small circular icon (28-32pt) on the leading edge of assistant messages only
-- Glass effect on the avatar circle for visual consistency
-- Avatar top-aligned with the first line of message content
-- Use SF Symbols or app-branded icon for the assistant
-- User messages do NOT need avatars — alignment differentiates roles
-
-### Metadata
-
-- Message timestamps use `.caption2`/tertiary styling and sit outside message surfaces. User timestamps are below and
- trailing the accent-tinted glass; assistant timestamps are trailing in the metadata row.
-- Optional token usage is shown in the completed assistant message metadata row.
-- While the user manually scrolls through history, a centered, noninteractive glass date capsule identifies the day of
- the first visible message. Visibility observations are presentation-only and must never drive automatic scrolling.
-- Do not make timestamps hover-only; they must remain available on touch platforms.
-
-## Input Bar
-
-### Design
-
-- Floating pill/capsule shape at the bottom of the chat
-- Glass effect background (`.glassEffect(.regular, in: .capsule)`)
-- Multi-line text input with dynamic height (1-5 lines)
-- No visible border on the text field — use `.plain` text field style; the glass provides the visual boundary
-- Generous padding inside the pill (horizontal: 16pt, vertical: 10pt)
-- Horizontal padding outside the pill for screen margins
-
-### Send Button
-
-- Circular filled icon positioned INSIDE the input pill, trailing edge
-- Icon: `arrow.up.circle.fill` with accent color
-- Appears when trimmed input is non-empty and a model is selected; otherwise a microphone action is shown when speech
- input is available
-- Smooth scale + opacity transition when appearing/disappearing
-- Keep a minimum 44×44pt hit target even though the current icon uses `.font(.title)`
-
-### Stop Button (During Streaming)
-
-- Replaces the send button when a response is being streamed
-- Icon: `stop.circle.fill` with destructive/red style
-- Tapping cancels the current stream immediately
-- Same size and position as the send button for visual consistency
-
-### Placeholder
-
-- Conversational tone: short and inviting (e.g., "Message...")
-- `.secondary` foreground color (default TextField behavior)
-
-## Empty State / Welcome Screen
-
-### Layout
-
-- Centered vertically within the scrollable message area
-- Maximum width constraint (~400pt) for readability on larger screens
-- Content: icon + greeting + optional subtitle + suggestion chips
-
-### Welcome Content
-
-- Large assistant icon (60-80pt) with glass effect circle background
-- Friendly greeting text uses Poppins SemiBold relative to `.title2`; most surrounding text uses semantic system fonts
-- Optional subtitle in `.subheadline` font, `.secondary` color
-- Vertically centered with generous spacing between elements
-
-### Suggestion Chips
-
-- 2-4 tappable prompt suggestions below the welcome area
-- Arranged in a 2-column grid (`LazyVGrid` with 2 flexible columns, 12pt spacing)
-- Each chip: SF Symbol icon + short text label
-- Glass effect with `.interactive()` for tap feedback
-- Rounded rectangle shape (cornerRadius 14-16pt)
-- `.subheadline` font, left-aligned content inside the chip
-- Tapping a chip immediately sends the prompt as a message
-- Chips are only visible when there are no messages
-
-## Model / Agent Selector
-
-- Positioned as the navigation bar's **principal** item (centered in toolbar)
-- Uses `Menu` with a label showing the current model name + chevron-down icon
-- `.headline` font weight for the model name, `.caption2` for the chevron
-- `lineLimit(1)` to prevent overflow on long model names
-- Tapping opens a dropdown/popup with available models
-- Selected model shows a checkmark in the menu
-- Fallback text when no model is selected (e.g., "No Model")
-- No additional icons (cpu, brain, etc.) — keep it clean: just text + chevron
-
-## Streaming Indicators
-
-### Typing Cursor
-
-- Append a blinking solid cursor character (`█`) to the final streaming text block.
-- Toggle it every 500 ms while streaming and remove it when streaming completes.
-- Before answer or reasoning content exists, show a static localized `Thinking...` label. Once text exists, the integrated
- cursor is the answer-stream indicator.
-- Streaming reasoning may blink the Thinking disclosure label on the same discrete 500 ms cadence; do not use a continuous
- phase animation inside scroll targets.
-
-### Progressive Rendering
-
-- Tokens appear immediately as they arrive from the stream
-- After the first server-streamed fragment, coalesce routine text mutations on a 50 ms cadence; payload size must not force
- additional same-frame publications, while lifecycle and non-text events may flush once to preserve content and event order
-- Locally simulated agent final content already arrives in bounded, 20 ms paced chunks and must bypass the server-streaming
- coalescer to avoid combining both presentation layers
-- Use `ScrollViewReader` with explicit top and bottom sentinels; never bind `ScrollPosition` to the chat scroll view
-- Keep message rows in an eager `VStack`. `LazyVStack` can enter a non-converging layout pass when upward user scrolling
- overlaps live message updates, freezing both iOS and macOS
-- Starting a response and each coalesced text publication scroll to the bottom without animation only while bottom-follow
- mode remains active
-- Manual scrolling detaches bottom-follow immediately; only a new response or the bottom button resumes it
-- Do not show a loading spinner for answer generation; use the current cursor/Thinking states
-
-## Message Entry Animations
-
-- Current message rows use `.transition(.opacity)`.
-- Top/bottom scroll buttons appear only after manual detachment and do not animate the scroll container.
-- Do not add a container-wide animation keyed to every token or message mutation; streaming updates should remain stable
- and readable.
-
-## Markdown Rendering
-
-### Inline Formatting (Minimum Viable)
-
-- Use `AttributedString(markdown:)` with `.inlineOnlyPreservingWhitespace` parsing option
-- Supports: **bold**, *italic*, `inline code`, [links], ~~strikethrough~~
-- Graceful fallback to plain text if markdown parsing fails
-- Apply to assistant messages only — user messages stay as plain text
-- While an answer streams, render its source as immediate plain text with the integrated cursor; apply full Markdown once
- after streaming completes
-- Parse structural and inline Markdown outside `MainActor`, cache the rendered result, and never invoke
- `AttributedString(markdown:)` from a SwiftUI `body`
-
-### Code Blocks (Current)
-
-- `MarkdownParser` splits fenced blocks from text and headings.
-- `CodeBlockView` uses `.system(.body, design: .monospaced)`, selectable text, and horizontal scrolling.
-- A language label, or localized "Code" fallback, appears in the header.
-- The copy button changes to a green checkmark/"Copied" state for two seconds.
-- The surface is `.ultraThinMaterial` with a subtle rounded stroke. Syntax highlighting is not implemented.
-
-## Color Guidelines
-
-### Principles
-
-- Use semantic system colors — adapts automatically to Light/Dark mode
-- Chat background: `Color(.systemBackground)`
-- User message bubble: glass effect (no custom color needed, glass adapts)
-- Assistant message: no background — text on systemBackground
-- Input bar: glass effect
-- Avatars: glass effect circles with accent-tinted icons
-- Error states: `Color.red` (system) for banners and indicators
-
-### Dark Mode
-
-- Glass effects adapt automatically — no manual color changes needed
-- Ensure sufficient contrast for text on glass surfaces
-- Test with varied wallpapers / desktop backgrounds
-
-## Navigation Context
-
-### Chat as Primary View
-
-- The chat interface should feel like the primary experience of the app
-- Navigation title area is used for the model selector, not a static title
-- Minimal toolbar items — only what's essential for the current context
-- Navigation bar uses inline display mode to maximize content area
-
-## macOS Chat Adaptations
-
-The chat interface shares the same core layout across platforms, but macOS requires subtle adjustments for a native desktop feel.
-
-### Input Bar
-
-- Same glass capsule input bar as iOS
-- On macOS, the text field should use `.textFieldStyle(.plain)` — consistent with iOS (glass provides the chrome)
-- Send/stop button: use `.buttonStyle(.plain)` since it's an icon inside the glass pill — same as iOS
-- No `.submitLabel()` on macOS — handle Enter key via `.onSubmit {}` (same behavior, no modifier needed)
-
-### Message Bubbles
-
-- Same layout rules as iOS (user right-aligned with glass, assistant left-aligned without background)
-- On macOS, user bubble glass may render slightly differently due to window backgrounds — test with various desktop wallpapers
-- Message timestamps and the transient date capsule use the same shared behavior as iOS; there is no macOS-only hover state
-
-### Action Buttons Inside Messages
-
-- Most inline message actions use `.buttonStyle(.plain)`.
-- `CodeBlockView` currently uses `.bordered` with `.controlSize(.small)` for its labeled copy action on macOS and plain
- style on iOS. Preserve that implemented distinction unless the code-block header is redesigned.
-
-### Scroll Behavior
-
-- The main macOS chat scroll keeps native indicators. `CodeBlockView` intentionally hides its horizontal indicator while
- retaining horizontal scrolling for long lines.
-- `.scrollDismissesKeyboard()` is iOS-only — omit on macOS (already guarded by `#if os(iOS)`)
-- Elastic overscroll is native on macOS — don't disable it
-- On macOS the keyboard notification for scroll adjustment is not needed — the keyboard doesn't overlay content
-
-### Model Selector (Toolbar)
-
-- Same `Menu` + chevron pattern as iOS, with Poppins SemiBold model text and middle truncation at 200 points
-- The shared Chat toolbar places it as a principal item where supported; verify placement on both platforms
-- macOS toolbar has built-in glass — don't add extra glass to the selector label
-
-### Suggestion Chips
-
-- Same 2-column grid with glass interactive chips
-- On macOS, chips respond to hover (`.interactive()` handles this automatically with Liquid Glass)
-- Ensure chips have pointer cursor on hover (system default for interactive glass)
-
-### Empty State
-
-- Same layout as iOS — centered icon + greeting + chips
-- On macOS with larger windows, the `maxWidth(400)` constraint keeps it readable
-- No adjustment needed — the constraint handles both platforms
-
----
-
-# Annex: App-Specific — OpenClient
-
-> The following rules are specific to the OpenClient project. Adjust for other projects as needed.
-
-## Assistant Identity
-
-- **Avatar SF Symbol**: `sparkles` (represents AI/generative capability)
-- **Avatar tint**: `Color.accentColor`
-- No user avatar displayed — alignment differentiates roles
-
-## Suggestion Prompts
-
-`ConversationStartersManager` currently chooses four random items from this localized pool:
-
-| Icon | English Key | Purpose |
-|---|---|---|
-| `lightbulb` | "Explain a complex topic simply" | Knowledge/explanation |
-| `pencil.and.outline` | "Write a creative story" | Creative writing |
-| `chevron.left.forwardslash.chevron.right` | "Help me with my code" | Code assistance |
-| `globe` | "Translate text to another language" | Translation |
-| `book` | "Summarize a long text" | Summarization |
-| `questionmark.bubble` | "Answer a tricky question" | General questions |
-| `text.badge.checkmark` | "Review and improve my writing" | Editing |
-| `brain.head.profile` | "Brainstorm ideas for a project" | Ideation |
-
-All prompts must be localized via `String(localized:)` for every supported language.
-
-## Input Placeholder
-
-- English: `"Message..."`
-- Must be localized for all supported languages
-
-## Empty State Greeting
-
-- English: `"How can I help you?"`
-- Must be localized for all supported languages
-
-## Model Selector
-
-- Placed at `ToolbarItem(placement: .principal)` in the ChatView toolbar
-- No navigation title displayed (set to empty string)
-- Chevron icon: `chevron.down` in `.caption2` font, `.secondary` style
+# OpenClient Chat Visual Style
+
+## Scope
+
+- This specification defines stable chat-specific behavior. A deliberate change to that behavior must update this file in
+ the same change.
+- Apply `design-ui.instructions.md`. Use the Chat implementation only for concrete controls, symbols, strings, spacing, and
+ navigation details not specified by either contract.
+- Keep the conversation content-first, readable, and visually stable during updates.
+
+## Conversation Layout
+
+- Keep messages in an adaptable readable column: use available compact width without allowing lines to become excessively
+ long in wide windows.
+- Distinguish roles through stable composition rather than duplicated decoration.
+- Present user messages on the trailing side in accent-tinted glass with legible foreground content.
+- Present assistant messages on the leading side without a message bubble; rendered content sits directly in the
+ conversation column.
+- Keep message metadata visually secondary but available on touch, keyboard, and pointer platforms. Do not make timestamps,
+ usage, status, or other meaningful metadata hover-only.
+
+## Message Content And Attachments
+
+- Render user source text as entered. Render completed assistant content with the existing structured Markdown pipeline,
+ including its current text, link, list, table, quotation, media, and code-block behavior.
+- During streaming, favor immediate stable text over repeatedly reparsing final Markdown. Switch to final Markdown when the
+ response completes.
+- Keep code and other horizontally constrained content readable and selectable without widening the conversation column.
+- Present attachments as part of their message or pending composer state. Preserve aspect ratio, recognizable previews,
+ loading/failure feedback, and accessible descriptions.
+- Do not invent attachment types, previews, or controls that the current capabilities do not support.
+
+## Message Actions
+
+- Expose actions only when they are valid for the message state, role, content, platform, and enabled capability.
+- Preserve access to implemented actions through the interaction patterns already used by each platform; do not rely on
+ hover or an undiscoverable gesture as the only route.
+- Keep destructive message or conversation actions confirmed according to the general UI specification.
+- Action feedback must not shift message content or obscure streaming state.
+
+## Composer
+
+- Keep the composer anchored to the conversation and clear of the keyboard and safe areas.
+- Adapt its arrangement as text, attachments, tools, speech, and available width change; do not force one fixed horizontal
+ layout across iPhone, iPad, and macOS.
+- Let text entry grow within the feature-defined bounds while keeping primary controls reachable.
+- Reflect action availability explicitly. Sending, stopping, attaching, speaking, and tool-related controls must match the
+ current model selection, input, permission, capability, and streaming state.
+- Preserve draft content and pending attachments across incidental layout or focus changes.
+
+## Streaming Stability
+
+- Show an immediate, localized waiting state until content arrives, then use the implemented streaming indicator.
+- Publish streamed content at the cadence established by the current pipeline; do not add per-token container animations,
+ repeated Markdown layout, or lazy-row behavior that destabilizes scrolling.
+- Keep message identity and row layout stable throughout reasoning, tool execution, answer generation, cancellation, error,
+ and completion.
+- Finalization must remove transient streaming presentation and render the final assistant Markdown without losing content.
+
+## Scroll Follow
+
+- Follow the bottom for initial entry and active responses only while follow mode is attached.
+- Detach immediately when the user deliberately reads history, preserve their position, and expose the implemented route
+ back to the latest content.
+- Resume follow only through the current explicit return behavior or the start of a new response.
+- Drive automatic positioning from semantic chat and scroll phases, not from message visibility or continuously changing
+ geometry. Visibility may inform presentation such as date context, but not automatic scrolling.
+- Preserve native scroll indicators and keyboard-dismiss behavior where the platform implementation provides them.
+
+## Chat Accessibility
+
+- Maintain a logical conversation reading order and expose message role, content, metadata, attachment state, streaming
+ state, and available actions to assistive technologies.
+- Keep streamed announcements useful without announcing every fragment.
+- Ensure Dynamic Type can reflow messages, metadata, Markdown, attachments, and composer controls without clipping or
+ hiding actions.
+- Localize all chat labels, status, errors, metadata, and accessibility text; this specification does not define their copy.
diff --git a/specs/code-style.instructions.md b/specs/code-style.instructions.md
index 030c1c20..a4ad59f2 100644
--- a/specs/code-style.instructions.md
+++ b/specs/code-style.instructions.md
@@ -1,416 +1,64 @@
---
-description: "Use when writing or reviewing Swift code style (formatting, naming, file layout, MARK sections, comments, and readability). Avoid architecture guidance in this file."
+description: "Use when writing or reviewing Swift formatting, naming, file layout, comments, MARK sections, localization, and readability."
applyTo: "**/*.swift"
---
# Swift Code Style
-This file defines preferred style. Existing code is the source of truth for local formatting when it differs from a
-generic example; do not perform unrelated cleanup while making a focused change.
+## General
-## 1) Style Intent
+- Preserve the style of nearby code and avoid unrelated cleanup.
+- Prefer the smallest readable implementation. Keep functions cohesive, side effects explicit, and state handling
+ exhaustive where practical.
+- `.swiftlint.yml` is authoritative for enabled rules, severities, and numeric limits. Do not duplicate those values here.
+- Use spaces rather than tabs, one statement per line, no trailing whitespace, and no more than one consecutive blank line.
+- Wrap long declarations and argument lists consistently, usually one argument per line when multiline.
+- Do not use force unwraps or force casts. Unwrap optionals with `guard let` or `if let`.
+- Do not initialize optional stored properties with `= nil`.
-- Keep code easy to scan and maintain.
-- Prefer consistency over personal preference.
-- Use strict rules where consistency is critical.
-- Use recommendations where context can vary.
+## Files And Types
-## 2) Formatting
+- Every Swift file starts with the repository copyright header used by nearby files. Use the owning target name for new
+ target-specific files; preserve historical headers unless changing them is the task.
+- Prefer one primary type per file named after that type. Tightly coupled supporting types and private helpers may remain
+ together; cohesive large types may use `Type+Concern.swift` files.
+- Prefer `struct` for value semantics and `enum` for closed states or options.
+- Put protocol conformances and cohesive concerns in focused extensions when that improves navigation.
+- Keep imports minimal and ordered consistently with nearby files.
-### Required
+## Organization
-- Use 4 spaces for indentation (no tabs).
-- Keep one statement per line.
-- Keep at most one empty line between code blocks.
-- Remove trailing spaces.
-- Use one space after commas and around binary operators.
-- Keep line length within linter limits.
+- Use `// MARK: -` sections only when they improve navigation; do not force sections into small files.
+- Name sections for their purpose, such as `Properties`, `Init`, `View`, `Input functions`, `Tests`, or `Private`.
+- Keep private helpers near the bottom when practical, without separating them from stored state they must access.
+- Keep each section and extension focused on one concern.
-### Recommended
+## Naming And APIs
-- Wrap long argument lists one item per line.
-- Prefer multi-line formatting when clarity improves, even below max length.
+- Use UpperCamelCase for types and lowerCamelCase for properties, functions, and enum cases.
+- Name actions with clear verbs and booleans with `is`, `has`, or `can` when semantically appropriate.
+- Prefer domain terminology over ambiguous names or nonstandard abbreviations.
+- Keep argument labels clear at call sites and associated-value labels where they improve meaning.
+- Use early `guard` exits for preconditions and `switch` for exhaustive event or state handling.
-### Good
+## Comments And Safety
-```swift
-let request = ChatCompletionRequest(
- model: model,
- messages: messages,
- stream: true,
- temperature: parameters.temperature
-)
-```
+- Prefer expressive code over comments. Add short factual comments only for non-obvious intent, constraints, or invariants.
+- Every `@unchecked Sendable` declaration requires a nearby comment documenting the actual synchronization or immutability
+ invariant; test-only scope is not itself a safety proof.
+- Do not leave tutorial commentary, stale implementation history, or comments that merely restate code.
-### Bad
+## SwiftUI And Localization
-```swift
-let request = ChatCompletionRequest(model:model,messages:messages,stream:true,temperature:parameters.temperature)
-```
+- Primary screens and reusable visual components need preview coverage, either locally or through a representative composed
+ preview.
+- Localize every user-facing source string. Write source strings in English.
+- Use `String(localized:)` when an API requires `String`; localized literals are appropriate for APIs accepting
+ `LocalizedStringKey` or `LocalizedStringResource`.
+- Do not edit `Localizable.xcstrings` manually; translations are maintained separately by the project author.
-## 3) File Header
+## Dependencies
-### Required
-
-- Use a consistent file header template in all Swift files.
-- Keep import statements immediately after the header, separated by one blank line.
-
-### Recommended
-
-- Keep imports sorted and minimal.
-
-### Good
-
-```swift
-//
-// ExampleView.swift
-// openclient-llm
-//
-// Created by Arturo Carretero Calvo on 01/01/2026.
-// Copyright © 2026 Arturo Carretero Calvo. All rights reserved.
-//
-
-import SwiftUI
-```
-
-Use the actual target name in the third header line for macOS and extension-owned files. Preserve an existing file's
-historical author/copyright spelling unless the task is specifically correcting headers.
-
-## 4) File Layout
-
-Use sections when they improve navigation. The repository does not use one universal section list for every kind of file.
-
-### Required
-
-1. Type declaration
-2. `// MARK: - Properties` when the type has a meaningful property group
-3. `// MARK: - Init` when an initializer exists
-4. A named public section such as `View`, `Input functions`, or `Tests`
-5. Protocol conformances in focused extensions
-6. `// MARK: - Private` and private helpers near the bottom when practical
-
-### Recommended
-
-- Keep private helpers in extensions at file bottom when that does not prevent access to private stored properties or
- make a large type harder to understand.
-- Keep each section compact and cohesive.
-
-### Good
-
-```swift
-final class ExampleType {
- // MARK: - Properties
-
- private let service: ServiceProtocol
-
- // MARK: - Init
-
- init(service: ServiceProtocol) {
- self.service = service
- }
-
- // MARK: - Public
-
- func execute() {
- prepare()
- }
-}
-
-// MARK: - Private
-
-private extension ExampleType {
- func prepare() {}
-}
-```
-
-### Bad
-
-```swift
-final class ExampleType {
- func execute() {}
- private let service: ServiceProtocol
- init(service: ServiceProtocol) { self.service = service }
-}
-```
-
-## 5) MARK Usage
-
-### Required
-
-- Use `// MARK: - ...` labels for major sections.
-- Add one blank line after each MARK label.
-- Do not create noisy MARK sections for tiny files.
-
-### Recommended
-
-- Use meaningful labels (`View`, `Input functions`, `Tests`, `Private`) instead of forcing `Public`/`Internal` labels.
-
-### Good
-
-```swift
-// MARK: - Public
-
-func send(_ event: Event) {}
-```
-
-### Bad
-
-```swift
-// MARK: public
-func send(_ event: Event) {}
-```
-
-## 6) Naming Conventions
-
-### Required
-
-- Use UpperCamelCase for types.
-- Use lowerCamelCase for variables, constants, functions, and enum cases.
-- Use clear verb-first names for actions (`loadModels`, `refreshData`).
-- Use boolean names prefixed with `is`, `has`, or `can`.
-
-### Recommended
-
-- Prefer domain words over abbreviations unless standard (`URL`, `ID`, `API`).
-- Keep names explicit even if longer.
-
-### Good
-
-```swift
-enum LoadingState {
- case idle
- case loading
-}
-
-let isRefreshing: Bool
-func fetchAvailableModels() {}
-```
-
-### Bad
-
-```swift
-enum loading_state {
- case Idle
-}
-
-let refresh: Bool
-func get() {}
-```
-
-## 7) Enums, Structs, and Protocols
-
-### Required
-
-- Prefer `struct` for value semantics.
-- Use `enum` for closed sets of states/options.
-- Keep protocol names descriptive and capability-oriented.
-- Keep associated value labels explicit when they improve readability.
-
-### Recommended
-
-- Group nested enums inside the parent type when scoped to that type.
-- Keep enum case naming parallel.
-
-### Good
-
-```swift
-enum Event {
- case inputChanged(String)
- case attachmentAdded(data: Data, fileName: String)
-}
-
-protocol SettingsStoreProtocol: Sendable {
- func getSelectedModelId() -> String?
-}
-```
-
-### Bad
-
-```swift
-enum Event {
- case a(String)
- case add(Data, String)
-}
-
-protocol Manager {
- func run()
-}
-```
-
-## 8) Function Style
-
-### Required
-
-- Keep function bodies small and focused.
-- Use early exits with `guard` for preconditions.
-- Prefer `switch` for exhaustive state/event handling.
-- Keep argument labels clear at call sites.
-
-### Recommended
-
-- Extract complex logic into private helpers.
-- Keep side effects obvious.
-
-### Good
-
-```swift
-func send(_ event: Event) {
- guard case .loaded(var loadedState) = state else { return }
-
- switch event {
- case .inputChanged(let text):
- loadedState.inputText = text
- state = .loaded(loadedState)
- case .sendTapped:
- submitCurrentInput(using: loadedState)
- }
-}
-```
-
-### Bad
-
-```swift
-func send(_ e: Event) {
- if true {
- // many unrelated operations in one block
- }
-}
-```
-
-## 9) Optionals and Defaults
-
-### Required
-
-- Do not initialize stored optionals with `= nil`.
-- Use `guard let` or `if let` for optional unwrapping.
-- Avoid force unwraps and force casts.
-
-### Recommended
-
-- Use nil-coalescing only when default behavior is obvious.
-
-### Good
-
-```swift
-var selectedModelId: String?
-
-guard let modelId = selectedModelId else { return }
-```
-
-### Bad
-
-```swift
-var selectedModelId: String? = nil
-let modelId = selectedModelId!
-```
-
-## 10) Comments and Documentation
-
-### Required
-
-- Write comments only when code intent is not obvious.
-- Keep comments short and factual.
-- Document safety invariants for any `@unchecked Sendable` usage.
-
-### Recommended
-
-- Prefer expressive names over explanatory comments.
-- Use section comments for test grouping.
-
-### Good
-
-```swift
-// Safety: UserDefaults is thread-safe per Apple documentation.
-// All stored properties are immutable (`let`).
-final class SettingsStore: @unchecked Sendable {}
-```
-
-### Bad
-
-```swift
-// sets value
-value = 1
-```
-
-## 11) Extensions And File Scope
-
-### Required
-
-- Prefer extensions for protocol conformances and cohesive splits.
-- Keep private helpers in a `private extension` at file bottom when practical.
-- Add MARK labels for substantial extension blocks.
-- Prefer one primary type per file. Small supporting enums, structs, protocols, and private helpers may share the file
- when they are tightly coupled. The repository also uses `Type+Concern.swift` files for large ViewModels and views.
-
-### Recommended
-
-- Keep each extension focused on one purpose.
-
-### Good
-
-```swift
-// MARK: - Equatable
-
-extension Conversation: Equatable {}
-
-// MARK: - Private
-
-private extension Conversation {
- func normalizedTitle() -> String { title.trimmingCharacters(in: .whitespacesAndNewlines) }
-}
-```
-
-## 12) Test Style
-
-### Required
-
-- Name tests as `test___()`.
-- Use `// Given`, `// When`, `// Then` blocks.
-- Keep one assertion intent per test.
-- Mark each XCTest class `@MainActor`; the test target does not set default actor isolation.
-
-### Recommended
-
-- Use section MARKs to group related test cases.
-
-### Good
-
-```swift
-func test_send_viewAppeared_withModels_setsLoadedState() async throws {
- // Given
- mockFetch.result = .success([.init(id: "gpt")])
-
- // When
- sut.send(.viewAppeared)
-
- // Then
- XCTAssertEqual(currentModels.count, 1)
-}
-```
-
-## 13) Linter Alignment
-
-`.swiftlint.yml` is authoritative. Its current limits are:
-
-- File length: warning 500, error 650
-- Line length: warning 120, error 150
-- Function body length: warning 50, error 80
-- Type body length: warning 300, error 400
-- Vertical whitespace: max one empty line
-- Force unwrap and force cast: errors
-- Trailing commas: allowed because the `trailing_comma` rule is disabled
-
-If guide text and linter disagree, update one of them so both stay aligned.
-
-## 14) Anti-Patterns to Avoid
-
-- Inconsistent MARK names/order across files.
-- Mixed formatting styles in the same file.
-- Large public methods that mix orchestration and implementation details.
-- Ambiguous names (`data`, `value`, `manager`) without context.
-- Excessive comments explaining obvious code.
-
-## 15) Practical Rule
-
-When unsure between two valid styles, choose the style already used in nearby files unless it conflicts with SwiftLint or
-a focused project specification.
+- Do not add or update packages without explicit user permission.
+- Read the dependency set and versions from the Xcode project or package manifest rather than maintaining a duplicate list
+ in documentation.
diff --git a/specs/concurrency.instructions.md b/specs/concurrency.instructions.md
index 8cedf5e1..4de91bab 100644
--- a/specs/concurrency.instructions.md
+++ b/specs/concurrency.instructions.md
@@ -1,388 +1,79 @@
---
-description: "Use when writing async/await code, choosing isolation strategy (@MainActor, actor, Sendable), fixing concurrency compiler errors, marking types as Sendable, using @unchecked Sendable, creating Tasks, or reviewing thread-safety."
+description: "Use when writing async code, choosing actor isolation, managing tasks or cancellation, applying Sendable, or reviewing thread safety."
applyTo: "**/*.swift"
---
-# Swift Concurrency Guidelines
-
-Based on the principles from [AvdLee's Swift Concurrency Agent Skill](https://github.com/AvdLee/Swift-Concurrency-Agent-Skill).
-
-## Project Concurrency Settings
-
-The iOS and macOS **app targets** use Swift 6, approachable concurrency, member import visibility, and
-`SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. Shared code is compiled in those app targets and therefore receives that
-default there.
-
-The `openclient-llm-test`, `ShareExtension`, `WidgetsExtension-iOS`, and `WidgetsExtension-macOS` target configurations
-currently do **not** set `SWIFT_DEFAULT_ACTOR_ISOLATION`. Do not describe the whole project or every target as implicitly
-main-actor isolated.
-
-### What `MainActor` Default Isolation Means
-
-- Declarations compiled by either app target are main-actor isolated by default unless explicitly opted out.
-- Writing `@MainActor` on ViewModels is redundant but kept for **documentation clarity**
-- Shared DTOs, request/response models, parsing helpers, and other values that must cross isolation boundaries commonly use
- `nonisolated` declarations plus `Sendable` in the current code.
-- Every XCTest class is explicitly `@MainActor`. This is necessary because the test target has no MainActor default and
- most production declarations it accesses are main-actor isolated in the host app.
-- With approachable concurrency, a nonisolated async function inherits the caller's isolation by default. `nonisolated`
- does not mean background execution; use `@concurrent` when an async function must leave the caller's actor.
-
-### Choosing `nonisolated` or `@concurrent`
-
-Use `nonisolated` to opt a declaration out of the target's default actor isolation when it must be usable from any
-isolation domain and does not access actor-owned state. This controls isolation and callability, not which executor runs
-the work.
-
-```swift
-// Immutable value that can cross isolation boundaries.
-nonisolated struct ParsedResponse: Sendable { ... }
-
-// Construction must be available outside MainActor.
-nonisolated init(configuration: Configuration) { ... }
-```
-
-Use `@concurrent` on substantial async work that must leave the caller's actor, such as CPU-heavy parsing, image
-processing, or encoding. `@concurrent` implies `nonisolated`; arguments and results crossing the boundary must be safe to
-send.
-
-```swift
-@concurrent
-func processImage(_ data: Data) async throws -> ProcessedImage { ... }
-```
-
-A synchronous `nonisolated` function still runs synchronously on the caller's thread. Making synchronous work
-`nonisolated` never dispatches it to a background executor. Do not add either annotation to ViewModels, Views, or code
-that touches UI-bound state. A UseCase may opt out when its entire dependency graph and transferred values support it;
-decide from behavior rather than layer name.
-
-## Core Principles
-
-1. **Understand target scope** — app/shared code has a MainActor default; tests and extensions do not
-2. **Keep explicit `@MainActor` on ViewModels** — redundant but serves as documentation that the type is intentionally UI-bound
-3. **Optimize for the smallest safe change** — don't add annotations, wrappers, or abstractions beyond what the compiler requires
-4. **Prefer structured concurrency** — `async let`, `TaskGroup` over unstructured `Task { }` whenever possible
-5. **`@unchecked Sendable` requires a documented safety invariant** — always add a comment explaining why the type is thread-safe
-6. **Prefer value types for Sendable** — structs/enums over classes whenever possible
-7. **Never silence warnings without understanding root cause** — every concurrency fix must have a clear, documented reason
-8. **Treat cancellation as control flow** — do not turn cancellation into an ordinary error or accidentally continue loops
-9. **Own long-lived tasks explicitly** — store, replace, and cancel tasks whose lifetime exceeds one synchronous event
-10. **Revalidate state after suspension** — actor isolation prevents data races, not stale completions or logical races
-
-## Decision Tree: Choosing Isolation
-
-```
-Declarations in the iOS/macOS app targets are @MainActor by default.
-│
-├─ Is the code UI-bound? (ViewModel, View, UI state)
-│ └─ Keep default @MainActor — add explicit annotation for clarity on ViewModels ✅
-│
-├─ Must the declaration be callable or constructed outside MainActor?
-│ └─ Mark the appropriate declaration `nonisolated` and make transferred values Sendable ✅
-│
-├─ Must substantial async work leave the caller's actor?
-│ └─ Mark the async function `@concurrent`; arguments and results must be safely transferable ✅
-│
-├─ Is it a value type with no mutable shared state?
-│ └─ struct — implicitly Sendable if all members are Sendable ✅
-│
-├─ Is it a reference type wrapping a thread-safe API?
-│ └─ @unchecked Sendable + safety comment ✅
-│
-├─ Is it a reference type with mutable state needing serialized async access?
-│ └─ Consider an actor and verify protocol isolation at call sites ✅
-│
-├─ Need synchronous fine-grained locking?
-│ └─ Mutex (iOS 18+) ✅
-│
-└─ Is it a function/closure crossing isolation boundaries?
- └─ @Sendable ✅
-```
-
-## Layer-Specific Patterns
-
-### ViewModel
-
-```swift
-@Observable
-@MainActor // Redundant (default) but kept for documentation clarity
-final class FeatureViewModel {
- private(set) var state: State
-}
-```
-
-- `@MainActor` is implicit (project default) but **keep it explicit** for clarity
-- **Justification**: state is read/written by SwiftUI on the main thread
-- Use `@Observable` (never `ObservableObject` / `@Published`)
-
-### UseCase
-
-```swift
-protocol SomeUseCaseProtocol: Sendable {
- func execute() async throws -> Result
-}
-
-struct SomeUseCase: SomeUseCaseProtocol {
- private let repository: SomeRepositoryProtocol
-}
-```
-
-- **`struct`** — value type, implicitly Sendable if all members are Sendable
-- **Protocol marked `: Sendable`** — ensures all conforming types are safe to pass across isolation domains
-- In app targets, an unannotated UseCase inherits the MainActor default. This is acceptable for lightweight orchestration.
-- Use explicit `nonisolated` only when all accessed dependencies and transferred values support it.
-- Use `@concurrent` for measured or intrinsically substantial async CPU work that must not inherit MainActor. Do not add it
- merely because a function is async or belongs to the UseCase layer.
-
-### Repository
-
-```swift
-// Stateless repository (wraps APIClient)
-struct SomeRepository: SomeRepositoryProtocol {
- private let apiClient: APIClientProtocol
-}
-
-// Preferred option for mutable state that genuinely needs actor serialization
-actor CachedRepository: SomeRepositoryProtocol {
- private var cache: [String: Data] = [:]
-}
-```
-
-- **Stateless** (API wrapper) → `struct` + Sendable
-- **Stateful** (cache, local storage) -> choose an actor, MainActor isolation, or a documented thread-safe API according to
- the required calling semantics. Current repositories are mostly structs/classes rather than actors.
-
-### Manager (Transversal Services)
-
-```swift
-// Thread-safe wrapper — @unchecked Sendable with documented invariant
-// Safety: UserDefaults is thread-safe per Apple documentation.
-// All stored properties are immutable (`let`).
-final class SettingsManager: SettingsManagerProtocol, @unchecked Sendable {
- private let defaults: UserDefaults
-}
-```
-
-- Prefer `@unchecked Sendable` for immutable wrappers around proven thread-safe APIs.
-- Framework callback state constrained to a specific actor or thread may exceptionally require it. Such an exception must
- document the complete synchronization invariant and keep any `nonisolated(unsafe)` storage narrowly scoped.
-- If mutable state has no proven serialization rule, use an actor or MainActor isolation instead.
-
-### APIClient
-
-```swift
-struct APIClient: APIClientProtocol, Sendable {
- private let session: URLSession
- private let baseURL: URL
-}
-```
-
-- `struct` — `URLSession` is thread-safe, client holds no mutable state
-- Protocol marked `: Sendable`
-
-## Tasks and SwiftUI
-
-### Preferred: `.task` Modifier
-
-```swift
-.task {
- viewModel.send(.viewAppeared)
-}
-
-.task(id: searchQuery) {
- viewModel.send(.searchChanged(searchQuery))
-}
-```
-
-- Automatically cancels when view disappears
-- `.task(id:)` cancels and restarts on value change — ideal for search debouncing
-
-### Task Entry Isolation
-
-`Task { }` inherits the enclosing isolation. In app/shared code, a bare task created from a ViewModel therefore starts on
-MainActor. Choose entry isolation from the synchronous prefix before the first `await`:
-
-- If that prefix reads or mutates UI-owned state, keep inherited MainActor isolation.
-- If it performs substantial non-UI work or only waits before eventually updating UI, use `Task { @concurrent in ... }`
- and return to MainActor only for the state mutation.
-- Do not rewrite every task whose first statement is `await`; an immediate actor hop is cheap and the called API may
- already define the correct isolation. Use `@concurrent` when it expresses an actual execution requirement.
-
-```swift
-Task {
- isLoading = true
- let result = await service.load()
- state = .loaded(result)
-}
-
-Task { @concurrent in
- let result = await processor.process(input)
- await MainActor.run { state = .loaded(result) }
-}
-```
-
-### When Unstructured `Task` is Acceptable
-
-```swift
-// Bridge to a genuinely async API from a synchronous action.
-Button("Send") {
- Task {
- await asyncService.send()
- }
-}
-```
-
-- Only when bridging synchronous UI callbacks to genuinely async work.
-- Current ViewModel `send(_:)` methods are generally synchronous event entry points and launch owned work internally, so
- views should call them directly rather than wrapping every event in `Task`.
-
-### Task Ownership and Cancellation
-
-- Store a task when later events must cancel, replace, or await it. Cancel the previous task before replacing it.
-- Long-running loops and `AsyncSequence` consumers must have an explicit owner and cleanup path. Use weak captures where a
- task owned by `self` would otherwise retain `self` indefinitely.
-- `isolated deinit` may cancel actor-isolated task properties, but it cannot break a retain cycle that prevents deinit.
-- Check cancellation before expensive synchronous work and after suspension points where continuing would be incorrect.
-- Handle `CancellationError` separately from user-facing failures. Never map cancellation to a network or validation error.
-- Do not use `try? await Task.sleep(...)` in a repeating loop: after cancellation, subsequent sleeps throw immediately and
- can create a busy loop. Return, break, or propagate cancellation.
-
-### Avoid
-
-```swift
-// ❌ Detached tasks (lose priority, cancellation, task-locals)
-Task.detached { ... }
-
-// ❌ Unstructured tasks when structured alternatives exist
-func loadData() async {
- Task { await fetchA() } // ❌
- Task { await fetchB() } // ❌
-}
-
-// ✅ Use async let or TaskGroup instead
-func loadData() async {
- async let a = fetchA()
- async let b = fetchB()
- let results = await (a, b)
-}
-```
-
-## Actor Reentrancy and Logical Races
-
-An actor serializes access at each instant, but other tasks may enter whenever an actor-isolated function suspends. Never
-assume state observed before `await` is unchanged afterward.
-
-- Complete related state mutation before suspending when possible.
-- Otherwise capture an ID, generation, or state snapshot and validate it after the suspension before committing results.
-- Repeated load/refresh events should cancel superseded work or ignore stale completions.
-- Serialization prevents data races; it does not guarantee business-operation ordering.
-
-## Sendable Rules
-
-### Value Types (Structs/Enums)
-
-- **Internal types**: Implicitly Sendable if all members are Sendable — no annotation needed
-- **Public types**: Require explicit `Sendable` conformance
-
-### Reference Types (Classes)
-
-Priority order:
-1. Can it be a struct? → Refactor
-2. Immutable (`final` + all `let` properties) → `Sendable`
-3. Mutable + UI-bound → `@MainActor` (implicit Sendable)
-4. Mutable + async → `actor`
-5. Wraps thread-safe API → `@unchecked Sendable` + safety comment
-6. `@unchecked Sendable` without justification → **NEVER**
-
-### Closures
-
-```swift
-// Closures crossing isolation boundaries must be @Sendable
-// Captured values must be Sendable and immutable
-let query = "search" // let, not var
-store.filter { contact in
- contact.name.contains(query) // ✅ Immutable capture
-}
-```
-
-## `@unchecked Sendable` Policy
-
-Preferred immutable-wrapper case:
-
-1. The type wraps a proven thread-safe API.
-2. Stored dependencies are immutable (`let`).
-3. A safety invariant comment is present immediately above the declaration.
-4. No better checked alternative exists.
-
-Framework observers with mutable callback tokens or metadata queries require the same proof. The comment must identify
-the actor/thread that owns every mutable property; narrowly scoped `nonisolated(unsafe)` may only express that documented
-framework constraint. `@unchecked Sendable` is never permission for unsynchronized mutation.
-
-```swift
-// ✅ Correct: documented invariant
-// Safety: UserDefaults is thread-safe per Apple documentation.
-// All stored properties are immutable (`let`).
-final class SettingsManager: @unchecked Sendable { ... }
-
-// ❌ Wrong: no documentation, mutable state
-final class Cache: @unchecked Sendable {
- var items: [String: Data] = [:] // Not thread-safe!
-}
-```
-
-## Test Mocks
-
-```swift
-// @unchecked Sendable is acceptable for test mocks
-// Safety: Only used within serialized @MainActor test methods.
-final class MockSettingsManager: SettingsManagerProtocol, @unchecked Sendable {
- var isOnboardingCompleted: Bool = false
-}
-```
-
-- Mocks may use `@unchecked Sendable` because tests are serialized
-- Add safety comment explaining test-only scope
-
-## Testing and `@MainActor`
-
-The test target does not set default actor isolation. **Every XCTest class must be explicitly `@MainActor`** to match the
-hosted app code and the existing suite:
-
-```swift
-@MainActor
-final class SomeViewModelTests: XCTestCase {
- private var sut: SomeViewModel!
- // ...
-}
-```
-
-- This is the project convention for all XCTest classes, including tests of otherwise nonisolated helpers.
-- Without `@MainActor`, the test can't access isolated properties/methods synchronously
-- All `setUp` / `tearDown` / test methods inherit the `@MainActor` isolation
-- Follow `testing.instructions.md`: use continuations, expectations, actor gates, or controllable dependencies instead of
- sleeps and unbounded polling to synchronize concurrent tests.
-
-## Common Diagnostics
-
-| Error | Question to Ask | Fix |
-|-------|----------------|-----|
-| "Main actor-isolated ... cannot be used from nonisolated context" | Is the code truly UI-bound? | If yes → `@MainActor` on caller. If no → `await MainActor.run { }` only when needed |
-| "Capture of ... with non-sendable type" | Can the type be made Sendable? | Prefer struct. If class → check Sendable rules above |
-| "Non-sendable type ... cannot cross actor boundary" | Does the type need to cross boundaries? | Make Sendable, or restructure to avoid crossing |
-| "Actor-isolated property ... cannot be mutated from nonisolated context" | Should the caller be isolated? | Pass as `isolated` parameter, or await the actor method |
-| "Static property ... is not concurrency-safe" | Is it a singleton? | `@MainActor static`, or `static let` + Sendable |
-| UI stalls despite `nonisolated async` | Must this work leave caller isolation? | Use `@concurrent` for substantial work and keep inputs/results Sendable |
-
-## Verification Checklist
-
-Before considering a concurrency fix complete:
-
-- [ ] The fix addresses the root cause, not just the symptom
-- [ ] Explicit `@MainActor` is retained on ViewModels and XCTest classes; other annotations reflect actual isolation
-- [ ] Every `@unchecked Sendable` has a documented safety invariant
-- [ ] Structured concurrency is used where possible
-- [ ] Long-lived tasks have an owner, cancellation path, and no retain cycle
-- [ ] Cancellation exits loops and is not presented as an ordinary failure
-- [ ] State used across `await` is revalidated when stale completion is possible
-- [ ] Tests still pass with strict concurrency checking
-- [ ] No force casts, force unwraps, or unsafe patterns introduced
+# Swift Concurrency
+
+## Configuration And Isolation
+
+- Read default actor isolation and strict-concurrency settings from each target's current Xcode configuration. Shared code
+ inherits the settings of every target that compiles it; do not generalize one target's defaults to the whole project.
+- Keep ViewModels explicitly `@MainActor` even when an app target supplies that default. Their observable state is UI-bound.
+- Use `@Observable`, not `ObservableObject` or `@Published`.
+- XCTest classes that synchronously access app-isolated production types must be explicitly `@MainActor`; follow the
+ existing suite's class-level convention.
+- Do not add isolation annotations solely to silence diagnostics. Identify which state owns the operation and which values
+ cross isolation boundaries.
+
+## Choosing An Isolation Model
+
+- Keep Views, ViewModels, and UI-owned state on `MainActor`.
+- Use `nonisolated` only when a declaration must be callable or constructible outside its enclosing actor and does not
+ access actor-owned state. It changes isolation, not the executor on which synchronous work runs.
+- Use `@concurrent` only for substantial asynchronous work that must leave caller isolation. Inputs and results crossing
+ that boundary must be safely transferable.
+- Prefer immutable `Sendable` value types for data that crosses isolation boundaries.
+- Use an actor for mutable state requiring asynchronous serialization, or `MainActor` when that state is UI-owned.
+- Use a proven thread-safe primitive or framework API only when its calling semantics fit the operation and the invariant
+ can be documented.
+
+## Tasks And Cancellation
+
+- Prefer structured concurrency such as direct `await`, `async let`, and task groups over unstructured tasks.
+- Use SwiftUI `.task` and `.task(id:)` for work owned by a view lifecycle.
+- A `Task` inherits its creation context. Create one only to bridge a synchronous entry point or to model independently
+ owned work; do not wrap every ViewModel event in a task from the View.
+- Store long-lived tasks when later events must cancel, replace, or await them. Define their owner and cleanup path, and
+ avoid ownership cycles.
+- Treat cancellation as control flow. Check it around expensive work and relevant suspension points, exit repeating loops,
+ and do not map `CancellationError` to a user-facing failure.
+- Avoid detached tasks unless loss of actor context, priority inheritance, task-local values, and parent cancellation is an
+ explicit requirement.
+
+## Reentrancy
+
+- Actor isolation prevents data races, not stale completions or business-ordering races.
+- Assume actor state may change across every `await`.
+- Complete related mutations before suspension when possible. Otherwise capture an identity, generation, or state snapshot
+ and validate it before committing the result.
+- Cancel superseded operations or ignore their stale completions.
+
+## Sendable
+
+- Prefer checked `Sendable` conformance. A type's stored values and mutation model must support its conformance in every
+ target and isolation domain where it is used.
+- Closures crossing isolation boundaries must be `@Sendable`, and their captures must be safely transferable.
+- Use `@unchecked Sendable` only when no checked design expresses a real, reviewed safety invariant.
+- Document immediately above each `@unchecked Sendable` declaration why every mutable access is serialized or why the
+ complete stored state is immutable and backed by APIs documented as thread-safe.
+- Keep `nonisolated(unsafe)` narrowly scoped and covered by the same explicit invariant. It is not a substitute for
+ synchronization.
+
+## Test Doubles
+
+- A mock is not safe merely because it is used by tests or because its owning XCTest class is `@MainActor`.
+- Prefer actor-isolated mocks, immutable value doubles, or synchronization primitives when callbacks or concurrent tasks
+ can access mutable test state.
+- An `@unchecked Sendable` mock must state and enforce a real invariant, such as all mutable access being confined to one
+ actor or protected by a lock. Ensure protocol methods and spawned tasks cannot violate that invariant.
+- Use deterministic coordination through controllable dependencies, continuations, expectations, actor gates, or other
+ bounded signals. Do not rely on arbitrary sleeps.
+
+## Review Checklist
+
+- Isolation matches ownership and target configuration.
+- Values crossing boundaries are safely transferable.
+- Tasks have intentional ownership, cancellation, and error handling.
+- State observed before suspension is revalidated when needed.
+- Every unchecked conformance has a complete, enforceable safety invariant.
diff --git a/specs/conversation-backup-format.instructions.md b/specs/conversation-backup-format.instructions.md
index ced58067..78f282ce 100644
--- a/specs/conversation-backup-format.instructions.md
+++ b/specs/conversation-backup-format.instructions.md
@@ -7,11 +7,11 @@ applyTo: "openclient-llm/Shared/Features/Chat/**/*.swift"
## Scope
-This specification defines the OpenClient JSON format used to export and restore conversations. It is the authoritative contract for both single-conversation exports and complete backups.
+This specification defines the interoperable OpenClient JSON backup contract and, separately, the behavior of the current
+importer. A single-conversation export contains one entry in `conversations`; a complete backup uses the same format and
+contains every conversation available at export time.
-A single-conversation export contains one element in `conversations`. A complete backup contains every conversation available at export time. Both use the same document structure and version.
-
-## Version 1 Schema
+## Interoperable Version 1 Contract
```json
{
@@ -33,80 +33,64 @@ A single-conversation export contains one element in `conversations`. A complete
}
```
-| Field | Type | Required | Definition |
+| Field | Type | Required | Contract |
|---|---|---|---|
-| `format` | String | Yes | Must equal `com.artcc.openclient-llm.conversations`. |
-| `version` | Integer | Yes | The document schema version. Version 1 is the current supported version. |
-| `exportedAt` | ISO 8601 date | Yes | Time when the export document was created. |
+| `format` | String | Yes | Exactly `com.artcc.openclient-llm.conversations`. |
+| `version` | Integer | Yes | Exactly `1`. |
+| `exportedAt` | ISO 8601 date | Yes | Document creation time. |
| `conversations` | Array | Yes | Zero or more exported conversations. |
-| `conversation` | Object | Yes | Persisted OpenClient conversation, including messages, parameters, tags, optional tag colors, pin state, timestamps, tool data, web search results, branch references, manual context settings, and optional compacted-context metadata. |
-| `attachments` | Array | Yes | Portable attachment payloads associated with messages in `conversation`. |
-| `attachments[].messageId` | UUID | Yes | Identifier of the message containing the attachment. |
-| `attachments[].attachmentId` | UUID | Yes | Identifier of the attachment in that message. |
+| `conversation` | Object | Yes | A Codable persisted `Conversation`, including its messages and optional compatible metadata. |
+| `attachments` | Array | Yes | Portable binary payloads referenced by `conversation`. |
+| `attachments[].messageId` | UUID | Yes | Message containing the attachment metadata. |
+| `attachments[].attachmentId` | UUID | Yes | Attachment identifier on that message. |
| `attachments[].data` | Base64 string | Yes | Binary attachment content. |
-## Export Rules
-
-- Every export writes the format identifier, current version, and export timestamp.
-- Attachment payloads are separate from conversation metadata so their binary content is portable.
-- An attachment whose local file cannot be read is omitted from `attachments`; the conversation remains exportable.
-- `fileRelativePath` is preserved in conversation metadata for Codable compatibility but is not a portable location and must not be used when restoring.
-- Context summaries and their inclusive compacted-message cursor are preserved when present; they are optional so Version 1 imports created before context compaction remain valid.
-- Tag names remain encoded in `tags` as strings. The optional `tagColors` object maps those names to stable semantic color identifiers so Version 1 backups remain readable by older app versions.
-- `contextWindowTokens` must be absent or greater than zero.
-- A context summary and cursor form an indivisible pair; the summary must contain text and the cursor must reference a message in the same conversation.
-
-## Import Rules
-
-- An importer must reject documents whose `format` or `version` is unsupported.
-- The current importer first decodes the complete `ConversationExportDocument` with ISO 8601 dates, then checks `format` and `version`. A malformed schema therefore reports an invalid document even if its raw format or version value would also be unsupported.
-- `ConversationExportDocument`, `ExportedConversation`, and `ExportedAttachment` use synthesized `Codable`: all fields shown as required must decode successfully, unknown JSON keys are ignored, and malformed UUIDs or dates reject the whole document.
-- `Conversation` has a compatibility decoder: context metadata, model parameters, pin state, tags, tag colors, and branch references may be absent and receive their implemented nil/default values. Tags without a color use orange. Message and attachment compatibility is governed by their own custom decoders.
-- Conversation IDs and message IDs must be unique across the document.
-- Every attachment payload must reference an attachment on the specified message. Duplicate attachment payloads are invalid.
-- Imported conversations, messages, and attachments receive new UUIDs. Existing local conversations are never overwritten.
-- Messages may contain an optional `imageGenerationAttempted` boolean reservation. Preserve it on import and branching;
- older Version 1 documents without the key remain valid and decode it as absent. This local metadata is not a model-API
- field and prevents repeating a generation whose attempt was saved before its tool transcript arrived.
-- Before restoring messages, attachment UUIDs are allocated per conversation for metadata with a present, decodable
- base64 payload. Repeated original attachment IDs with identical bytes share one new ID within that conversation,
- including across messages; Version 1 does not reject duplicate attachment metadata. This preserves preexisting
- ambiguous identity rather than arbitrarily selecting one attachment. If the same original ID has conflicting payload
- bytes, allocate separate new IDs per message and leave all textual/tool references to that ID unchanged: sharing a
- persisted path would otherwise introduce a new import failure or data loss. The same old ID in different conversations
- receives independent new IDs.
-- References to restored image UUIDs are remapped in `contextSummary` and assistant `content`, including later model
- responses without tool metadata. Text replacement is case-insensitive and limited to complete canonical UUID tokens;
- UUIDs embedded in identifiers, filenames, or slash/backslash paths are not replaced. Summary cursors continue to use
- the separate message-ID map. PDF IDs and IDs without any restored image payload are not rewritten in text.
-- For assistant `analyze_images` calls, structurally remap only `attachment_ids` array entries and explicit UUID tokens in
- `question`. For tool results, structurally remap `list_image_attachments.image_attachment_ids`, and UUID tokens in the
- `analyze_images` wrapper's `untrustedExternalToolResult` string or legacy plain-text analysis. Follow recognized nested
- wrappers up to eight levels, covering the tool's wrapper plus the agent's result wrapper. Deeper, malformed, or
- unrecognized wrappers stay unchanged.
-- JSON remapping replaces only selected string tokens; preserve other bytes, including whitespace, key order, array
- order, unknown fields, and numeric precision/range. Do not decode unknown numbers into fixed-precision numeric types.
- The local scanner leaves JSON deeper than 128 levels unchanged rather than rejecting the backup. Replacement strings
- may use equivalent JSON escaping; unrelated tokens retain their original spelling.
-- Tool results are identified by their explicit `toolName`, or, when absent, by an unambiguous assistant call name for
- their `toolCallId` in the same conversation. Other tools (including image generation and external tools), user/system
- message content, system prompts, reasoning, filenames, and unrelated metadata remain unchanged. No tool is executed
- during import. Malformed JSON arguments/results and unrecognized JSON wrappers remain unchanged, without rejecting
- an otherwise valid backup. Missing image payloads can therefore leave historical references unresolved; never invent
- replacement references for omitted attachments. Version 1 has no finer provenance for UUID mentions in assistant text
- or summaries, so exact tokens matching restored images are treated as references there, even when quoted by the model.
-- Imported attachment data is written to a new local path. Exported `fileRelativePath` values are ignored.
-- After the document has decoded and validated, a missing payload for attachment metadata or an invalid base64 payload omits only that attachment and increments `skippedAttachmentCount`. A missing required `data` field in an exported attachment object fails document decoding instead.
-- Branch references are remapped when the referenced conversation or message is present in the document; external references are removed.
-- Invalid context windows, summaries, or summary cursors reject the document before any conversation is restored.
-- If a conversation cannot be persisted, its newly written attachments are removed. If a later conversation fails, earlier conversations restored from the same document are rolled back.
+Version 1 rules:
+
+- Dates are ISO 8601. Required fields must decode; unknown JSON keys are ignored.
+- Conversation and message UUIDs are unique across the document. Each payload references attachment metadata on its
+ declared message, and a message cannot contain duplicate payload entries for the same attachment UUID.
+- Attachment bytes live in `attachments`; metadata `fileRelativePath` is retained only for Codable compatibility and is
+ never a portable restore location. An unreadable local file may be omitted without preventing conversation export.
+- Optional fields remain optional for older Version 1 documents. This includes model parameters, pin state, tags,
+ `tagColors`, branch references, compacted-context metadata, and `imageGenerationAttempted`; their model decoders define
+ defaults. Tags without a color use orange.
+- `contextWindowTokens`, when present, is greater than zero. A context summary and its inclusive cursor are an indivisible
+ pair: the summary is non-empty and the cursor identifies a message in the same conversation.
+- Tag names remain strings in `tags`; optional `tagColors` maps those names to stable semantic color identifiers.
+
+## Current Importer Behavior
+
+- The importer decodes the complete document with ISO 8601 dates, then validates `format`, `version`, identifiers,
+ attachment references, and context metadata before persisting anything. Malformed UUIDs, dates, or required fields
+ invalidate the document; unsupported format or version is rejected explicitly after decoding.
+- Imported conversations, messages, and attachments receive new UUIDs. Existing conversations are never overwritten.
+ Branch references are remapped when both endpoints are imported; external references are removed. Summary cursors use
+ the message UUID map.
+- Attachment payloads are decoded and written to new local paths; exported paths are ignored. Missing or invalid base64
+ for declared attachment metadata skips that attachment and increments `skippedAttachmentCount`; a missing required
+ `data` field or an invalid payload reference invalidates the document.
+- Repeated attachment UUIDs with identical bytes within one conversation share a new UUID. Conflicting bytes receive
+ separate UUIDs per message and ambiguous textual or tool references remain unchanged. UUID scope is per conversation.
+- References to successfully restored images are remapped in context summaries, assistant content, and the recognized
+ `analyze_images` and `list_image_attachments` tool fields. Matching is case-insensitive and limited to complete canonical
+ UUID tokens; identifiers, filenames, paths, PDFs, missing payloads, user/system content, prompts, reasoning, and unrelated
+ metadata are not rewritten.
+- Recognized JSON tool envelopes are remapped without normalizing unrelated content. Malformed, deeply nested, or unknown
+ JSON and wrappers remain unchanged rather than invalidating an otherwise valid backup. Tool identity comes from
+ `toolName` or an unambiguous assistant call with the same `toolCallId`.
+- Import never executes tool calls. Tool transcripts, including `imageGenerationAttempted`, are restored as historical
+ data only.
+- Persistence is atomic for the import batch: a failure removes newly written attachments and rolls back every conversation
+ already restored by that document.
## Privacy And Limits
-Backup files include full conversation content and raw attachment data. They must only be stored or shared through trusted, encrypted locations.
-
-Version 1 imposes no artificial file or conversation-count limit. Available device storage and memory remain the practical limits.
+Backups contain full conversation content, tool transcripts, and raw attachment bytes. Store and share them only through
+trusted encrypted locations. Version 1 defines no artificial file-size or conversation-count limit; device storage and
+memory are the practical limits.
## Versioning
-Incompatible changes require a new integer `version`. Importers must reject unknown versions rather than infer a schema. Any migration support must be explicitly implemented and covered by tests.
+Any incompatible schema change requires a new integer `version`. Importers must reject unknown versions rather than infer
+a schema. Compatibility or migration for another version must be explicit and covered by tests.
diff --git a/specs/design-ui.instructions.md b/specs/design-ui.instructions.md
index fbf9130a..ea6c16d6 100644
--- a/specs/design-ui.instructions.md
+++ b/specs/design-ui.instructions.md
@@ -1,673 +1,58 @@
---
-description: "Use when designing UI, choosing colors, applying Liquid Glass style, configuring Dark Mode, adding SF Symbols, handling accessibility, haptics, or animations in SwiftUI views."
-applyTo: "**/*.swift"
+description: "Use when changing OpenClient SwiftUI visuals, interaction feedback, accessibility, localization, or visible UI states."
+applyTo: "{openclient-llm/Shared/**/Views/**/*.swift,openclient-llm-macOS/**/*.swift,WidgetsShared/**/*.swift}"
---
-# Design & UI Guidelines
+# OpenClient UI Design
-## Design Philosophy
+## Contract
-Native-first design. The app should feel like a first-party Apple app, leveraging system components and platform conventions.
+- These rules define OpenClient's stable visual and interaction constraints. UI changes must follow them or update this
+ specification deliberately in the same change.
+- Use the surrounding feature code for concrete details not specified here, such as exact icons, copy, spacing, navigation,
+ and animation values. Those details do not override this contract.
+- Do not use this specification as a reason to redesign unrelated UI.
-> **Generic vs. App-Specific**: This document contains both generic Apple design guidelines (reusable across projects) and app-specific configuration for OpenClient. Sections marked with **"App-Specific"** should be adapted when reusing these guidelines in other projects. See the summary at the bottom for a full list.
+## Native Visual Language
-## Liquid Glass (iOS 26+ / macOS 26+)
+- Prefer native SwiftUI controls, containers, materials, interactions, and platform conventions over custom replicas.
+- Use Liquid Glass where the current hierarchy calls for a distinct surface or interactive control.
+- Do not add glass to navigation, toolbar, tab, sidebar, or other system chrome that already supplies it.
+- Avoid nested or overlapping glass surfaces that create double glass. Group related glass elements only when their
+ composition requires it.
+- Apply interactive glass only to interactive elements; preserve legibility over changing backgrounds and appearances.
+- Use semantic system colors and semantic asset colors. Do not hardcode RGB or hexadecimal colors in views.
+- Follow the system appearance. Do not force light or dark mode.
-### Core Guidelines
+## Typography And Symbols
-- Prefer native Liquid Glass APIs over custom blurs or materials
-- Use `GlassEffectContainer` when multiple glass elements coexist in the same view
-- Apply `.glassEffect(...)` **after** layout and visual modifiers (padding, frame, font, foregroundStyle)
-- Use `.interactive()` only for elements that respond to touch or pointer (buttons, tappable chips)
-- Keep shapes consistent across related glass elements for a cohesive look
-- Navigation bars, tab bars, and toolbars get Liquid Glass automatically with the iOS 26 SDK — don't add manual glass to them
-- Test with varied wallpapers to ensure readability over different backgrounds
+- Use semantic system text styles for the interface by default.
+- Keep Poppins as a selective display accent where OpenClient already establishes it; do not turn it into the default UI
+ typeface.
+- Ensure custom typography scales relative to a Dynamic Type text style and does not clip at accessibility sizes.
+- Prefer SF Symbols for generic actions, status, and concepts. Preserve custom artwork for OpenClient and provider branding.
-### API Reference
+## Interaction
-#### Glass surfaces
+- Keep motion restrained, interruptible, and tied to meaningful state changes. Respect Reduce Motion and avoid animation
+ that destabilizes live or frequently updating content.
+- Preserve native keyboard, pointer, focus, context-menu, and touch behavior for each platform.
+- Keep interactive targets reachable and clearly labelled when their visible content does not communicate the action.
+- Confirm destructive operations that can remove user data or cannot be undone. Use the platform-native destructive role
+ and retain a clear cancellation path.
-```swift
-Text("Label")
- .padding()
- .glassEffect(.regular, in: .rect(cornerRadius: 16))
-```
+## Accessibility And Localization
-#### Interactive glass (tappable elements)
+- Localize every user-facing source string in English according to the repository localization rules.
+- Support VoiceOver with meaningful labels, values, hints when needed, logical reading order, and no interaction available
+ only through visual position, color, hover, or gesture.
+- Preserve sufficient contrast, Dynamic Type reflow, text selection where expected, and platform-appropriate target sizes.
+- Do not encode meaning solely through color or animation.
-```swift
-Text("Tappable")
- .padding()
- .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 16))
-```
+## Visible States
-#### Tinted glass
-
-```swift
-Text("Tinted")
- .padding()
- .glassEffect(.regular.tint(.accent).interactive(), in: .rect(cornerRadius: 16))
-```
-
-#### Grouped glass elements
-
-```swift
-GlassEffectContainer(spacing: 24) {
- HStack(spacing: 24) {
- Image(systemName: "scribble.variable")
- .frame(width: 72, height: 72)
- .font(.system(size: 32))
- .glassEffect()
- Image(systemName: "eraser.fill")
- .frame(width: 72, height: 72)
- .font(.system(size: 32))
- .glassEffect()
- }
-}
-```
-
-#### Glass button styles
-
-```swift
-Button("Action") { }
- .buttonStyle(.glass)
-
-Button("Primary Action") { }
- .buttonStyle(.glassProminent)
-```
-
-#### Morphing transitions
-
-```swift
-@Namespace private var glassNamespace
-
-// Use glassEffectID for animated transitions between glass elements
-view.glassEffect(.regular, in: .capsule)
- .glassEffectID("elementID", in: glassNamespace)
-```
-
-### Availability Gating
-
-The app targets iOS 26 and macOS 26, so use Liquid Glass APIs directly. Add availability checks and a fallback only if a
-target's deployment version is lowered.
-
-```swift
-if #available(iOS 26, *) {
- Text("Hello")
- .padding()
- .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 16))
-} else {
- Text("Hello")
- .padding()
- .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
-}
-```
-
-> **Note**: Since the project minimum deployment is iOS 26, availability checks are only needed if we ever lower the target. For now, use glass APIs directly without `#available`.
-
-### Review Checklist
-
-When reviewing or adding Liquid Glass to a view, verify:
-
-1. **Composition**: Multiple glass views wrapped in `GlassEffectContainer`
-2. **Modifier order**: `glassEffect` applied after layout/appearance modifiers
-3. **Interactivity**: `.interactive()` only where user interaction exists
-4. **Transitions**: `glassEffectID` used with `@Namespace` for morphing animations
-5. **Consistency**: Shapes, tinting, and spacing align across the feature
-6. **No double glass**: Don't add glass to system components that already have it (NavigationBar, TabBar, Toolbar)
-
-## Color System
-
-### Principles
-
-- **Always use semantic colors** from Asset Catalog — never hardcoded hex/RGB values
-- Define colors with both Light and Dark appearance variants in the Asset Catalog
-- Use system colors (`Color.primary`, `Color.secondary`, `Color.accentColor`) as base
-- Custom colors should be high-contrast and accessible in both modes
-- Use `Color(.systemBackground)`, `Color(.secondarySystemBackground)` for surfaces
-
-### Current App Assets
-
-> **App-Specific** — Adapt this palette for your project.
-
-`Shared/Resources/Assets.xcassets` currently contains `AccentColor`, legacy chat colors (`UserBubble`,
-`UserBubbleText`, `AssistantBubble`, `AssistantBubbleText`, `CodeBlockBackground`), the app logo, and raster provider
-logos. Do not document exact RGB values unless they are verified from the asset catalog.
-
-Current chat UI primarily uses `Color.appAccent`, semantic foreground styles, tinted glass for the user bubble, no
-assistant bubble background, and `.ultraThinMaterial` for code blocks. Existing color assets do not imply every one is
-actively used.
-
-### Usage Rules
-
-- **Accent color** is the only branded color — everything else uses system semantics
-- **Message bubbles**: User = accent-tinted glass (right-aligned); Assistant = unboxed content (left-aligned)
-- **Surfaces**: Always `Color(.systemBackground)` and `Color(.secondarySystemBackground)` — never custom background colors
-- **Text**: Always `Color.primary` / `Color.secondary` except inside colored user bubbles (fixed white)
-- **Destructive actions**: Always `Color.red` (system) — never custom reds
-- When in doubt, use a system color over a custom one
-
-## Typography
-
-- Use semantic system fonts for most UI.
-- Poppins is bundled under `Shared/Resources/Fonts` and exposed through `Font.poppins`. Current code uses it selectively
- for onboarding display text, the chat greeting/model selector, and a conversation badge. Preserve that accent rather
- than claiming the app is system-font-only.
-- Pair custom font sizes with `relativeTo:` so Dynamic Type can scale them. Fixed SF Symbol sizes are acceptable where
- the icon has a constrained visual role.
-- Use `@ScaledMetric` for spacing that should scale with text size
-- Prefer `Text` view with system font styles over custom attributed strings
-
-## Icons
-
-- Prefer SF Symbols for controls, navigation, status, and generic concepts.
-- The app intentionally uses custom image assets for its logo and model-provider branding. Do not replace recognizable
- provider artwork with a generic SF Symbol merely to satisfy an all-SF-Symbol rule.
-- Apply symbol rendering modes: `.monochrome`, `.hierarchical`, `.palette`, `.multicolor`
-- Use symbol effects for animations: `.symbolEffect(.bounce)`, `.symbolEffect(.pulse)`
-- Prefer `.symbolVariant(.fill)` for selected/active states
-
-## Dark Mode
-
-- **System-only**: The app follows the system appearance setting — there is no in-app light/dark toggle
-- Always test both Light and Dark appearances
-- Use semantic colors and system materials — they adapt automatically
-- Images should use template rendering where appropriate
-- Asset Catalog images can have Light/Dark variants when needed
-- Never use `preferredColorScheme()` to force a specific appearance
-
-## Accessibility
-
-- Add `.accessibilityLabel()` to all interactive elements without visible text
-- Support **VoiceOver** navigation with logical reading order
-- Use `.accessibilityHint()` for non-obvious actions
-- Ensure minimum touch target of 44×44pt on iOS
-- Test with larger text sizes (Accessibility Inspector)
-
-## Haptics (iOS only)
-
-- Use `UIImpactFeedbackGenerator` for action feedback (send message, button taps)
-- Use `UINotificationFeedbackGenerator` for success/error/warning states
-- Keep haptics subtle — don't overuse
-- Guard with `#if os(iOS)` — no haptics on macOS
-
-## macOS-Specific Design
-
-macOS has different visual expectations than iOS. The same SwiftUI code can look out of place on Mac if it uses iOS-centric patterns. Follow these rules to ensure a native macOS feel.
-
-### Button Styles
-
-- **Never** use `.buttonStyle(.plain)` for standard macOS actions — it removes the native button chrome and looks broken
-- Use `.buttonStyle(.bordered)` as the default for secondary/standard buttons on macOS
-- Use `.buttonStyle(.borderedProminent)` for primary/call-to-action buttons
-- Use `.buttonStyle(.glass)` or `.buttonStyle(.glassProminent)` only for elements inside Liquid Glass contexts (toolbars, floating panels)
-- Use `.buttonStyle(.plain)` only for inline/invisible tap targets (e.g., icons inside custom rows)
-- Apply platform-specific button styles with `#if os()` when iOS and macOS need different treatments:
-
-```swift
-Button("Action") { }
-#if os(macOS)
- .buttonStyle(.bordered)
-#else
- .buttonStyle(.plain)
-#endif
-```
-
-### Control Sizes
-
-- macOS supports `.controlSize()` which significantly impacts visual density:
- - `.controlSize(.small)` — Use in toolbars, sidebars, compact lists
- - `.controlSize(.regular)` — Default for most content areas
- - `.controlSize(.large)` — Use for primary actions in onboarding or prominent forms
-- Apply `.controlSize()` at the container level (e.g., on a `Form` or `VStack`) to affect all children
-- iOS ignores `.controlSize()` — safe to apply unconditionally but prefer `#if os(macOS)` for clarity
-
-### Spacing & Padding
-
-- macOS uses more compact spacing than iOS — information density is expected on desktop
-- Use platform-conditional padding when a view feels too spread out on Mac:
-
-```swift
-.padding(.horizontal, platformHorizontalPadding)
-
-// Define as computed property or constant:
-#if os(macOS)
-private let platformHorizontalPadding: CGFloat = 12
-#else
-private let platformHorizontalPadding: CGFloat = 16
-#endif
-```
-
-- Sidebar items: 8-10pt vertical padding (macOS) vs 12-14pt (iOS)
-- Form rows: macOS uses native `Form` spacing — don't override it
-- List rows: macOS uses tighter default spacing — respect it
-
-### Forms & Settings
-
-- On macOS, `Form` renders as a grouped macOS-style form automatically — don't wrap in custom containers
-- `Toggle`, `Picker`, `TextField` inside `Form` on macOS get native AppKit-style rendering
-- Don't add custom backgrounds or glass effects to form controls on macOS — they already have system chrome
-- Use `LabeledContent` for read-only form rows on macOS
-- `.textFieldStyle(.roundedBorder)` on macOS for standalone text fields outside of Forms
-
-### Sheets & Popovers
-
-- On macOS, prefer `.popover()` over `.sheet()` for small contextual content (pickers, confirmations)
-- `.sheet()` on macOS renders as a floating window-attached sheet — use for multi-step flows or larger forms
-- `.confirmationDialog()` renders as a native macOS alert sheet — use for destructive confirmations
-- Size sheets explicitly on macOS with `.frame(width:height:)` inside the sheet content — macOS sheets don't auto-size as gracefully as iOS
-- Avoid full-screen covers (`.fullScreenCover()`) on macOS — they don't exist; use `.sheet()` instead
-
-### Toolbar & Menu Bar
-
-- macOS toolbars have built-in Liquid Glass — don't add extra glass to toolbar content
-- Use `ToolbarItem(placement: .automatic)` on macOS — specific placements like `.navigationBarTrailing` don't exist
-- Toolbar buttons on macOS should use SF Symbols with `.label` style (icon + text) for discoverability
-- Add keyboard shortcuts to frequently used actions via `.keyboardShortcut()`:
-
-```swift
-Button("New Chat") { }
- .keyboardShortcut("n", modifiers: .command)
-```
-
-### Scroll & Content Areas
-
-- macOS scroll views have elastic bouncing and scroll bars — respect system defaults
-- Don't hide scroll indicators on macOS — users expect visible scroll bars
-- Use `.scrollContentBackground(.visible)` on macOS Lists to keep the native background
-- Avoid `.scrollDismissesKeyboard()` on macOS — it's iOS-only behavior
-
-### Hover Effects
-
-- macOS supports hover states — use `.onHover()` for interactive feedback:
- - Highlight list rows on hover
- - Show secondary actions on hover (e.g., timestamp on message hover)
-- Prefer subtle background change or opacity shift over bold color changes
-- Don't use `.onHover()` on iOS — it's ignored on touch devices
-
-### Focus & Keyboard Navigation
-
-- macOS users navigate with Tab key — ensure logical focus order
-- Use `.focusable()` and `.focused()` for keyboard-navigable custom controls
-- Highlight focused elements with a visible focus ring (system default)
-
-## Animations
-
-- Use SwiftUI built-in transitions and animations (`.animation()`, `withAnimation {}`)
-- Prefer `.spring()` or `.smooth` for natural motion
-- Use `matchedGeometryEffect` for shared element transitions
-- Keep animations fast (0.2-0.35s) — never block interaction
-- Avoid custom animations when a system component handles it natively
-- Preserve feature-specific motion instead of imposing one global transition: onboarding uses `.smooth` and springs,
- input state changes use spring/push/opacity transitions, and chat messages currently insert with opacity only.
-
-## App Navigation Structure
-
-> **App-Specific** — Adapt tabs, sidebar sections, and navigation hierarchy for your project.
-
-### iOS / iPadOS
-
-- Root navigation is a `.sidebarAdaptable` `TabView` with four destinations.
-- **Chats** uses `bubble.left.and.bubble.right`.
-- **Models** uses `brain.head.profile`.
-- **Settings** uses `gearshape`.
-- **Search** uses `magnifyingglass` and `role: .search`.
-- Chats and Search own `NavigationStack` navigation. Models and Settings own their screen navigation as implemented.
-- iPadOS currently uses the same layout and SwiftUI's adaptive tab style; there is no separate Chats
- `NavigationSplitView` implementation.
-
-### macOS
-
-- Root navigation: `NavigationSplitView` with sidebar (no Tab Bar)
-- Sidebar shows Chats, Models, and Settings. Search is not a macOS sidebar destination.
-- Liquid Glass applies to sidebar and toolbar automatically
-
-## App Flow
-
-> **App-Specific** — Adapt the entry point, onboarding, and routing for your project.
-
-The app has a single entry point (`LaunchView`) that routes based on onboarding state:
-
-```
-LaunchView
-├── isOnboardingCompleted == false → OnboardingView
-│ ├── Step 1: Welcome (app intro + "Get Started" button)
-│ ├── Step 2: Server Configuration (base URL + API key + Test Connection)
-│ └── Step 3: All Set (confirmation + "Start Chatting" button)
-│ └── Saves isOnboardingCompleted = true → HomeView
-└── isOnboardingCompleted == true → HomeView (TabView / NavigationSplitView)
-```
-
-### LaunchView Rules
-
-- `LaunchView` is the **root view** in both iOS and macOS app entry points
-- It reads `isOnboardingCompleted` from `SettingsManager` (UserDefaults)
-- No animation on initial routing — instant switch
-- After onboarding completes, transition to `HomeView` with a smooth animation
-
-### OnboardingView Rules
-
-- Full-screen flow, no Tab Bar or navigation chrome visible
-- Step indicator (dots or progress) at the top
-- "Back" button available from Step 2 and 3 (not Step 1)
-- **"Skip" button** visible on all steps — user can skip the entire onboarding
-- If skipped, `isOnboardingCompleted = true` is still saved, and the user goes directly to `HomeView`
-- When skipped, server configuration fields remain empty — the app shows the Settings tab with a prompt to configure the server
-- Step 2 must validate server connection before allowing "Next" (but skip bypasses this)
-- On completion, persist `isOnboardingCompleted = true` and route to `HomeView`
-- User can always configure or reconfigure the server later from Settings
-
-### HomeView Rules
-
-- `HomeView` is the main shell — it hosts `TabView` (iOS/iPadOS) or `NavigationSplitView` (macOS)
-- `HomeView` is never wrapped in another `NavigationStack` — each tab manages its own
-- Default selected tab on launch: **Chats**
-
-## macOS Window
-
-> **App-Specific** — Adapt window sizes for your project.
-
-### Current Size
-
-- `LaunchView` has a minimum size of **800×600 pt**.
-- `WindowGroup(id: "main")` has a default size of **800×600 pt**.
-- The app does not currently apply `.windowResizability(.contentSize)`.
-
-### Persist Window Size & Position
-
-- The app must remember the user's window size and position across launches
-- Use `WindowGroup` with a stable identifier so macOS restores state automatically:
-
-```swift
-// macOS App entry point
-@main
-struct OpenClientApp: App {
- var body: some Scene {
- WindowGroup(id: "main") {
- LaunchView()
- .frame(minWidth: 800, minHeight: 600)
- }
- .defaultSize(width: 800, height: 600)
- }
-}
-```
-
-- macOS automatically persists window frame for `WindowGroup` with a stable `id` — no manual `UserDefaults` saving needed
-- Do **not** allow the window to resize below the minimum size
-
-## Toolbar Patterns
-
-> **App-Specific** (table below) — Adapt per-screen toolbar actions for your project.
-
-Define standard toolbar actions per screen to keep the UI consistent:
-
-| Screen | Leading | Center / Title | Trailing |
-|---|---|---|---|
-| **Chats list** | — | "Chats" title | New Chat menu (`square.and.pencil`), backup import/export actions |
-| **Chat detail** | Back (auto) | Model name (subtitle style) | Chat info/options (`ellipsis.circle`) |
-| **Models list** | — | "Models" title | Refresh button (`arrow.clockwise`) |
-| **Settings** | — | "Settings" title | — |
-| **Onboarding** | Back (Step 2+) | Step indicator | Skip button |
-
-### Implementation Rules
-
-- Use `.toolbar {}` with `ToolbarItem(placement:)` — never custom HStacks in the navigation bar
-- On macOS, add keyboard shortcuts to toolbar actions (e.g., `⌘N` for New Chat)
-- Keep toolbar items minimal — max 2-3 per screen
-- Use SF Symbols for all toolbar icons
-
-## Search
-
-Current implementation:
-
-- iOS/iPadOS has a dedicated Search tab backed by `SearchConversationsView`.
-- It uses local conversation filtering through `ConversationListViewModel`, `.searchable`, and
- `ContentUnavailableView.search` for no matches.
-- iOS places the field in an always-visible navigation bar drawer; macOS uses default searchable placement when this
- shared view is presented.
-- The macOS conversation list also has its own toolbar search expansion.
-- iPad adds a search field in `tabViewSidebarHeader`. While the sidebar is visible, it replaces the Search tab label
- and presents results in the main Chats area without a second search field. The top-bar layout retains the Search tab.
-- Activating iPad search with an empty query shows guidance, not all conversations. Selecting a result opens it in Chats,
- removes search focus, and preserves the query. Selecting a sidebar destination exits search.
-- `ModelsView` does not currently use `.searchable`; do not claim model search exists.
-
-```swift
-NavigationStack {
- List(filteredConversations) { conversation in
- ConversationRow(conversation: conversation)
- }
- .searchable(text: $searchText, prompt: String(localized: "Search conversations..."))
-}
-```
-
-## Toast Notifications (Preferred, Not Currently Centralized)
-
-There is no current app-wide `ToastManager`/`ToastView` overlay. If a feature introduces centralized transient feedback,
-use the following design rather than assuming the sample below already exists.
-
-### Design
-
-- **Position**: Top of the screen, centered horizontally, below the safe area
-- **Style**: Rounded capsule with translucent background (`.ultraThinMaterial`) and subtle shadow
-- **Content**: SF Symbol icon + short message text (one line max)
-- **Animation**: Slide in from top with `.spring()`, auto-dismiss after **3 seconds**
-- **Dismiss**: Auto-dismisses; user can also swipe up to dismiss early
-- **Stacking**: Only one toast visible at a time — new toasts replace the current one
-
-### When to Use
-
-> **App-Specific** (table below) — Adapt toast scenarios for your project.
-
-| Scenario | Toast |
-|---|---|
-| Connection test success | ✅ `checkmark.circle` + "Connected successfully" |
-| Message copied | ✅ `doc.on.doc` + "Copied to clipboard" |
-| Connection test failed | ✅ `xmark.circle` + "Connection failed" (if inline error also shown) |
-| Settings saved | ✅ `checkmark.circle` + "Settings saved" |
-
-### When NOT to Use
-
-- Critical errors that need user action → use inline error state
-- Data loss confirmations → use `.confirmationDialog()`
-- Loading states → use inline `ProgressView`
-
-### Implementation Pattern
-
-```swift
-// Toast overlay applied at HomeView level (once, not per screen)
-.overlay(alignment: .top) {
- if let toast = toastManager.current {
- ToastView(toast: toast)
- .transition(.move(edge: .top).combined(with: .opacity))
- .padding(.top, 8)
- }
-}
-```
-
-## Keyboard Avoidance (iOS / iPadOS)
-
-**Mandatory rule**: The keyboard must NEVER cover any text input field where the user types. This is a non-negotiable UX requirement.
-
-### Implementation Rules
-
-- Every view with text input must be wrapped in a `ScrollView` or use a layout that adjusts for the keyboard
-- SwiftUI's default keyboard avoidance is enabled by default — **do not disable it** with `.ignoresSafeArea(.keyboard)`
-- For chat input at the bottom of the screen: the input area must sit above the keyboard when active, pushing content up
-- Use `.scrollDismissesKeyboard(.interactively)` on `ScrollView` to allow dismissing the keyboard by dragging down
-- On iPadOS with external keyboard: ensure layouts don't break when the software keyboard is hidden
-
-### Chat Input Specific
-
-```swift
-// Input area stays above keyboard automatically via safe area
-VStack {
- ScrollView {
- // Messages
- }
- .scrollDismissesKeyboard(.interactively)
-
- ChatInputView() // Anchored to bottom, respects keyboard safe area
-}
-```
-
-### Form Input Specific
-
-- Forms with multiple fields: wrap in `ScrollView` so the active field scrolls into view
-- Use `Form` or `List` containers — they handle keyboard avoidance natively
-- After submit, dismiss keyboard explicitly with `FocusState`
-
-### Dismiss Keyboard Rules
-
-- Tapping outside a text field should dismiss the keyboard (use `FocusState` + `.onTapGesture`)
-- Sending a message in chat should NOT dismiss the keyboard (user may want to keep typing)
-- Submitting a form should dismiss the keyboard
-- Swiping down on a `ScrollView` dismisses the keyboard (`.scrollDismissesKeyboard(.interactively)`)
-
-## State Patterns
-
-Every screen must handle all possible data states. Never show a blank screen.
-
-### Loading State
-
-- Use `ProgressView()` centered on screen for initial data load
-- For refreshing existing data, prefer inline indicators (e.g., toolbar progress) over full-screen spinners
-- Never block interaction with a full-screen opaque loader — use non-blocking indicators when possible
-- Streaming responses use an animated typing indicator, not a spinner
-
-### Empty State
-
-- Every list/collection screen must show an empty state when there are no items
-- Empty state pattern: **SF Symbol** (large, secondary color) + **title** (headline) + **subtitle** (subheadline, secondary) + **action button** (optional)
-- Example: Chats tab with no conversations → `bubble.left.and.bubble.right` icon + "No conversations yet" + "Start a new chat" button
-- Use `ContentUnavailableView` (iOS 17+) as the standard empty state component
-
-```swift
-ContentUnavailableView {
- Label(String(localized: "No conversations"), systemImage: "bubble.left.and.bubble.right")
-} description: {
- Text(String(localized: "Start a new chat to begin"))
-} actions: {
- Button(String(localized: "New Chat")) {
- // action
- }
-}
-```
-
-### Error State
-
-- Show errors inline within the view — avoid blocking `alert()` dialogs for recoverable errors
-- Error pattern: SF Symbol (`exclamationmark.triangle`) + error message + "Retry" button
-- Use `ContentUnavailableView` for full-screen errors (e.g., failed to load model list)
-- For transient errors (network timeout), show a banner or inline message that auto-dismisses or has a manual dismiss
-- Connection errors on chat: show inline error below the failed message with a "Retry" option
-- Never show raw error codes or technical details to the user — use human-readable localized messages
-
-### No Connection State
-
-- When the server is unreachable, show a clear "No connection" state with a retry action
-- Do not silently fail — always inform the user
-
-## Form & Input Patterns
-
-### Text Fields
-
-- Use `TextField` with a clear localized placeholder
-- Use `SecureField` for API keys and sensitive data
-- Group related fields with `Section` inside `Form` or `List`
-- Add `.textContentType()` hint when applicable (`.URL` for server URL)
-- Use `.autocorrectionDisabled()` and `.textInputAutocapitalization(.never)` for URLs, API keys, and technical input
-- Use `.submitLabel()` to set the keyboard return key (`.done`, `.next`, `.send`)
-
-### Validation
-
-- Validate input inline as the user types or on field exit — not only on submit
-- Show validation errors below the field in `.caption` font with destructive color
-- Disable "Submit" / "Next" button until required fields are valid
-- For server URL: validate format before allowing connection test
-- For API key: don't validate format — only test via actual connection
-
-### FocusState
-
-- Use `@FocusState` to manage keyboard focus across multiple fields
-- Move focus to the next field on "Next" keyboard button
-- Dismiss keyboard on "Done" or successful form submission
-
-## Modals & Sheets
-
-- Use `.sheet()` for modal presentations (new chat, edit settings)
-- Prefer `.confirmationDialog()` for contextual destructive actions. Existing Settings and conversation flows also use
- `.alert`; preserve their behavior unless the task includes migrating that presentation.
-- Use `.popover()` on iPadOS/macOS for contextual options
-- Sheets should have a clear dismiss action (Cancel button or swipe down)
-- Sheets should not be full-screen on iPadOS/macOS — use `.presentationDetents()` to control height when appropriate
-
-### Destructive Actions
-
-- **Always confirm** before deleting conversations, clearing data, or resetting settings
-- Use `.confirmationDialog()` with a descriptive title and a `.destructive` role button
-- Example: "Delete Conversation?" → "This action cannot be undone." → [Delete] [Cancel]
-- Never auto-delete without user confirmation
-
-## Scroll Behavior
-
-### Chat Scroll
-
-- Use `ScrollViewReader` with explicit top and bottom sentinels; do not bind live chat position on macOS
-- If the user scrolls to read history, detach bottom-follow immediately and show a "Scroll to bottom" floating button
-- `ScrollTriggerModifier` owns initial, response-start, coalesced-content, final-layout, and favourite-message positioning
-- Never drive automatic chat scrolling from geometry or visibility observers; use semantic revisions and scroll phases
-- Preserve the user's position after interaction until a new response starts or the user explicitly returns to the bottom
-
-### List Scroll
-
-- Use `.refreshable {}` for pull-to-refresh on lists that load from the server (models list, conversations list)
-- Maintain scroll position when data updates (e.g., new conversation added to list)
-
-## Safe Areas
-
-- **Always respect safe areas** — never place interactive content under the notch, Dynamic Island, or home indicator
-- Use `.safeAreaInset()` for floating elements that should respect the safe area (e.g., floating action button)
-- The chat input bar uses `.safeAreaInset(edge: .bottom)` to anchor above the safe area and keyboard
-- On macOS, respect the title bar area — never overlap content with window controls
-
-## Gestures
-
-- Use standard system gestures — swipe to delete in lists, swipe back for navigation
-- Do not override system gestures (edge swipe for back navigation)
-- Long press on a message for context menu (copy, retry, delete)
-- Use `.contextMenu()` or `.swipeActions()` — never custom gesture recognizers for standard interactions
-
-## Chat UI Patterns
-
-- **Message bubbles**: Rounded rectangles, different alignment/color for user vs assistant
-- **Streaming indicator**: A blinking `█` cursor after content begins; a static localized "Thinking..." label appears
- while streaming has started but both answer and reasoning content are empty
-- **Input area**: Text field with send button, anchored to bottom with keyboard avoidance
-- **Scroll behavior**: Auto-scroll with explicit `ScrollViewReader` targets, allow manual scroll to detach immediately, and
- never bind live scroll position while a response is active
-- **Code blocks**: Implemented as horizontally scrolling monospaced text with language/"Code" header, material
- background, and a copy button. Syntax coloring is not currently implemented.
-- **Markdown rendering**: Render assistant messages as Markdown (bold, italic, lists, links, code)
-- **Timestamps**: Shown outside message surfaces in trailing `.caption2`/tertiary styling; a centered date capsule appears
- only during manual history scrolling
-- **Copy message**: Long press or context menu to copy full message text
-
-> For detailed chat UI implementation patterns, see `chat-visual-style.instructions.md`.
-
----
-
-## App-Specific Sections Summary
-
-The following sections in this document contain project-specific configuration for **OpenClient** and should be adapted when reusing these guidelines in another project:
-
-| Section | What to adapt |
-|---|---|
-| **Current App Assets** | Accent, legacy chat colors, logo/provider images, and their actual use |
-| **App Navigation Structure** | Specific tabs, sidebar sections, navigation hierarchy |
-| **App Flow** | Entry point routing, onboarding steps, screen flow |
-| **macOS Window** | Window sizes and resizability |
-| **Toolbar Patterns** | Per-screen toolbar action table |
-| **Toast Scenarios** | App-specific notification scenarios table |
-
-All other sections are **generic Apple design guidelines** reusable across any SwiftUI project targeting iOS 26+ / macOS 26+.
+- Never leave a screen blank while data is loading, unavailable, empty, or failed.
+- Reuse the feature's existing loading, empty, error, disconnected, disabled, and in-progress presentations.
+- Keep recoverable errors near their context with an available recovery action when one exists; reserve blocking
+ presentation for decisions that require it.
+- Do not expose raw implementation errors or credentials in user-facing UI.
diff --git a/specs/icloud-sync.instructions.md b/specs/icloud-sync.instructions.md
index 4aad2b52..d9e70b07 100644
--- a/specs/icloud-sync.instructions.md
+++ b/specs/icloud-sync.instructions.md
@@ -6,44 +6,27 @@ description: "Use when implementing or changing iCloud synchronization, iCloud D
## Scope
-This specification is the authoritative contract for OpenClient synchronization through the app's private iCloud
-Documents container. It covers conversations, attachments, the user profile, memory items, custom prompt templates, and
-the metadata required to reconcile or delete them.
+OpenClient synchronizes conversations and their attachments, the user profile, memory items, custom prompt templates, and
+their deletion metadata through the private iCloud Documents container. The contract is file based through `Codable` and
+`FileManager`; SwiftData, CloudKit records, third-party databases, and server-side synchronization are outside its scope.
-The synchronization implementation must remain file based through `Codable` and `FileManager`. SwiftData, CloudKit
-records, third-party databases, and a server-side synchronization service are outside the scope of this feature.
+## Data-Safety Guarantees
-## Implementation Status
+- Existing local and cloud JSON files are user data and remain readable across compatible upgrades.
+- Empty or missing data, unavailable containers, pending downloads, decode failures, and unsupported schemas never imply
+ deletion.
+- Writes and deletes begin only after all reconciliation inputs and deletion metadata that can affect the decision are
+ current and validated.
+- Reconciliation is deterministic and idempotent. At most one operation mutates local or cloud state at a time; triggers
+ received during a run are coalesced into at most one follow-up run.
+- Permanent deletion requires explicit user intent or durable deletion metadata created by that intent.
+- A category failure is retained as a partial result and never converted into global success. iCloud file access does not
+ block the main actor.
-The file-based iCloud Documents synchronization stabilization was completed in this order:
-
-- [x] Synchronization contract, runtime-state contract, and compatible schema versioning.
-- [x] Testable serialized storage infrastructure and critical correctness fixes.
-- [x] Consistent reconciliation for conversations, attachments, profile, memory, and prompt templates.
-- [x] Accurate Settings state and user communication.
-- [x] Cloud inventory plus durable individual and global deletion.
-- [x] Automated two-device coverage.
-
-## Core Guarantees
-
-- Existing local and cloud JSON files are user data and must remain readable across upgrades.
-- Synchronization must never infer deletion from an empty directory, a missing file, an unavailable container, a pending
- iCloud download, a decoding failure, or an unsupported schema.
-- A write or delete may begin only after all metadata that can affect its reconciliation decision is current.
-- Repeating the same synchronization with unchanged inputs must produce the same result and no additional writes.
-- At most one synchronization operation may mutate local or cloud state at a time. Triggers received during a run are
- coalesced into at most one follow-up run.
-- User data may be permanently deleted only after an explicit user action or after applying durable deletion metadata
- created by such an action.
-- No iCloud file operation may block the main actor.
-- A failure in one data category must be reported. It must not be converted into global success or silently discarded.
-
-## Storage Backend
+## Version 1 Container And Layout
Both app targets use the private ubiquity container `iCloud.com.artcc.openclient-llm` with the `CloudDocuments` service.
-The developer and the configured LiteLLM server have no access to this container.
-
-The current Version 1 layout under the container's `Documents` directory is:
+Neither the developer nor the configured LiteLLM server can access it.
```text
Documents/
@@ -62,17 +45,14 @@ Documents/
SyncManifest.json
```
-`ConversationTombstones.json` is the legacy aggregate tombstone file. Readers must continue to merge it with per-record
-tombstones while it can exist in shipped installations. New user data must not be stored in synchronization metadata.
-`CloudPurgeMarker.json` is the verified global deletion barrier shared by every category. It is synchronization metadata,
-not an independently manageable user record.
-
-## Schema Versioning
+`ConversationTombstones.json` is the legacy aggregate tombstone file and is merged with per-record tombstones.
+`ConversationDeleteAll.json` remains compatible conversation-wide deletion metadata. New user records are never stored in
+metadata files. `CloudPurgeMarker.json` is the shared global deletion barrier, not a user record.
-Version 1 is the current storage schema. A missing `SyncManifest.json` means legacy Version 1 and is valid. The absence of
-the manifest must never make the container look empty or unsupported.
+## Schema And Serialization
-When written, the additive manifest has this schema:
+Version 1 is the current schema. A missing `SyncManifest.json` is valid legacy Version 1 and never means that the container
+is empty or unsupported. When present, the additive manifest is:
```json
{
@@ -82,132 +62,85 @@ When written, the additive manifest has this schema:
}
```
-Manifest rules:
-
-- `format` must match exactly.
-- `schemaVersion` describes the layout written by the newest participating app.
-- `minimumReaderVersion` is the oldest implementation allowed to mutate that layout.
-- Unknown fields are ignored for forward-compatible additive changes.
-- A malformed manifest, an unknown format, or an unsupported version puts synchronization into a read-only failure state.
- The app must not write, migrate, or delete cloud files in that state.
-- An incompatible layout change requires an incremented schema version and an explicit, tested migration.
-- Migration writes the new representation first, reads it back, validates it, and only then records completion.
-- Files that would be replaced or removed during migration must first be copied to local recovery storage outside the
- iCloud container. Corrupt or unrecognized files are preserved and reported.
-- Cleanup of a previous representation is deferred until the new representation has completed verified synchronization.
-
-## User Intent And Runtime State
+- `format` matches exactly. Versions are positive, `minimumReaderVersion <= schemaVersion`, and mutation is allowed only
+ when `minimumReaderVersion` is supported. Unknown fields are ignored.
+- A malformed manifest, unknown format, invalid version range, or unsupported minimum reader version makes cloud storage
+ read-only for that run: no write, migration, or deletion is allowed.
+- An incompatible layout requires a new schema version and explicit tested migration. Migration writes, reads back, and
+ validates the new representation before recording completion; replaced or removed files are first preserved in local
+ recovery storage, and old cleanup waits for verified synchronization.
+- Synchronized JSON uses sorted, pretty-printed keys and ISO 8601 UTC dates with microsecond precision. Readers accept ISO
+ 8601 dates. Writes are atomic, coordinated where required, read back, byte-checked, and decoded before success.
-The persisted `isCloudSyncEnabled` setting represents only user intent. It does not mean that iCloud is available or that
-data is synchronized.
+## Runtime Contract
-Runtime state is ephemeral and has these semantic states:
+Persisted `isCloudSyncEnabled` records user intent only. Availability and `CloudSyncStatus` are ephemeral:
-| State | Meaning |
+| `CloudSyncStatus` | Meaning |
|---|---|
-| `disabled` | User intent is off. No observers, retries, downloads, writes, or deletes are active. |
-| `checkingAvailability` | The app is resolving account, container, schema, and initial metadata state. |
-| `idle` | User intent is on and the container is usable, but no complete successful run is currently asserted. |
-| `synchronizing` | A serialized reconciliation is in progress. |
-| `waitingForDownloads` | Required ubiquitous items are not current; their downloads have been requested and no writes are allowed. |
-| `synchronized` | Every enabled data category completed successfully in the same run. |
-| `unavailable` | The account or container cannot currently be used. User intent may remain on. |
-| `failed` | A non-pending operation failed. The error and affected categories are retained for UI and retry. |
-
-Rules for state and settings:
-
-- Availability and runtime state are never persisted as if they were user preferences.
-- The last successful synchronization date is local diagnostic state, not proof that the current container is available.
-- Turning synchronization off must always be possible, including while iCloud is unavailable.
-- Turning synchronization off cancels pending work and stops observers. It does not delete local or cloud data.
-- Enabling synchronization performs availability, schema, and metadata preflight before any user data write.
-- `synchronized` describes conversations, attachments, profile, memory, and templates together. A conversation-only result
- must never be presented as global synchronization success.
-
-## Reconciliation Rules
-
-All categories follow these common rules:
-
-1. Resolve the container and validate the manifest.
-2. Gather metadata and request required placeholder downloads.
-3. If required input is pending, return `waitingForDownloads` without writing user data.
-4. Decode local data, cloud data, and deletion metadata independently.
-5. Merge logical records deterministically.
-6. Persist local output and verify it.
-7. Persist cloud output through coordinated atomic writes and verify it.
-8. Apply durable deletions only after their metadata is safely stored.
-9. Return a per-category result and derive the global runtime state.
-
-An item present only locally is uploaded unless durable deletion metadata rejects it. An item present only in iCloud is
-downloaded unless durable deletion metadata rejects it. An empty side contributes no records; it is not an instruction to
-remove records from the other side.
-
-Category identity and conflict rules:
-
-- Conversations are records keyed by `Conversation.id`. The newest valid `updatedAt` wins. A tombstone rejects only a
- version that is not newer than its deletion date.
-- Attachments are children of a conversation and are never reconciled as independent user records. Referenced files are
- materialized; unreferenced cloud folders are cleaned only after the parent reconciliation is verified.
-- Memory is merged by `MemoryItem.id`, not by treating `Memory.json` as an indivisible winner. Item updates require a
- modification value and item deletions require durable metadata.
-- Custom prompt templates are records keyed by `PromptTemplate.id`. Built-in templates are never cloud user data. Template
- updates require a modification value and deletions require durable metadata.
-- The profile is a singleton record. Its modification metadata and deletion marker determine the winner. If an automatic
- choice cannot be made safely, the conflict UI must explicitly refer only to the profile.
-- Equal modification values with different content are conflicts. Resolution must be deterministic and the losing valid
- representation must remain recoverable.
-
-## Deletion Rules
-
-- Individual deletion writes durable deletion metadata before removing the corresponding local or cloud data.
-- Deletion metadata is merged using the newest deletion date and is intentionally retained so an offline device cannot
- resurrect old content.
-- A delete-all operation writes its purge marker before deleting any category.
-- A purge marker rejects records whose modification value is not newer than the marker. Records created or deliberately
- updated after the purge remain eligible to synchronize.
-- Delete operations are idempotent. An already absent payload is success only when its required deletion metadata exists.
-- The cloud-management UI uses synchronized deletion semantics: deletion affects iCloud and all synchronized devices.
- Removing only a cloud copy while synchronization remains active is not supported because another device can re-upload it.
-- Internal manifests, tombstones, and purge markers are not presented as independently deletable user records.
-
-## Availability And Observation
-
-- Availability requires a valid ubiquity identity and a resolvable container URL, but these checks are runtime snapshots,
- not permanent facts.
-- The app observes ubiquity identity changes and re-checks availability when becoming active.
-- Metadata observation starts only when user intent is enabled and the container is available. It stops when either ceases
- to be true and can start again later.
-- Initial metadata gathering always establishes a baseline. Starting while synchronization is disabled must not leave an
- observer that can neither emit nor restart.
-- Metadata events are debounced and coalesced. Writes generated by the app may trigger observation, but idempotent file
- comparison and the serialized coordinator must prevent feedback loops.
-
-## Error And Recovery Contract
-
-- Errors distinguish unavailable account/container, pending download, unsupported schema, invalid data, coordinated file
- access failure, insufficient storage, and partial category failure.
-- Transient failures may retry with bounded backoff. Permanent failures wait for explicit user action or a relevant system
- event. Disabling synchronization cancels retries.
-- Raw file paths, profile content, memory content, conversation content, and attachment content must not be logged.
-- A valid representation that loses conflict resolution is copied to local recovery storage before it can be replaced.
-- Automatic recovery never uploads an unvalidated file.
-
-## User Communication
-
-Settings must communicate user intent and runtime state separately. It must identify all synchronized categories, show
-pending downloads and failures, retain the last successful date, and provide retry when appropriate. Manual synchronization
-must cover every category; otherwise it must be labeled with the category it actually affects.
-
-Destructive actions require confirmation that explains their device-wide synchronized effect. A partial delete must list
-the categories that failed and remain retryable; it must not report that all data was deleted.
-
-## Certification Requirements
-
-A synchronization behavior is not complete until it has:
-
-- Unit tests against an injectable temporary cloud root.
-- Deterministic two-device tests with separate local roots and a shared cloud root.
-- Tests for local-only, cloud-only, equal, divergent, pending, unavailable, corrupt, deleted, and repeated inputs.
+| `disabled` | User intent is off; no synchronization work is active. |
+| `checkingAvailability` | Account, container, schema, and initial metadata are being resolved. |
+| `idle(lastSuccessfulSyncAt:)` | The container is usable, without asserting a complete current run. |
+| `synchronizing` | Serialized reconciliation is running. |
+| `waitingForDownloads` | Required ubiquitous items are not current; no writes are allowed. |
+| `synchronized(lastSuccessfulSyncAt:)` | Every data category succeeded in the same run. |
+| `unavailable` | The account or container is unavailable; user intent may remain enabled. |
+| `failed` | A non-pending failure affects the recorded categories. |
+| `incomplete` | Categories have mixed pending, unavailable, or failed outcomes; unaffected work is not reported as global success. |
+
+The last successful date is local diagnostic state, not proof of present availability. Disabling sync cancels pending work,
+observation, and retries without deleting data. Enabling performs availability, schema, and metadata preflight before any
+user-data write.
+
+## Reconciliation
+
+Each category follows the same safety sequence: resolve the container and schema; gather metadata and request placeholder
+downloads; stop without writes if required input is pending; decode local, cloud, and deletion inputs independently; merge
+deterministically; persist and verify local then cloud output; and apply deletion only after its metadata is durable.
+
+- Local-only records upload and cloud-only records download unless rejected by durable deletion metadata. An empty side
+ contributes no records and never removes records from the other side.
+- Conversations are keyed by `Conversation.id`; the newest valid `updatedAt` wins. A tombstone rejects versions not newer
+ than its deletion date. Attachments are children of conversations; referenced files are materialized, and unreferenced
+ cloud folders are removed only after verified parent reconciliation.
+- Memory is merged by `MemoryItem.id`, and custom templates by `PromptTemplate.id`, using their modification values and
+ durable per-item deletion metadata. Built-in templates are not cloud user data.
+- The profile is a singleton resolved by modification metadata and `UserProfileDeletion.json`. Unsafe automatic choices
+ remain conflicts.
+- Equal modification values with different content are deterministic conflicts; the losing valid representation is
+ preserved in local recovery storage before replacement.
+
+## Tombstones And Purge
+
+- Individual deletion stores durable metadata before removing payloads. Tombstones merge by newest deletion date and are
+ retained so offline devices cannot resurrect stale records.
+- Delete-all stores `CloudPurgeMarker.json` before deleting any category and journals per-category completion for safe,
+ resumable cleanup. The marker rejects records whose modification value is not newer than `deletedAt`; later records can
+ synchronize normally.
+- Deletion is idempotent. An absent payload is success only when the required deletion metadata exists. Metadata is not an
+ independently deletable user record.
+- Partial purge failures preserve the marker and unfinished categories for retry; they never report complete deletion.
+
+## Availability, Recovery, And Privacy
+
+- Availability requires a current ubiquity identity and resolvable container URL. Identity changes and app activation
+ invalidate the snapshot and require a new preflight.
+- Metadata observation exists only while intent is enabled and the container is available. It establishes an initial
+ baseline; events are debounced and coalesced, and idempotent comparison prevents write feedback loops.
+- Errors distinguish unavailable account/container, pending downloads, unsupported schema, invalid data, coordinated file
+ access failure, insufficient storage, and partial category failure. Transient retries use bounded backoff; disabling sync
+ cancels them.
+- A valid losing or replaced representation is preserved in local recovery storage. Corrupt or unrecognized files are
+ preserved and reported. Recovery never uploads unvalidated data.
+- Logs never contain raw paths, profile or memory content, conversation content, or attachment content.
+
+## Certification
+
+Changes to synchronization behavior require:
+
+- Unit coverage against an injectable temporary cloud root.
+- Deterministic two-device coverage with separate local roots and one shared cloud root.
+- Cases for local-only, cloud-only, equal, divergent, pending, unavailable, corrupt, deleted, partial, and repeated inputs.
- iOS and macOS verification.
-- Manual validation with two app installations using a real test iCloud account for placeholder and metadata behavior that
- cannot be faithfully reproduced by the local test harness.
+- Manual two-installation validation with a test iCloud account for placeholder and metadata behavior that the local harness
+ cannot reproduce faithfully.
diff --git a/specs/litellm-api.instructions.md b/specs/litellm-api.instructions.md
index 93f3958d..4be26ce8 100644
--- a/specs/litellm-api.instructions.md
+++ b/specs/litellm-api.instructions.md
@@ -1,243 +1,78 @@
---
-description: "Use when implementing API client, networking layer, LiteLLM integration, chat completions, model listing, streaming SSE responses, or server health checks."
+description: "Use when changing OpenAI-compatible or LiteLLM networking, model discovery, chat/SSE transport, images, MCP endpoints, health checks, or network logging."
---
-# LiteLLM API Integration
-
-## Server Overview
-
-LiteLLM is a self-hosted proxy that exposes an **OpenAI-compatible API** for multiple LLM providers (Ollama, OpenAI, Anthropic, Groq, etc.). The app connects to a single user-configured base URL.
-
-## Configuration
-
-- **Base URL**: User-configurable (e.g., `https://litellm.example.com`), stored in app settings
-- **API Key**: Optional, via `Authorization: Bearer ` header
-- **No hardcoded endpoints**: Always build URLs relative to the base URL
-
-## Key Endpoints
-
-### Chat Completions — `POST /chat/completions`
-
-```json
-{
- "model": "gpt-4",
- "messages": [
- {"role": "system", "content": "You are a helpful assistant."},
- {"role": "user", "content": "Hello"}
- ],
- "stream": true
-}
-```
-
-- Supports streaming via **Server-Sent Events (SSE)** when `stream: true`
-- Response follows OpenAI chat completions format
-- For streaming: use `URLSession` bytes async sequence, parse `data: ` prefixed JSON lines
-- Handle `[DONE]` sentinel to detect stream end
-
-### Native Chat Images
-
-- Input images use multimodal content parts with `type: "image_url"` and `image_url.url` containing a base64 data URL.
- `ChatRepository` uses the attachment's MIME type and the existing 5 MB prepared-input limit. This differs from the
- larger generated-output limit below. PDF attachments continue through existing text extraction, not visual delegation.
-- Native generated output is read from `choices[0].message.images` for non-streaming agent completions or
- `choices[0].delta.images` for SSE. Both use `ChatCompletionResponse.ImageItem`, with `image_url.url` and optional
- `type` and `index` metadata. The supported URL form is `data:image/;base64,...`.
-- `GeneratedImageDecoder` bounds the encoded payload and predicted decoded allocation before base64 decoding, rejects
- empty/invalid images and decoded data over 25 MiB, and determines the actual MIME type from ImageIO/UTType rather than
- trusting the data URL's declared subtype. Do not hardcode PNG for typed chat-generated output.
-- Remote HTTP(S) URLs are not supported for generated images returned through chat completions. Reject them rather than
- downloading them or falling back to an images endpoint. Dedicated image endpoints retain their existing URL handling.
-- `GenerateChatImageUseCase` conforms to `GenerateImageUseCaseProtocol` and calls `ChatRepository.streamMessage` with one
- user text prompt and default parameters. It rejects empty prompts and all attachments, ignores text/reasoning/usage
- chunks, and returns the first image decoded as `GeneratedImage`; a stream without an image fails. It never supplies
- tools, invokes the agent recursively, or retries through a different transport.
-- Chat image specialists and principals with native generation use `modalities: ["image", "text"]` through their captured
- chat repository. Ordinary chat and analysis requests retain `modalities: nil`; capability metadata never redirects a
- chat model to `/images/generations`. Non-streaming native image-only agent responses are valid final responses and emit
- `.generatedImage(GeneratedImage)` before paced text, alongside support for legacy `.image(Data)` events.
-
-### List Models — `GET /models`
-
-Returns available models in OpenAI format:
-
-```json
-{
- "data": [
- {"id": "gpt-4", "object": "model", "owned_by": "openai"},
- {"id": "ollama/llama3", "object": "model", "owned_by": "ollama"}
- ]
-}
-```
-
-### Model Info — `GET /model/info` (optional LiteLLM enrichment)
-
-Returns detailed information about each model, including capabilities and cost data pulled from model config and the [LiteLLM model cost map](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json).
-
-```json
-{
- "data": [
- {
- "model_name": "gpt-4",
- "litellm_params": { "model": "gpt-4" },
- "model_info": {
- "id": "...",
- "key": "gpt-4",
- "max_tokens": 4096,
- "max_input_tokens": 8192,
- "max_output_tokens": 4096,
- "input_cost_per_token": 3e-05,
- "output_cost_per_token": 6e-05,
- "litellm_provider": "openai",
- "mode": "chat",
- "supports_vision": true,
- "supports_function_calling": true,
- "supports_parallel_function_calling": true,
- "supports_response_schema": false
- }
- }
- ]
-}
-```
-
-Key capability fields in `model_info`:
-- `supports_vision` — model can process images
-- `supports_function_calling` — model supports tool/function calls
-- `supports_parallel_function_calling` — model can call multiple tools in parallel
-- `supports_response_schema` — model supports structured JSON output schema
-- `supported_output_modalities` - optional output types; `image` adds generation capability while preserving `mode`.
- Absence, null, or a list without `image` does not imply generation. `supports_vision` alone never implies image output.
-- `mode` — model type: `chat`, `completion`, `embedding`, etc.
-- `litellm_provider` — provider name (openai, anthropic, ollama, etc.)
-- `max_input_tokens` / `max_output_tokens` — context window limits
-
-For Ollama models, `litellm_params.model` determines the tool-calling transport. `ollama_chat/` uses Ollama's
-native `/api/chat` structured `tool_calls` protocol. The legacy `ollama/` adapter uses `/api/generate`, forces JSON
-output, and emulates function calls through the prompt. The app therefore does not expose `.functionCalling` for
-`ollama/` routes even if model metadata advertises it; configure `ollama_chat/` to enable agent routing.
-
-`/model/info` is LiteLLM-specific enrichment, not a prerequisite for the OpenAI-compatible
-chat API. Servers such as Ollama, vLLM, llama.cpp, or custom proxies may omit it or return an
-error. In that case, continue with `/models` and `/chat/completions`; model capabilities, token
-limits, pricing, and usage metadata must remain optional. Users can configure a manual context
-window per conversation when the server does not provide `max_input_tokens`.
-
-### Image And Vision Defaults
-
-- Models exposes independent optional `Vision` and `Image Generation` selections in **Image and Vision**, persisted by
- `SettingsManager` as `selectedVisionModelId` and `selectedImageGenerationModelId`. They do not change the principal
- model, each offers `None`, and the same dual-capability chat model may fill both roles.
-- Vision eligibility requires `.vision` with mode `.chat`, `.completion`, or `.unknown`. Image-generation eligibility
- requires dedicated `.imageGeneration` mode or `.imageGeneration` capability with one of those chat-compatible modes.
- Use `LLMModel.isVisionSpecialist` and `isImageGenerationSpecialist`, not capability labels alone, for eligibility.
-- Keep missing/ineligible defaults as unavailable until the user selects a replacement or `None`. Never silently select
- another model, infer support when metadata is absent, or fall back between dedicated and chat transports.
-- Native capability takes priority per role: `.vision` is native vision; dedicated image mode or `.imageGeneration`
- capability is native generation. `analyze_images` requires a function-calling principal without native vision, a
- selected eligible vision specialist in the current catalog, and image attachments. `generate_image` requires a
- function-calling principal without native generation and a selected eligible dedicated or chat image specialist.
-- A principal without function calling cannot delegate. A principal with native support does not advertise the equivalent
- specialist tool, even after a native failure. Disabling a specialist does not disable native capabilities.
-- See `agent-tool-calling.instructions.md` for UUID projection across history before budgeting/compaction, historical
- attachment IDs in definitions, argument limits, one generation attempt per turn, and image persistence checkpoints.
-
-### Images - `POST /images/generations` And `POST /images/edits`
-
-- Dedicated image models keep the existing `GenerateImageUseCase` / `ImageGenerationRepository` path.
- `/images/generations` sends JSON with `model`, `prompt`, `n: 1`, and `response_format: "b64_json"`.
-- The existing dedicated editing flow uses multipart `/images/edits` with prepared `image` files, `model`, `prompt`, and
- `n: "1"`. This is not exposed by `generate_image`, which always sends an empty attachment list and is text-only;
- `GenerateChatImageUseCase` does not support editing either. Do not describe the existing dedicated editor as unsupported.
-- Both dedicated requests use a 600-second network timeout. The repository consumes the first response image, supporting
- existing `b64_json` or downloaded URL output and `revised_prompt`. Its current base64 path labels output `image/png`;
- actual decoded MIME detection described above applies to the chat image decoder, not this legacy dedicated path.
-
-### Search — `POST /v1/search/{search_tool_name}` and `GET /v1/search/tools`
-
-`POST /v1/search/{search_tool_name}` executes the configured LiteLLM search tool. `GET /v1/search/tools` discovers search tools available on the server.
-
-### MCP Tools — `GET /v1/mcp/server`, `GET /mcp-rest/tools/list`, `POST /mcp-rest/tools/call`
-
-`GET /v1/mcp/server` returns the list of MCP servers configured on the LiteLLM instance:
-
-```json
-{
- "data": [
- { "server_name": "github_mcp", "server_id": "github_mcp" }
- ]
-}
-```
-
-`GET /mcp-rest/tools/list?server_id={id}` lists the tools exposed by a given MCP server,
-each with a `name`, optional `description`, and an `inputSchema` (recursive JSON Schema).
-
-`POST /mcp-rest/tools/call` executes a tool with the given server ID, tool name, and
-JSON `arguments` payload, returning `content` items with text and an optional `isError` flag.
-
-These endpoints are LiteLLM-specific. If the server does not expose them, the app continues
-without MCP tools and the MCP antenna icon in the chat input bar shows in grey.
-
-### Audio — `POST /v1/audio/transcriptions` and `POST /v1/audio/speech`
-
-Transcription uses multipart form data. Speech synthesis returns raw audio data.
-
-### Connection And LiteLLM Detection
-
-- Connection tests call `GET /models` with an optional bearer token.
-- LiteLLM detection separately calls `GET /health/readiness` and treats a `200` JSON response containing `litellm_version` as LiteLLM.
-- There is no current `GET /health` `APIClient` operation.
-
-## Networking Architecture
-
-- `APIClient` is a `Sendable` struct conforming to `APIClientProtocol`; it stores a `URLSession` and main-actor endpoint/key
- providers. Initialization can read settings dynamically or capture an explicit endpoint/credential pair.
-- Request/response models as `Codable` structs in `Core/Networking/`
-- Generic JSON and multipart responses use `JSONDecoder` with `.convertFromSnakeCase`; streaming decoding is performed by `ChatRepository`.
-- Handle HTTP errors with typed `APIError` enum
-- Default JSON/raw and ordinary chat streaming requests use 60 seconds; downloads use 120 seconds and the single-file
- multipart convenience overload uses 125 seconds. Dedicated image requests explicitly use 600 seconds. Onboarding
- connection and readiness checks use 30 and 10 seconds respectively; these are not user-configurable settings.
-- Specialist and native chat image generation use `streamTimeoutInterval: 600` and a repository configured with a
- 600-second non-streaming completion timeout. Ordinary chat and vision analysis retain their 60-second default.
- This is separate from the agent's active-time budget: 300 seconds base plus `AgentToolContext.additionalExecutionTime`,
- with 600 additional seconds when `generate_image` is initially advertised or the principal supports native generation.
- Increasing the loop budget alone does not extend a network request timeout.
-- SSE streaming via `URLSession.bytes(for:)` async sequence
-- Endpoints are passed as relative strings such as `models`, `model/info`, and `chat/completions`; `URL.appendingPathComponent` resolves them against the user-configured base URL.
-
-### Specialist Request Boundaries
-
-- Build specialist repositories with API clients capturing one endpoint/credential pair. Capture the model-catalog
- authorization scope and revalidate it, selected IDs, catalog eligibility, and principal capabilities when advertising
- tools and before/after asynchronous specialist work. Do not route in-flight conversation data to newly edited settings.
-- Analysis sends only its question and 1 to 4 prepared images, with a specialist system instruction and no tools. Its
- question is limited to 4000 characters; generation accepts only a text prompt up to 8000 characters and one attempted
- request per user turn. Analysis/OCR is bounded untrusted data, never instructions for subsequent tools.
-- Generated bytes travel through `ToolExecutionResult.images` and `.generatedImage(GeneratedImage)`, not tool text or
- hidden model-facing transcripts. Preserve images when truncating textual results. The visible assistant receives image
- attachments immediately, with normal persistence checkpoints; Private Chat keeps these attachments only in memory.
-- Do not attribute specialist tokens or charges to the principal model. Agent usage aggregates principal completions
- only, while specialist calls may incur additional provider charges that are not included in those counters.
-
-## Architecture Integration
-
-- **Repository** wraps `APIClient` calls (e.g., `ChatRepository`, `ModelsRepository`)
-- **UseCase** encapsulates business logic using repositories (e.g., `SendMessageUseCase`, `FetchModelsUseCase`)
-- **ViewModel** calls UseCases via Event/State pattern — never calls `APIClient` directly
-- **Manager** handles transversal concerns (e.g., `AuthManager` for API key, `ConnectivityManager`)
-
-## Error Handling
-
-- Network errors: no connectivity, timeout, DNS failure
-- HTTP errors: 401 (auth), 429 (rate limit), 500 (server error)
-- Parse errors: malformed JSON responses
-- Server unreachable: LiteLLM not running or wrong URL
-- Model-info unavailable: continue with minimal models and optional manual context settings
-- Present user-friendly error messages, log technical details
-
-## Current Debug Logging
-
-`LogManager` prints only in `DEBUG` builds. `APIClient` logs request metadata, status codes, and byte counts rather than
-successful response bodies or HTTP error payloads. `ChatRepository` still logs a short payload preview when skipping an
-undecodable SSE chunk; this is a remaining payload-logging risk, not a pattern to extend. Image data URLs, base64, analysis
-text, prompts, and credentials must not be added to logs. Prefer endpoints, sizes, and redacted diagnostics, and keep
-production logging disabled.
+# LiteLLM API
+
+## Configuration And Layering
+
+- Build every API URL relative to the user-configured base URL. Add `Authorization: Bearer ` only when the Keychain
+ value is nonempty; never hardcode hosts or credentials.
+- `APIClient` owns HTTP construction, decoding, SSE transport, multipart uploads, downloads, and typed `APIError` mapping.
+ Repositories map endpoint DTOs, UseCases apply business rules, and ViewModels coordinate them.
+- Use `.convertFromSnakeCase` for API response decoding. Keep request models `Encodable` and transport values `Sendable`.
+- Use a captured endpoint/key pair for operations whose authorization scope must not change in flight, including MCP and
+ delegated image work. Ordinary clients may read current settings per request.
+
+## OpenAI-Compatible Endpoints
+
+- `GET /models` is the required model catalog endpoint.
+- `POST /chat/completions` serves ordinary streaming chat, non-streaming calls, and non-streaming agent rounds.
+- Streaming requests use SSE lines prefixed with `data: ` and terminate on `[DONE]`. Decode content, reasoning, usage, and
+ native image deltas without retaining raw chunks.
+- Agent requests send OpenAI-compatible `tools`, `tool_choice: "auto"`, assistant `tool_calls`, and `role: "tool"`
+ messages as specified in `agent-tool-calling.instructions.md`.
+- Multimodal input uses `image_url` content parts with base64 data URLs after the attachment preparation and size checks.
+ PDFs remain text extraction inputs.
+
+## LiteLLM Enrichment And Fallback
+
+- `GET /model/info` is optional LiteLLM enrichment for capabilities, provider, mode, context/output limits, pricing, and
+ supported output modalities. Match enrichment to `/models` by model ID.
+- If `/model/info` is missing, fails, or is empty, keep `/models` usable. Treat capabilities, limits, pricing, and usage as
+ optional; conversations may use a manually configured context window.
+- When enrichment is unavailable, attempt Ollama `/api/show` capability enrichment from the server root without making
+ that endpoint a prerequisite for chat.
+- `supported_output_modalities` containing `image` adds `.imageGeneration` without changing model mode. Vision does not
+ imply image generation, and absent metadata does not imply either capability.
+- `ollama_chat/` uses Ollama's structured chat tool-call transport and may retain `.functionCalling`.
+ `ollama/` uses legacy generate/JSON emulation; remove `.functionCalling` and
+ `.parallelFunctionCalling` from that route even when metadata advertises them.
+
+## Native And Dedicated Images
+
+- A chat or completion model with `.imageGeneration` remains on `POST /chat/completions` and requests image/text output
+ modalities. Do not redirect it to an image endpoint based only on capability metadata.
+- Read native generated images from message images in non-streaming completions and delta images in SSE. Accept only
+ bounded `data:image/...;base64,...` values, validate the decoded image, and derive its actual MIME type. Do not download
+ remote URLs returned in chat image fields.
+- Dedicated `.imageGeneration` models use `POST /images/generations`; the existing dedicated edit flow uses multipart
+ `POST /images/edits`. Dedicated responses may contain bounded base64 data or an HTTP(S) URL handled by the existing image
+ repository.
+- Chat image generation accepts text only, returns the first native image, supplies no tools, and has no fallback to a
+ dedicated endpoint. Dedicated generation and editing must not be described as the same capability.
+- Generated output is capped at 25 MiB. Preserve typed image data out of model-facing tool text and persist it according to
+ `agent-tool-calling.instructions.md`.
+
+## LiteLLM-Specific Endpoints
+
+- `POST /v1/search/{search_tool_name}` executes web search and `GET /v1/search/tools` discovers configured search tools.
+ Search behavior belongs to `web-browsing.instructions.md`.
+- `GET /v1/mcp/server` discovers MCP servers, `GET /mcp-rest/tools/list?server_id=...` discovers their tools, and
+ `POST /mcp-rest/tools/call` executes a tool with server ID, original tool name, and parsed object arguments.
+- Missing search or MCP endpoints disable only those optional features. They must not prevent model listing or chat.
+- Connection testing uses `GET /models`. LiteLLM detection separately uses `GET /health/readiness` and requires a successful
+ JSON response containing `litellm_version`.
+- Audio transcription uses `POST /v1/audio/transcriptions`; speech synthesis uses `POST /v1/audio/speech`.
+
+## Security And Logging
+
+- Validate HTTP status before decoding. Map authentication, rate limiting, transport, timeout, malformed response, and
+ cancellation failures to typed errors without exposing server payloads.
+- Bound uploads, generated images, downloads, MCP arguments/results, and delegated image inputs before expensive decoding
+ or allocation. Accept remote image downloads only on the dedicated image response path and only over HTTP(S).
+- Log request method, relative endpoint, status, counts, sizes, timing-relevant state, and redacted errors only in debug
+ builds. Never log request or response payloads, SSE chunk previews, prompts, messages, tool arguments/results, OCR,
+ base64/data URLs, downloaded content, API keys, or authorization scopes.
+- `ChatRepository` still includes a short payload preview when an SSE chunk cannot be decoded. Treat it as unresolved
+ hardening, not as an approved logging pattern; remove or redact it when changing that error path.
diff --git a/specs/readme.instructions.md b/specs/readme.instructions.md
index 4ef65fcb..672b59e6 100644
--- a/specs/readme.instructions.md
+++ b/specs/readme.instructions.md
@@ -5,12 +5,8 @@ applyTo: "**/README.md"
# README Maintenance
-`README.md` is product documentation, not a fixed nine-section template. Preserve its current voice and broad ordering
-unless a deliberate documentation redesign is requested.
-
-The current README includes: centered icon/title/badges, Description and feature groups, website/download links,
-Screenshots, Technologies, Architecture, Usage/Requirements/Self-hosting, License, Contributing, Feedback, Author, and a
-closing product statement.
+`README.md` is the product entry point, not a fixed section template. Preserve its visual design, voice, useful content,
+and broad ordering unless a deliberate redesign is requested. Keep claims user-focused and aligned with shipped behavior.
## Linked files
@@ -19,16 +15,12 @@ the architecture described to contributors.
### ARCHITECTURE.md
-- Contains a representative structural tree for all six targets: `openclient-llm`, `openclient-llm-macOS`,
- `openclient-llm-test`, `ShareExtension`, `WidgetsExtension-iOS`, and `WidgetsExtension-macOS`
-- Contains the layer diagram (`View → ViewModel → UseCase → Repository → APIClient / LocalStorage`)
-- Contains per-layer responsibility descriptions
+- Describe the current structure, target boundaries, layer responsibilities, and important cross-target data flows.
+- Keep its tree representative rather than exhaustive.
**When to update `ARCHITECTURE.md`:**
-- A new feature folder is added under `Shared/Features/`
-- A layer, target, top-level directory, feature module, or platform ownership rule changes
-- A Core area is added, removed, or changes responsibility
-- Extension/App Group data flow or target relationships change materially
+- A layer, target, top-level area, feature boundary, or platform ownership rule changes.
+- Extension, App Group, or other cross-target data flow changes materially.
Do not update `ARCHITECTURE.md` merely because an implementation file or test file is added inside an already documented
folder. Its tree is intentionally directory-level with selected explanatory file names, not a complete file manifest.
@@ -37,8 +29,8 @@ folder. Its tree is intentionally directory-level with selected explanatory file
- Use the existing tree style with `├──`, `│`, `└──` box-drawing characters
- File names are listed without inline comments unless the purpose is non-obvious
- Keep the layer diagram at the top unchanged unless the architecture itself changes
-- Keep target names, paths, layer descriptions, and data-flow diagrams aligned with the Xcode project and current code.
- Preserve the existing section order when possible; add focused sections only when they help explain a real subsystem.
+- Keep names, paths, responsibilities, and data flows aligned with the project. Add detail only when it explains a real
+ structural distinction.
### README.md Architecture section
@@ -46,7 +38,7 @@ The Architecture section in `README.md` is intentionally brief — it describes
## Rules
-- Badges use shields.io `flat-square` style; keep platform/Xcode version badges in sync with deployment targets
+- Badges use shields.io `flat-square` style and remain synchronized with active product and platform versions
- Keep the opening product description concise, then maintain the existing feature groups as shipped behavior changes.
- Usage must cover clone, open in Xcode, configure a server URL, and run, plus current toolchain/platform/backend
requirements.
diff --git a/specs/roadmap-completed.instructions.md b/specs/roadmap-completed.instructions.md
deleted file mode 100644
index ea5c168a..00000000
--- a/specs/roadmap-completed.instructions.md
+++ /dev/null
@@ -1,157 +0,0 @@
----
-description: "Use when reviewing completed features or checking what has already been implemented in the project roadmap."
----
-
-# Feature Roadmap
-
-## Development Approach
-
-Build incrementally from less to more. Each phase should result in a functional app.
-
-## Phase 1 — Foundation
-
-Goal: Basic chat with a LiteLLM server.
-
-- [x] **Server configuration**: Settings screen to input base URL and optional API key
-- [x] **Connection test**: Health check to validate server is reachable
-- [x] **Model listing**: Fetch and display available models from LiteLLM
-- [x] **Basic chat**: Send a message, receive a response (non-streaming)
-- [x] **Streaming chat**: SSE streaming for real-time token display
-- [x] **Conversation view**: Chat bubble UI with user/assistant messages
-- [x] **UI redesign**: ChatGPT-inspired conversational interface (glass messages, pill input bar, suggestion chips, model selector, streaming cursor, markdown rendering)
-- [x] **Model capabilities**: Display model capabilities as colored tags (vision, tools, function calling, JSON mode, etc.) fetched from `GET /model/info` endpoint
-- [x] **Model selection from list**: Tap a model in the models screen to select it as active; selected model highlighted with blue accent border; change reflected instantly in the chat scene model selector
-- [x] **Settings support section**: Settings provides Buy Me a Coffee, Rate the App, Suggest Features, and Help actions. Suggest Features presents `Votice.feedbackView()`; Votice is configured at launch from bundle values supplied by `Secrets.xcconfig`, with localized text, comments, status filters, Liquid Glass, and SDK debug logging disabled. `VoticeManager` currently sets premium status to `false` regardless of the `userIsPremium` argument.
-- [x] **About author**: In the About section of Settings, show author name (Arturo Carretero Calvo) with a link to the GitHub profile (https://github.com/ArtCC) that opens in a modal WebView
-
-## Phase 2 — Usability
-
-Goal: Daily-usable chat experience.
-
-- [x] **Conversation persistence**: Save/load conversations locally (Codable + FileManager)
-- [x] **Conversation list**: Sidebar/list of past conversations
-- [x] **New conversation**: Create new chats, select model per conversation
-- [x] **System prompt**: Configurable system prompt per conversation
-- [x] **Copy/share messages**: Copy individual messages, share conversations
-- [x] **Markdown rendering**: Render assistant responses with full Markdown + code blocks (basic inline markdown already implemented)
-- [x] **Vision (images in chat)**: Attach photos from camera/gallery for the LLM to analyze (same /chat/completions endpoint with image_url content)
-- [x] **Document understanding (PDFs in chat)**: Upload PDFs and ask questions about their content (same /chat/completions endpoint with file content)
-
-## Phase 3 — Multi-Platform Polish
-
-Goal: Platform-optimized experience.
-
-- [x] **macOS sidebar**: NavigationSplitView with conversation list
-- [x] **iPadOS split view**: Adaptive layout for iPad
-- [x] **Keyboard shortcuts**: macOS keyboard navigation
-- [x] **Menu bar**: macOS menu items for common actions
-- [x] **Dark/Light mode**: Full theme support with semantic colors
-- [x] **Debug logging system**: LogManager with emoji-differentiated log levels (info, debug, warning, error, network) for readable console output in DEBUG builds
-- [x] **Attachment thumbnails in chat**: Show image thumbnails inline in sent messages (small rounded preview); show document attachments as icon + filename card
-- [x] **Camera image capture**: Attach images directly from the device camera in chat (iOS/iPadOS only)
-
-## Phase 4 — Advanced Features
-
-Goal: Power user features.
-
-- [x] **Token usage display**: Show token count per message/conversation
-- [x] **Model parameters**: Temperature, max tokens, top_p per conversation
-- [x] **Search conversations**: Full-text search across conversations
-- [x] **iCloud sync**: Sync conversations across devices through iCloud Drive with one JSON file per conversation, attachment folders, safe first-time reconciliation, remote-change observation, offline deletion tombstones, and manual sync status in Settings; sync is private to devices signed into the same Apple ID
-- [x] **Generated images in chat**: Display images returned by compatible chat models and support dedicated image-generation models through `/v1/images/generations`.
-- [x] **Audio transcription (Speech-to-Text)**: Dictate messages in chat via microphone; audio transcribed via POST /v1/audio/transcriptions (Whisper, Groq, Deepgram, Gemini) and inserted into the chat input field
-- [x] **Text-to-Speech**: Read assistant responses aloud via POST /v1/audio/speech (OpenAI TTS, AWS Polly, ElevenLabs, Gemini TTS)
-
-## Phase 5 — Personalization
-
-Goal: User customization.
-
-- [x] **Pinned conversations**: Pin important conversations to the top of the list
-- [x] **Conversation folders/tags**: Organize chats into folders or with tags
-- [x] **User profile (personal context)**: Settings lets the user configure a display name, personal description, and extra context, which are injected into the effective system prompt. `UserProfileManager` stores `UserProfile.json` locally in Documents and, when iCloud sync is enabled, uses the iCloud Documents copy as the source of truth while maintaining the local cache. It migrates the former UserDefaults blob and legacy per-key UserDefaults/`NSUbiquitousKeyValueStore` values.
-- [x] **iPadOS layout redesign**: Full review and fix of the iPadOS UI — layouts, navigation, split view, and all interactions — so the app works flawlessly on iPad
-- [x] **macOS layout redesign**: Full review and fix of the macOS UI — sidebar, toolbar, window sizing, keyboard navigation, and all platform-specific interactions — so the app works flawlessly on Mac
-- [x] **Voice selector for TTS models**: In the Models screen, TTS models (identified by `mode == "audio_speech"` from `/model/info`) show a voice picker. Displays the 6 canonical OpenAI voices (`alloy`, `echo`, `fable`, `onyx`, `nova`, `shimmer`) as preset options plus a free-text field for custom voice IDs (ElevenLabs IDs, AWS Polly names, etc.). Selected voice saved in `SettingsManager` (UserDefaults) keyed by model name. Voice sent as the `voice` field in every `POST /v1/audio/speech` request. Note: LiteLLM does not expose a `supported_voices` field in `/model/info` — the canonical OpenAI voices are used as sensible defaults since LiteLLM maps them automatically across most providers (ElevenLabs, Gemini, Vertex AI, etc.).
-
-## Phase 6 — Productivity & Editing
-
-Goal: Conversation editing, content management, and productivity tools.
-
-- [x] **Export**: Export conversations to JSON
-- [x] **Conversation branching**: Fork a conversation from any message to explore alternative responses (edit & resend)
-- [x] **Message editing**: Edit an already sent user message and regenerate the assistant response
-- [x] **Response regeneration**: "Regenerate" button to request a new response to the last message
-
-## Phase 7 — Web, Agents & Prompt Library
-
-Goal: Prompt templates, web search, and agentic tool-calling loop.
-
-- [x] **Thinking / Reasoning disclosure**: Collapsible "Thinking…" block shown above the assistant reply for models that return reasoning content. LiteLLM ≥ v1.63.0 exposes a standardised `reasoning_content` field in `message` (and `delta.reasoning_content` in SSE chunks) for all supported reasoning providers (Anthropic, Deepseek, OpenAI Responses API, Gemini, Groq, Mistral, Perplexity, OpenRouter, XAI, Bedrock). Implementation: (A) extend `StreamChunk` with a `.reasoning(String)` case; (B) parse `delta.reasoning_content` in the SSE decoder and emit reasoning chunks separately from normal token chunks; (C) add a `reasoningContent: String?` field to `ChatMessage`; (D) in `MessageBubbleView`, show a tappable `DisclosureGroup` styled pill ("Thinking · chevron") that streams the reasoning text live — animated pulsing while still receiving chunks, static when complete; disclosure view has a fixed max height with internal scroll so it never dominates the screen; reasoning text styled in a dimmer secondary color with monospace font; the pill collapses by default after streaming finishes; (E) no setting required — the widget appears automatically when `reasoningContent` is non-nil.
-- [x] **Prompt templates/library**: Library of predefined system prompts (coding assistant, translator, summarizer...) that users can save and reuse
-- [x] **Web browsing**: Function-calling models can invoke `web_search` in the agent loop; `WebSearchTool` delegates to the user's LiteLLM proxy through `POST /v1/search/{search_tool_name}` and returns source metadata to the chat UI. Search providers and their credentials remain server-side. Web search defaults off, the search tool name defaults to an empty string, Settings discovers configured tools through `GET /v1/search/tools`, and the globe cannot enable search until a tool is configured. See `web-browsing.instructions.md`.
-- [x] **Agent mode (tool calling)**: Support LiteLLM function/tool calling loop — parse tool_calls from model responses, execute registered tools, send results back, and repeat until final answer
-- [x] **MCP tools support**: Discover, list, enable, and execute tools from Model Context Protocol servers configured on the LiteLLM backend. `MCPTool` conforms to `ChatToolProtocol` and is automatically added to the agent tool registry. Users manage tools through a dedicated MCP Tools sheet accessible from the chat input bar or Settings.
-
-## Phase 8 — System Integration & Shortcuts
-
-Goal: Deeper OS integration and quick actions.
-
-- [x] **App icon quick actions (iOS/iPadOS)**: Add Home Screen quick actions to the iOS and iPadOS app icon using `UIApplicationShortcutItem`. Actions: "New Chat" (creates a blank conversation and navigates directly to chat input) and "Search" (opens the conversation list with the search field already focused). Actions defined statically in `Info.plist` and/or dynamically at runtime via `UIApplication.shortcutItems`. Handled in the app delegate / scene delegate with a `ShortcutAction` enum (`newChat`, `search`) routed through the navigation state.
-- [x] **Spotlight search**: Index conversations with `CSSearchableItem` / `CoreSpotlight` so users can find past chats directly from Spotlight. Each conversation is indexed with its title and a snippet of the last message. Tapping a Spotlight result opens the app directly in that conversation via `NSUserActivity` continuation.
-
-## Phase 9 — UI Polish & macOS Companion
-
-Goal: Clean up the chat header, reduce toolbar clutter, and bring a quick-access companion to macOS.
-
-- [x] **Chat toolbar menu consolidation**: Replace the three individual action buttons on the right side of the chat header (`square.and.arrow.up`, `slider.horizontal.3`, `text.bubble`) with a single `Menu` button (`ellipsis.circle`). Menu options listed in alphabetical order: Export (ShareLink), Favourites, Media & Files, Model Parameters, System Prompt. "Media & Files" is only shown when the conversation has at least one attachment. Applies to both iOS and macOS. On macOS, use the native SwiftUI `Menu` behaviour; if a custom view is needed to match platform conventions it will be implemented as a dedicated component.
-- [x] **Favourite messages**: Long-pressing any message shows a context menu option to toggle its `isFavourite` value, persisted with the conversation through the existing `Codable` + `FileManager` layer. The "Favourites" entry in the chat toolbar menu opens a sheet listing all favourited messages in the current conversation. Each row shows the message role, a text preview, and the date. Tapping a row dismisses the sheet and requests a scroll to that message through the chat's programmatic scroll state.
-- [x] **macOS menu bar companion**: A persistent `NSStatusItem` in the macOS menu bar that opens a compact popover with a full quick-chat interface. Features: text input, streaming response display using the currently active model and server configuration, and an "Open in app" button to continue the conversation in the main window. The companion works whether the main app window is open or closed. State (active model, API key, base URL) is shared with the main app via the existing managers.
-- [x] **Media & Files gallery**: The "Media & Files" entry in the chat toolbar menu opens a sheet with image thumbnails and a document list. Attachment metadata remains in the conversation while binary data is loaded on demand from `AttachmentRepository`; no network request is required. Tapping an image opens `ImagePreviewView`, and tapping a PDF opens the platform preview. "Go to message" dismisses the sheet and requests a scroll to the originating message through the chat's programmatic scroll state.
-
-## Phase 10 — Memory
-
-Goal: Give users and models a persistent, editable memory layer that is always injected into the system prompt.
-
-- [x] **User memory list**: Settings shows memory items with enabled state, source, and creation date; users can add, edit, delete, and toggle them, and enabled items are injected as a `## Memory` block. `MemoryManager` stores `Memory.json` locally in Documents and mirrors it to iCloud Documents when sync is enabled, using the cloud copy as source of truth and maintaining a local cache. It migrates the legacy `memory_items` UserDefaults blob.
-- [x] **Model memory tool**: Register a `save_memory(content: String)` tool in the existing agentic loop (Phase 7). When the model calls it, a new `MemoryItem` with `source: .model` is created and saved to the same store as user memory. The item appears immediately in the Memory list in Settings, where the user can review, edit, disable, or delete it.
-
-## Phase 11 — Model Detail & Cost Intelligence
-
-Goal: Surface per-model metadata and give users visibility into conversation cost.
-
-- [x] **Model detail view**: Each model row in the Models screen gets a new info button (`ⓘ`) that opens a detail sheet without affecting the existing tap-to-select gesture. The `ⓘ` button is shown for all models. No additional network request is needed: `GET /model/info` is already called during model list fetch; the missing step is persisting `maxInputTokens`, `maxOutputTokens`, `inputCostPerToken`, and `outputCostPerToken` into `LLMModel` (they are currently discarded in `FetchModelsUseCase`). The detail sheet renders only rows with real data (non-nil, non-zero): context window, pricing, provider, mode, and capability badges. Rows with no data are simply omitted — no empty or zero-value fields shown.
-- [x] **Estimated conversation cost**: Running cost total displayed in the model parameters sheet, calculated from stored per-message token counts × `inputCostPerToken` / `outputCostPerToken` from the active model. Shown as a formatted currency string (e.g. `~$0.0042`). Hidden entirely when pricing data is unavailable (nil or zero — local/Ollama models).
-
-## Phase 12 — System Integration & Import
-
-Goal: Allow other apps to send content to OpenClient and let users bring data from external sources.
-
-- [x] **Share Extension (iOS/iPadOS)**: System extension to receive text, URLs, images, and PDFs shared from any app (Safari, Notes, Files…). When activated, opens OpenClient and creates a new conversation with the shared content as an attachment or initial message.
-- [x] **Custom URL scheme (`openclient://`)**: URL scheme to open the app with prefilled content from external automations, Shortcuts, or third-party apps.
-- [x] **Drag & Drop between apps**: Accept drags from other apps directly into the chat input — text, images, files — especially useful on iPad and macOS where multitasking with Split View is common.
-- [x] **Apple Shortcuts integration**: Define `AppIntents`/`NSUserActivity` so Shortcuts can execute actions such as "New conversation with message", "Search conversations", or "Send file to chat".
-
-## Phase 13 — Apple Platform Extensions
-
-Goal: Extend the app across Apple platforms and system surfaces with widgets and quick-access controls.
-
-- [x] **New Chat system control (iOS/iPadOS/macOS)**: `NewChatControlWidget` uses the shared `NewChatControlIntent`, which writes a one-time request through `WidgetControlStore` in the App Group. The iOS and macOS lifecycle delegates consume the request after activation and route it through `ShortcutManager`.
-- [x] **Widgets (WidgetKit)**: `WidgetsExtension-iOS` and `WidgetsExtension-macOS` compile the same seven widgets, control, providers, intents, models, and resources from `WidgetsShared`. Widgets use `openclient://` deep links. Lightweight snapshots (`id`, title, model ID, last-message preview, update date, pinned state, and tags) are stored in App Group `UserDefaults` under `group.com.artcc.openclient-llm`; full conversations, credentials, server URLs, and settings are not shared. The apps rebuild recent, pinned, and tagged snapshots after conversation changes and selectively reload the affected timelines. Widgets:
- - **New Chat (Small)**: `StaticConfiguration` with a single timeline entry. Shows the app icon and "New Chat" label. Tap opens the app in a blank conversation via `widgetURL(URL(string: "openclient://new-chat"))`.
- - **Search (Small)**: `StaticConfiguration` with a single timeline entry. `openclient://search` opens the Search tab on iOS/iPadOS and expands the conversation-list toolbar search on macOS.
- - **Quick Actions (Medium)**: `StaticConfiguration` with vertically stacked New Chat and Search link rows, each showing an icon, label, subtitle, and chevron. Uses the same deep links as the Small widgets.
- - **Conversations Overview (Medium/Large)**: `TimelineProvider` showing up to two recent conversations in medium size or five in large size, with title, preview, model styling, timestamp, Search, and New Chat links.
- - **Continue Chat (Medium)**: Shows the latest conversation and opens it directly through its conversation deep link.
- - **Pinned Conversations (Medium/Large)**: Shows up to two or five pinned conversations from the dedicated pinned snapshot.
- - **Tagged Conversations (Medium/Large)**: `AppIntentConfiguration` lets the user choose a tag and shows matching conversation snapshots.
-
-## Phase 14 — Contextual Feature Discovery
-
-Goal: Help users discover advanced features at the moment they become relevant without extending the initial onboarding.
-
-- [x] **Native TipKit integration**: Configure TipKit once per iOS, iPadOS, and macOS app session with a daily global display frequency and native popover presentation.
-- [x] **Chat feature tips**: Contextual popovers explain model selection, attachments, message actions, web search, conversation options, and context usage only while their related controls and capabilities are available.
-- [x] **Organization and privacy tips**: The conversation list introduces Private Chat after a normal conversation exists and organization controls after five conversations.
-- [x] **Memory tip**: Settings introduces editable memory after the user has accumulated at least three conversations.
-- [x] **Informational-only content**: Tips contain only localized titles and messages, with no actions or custom navigation inside the popover; using the highlighted feature invalidates its tip.
-- [x] **Tip reset and testing**: Help can make invalidated tips eligible again, while the `-showAllFeatureTips` DEBUG launch argument uses TipKit's testing override for visual verification.
diff --git a/specs/security.instructions.md b/specs/security.instructions.md
index 22bcbc1d..98dbb276 100644
--- a/specs/security.instructions.md
+++ b/specs/security.instructions.md
@@ -1,174 +1,84 @@
---
-description: "Use when storing sensitive data, handling user input, managing credentials, working with the network layer, or reviewing code for security vulnerabilities."
+description: "Use when handling credentials, sensitive data, user or remote input, networking, persistence, logging, cryptography, authentication, or extension boundaries."
applyTo: "**/*.swift"
---
-# Security Guidelines
-
-Based on OWASP Mobile Top 10 and Apple platform best practices.
-
----
-
-## Sensitive data storage
-
-### Never store sensitive data in UserDefaults or plain files
-
-```swift
-// ❌ UserDefaults — readable without entitlements on jailbroken devices
-UserDefaults.standard.set(token, forKey: "auth_token")
-
-// ❌ Plain file in Documents/
-let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
-try token.write(to: url.appendingPathComponent("token.txt"), atomically: true, encoding: .utf8)
-
-// ✅ Keychain for credentials, tokens, private keys
-try keychainManager.save(token, forKey: "auth_token")
-```
-
-### Keychain rules
-
-- Use `kSecAttrAccessibleAfterFirstUnlock` for background-accessible items
-- Use `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` for items that must not leave the device
-- Set `kSecAttrSynchronizable: false` unless iCloud sync is explicitly required
-- Never log Keychain references or their contents
-
----
-
-## No sensitive data in logs
-
-```swift
-// ❌ Logs API keys, tokens, PII
-print("Token: \(authToken)")
-print("User email: \(user.email)")
-
-// ✅ Log categories, not values
-print("Auth token loaded successfully")
-print("User authenticated")
-```
-
-Rules:
-- Never log: passwords, tokens, API keys, private keys, PII (name, email, phone, location)
-- Log events and outcomes — not the data involved
-- `LogManager` currently uses `print` behind `#if DEBUG`; all levels are no-ops in release builds. Do not describe this as `os_log` or rely on debug-level privacy redaction.
-- `APIClient.request` currently prints successful generic JSON response bodies in full and prints up to 500 characters of HTTP error bodies in DEBUG builds. This is existing behavior, not endorsed guidance; do not add similar logging, and treat redaction/removal as unresolved hardening because model responses and server errors may contain sensitive content.
-
----
-
-## Input validation
-
-Validate all input at system boundaries (network responses, file imports, user input fields):
-
-```swift
-// ✅ Validate before using
-guard !name.trimmingCharacters(in: .whitespaces).isEmpty else {
- throw ValidationError.emptyName
-}
-guard name.count <= 255 else {
- throw ValidationError.nameTooLong
-}
-
-// ✅ Decode with explicit types — never use Any or untyped JSON
-struct APIResponse: Decodable {
- let id: UUID
- let name: String
- let createdAt: Date
-}
-let response = try JSONDecoder().decode(APIResponse.self, from: data)
-```
-
-- Never pass raw user input to system APIs (file paths, shell commands, URL construction)
-- Sanitise strings displayed in UI that originate from external sources
-
----
-
-## Network security
-
-```swift
-// Prefer HTTPS for internet-reachable servers.
-// This app also supports user-selected self-hosted HTTP endpoints on localhost/LAN.
-
-// ✅ Certificate pinning for high-sensitivity endpoints (if required)
-// Implement via URLSession delegate — do not use third-party libraries unless vetted
-
-// ✅ Validate server responses before using
-guard (200..<300).contains(httpResponse.statusCode) else {
- throw NetworkError.unexpectedStatusCode(httpResponse.statusCode)
-}
-```
-
-- The iOS and macOS targets currently set `NSAllowsArbitraryLoads = true` so users can reach self-hosted LiteLLM/OpenAI-compatible servers over HTTP on localhost, LANs, or private networks. This is a compatibility exception, not a statement that HTTP is secure.
-- Prefer HTTPS and valid certificate verification whenever the server is internet-reachable. Do not broaden HTTP use to app-owned or fixed third-party services, and do not add trust-all certificate delegates.
-- Do not remove or narrow the current ATS exception without a tested replacement that preserves user-configured self-hosted HTTP connectivity on every supported platform.
-- Do not log raw HTTP responses that may contain sensitive data
-- Set reasonable timeouts — never use `timeoutInterval: 0`
-
----
+# Security
+
+## Data Classification And Storage
+
+- Classify data before persisting or sharing it. Credentials, tokens, private keys, and equivalent secrets belong in
+ `KeychainManager`, never `UserDefaults`, logs, source code, or plain files.
+- Store non-sensitive preferences through `SettingsManager`. Do not move sensitive values there for convenience.
+- Choose Keychain accessibility from the actual access requirement. Prefer device-only accessibility when migration is not
+ required, and enable synchronization only for an explicitly designed feature.
+- Treat values compiled from build configuration into an app bundle as recoverable client configuration, not privileged
+ server-side secrets. Keep local secret configuration and CI credentials out of source control.
+- Apply platform data protection and least-privilege entitlements to files, App Groups, extensions, and widgets according to
+ the sensitivity and required background access.
+
+## Logging And Diagnostics
+
+- Never log credentials, authorization headers, private keys, personal data, conversations, prompts, attachments, request
+ bodies, response bodies, or raw server errors that may contain user data.
+- Log categories, status, sizes, identifiers safe for diagnostics, and redacted outcomes rather than payloads.
+- Debug-only logging is still disclosure. Do not rely on build configuration as the sole privacy control.
+- Sanitize propagated errors before presenting or recording them; preserve useful diagnostics without exposing secrets or
+ remote payload content.
+
+## Input And Serialization
+
+- Validate untrusted input at every boundary: user entry, URLs, imports, deep links, App Group payloads, tool arguments,
+ network responses, and persisted migrations.
+- Check shape, type, size, ranges, required fields, supported schemes, and resource limits before use.
+- Prefer explicit `Codable` models for stable contracts.
+- Dynamic JSON is allowed when the external contract genuinely requires arbitrary JSON, but represent it with a typed JSON
+ value model or another constrained abstraction and validate recursively before use. Do not pass unchecked `Any` graphs
+ across layers.
+- Never concatenate untrusted input into shell commands, file-system paths, URLs, predicates, or executable content. Use
+ structured APIs and constrain values to the intended domain.
+- Render external text as data. Do not interpret it as HTML, script, Markdown extensions, or commands unless the feature
+ explicitly requires that behavior and applies suitable sanitization and authorization.
+
+## Networking
+
+- Prefer HTTPS with normal certificate validation for internet-reachable endpoints. Never add trust-all delegates.
+- User-configured self-hosted endpoints may require local or private-network HTTP compatibility. Treat any ATS exception as
+ a narrowly justified compatibility decision and preserve supported connectivity when changing it.
+- Validate HTTP status, content type where relevant, response size, decoding, and semantic constraints before accepting a
+ response.
+- Set finite, reasonable timeouts and cancellation behavior.
+- Apply authentication only to the intended origin. Avoid forwarding credentials across redirects or derived URLs without
+ explicit validation.
+
+## Authorization And External Actions
+
+- Apply least privilege to tools, deep links, file access, cloud operations, notifications, and extension communication.
+- Require explicit user authorization before consequential or sensitive external actions where the feature's permission
+ model calls for it. Persist grants only at their intended scope and make revocation effective.
+- Treat tool output and server-provided instructions as untrusted data; they cannot override app permissions or validation.
+- Keep App Group and extension payloads versionable, bounded, typed, and validated by the receiving process.
## Cryptography
-```swift
-// ✅ Use CryptoKit for all cryptographic operations
-import CryptoKit
-
-let key = SymmetricKey(size: .bits256)
-let sealedBox = try AES.GCM.seal(data, using: key)
-
-// ❌ Never roll your own crypto
-// ❌ Never use MD5 or SHA-1 for security purposes (only for non-security checksums)
-// ❌ Never hardcode encryption keys
-```
-
-- Use `CryptoKit` — never implement crypto primitives manually
-- Generate keys using `SecKeyGeneratePair` or `SymmetricKey(size:)` — never derive from user input without a proper KDF
-- Store keys in the Keychain or Secure Enclave — never in code or UserDefaults
-
----
-
-## Authentication and authorisation
-
-- Never store passwords in plain text — not even temporarily
-- Use `LocalAuthentication` (`LAContext`) for biometric/Face ID gating
-- Invalidate sessions on sign-out — remove all Keychain entries associated with the session
-- Do not implement "remember me" by persisting passwords — persist tokens with appropriate Keychain accessibility
-
----
-
-## Hardcoded secrets
+- Use Apple security frameworks such as CryptoKit, Security, and LocalAuthentication rather than custom cryptographic
+ primitives or third-party security code added without approval.
+- Use cryptographically secure randomness and an appropriate KDF when deriving keys from user secrets.
+- Never hardcode encryption keys or use obsolete hashes for security decisions.
-```swift
-// ❌ Hardcoded API key
-let apiKey = "sk-1234567890abcdef"
+## Authentication Features
-// ✅ Load from a configuration source (environment, secure config, backend-provided token)
-let apiKey = Configuration.apiKey // Loaded from a non-committed source
-```
+- If a feature introduces biometric or device-owner gating, use `LocalAuthentication`, handle unavailable or changed
+ biometric state, and define an appropriate fallback policy.
+- If a feature introduces authenticated sessions, store session credentials in Keychain, avoid persisting passwords, scope
+ tokens appropriately, and invalidate related credentials and state on sign-out or revocation.
+- Do not add biometric gates, session machinery, or password persistence rules to features that do not have those concepts.
-- No API keys, secrets, or credentials in source code
-- Add `*.xcconfig` files containing secrets to `.gitignore`
-- Use environment variables or a secrets manager for CI/CD
-- `Secrets.xcconfig` values used by Votice are expanded into the client bundle. They must be treated as recoverable client
- configuration even though the local file and CI values are protected from source control. Never use this mechanism for
- a privileged server-side secret.
-
----
-
-## Data in transit between app and extension (if applicable)
-
-- Use `Codable` with explicit types for `handleAppMessage` payloads
-- Validate and bounds-check all values received from the extension before using them
-- Do not pass raw strings that could be interpreted as code or paths
-
----
+## Review Checklist
-## Checklist (per PR / feature)
-
-- [ ] No sensitive data in UserDefaults or plain files — use Keychain
-- [ ] No secrets, API keys, or credentials in source code
-- [ ] No PII or tokens in logs
-- [ ] All user input validated at the boundary
-- [ ] Network: HTTPS is preferred; the current ATS exception remains only to preserve user-configured self-hosted HTTP connectivity
-- [ ] Debug diagnostics do not add request, response, server-error, conversation, or credential payload logging
-- [ ] Cryptography uses `CryptoKit` — no custom implementations
-- [ ] Biometric gating uses `LocalAuthentication`
-- [ ] Sessions are fully invalidated on sign-out
-- [ ] `Decodable` types are explicit — no `Any` in JSON parsing
+- Sensitive data has appropriate storage, transport, retention, and deletion behavior.
+- Logs and user-visible errors contain no payloads or secrets.
+- Inputs and dynamic structures are typed or constrained, bounded, and validated.
+- Network trust and credential forwarding are no broader than required.
+- Permissions and external actions follow least privilege and explicit authorization.
+- Authentication, biometrics, and session cleanup are applied only where those features exist.
diff --git a/specs/swiftui-multiplatform.instructions.md b/specs/swiftui-multiplatform.instructions.md
index fe3e0fe9..77c79df8 100644
--- a/specs/swiftui-multiplatform.instructions.md
+++ b/specs/swiftui-multiplatform.instructions.md
@@ -1,259 +1,52 @@
---
-description: "Use when creating or modifying SwiftUI views, building multi-platform UI, adapting layouts for iOS/iPadOS/macOS, or working with platform-specific navigation and controls."
-applyTo: "**/*.swift"
+description: "Use when deciding how OpenClient SwiftUI code and behavior are shared or adapted across iOS, iPadOS, and macOS."
+applyTo: "{openclient-llm/Shared/**/Views/**/*.swift,openclient-llm-macOS/**/*.swift,WidgetsShared/**/*.swift}"
---
-# SwiftUI Multi-Platform Patterns
-
-## Platform Adaptation
-
-Use conditional compilation for platform-specific UI:
-
-```swift
-#if os(iOS)
-// iPhone-specific layout
-#elseif os(macOS)
-// macOS-specific layout (sidebar, toolbar, menu bar)
-#endif
-```
-
-Shared views and logic live in `openclient-llm/Shared/`. The repository has no `openclient-llm/Views/` directory. The
-macOS target compiles Shared and adds only genuinely macOS-specific app, menu bar, and commands UI from
-`openclient-llm-macOS/`.
-
-For shared views that differ slightly by platform, use `#if os()` inside the view. Only create separate view files per target when the UI is fundamentally different.
-
-## View Structure
-
-- One View per file, named after the view
-- Primary screens and reusable visual components need preview coverage, either in the same file or a dedicated
- `Type+Previews.swift` file. Platform adapters and infrastructure-only views may rely on a composed parent preview
-- Use `@State` for view-local state, `@Environment` for injected dependencies
-- Use `@Observable` view models injected via `@State private var` in the view
-- Views switch on `viewModel.state` to render `.loading` / `.loaded` states
-- Use `.task {}` instead of `.onAppear` for async loading
-
-```swift
-struct ChatView: View {
- // MARK: - Properties
-
- @State private var viewModel = ChatViewModel()
-
- // MARK: - View
-
- var body: some View {
- Group {
- switch viewModel.state {
- case .loading:
- ProgressView()
- case .loaded:
- // Feature content
- }
- }
- .task {
- viewModel.send(.viewAppeared)
- }
- }
-}
-
-// MARK: - Private
-
-private extension ChatView {}
-
-#Preview {
- ChatView()
-}
-```
-
-## Navigation
-
-> **Generic vs. App-Specific**: Navigation patterns below are generic. The specific tab names, icons, and sidebar structure are marked as **app-specific** and should be adapted per project.
-
-### iOS / iPadOS - Current Tab Bar
-
-The app uses a `TabView` with Liquid Glass style as the root navigation on iOS and iPadOS. The Tab Bar gets Liquid Glass automatically with the iOS 26+ SDK.
-
-> **App-Specific** — Adapt tab names, icons, and content for your project.
-
-| Tab | SF Symbol | Content |
-|---|---|---|
-| **Chats** | `bubble.left.and.bubble.right` | Conversation list + chat view (`NavigationStack`) |
-| **Models** | `brain.head.profile` | Available models from the configured server |
-| **Settings** | `gearshape` | Server configuration, API key, preferences |
-| **Search** | `magnifyingglass` | Dedicated conversation search, using `role: .search` |
-
-> **App-Specific** — Adapt tab structure for your project.
-
-```swift
-TabView(selection: $selectedTab) {
- Tab(value: AppTab.chats) {
- ChatsNavigationView()
- } label: {
- Label(String(localized: "Chats"), systemImage: "bubble.left.and.bubble.right")
- }
- Tab(value: AppTab.models) {
- ModelsView()
- } label: {
- Label(String(localized: "Models"), systemImage: "brain.head.profile")
- }
- Tab(value: AppTab.settings) {
- SettingsView()
- } label: {
- Label(String(localized: "Settings"), systemImage: "gearshape")
- }
- Tab(value: AppTab.search, role: .search) {
- SearchConversationsView()
- } label: {
- Label(String(localized: "Search"), systemImage: "magnifyingglass")
- }
-}
-.tabViewStyle(.sidebarAdaptable)
-```
-
-- `HomeView` uses `.tabViewStyle(.sidebarAdaptable)` and each destination owns navigation where needed.
-- Current iPadOS behavior uses the same iOS `NavigationStack` chat layout; it does not contain a separate
- `NavigationSplitView` implementation.
-- iPad sidebar search uses a `tabViewSidebarHeader` field and renders conversation search results in the main area.
- The Search tab remains available in the top-bar layout. Query text persists when opening a result in Chats;
- an empty iPad query shows search guidance instead of the entire conversation list.
-- Tabs are scalable — future features (e.g., "Images" for image generation) can be added as new tabs
-
-### macOS — NavigationSplitView with Sidebar
-
-macOS does **not** use Tab Bar. Instead, use `NavigationSplitView` with a sidebar as the root navigation:
-
-- Sidebar contains Chats, Models, and Settings destinations.
-- The Chats detail owns a `NavigationStack` with the conversation list and chat navigation.
-- Search is not a macOS sidebar destination in the current implementation.
-- Use toolbar items and keyboard shortcuts for macOS-native interaction
-
-### Navigation Destinations
-
-- Define navigation destinations with enums conforming to `Hashable`
-- Use `NavigationStack` with typed `NavigationPath` for push navigation within each tab/section
-
-## Layout Guidelines
-
-- **iOS**: `TabView` (Liquid Glass) as root → `NavigationStack` inside each tab
-- **iPadOS**: Same `.sidebarAdaptable` `TabView` and Chats `NavigationStack` as iPhone; let SwiftUI adapt the tab chrome
-- **macOS**: `NavigationSplitView` with sidebar, toolbar items, keyboard shortcuts — no Tab Bar
-
-## Reusable Components & Custom Modifiers
-
-### Custom Views
-
-- When a piece of UI is used in more than one place, extract it into a **custom reusable View** (e.g., `LoadingButton`, `ErrorBanner`, `APIKeyField`)
-- Place cross-feature shared views in `openclient-llm/Shared/Core/Views/`; feature-owned views stay under
- `openclient-llm/Shared/Features//Views/`.
-- Custom views must be self-contained: receive data through initializer parameters, not by reaching into parent state
-- Reusable visual components need preview coverage, either in the same file or a dedicated `Type+Previews.swift` file
-
-### Custom ViewModifiers
-
-- When the same combination of modifiers is applied in multiple places, create a **custom `ViewModifier`** (e.g., `.urlFieldStyle()`, `.cardStyle()`)
-- Keep feature-only modifiers beside the feature (for example, Chat view modifiers currently live under Chat `Views/`).
- Create `Shared/Core/Modifiers/` only when a genuinely cross-feature modifier warrants that directory.
-- Provide a convenience `View` extension for each modifier:
- ```swift
- struct URLFieldModifier: ViewModifier {
- func body(content: Content) -> some View {
- content
- .textContentType(.URL)
- .autocorrectionDisabled()
- #if os(iOS)
- .textInputAutocapitalization(.never)
- .keyboardType(.URL)
- #endif
- }
- }
-
- extension View {
- func urlFieldStyle() -> some View {
- modifier(URLFieldModifier())
- }
- }
- ```
-- Prefer a custom modifier over repeating 3+ identical modifiers across views
-- Keep modifiers focused on a single responsibility — don't create "god modifiers" that do too much
-
-## Common Patterns
-
-- Use `.task {}` modifier for async data loading on view appear
-- Use `ViewThatFits` or `GeometryReader` sparingly for adaptive layouts
-- Prefer built-in SwiftUI components over custom implementations
-- Use `.searchable()` for search functionality
-- Use `.sheet()`, `.popover()`, `.confirmationDialog()` for modal presentations
-- For chat programmatic scrolling, use `ScrollViewReader`, explicit sentinels, semantic layout revisions, and scroll phase
- APIs. Never bind live scroll position or drive automatic scrolling from geometry/visibility observations; this avoids a
- macOS AttributeGraph loop when users scroll during an active response.
-
-## Platform-Specific Control Patterns
-
-When a shared view needs different control appearance per platform, use `#if os()` to apply platform-appropriate styles. Common divergences:
-
-### Buttons
-
-```swift
-// Primary action — looks native on both platforms
-Button("Save") { }
-#if os(macOS)
- .buttonStyle(.borderedProminent)
- .controlSize(.regular)
-#endif
-
-// Secondary action inside a non-glass context
-Button("Cancel") { }
-#if os(macOS)
- .buttonStyle(.bordered)
-#endif
-```
-
-### Text Fields
-
-```swift
-// Standalone text field (outside Form)
-TextField("URL", text: $url)
-#if os(macOS)
- .textFieldStyle(.roundedBorder)
-#else
- .textFieldStyle(.plain)
-#endif
-```
-
-### Modal Presentations
-
-```swift
-// Small contextual content
-#if os(macOS)
-.popover(isPresented: $showPicker) { content }
-#else
-.sheet(isPresented: $showPicker) { content }
-#endif
-```
-
-### Conditional Padding Helper
-
-When iOS and macOS need different spacing, define platform constants:
-
-```swift
-private extension CGFloat {
- #if os(macOS)
- static let horizontalContentPadding: CGFloat = 12
- static let verticalItemSpacing: CGFloat = 8
- #else
- static let horizontalContentPadding: CGFloat = 16
- static let verticalItemSpacing: CGFloat = 12
- #endif
-}
-```
-
----
-
-## App-Specific Sections Summary
-
-The following parts of this document are specific to **OpenClient**:
-
-- **Tab Bar configuration** — Specific tabs (Chats, Models, Settings, Search), icons, and content
-- **macOS sidebar structure** — Specific sidebar sections
-
-All other sections are **generic SwiftUI multi-platform patterns** reusable across projects.
+# OpenClient SwiftUI Multiplatform Structure
+
+## Contract
+
+- The Xcode project defines actual target membership; this specification defines how shared and platform-specific UI must
+ be structured. Keep both aligned in every architectural change.
+- Do not introduce a new navigation hierarchy, shared abstraction, or platform behavior unless the requested change also
+ updates the applicable specification and structural documentation.
+- Verify behavior on every target affected by a shared view; visual parity is not a substitute for native behavior.
+
+## Shared And Platform-Specific Code
+
+- Shared app UI and feature code lives under `openclient-llm/Shared/` and is compiled by the iOS and macOS app targets.
+- Keep genuinely platform-specific app, commands, menu bar, lifecycle, and UI code in its platform target directory.
+- Keep a view shared when its structure and behavior are substantially the same. Use a small `#if os(...)` branch for a
+ localized platform difference.
+- Split platform implementations when composition or interaction is fundamentally different. Do not accumulate broad
+ conditional branches inside a nominally shared view.
+- Feature-owned components stay with their feature. Move a component to shared core only when it has real cross-feature
+ use.
+
+## Deliberate Adaptation
+
+- Prefer native SwiftUI APIs available to all affected deployment targets.
+- Choose controls, presentation, density, focus, keyboard handling, pointer behavior, menus, commands, sheets, popovers,
+ toolbars, and window behavior deliberately for each platform.
+- Let iPadOS adapt to size class, input method, and available width; do not treat it as either a stretched iPhone or a Mac.
+- Preserve system-provided chrome and behavior. Do not recreate it in shared content or layer custom Liquid Glass over it.
+- Guard platform-only APIs at the narrowest useful scope and keep unsupported code out of the other target's compilation.
+- Share user-visible capability where appropriate, but allow platform-native routes to that capability to differ.
+
+## View Responsibilities
+
+- Views render current observable state and send user events through the feature's established interfaces.
+- Keep platform presentation decisions in views or focused platform adapters; keep shared domain behavior independent of
+ platform UI.
+- Represent loading, empty, error, disabled, and in-progress states explicitly, following the general UI specification.
+- Preserve state across adaptive layout changes unless the current feature intentionally resets it.
+
+## Previews
+
+- Provide preview coverage for primary screens and reusable visual components, in the same file or an existing dedicated
+ preview file.
+- Cover the meaningful states and layout variants needed to understand a component, not an exhaustive snapshot matrix.
+- Include representative compact and wide contexts when adaptation is part of the component's responsibility.
+- Platform adapters and infrastructure-only views may rely on a composed parent preview when that exercises their UI.
+- Keep previews deterministic, lightweight, localized through source strings, and independent of live services or secrets.
diff --git a/specs/testing.instructions.md b/specs/testing.instructions.md
index 604ecb7c..8fdb1a7b 100644
--- a/specs/testing.instructions.md
+++ b/specs/testing.instructions.md
@@ -1,162 +1,63 @@
---
-description: "Use when writing unit tests, integration tests, creating mocks, test doubles, or structuring test files. Covers testing ViewModels, UseCases, Repositories, and API integration."
+description: "Use when adding or changing XCTest coverage, test doubles, fixtures, async synchronization, or test organization."
+applyTo: "openclient-llm-test/**/*.swift"
---
-# Testing Guidelines
+# Testing
-## Overview
+## Scope And Boundaries
-All current tests live in the iOS-hosted `openclient-llm-test/` XCTest target. There is no UI test target and no dedicated
-integration-test suite.
+- Add focused tests for changed behavior at the smallest useful boundary. Do not add ceremonial tests for pass-through
+ types or test private implementation details.
+- Test ViewModels through events and observable state, UseCases through business outcomes, Repositories through mapping and
+ persistence boundaries, and Managers through their public contracts.
+- Unit tests must isolate external services with protocol-backed doubles. Real network or service integration requires an
+ explicit task scope, opt-in configuration, and safeguards against accidental CI execution.
+- Mirror production feature or core ownership in the test directory. Put reusable doubles in `Mocks/`; keep a small,
+ single-use helper private to its test file.
+- Large test types may use cohesive `Type+Concern.swift` splits or focused XCTest classes.
-## Test Types
+## New Test Conventions
-### Unit Tests
+Apply these conventions to new tests and substantially rewritten tests; do not churn unrelated existing tests solely for
+conformance:
-Test a single unit in isolation with mocked dependencies.
+- Test files and classes use `Tests`.
+- Test methods use `test___()` with meaningful domain terms.
+- Organize setup, action, and assertions as Given-When-Then, using `// Given`, `// When`, and `// Then` when the separation
+ improves readability.
+- Keep each test focused on one behavioral intent, with all assertions needed to describe that outcome.
+- Use `@testable import openclient_llm` for internal production APIs.
+- Mark XCTest classes `@MainActor` where required by production isolation and follow the suite's established class-level
+ annotation pattern rather than annotating individual methods inconsistently.
-**What to test:**
-- **ViewModels**: Event/State transitions, business logic coordination
-- **UseCases**: Business rules, data transformations, edge cases
-- **Repositories**: Data mapping, caching logic (mock the APIClient)
-- **Managers**: Transversal service behavior
+## Test Doubles
-Some tests exercise multiple local layers, persistence behavior, cloud-sync mapping, widget snapshots, or streaming logic,
-but they remain in the normal feature/core folders. The suite currently contains no tests guarded by `LITELLM_TEST_URL`,
-no real-server tests, and no `Integration/` directory. Do not create a network integration suite unless the task explicitly
-requires one and its opt-in configuration is defined.
+- Prefer configurable protocol-backed doubles with explicit defaults that fail clearly when required behavior is not set.
+- Record only the calls and values needed by assertions.
+- Reset or recreate mutable state per test; do not depend on test execution order.
+- Follow `concurrency.instructions.md` for `Sendable`. Test-only use and `@MainActor` on the XCTest class do not by themselves
+ make a mutable mock safe.
-## File Organization
+## Async And Concurrent Tests
-```
-openclient-llm-test/
-├── Features/
-│ └── Chat/
-│ ├── ChatViewModelTests.swift
-│ ├── ChatViewModelTests+StreamingConcern.swift
-│ └── SendMessageUseCaseTests.swift
-├── Core/
-│ └── Managers/
-│ └── SettingsManagerTTSTests.swift
-└── Mocks/
- ├── MockChatRepository.swift
- ├── MockAPIClient.swift
- └── MockSettingsManager.swift
-```
+- Prefer direct `async` test methods for async APIs.
+- Synchronize deterministically with controllable dependencies, continuations, XCTest expectations, actor gates, clocks,
+ or bounded signals appropriate to the behavior. Expectations remain valid when testing callbacks or explicit events.
+- Avoid arbitrary sleeps, timing guesses, unbounded polling, and reliance on scheduler order.
+- Fulfill continuations and expectations exactly once, bound waits with meaningful timeouts, and clean up long-lived tasks.
+- Exercise cancellation, stale-result rejection, and ordering when those behaviors are part of the contract.
-## Naming Conventions
+## Reliability
-- Test files: `Tests.swift`
-- Test classes: `Tests`
-- Test methods: `test___()`
+- Keep tests independent, repeatable, and free of real user settings, Keychain entries, App Group data, or persistent files.
+- Use isolated stores and unique namespaces for persistence tests, then remove only data created by that test.
+- Avoid force unwraps in test code; use XCTest unwrapping and explicit failures.
+- Assert public outputs and meaningful side effects rather than incidental call sequences unless ordering is itself required.
-```swift
-func test_send_viewAppeared_setsLoadedState() async { }
-func test_execute_withInvalidURL_throwsConnectionError() async { }
-func test_fetchModels_serverUnavailable_returnsEmpty() async { }
-```
+## Validation
-## Test Structure (Given-When-Then)
-
-```swift
-import XCTest
-@testable import openclient_llm
-
-@MainActor
-final class SendMessageUseCaseTests: XCTestCase {
- // MARK: - Properties
-
- private var sut: SendMessageUseCase!
- private var mockRepository: MockChatRepository!
-
- // MARK: - Setup
-
- override func setUp() {
- super.setUp()
-
- mockRepository = MockChatRepository()
- sut = SendMessageUseCase(repository: mockRepository)
- }
-
- override func tearDown() {
- sut = nil
- mockRepository = nil
-
- super.tearDown()
- }
-
- // MARK: - Tests
-
- func test_execute_withValidMessage_returnsResponse() async throws {
- // Given
- mockRepository.sendMessageResult = .success(.stub())
-
- // When
- let response = try await sut.execute(message: "Hello")
-
- // Then
- XCTAssertFalse(response.content.isEmpty)
- }
-}
-```
-
-## Mocking Pattern
-
-Use protocol-backed dependencies where production code exposes a protocol. Shared mocks live in `Mocks/`; a small helper
-used by only one test file may remain private in that file.
-
-```swift
-// Protocol (in Shared/Features/Chat/Repositories/)
-protocol ChatRepositoryProtocol: Sendable {
- func sendMessage(_ message: String, model: String) async throws -> ChatResponse
-}
-
-// Mock (in openclient-llm-test/Mocks/)
-// Safety: Only used within serialized @MainActor test methods.
-final class MockChatRepository: ChatRepositoryProtocol, @unchecked Sendable {
- var sendMessageResult: Result = .failure(MockError.notConfigured)
-
- func sendMessage(_ message: String, model: String) async throws -> ChatResponse {
- try sendMessageResult.get()
- }
-}
-```
-
-## Async Testing
-
-Use `async` test methods directly — no need for expectations with modern concurrency:
-
-```swift
-func test_fetchModels_returnsModelList() async throws {
- let models = try await sut.execute()
- XCTAssertEqual(models.count, 3)
-}
-```
-
-Mark the **XCTest class**, not individual methods, `@MainActor`. The test target has no default actor isolation and the
-current suite applies this annotation to every XCTest class:
-
-```swift
-@MainActor
-final class FeatureTests: XCTestCase {
- func test_send_viewAppeared_loadsData() async {
- viewModel.send(.viewAppeared)
- XCTAssertEqual(viewModel.state, .loaded(.init()))
- }
-}
-```
-
-Large test types may be split with `Type+Concern.swift` extensions or into focused XCTest classes, matching the existing
-Chat and Settings suites. Keep each file under the corresponding `Features//` or `Core//` path.
-
-## Rules
-
-- Add focused tests for changed behavior at the smallest useful boundary; do not require one ceremonial test file for
- every pass-through type.
-- ViewModels should be tested for all Event → State transitions
-- Never test private methods — test through the public API
-- Use `@testable import` to access internal types
-- Keep tests fast — mock all external dependencies in unit tests
-- No sleep/delays — use async/await patterns for timing
-- `@unchecked Sendable` mocks require the standard safety comment and must only be mutated from the MainActor-isolated
- tests that own them.
+- Ask the user before running any test, build, linter, or related validation command.
+- Once authorized, run the smallest relevant test target or class first. Broaden validation only when the change crosses
+ shared boundaries or focused results justify it.
+- Report skipped validation and residual coverage risks explicitly.
diff --git a/specs/version.instructions.md b/specs/version.instructions.md
index b0a2033e..a49b16a7 100644
--- a/specs/version.instructions.md
+++ b/specs/version.instructions.md
@@ -6,19 +6,17 @@ applyTo: "{CHANGELOG.md,README.md,TestFlight/*.txt,config.json,config-dev.json,o
# Release Version Workflow
Read this specification together with `changelog.instructions.md` whenever a request adds or changes a changelog entry.
-A numbered changelog release is an atomic version update: keep every active app-version reference listed below in sync.
+A numbered changelog release is an atomic version update: keep active app-version references in sync without changing
+historical release data.
## Required Questions
-Before editing `CHANGELOG.md`, inspect its latest numeric release header and ask for any of the following information the
-user has not already supplied:
+Before a numbered release edit, inspect the latest numeric changelog header and explicitly ask for any missing value:
-1. What marketing version to use (`MAJOR.MINOR.PATCH`).
-2. What build number to use.
+1. Marketing version (`MAJOR.MINOR.PATCH`).
+2. Build number.
-Ask these as distinct, concise questions in the user's language; they may be grouped into one message. Mention the current
-latest version and build as context. Do not infer or increment either value, and do not start the changelog or version edit
-until both decisions are known.
+Include the current values as context. Do not infer or increment either value, and do not edit until both are known.
If the user explicitly wants an `Unreleased` entry, confirm that choice instead of requiring a numeric version and build.
An `Unreleased` entry does not change TestFlight notes or any active app-version reference.
@@ -29,108 +27,61 @@ An `Unreleased` entry does not change TestFlight notes or any active app-version
- Use the exact version and build chosen by the user; normalize a bare build number such as `78` to `build-78`.
- Use the current date unless the user supplies a different release date.
- Follow `changelog.instructions.md` for section order, entry wording, and what belongs in the changelog.
-- When adding to the current marketing version, keep one latest release section and use the chosen build and date rather
- than creating a duplicate section for the same marketing version.
+- Create a new section for every published build, even when its marketing version matches the previous build.
+- Never replace, rename, merge into, or otherwise repurpose a historical build section.
## Active Version Synchronization
-For every numbered changelog release, locate the active references before editing and synchronize the chosen marketing
-version in all of these places:
+For every numbered release, locate and synchronize the chosen marketing version in:
-1. The latest release header in `CHANGELOG.md`, together with the chosen build number.
+1. The new release header in `CHANGELOG.md`, together with the chosen build number.
2. Every existing `TestFlight/*.txt` file, following the TestFlight rules below.
-3. `MARKETING_VERSION` for the iOS app, macOS app, Share Extension, `WidgetsExtension-iOS`, and
- `WidgetsExtension-macOS` in
- `openclient-llm.xcodeproj/project.pbxproj`, for both Debug and Release configurations.
+3. `MARKETING_VERSION` in every shipping app and extension target, for Debug and Release configurations.
4. Both the badge URL and alt text of the version badge in `README.md`.
-5. `app_update.ios.latest_version` and `app_update.macos.latest_version` in the existing local `config.json` and
- `config-dev.json` files, even when their update notification is disabled.
+5. The iOS and macOS `latest_version` values in existing local `config.json` and `config-dev.json` files, even when update
+ notifications are disabled.
Do not rely only on a blind repository-wide replacement. Search for the previous active version and classify each match so
historical releases and unrelated version numbers remain unchanged.
## Values That Must Remain Stable
-- Keep every shipping target's checked-in `CURRENT_PROJECT_VERSION` at `1`. Deployment increments the published build
- automatically; the user-supplied build number belongs in the changelog header.
+- Keep every shipping target's checked-in `CURRENT_PROJECT_VERSION` stable at `1`. Deployment supplies the published
+ build number; the user-supplied build belongs in the changelog header.
- Keep the unit-test target's `MARKETING_VERSION` at `1.0.0`.
-- Do not synchronize release versions into tests or mocks. Version-comparison fixtures use `1.0.0` for the current and
- default version, `1.1.0` for a newer version, and `0.9.0` for an older version.
+- Do not synchronize release versions into tests or mocks.
- Do not change historical changelog headers or entries.
-- Do not change project object versions, schema versions, backup-format versions, deployment targets, package versions, or
- other numbers that are not the active app marketing version.
+- Do not change unrelated project, schema, backup-format, deployment-target, package, or fixture versions.
- Keep `config.json` and `config-dev.json` ignored by Git. Never force-add them to a commit.
## TestFlight Release Notes
-Update every existing file matching `TestFlight/*.txt` for each numbered changelog release; do not ask a separate
-TestFlight question and do not create new locale files. Preserve each file's language, greeting, closing text, bullet
-character, punctuation, and overall layout. TestFlight notes are user-facing, so adapt changelog content into concise
-product language instead of copying technical details verbatim.
+Update every existing `TestFlight/*.txt` file for each numbered build; do not ask a separate question or create locale
+files. The actual format is a localized greeting, a block of `•` bullets for the current build, and localized closing
+paragraphs, with blank lines between those parts. Preserve the greeting, closing, language, bullet character, punctuation,
+and layout; replace only the release bullet block. Do not add version headings, build headings, or release history.
-### Starting a new marketing version
-
-When the requested marketing version differs from the current `***MAJOR.MINOR.PATCH:` heading in a TestFlight file:
-
-1. Replace that heading with the requested marketing version; TestFlight headings do not include the build number.
-2. Replace the current-version notes with the new version's notes.
-3. Move the previous version's substantive, version-specific bullets to the beginning of the `***Recent Updates:` section,
- preserving their order.
-4. Keep the generic `• Minor bug fixes and improvements for a smoother experience.` bullet as the current-version fallback
- when appropriate; do not move or duplicate it in `***Recent Updates:`.
-
-### Adding to the current marketing version
-
-When the requested marketing version matches the current TestFlight heading, keep the heading and `***Recent Updates:`
-section in place. Add the new substantive bullets to the current-version block, before the generic minor-fixes fallback.
-
-Do not deduplicate, rewrite, reorder, or prune older `***Recent Updates:` entries as part of an unrelated release update.
+Adapt changelog entries into concise user-facing benefits rather than copying implementation details. Keep the localized
+minor-fixes bullet when it accurately summarizes the build or complements substantive bullets.
## Remote Config Decisions
-The local `config.json` and `config-dev.json` files are release-management copies of Remote Config. Their
-`latest_version` values follow the active app marketing version automatically, but their behavior flags and banner content
-require explicit decisions. Do not assume that production and development should use the same settings merely because the
-files are currently identical.
-
-Before editing the Remote Config files for a numbered release, ask:
+The local `config.json` and `config-dev.json` files mirror Remote Config. Synchronize only their `latest_version` values by
+default; do not assume production and development behavior should match.
-1. Whether update notifications should be enabled and whether the update should be forced, separately for production and
- development when needed. Never enable `force_update` without explicit confirmation.
-2. Whether the banner should be kept unchanged, disabled, or replaced with a new banner.
-3. Whether the resulting banner and activation state should apply to `config.json`, `config-dev.json`, or both.
-
-Keeping a banner unchanged means preserving its `dismiss_banner_key`, localized items, activation, and any version text in
-its title. Do not mechanically replace version numbers inside an existing banner. Disabling a banner changes its `active`
-state but preserves its content unless the user asks to remove or replace it.
+Explicitly ask before enabling `force_update` and identify whether it applies to production, development, or both. Also
+ask before any destructive banner change: disabling, replacing, removing content, or changing `dismiss_banner_key`. Keep
+all other flags and banner fields unchanged unless the user requests them. Never replace version text inside banner copy
+mechanically.
### New banner questions
-When the user chooses a new banner, ask for any information not already supplied:
-
-- The feature or message to announce, or permission to derive it from the new changelog entries.
-- Platforms: iOS, macOS, or both.
-- Whether the banner is active in each selected Remote Config file.
-- Emoji, title, subtitle, CTA label, and action.
-- The destination URL when the action is `open_url`.
-- Which locales to include or whether to adapt the copy for every locale already present in the file.
-
-Generate a new `dismiss_banner_key` from the release version and a concise stable slug unless the user supplies one, for
-example `release-1.0.0-chat-streaming`. A new key makes the banner visible again to users who dismissed an older banner;
-never change it merely to re-show unchanged content.
+For a confirmed new banner, collect any missing message, platforms, target config files, activation state, localized copy,
+CTA action, and URL when applicable. Generate a stable release-and-message `dismiss_banner_key` unless supplied; do not
+change the key merely to re-show unchanged content.
### Valid banner actions
-Only these exact, case-sensitive JSON values are supported:
-
-| JSON value | Behavior |
-|---|---|
-| `close` | The CTA dismisses the banner. |
-| `open_url` | Opens the item's URL inside the app, then dismisses the banner. |
-| `feedback` | Opens the Feedback presentation in Settings, then dismisses the banner. |
-| `tip` | Opens the Tip Jar presentation in Settings, then dismisses the banner. |
-
-- Unknown action values make the entire Remote Config fail to decode; never invent or approximate an action name.
-- `open_url` requires a valid `http` or `https` URL. An invalid or unsupported URL only dismisses the banner.
-- For actions other than `open_url`, use an empty `url` unless the user has a reason to preserve another value.
-- An empty `cta` hides the action button. The separate close button always dismisses the banner regardless of its action.
+Use only the exact, case-sensitive actions `close`, `open_url`, `feedback`, and `tip`; unknown values break decoding.
+`open_url` requires a valid HTTP(S) URL. For other actions, use an empty `url` unless explicitly preserving one. An empty
+`cta` hides the action button.
diff --git a/specs/web-browsing.instructions.md b/specs/web-browsing.instructions.md
index 8efbf4f3..7945ef71 100644
--- a/specs/web-browsing.instructions.md
+++ b/specs/web-browsing.instructions.md
@@ -1,141 +1,52 @@
---
-description: "Use when implementing web browsing or web search capabilities, integrating web search via LiteLLM, displaying search results or citations in chat, or configuring search-related settings."
-applyTo: "**/*.swift"
+description: "Use when changing LiteLLM web-search configuration, registration, execution, source persistence, or citation context."
+applyTo: "{openclient-llm/Shared/Features/Chat/**/*.swift,openclient-llm/Shared/Features/Settings/**/*.swift}"
---
-# Web Search — Integration via LiteLLM
+# Web Search
-## References
+## Transport Contract
-- LiteLLM Search API (`/v1/search`): https://docs.litellm.ai/docs/search/
-- LiteLLM Function Calling: https://docs.litellm.ai/docs/completion/function_call
+- `web_search` is the only web-search mechanism. The app must call the configured LiteLLM
+ `POST /v1/search/{search_tool_name}` endpoint and must never call a search provider directly.
+- Provider credentials and provider selection remain on the LiteLLM server. Do not add provider SDKs, provider-specific
+ routing, client-side search keys, native `web_search_options`, or manual search-result injection.
+- Discover available server configurations through `GET /v1/search/tools`. The selected tool name must be a path component
+ supplied by settings, not a model-provided value.
-## Overview
+## Availability And Routing
-Web search uses a **single mechanism**: an **agent loop** with a tool named `web_search`. The app never calls any search provider API directly — LiteLLM handles provider selection, API keys, and result fetching on the server side via its `/v1/search` endpoint.
+- Web search requires a selected model with `.functionCalling`, a configured search tool name, the per-chat search setting,
+ and the `web_search` built-in setting all to be enabled.
+- Enabling search adds `WebSearchTool` to the normal registry; it does not enable agent mode. Function-calling models use
+ the agent loop with or without search, while other models use regular streaming and cannot search.
+- Recheck built-in enablement before execution. Disabling the built-in removes advertisement and blocks a pending new
+ execution without erasing the saved chat preference or search configuration.
+- Use the technical name `web_search`. Do not rename it to a LiteLLM-reserved or provider-specific function name.
-- **No search API keys in the app** — keys live in LiteLLM's server environment
-- **No direct calls to any search provider** (Brave, Perplexity, Tavily, etc.)
-- **No manual context injection** — results are never fetched client-side and injected as system messages
-- **No `web_search_options`** — the app never sends native web search parameters in the request body
-- The LiteLLM base URL is the same one already configured by the user for chat
+## Execution And Results
-### Web Search Flow Table
-
-| Condition | Globe Color | Behavior |
-|-----------|------------|----------|
-| Web search OFF | Grey | No search; function-calling models still use the agent loop with other built-in tools, while other models use regular streaming |
-| Web search ON + `.functionCalling` + built-in search enabled | Accent | Agent loop with tool `web_search` → executed via `/v1/search` endpoint |
-| Web search unavailable because the model lacks `.functionCalling`, no search tool is configured, or the built-in tool is disabled in Settings → Tools | Red | The toggle does not change state; no search occurs |
-
-### How It Works (Agent Loop with `/v1/search`)
-
-For models with function calling capability. The app registers a tool named `web_search` and runs an **agentic loop**:
-
-1. App sends request with `tools: [web_search]` + `tool_choice: "auto"`
-2. Model responds with `tool_calls: [{"function": {"name": "web_search", ...}}]`
-3. App's `AgentStreamUseCase` → `WebSearchTool.execute()` → calls `POST /v1/search/{search_tool_name}` (e.g., `/v1/search/brave-search`)
-4. Search results sent back to model as tool result
-5. Model may call tools again; the registry remains attached until the model answers or a safety limit forces a final request without tools
-
-This works with **any model from any provider** (Ollama, OpenAI, Anthropic, Groq, etc.) as long as the model returns structured `tool_calls` (not text-plain JSON). The `/v1/search` endpoint is completely model-agnostic.
-
-**Model detection**: `model_info.supports_function_calling == true` → `.functionCalling` capability.
-
-**Known limitation**: Some small/specialized models (e.g., `qwen2.5-coder`) may emit tool calls as plain text in `content` instead of structured `tool_calls` in the response. In this case the agent loop cannot intercept them and the raw JSON is displayed as the assistant message.
-
-### Why Not Native `web_search_options`?
-
-Native web search (`web_search_options` in the request body) was intentionally removed because:
-
-- **OpenAI** only supports it for search-dedicated models (`gpt-5-search-api`, `gpt-4o-search-preview`); regular OpenAI models reject it with HTTP 400
-- **Provider-specific routing** creates fragile code paths that are hard to test and maintain
-- The **agent loop approach works universally** across all providers and models with function calling
-- One single method = simpler codebase, fewer bugs, consistent behavior
-
-### Tool Name: `web_search` (Not `litellm_web_search`)
-
-The app uses `web_search` as the tool name (not `litellm_web_search`) to **avoid triggering LiteLLM's server-side web search interception**. The interception feature may cause unexpected behavior with some providers like Ollama.
-
----
-
-## LiteLLM Server Configuration (reference for users)
-
-### Search Tools (required for `/v1/search`)
-
-```yaml
-search_tools:
- - search_tool_name: brave-search
- litellm_params:
- search_provider: brave
- api_key: os.environ/BRAVE_API_KEY
-```
-
-### Supported Search Providers (agnostic to the app)
-
-| Provider | `search_provider` value | Server env var |
-|----------|------------------------|----------------|
-| Brave Search | `brave` | `BRAVE_API_KEY` |
-| Perplexity | `perplexity` | `PERPLEXITYAI_API_KEY` |
-| Tavily | `tavily` | `TAVILY_API_KEY` |
-| Exa AI | `exa_ai` | `EXA_API_KEY` |
-| DuckDuckGo | `duckduckgo` | `DUCKDUCKGO_API_BASE` |
-| SearXNG | `searxng` | `SEARXNG_API_BASE` |
-| Google PSE | `google_pse` | `GOOGLE_PSE_API_KEY` + `GOOGLE_PSE_ENGINE_ID` |
-| Firecrawl | `firecrawl` | `FIRECRAWL_API_KEY` |
-| Linkup | `linkup` | `LINKUP_API_KEY` |
-| Serper | `serper` | `SERPER_API_KEY` |
-| SearchAPI.io | `searchapi` | `SEARCHAPI_API_KEY` |
-
----
-
-## Implementation
-
-### Decision Logic (`streamWithWebSearch`)
-
-```swift
-func streamWithWebSearch(_ context: SendMessageContext) async {
- let useAgentMode = context.modelCapabilities.contains(.functionCalling)
- if useAgentMode {
- // The registry contains enabled, eligible built-in tools.
- // web_search also requires context.webSearchEnabled to be true.
- await performAgentStreaming(...)
- } else {
- // No search (disabled or no capabilities) → regular streaming
- await performStreaming(...)
- }
-}
-```
-
-### Key Types
-
-- `WebSearchTool` — Implements `ChatToolProtocol`, defines `web_search` function. On execution, calls `WebSearchUseCase` which hits `/v1/search/{search_tool_name}`
-- `ToolRegistry` — Registers `WebSearchTool` only when web search is enabled; agent mode itself is automatic for function-calling models
-- `AgentStreamUseCase` — Manages the agentic loop (see `agent-tool-calling.instructions.md`)
-- `WebSearchUseCase` — Calls `APIClient.searchRequest()` → `POST /v1/search/{search_tool_name}`
-
-### Tool Result Messages
-
-When the agent loop handles `web_search`, tool result messages include the `name` field per OpenAI spec:
+- `WebSearchTool` accepts a JSON object containing a nonempty string `query`. Invalid or missing input returns a textual
+ tool result that asks the model to answer without searching.
+- `WebSearchUseCase` sends the configured result limit to LiteLLM and returns `[LiteLLMSearchResult]`.
+- `WebSearchTool` returns `ToolExecutionResult.text` as concise, human-readable source entries followed by citation
+ guidance. It returns the complete source array separately in `ToolExecutionResult.searchResults` for chat presentation
+ and persistence. The tool message `content` is that formatted text, not JSON containing the results.
+- Bound the model-facing formatted subset independently from the retained source array. The agent's remaining-context
+ budget may further truncate text without removing `searchResults`.
+- Tool transcript messages use the standard `role: "tool"`, matching `tool_call_id`, `name: "web_search"`, and formatted
+ text content. Follow `agent-tool-calling.instructions.md` for ordering, persistence, cancellation, and finalization.
```json
{
"role": "tool",
"tool_call_id": "call_abc123",
"name": "web_search",
- "content": "{\"results\": [...]}"
+ "content": "1. Source title\n URL: https://example.com\n Result snippet\n\nUse these sources..."
}
```
-The `name` field is stored in `ChatMessage.toolName` and serialized via `ChatCompletionMessage.name`.
-
----
-
-## Settings
-
-- **Web search enabled**: Stored in `SettingsManager` (`UserDefaults`) and defaults to `false` when no value exists.
-- **Built-in search tool enabled**: Managed independently in Settings → Tools and defaults to `true`. Disabling it
- removes advertisement and blocks new executions without changing the saved globe preference or search configuration.
-- **Search tool name**: Stored in `SettingsManager` (`UserDefaults`) and defaults to the empty string. It must match a `search_tool_name` in LiteLLM configuration; Settings can discover tools through `GET /v1/search/tools` and select the first returned tool when the saved value is unavailable.
-- **Maximum results**: Stored in `SettingsManager` (`UserDefaults`) and defaults to 10. `WebSearchUseCase` sends this value to LiteLLM, while `WebSearchTool` formats at most the first five returned results for model context and retains the full result array for source display.
-- **Tool rounds**: The agent permits up to 15 iterations, 20 tool calls in total, and 8 calls in one iteration. These are agent safeguards, not user-configurable search settings.
+- Merge `searchResults` into the visible assistant message associated with the run so source UI is independent from the
+ hidden tool transcript.
+- Search failures propagate through normal tool execution handling as bounded textual errors; they do not introduce a
+ second search path or silently fall back to a provider API.
From 0631e289f57d177ba5b5cf84a5babfb114075d1c Mon Sep 17 00:00:00 2001
From: Arturo Carretero Calvo <10163049+ArtCC@users.noreply.github.com>
Date: Wed, 16 Sep 2026 08:24:49 +0200
Subject: [PATCH 4/7] Refine documentation scope and update spec contracts
- Move pending improvement guidance to `IMPROVEMENTS.md`
- Update `AGENTS.md` to point spec references at `specs/`
- Refresh `CHANGELOG.md` release date
- Expand and tighten contracts across architecture, concurrency, security, testing, and UI specs
- Clarify chat, tool-calling, iCloud sync, LiteLLM, web browsing, backup, and versioning behavior
- Adjust README and documentation selection rules to match current layout
---
AGENTS.md | 34 ++++++++-------
CHANGELOG.md | 2 +-
IMPROVEMENTS.md | 8 ++++
specs/agent-tool-calling.instructions.md | 18 ++++----
specs/architecture.instructions.md | 9 ++--
specs/changelog.instructions.md | 4 +-
specs/chat-visual-style.instructions.md | 29 +++++++------
specs/code-style.instructions.md | 4 +-
specs/concurrency.instructions.md | 4 +-
...conversation-backup-format.instructions.md | 17 +++++---
specs/design-ui.instructions.md | 13 ++++--
specs/icloud-sync.instructions.md | 42 ++++++++++++-------
specs/litellm-api.instructions.md | 18 ++++----
specs/readme.instructions.md | 2 +-
specs/security.instructions.md | 11 +++--
specs/swiftui-multiplatform.instructions.md | 10 +++--
specs/testing.instructions.md | 6 ++-
specs/version.instructions.md | 11 ++---
specs/web-browsing.instructions.md | 5 +--
19 files changed, 146 insertions(+), 101 deletions(-)
create mode 100644 IMPROVEMENTS.md
diff --git a/AGENTS.md b/AGENTS.md
index 5495b4ac..38228350 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -28,6 +28,8 @@ then update both in the same change. If intent remains unclear, stop and ask for
- Preserve the architecture, conventions, and nearby implementation patterns unless the task explicitly changes them.
- Keep applicable specs synchronized whenever behavior, compatibility, architecture, or a documented invariant changes.
Do not leave a known mismatch for a later documentation pass.
+- Keep pending improvements and technical debt in `IMPROVEMENTS.md`, not in specifications. Specs contain only current
+ contracts and guidance.
- Do not discard, overwrite, or reformat unrelated user changes. Limit edits to the requested scope.
- Do not add or update dependencies without explicit permission. Read package declarations from the Xcode project rather
than duplicating their inventory here.
@@ -45,22 +47,22 @@ scope is expressible by path. Update this table when adding or removing a spec.
| File | Read when |
|---|---|
-| `agent-tool-calling.instructions.md` | Implementing tool calling, tool UI, or the agent loop. |
-| `architecture.instructions.md` | Creating Swift files, features, targets, or changing layer boundaries. |
-| `changelog.instructions.md` | Updating `CHANGELOG.md`. |
-| `chat-visual-style.instructions.md` | Designing chat-specific SwiftUI. |
-| `code-style.instructions.md` | Writing or reviewing Swift style. |
-| `concurrency.instructions.md` | Working with async code, isolation, tasks, or `Sendable`. |
-| `conversation-backup-format.instructions.md` | Changing conversation backup export, import, validation, or versioning. |
-| `design-ui.instructions.md` | Designing general SwiftUI, accessibility, haptics, or animation. |
-| `icloud-sync.instructions.md` | Changing iCloud synchronization, storage, conflicts, or cloud data management. |
-| `litellm-api.instructions.md` | Changing LiteLLM/OpenAI-compatible API integration. |
-| `readme.instructions.md` | Updating `README.md`. |
-| `security.instructions.md` | Handling sensitive data, input, credentials, networking, or security review. |
-| `swiftui-multiplatform.instructions.md` | Building shared iOS, iPadOS, or macOS SwiftUI. |
-| `testing.instructions.md` | Adding or changing tests, fixtures, or mocks. |
-| `version.instructions.md` | Changing release versions, build metadata, or TestFlight notes. |
-| `web-browsing.instructions.md` | Implementing web search or browsing features. |
+| `specs/agent-tool-calling.instructions.md` | Implementing tool calling, tool UI, or the agent loop. |
+| `specs/architecture.instructions.md` | Creating Swift files, features, targets, or changing layer boundaries. |
+| `specs/changelog.instructions.md` | Updating `CHANGELOG.md`. |
+| `specs/chat-visual-style.instructions.md` | Designing chat-specific SwiftUI. |
+| `specs/code-style.instructions.md` | Writing or reviewing Swift style. |
+| `specs/concurrency.instructions.md` | Working with async code, isolation, tasks, or `Sendable`. |
+| `specs/conversation-backup-format.instructions.md` | Changing conversation backup export, import, validation, or versioning. |
+| `specs/design-ui.instructions.md` | Designing general SwiftUI, accessibility, haptics, or animation. |
+| `specs/icloud-sync.instructions.md` | Changing iCloud synchronization, storage, conflicts, or cloud data management. |
+| `specs/litellm-api.instructions.md` | Changing LiteLLM/OpenAI-compatible API integration. |
+| `specs/readme.instructions.md` | Updating `README.md`. |
+| `specs/security.instructions.md` | Handling sensitive data, input, credentials, networking, or security review. |
+| `specs/swiftui-multiplatform.instructions.md` | Building shared iOS, iPadOS, or macOS SwiftUI. |
+| `specs/testing.instructions.md` | Adding or changing tests, fixtures, or mocks. |
+| `specs/version.instructions.md` | Changing release versions, build metadata, or TestFlight notes. |
+| `specs/web-browsing.instructions.md` | Implementing web search or browsing features. |
## Architecture At A Glance
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 26774de5..2b3b29fc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
-## [1.7.10-build-112] - 2026-09-15
+## [1.7.10-build-112] - 2026-09-16
### Changed
diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md
new file mode 100644
index 00000000..a3731251
--- /dev/null
+++ b/IMPROVEMENTS.md
@@ -0,0 +1,8 @@
+# Improvements
+
+This file tracks optional follow-up work that is not part of the current project contract. Specifications must describe
+supported behavior and invariants, not pending improvements.
+
+## Networking
+
+- Redact or remove the short undecodable SSE payload preview currently emitted by `ChatRepository` debug logging.
diff --git a/specs/agent-tool-calling.instructions.md b/specs/agent-tool-calling.instructions.md
index 97f85a21..171bacae 100644
--- a/specs/agent-tool-calling.instructions.md
+++ b/specs/agent-tool-calling.instructions.md
@@ -1,6 +1,5 @@
---
description: "Use when changing automatic agent routing, tool registration, tool-call transcripts, agent limits, MCP authorization, or delegated image tools."
-applyTo: "openclient-llm/Shared/Features/Chat/**/*.swift"
---
# Agent Tool Calling
@@ -47,8 +46,8 @@ applyTo: "openclient-llm/Shared/Features/Chat/**/*.swift"
## Registry
-- `ToolRegistry` contains eligible built-ins first and enabled, currently discovered MCP tools afterward. Definitions are
- filtered dynamically, and execution rechecks availability and configuration.
+- `ToolRegistry` contains eligible built-ins and enabled, currently discovered MCP tools. Definitions are filtered
+ dynamically, and execution rechecks availability and configuration; definition order is not part of the contract.
- Built-ins are `get_current_datetime`, `save_memory`, `delete_memory`, `web_search`, `analyze_images`,
`list_image_attachments`, and `generate_image`. Wrap them in `ConfiguredBuiltInTool` so local enablement is checked both
when advertising and when executing.
@@ -60,9 +59,9 @@ applyTo: "openclient-llm/Shared/Features/Chat/**/*.swift"
- Discover servers and tools through the LiteLLM endpoints defined in `litellm-api.instructions.md`, using one captured
endpoint/credential pair. Discovery failure must not affect ordinary chat.
-- Advertise only enabled tools from a successful, current discovery scope with a supported object input schema. Preserve
- the raw supported schema in the definition; validate the locally understood structure before authorization and again
- before execution.
+- Advertise only enabled tools from a successful, current discovery scope whose decoded input schema has an object root.
+ Preserve the raw schema in the definition and validate the constraints represented by the local schema model before
+ authorization and again before execution. Unsupported JSON Schema keywords are not enforced locally.
- Prefix model-facing MCP names to avoid registry collisions, but send the original server tool name when executing.
- Permissions are `alwaysAllow`, `ask`, and `deny`, defaulting to `ask`. Batch all approval requests for a model round
before starting any call. Dismissal denies the batch; cancellation abandons it.
@@ -99,9 +98,10 @@ applyTo: "openclient-llm/Shared/Features/Chat/**/*.swift"
current specialist is either a dedicated image model or a generation-capable chat model.
- `generate_image` accepts only a nonblank text prompt of at most 8,000 characters in a 64 KiB argument object. It creates
one new image and never edits or consumes attachments.
-- Allow one generation attempt per user turn. Reserve the attempt before suspension because a failed request may already
- incur cost; persist `imageGenerationAttempted` before sending the specialist request. Do not retry, change specialist, or
- switch transport in the same turn.
+- Allow one generation attempt per user turn. Reserve the attempt in conversation state before suspension and request
+ persistence before sending the specialist request because a failed request may already incur cost. The active run
+ enforces the reservation in memory; a successfully persisted flag also survives restoration. Do not retry, change
+ specialist, or switch transport in the same turn.
- Dedicated specialists use the image endpoint path. Chat specialists request native image output through chat, consume
the first image, and never receive tools or invoke the agent recursively.
- Deliver generated bytes only through typed image events, attach them to the visible assistant message, and persist each
diff --git a/specs/architecture.instructions.md b/specs/architecture.instructions.md
index ae0f1809..9ade90f2 100644
--- a/specs/architecture.instructions.md
+++ b/specs/architecture.instructions.md
@@ -1,6 +1,5 @@
---
description: "Use when creating Swift files or features, assigning target ownership, or changing View, ViewModel, UseCase, Repository, Manager, networking, or storage boundaries."
-applyTo: "**/*.swift"
---
# Architecture
@@ -28,7 +27,8 @@ View -> ViewModel -> UseCase -> Repository -> APIClient / local storage
\-----------------------> Manager
```
-- Views render state and emit events. They do not perform persistence, networking, or business decisions.
+- Views render state and emit events. Keep new business decisions, persistence, and general service networking out of
+ Views. Existing focused UI adapters may bridge file import, transfer, or remote media loading at the presentation edge.
- ViewModels coordinate screen behavior and own UI state. Use `@Observable`, keep explicit `@MainActor`, and prefer
`send(_:)` as the UI event entry point while preserving established awaitable APIs where needed.
- UseCases represent meaningful operations or business rules. Do not create a pass-through UseCase only to satisfy the
@@ -36,8 +36,9 @@ View -> ViewModel -> UseCase -> Repository -> APIClient / local storage
- Repositories own data access, mapping, and persistence abstractions where those boundaries add value.
- Managers provide transversal settings, credentials, sync, routing, device, and SDK services. A ViewModel may depend on a
Manager directly when it represents UI-facing state or a system service and a UseCase would only forward the call.
-- `APIClient` is the networking and streaming boundary. Feature-specific request and response mapping belongs near the
- repository or feature that owns the contract.
+- `APIClient` is the primary boundary for the configured LiteLLM/OpenAI-compatible API and streaming transport.
+ Specialized repositories may use focused `URLSession` clients for pre-configuration checks, provider enrichment, or
+ external resources. Feature-specific mapping stays near the repository or feature that owns the contract.
- Prefer protocol-backed dependencies and initializer injection at useful test seams.
- Keep asynchronous ownership and state mutation in the ViewModel rather than starting unowned work from Views.
diff --git a/specs/changelog.instructions.md b/specs/changelog.instructions.md
index be3bc50c..4d3c673e 100644
--- a/specs/changelog.instructions.md
+++ b/specs/changelog.instructions.md
@@ -48,8 +48,8 @@ The changelog follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) a
## When to Update
-Update `CHANGELOG.md` only when a feature or breaking change is implemented, or a bug fix is confirmed. Do not add
-speculative or mid-implementation entries.
+Update `CHANGELOG.md` when a feature or breaking change is implemented, a bug fix is confirmed, or a toolchain, platform,
+or infrastructure change materially affects users or contributors. Do not add speculative or mid-implementation entries.
## Unreleased Section
diff --git a/specs/chat-visual-style.instructions.md b/specs/chat-visual-style.instructions.md
index c78d3456..09a8dc7a 100644
--- a/specs/chat-visual-style.instructions.md
+++ b/specs/chat-visual-style.instructions.md
@@ -1,6 +1,6 @@
---
description: "Use when changing OpenClient chat messages, attachments, composer, streaming presentation, Markdown, actions, or scrolling."
-applyTo: "openclient-llm/Shared/Features/Chat/Views/**/*.swift"
+applyTo: "{openclient-llm/Shared/Features/Chat/**/*.swift,openclient-llm/Shared/Core/Utils/Markdown*.swift,openclient-llm/Shared/Core/Utils/RenderedMarkdown.swift,openclient-llm-macOS/Views/MenuBarChatView.swift}"
---
# OpenClient Chat Visual Style
@@ -26,8 +26,9 @@ applyTo: "openclient-llm/Shared/Features/Chat/Views/**/*.swift"
## Message Content And Attachments
-- Render user source text as entered. Render completed assistant content with the existing structured Markdown pipeline,
- including its current text, link, list, table, quotation, media, and code-block behavior.
+- Render user source text as entered. Render completed assistant content through `RenderedMarkdown`, preserving support for
+ headings, paragraphs, emphasis, links, lists and task lists, quotations, rules, code, tables, images, footnotes, and the
+ supported inline subscript and superscript forms.
- During streaming, favor immediate stable text over repeatedly reparsing final Markdown. Switch to final Markdown when the
response completes.
- Keep code and other horizontally constrained content readable and selectable without widening the conversation column.
@@ -55,9 +56,10 @@ applyTo: "openclient-llm/Shared/Features/Chat/Views/**/*.swift"
## Streaming Stability
-- Show an immediate, localized waiting state until content arrives, then use the implemented streaming indicator.
-- Publish streamed content at the cadence established by the current pipeline; do not add per-token container animations,
- repeated Markdown layout, or lazy-row behavior that destabilizes scrolling.
+- Show an immediate, localized waiting state until content arrives, then transition to the streaming presentation.
+- Group streamed UI updates rather than publishing every token independently; the exact debounce interval is an
+ implementation detail. Do not add per-token container animations, repeated Markdown layout, or lazy-row behavior that
+ destabilizes scrolling.
- Keep message identity and row layout stable throughout reasoning, tool execution, answer generation, cancellation, error,
and completion.
- Finalization must remove transient streaming presentation and render the final assistant Markdown without losing content.
@@ -65,18 +67,19 @@ applyTo: "openclient-llm/Shared/Features/Chat/Views/**/*.swift"
## Scroll Follow
- Follow the bottom for initial entry and active responses only while follow mode is attached.
-- Detach immediately when the user deliberately reads history, preserve their position, and expose the implemented route
- back to the latest content.
-- Resume follow only through the current explicit return behavior or the start of a new response.
+- Detach immediately when the user deliberately reads history, preserve their position, and expose an explicit route back
+ to the latest content.
+- Resume follow when that route is used or a new response starts.
- Drive automatic positioning from semantic chat and scroll phases, not from message visibility or continuously changing
geometry. Visibility may inform presentation such as date context, but not automatic scrolling.
-- Preserve native scroll indicators and keyboard-dismiss behavior where the platform implementation provides them.
+- Preserve the main conversation's native scroll and keyboard-dismiss behavior. Horizontal code, table, and attachment
+ scrollers may hide indicators when their content and interaction remain discoverable.
## Chat Accessibility
-- Maintain a logical conversation reading order and expose message role, content, metadata, attachment state, streaming
- state, and available actions to assistive technologies.
-- Keep streamed announcements useful without announcing every fragment.
+- For new or materially changed message presentation, maintain a logical reading order and expose the meaningful role,
+ content, state, and available actions to assistive technologies without duplicating the full visual tree.
+- Group any streamed accessibility announcements; do not announce every fragment.
- Ensure Dynamic Type can reflow messages, metadata, Markdown, attachments, and composer controls without clipping or
hiding actions.
- Localize all chat labels, status, errors, metadata, and accessibility text; this specification does not define their copy.
diff --git a/specs/code-style.instructions.md b/specs/code-style.instructions.md
index a4ad59f2..e300ad8d 100644
--- a/specs/code-style.instructions.md
+++ b/specs/code-style.instructions.md
@@ -50,8 +50,8 @@ applyTo: "**/*.swift"
## SwiftUI And Localization
-- Primary screens and reusable visual components need preview coverage, either locally or through a representative composed
- preview.
+- New or materially changed primary screens and reusable visual components need preview coverage, either locally or
+ through a representative composed preview. Existing components without previews do not require unrelated retrofit work.
- Localize every user-facing source string. Write source strings in English.
- Use `String(localized:)` when an API requires `String`; localized literals are appropriate for APIs accepting
`LocalizedStringKey` or `LocalizedStringResource`.
diff --git a/specs/concurrency.instructions.md b/specs/concurrency.instructions.md
index 4de91bab..9d686430 100644
--- a/specs/concurrency.instructions.md
+++ b/specs/concurrency.instructions.md
@@ -1,10 +1,12 @@
---
description: "Use when writing async code, choosing actor isolation, managing tasks or cancellation, applying Sendable, or reviewing thread safety."
-applyTo: "**/*.swift"
---
# Swift Concurrency
+Apply these rules to new concurrency code and to behavior being materially changed, without expanding the task into
+unrelated refactoring.
+
## Configuration And Isolation
- Read default actor isolation and strict-concurrency settings from each target's current Xcode configuration. Shared code
diff --git a/specs/conversation-backup-format.instructions.md b/specs/conversation-backup-format.instructions.md
index 78f282ce..5f4341d8 100644
--- a/specs/conversation-backup-format.instructions.md
+++ b/specs/conversation-backup-format.instructions.md
@@ -39,7 +39,7 @@ contains every conversation available at export time.
| `version` | Integer | Yes | Exactly `1`. |
| `exportedAt` | ISO 8601 date | Yes | Document creation time. |
| `conversations` | Array | Yes | Zero or more exported conversations. |
-| `conversation` | Object | Yes | A Codable persisted `Conversation`, including its messages and optional compatible metadata. |
+| `conversation` | Object | Yes | A persisted `Conversation` encoded by the Version 1 model contract. |
| `attachments` | Array | Yes | Portable binary payloads referenced by `conversation`. |
| `attachments[].messageId` | UUID | Yes | Message containing the attachment metadata. |
| `attachments[].attachmentId` | UUID | Yes | Attachment identifier on that message. |
@@ -48,6 +48,9 @@ contains every conversation available at export time.
Version 1 rules:
- Dates are ISO 8601. Required fields must decode; unknown JSON keys are ignored.
+- A conversation requires `id`, `title`, `modelId`, `messages`, `createdAt`, and `updatedAt`. Each message requires `id`,
+ `role`, `content`, and `timestamp`. Attachment metadata requires `id`, `type`, and `fileName`. Their decoders supply the
+ compatible defaults for fields added during Version 1.
- Conversation and message UUIDs are unique across the document. Each payload references attachment metadata on its
declared message, and a message cannot contain duplicate payload entries for the same attachment UUID.
- Attachment bytes live in `attachments`; metadata `fileRelativePath` is retained only for Codable compatibility and is
@@ -56,7 +59,8 @@ Version 1 rules:
`tagColors`, branch references, compacted-context metadata, and `imageGenerationAttempted`; their model decoders define
defaults. Tags without a color use orange.
- `contextWindowTokens`, when present, is greater than zero. A context summary and its inclusive cursor are an indivisible
- pair: the summary is non-empty and the cursor identifies a message in the same conversation.
+ pair: the summary is non-empty and the cursor identifies a message in the same conversation. If another message follows
+ that cursor, it is a user message.
- Tag names remain strings in `tags`; optional `tagColors` maps those names to stable semantic color identifiers.
## Current Importer Behavior
@@ -65,8 +69,8 @@ Version 1 rules:
attachment references, and context metadata before persisting anything. Malformed UUIDs, dates, or required fields
invalidate the document; unsupported format or version is rejected explicitly after decoding.
- Imported conversations, messages, and attachments receive new UUIDs. Existing conversations are never overwritten.
- Branch references are remapped when both endpoints are imported; external references are removed. Summary cursors use
- the message UUID map.
+ Parent-conversation and branched-message references are remapped independently when their UUID exists in the imported
+ document; references without a mapped UUID are removed. Summary cursors use the message UUID map.
- Attachment payloads are decoded and written to new local paths; exported paths are ignored. Missing or invalid base64
for declared attachment metadata skips that attachment and increments `skippedAttachmentCount`; a missing required
`data` field or an invalid payload reference invalidates the document.
@@ -81,8 +85,9 @@ Version 1 rules:
`toolName` or an unambiguous assistant call with the same `toolCallId`.
- Import never executes tool calls. Tool transcripts, including `imageGenerationAttempted`, are restored as historical
data only.
-- Persistence is atomic for the import batch: a failure removes newly written attachments and rolls back every conversation
- already restored by that document.
+- Local persistence is atomic for the import batch: a local write or verification failure removes newly written attachments
+ and rolls back every conversation already restored by that document. When cloud sync is enabled, synchronization occurs
+ before and after the local commit; a failure after that commit is reported but does not roll back the local batch.
## Privacy And Limits
diff --git a/specs/design-ui.instructions.md b/specs/design-ui.instructions.md
index ea6c16d6..8d2816b6 100644
--- a/specs/design-ui.instructions.md
+++ b/specs/design-ui.instructions.md
@@ -1,6 +1,5 @@
---
description: "Use when changing OpenClient SwiftUI visuals, interaction feedback, accessibility, localization, or visible UI states."
-applyTo: "{openclient-llm/Shared/**/Views/**/*.swift,openclient-llm-macOS/**/*.swift,WidgetsShared/**/*.swift}"
---
# OpenClient UI Design
@@ -12,6 +11,8 @@ applyTo: "{openclient-llm/Shared/**/Views/**/*.swift,openclient-llm-macOS/**/*.s
- Use the surrounding feature code for concrete details not specified here, such as exact icons, copy, spacing, navigation,
and animation values. Those details do not override this contract.
- Do not use this specification as a reason to redesign unrelated UI.
+- Accessibility, motion, preview, and state-presentation rules apply to new or materially changed surfaces; they do not
+ require unrelated retrofits of untouched views.
## Native Visual Language
@@ -34,8 +35,8 @@ applyTo: "{openclient-llm/Shared/**/Views/**/*.swift,openclient-llm-macOS/**/*.s
## Interaction
-- Keep motion restrained, interruptible, and tied to meaningful state changes. Respect Reduce Motion and avoid animation
- that destabilizes live or frequently updating content.
+- Keep new or changed motion restrained, interruptible, and tied to meaningful state changes. Respect Reduce Motion when
+ adding repeating, spatial, or decorative animation, and avoid animation that destabilizes live content.
- Preserve native keyboard, pointer, focus, context-menu, and touch behavior for each platform.
- Keep interactive targets reachable and clearly labelled when their visible content does not communicate the action.
- Confirm destructive operations that can remove user data or cannot be undone. Use the platform-native destructive role
@@ -44,6 +45,9 @@ applyTo: "{openclient-llm/Shared/**/Views/**/*.swift,openclient-llm-macOS/**/*.s
## Accessibility And Localization
- Localize every user-facing source string in English according to the repository localization rules.
+- Use localized SwiftUI literals, `String(localized:)`, or `LocalizedStringResource` according to the receiving API. Keep
+ user, model, server, tag, and filename data verbatim. Permission prompts are maintained in localized
+ `InfoPlist.strings`; do not edit `Localizable.xcstrings` manually.
- Support VoiceOver with meaningful labels, values, hints when needed, logical reading order, and no interaction available
only through visual position, color, hover, or gesture.
- Preserve sufficient contrast, Dynamic Type reflow, text selection where expected, and platform-appropriate target sizes.
@@ -51,7 +55,8 @@ applyTo: "{openclient-llm/Shared/**/Views/**/*.swift,openclient-llm-macOS/**/*.s
## Visible States
-- Never leave a screen blank while data is loading, unavailable, empty, or failed.
+- New or materially changed primary surfaces must distinguish loading, unavailable, empty, and failed states rather than
+ presenting unexplained blank content. Structural subviews may intentionally render no content.
- Reuse the feature's existing loading, empty, error, disconnected, disabled, and in-progress presentations.
- Keep recoverable errors near their context with an available recovery action when one exists; reserve blocking
presentation for decisions that require it.
diff --git a/specs/icloud-sync.instructions.md b/specs/icloud-sync.instructions.md
index d9e70b07..fd19bb00 100644
--- a/specs/icloud-sync.instructions.md
+++ b/specs/icloud-sync.instructions.md
@@ -20,8 +20,9 @@ their deletion metadata through the private iCloud Documents container. The cont
- Reconciliation is deterministic and idempotent. At most one operation mutates local or cloud state at a time; triggers
received during a run are coalesced into at most one follow-up run.
- Permanent deletion requires explicit user intent or durable deletion metadata created by that intent.
-- A category failure is retained as a partial result and never converted into global success. iCloud file access does not
- block the main actor.
+- A category failure is retained as a partial result and never converted into global success. Most category operations use
+ asynchronous file coordination; conversation preflight also retains synchronous snapshot boundaries. Do not introduce
+ additional main-actor file work unless the task explicitly changes those boundaries.
## Version 1 Container And Layout
@@ -42,12 +43,14 @@ Documents/
Memory.json
MemoryTombstones.json
CloudPurgeMarker.json
+ CloudPurgeJournal.json
SyncManifest.json
```
`ConversationTombstones.json` is the legacy aggregate tombstone file and is merged with per-record tombstones.
`ConversationDeleteAll.json` remains compatible conversation-wide deletion metadata. New user records are never stored in
-metadata files. `CloudPurgeMarker.json` is the shared global deletion barrier, not a user record.
+metadata files. `CloudPurgeMarker.json` is the shared global deletion barrier, and `CloudPurgeJournal.json` records
+per-category purge progress; neither is a user record.
## Schema And Serialization
@@ -62,6 +65,10 @@ is empty or unsupported. When present, the additive manifest is:
}
```
+Version 1 payload files use the compatible `Codable` models and defaults implemented for conversations, memory, prompt
+templates, user profiles, tombstones, and purge metadata. Adding a required field that existing Version 1 readers cannot
+decode is an incompatible schema change.
+
- `format` matches exactly. Versions are positive, `minimumReaderVersion <= schemaVersion`, and mutation is allowed only
when `minimumReaderVersion` is supported. Unknown fields are ignored.
- A malformed manifest, unknown format, invalid version range, or unsupported minimum reader version makes cloud storage
@@ -69,8 +76,10 @@ is empty or unsupported. When present, the additive manifest is:
- An incompatible layout requires a new schema version and explicit tested migration. Migration writes, reads back, and
validates the new representation before recording completion; replaced or removed files are first preserved in local
recovery storage, and old cleanup waits for verified synchronization.
-- Synchronized JSON uses sorted, pretty-printed keys and ISO 8601 UTC dates with microsecond precision. Readers accept ISO
- 8601 dates. Writes are atomic, coordinated where required, read back, byte-checked, and decoded before success.
+- Synchronized JSON uses sorted, pretty-printed keys and ISO 8601 dates. Category data written through `SyncJSONCoding`
+ uses the canonical UTC representation with microsecond precision; auxiliary metadata written through the generic cloud
+ writer uses `JSONEncoder`'s ISO 8601 strategy. Writes are atomic, coordinated where required, and byte-checked after
+ writing. Category flows perform any additional decode verification required by that format.
## Runtime Contract
@@ -80,7 +89,7 @@ Persisted `isCloudSyncEnabled` records user intent only. Availability and `Cloud
|---|---|
| `disabled` | User intent is off; no synchronization work is active. |
| `checkingAvailability` | Account, container, schema, and initial metadata are being resolved. |
-| `idle(lastSuccessfulSyncAt:)` | The container is usable, without asserting a complete current run. |
+| `idle(lastSuccessfulSyncAt:)` | Intent is enabled and no reconciliation is active; startup may publish it before the current availability preflight completes. |
| `synchronizing` | Serialized reconciliation is running. |
| `waitingForDownloads` | Required ubiquitous items are not current; no writes are allowed. |
| `synchronized(lastSuccessfulSyncAt:)` | Every data category succeeded in the same run. |
@@ -88,9 +97,9 @@ Persisted `isCloudSyncEnabled` records user intent only. Availability and `Cloud
| `failed` | A non-pending failure affects the recorded categories. |
| `incomplete` | Categories have mixed pending, unavailable, or failed outcomes; unaffected work is not reported as global success. |
-The last successful date is local diagnostic state, not proof of present availability. Disabling sync cancels pending work,
-observation, and retries without deleting data. Enabling performs availability, schema, and metadata preflight before any
-user-data write.
+The last successful date and an initial `idle` state are local diagnostic state, not proof of present availability.
+Disabling sync cancels pending work and observation without deleting data. Enabling performs availability, schema, and
+metadata preflight before any user-data write.
## Reconciliation
@@ -128,10 +137,10 @@ deterministically; persist and verify local then cloud output; and apply deletio
- Metadata observation exists only while intent is enabled and the container is available. It establishes an initial
baseline; events are debounced and coalesced, and idempotent comparison prevents write feedback loops.
- Errors distinguish unavailable account/container, pending downloads, unsupported schema, invalid data, coordinated file
- access failure, insufficient storage, and partial category failure. Transient retries use bounded backoff; disabling sync
- cancels them.
-- A valid losing or replaced representation is preserved in local recovery storage. Corrupt or unrecognized files are
- preserved and reported. Recovery never uploads unvalidated data.
+ access failure, insufficient storage, and partial category failure. Later metadata, lifecycle, or user triggers can start
+ another reconciliation; disabling sync cancels pending work.
+- A valid losing or replaced representation is preserved in local recovery storage. Corrupt recognized JSON is preserved
+ and reported; unrelated or unrecognized files may be ignored. Recovery never uploads unvalidated data.
- Logs never contain raw paths, profile or memory content, conversation content, or attachment content.
## Certification
@@ -141,6 +150,7 @@ Changes to synchronization behavior require:
- Unit coverage against an injectable temporary cloud root.
- Deterministic two-device coverage with separate local roots and one shared cloud root.
- Cases for local-only, cloud-only, equal, divergent, pending, unavailable, corrupt, deleted, partial, and repeated inputs.
-- iOS and macOS verification.
-- Manual two-installation validation with a test iCloud account for placeholder and metadata behavior that the local harness
- cannot reproduce faithfully.
+- iOS verification and a macOS build for shared synchronization changes; add platform-specific tests when a suitable test
+ target exists.
+- Manual two-installation validation with a test iCloud account when changing placeholder, metadata-query, identity, or
+ other behavior that the local harness cannot reproduce faithfully.
diff --git a/specs/litellm-api.instructions.md b/specs/litellm-api.instructions.md
index 4be26ce8..0c1c1356 100644
--- a/specs/litellm-api.instructions.md
+++ b/specs/litellm-api.instructions.md
@@ -6,8 +6,10 @@ description: "Use when changing OpenAI-compatible or LiteLLM networking, model d
## Configuration And Layering
-- Build every API URL relative to the user-configured base URL. Add `Authorization: Bearer ` only when the Keychain
- value is nonempty; never hardcode hosts or credentials.
+- Build API URLs by appending each repository's endpoint path to the user-configured server base URL. The current endpoint
+ set mixes OpenAI-compatible paths such as `models` with LiteLLM paths such as `v1/search/tools`; preserve those spellings
+ and test base-path behavior when changing URL construction. Add `Authorization: Bearer ` only when the Keychain
+ value is nonempty; never hardcode credentials.
- `APIClient` owns HTTP construction, decoding, SSE transport, multipart uploads, downloads, and typed `APIError` mapping.
Repositories map endpoint DTOs, UseCases apply business rules, and ViewModels coordinate them.
- Use `.convertFromSnakeCase` for API response decoding. Keep request models `Encodable` and transport values `Sendable`.
@@ -67,12 +69,12 @@ description: "Use when changing OpenAI-compatible or LiteLLM networking, model d
## Security And Logging
-- Validate HTTP status before decoding. Map authentication, rate limiting, transport, timeout, malformed response, and
- cancellation failures to typed errors without exposing server payloads.
-- Bound uploads, generated images, downloads, MCP arguments/results, and delegated image inputs before expensive decoding
- or allocation. Accept remote image downloads only on the dedicated image response path and only over HTTP(S).
+- Validate HTTP status before decoding. Map authentication, rate limiting, transport, timeout, and malformed responses to
+ the existing typed errors without exposing server payloads. Preserve current cancellation behavior unless the task
+ explicitly changes it together with its callers.
+- Preserve the limits already enforced for generated images, MCP values, tool results, and delegated image inputs. New or
+ changed upload and download paths should reject oversized content as early as their transport API permits. Accept remote
+ image downloads only on the dedicated image response path and only over HTTP(S).
- Log request method, relative endpoint, status, counts, sizes, timing-relevant state, and redacted errors only in debug
builds. Never log request or response payloads, SSE chunk previews, prompts, messages, tool arguments/results, OCR,
base64/data URLs, downloaded content, API keys, or authorization scopes.
-- `ChatRepository` still includes a short payload preview when an SSE chunk cannot be decoded. Treat it as unresolved
- hardening, not as an approved logging pattern; remove or redact it when changing that error path.
diff --git a/specs/readme.instructions.md b/specs/readme.instructions.md
index 672b59e6..6430f1c2 100644
--- a/specs/readme.instructions.md
+++ b/specs/readme.instructions.md
@@ -1,6 +1,6 @@
---
description: "Use when updating the README, adding badges, updating the architecture diagram, documenting new features, or changing project documentation."
-applyTo: "**/README.md"
+applyTo: "{README.md,ARCHITECTURE.md}"
---
# README Maintenance
diff --git a/specs/security.instructions.md b/specs/security.instructions.md
index 98dbb276..ecbc29f9 100644
--- a/specs/security.instructions.md
+++ b/specs/security.instructions.md
@@ -1,15 +1,17 @@
---
description: "Use when handling credentials, sensitive data, user or remote input, networking, persistence, logging, cryptography, authentication, or extension boundaries."
-applyTo: "**/*.swift"
---
# Security
+Apply these rules to new or materially changed paths while keeping reviews and edits within the requested scope.
+
## Data Classification And Storage
- Classify data before persisting or sharing it. Credentials, tokens, private keys, and equivalent secrets belong in
`KeychainManager`, never `UserDefaults`, logs, source code, or plain files.
-- Store non-sensitive preferences through `SettingsManager`. Do not move sensitive values there for convenience.
+- Store app-facing non-sensitive user preferences through `SettingsManager`. App Group snapshots, cross-process signals,
+ migration markers, caches, and other specialized state may use dedicated stores.
- Choose Keychain accessibility from the actual access requirement. Prefer device-only accessibility when migration is not
required, and enable synchronization only for an explicitly designed feature.
- Treat values compiled from build configuration into an app bundle as recoverable client configuration, not privileged
@@ -19,8 +21,9 @@ applyTo: "**/*.swift"
## Logging And Diagnostics
-- Never log credentials, authorization headers, private keys, personal data, conversations, prompts, attachments, request
- bodies, response bodies, or raw server errors that may contain user data.
+- Do not add logging of credentials, authorization headers, private keys, personal data, conversations, prompts,
+ attachments, request bodies, response bodies, or raw server errors that may contain user data. Redact such data when
+ modifying an existing diagnostic path.
- Log categories, status, sizes, identifiers safe for diagnostics, and redacted outcomes rather than payloads.
- Debug-only logging is still disclosure. Do not rely on build configuration as the sole privacy control.
- Sanitize propagated errors before presenting or recording them; preserve useful diagnostics without exposing secrets or
diff --git a/specs/swiftui-multiplatform.instructions.md b/specs/swiftui-multiplatform.instructions.md
index 77c79df8..fb6e93c1 100644
--- a/specs/swiftui-multiplatform.instructions.md
+++ b/specs/swiftui-multiplatform.instructions.md
@@ -1,6 +1,5 @@
---
description: "Use when deciding how OpenClient SwiftUI code and behavior are shared or adapted across iOS, iPadOS, and macOS."
-applyTo: "{openclient-llm/Shared/**/Views/**/*.swift,openclient-llm-macOS/**/*.swift,WidgetsShared/**/*.swift}"
---
# OpenClient SwiftUI Multiplatform Structure
@@ -16,7 +15,9 @@ applyTo: "{openclient-llm/Shared/**/Views/**/*.swift,openclient-llm-macOS/**/*.s
## Shared And Platform-Specific Code
- Shared app UI and feature code lives under `openclient-llm/Shared/` and is compiled by the iOS and macOS app targets.
-- Keep genuinely platform-specific app, commands, menu bar, lifecycle, and UI code in its platform target directory.
+- Keep platform app entry points, lifecycle, commands, menu bar, and independent platform-only UI in the platform target
+ directory. A focused platform extension tightly coupled to a shared feature may remain beside that feature behind a
+ file-level platform guard.
- Keep a view shared when its structure and behavior are substantially the same. Use a small `#if os(...)` branch for a
localized platform difference.
- Split platform implementations when composition or interaction is fundamentally different. Do not accumulate broad
@@ -44,8 +45,9 @@ applyTo: "{openclient-llm/Shared/**/Views/**/*.swift,openclient-llm-macOS/**/*.s
## Previews
-- Provide preview coverage for primary screens and reusable visual components, in the same file or an existing dedicated
- preview file.
+- Provide preview coverage for new or materially changed primary screens and reusable visual components, in the same file
+ or an existing dedicated preview file. Existing views and widgets may rely on their current composed or timeline
+ snapshots until they are changed.
- Cover the meaningful states and layout variants needed to understand a component, not an exhaustive snapshot matrix.
- Include representative compact and wide contexts when adaptation is part of the component's responsibility.
- Platform adapters and infrastructure-only views may rely on a composed parent preview when that exercises their UI.
diff --git a/specs/testing.instructions.md b/specs/testing.instructions.md
index 8fdb1a7b..077dcfdb 100644
--- a/specs/testing.instructions.md
+++ b/specs/testing.instructions.md
@@ -50,8 +50,10 @@ conformance:
## Reliability
-- Keep tests independent, repeatable, and free of real user settings, Keychain entries, App Group data, or persistent files.
-- Use isolated stores and unique namespaces for persistence tests, then remove only data created by that test.
+- Keep tests independent, repeatable, and free of production user settings, Keychain namespaces, App Group data, or
+ persistent files.
+- Persistence and Keychain tests may use isolated stores, synthetic values, and unique namespaces. Remove only data created
+ by that test.
- Avoid force unwraps in test code; use XCTest unwrapping and explicit failures.
- Assert public outputs and meaningful side effects rather than incidental call sequences unless ordering is itself required.
diff --git a/specs/version.instructions.md b/specs/version.instructions.md
index a49b16a7..7a3dca15 100644
--- a/specs/version.instructions.md
+++ b/specs/version.instructions.md
@@ -66,8 +66,9 @@ minor-fixes bullet when it accurately summarizes the build or complements substa
## Remote Config Decisions
-The local `config.json` and `config-dev.json` files mirror Remote Config. Synchronize only their `latest_version` values by
-default; do not assume production and development behavior should match.
+The ignored local `config.json` and `config-dev.json` files are release-management copies of the Remote Config structure;
+the repository does not prove that they match deployed state. Synchronize only their `latest_version` values by default,
+and do not assume production and development behavior should match.
Explicitly ask before enabling `force_update` and identify whether it applies to production, development, or both. Also
ask before any destructive banner change: disabling, replacing, removing content, or changing `dismiss_banner_key`. Keep
@@ -76,9 +77,9 @@ mechanically.
### New banner questions
-For a confirmed new banner, collect any missing message, platforms, target config files, activation state, localized copy,
-CTA action, and URL when applicable. Generate a stable release-and-message `dismiss_banner_key` unless supplied; do not
-change the key merely to re-show unchanged content.
+For a confirmed new banner, collect any missing platforms, target config files, activation state, and localized items. Each
+item requires `title`, `subtitle`, `cta`, `action`, `url`, and `emoji`; include an English fallback. Generate a stable
+release-and-message `dismiss_banner_key` unless supplied; do not change the key merely to re-show unchanged content.
### Valid banner actions
diff --git a/specs/web-browsing.instructions.md b/specs/web-browsing.instructions.md
index 7945ef71..9ae6905c 100644
--- a/specs/web-browsing.instructions.md
+++ b/specs/web-browsing.instructions.md
@@ -1,6 +1,5 @@
---
description: "Use when changing LiteLLM web-search configuration, registration, execution, source persistence, or citation context."
-applyTo: "{openclient-llm/Shared/Features/Chat/**/*.swift,openclient-llm/Shared/Features/Settings/**/*.swift}"
---
# Web Search
@@ -11,8 +10,8 @@ applyTo: "{openclient-llm/Shared/Features/Chat/**/*.swift,openclient-llm/Shared/
`POST /v1/search/{search_tool_name}` endpoint and must never call a search provider directly.
- Provider credentials and provider selection remain on the LiteLLM server. Do not add provider SDKs, provider-specific
routing, client-side search keys, native `web_search_options`, or manual search-result injection.
-- Discover available server configurations through `GET /v1/search/tools`. The selected tool name must be a path component
- supplied by settings, not a model-provided value.
+- Discover available server configurations through `GET /v1/search/tools`. Settings supply the selected server tool name
+ used in the endpoint; the model cannot choose or override it.
## Availability And Routing
From 5aaf3bfbffc0686ddd9d6d280634f6d726705fff Mon Sep 17 00:00:00 2001
From: Arturo Carretero Calvo <10163049+ArtCC@users.noreply.github.com>
Date: Thu, 17 Sep 2026 14:29:53 +0200
Subject: [PATCH 5/7] Unify cross-platform button and animation styles
- Remove macOS-specific branches for conversation tag filters and use a single glass effect
- Apply smooth remote banner animation on all platforms in HomeView
- Use glass button styles in onboarding server configuration and primary actions
- Keep macOS-only trailing alignment for the onboarding continue button
- Simplify the conversation list section header by removing the extra divider wrapper
---
.../Features/Chat/Views/ConversationListView.swift | 11 ++---------
.../Shared/Features/Home/Views/HomeView.swift | 4 ----
.../Views/OnboardingServerConfigurationView.swift | 4 ----
.../Features/Onboarding/Views/OnboardingView.swift | 12 +-----------
4 files changed, 3 insertions(+), 28 deletions(-)
diff --git a/openclient-llm/Shared/Features/Chat/Views/ConversationListView.swift b/openclient-llm/Shared/Features/Chat/Views/ConversationListView.swift
index 5f6a34b3..aaa46e21 100644
--- a/openclient-llm/Shared/Features/Chat/Views/ConversationListView.swift
+++ b/openclient-llm/Shared/Features/Chat/Views/ConversationListView.swift
@@ -274,11 +274,8 @@ private extension ConversationListView {
Section {
// pinned tag filter bar — no rows
} header: {
- VStack(spacing: 0) {
- tagFilterBar(loadedState)
- Divider()
- }
- .listRowInsets(EdgeInsets())
+ tagFilterBar(loadedState)
+ .listRowInsets(EdgeInsets())
}
}
ForEach(loadedState.groupedConversations) { section in
@@ -426,14 +423,10 @@ private extension ConversationListView {
.lineLimit(1)
.padding(.horizontal, 12)
.padding(.vertical, 6)
-#if os(macOS)
- .background(isSelected ? Color.appAccent : Color.primary.opacity(0.08), in: .capsule)
-#else
.glassEffect(
isSelected ? .regular.tint(Color.appAccent).interactive() : .regular.interactive(),
in: .capsule
)
-#endif
}
.buttonStyle(.plain)
.accessibilityAddTraits(isSelected ? .isSelected : [])
diff --git a/openclient-llm/Shared/Features/Home/Views/HomeView.swift b/openclient-llm/Shared/Features/Home/Views/HomeView.swift
index df4f6ab5..49861392 100644
--- a/openclient-llm/Shared/Features/Home/Views/HomeView.swift
+++ b/openclient-llm/Shared/Features/Home/Views/HomeView.swift
@@ -118,11 +118,7 @@ struct HomeView: View {
guard action != nil else { return }
viewModel.send(.urlSchemeActionReceived)
}
-#if os(macOS)
- .animation(nil, value: remoteBanner?.id)
-#else
.animation(.smooth, value: remoteBanner?.id)
-#endif
}
}
diff --git a/openclient-llm/Shared/Features/Onboarding/Views/OnboardingServerConfigurationView.swift b/openclient-llm/Shared/Features/Onboarding/Views/OnboardingServerConfigurationView.swift
index cd2d1670..70cb5ba0 100644
--- a/openclient-llm/Shared/Features/Onboarding/Views/OnboardingServerConfigurationView.swift
+++ b/openclient-llm/Shared/Features/Onboarding/Views/OnboardingServerConfigurationView.swift
@@ -188,11 +188,7 @@ private extension OnboardingServerConfigurationView {
.frame(minHeight: 44)
#endif
}
-#if os(macOS)
- .buttonStyle(.bordered)
-#else
.buttonStyle(.glass)
-#endif
.controlSize(.large)
.disabled(state.serverURL.isEmpty || state.connectionStatus == .testing)
diff --git a/openclient-llm/Shared/Features/Onboarding/Views/OnboardingView.swift b/openclient-llm/Shared/Features/Onboarding/Views/OnboardingView.swift
index a6bec7da..6f7b5c8b 100644
--- a/openclient-llm/Shared/Features/Onboarding/Views/OnboardingView.swift
+++ b/openclient-llm/Shared/Features/Onboarding/Views/OnboardingView.swift
@@ -147,11 +147,7 @@ private extension OnboardingView {
.frame(minHeight: 44)
#endif
}
-#if os(macOS)
- .buttonStyle(.bordered)
-#else
.buttonStyle(.glass)
-#endif
} else {
Text("OpenClient")
.font(.headline)
@@ -167,11 +163,7 @@ private extension OnboardingView {
.frame(minWidth: 44, minHeight: 44)
#endif
}
-#if os(macOS)
- .buttonStyle(.bordered)
-#else
.buttonStyle(.glass)
-#endif
.accessibilityHint("Finish onboarding without saving these server settings.")
}
@@ -303,11 +295,9 @@ private extension OnboardingView {
.frame(maxWidth: .infinity, minHeight: 44)
#endif
}
+ .buttonStyle(.glassProminent)
#if os(macOS)
- .buttonStyle(.borderedProminent)
.frame(maxWidth: .infinity, alignment: .trailing)
-#else
- .buttonStyle(.glassProminent)
#endif
.controlSize(.large)
.disabled(loadedState.currentStep == .serverConfiguration && loadedState.connectionStatus != .success)
From a3fe80aed36fe0a7e939ca4f13e873ee6cab0ad5 Mon Sep 17 00:00:00 2001
From: Arturo Carretero Calvo <10163049+ArtCC@users.noreply.github.com>
Date: Thu, 17 Sep 2026 14:36:05 +0200
Subject: [PATCH 6/7] Update changelog for build 113
- Bump release entry to 1.7.10-build-113 dated 2026-09-17
- Document Liquid Glass parity updates for tag filters, onboarding actions, and the remote banner on macOS
- Note the conversation list separator duplication fix
---
CHANGELOG.md | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2b3b29fc..cc0546d6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,14 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
-## [1.7.10-build-112] - 2026-09-16
+## [1.7.10-build-113] - 2026-09-17
### Changed
- Build compatibility with Xcode 27 and the iOS 27 and macOS 27 SDKs
+- Tag filter chips in the conversation list now render with Liquid Glass on macOS, matching iOS and iPadOS
+- Onboarding actions now use Liquid Glass button styles on macOS, matching iOS and iPadOS
+- The remote banner now animates into view on macOS with the same transition used on iOS and iPadOS
### Fixed
+- The conversation list no longer shows a duplicate separator between the tag filter bar and the conversation sections
- The Models screen no longer repeatedly requests model data when its SwiftUI task restarts
- **Minor bug fixes**
From 0eb4cf00865cb02cb32f783f2b15d4a80be65333 Mon Sep 17 00:00:00 2001
From: Arturo Carretero Calvo <10163049+ArtCC@users.noreply.github.com>
Date: Sat, 19 Sep 2026 08:27:11 +0200
Subject: [PATCH 7/7] Update release notes for build 115
- Bump changelog entry to 1.7.10-build-115 dated 2026-09-19
- Expand TestFlight notes in English and Spanish with Mac UI polish and bug fixes
- Describe updated Liquid Glass styling, Home screen animation, separator fix, and Models loading fix
---
CHANGELOG.md | 2 +-
TestFlight/WhatToTest.en-US.txt | 4 ++++
TestFlight/WhatToTest.es-ES.txt | 4 ++++
3 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cc0546d6..a96debd0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
-## [1.7.10-build-113] - 2026-09-17
+## [1.7.10-build-115] - 2026-09-19
### Changed
diff --git a/TestFlight/WhatToTest.en-US.txt b/TestFlight/WhatToTest.en-US.txt
index b544872b..38b16e0f 100644
--- a/TestFlight/WhatToTest.en-US.txt
+++ b/TestFlight/WhatToTest.en-US.txt
@@ -1,6 +1,10 @@
Hi there! We've got some great new features for you in this update.
• OpenClient is now ready for Xcode 27 and the latest iOS 27 and macOS 27 SDKs, keeping your experience up to date across iPhone, iPad, and Mac.
+• On Mac, tag filters in your conversation list and the onboarding buttons now use the same Liquid Glass style you already enjoy on iPhone and iPad.
+• Announcements on the Home screen now animate into view on Mac with the same smooth transition used on iPhone and iPad.
+• Your conversation list no longer shows a duplicate separator above your chats.
+• The Models screen no longer repeats requests while loading, so it stays responsive.
• Minor bug fixes and improvements for a smoother experience.
Thanks for your continued support and for helping us build the best possible LLM client together.
diff --git a/TestFlight/WhatToTest.es-ES.txt b/TestFlight/WhatToTest.es-ES.txt
index bfc9ab90..a714c407 100644
--- a/TestFlight/WhatToTest.es-ES.txt
+++ b/TestFlight/WhatToTest.es-ES.txt
@@ -1,6 +1,10 @@
¡Hola! Esta actualización viene cargada de novedades.
• OpenClient ya está preparado para Xcode 27 y los últimos SDK de iOS 27 y macOS 27, para ofrecerte una experiencia actualizada en iPhone, iPad y Mac.
+• En Mac, los filtros de etiquetas de la lista de conversaciones y los botones del onboarding ahora usan el mismo estilo Liquid Glass que ya disfrutas en iPhone y iPad.
+• Los anuncios de la pantalla de Inicio ahora aparecen con una animación fluida en Mac, con la misma transición que en iPhone e iPad.
+• La lista de conversaciones ya no muestra un separador duplicado encima de tus chats.
+• La pantalla de Modelos ya no repite solicitudes mientras se carga, para mantenerse ágil.
• Pequeñas correcciones de errores y mejoras para disfrutar de una experiencia más fluida.
Gracias por seguir apoyándonos y por ayudarnos a crear juntos el mejor cliente posible para modelos de lenguaje.