8 Commits

18 changed files with 858 additions and 272 deletions

View File

@@ -1,5 +1,8 @@
update_settings(max_parallel_updates=4) update_settings(max_parallel_updates=4)
load('ext://dotenv', 'dotenv')
dotenv() # defaults to .env in the current directory
local_resource( local_resource(
"server", "server",
cmd="npm ci --no-audit --no-fund", cmd="npm ci --no-audit --no-fund",

View File

@@ -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. - If backend contract changes (request/response shapes, SSE events, auth semantics), update docs in the same change.
## Practical Notes ## 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: - Provider fallback models:
- OpenAI: `gpt-4.1-mini` - OpenAI: `gpt-4.1-mini`
- Anthropic: `claude-3-5-sonnet-latest` - Anthropic: `claude-3-5-sonnet-latest`

Binary file not shown.

Binary file not shown.

View File

@@ -4,7 +4,10 @@ public struct SplitView: View {
@State private var viewModel = SybilViewModel() @State private var viewModel = SybilViewModel()
@Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.horizontalSizeClass) private var horizontalSizeClass
public init() {} @MainActor public init() {
SybilFontRegistry.registerIfNeeded()
SybilTheme.applySystemAppearance()
}
public var body: some View { public var body: some View {
ZStack { ZStack {
@@ -23,7 +26,6 @@ public struct SplitView: View {
} else { } else {
NavigationSplitView { NavigationSplitView {
SybilSidebarView(viewModel: viewModel) SybilSidebarView(viewModel: viewModel)
.navigationTitle("Sybil")
} detail: { } detail: {
SybilWorkspaceView(viewModel: viewModel) SybilWorkspaceView(viewModel: viewModel)
} }
@@ -31,6 +33,8 @@ public struct SplitView: View {
.tint(SybilTheme.primary) .tint(SybilTheme.primary)
} }
} }
.font(.sybil(.body))
.preferredColorScheme(.dark)
.task { .task {
await viewModel.bootstrap() await viewModel.bootstrap()
} }

View File

@@ -15,16 +15,17 @@ struct SybilChatTranscriptView: View {
var body: some View { var body: some View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
ScrollView { ScrollView {
LazyVStack(alignment: .leading, spacing: 24) { LazyVStack(alignment: .leading, spacing: 26) {
if isLoading && messages.isEmpty { if isLoading && messages.isEmpty {
Text("Loading messages…") Text("Loading messages…")
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
.padding(.top, 24) .padding(.top, 24)
} }
ForEach(messages) { message in ForEach(messages) { message in
MessageBubble(message: message, isSending: isSending) MessageBubble(message: message, isSending: isSending)
.frame(maxWidth: .infinity)
.id(message.id) .id(message.id)
} }
@@ -34,7 +35,7 @@ struct SybilChatTranscriptView: View {
.controlSize(.small) .controlSize(.small)
.tint(SybilTheme.textMuted) .tint(SybilTheme.textMuted)
Text("Assistant is typing…") Text("Assistant is typing…")
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
} }
.id("typing-indicator") .id("typing-indicator")
@@ -86,11 +87,14 @@ private struct MessageBubble: View {
} }
var body: some View { var body: some View {
HStack { HStack(alignment: .top, spacing: 0) {
leadingSpacer
if let toolCallMetadata { if let toolCallMetadata {
ToolCallActivityChip( ToolCallActivityChip(
metadata: toolCallMetadata, metadata: toolCallMetadata,
fallbackContent: message.content fallbackContent: message.content,
createdAt: message.createdAt
) )
} else { } else {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
@@ -98,9 +102,9 @@ private struct MessageBubble: View {
HStack(spacing: 8) { HStack(spacing: 8) {
ProgressView() ProgressView()
.controlSize(.small) .controlSize(.small)
.tint(SybilTheme.textMuted) .tint(SybilTheme.primary)
Text("Thinking…") Text("Thinking…")
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
} }
.padding(.vertical, 2) .padding(.vertical, 2)
@@ -108,18 +112,16 @@ private struct MessageBubble: View {
Markdown(message.content) Markdown(message.content)
.tint(SybilTheme.primary) .tint(SybilTheme.primary)
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95)) .foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95))
.markdownTextStyle { .markdownTheme(.sybilReadable)
FontSize(15)
}
} }
} }
.padding(.horizontal, isUser ? 14 : 2) .padding(.horizontal, isUser ? 14 : 2)
.padding(.vertical, isUser ? 11 : 2) .padding(.vertical, isUser ? 13 : 2)
.background( .background(
Group { Group {
if isUser { if isUser {
RoundedRectangle(cornerRadius: 16) RoundedRectangle(cornerRadius: 16)
.fill(SybilTheme.userBubble.opacity(0.86)) .fill(SybilTheme.userBubbleGradient)
.overlay( .overlay(
RoundedRectangle(cornerRadius: 16) RoundedRectangle(cornerRadius: 16)
.stroke(SybilTheme.primary.opacity(0.45), lineWidth: 1) .stroke(SybilTheme.primary.opacity(0.45), lineWidth: 1)
@@ -132,6 +134,23 @@ private struct MessageBubble: View {
) )
.frame(maxWidth: isUser ? 420 : nil, alignment: isUser ? .trailing : .leading) .frame(maxWidth: isUser ? 420 : nil, alignment: isUser ? .trailing : .leading)
} }
trailingSpacer
}
.frame(maxWidth: .infinity, alignment: isUser ? .trailing : .leading)
}
@ViewBuilder
private var leadingSpacer: some View {
if isUser {
Spacer(minLength: 44)
}
}
@ViewBuilder
private var trailingSpacer: some View {
if !isUser {
Spacer(minLength: 0)
} }
} }
} }
@@ -139,17 +158,38 @@ private struct MessageBubble: View {
private struct ToolCallActivityChip: View { private struct ToolCallActivityChip: View {
var metadata: ToolCallMetadata var metadata: ToolCallMetadata
var fallbackContent: String var fallbackContent: String
var createdAt: Date
private var summary: String { private var summary: String {
if let text = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty { if let text = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
return text 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 { if !fallbackContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return fallbackContent return fallbackContent
} }
return "Ran tool '\(metadata.toolName ?? "unknown_tool")'." 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 { private var iconName: String {
let name = (metadata.toolName ?? "").lowercased() let name = (metadata.toolName ?? "").lowercased()
if name.contains("search") { if name.contains("search") {
@@ -165,26 +205,60 @@ private struct ToolCallActivityChip: View {
(metadata.status ?? "").lowercased() == "failed" (metadata.status ?? "").lowercased() == "failed"
} }
var body: some View { private var detailLabel: String {
HStack(spacing: 8) { var pieces: [String] = [isFailed ? "Failed" : "Completed"]
Image(systemName: iconName) if let durationMs = metadata.durationMs, durationMs > 0 {
.font(.system(size: 12, weight: .semibold)) pieces.append("\(durationMs) ms")
.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)
} }
.padding(.horizontal, 10) pieces.append(createdAt.sybilShortTimeLabel)
.padding(.vertical, 7) 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))
.lineSpacing(3)
.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( .background(
RoundedRectangle(cornerRadius: 10) RoundedRectangle(cornerRadius: 12)
.fill((isFailed ? SybilTheme.danger : SybilTheme.primary).opacity(0.12)) .fill(isFailed ? SybilTheme.failedToolCallGradient : SybilTheme.toolCallGradient)
.overlay( .overlay(
RoundedRectangle(cornerRadius: 10) RoundedRectangle(cornerRadius: 12)
.stroke((isFailed ? SybilTheme.danger : SybilTheme.primary).opacity(0.35), lineWidth: 1) .stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1)
) )
) )
.frame(maxWidth: 460, alignment: .leading) .frame(maxWidth: 520, alignment: .leading)
} }
} }

View File

@@ -8,23 +8,32 @@ struct SybilConnectionView: View {
@Bindable var settings = viewModel.settings @Bindable var settings = viewModel.settings
VStack(spacing: 20) { VStack(spacing: 20) {
HStack {
SybilWordmark(size: 34)
Spacer()
}
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
Image(systemName: "shield.lefthalf.filled") Image(systemName: "shield.lefthalf.filled")
.font(.title3.weight(.semibold)) .font(.system(size: 20, weight: .semibold))
.foregroundStyle(SybilTheme.primary) .foregroundStyle(SybilTheme.accent)
.frame(width: 34, height: 34) .frame(width: 34, height: 34)
.background( .background(
RoundedRectangle(cornerRadius: 10) 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) { VStack(alignment: .leading, spacing: 4) {
Text("Connect to Sybil") Text("Connect to Sybil")
.font(.title3.weight(.semibold)) .font(.sybil(.title3, weight: .semibold))
.foregroundStyle(SybilTheme.text) .foregroundStyle(SybilTheme.text)
Text("Point the app at your backend and sign in with ADMIN_TOKEN if token mode is enabled.") 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) .foregroundStyle(SybilTheme.textMuted)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
@@ -32,25 +41,25 @@ struct SybilConnectionView: View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text("API URL") Text("API URL")
.font(.caption.weight(.semibold)) .font(.sybil(.caption, weight: .semibold))
.foregroundStyle(SybilTheme.textMuted) .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) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
.keyboardType(.URL) .keyboardType(.URL)
.padding(12) .padding(12)
.background( .background(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.surface) .fill(SybilTheme.surface.opacity(0.78))
) )
.overlay( .overlay(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.stroke(SybilTheme.border, lineWidth: 1) .stroke(SybilTheme.border.opacity(0.88), lineWidth: 1)
) )
Text("Admin Token") Text("Admin Token")
.font(.caption.weight(.semibold)) .font(.sybil(.caption, weight: .semibold))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
SecureField("ADMIN_TOKEN (optional in open mode)", text: $settings.adminToken) SecureField("ADMIN_TOKEN (optional in open mode)", text: $settings.adminToken)
@@ -59,11 +68,11 @@ struct SybilConnectionView: View {
.padding(12) .padding(12)
.background( .background(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.surface) .fill(SybilTheme.surface.opacity(0.78))
) )
.overlay( .overlay(
RoundedRectangle(cornerRadius: 12) 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 { if let authError = viewModel.authError {
Text(authError) Text(authError)
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.danger) .foregroundStyle(SybilTheme.danger)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
@@ -103,10 +112,10 @@ struct SybilConnectionView: View {
.frame(maxWidth: 520) .frame(maxWidth: 520)
.background( .background(
RoundedRectangle(cornerRadius: 20) RoundedRectangle(cornerRadius: 20)
.fill(SybilTheme.card) .fill(SybilTheme.panelGradient)
.overlay( .overlay(
RoundedRectangle(cornerRadius: 20) RoundedRectangle(cornerRadius: 20)
.stroke(SybilTheme.border, lineWidth: 1) .stroke(SybilTheme.border.opacity(0.9), lineWidth: 1)
) )
) )
} }

View File

@@ -0,0 +1,165 @@
import MarkdownUI
import SwiftUI
@MainActor
extension Theme {
static let sybilReadable: Theme = {
SybilFontRegistry.registerIfNeeded()
return Theme()
.text {
FontFamily(.custom("Inter"))
FontSize(15)
ForegroundColor(SybilTheme.text)
}
.code {
FontFamilyVariant(.monospaced)
FontSize(.em(0.88))
BackgroundColor(SybilTheme.surfaceStrong.opacity(0.78))
}
.strong {
FontWeight(.semibold)
}
.link {
ForegroundColor(SybilTheme.accent)
}
.heading1 { configuration in
configuration.label
.fixedSize(horizontal: false, vertical: true)
.relativeLineSpacing(.em(0.18))
.markdownMargin(top: .em(1.1), bottom: .em(0.5))
.markdownTextStyle {
FontWeight(.semibold)
FontSize(.em(1.45))
}
}
.heading2 { configuration in
configuration.label
.fixedSize(horizontal: false, vertical: true)
.relativeLineSpacing(.em(0.18))
.markdownMargin(top: .em(1.0), bottom: .em(0.45))
.markdownTextStyle {
FontWeight(.semibold)
FontSize(.em(1.25))
}
}
.heading3 { configuration in
configuration.label
.fixedSize(horizontal: false, vertical: true)
.relativeLineSpacing(.em(0.18))
.markdownMargin(top: .em(0.9), bottom: .em(0.4))
.markdownTextStyle {
FontWeight(.semibold)
FontSize(.em(1.12))
}
}
.heading4 { configuration in
configuration.label
.fixedSize(horizontal: false, vertical: true)
.relativeLineSpacing(.em(0.18))
.markdownMargin(top: .em(0.85), bottom: .em(0.35))
.markdownTextStyle {
FontWeight(.semibold)
}
}
.heading5 { configuration in
configuration.label
.fixedSize(horizontal: false, vertical: true)
.relativeLineSpacing(.em(0.18))
.markdownMargin(top: .em(0.8), bottom: .em(0.35))
.markdownTextStyle {
FontWeight(.semibold)
FontSize(.em(0.92))
}
}
.heading6 { configuration in
configuration.label
.fixedSize(horizontal: false, vertical: true)
.relativeLineSpacing(.em(0.18))
.markdownMargin(top: .em(0.8), bottom: .em(0.35))
.markdownTextStyle {
FontWeight(.semibold)
FontSize(.em(0.86))
}
}
.paragraph { configuration in
configuration.label
.fixedSize(horizontal: false, vertical: true)
.relativeLineSpacing(.em(0.36))
.markdownMargin(top: .zero, bottom: .em(0.82))
}
.blockquote { configuration in
HStack(alignment: .top, spacing: 10) {
RoundedRectangle(cornerRadius: 2)
.fill(SybilTheme.primary.opacity(0.55))
.frame(width: 3)
configuration.label
.markdownTextStyle {
ForegroundColor(SybilTheme.textMuted)
}
}
.fixedSize(horizontal: false, vertical: true)
.markdownMargin(top: .em(0.25), bottom: .em(0.9))
}
.codeBlock { configuration in
ScrollView(.horizontal) {
configuration.label
.fixedSize(horizontal: false, vertical: true)
.relativeLineSpacing(.em(0.28))
.markdownTextStyle {
FontFamilyVariant(.monospaced)
FontSize(.em(0.88))
}
.padding(12)
}
.background(
RoundedRectangle(cornerRadius: 10)
.fill(SybilTheme.surfaceStrong.opacity(0.82))
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(SybilTheme.border.opacity(0.72), lineWidth: 1)
)
.markdownMargin(top: .em(0.2), bottom: .em(1))
}
.list { configuration in
configuration.label
.fixedSize(horizontal: false, vertical: true)
.markdownMargin(top: .em(0.08), bottom: .em(0.78))
}
.listItem { configuration in
configuration.label
.markdownMargin(top: .em(0.28))
}
.table { configuration in
configuration.label
.fixedSize(horizontal: false, vertical: true)
.markdownTableBorderStyle(.init(color: SybilTheme.border.opacity(0.85)))
.markdownTableBackgroundStyle(
.alternatingRows(
SybilTheme.surface.opacity(0.72),
SybilTheme.surfaceStrong.opacity(0.62)
)
)
.markdownMargin(top: .em(0.2), bottom: .em(1))
}
.tableCell { configuration in
configuration.label
.markdownTextStyle {
if configuration.row == 0 {
FontWeight(.semibold)
}
BackgroundColor(nil)
}
.fixedSize(horizontal: false, vertical: true)
.relativeLineSpacing(.em(0.30))
.padding(.vertical, 7)
.padding(.horizontal, 11)
}
.thematicBreak {
Divider()
.overlay(SybilTheme.border.opacity(0.82))
.markdownMargin(top: .em(1.2), bottom: .em(1.2))
}
}()
}

View File

@@ -58,7 +58,10 @@ public struct Message: Codable, Identifiable, Hashable, Sendable {
toolCallId: object["toolCallId"]?.stringValue, toolCallId: object["toolCallId"]?.stringValue,
toolName: object["toolName"]?.stringValue ?? name, toolName: object["toolName"]?.stringValue ?? name,
status: object["status"]?.stringValue, 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 toolName: String?
public var status: String? public var status: String?
public var summary: String? public var summary: String?
public var durationMs: Int?
public var error: String?
public var resultPreview: String?
} }
public enum JSONValue: Codable, Hashable, Sendable { public enum JSONValue: Codable, Hashable, Sendable {
@@ -136,6 +142,13 @@ public enum JSONValue: Codable, Hashable, Sendable {
return nil return nil
} }
public var numberValue: Double? {
if case let .number(value) = self {
return value
}
return nil
}
public var objectValue: [String: JSONValue]? { public var objectValue: [String: JSONValue]? {
if case let .object(value) = self { if case let .object(value) = self {
return value return value
@@ -412,6 +425,14 @@ extension DateFormatter {
formatter.timeStyle = .short formatter.timeStyle = .short
return formatter return formatter
}() }()
static let sybilShortTime: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = .autoupdatingCurrent
formatter.dateStyle = .none
formatter.timeStyle = .short
return formatter
}()
} }
extension Date { extension Date {
@@ -421,4 +442,8 @@ extension Date {
formatter.unitsStyle = .abbreviated formatter.unitsStyle = .abbreviated
return formatter.localizedString(for: self, relativeTo: Date()) return formatter.localizedString(for: self, relativeTo: Date())
} }
var sybilShortTimeLabel: String {
DateFormatter.sybilShortTime.string(from: self)
}
} }

View File

@@ -27,8 +27,13 @@ struct SybilPhoneShellView: View {
var body: some View { var body: some View {
NavigationStack(path: $path) { NavigationStack(path: $path) {
SybilPhoneSidebarRoot(viewModel: viewModel, path: $path) SybilPhoneSidebarRoot(viewModel: viewModel, path: $path)
.navigationTitle("Sybil") .navigationTitle("")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
SybilWordmark(size: 18)
}
}
.navigationDestination(for: PhoneRoute.self) { route in .navigationDestination(for: PhoneRoute.self) { route in
SybilPhoneDestinationView(viewModel: viewModel, route: route) SybilPhoneDestinationView(viewModel: viewModel, route: route)
} }
@@ -43,14 +48,9 @@ private struct SybilPhoneSidebarRoot: View {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
buttonRow
Divider()
.overlay(SybilTheme.border)
if let errorMessage = viewModel.errorMessage { if let errorMessage = viewModel.errorMessage {
Text(errorMessage) Text(errorMessage)
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.danger) .foregroundStyle(SybilTheme.danger)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12) .padding(.horizontal, 12)
@@ -65,7 +65,7 @@ private struct SybilPhoneSidebarRoot: View {
ProgressView() ProgressView()
.tint(SybilTheme.primary) .tint(SybilTheme.primary)
Text("Loading conversations…") Text("Loading conversations…")
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
@@ -73,10 +73,10 @@ private struct SybilPhoneSidebarRoot: View {
} else if viewModel.sidebarItems.isEmpty { } else if viewModel.sidebarItems.isEmpty {
VStack(spacing: 10) { VStack(spacing: 10) {
Image(systemName: "message.badge") Image(systemName: "message.badge")
.font(.title3) .font(.system(size: 20, weight: .medium))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
Text("Start a chat or run your first search.") Text("Start a chat or run your first search.")
.font(.footnote) .font(.sybil(.footnote))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
} }
@@ -104,48 +104,67 @@ private struct SybilPhoneSidebarRoot: View {
.padding(10) .padding(10)
} }
} }
}
.background(SybilTheme.panelGradient)
.safeAreaInset(edge: .bottom, spacing: 0) {
bottomToolbar
}
}
private var bottomToolbar: some View {
VStack(spacing: 0) {
Divider() Divider()
.overlay(SybilTheme.border) .overlay(SybilTheme.border)
NavigationLink(value: PhoneRoute.settings) { HStack(spacing: 12) {
Label("Settings", systemImage: "gearshape") toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
.font(.subheadline.weight(.medium)) path = [.settings]
.foregroundStyle(SybilTheme.text) }
.padding(.horizontal, 12)
.padding(.vertical, 10) Spacer()
.frame(maxWidth: .infinity, alignment: .leading)
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(.horizontal, 18)
.padding(10) .padding(.vertical, 10)
.background(SybilTheme.panelGradient)
} }
.background(SybilTheme.surfaceStrong.opacity(0.84))
} }
private var buttonRow: some View { private func toolbarIconButton(
HStack(spacing: 10) { systemImage: String,
Button { accessibilityLabel: String,
viewModel.startNewChat() isPrimary: Bool = false,
path.append(.draftChat) action: @escaping () -> Void
} label: { ) -> some View {
Label("New chat", systemImage: "plus") Button(action: action) {
.frame(maxWidth: .infinity) Image(systemName: systemImage)
} .font(.system(size: 18, weight: .semibold))
.buttonStyle(.borderedProminent) .foregroundStyle(isPrimary ? SybilTheme.text : SybilTheme.textMuted)
.tint(SybilTheme.primarySoft) .frame(width: 42, height: 42)
.background(
Button { Circle()
viewModel.startNewSearch() .fill(
path.append(.draftSearch) isPrimary
} label: { ? AnyShapeStyle(SybilTheme.primaryGradient)
Label("New search", systemImage: "magnifyingglass") : AnyShapeStyle(SybilTheme.surface.opacity(0.78))
.frame(maxWidth: .infinity) )
} )
.buttonStyle(.bordered) .overlay(
.tint(SybilTheme.textMuted) Circle()
.stroke(isPrimary ? SybilTheme.primary.opacity(0.42) : SybilTheme.border.opacity(0.76), lineWidth: 1)
)
} }
.padding(.horizontal, 12) .buttonStyle(.plain)
.padding(.vertical, 12) .accessibilityLabel(accessibilityLabel)
} }
} }
@@ -156,24 +175,36 @@ private struct SybilPhoneSidebarRow: View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: item.kind == .chat ? "message" : "globe") 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) Text(item.title)
.font(.subheadline.weight(.medium)) .font(.sybil(.subheadline, weight: .semibold))
.lineLimit(1) .lineLimit(1)
} }
HStack(spacing: 8) { HStack(spacing: 8) {
Text(item.updatedAt.sybilRelativeLabel) Text(item.updatedAt.sybilRelativeLabel)
.font(.caption2) .font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
if let initiated = item.initiatedLabel { if let initiated = item.initiatedLabel {
Spacer(minLength: 0) Spacer(minLength: 0)
Text(initiated) Text(initiated)
.font(.caption2) .font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted.opacity(0.88)) .foregroundStyle(SybilTheme.textMuted.opacity(0.88))
.lineLimit(1) .lineLimit(1)
.multilineTextAlignment(.trailing)
.frame(maxWidth: .infinity, alignment: .trailing)
} }
} }
} }
@@ -183,7 +214,7 @@ private struct SybilPhoneSidebarRow: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background( .background(
RoundedRectangle(cornerRadius: 12) 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( .overlay(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)

View File

@@ -12,15 +12,15 @@ struct SybilSearchResultsView: View {
if let query = search?.query, !query.isEmpty { if let query = search?.query, !query.isEmpty {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Results for") Text("Results for")
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
Text(query) Text(query)
.font(.title3.weight(.semibold)) .font(.sybil(.title3, weight: .semibold))
.foregroundStyle(SybilTheme.text) .foregroundStyle(SybilTheme.text)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
Text(resultCountLabel) Text(resultCountLabel)
.font(.caption) .font(.sybil(.caption))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
} }
} }
@@ -36,14 +36,14 @@ struct SybilSearchResultsView: View {
.controlSize(.small) .controlSize(.small)
.tint(SybilTheme.textMuted) .tint(SybilTheme.textMuted)
Text(isRunning ? "Searching Exa…" : "Loading search…") Text(isRunning ? "Searching Exa…" : "Loading search…")
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
} }
} }
if !isLoading, !isRunning, let search, !search.query.orEmpty.isEmpty, search.results.isEmpty { if !isLoading, !isRunning, let search, !search.query.orEmpty.isEmpty, search.results.isEmpty {
Text("No results found.") Text("No results found.")
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
} }
@@ -55,7 +55,7 @@ struct SybilSearchResultsView: View {
if let error = search?.error, !error.isEmpty { if let error = search?.error, !error.isEmpty {
Text(error) Text(error)
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.danger) .foregroundStyle(SybilTheme.danger)
} }
} }
@@ -80,9 +80,9 @@ struct SybilSearchResultsView: View {
private var answerCard: some View { private var answerCard: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text("Answer") Text("Answer")
.font(.caption.weight(.semibold)) .font(.sybil(.caption, weight: .semibold))
.textCase(.uppercase) .textCase(.uppercase)
.foregroundStyle(SybilTheme.primary) .foregroundStyle(SybilTheme.primary.opacity(0.92))
if isRunning && (search?.answerText.orEmpty.isEmpty ?? true) { if isRunning && (search?.answerText.orEmpty.isEmpty ?? true) {
HStack(spacing: 8) { HStack(spacing: 8) {
@@ -90,21 +90,19 @@ struct SybilSearchResultsView: View {
.controlSize(.small) .controlSize(.small)
.tint(SybilTheme.textMuted) .tint(SybilTheme.textMuted)
Text("Generating answer…") Text("Generating answer…")
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
} }
} else if let answer = search?.answerText, !answer.isEmpty { } else if let answer = search?.answerText, !answer.isEmpty {
Markdown(answer) Markdown(answer)
.tint(SybilTheme.primary) .tint(SybilTheme.primary)
.foregroundStyle(SybilTheme.text) .foregroundStyle(SybilTheme.text)
.markdownTextStyle { .markdownTheme(.sybilReadable)
FontSize(15)
}
} }
if let answerError = search?.answerError, !answerError.isEmpty { if let answerError = search?.answerError, !answerError.isEmpty {
Text(answerError) Text(answerError)
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.danger) .foregroundStyle(SybilTheme.danger)
} }
@@ -115,23 +113,23 @@ struct SybilSearchResultsView: View {
Link(destination: URL(string: link) ?? URL(string: "https://example.com")!) { Link(destination: URL(string: link) ?? URL(string: "https://example.com")!) {
HStack(spacing: 6) { HStack(spacing: 6) {
Text("[\(index + 1)]") Text("[\(index + 1)]")
.font(.caption2.weight(.semibold)) .font(.sybil(.caption2, weight: .semibold))
.foregroundStyle(SybilTheme.primary) .foregroundStyle(SybilTheme.primary)
Text(citation.title.orEmpty.isEmpty ? host(for: link) : citation.title.orEmpty) Text(citation.title.orEmpty.isEmpty ? host(for: link) : citation.title.orEmpty)
.font(.caption) .font(.sybil(.caption))
.lineLimit(1) .lineLimit(1)
.foregroundStyle(SybilTheme.text) .foregroundStyle(SybilTheme.text)
} }
.padding(.horizontal, 10) .padding(.horizontal, 10)
.padding(.vertical, 6) .padding(.vertical, 6)
.background( .background(
Capsule() Capsule()
.fill(SybilTheme.surface) .fill(SybilTheme.primary.opacity(0.10))
.overlay( .overlay(
Capsule() Capsule()
.stroke(SybilTheme.border, lineWidth: 1) .stroke(SybilTheme.primary.opacity(0.28), lineWidth: 1)
) )
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
@@ -142,10 +140,10 @@ struct SybilSearchResultsView: View {
.padding(14) .padding(14)
.background( .background(
RoundedRectangle(cornerRadius: 16) RoundedRectangle(cornerRadius: 16)
.fill(SybilTheme.searchCard) .fill(LinearGradient(colors: [SybilTheme.searchCard.opacity(0.98), SybilTheme.surface.opacity(0.78)], startPoint: .topLeading, endPoint: .bottomTrailing))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 16) RoundedRectangle(cornerRadius: 16)
.stroke(SybilTheme.border, lineWidth: 1) .stroke(SybilTheme.border.opacity(0.88), lineWidth: 1)
) )
) )
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -171,45 +169,49 @@ private struct SearchResultCard: View {
HStack { HStack {
VStack(alignment: .leading, spacing: 7) { VStack(alignment: .leading, spacing: 7) {
Text(host) Text(host)
.font(.caption) .font(.sybil(.caption))
.foregroundStyle(SybilTheme.primary.opacity(0.9)) .foregroundStyle(SybilTheme.accent.opacity(0.9))
.lineLimit(1) .lineLimit(1)
if let resolvedURL { if let resolvedURL {
Link(destination: resolvedURL) { Link(destination: resolvedURL) {
Text(result.title.orEmpty.orFallback(result.url)) Text(result.title.orEmpty.orFallback(result.url))
.font(.headline) .font(.sybil(.headline))
.foregroundStyle(SybilTheme.primary) .foregroundStyle(SybilTheme.primary.opacity(0.96))
.lineSpacing(2)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} else { } else {
Text(result.title.orEmpty.orFallback(result.url)) Text(result.title.orEmpty.orFallback(result.url))
.font(.headline) .font(.sybil(.headline))
.foregroundStyle(SybilTheme.primary) .foregroundStyle(SybilTheme.primary.opacity(0.96))
.lineSpacing(2)
} }
if let date = result.publishedDate, !date.isEmpty { if let date = result.publishedDate, !date.isEmpty {
Text(date + (result.author.orEmpty.isEmpty ? "" : "\(result.author.orEmpty)")) Text(date + (result.author.orEmpty.isEmpty ? "" : "\(result.author.orEmpty)"))
.font(.caption) .font(.sybil(.caption))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
} else if let author = result.author, !author.isEmpty { } else if let author = result.author, !author.isEmpty {
Text(author) Text(author)
.font(.caption) .font(.sybil(.caption))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
} }
Text(result.url) Text(result.url)
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.text.opacity(0.92)) .foregroundStyle(SybilTheme.text.opacity(0.92))
.lineSpacing(2)
.lineLimit(3) .lineLimit(3)
.textSelection(.enabled) .textSelection(.enabled)
if let highlights = result.highlights, !highlights.isEmpty { if let highlights = result.highlights, !highlights.isEmpty {
ForEach(Array(highlights.prefix(2).enumerated()), id: \.offset) { _, highlight in ForEach(Array(highlights.prefix(2).enumerated()), id: \.offset) { _, highlight in
Text("\(highlight)") Text("\(highlight)")
.font(.caption) .font(.sybil(.caption))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
.lineSpacing(2)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
} }
@@ -220,10 +222,10 @@ private struct SearchResultCard: View {
.padding(14) .padding(14)
.background( .background(
RoundedRectangle(cornerRadius: 14) 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( .overlay(
RoundedRectangle(cornerRadius: 14) RoundedRectangle(cornerRadius: 14)
.stroke(SybilTheme.border, lineWidth: 1) .stroke(SybilTheme.border.opacity(0.84), lineWidth: 1)
) )
) )
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)

View File

@@ -24,7 +24,7 @@ final class SybilSettingsStore {
self.defaults = defaults self.defaults = defaults
let storedBaseURL = defaults.string(forKey: Keys.apiBaseURL)?.trimmingCharacters(in: .whitespacesAndNewlines) 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.apiBaseURL = storedBaseURL?.isEmpty == false ? storedBaseURL! : fallbackBaseURL
self.adminToken = defaults.string(forKey: Keys.adminToken) ?? "" self.adminToken = defaults.string(forKey: Keys.adminToken) ?? ""
@@ -64,10 +64,19 @@ final class SybilSettingsStore {
var raw = apiBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) var raw = apiBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)
guard !raw.isEmpty else { return nil } guard !raw.isEmpty else { return nil }
if raw.hasSuffix("/") { while raw.hasSuffix("/") {
raw.removeLast() 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
} }
} }

View File

@@ -11,19 +11,19 @@ struct SybilSettingsView: View {
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text("Connection") Text("Connection")
.font(.title3.weight(.semibold)) .font(.sybil(.title3, weight: .semibold))
.foregroundStyle(SybilTheme.text) .foregroundStyle(SybilTheme.text)
Text("Use the same API root as the web client. Example: `http://127.0.0.1:8787/api`") Text("Use the API server root. Example: `http://127.0.0.1:8787`")
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
} }
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("API URL") Text("API URL")
.font(.caption.weight(.semibold)) .font(.sybil(.caption, weight: .semibold))
.foregroundStyle(SybilTheme.textMuted) .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) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
.keyboardType(.URL) .keyboardType(.URL)
@@ -38,7 +38,7 @@ struct SybilSettingsView: View {
) )
Text("Admin Token") Text("Admin Token")
.font(.caption.weight(.semibold)) .font(.sybil(.caption, weight: .semibold))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
SecureField("ADMIN_TOKEN (optional in open mode)", text: $settings.adminToken) SecureField("ADMIN_TOKEN (optional in open mode)", text: $settings.adminToken)
@@ -82,19 +82,19 @@ struct SybilSettingsView: View {
if let mode = viewModel.authMode { if let mode = viewModel.authMode {
Label(mode == "open" ? "Server is in open mode" : "Server requires token", systemImage: "dot.radiowaves.left.and.right") 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) .foregroundStyle(SybilTheme.textMuted)
} }
if let authError = viewModel.authError { if let authError = viewModel.authError {
Text(authError) Text(authError)
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.danger) .foregroundStyle(SybilTheme.danger)
} }
if let runtimeError = viewModel.errorMessage { if let runtimeError = viewModel.errorMessage {
Text(runtimeError) Text(runtimeError)
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.danger) .foregroundStyle(SybilTheme.danger)
} }
} }

View File

@@ -17,27 +17,29 @@ struct SybilSidebarView: View {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
HStack(spacing: 10) { VStack(alignment: .leading, spacing: 14) {
Button { VStack(spacing: 10) {
viewModel.startNewChat() sidebarActionButton(
} label: { title: "New chat",
Label("New chat", systemImage: "plus") systemImage: "plus",
.frame(maxWidth: .infinity) isPrimary: true,
} isActive: viewModel.draftKind == .chat
.buttonStyle(.borderedProminent) ) {
.tint(viewModel.draftKind == .chat ? SybilTheme.primary : SybilTheme.primarySoft) viewModel.startNewChat()
}
Button { sidebarActionButton(
viewModel.startNewSearch() title: "New search",
} label: { systemImage: "magnifyingglass",
Label("New search", systemImage: "magnifyingglass") isPrimary: false,
.frame(maxWidth: .infinity) isActive: viewModel.draftKind == .search
) {
viewModel.startNewSearch()
}
} }
.buttonStyle(.bordered)
.tint(viewModel.draftKind == .search ? SybilTheme.primary : SybilTheme.textMuted)
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.top, 12) .padding(.top, 18)
.padding(.bottom, 10) .padding(.bottom, 10)
Divider() Divider()
@@ -45,7 +47,7 @@ struct SybilSidebarView: View {
if let errorMessage = viewModel.errorMessage { if let errorMessage = viewModel.errorMessage {
Text(errorMessage) Text(errorMessage)
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.danger) .foregroundStyle(SybilTheme.danger)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12) .padding(.horizontal, 12)
@@ -60,7 +62,7 @@ struct SybilSidebarView: View {
ProgressView() ProgressView()
.tint(SybilTheme.primary) .tint(SybilTheme.primary)
Text("Loading conversations…") Text("Loading conversations…")
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
@@ -68,10 +70,10 @@ struct SybilSidebarView: View {
} else if viewModel.sidebarItems.isEmpty { } else if viewModel.sidebarItems.isEmpty {
VStack(spacing: 10) { VStack(spacing: 10) {
Image(systemName: "message.badge") Image(systemName: "message.badge")
.font(.title3) .font(.system(size: 20, weight: .medium))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
Text("Start a chat or run your first search.") Text("Start a chat or run your first search.")
.font(.footnote) .font(.sybil(.footnote))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
} }
@@ -87,24 +89,36 @@ struct SybilSidebarView: View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: iconName(for: item)) 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) Text(item.title)
.font(.subheadline.weight(.medium)) .font(.sybil(.subheadline, weight: .semibold))
.lineLimit(1) .lineLimit(1)
} }
HStack(spacing: 8) { HStack(spacing: 8) {
Text(item.updatedAt.sybilRelativeLabel) Text(item.updatedAt.sybilRelativeLabel)
.font(.caption2) .font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
if let initiated = item.initiatedLabel { if let initiated = item.initiatedLabel {
Spacer(minLength: 0) Spacer(minLength: 0)
Text(initiated) Text(initiated)
.font(.caption2) .font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted.opacity(0.88)) .foregroundStyle(SybilTheme.textMuted.opacity(0.88))
.lineLimit(1) .lineLimit(1)
.multilineTextAlignment(.trailing)
.frame(maxWidth: .infinity, alignment: .trailing)
} }
} }
} }
@@ -114,7 +128,7 @@ struct SybilSidebarView: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background( .background(
RoundedRectangle(cornerRadius: 12) 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( .overlay(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
@@ -144,7 +158,7 @@ struct SybilSidebarView: View {
viewModel.openSettings() viewModel.openSettings()
} label: { } label: {
Label("Settings", systemImage: "gearshape") Label("Settings", systemImage: "gearshape")
.font(.subheadline.weight(.medium)) .font(.sybil(.subheadline, weight: .medium))
.foregroundStyle(SybilTheme.text) .foregroundStyle(SybilTheme.text)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 10) .padding(.vertical, 10)
@@ -157,6 +171,39 @@ struct SybilSidebarView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(10) .padding(10)
} }
.background(SybilTheme.surfaceStrong.opacity(0.84)) .background(SybilTheme.panelGradient)
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
SybilWordmark(size: 18)
}
}
}
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)
} }
} }

