diff --git a/ios/AGENTS.md b/ios/AGENTS.md index 4632039..e38cd66 100644 --- a/ios/AGENTS.md +++ b/ios/AGENTS.md @@ -34,7 +34,8 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`. - If backend contract changes (request/response shapes, SSE events, auth semantics), update docs in the same change. ## Practical Notes -- Default API URL is `http://127.0.0.1:8787/api` (configurable in-app). +- Default API URL is `http://127.0.0.1:8787` (configurable in-app). +- Previously saved `/api` API roots are normalized to the server root by the iOS client. - Provider fallback models: - OpenAI: `gpt-4.1-mini` - Anthropic: `claude-3-5-sonnet-latest` diff --git a/ios/Apps/Sybil/Resources/Fonts/Inter.ttf b/ios/Apps/Sybil/Resources/Fonts/Inter.ttf new file mode 100644 index 0000000..047c92f Binary files /dev/null and b/ios/Apps/Sybil/Resources/Fonts/Inter.ttf differ diff --git a/ios/Apps/Sybil/Resources/Fonts/Orbitron.ttf b/ios/Apps/Sybil/Resources/Fonts/Orbitron.ttf new file mode 100644 index 0000000..1fc54ce Binary files /dev/null and b/ios/Apps/Sybil/Resources/Fonts/Orbitron.ttf differ diff --git a/ios/Packages/Sybil/Sources/Sybil/SplitView.swift b/ios/Packages/Sybil/Sources/Sybil/SplitView.swift index 4471ce7..460ef3a 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SplitView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SplitView.swift @@ -4,7 +4,9 @@ public struct SplitView: View { @State private var viewModel = SybilViewModel() @Environment(\.horizontalSizeClass) private var horizontalSizeClass - public init() {} + public init() { + SybilFontRegistry.registerIfNeeded() + } public var body: some View { ZStack { @@ -31,6 +33,7 @@ public struct SplitView: View { .tint(SybilTheme.primary) } } + .font(.sybil(.body)) .task { await viewModel.bootstrap() } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift index 1eea7b9..fa23eb5 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift @@ -18,7 +18,7 @@ struct SybilChatTranscriptView: View { LazyVStack(alignment: .leading, spacing: 24) { if isLoading && messages.isEmpty { Text("Loading messages…") - .font(.footnote) + .font(.sybil(.footnote)) .foregroundStyle(SybilTheme.textMuted) .padding(.top, 24) } @@ -34,7 +34,7 @@ struct SybilChatTranscriptView: View { .controlSize(.small) .tint(SybilTheme.textMuted) Text("Assistant is typing…") - .font(.footnote) + .font(.sybil(.footnote)) .foregroundStyle(SybilTheme.textMuted) } .id("typing-indicator") @@ -86,11 +86,16 @@ private struct MessageBubble: View { } var body: some View { - HStack { + HStack(alignment: .top) { + if isUser { + Spacer(minLength: 44) + } + if let toolCallMetadata { ToolCallActivityChip( metadata: toolCallMetadata, - fallbackContent: message.content + fallbackContent: message.content, + createdAt: message.createdAt ) } else { VStack(alignment: .leading, spacing: 8) { @@ -98,9 +103,9 @@ private struct MessageBubble: View { HStack(spacing: 8) { ProgressView() .controlSize(.small) - .tint(SybilTheme.textMuted) + .tint(SybilTheme.primary) Text("Thinking…") - .font(.footnote) + .font(.sybil(.footnote)) .foregroundStyle(SybilTheme.textMuted) } .padding(.vertical, 2) @@ -119,7 +124,7 @@ private struct MessageBubble: View { Group { if isUser { RoundedRectangle(cornerRadius: 16) - .fill(SybilTheme.userBubble.opacity(0.86)) + .fill(SybilTheme.userBubbleGradient) .overlay( RoundedRectangle(cornerRadius: 16) .stroke(SybilTheme.primary.opacity(0.45), lineWidth: 1) @@ -132,24 +137,50 @@ private struct MessageBubble: View { ) .frame(maxWidth: isUser ? 420 : nil, alignment: isUser ? .trailing : .leading) } + + if !isUser { + Spacer(minLength: 0) + } } + .frame(maxWidth: .infinity, alignment: isUser ? .trailing : .leading) } } private struct ToolCallActivityChip: View { var metadata: ToolCallMetadata var fallbackContent: String + var createdAt: Date private var summary: String { if let text = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty { return text } + if (metadata.status ?? "").lowercased() == "failed", + let error = metadata.error?.trimmingCharacters(in: .whitespacesAndNewlines), + !error.isEmpty { + return "Tool failed: \(error)" + } + if let preview = metadata.resultPreview?.trimmingCharacters(in: .whitespacesAndNewlines), !preview.isEmpty { + return preview + } if !fallbackContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return fallbackContent } return "Ran tool '\(metadata.toolName ?? "unknown_tool")'." } + private var toolLabel: String { + let raw = metadata.toolName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !raw.isEmpty else { return "Tool call" } + return raw + .replacingOccurrences(of: "_", with: " ") + .split(separator: " ") + .map { word in + word.prefix(1).uppercased() + word.dropFirst() + } + .joined(separator: " ") + } + private var iconName: String { let name = (metadata.toolName ?? "").lowercased() if name.contains("search") { @@ -165,26 +196,59 @@ private struct ToolCallActivityChip: View { (metadata.status ?? "").lowercased() == "failed" } - var body: some View { - HStack(spacing: 8) { - Image(systemName: iconName) - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(isFailed ? SybilTheme.danger : SybilTheme.primary) - Text(summary) - .font(.caption) - .foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.95) : SybilTheme.text.opacity(0.9)) - .fixedSize(horizontal: false, vertical: true) + private var detailLabel: String { + var pieces: [String] = [isFailed ? "Failed" : "Completed"] + if let durationMs = metadata.durationMs, durationMs > 0 { + pieces.append("\(durationMs) ms") } - .padding(.horizontal, 10) - .padding(.vertical, 7) + pieces.append(createdAt.sybilShortTimeLabel) + return pieces.joined(separator: " • ") + } + + var body: some View { + HStack(alignment: .top, spacing: 11) { + ZStack { + RoundedRectangle(cornerRadius: 9) + .fill((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.13)) + .overlay( + RoundedRectangle(cornerRadius: 9) + .stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1) + ) + Image(systemName: iconName) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(isFailed ? SybilTheme.danger : SybilTheme.accent) + } + .frame(width: 30, height: 30) + + VStack(alignment: .leading, spacing: 4) { + Text(summary) + .font(.sybil(.subheadline)) + .foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.96) : SybilTheme.text.opacity(0.94)) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 6) { + Text(toolLabel) + .font(.sybil(.caption2, weight: .semibold)) + .foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.84) : SybilTheme.accent.opacity(0.90)) + .lineLimit(1) + + Text(detailLabel) + .font(.sybil(.caption2)) + .foregroundStyle(SybilTheme.textMuted) + .lineLimit(1) + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) .background( - RoundedRectangle(cornerRadius: 10) - .fill((isFailed ? SybilTheme.danger : SybilTheme.primary).opacity(0.12)) + RoundedRectangle(cornerRadius: 12) + .fill(isFailed ? SybilTheme.failedToolCallGradient : SybilTheme.toolCallGradient) .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke((isFailed ? SybilTheme.danger : SybilTheme.primary).opacity(0.35), lineWidth: 1) + RoundedRectangle(cornerRadius: 12) + .stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1) ) ) - .frame(maxWidth: 460, alignment: .leading) + .frame(maxWidth: 520, alignment: .leading) } } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilConnectionView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilConnectionView.swift index 28495a8..9319276 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilConnectionView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilConnectionView.swift @@ -8,23 +8,32 @@ struct SybilConnectionView: View { @Bindable var settings = viewModel.settings VStack(spacing: 20) { + HStack { + SybilWordmark(size: 34) + Spacer() + } + HStack(alignment: .top, spacing: 12) { Image(systemName: "shield.lefthalf.filled") - .font(.title3.weight(.semibold)) - .foregroundStyle(SybilTheme.primary) + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(SybilTheme.accent) .frame(width: 34, height: 34) .background( RoundedRectangle(cornerRadius: 10) - .fill(SybilTheme.primary.opacity(0.18)) + .fill(SybilTheme.accent.opacity(0.12)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(SybilTheme.accent.opacity(0.28), lineWidth: 1) + ) ) VStack(alignment: .leading, spacing: 4) { Text("Connect to Sybil") - .font(.title3.weight(.semibold)) + .font(.sybil(.title3, weight: .semibold)) .foregroundStyle(SybilTheme.text) Text("Point the app at your backend and sign in with ADMIN_TOKEN if token mode is enabled.") - .font(.callout) + .font(.sybil(.callout)) .foregroundStyle(SybilTheme.textMuted) .fixedSize(horizontal: false, vertical: true) } @@ -32,25 +41,25 @@ struct SybilConnectionView: View { VStack(alignment: .leading, spacing: 10) { Text("API URL") - .font(.caption.weight(.semibold)) + .font(.sybil(.caption, weight: .semibold)) .foregroundStyle(SybilTheme.textMuted) - TextField("http://127.0.0.1:8787/api", text: $settings.apiBaseURL) + TextField("http://127.0.0.1:8787", text: $settings.apiBaseURL) .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.URL) .padding(12) .background( RoundedRectangle(cornerRadius: 12) - .fill(SybilTheme.surface) + .fill(SybilTheme.surface.opacity(0.78)) ) .overlay( RoundedRectangle(cornerRadius: 12) - .stroke(SybilTheme.border, lineWidth: 1) + .stroke(SybilTheme.border.opacity(0.88), lineWidth: 1) ) Text("Admin Token") - .font(.caption.weight(.semibold)) + .font(.sybil(.caption, weight: .semibold)) .foregroundStyle(SybilTheme.textMuted) SecureField("ADMIN_TOKEN (optional in open mode)", text: $settings.adminToken) @@ -59,11 +68,11 @@ struct SybilConnectionView: View { .padding(12) .background( RoundedRectangle(cornerRadius: 12) - .fill(SybilTheme.surface) + .fill(SybilTheme.surface.opacity(0.78)) ) .overlay( RoundedRectangle(cornerRadius: 12) - .stroke(SybilTheme.border, lineWidth: 1) + .stroke(SybilTheme.border.opacity(0.88), lineWidth: 1) ) } @@ -94,7 +103,7 @@ struct SybilConnectionView: View { if let authError = viewModel.authError { Text(authError) - .font(.footnote) + .font(.sybil(.footnote)) .foregroundStyle(SybilTheme.danger) .frame(maxWidth: .infinity, alignment: .leading) } @@ -103,10 +112,10 @@ struct SybilConnectionView: View { .frame(maxWidth: 520) .background( RoundedRectangle(cornerRadius: 20) - .fill(SybilTheme.card) + .fill(SybilTheme.panelGradient) .overlay( RoundedRectangle(cornerRadius: 20) - .stroke(SybilTheme.border, lineWidth: 1) + .stroke(SybilTheme.border.opacity(0.9), lineWidth: 1) ) ) } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift index d5e8f8a..aabe319 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift @@ -58,7 +58,10 @@ public struct Message: Codable, Identifiable, Hashable, Sendable { toolCallId: object["toolCallId"]?.stringValue, toolName: object["toolName"]?.stringValue ?? name, status: object["status"]?.stringValue, - summary: object["summary"]?.stringValue + summary: object["summary"]?.stringValue, + durationMs: object["durationMs"]?.numberValue.map(Int.init), + error: object["error"]?.stringValue, + resultPreview: object["resultPreview"]?.stringValue ) } @@ -72,6 +75,9 @@ public struct ToolCallMetadata: Hashable, Sendable { public var toolName: String? public var status: String? public var summary: String? + public var durationMs: Int? + public var error: String? + public var resultPreview: String? } public enum JSONValue: Codable, Hashable, Sendable { @@ -136,6 +142,13 @@ public enum JSONValue: Codable, Hashable, Sendable { return nil } + public var numberValue: Double? { + if case let .number(value) = self { + return value + } + return nil + } + public var objectValue: [String: JSONValue]? { if case let .object(value) = self { return value @@ -412,6 +425,14 @@ extension DateFormatter { formatter.timeStyle = .short return formatter }() + + static let sybilShortTime: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .autoupdatingCurrent + formatter.dateStyle = .none + formatter.timeStyle = .short + return formatter + }() } extension Date { @@ -421,4 +442,8 @@ extension Date { formatter.unitsStyle = .abbreviated return formatter.localizedString(for: self, relativeTo: Date()) } + + var sybilShortTimeLabel: String { + DateFormatter.sybilShortTime.string(from: self) + } } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift index e3b55bf..8dde7e7 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift @@ -27,8 +27,13 @@ struct SybilPhoneShellView: View { var body: some View { NavigationStack(path: $path) { SybilPhoneSidebarRoot(viewModel: viewModel, path: $path) - .navigationTitle("Sybil") + .navigationTitle("") .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + SybilWordmark(size: 19) + } + } .navigationDestination(for: PhoneRoute.self) { route in SybilPhoneDestinationView(viewModel: viewModel, route: route) } @@ -43,14 +48,9 @@ private struct SybilPhoneSidebarRoot: View { var body: some View { VStack(spacing: 0) { - buttonRow - - Divider() - .overlay(SybilTheme.border) - if let errorMessage = viewModel.errorMessage { Text(errorMessage) - .font(.footnote) + .font(.sybil(.footnote)) .foregroundStyle(SybilTheme.danger) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 12) @@ -65,7 +65,7 @@ private struct SybilPhoneSidebarRoot: View { ProgressView() .tint(SybilTheme.primary) Text("Loading conversations…") - .font(.footnote) + .font(.sybil(.footnote)) .foregroundStyle(SybilTheme.textMuted) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) @@ -73,10 +73,10 @@ private struct SybilPhoneSidebarRoot: View { } else if viewModel.sidebarItems.isEmpty { VStack(spacing: 10) { Image(systemName: "message.badge") - .font(.title3) + .font(.system(size: 20, weight: .medium)) .foregroundStyle(SybilTheme.textMuted) Text("Start a chat or run your first search.") - .font(.footnote) + .font(.sybil(.footnote)) .multilineTextAlignment(.center) .foregroundStyle(SybilTheme.textMuted) } @@ -104,48 +104,67 @@ private struct SybilPhoneSidebarRoot: View { .padding(10) } } + } + .background(SybilTheme.panelGradient) + .safeAreaInset(edge: .bottom, spacing: 0) { + bottomToolbar + } + } + private var bottomToolbar: some View { + VStack(spacing: 0) { Divider() .overlay(SybilTheme.border) - NavigationLink(value: PhoneRoute.settings) { - Label("Settings", systemImage: "gearshape") - .font(.subheadline.weight(.medium)) - .foregroundStyle(SybilTheme.text) - .padding(.horizontal, 12) - .padding(.vertical, 10) - .frame(maxWidth: .infinity, alignment: .leading) + HStack(spacing: 12) { + toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") { + path = [.settings] + } + + Spacer() + + toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") { + viewModel.startNewSearch() + path = [.draftSearch] + } + + toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) { + viewModel.startNewChat() + path = [.draftChat] + } } - .buttonStyle(.plain) - .padding(10) + .padding(.horizontal, 18) + .padding(.vertical, 10) + .background(SybilTheme.panelGradient) } - .background(SybilTheme.surfaceStrong.opacity(0.84)) } - private var buttonRow: some View { - HStack(spacing: 10) { - Button { - viewModel.startNewChat() - path.append(.draftChat) - } label: { - Label("New chat", systemImage: "plus") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .tint(SybilTheme.primarySoft) - - Button { - viewModel.startNewSearch() - path.append(.draftSearch) - } label: { - Label("New search", systemImage: "magnifyingglass") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .tint(SybilTheme.textMuted) + private func toolbarIconButton( + systemImage: String, + accessibilityLabel: String, + isPrimary: Bool = false, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Image(systemName: systemImage) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(isPrimary ? SybilTheme.text : SybilTheme.textMuted) + .frame(width: 42, height: 42) + .background( + Circle() + .fill( + isPrimary + ? AnyShapeStyle(SybilTheme.primaryGradient) + : AnyShapeStyle(SybilTheme.surface.opacity(0.78)) + ) + ) + .overlay( + Circle() + .stroke(isPrimary ? SybilTheme.primary.opacity(0.42) : SybilTheme.border.opacity(0.76), lineWidth: 1) + ) } - .padding(.horizontal, 12) - .padding(.vertical, 12) + .buttonStyle(.plain) + .accessibilityLabel(accessibilityLabel) } } @@ -156,24 +175,36 @@ private struct SybilPhoneSidebarRow: View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { Image(systemName: item.kind == .chat ? "message" : "globe") - .font(.caption.weight(.semibold)) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(SybilTheme.textMuted) + .frame(width: 22, height: 22) + .background( + RoundedRectangle(cornerRadius: 7) + .fill(SybilTheme.surface.opacity(0.72)) + .overlay( + RoundedRectangle(cornerRadius: 7) + .stroke(SybilTheme.border.opacity(0.72), lineWidth: 1) + ) + ) Text(item.title) - .font(.subheadline.weight(.medium)) + .font(.sybil(.subheadline, weight: .semibold)) .lineLimit(1) } HStack(spacing: 8) { Text(item.updatedAt.sybilRelativeLabel) - .font(.caption2) + .font(.sybil(.caption2)) .foregroundStyle(SybilTheme.textMuted) if let initiated = item.initiatedLabel { Spacer(minLength: 0) Text(initiated) - .font(.caption2) + .font(.sybil(.caption2)) .foregroundStyle(SybilTheme.textMuted.opacity(0.88)) .lineLimit(1) + .multilineTextAlignment(.trailing) + .frame(maxWidth: .infinity, alignment: .trailing) } } } @@ -183,7 +214,7 @@ private struct SybilPhoneSidebarRow: View { .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 12) - .fill(SybilTheme.surface.opacity(0.55)) + .fill(LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing)) ) .overlay( RoundedRectangle(cornerRadius: 12) diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilSearchResultsView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilSearchResultsView.swift index 54d902a..b2a2a87 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilSearchResultsView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilSearchResultsView.swift @@ -12,15 +12,15 @@ struct SybilSearchResultsView: View { if let query = search?.query, !query.isEmpty { VStack(alignment: .leading, spacing: 4) { Text("Results for") - .font(.footnote) + .font(.sybil(.footnote)) .foregroundStyle(SybilTheme.textMuted) Text(query) - .font(.title3.weight(.semibold)) + .font(.sybil(.title3, weight: .semibold)) .foregroundStyle(SybilTheme.text) .fixedSize(horizontal: false, vertical: true) Text(resultCountLabel) - .font(.caption) + .font(.sybil(.caption)) .foregroundStyle(SybilTheme.textMuted) } } @@ -36,14 +36,14 @@ struct SybilSearchResultsView: View { .controlSize(.small) .tint(SybilTheme.textMuted) Text(isRunning ? "Searching Exa…" : "Loading search…") - .font(.footnote) + .font(.sybil(.footnote)) .foregroundStyle(SybilTheme.textMuted) } } if !isLoading, !isRunning, let search, !search.query.orEmpty.isEmpty, search.results.isEmpty { Text("No results found.") - .font(.footnote) + .font(.sybil(.footnote)) .foregroundStyle(SybilTheme.textMuted) } @@ -55,7 +55,7 @@ struct SybilSearchResultsView: View { if let error = search?.error, !error.isEmpty { Text(error) - .font(.footnote) + .font(.sybil(.footnote)) .foregroundStyle(SybilTheme.danger) } } @@ -80,9 +80,9 @@ struct SybilSearchResultsView: View { private var answerCard: some View { VStack(alignment: .leading, spacing: 10) { Text("Answer") - .font(.caption.weight(.semibold)) + .font(.sybil(.caption, weight: .semibold)) .textCase(.uppercase) - .foregroundStyle(SybilTheme.primary) + .foregroundStyle(SybilTheme.primary.opacity(0.92)) if isRunning && (search?.answerText.orEmpty.isEmpty ?? true) { HStack(spacing: 8) { @@ -90,7 +90,7 @@ struct SybilSearchResultsView: View { .controlSize(.small) .tint(SybilTheme.textMuted) Text("Generating answer…") - .font(.footnote) + .font(.sybil(.footnote)) .foregroundStyle(SybilTheme.textMuted) } } else if let answer = search?.answerText, !answer.isEmpty { @@ -104,7 +104,7 @@ struct SybilSearchResultsView: View { if let answerError = search?.answerError, !answerError.isEmpty { Text(answerError) - .font(.footnote) + .font(.sybil(.footnote)) .foregroundStyle(SybilTheme.danger) } @@ -115,23 +115,23 @@ struct SybilSearchResultsView: View { Link(destination: URL(string: link) ?? URL(string: "https://example.com")!) { HStack(spacing: 6) { Text("[\(index + 1)]") - .font(.caption2.weight(.semibold)) + .font(.sybil(.caption2, weight: .semibold)) .foregroundStyle(SybilTheme.primary) Text(citation.title.orEmpty.isEmpty ? host(for: link) : citation.title.orEmpty) - .font(.caption) + .font(.sybil(.caption)) .lineLimit(1) .foregroundStyle(SybilTheme.text) } - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background( - Capsule() - .fill(SybilTheme.surface) - .overlay( - Capsule() - .stroke(SybilTheme.border, lineWidth: 1) - ) - ) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + Capsule() + .fill(SybilTheme.primary.opacity(0.10)) + .overlay( + Capsule() + .stroke(SybilTheme.primary.opacity(0.28), lineWidth: 1) + ) + ) } .buttonStyle(.plain) } @@ -142,10 +142,10 @@ struct SybilSearchResultsView: View { .padding(14) .background( RoundedRectangle(cornerRadius: 16) - .fill(SybilTheme.searchCard) + .fill(LinearGradient(colors: [SybilTheme.searchCard.opacity(0.98), SybilTheme.surface.opacity(0.78)], startPoint: .topLeading, endPoint: .bottomTrailing)) .overlay( RoundedRectangle(cornerRadius: 16) - .stroke(SybilTheme.border, lineWidth: 1) + .stroke(SybilTheme.border.opacity(0.88), lineWidth: 1) ) ) .frame(maxWidth: .infinity, alignment: .leading) @@ -171,36 +171,36 @@ private struct SearchResultCard: View { HStack { VStack(alignment: .leading, spacing: 7) { Text(host) - .font(.caption) - .foregroundStyle(SybilTheme.primary.opacity(0.9)) + .font(.sybil(.caption)) + .foregroundStyle(SybilTheme.accent.opacity(0.9)) .lineLimit(1) if let resolvedURL { Link(destination: resolvedURL) { Text(result.title.orEmpty.orFallback(result.url)) - .font(.headline) - .foregroundStyle(SybilTheme.primary) + .font(.sybil(.headline)) + .foregroundStyle(SybilTheme.primary.opacity(0.96)) .multilineTextAlignment(.leading) } .buttonStyle(.plain) } else { Text(result.title.orEmpty.orFallback(result.url)) - .font(.headline) - .foregroundStyle(SybilTheme.primary) + .font(.sybil(.headline)) + .foregroundStyle(SybilTheme.primary.opacity(0.96)) } if let date = result.publishedDate, !date.isEmpty { Text(date + (result.author.orEmpty.isEmpty ? "" : " • \(result.author.orEmpty)")) - .font(.caption) + .font(.sybil(.caption)) .foregroundStyle(SybilTheme.textMuted) } else if let author = result.author, !author.isEmpty { Text(author) - .font(.caption) + .font(.sybil(.caption)) .foregroundStyle(SybilTheme.textMuted) } Text(result.url) - .font(.footnote) + .font(.sybil(.footnote)) .foregroundStyle(SybilTheme.text.opacity(0.92)) .lineLimit(3) .textSelection(.enabled) @@ -208,7 +208,7 @@ private struct SearchResultCard: View { if let highlights = result.highlights, !highlights.isEmpty { ForEach(Array(highlights.prefix(2).enumerated()), id: \.offset) { _, highlight in Text("• \(highlight)") - .font(.caption) + .font(.sybil(.caption)) .foregroundStyle(SybilTheme.textMuted) .fixedSize(horizontal: false, vertical: true) } @@ -220,10 +220,10 @@ private struct SearchResultCard: View { .padding(14) .background( RoundedRectangle(cornerRadius: 14) - .fill(SybilTheme.searchCard.opacity(0.95)) + .fill(LinearGradient(colors: [SybilTheme.searchCard.opacity(0.96), SybilTheme.surface.opacity(0.72)], startPoint: .topLeading, endPoint: .bottomTrailing)) .overlay( RoundedRectangle(cornerRadius: 14) - .stroke(SybilTheme.border, lineWidth: 1) + .stroke(SybilTheme.border.opacity(0.84), lineWidth: 1) ) ) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilSettingsStore.swift b/ios/Packages/Sybil/Sources/Sybil/SybilSettingsStore.swift index cdcc357..86dbf03 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilSettingsStore.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilSettingsStore.swift @@ -24,7 +24,7 @@ final class SybilSettingsStore { self.defaults = defaults let storedBaseURL = defaults.string(forKey: Keys.apiBaseURL)?.trimmingCharacters(in: .whitespacesAndNewlines) - let fallbackBaseURL = "http://127.0.0.1:8787/api" + let fallbackBaseURL = "http://127.0.0.1:8787" self.apiBaseURL = storedBaseURL?.isEmpty == false ? storedBaseURL! : fallbackBaseURL self.adminToken = defaults.string(forKey: Keys.adminToken) ?? "" @@ -64,10 +64,19 @@ final class SybilSettingsStore { var raw = apiBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) guard !raw.isEmpty else { return nil } - if raw.hasSuffix("/") { + while raw.hasSuffix("/") { raw.removeLast() } - return URL(string: raw) + guard var components = URLComponents(string: raw) else { + return nil + } + + let path = components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if path.lowercased() == "api" { + components.path = "" + } + + return components.url } } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilSettingsView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilSettingsView.swift index c90799f..0c07692 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilSettingsView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilSettingsView.swift @@ -11,19 +11,19 @@ struct SybilSettingsView: View { VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 6) { Text("Connection") - .font(.title3.weight(.semibold)) + .font(.sybil(.title3, weight: .semibold)) .foregroundStyle(SybilTheme.text) - Text("Use the same API root as the web client. Example: `http://127.0.0.1:8787/api`") - .font(.footnote) + Text("Use the API server root. Example: `http://127.0.0.1:8787`") + .font(.sybil(.footnote)) .foregroundStyle(SybilTheme.textMuted) } VStack(alignment: .leading, spacing: 8) { Text("API URL") - .font(.caption.weight(.semibold)) + .font(.sybil(.caption, weight: .semibold)) .foregroundStyle(SybilTheme.textMuted) - TextField("http://127.0.0.1:8787/api", text: $settings.apiBaseURL) + TextField("http://127.0.0.1:8787", text: $settings.apiBaseURL) .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.URL) @@ -38,7 +38,7 @@ struct SybilSettingsView: View { ) Text("Admin Token") - .font(.caption.weight(.semibold)) + .font(.sybil(.caption, weight: .semibold)) .foregroundStyle(SybilTheme.textMuted) SecureField("ADMIN_TOKEN (optional in open mode)", text: $settings.adminToken) @@ -82,19 +82,19 @@ struct SybilSettingsView: View { if let mode = viewModel.authMode { Label(mode == "open" ? "Server is in open mode" : "Server requires token", systemImage: "dot.radiowaves.left.and.right") - .font(.footnote) + .font(.sybil(.footnote)) .foregroundStyle(SybilTheme.textMuted) } if let authError = viewModel.authError { Text(authError) - .font(.footnote) + .font(.sybil(.footnote)) .foregroundStyle(SybilTheme.danger) } if let runtimeError = viewModel.errorMessage { Text(runtimeError) - .font(.footnote) + .font(.sybil(.footnote)) .foregroundStyle(SybilTheme.danger) } } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift index daaebac..567e435 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilSidebarView.swift @@ -17,27 +17,31 @@ struct SybilSidebarView: View { var body: some View { VStack(spacing: 0) { - HStack(spacing: 10) { - Button { - viewModel.startNewChat() - } label: { - Label("New chat", systemImage: "plus") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .tint(viewModel.draftKind == .chat ? SybilTheme.primary : SybilTheme.primarySoft) + VStack(alignment: .leading, spacing: 14) { + SybilWordmark(size: 31) - Button { - viewModel.startNewSearch() - } label: { - Label("New search", systemImage: "magnifyingglass") - .frame(maxWidth: .infinity) + VStack(spacing: 10) { + sidebarActionButton( + title: "New chat", + systemImage: "plus", + isPrimary: true, + isActive: viewModel.draftKind == .chat + ) { + viewModel.startNewChat() + } + + sidebarActionButton( + title: "New search", + systemImage: "magnifyingglass", + isPrimary: false, + isActive: viewModel.draftKind == .search + ) { + viewModel.startNewSearch() + } } - .buttonStyle(.bordered) - .tint(viewModel.draftKind == .search ? SybilTheme.primary : SybilTheme.textMuted) } .padding(.horizontal, 12) - .padding(.top, 12) + .padding(.top, 18) .padding(.bottom, 10) Divider() @@ -45,7 +49,7 @@ struct SybilSidebarView: View { if let errorMessage = viewModel.errorMessage { Text(errorMessage) - .font(.footnote) + .font(.sybil(.footnote)) .foregroundStyle(SybilTheme.danger) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 12) @@ -60,7 +64,7 @@ struct SybilSidebarView: View { ProgressView() .tint(SybilTheme.primary) Text("Loading conversations…") - .font(.footnote) + .font(.sybil(.footnote)) .foregroundStyle(SybilTheme.textMuted) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) @@ -68,10 +72,10 @@ struct SybilSidebarView: View { } else if viewModel.sidebarItems.isEmpty { VStack(spacing: 10) { Image(systemName: "message.badge") - .font(.title3) + .font(.system(size: 20, weight: .medium)) .foregroundStyle(SybilTheme.textMuted) Text("Start a chat or run your first search.") - .font(.footnote) + .font(.sybil(.footnote)) .multilineTextAlignment(.center) .foregroundStyle(SybilTheme.textMuted) } @@ -87,24 +91,36 @@ struct SybilSidebarView: View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { Image(systemName: iconName(for: item)) - .font(.caption.weight(.semibold)) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(isSelected(item) ? SybilTheme.accent : SybilTheme.textMuted) + .frame(width: 22, height: 22) + .background( + RoundedRectangle(cornerRadius: 7) + .fill(isSelected(item) ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72)) + .overlay( + RoundedRectangle(cornerRadius: 7) + .stroke(isSelected(item) ? SybilTheme.accent.opacity(0.36) : SybilTheme.border.opacity(0.72), lineWidth: 1) + ) + ) Text(item.title) - .font(.subheadline.weight(.medium)) + .font(.sybil(.subheadline, weight: .semibold)) .lineLimit(1) } HStack(spacing: 8) { Text(item.updatedAt.sybilRelativeLabel) - .font(.caption2) + .font(.sybil(.caption2)) .foregroundStyle(SybilTheme.textMuted) if let initiated = item.initiatedLabel { Spacer(minLength: 0) Text(initiated) - .font(.caption2) + .font(.sybil(.caption2)) .foregroundStyle(SybilTheme.textMuted.opacity(0.88)) .lineLimit(1) + .multilineTextAlignment(.trailing) + .frame(maxWidth: .infinity, alignment: .trailing) } } } @@ -114,7 +130,7 @@ struct SybilSidebarView: View { .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 12) - .fill(isSelected(item) ? SybilTheme.primary.opacity(0.28) : SybilTheme.surface.opacity(0.4)) + .fill(isSelected(item) ? SybilTheme.selectedRowGradient : LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing)) ) .overlay( RoundedRectangle(cornerRadius: 12) @@ -144,7 +160,7 @@ struct SybilSidebarView: View { viewModel.openSettings() } label: { Label("Settings", systemImage: "gearshape") - .font(.subheadline.weight(.medium)) + .font(.sybil(.subheadline, weight: .medium)) .foregroundStyle(SybilTheme.text) .padding(.horizontal, 12) .padding(.vertical, 10) @@ -157,6 +173,32 @@ struct SybilSidebarView: View { .buttonStyle(.plain) .padding(10) } - .background(SybilTheme.surfaceStrong.opacity(0.84)) + .background(SybilTheme.panelGradient) + } + + private func sidebarActionButton( + title: String, + systemImage: String, + isPrimary: Bool, + isActive: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Label(title, systemImage: systemImage) + .font(.sybil(.subheadline, weight: .semibold)) + .foregroundStyle(isPrimary ? SybilTheme.text : SybilTheme.text.opacity(0.92)) + .padding(.horizontal, 13) + .padding(.vertical, 11) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 11) + .fill(isPrimary ? SybilTheme.primaryGradient : LinearGradient(colors: [SybilTheme.surface.opacity(0.86), SybilTheme.surface.opacity(0.62)], startPoint: .topLeading, endPoint: .bottomTrailing)) + ) + .overlay( + RoundedRectangle(cornerRadius: 11) + .stroke(isActive ? SybilTheme.primary.opacity(0.70) : SybilTheme.border.opacity(isPrimary ? 0.28 : 0.72), lineWidth: 1) + ) + } + .buttonStyle(.plain) } } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilTheme.swift b/ios/Packages/Sybil/Sources/Sybil/SybilTheme.swift index 0bae438..8d8fbc1 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilTheme.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilTheme.swift @@ -1,28 +1,194 @@ +import CoreText +import Foundation import SwiftUI +enum SybilFontRegistry { + static func registerIfNeeded() { + _ = registeredFonts + } + + private static let registeredFonts: Void = { + for fontName in ["Inter", "Orbitron"] { + guard let url = Bundle.main.url(forResource: fontName, withExtension: "ttf", subdirectory: "Fonts") ?? + Bundle.main.url(forResource: fontName, withExtension: "ttf") + else { + continue + } + + CTFontManagerRegisterFontsForURL(url as CFURL, .process, nil) + } + }() +} + +extension Font { + static func sybil(_ textStyle: Font.TextStyle, weight: Font.Weight = .regular) -> Font { + SybilFontRegistry.registerIfNeeded() + return .custom("Inter", size: Self.sybilPointSize(for: textStyle), relativeTo: textStyle) + .weight(weight) + } + + static func sybil(size: CGFloat, weight: Font.Weight = .regular) -> Font { + SybilFontRegistry.registerIfNeeded() + return .custom("Inter", size: size) + .weight(weight) + } + + private static func sybilPointSize(for textStyle: Font.TextStyle) -> CGFloat { + switch textStyle { + case .largeTitle: + return 34 + case .title: + return 28 + case .title2: + return 22 + case .title3: + return 20 + case .headline: + return 17 + case .subheadline: + return 15 + case .callout: + return 16 + case .caption: + return 12 + case .caption2: + return 11 + case .footnote: + return 13 + case .body: + return 17 + @unknown default: + return 17 + } + } +} + enum SybilTheme { - static let background = Color(red: 0.07, green: 0.05, blue: 0.11) - static let surface = Color(red: 0.14, green: 0.09, blue: 0.19) - static let surfaceStrong = Color(red: 0.17, green: 0.10, blue: 0.23) - static let card = Color(red: 0.12, green: 0.08, blue: 0.17) - static let border = Color(red: 0.30, green: 0.22, blue: 0.39) - static let primary = Color(red: 0.66, green: 0.47, blue: 0.94) - static let primarySoft = Color(red: 0.53, green: 0.37, blue: 0.78) - static let text = Color(red: 0.95, green: 0.91, blue: 0.98) - static let textMuted = Color(red: 0.72, green: 0.65, blue: 0.80) - static let searchCard = Color(red: 0.16, green: 0.10, blue: 0.22) - static let userBubble = Color(red: 0.35, green: 0.20, blue: 0.62) - static let danger = Color(red: 0.93, green: 0.38, blue: 0.42) + static let background = Color(red: 0.02, green: 0.02, blue: 0.05) + static let surface = Color(red: 0.05, green: 0.04, blue: 0.10) + static let surfaceStrong = Color(red: 0.07, green: 0.06, blue: 0.14) + static let card = Color(red: 0.06, green: 0.05, blue: 0.12) + static let border = Color(red: 0.24, green: 0.20, blue: 0.38) + static let primary = Color(red: 0.57, green: 0.38, blue: 0.96) + static let primarySoft = Color(red: 0.43, green: 0.25, blue: 0.76) + static let accent = Color(red: 0.31, green: 0.88, blue: 0.95) + static let text = Color(red: 0.96, green: 0.94, blue: 1.0) + static let textMuted = Color(red: 0.65, green: 0.62, blue: 0.76) + static let searchCard = Color(red: 0.07, green: 0.06, blue: 0.14) + static let userBubble = Color(red: 0.29, green: 0.13, blue: 0.65) + static let danger = Color(red: 0.96, green: 0.32, blue: 0.40) static var backgroundGradient: LinearGradient { LinearGradient( colors: [ - Color(red: 0.27, green: 0.12, blue: 0.36), - Color(red: 0.15, green: 0.09, blue: 0.23), - Color(red: 0.07, green: 0.05, blue: 0.11) + Color(red: 0.12, green: 0.08, blue: 0.28), + Color(red: 0.04, green: 0.03, blue: 0.09), + background ], startPoint: .top, endPoint: .bottom ) } + + static var brandGradient: LinearGradient { + LinearGradient( + colors: [ + Color(red: 1.0, green: 0.55, blue: 0.97), + Color(red: 0.60, green: 0.43, blue: 1.0), + Color(red: 0.40, green: 0.87, blue: 1.0) + ], + startPoint: .leading, + endPoint: .trailing + ) + } + + static var panelGradient: LinearGradient { + LinearGradient( + colors: [ + Color(red: 0.07, green: 0.06, blue: 0.15).opacity(0.94), + Color(red: 0.03, green: 0.03, blue: 0.08).opacity(0.96) + ], + startPoint: .top, + endPoint: .bottom + ) + } + + static var primaryGradient: LinearGradient { + LinearGradient( + colors: [ + Color(red: 0.43, green: 0.27, blue: 0.92), + Color(red: 0.45, green: 0.09, blue: 0.63) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + static var selectedRowGradient: LinearGradient { + LinearGradient( + colors: [ + primary.opacity(0.56), + primarySoft.opacity(0.48) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + static var userBubbleGradient: LinearGradient { + LinearGradient( + colors: [ + Color(red: 0.38, green: 0.16, blue: 0.80), + Color(red: 0.25, green: 0.08, blue: 0.55) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + static var composerGradient: LinearGradient { + LinearGradient( + colors: [ + Color(red: 0.04, green: 0.04, blue: 0.10).opacity(0.98), + Color(red: 0.09, green: 0.06, blue: 0.16).opacity(0.94) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + static var toolCallGradient: LinearGradient { + LinearGradient( + colors: [ + Color(red: 0.01, green: 0.15, blue: 0.17).opacity(0.70), + Color(red: 0.03, green: 0.09, blue: 0.15).opacity(0.78) + ], + startPoint: .leading, + endPoint: .trailing + ) + } + + static var failedToolCallGradient: LinearGradient { + LinearGradient( + colors: [ + danger.opacity(0.18), + Color(red: 0.15, green: 0.03, blue: 0.07).opacity(0.72) + ], + startPoint: .leading, + endPoint: .trailing + ) + } +} + +struct SybilWordmark: View { + var size: CGFloat = 30 + + var body: some View { + Text("SYBIL") + .font(.custom("Orbitron", size: size)) + .fontWeight(.black) + .tracking(0) + .foregroundStyle(SybilTheme.brandGradient) + .accessibilityLabel("Sybil") + } } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift index dd41827..e0c5fda 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift @@ -111,11 +111,15 @@ final class SybilViewModel { } var providerModelOptions: [String] { - let serverModels = modelCatalog[provider]?.models ?? [] + modelOptions(for: provider) + } + + func modelOptions(for candidate: Provider) -> [String] { + let serverModels = modelCatalog[candidate]?.models ?? [] if !serverModels.isEmpty { return serverModels } - return fallbackModels[provider] ?? [] + return fallbackModels[candidate] ?? [] } var selectedTitle: String { @@ -222,7 +226,7 @@ final class SybilViewModel { let initiatedLabel: String? if let model = chat.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty { if let provider = chat.initiatedProvider { - initiatedLabel = "\(provider.displayName) · \(model)" + initiatedLabel = "\(provider.displayName) • \(model)" } else { initiatedLabel = model } @@ -336,6 +340,15 @@ final class SybilViewModel { SybilLog.info(SybilLog.ui, "Model changed to \(nextModel)") } + func setProvider(_ nextProvider: Provider, model nextModel: String) { + provider = nextProvider + model = nextModel + settings.preferredProvider = nextProvider + settings.preferredModelByProvider[nextProvider] = nextModel + settings.persist() + SybilLog.info(SybilLog.ui, "Provider changed to \(nextProvider.rawValue), model=\(nextModel)") + } + func startNewChat() { SybilLog.debug(SybilLog.ui, "Starting draft chat") draftKind = .chat diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift index bae728f..e792065 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift @@ -3,8 +3,13 @@ import SwiftUI struct SybilWorkspaceView: View { @Bindable var viewModel: SybilViewModel + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @FocusState private var composerFocused: Bool + private var isCompact: Bool { + horizontalSizeClass == .compact + } + private var isSettingsSelected: Bool { if case .settings = viewModel.selectedItem { return true @@ -12,12 +17,18 @@ struct SybilWorkspaceView: View { return false } + private var showsHeader: Bool { + !isCompact || viewModel.errorMessage != nil + } + var body: some View { VStack(spacing: 0) { - header + if showsHeader { + header - Divider() - .overlay(SybilTheme.border) + Divider() + .overlay(SybilTheme.border) + } Group { if isSettingsSelected { @@ -44,7 +55,23 @@ struct SybilWorkspaceView: View { composerBar } } - .navigationTitle(viewModel.selectedTitle) + .navigationTitle(isCompact ? "" : viewModel.selectedTitle) + .toolbar { + if isCompact { + ToolbarItem(placement: .principal) { + Text(viewModel.selectedTitle) + .font(.sybil(.headline, weight: .semibold)) + .foregroundStyle(SybilTheme.text) + .lineLimit(1) + } + } + + if isCompact && !viewModel.isSearchMode && !isSettingsSelected { + ToolbarItem(placement: .topBarTrailing) { + compactProviderModelMenu + } + } + } .background(SybilTheme.background) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .onChange(of: viewModel.isSending) { _, isSending in @@ -56,37 +83,84 @@ struct SybilWorkspaceView: View { private var header: some View { VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .top, spacing: 12) { - Spacer() + if !isCompact { + HStack(alignment: .top, spacing: 12) { + Spacer() - if !viewModel.isSearchMode && !isSettingsSelected { - providerControls - } else if viewModel.isSearchMode { - Label("Search mode", systemImage: "globe") - .font(.caption.weight(.medium)) - .foregroundStyle(SybilTheme.textMuted) - .padding(.horizontal, 10) - .padding(.vertical, 7) - .background( - Capsule() - .fill(SybilTheme.surface) - .overlay( - Capsule() - .stroke(SybilTheme.border, lineWidth: 1) - ) - ) + if !viewModel.isSearchMode && !isSettingsSelected { + providerControls + } else if viewModel.isSearchMode { + Label("Search mode", systemImage: "globe") + .font(.sybil(.caption, weight: .medium)) + .foregroundStyle(SybilTheme.accent) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(SybilTheme.accent.opacity(0.10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(SybilTheme.accent.opacity(0.24), lineWidth: 1) + ) + ) + } } } if let error = viewModel.errorMessage { Text(error) - .font(.footnote) + .font(.sybil(.footnote)) .foregroundStyle(SybilTheme.danger) .frame(maxWidth: .infinity, alignment: .leading) } } .padding(.horizontal, 16) .padding(.vertical, 12) + .background(SybilTheme.panelGradient.opacity(0.58)) + } + + private var compactProviderModelMenu: some View { + Menu { + Text("\(viewModel.provider.displayName) • \(viewModel.model)") + .font(.sybil(.caption)) + + Divider() + + ForEach(Provider.allCases, id: \.self) { candidate in + Menu(candidate.displayName) { + let models = viewModel.modelOptions(for: candidate) + if models.isEmpty { + Text("No models") + } else { + ForEach(models, id: \.self) { candidateModel in + Button { + viewModel.setProvider(candidate, model: candidateModel) + } label: { + if viewModel.provider == candidate && viewModel.model == candidateModel { + Label(candidateModel, systemImage: "checkmark") + } else { + Text(candidateModel) + } + } + } + } + } + } + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(SybilTheme.text) + .frame(width: 34, height: 34) + .background( + Circle() + .fill(SybilTheme.surface.opacity(0.78)) + ) + .overlay( + Circle() + .stroke(SybilTheme.border.opacity(0.82), lineWidth: 1) + ) + } + .accessibilityLabel("Provider and model") } private var providerControls: some View { @@ -100,16 +174,16 @@ struct SybilWorkspaceView: View { } label: { Label(viewModel.provider.displayName, systemImage: "chevron.down") .labelStyle(.titleAndIcon) - .font(.caption.weight(.medium)) + .font(.sybil(.caption, weight: .medium)) .foregroundStyle(SybilTheme.text) .padding(.horizontal, 10) .padding(.vertical, 7) .background( RoundedRectangle(cornerRadius: 10) - .fill(SybilTheme.surface) + .fill(SybilTheme.surface.opacity(0.78)) .overlay( RoundedRectangle(cornerRadius: 10) - .stroke(SybilTheme.border, lineWidth: 1) + .stroke(SybilTheme.border.opacity(0.88), lineWidth: 1) ) ) } @@ -123,17 +197,17 @@ struct SybilWorkspaceView: View { } label: { Label(viewModel.model, systemImage: "chevron.down") .labelStyle(.titleAndIcon) - .font(.caption.weight(.medium)) + .font(.sybil(.caption, weight: .medium)) .foregroundStyle(SybilTheme.text) .lineLimit(1) .padding(.horizontal, 10) .padding(.vertical, 7) .background( RoundedRectangle(cornerRadius: 10) - .fill(SybilTheme.surface) + .fill(SybilTheme.surface.opacity(0.78)) .overlay( RoundedRectangle(cornerRadius: 10) - .stroke(SybilTheme.border, lineWidth: 1) + .stroke(SybilTheme.border.opacity(0.88), lineWidth: 1) ) ) } @@ -161,10 +235,10 @@ struct SybilWorkspaceView: View { .padding(.vertical, 10) .background( RoundedRectangle(cornerRadius: 12) - .fill(SybilTheme.surface) + .fill(SybilTheme.composerGradient) .overlay( RoundedRectangle(cornerRadius: 12) - .stroke(SybilTheme.border, lineWidth: 1) + .stroke(SybilTheme.primary.opacity(0.34), lineWidth: 1) ) ) .foregroundStyle(SybilTheme.text) @@ -175,11 +249,15 @@ struct SybilWorkspaceView: View { } } label: { Image(systemName: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up") - .font(.headline.weight(.semibold)) + .font(.system(size: 17, weight: .semibold)) .frame(width: 40, height: 40) .background( Circle() - .fill(viewModel.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending ? SybilTheme.surface : SybilTheme.primary) + .fill( + viewModel.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending + ? AnyShapeStyle(SybilTheme.surface) + : AnyShapeStyle(SybilTheme.primaryGradient) + ) ) .foregroundStyle(viewModel.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending ? SybilTheme.textMuted : SybilTheme.text) } @@ -188,6 +266,15 @@ struct SybilWorkspaceView: View { } .padding(.horizontal, 14) .padding(.vertical, 12) - .background(SybilTheme.background) + .background( + LinearGradient( + colors: [ + SybilTheme.background.opacity(0.18), + SybilTheme.background.opacity(0.96) + ], + startPoint: .top, + endPoint: .bottom + ) + ) } }