View File

@@ -1,28 +1,212 @@
import CoreText
import Foundation
import SwiftUI import SwiftUI
import UIKit
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 { enum SybilTheme {
static let background = Color(red: 0.07, green: 0.05, blue: 0.11) static let background = Color(red: 0.02, green: 0.02, blue: 0.05)
static let surface = Color(red: 0.14, green: 0.09, blue: 0.19) static let surface = Color(red: 0.05, green: 0.04, blue: 0.10)
static let surfaceStrong = Color(red: 0.17, green: 0.10, blue: 0.23) static let surfaceStrong = Color(red: 0.07, green: 0.06, blue: 0.14)
static let card = Color(red: 0.12, green: 0.08, blue: 0.17) static let card = Color(red: 0.06, green: 0.05, blue: 0.12)
static let border = Color(red: 0.30, green: 0.22, blue: 0.39) static let border = Color(red: 0.24, green: 0.20, blue: 0.38)
static let primary = Color(red: 0.66, green: 0.47, blue: 0.94) static let primary = Color(red: 0.57, green: 0.38, blue: 0.96)
static let primarySoft = Color(red: 0.53, green: 0.37, blue: 0.78) static let primarySoft = Color(red: 0.43, green: 0.25, blue: 0.76)
static let text = Color(red: 0.95, green: 0.91, blue: 0.98) static let accent = Color(red: 0.31, green: 0.88, blue: 0.95)
static let textMuted = Color(red: 0.72, green: 0.65, blue: 0.80) static let text = Color(red: 0.96, green: 0.94, blue: 1.0)
static let searchCard = Color(red: 0.16, green: 0.10, blue: 0.22) static let textMuted = Color(red: 0.65, green: 0.62, blue: 0.76)
static let userBubble = Color(red: 0.35, green: 0.20, blue: 0.62) static let searchCard = Color(red: 0.07, green: 0.06, blue: 0.14)
static let danger = Color(red: 0.93, green: 0.38, blue: 0.42) 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)
@MainActor static func applySystemAppearance() {
let navAppearance = UINavigationBarAppearance()
navAppearance.configureWithOpaqueBackground()
navAppearance.backgroundColor = UIColor(red: 0.02, green: 0.02, blue: 0.05, alpha: 1)
navAppearance.shadowColor = UIColor(red: 0.24, green: 0.20, blue: 0.38, alpha: 0.9)
navAppearance.titleTextAttributes = [
.foregroundColor: UIColor(red: 0.96, green: 0.94, blue: 1.0, alpha: 1)
]
navAppearance.largeTitleTextAttributes = navAppearance.titleTextAttributes
UINavigationBar.appearance().prefersLargeTitles = false
UINavigationBar.appearance().standardAppearance = navAppearance
UINavigationBar.appearance().compactAppearance = navAppearance
UINavigationBar.appearance().scrollEdgeAppearance = navAppearance
UINavigationBar.appearance().compactScrollEdgeAppearance = navAppearance
}
static var backgroundGradient: LinearGradient { static var backgroundGradient: LinearGradient {
LinearGradient( LinearGradient(
colors: [ colors: [
Color(red: 0.27, green: 0.12, blue: 0.36), Color(red: 0.12, green: 0.08, blue: 0.28),
Color(red: 0.15, green: 0.09, blue: 0.23), Color(red: 0.04, green: 0.03, blue: 0.09),
Color(red: 0.07, green: 0.05, blue: 0.11) background
], ],
startPoint: .top, startPoint: .top,
endPoint: .bottom 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")
}
} }

View File

@@ -111,11 +111,15 @@ final class SybilViewModel {
} }
var providerModelOptions: [String] { 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 { if !serverModels.isEmpty {
return serverModels return serverModels
} }
return fallbackModels[provider] ?? [] return fallbackModels[candidate] ?? []
} }
var selectedTitle: String { var selectedTitle: String {
@@ -222,7 +226,7 @@ final class SybilViewModel {
let initiatedLabel: String? let initiatedLabel: String?
if let model = chat.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty { if let model = chat.initiatedModel?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty {
if let provider = chat.initiatedProvider { if let provider = chat.initiatedProvider {
initiatedLabel = "\(provider.displayName) · \(model)" initiatedLabel = "\(provider.displayName) \(model)"
} else { } else {
initiatedLabel = model initiatedLabel = model
} }
@@ -336,6 +340,15 @@ final class SybilViewModel {
SybilLog.info(SybilLog.ui, "Model changed to \(nextModel)") 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() { func startNewChat() {
SybilLog.debug(SybilLog.ui, "Starting draft chat") SybilLog.debug(SybilLog.ui, "Starting draft chat")
draftKind = .chat draftKind = .chat

View File

@@ -12,12 +12,18 @@ struct SybilWorkspaceView: View {
return false return false
} }
private var showsHeader: Bool {
viewModel.errorMessage != nil
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
header if showsHeader {
header
Divider() Divider()
.overlay(SybilTheme.border) .overlay(SybilTheme.border)
}
Group { Group {
if isSettingsSelected { if isSettingsSelected {
@@ -45,6 +51,19 @@ struct SybilWorkspaceView: View {
} }
} }
.navigationTitle(viewModel.selectedTitle) .navigationTitle(viewModel.selectedTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbarRole(.editor)
.toolbar {
if !isSettingsSelected {
ToolbarItem(placement: .topBarTrailing) {
if viewModel.isSearchMode {
searchModeChip
} else {
providerModelMenu
}
}
}
}
.background(SybilTheme.background) .background(SybilTheme.background)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onChange(of: viewModel.isSending) { _, isSending in .onChange(of: viewModel.isSending) { _, isSending in
@@ -56,88 +75,76 @@ struct SybilWorkspaceView: View {
private var header: some View { private var header: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
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 let error = viewModel.errorMessage { if let error = viewModel.errorMessage {
Text(error) Text(error)
.font(.footnote) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.danger) .foregroundStyle(SybilTheme.danger)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 12)
.background(SybilTheme.panelGradient.opacity(0.58))
} }
private var providerControls: some View { private var providerModelMenu: some View {
HStack(spacing: 8) { Menu {
Menu { Text("\(viewModel.provider.displayName)\(viewModel.model)")
ForEach(Provider.allCases, id: \.self) { candidate in .font(.sybil(.caption))
Button(candidate.displayName) {
viewModel.setProvider(candidate)
}
}
} label: {
Label(viewModel.provider.displayName, systemImage: "chevron.down")
.labelStyle(.titleAndIcon)
.font(.caption.weight(.medium))
.foregroundStyle(SybilTheme.text)
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(SybilTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(SybilTheme.border, lineWidth: 1)
)
)
}
Menu { Divider()
ForEach(viewModel.providerModelOptions, id: \.self) { model in
Button(model) { ForEach(Provider.allCases, id: \.self) { candidate in
viewModel.setModel(model) 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: {
Label(viewModel.model, systemImage: "chevron.down")
.labelStyle(.titleAndIcon)
.font(.caption.weight(.medium))
.foregroundStyle(SybilTheme.text)
.lineLimit(1)
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(SybilTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(SybilTheme.border, lineWidth: 1)
)
)
} }
} 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 searchModeChip: some View {
Label("Search", systemImage: "globe")
.font(.sybil(.caption, weight: .medium))
.foregroundStyle(SybilTheme.accent)
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(
Capsule()
.fill(SybilTheme.accent.opacity(0.10))
.overlay(
Capsule()
.stroke(SybilTheme.accent.opacity(0.24), lineWidth: 1)
)
)
} }
private var composerBar: some View { private var composerBar: some View {
@@ -161,10 +168,10 @@ struct SybilWorkspaceView: View {
.padding(.vertical, 10) .padding(.vertical, 10)
.background( .background(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.surface) .fill(SybilTheme.composerGradient)
.overlay( .overlay(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.stroke(SybilTheme.border, lineWidth: 1) .stroke(SybilTheme.primary.opacity(0.34), lineWidth: 1)
) )
) )
.foregroundStyle(SybilTheme.text) .foregroundStyle(SybilTheme.text)
@@ -175,11 +182,15 @@ struct SybilWorkspaceView: View {
} }
} label: { } label: {
Image(systemName: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up") Image(systemName: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up")
.font(.headline.weight(.semibold)) .font(.system(size: 17, weight: .semibold))
.frame(width: 40, height: 40) .frame(width: 40, height: 40)
.background( .background(
Circle() 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) .foregroundStyle(viewModel.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending ? SybilTheme.textMuted : SybilTheme.text)
} }
@@ -188,6 +199,15 @@ struct SybilWorkspaceView: View {
} }
.padding(.horizontal, 14) .padding(.horizontal, 14)
.padding(.vertical, 12) .padding(.vertical, 12)
.background(SybilTheme.background) .background(
LinearGradient(
colors: [
SybilTheme.background.opacity(0.18),
SybilTheme.background.opacity(0.96)
],
startPoint: .top,
endPoint: .bottom
)
)
} }
} }

View File

@@ -1230,6 +1230,10 @@ export default function App() {
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent"> <div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
SYBIL SYBIL
</div> </div>
<p className="mt-2 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
</p>
</div> </div>
<div className="space-y-3 px-3 pb-3"> <div className="space-y-3 px-3 pb-3">
@@ -1328,11 +1332,6 @@ export default function App() {
<div> <div>
<h1 className="text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1> <h1 className="text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1>
<p className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
{isSearchMode ? " • Exa Search" : ""}
</p>
</div> </div>
</div> </div>
<div className="flex w-full max-w-xl items-center gap-2 md:w-auto"> <div className="flex w-full max-w-xl items-center gap-2 md:w-auto">