8 Commits

13 changed files with 825 additions and 253 deletions

View File

@@ -8,8 +8,19 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
- `just build` will: - `just build` will:
1. generate `Sybil.xcodeproj` with `xcodegen` if missing, 1. generate `Sybil.xcodeproj` with `xcodegen` if missing,
2. build scheme `Sybil` for `iPhone 16e` simulator. 2. build scheme `Sybil` for `iPhone 16e` simulator.
- Preferred test command: `just test`
- `just test` runs the Swift package tests through `xcodebuild test` on the `iPhone 16e` iOS simulator from `ios/Packages/Sybil`.
- `just test` disables Xcode parallel testing because the current async view-model tests use timing-sensitive selection tasks.
- Do not use plain `swift test` for this package; it runs as host macOS and hits a deployment mismatch with `MarkdownUI`.
- If `xcbeautify` is installed it is used automatically; otherwise raw `xcodebuild` output is used. - If `xcbeautify` is installed it is used automatically; otherwise raw `xcodebuild` output is used.
## Simulator Workflow
- Run the app in the simulator with `just run` from `/Users/buzzert/src/sybil-2/ios`.
- `just run` boots the `iPhone 16e` simulator if needed, builds with a stable derived data path, installs `Sybil.app`, and launches bundle id `net.buzzert.sybil2`.
- Capture a simulator screenshot with `just screenshot` from `/Users/buzzert/src/sybil-2/ios`; it writes `build/sybil-screenshot.png` by default.
- To choose a screenshot path, run `just screenshot path=build/name.png`.
- The underlying screenshot command is `xcrun simctl io booted screenshot <path>` and requires a booted simulator.
## App Structure ## App Structure
- App target entry: `/Users/buzzert/src/sybil-2/ios/Apps/Sybil/Sources/SybilApp.swift` - App target entry: `/Users/buzzert/src/sybil-2/ios/Apps/Sybil/Sources/SybilApp.swift`
- Shared iOS app code lives in Swift package: - Shared iOS app code lives in Swift package:

View File

@@ -6,6 +6,7 @@ public struct SplitView: View {
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@State private var shouldRefreshOnForeground = false @State private var shouldRefreshOnForeground = false
@State private var composerFocusRequest = 0 @State private var composerFocusRequest = 0
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
private var keyboardActions: SybilKeyboardActions? { private var keyboardActions: SybilKeyboardActions? {
guard !viewModel.isCheckingSession, viewModel.isAuthenticated else { guard !viewModel.isCheckingSession, viewModel.isAuthenticated else {
@@ -50,16 +51,24 @@ public struct SplitView: View {
} else if horizontalSizeClass == .compact { } else if horizontalSizeClass == .compact {
SybilPhoneShellView(viewModel: viewModel) SybilPhoneShellView(viewModel: viewModel)
} else { } else {
NavigationSplitView { GeometryReader { proxy in
SybilSidebarView(viewModel: viewModel) NavigationSplitView(columnVisibility: $columnVisibility) {
} detail: { SybilSidebarView(viewModel: viewModel)
SybilWorkspaceView(viewModel: viewModel, composerFocusRequest: composerFocusRequest) { } detail: {
viewModel.startNewChat() SybilWorkspaceView(
composerFocusRequest += 1 viewModel: viewModel,
composerFocusRequest: composerFocusRequest,
navigationLeadingControl: splitNavigationLeadingControl(for: proxy.size),
onShowSidebar: showSidebar,
onRequestNewChat: {
viewModel.startNewChat()
composerFocusRequest += 1
}
)
} }
.navigationSplitViewStyle(.balanced)
.tint(SybilTheme.primary)
} }
.navigationSplitViewStyle(.balanced)
.tint(SybilTheme.primary)
} }
} }
.font(.sybil(.body)) .font(.sybil(.body))
@@ -93,6 +102,16 @@ public struct SplitView: View {
} }
} }
} }
private func splitNavigationLeadingControl(for size: CGSize) -> SybilWorkspaceNavigationLeadingControl {
return size.width < size.height ? .showSidebar : .hidden
}
private func showSidebar() {
withAnimation(.easeInOut(duration: 0.22)) {
columnVisibility = .all
}
}
} }
public struct SybilCommands: Commands { public struct SybilCommands: Commands {

View File

@@ -6,7 +6,7 @@ struct SybilChatTranscriptView: View {
var isLoading: Bool var isLoading: Bool
var isSending: Bool var isSending: Bool
var topContentInset: CGFloat = 0 var topContentInset: CGFloat = 0
@State private var hasHandledInitialTranscriptScroll = false var bottomContentInset: CGFloat = 0
private var hasPendingAssistant: Bool { private var hasPendingAssistant: Bool {
messages.contains { message in messages.contains { message in
@@ -15,66 +15,42 @@ struct SybilChatTranscriptView: View {
} }
var body: some View { var body: some View {
ScrollViewReader { proxy in ScrollView {
ScrollView { LazyVStack(alignment: .leading, spacing: 26) {
LazyVStack(alignment: .leading, spacing: 26) { if isSending && !hasPendingAssistant {
if isLoading && messages.isEmpty { HStack(spacing: 8) {
Text("Loading messages…") ProgressView()
.controlSize(.small)
.tint(SybilTheme.textMuted)
Text("Assistant is typing…")
.font(.sybil(.footnote)) .font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
.padding(.top, 24)
} }
.scaleEffect(x: 1, y: -1)
ForEach(messages) { message in }
MessageBubble(message: message, isSending: isSending)
.frame(maxWidth: .infinity) ForEach(messages.reversed()) { message in
.id(message.id) MessageBubble(message: message, isSending: isSending)
} .frame(maxWidth: .infinity)
.scaleEffect(x: 1, y: -1)
if isSending && !hasPendingAssistant { }
HStack(spacing: 8) {
ProgressView() if isLoading && messages.isEmpty {
.controlSize(.small) Text("Loading messages…")
.tint(SybilTheme.textMuted) .font(.sybil(.footnote))
Text("Assistant is typing…") .foregroundStyle(SybilTheme.textMuted)
.font(.sybil(.footnote)) .padding(.top, 24)
.foregroundStyle(SybilTheme.textMuted) .scaleEffect(x: 1, y: -1)
}
.id("typing-indicator")
}
Color.clear
.frame(height: 2)
.id("chat-bottom-anchor")
} }
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.top, 18 + topContentInset)
.padding(.bottom, 18)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.scrollDismissesKeyboard(.interactively) .padding(.horizontal, 14)
.onAppear { .padding(.top, 18 + bottomContentInset)
scrollToBottom(with: proxy, animated: false) .padding(.bottom, 18 + topContentInset)
}
.onChange(of: messages.map(\.id)) { _, _ in
scrollToBottom(with: proxy, animated: hasHandledInitialTranscriptScroll && !isLoading)
hasHandledInitialTranscriptScroll = true
}
.onChange(of: isSending) { _, _ in
scrollToBottom(with: proxy, animated: hasHandledInitialTranscriptScroll)
}
}
}
private func scrollToBottom(with proxy: ScrollViewProxy, animated: Bool) {
if animated {
withAnimation(.easeOut(duration: 0.22)) {
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
}
} else {
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
} }
.frame(maxWidth: .infinity, alignment: .leading)
.scrollDismissesKeyboard(.interactively)
.scaleEffect(x: 1, y: -1)
} }
} }

View File

@@ -34,7 +34,7 @@ struct SybilPhoneShellView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .topBarLeading) { ToolbarItem(placement: .topBarLeading) {
SybilWordmark(size: 18) SybilWordmark(size: 21)
} }
} }
.navigationDestination(for: PhoneRoute.self) { route in .navigationDestination(for: PhoneRoute.self) { route in
@@ -77,6 +77,27 @@ struct SybilPhoneShellView: View {
private struct SybilPhoneSidebarRoot: View { private struct SybilPhoneSidebarRoot: View {
@Bindable var viewModel: SybilViewModel @Bindable var viewModel: SybilViewModel
@Binding var path: [PhoneRoute] @Binding var path: [PhoneRoute]
@State private var openingSelection: SidebarSelection?
@State private var openingRequestID: UUID?
private var highlightedSelection: SidebarSelection? {
if let openingSelection {
return openingSelection
}
guard let route = path.last else {
return nil
}
switch route {
case let .chat(chatID):
return .chat(chatID)
case let .search(searchID):
return .search(searchID)
case .draftChat, .draftSearch, .settings:
return nil
}
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -116,12 +137,21 @@ private struct SybilPhoneSidebarRoot: View {
.padding(16) .padding(16)
} else { } else {
ScrollView { ScrollView {
LazyVStack(alignment: .leading, spacing: 8) { LazyVStack(alignment: .leading, spacing: 0) {
ForEach(viewModel.sidebarItems) { item in ForEach(viewModel.sidebarItems) { item in
NavigationLink(value: PhoneRoute.from(selection: item.selection)) { Button {
SybilPhoneSidebarRow(item: item) open(item.selection)
} label: {
VStack(spacing: 0.0) {
SybilPhoneSidebarRow(item: item)
Divider()
}
} }
.buttonStyle(.plain) .buttonStyle(
SybilPhoneSidebarRowButtonStyle(
isHighlighted: highlightedSelection == item.selection
)
)
.contextMenu { .contextMenu {
Button(role: .destructive) { Button(role: .destructive) {
Task { Task {
@@ -133,7 +163,6 @@ private struct SybilPhoneSidebarRoot: View {
} }
} }
} }
.padding(10)
} }
} }
} }
@@ -150,17 +179,20 @@ private struct SybilPhoneSidebarRoot: View {
HStack(spacing: 12) { HStack(spacing: 12) {
toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") { toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
clearOpeningSelection()
path = [.settings] path = [.settings]
} }
Spacer() Spacer()
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") { toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
clearOpeningSelection()
viewModel.startNewSearch() viewModel.startNewSearch()
path = [.draftSearch] path = [.draftSearch]
} }
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) { toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
clearOpeningSelection()
viewModel.startNewChat() viewModel.startNewChat()
path = [.draftChat] path = [.draftChat]
} }
@@ -198,25 +230,69 @@ private struct SybilPhoneSidebarRoot: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(accessibilityLabel) .accessibilityLabel(accessibilityLabel)
} }
private func clearOpeningSelection() {
openingRequestID = nil
openingSelection = nil
}
private func open(_ selection: SidebarSelection) {
guard openingSelection != selection else {
return
}
let requestID = UUID()
openingRequestID = requestID
openingSelection = selection
Task {
await viewModel.selectForNavigation(selection)
guard openingRequestID == requestID else {
return
}
path = [PhoneRoute.from(selection: selection)]
openingRequestID = nil
openingSelection = nil
}
}
}
private struct SybilPhoneSidebarRowIsActiveKey: EnvironmentKey {
static let defaultValue = false
}
private extension EnvironmentValues {
var sybilPhoneSidebarRowIsActive: Bool {
get { self[SybilPhoneSidebarRowIsActiveKey.self] }
set { self[SybilPhoneSidebarRowIsActiveKey.self] = newValue }
}
}
private struct SybilPhoneSidebarRowButtonStyle: ButtonStyle {
var isHighlighted: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.environment(\.sybilPhoneSidebarRowIsActive, isHighlighted || configuration.isPressed)
}
} }
private struct SybilPhoneSidebarRow: View { private struct SybilPhoneSidebarRow: View {
@Environment(\.sybilPhoneSidebarRowIsActive) private var isHighlighted
var item: SidebarItem var item: SidebarItem
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 6) { let leadingWidth = 22.0
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: item.kind == .chat ? "message" : "globe") Image(systemName: item.kind == .chat ? "message" : "globe")
.font(.system(size: 12, weight: .semibold)) .font(.system(size: 12, weight: .semibold))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(isHighlighted ? SybilTheme.accent : SybilTheme.textMuted)
.frame(width: 22, height: 22) .frame(width: leadingWidth, height: leadingWidth)
.background( .background(
RoundedRectangle(cornerRadius: 7) Rectangle()
.fill(SybilTheme.surface.opacity(0.72)) .fill(isHighlighted ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
.overlay(
RoundedRectangle(cornerRadius: 7)
.stroke(SybilTheme.border.opacity(0.72), lineWidth: 1)
)
) )
Text(item.title) Text(item.title)
@@ -225,6 +301,9 @@ private struct SybilPhoneSidebarRow: View {
} }
HStack(spacing: 8) { HStack(spacing: 8) {
Spacer()
.frame(width: leadingWidth)
Text(item.updatedAt.sybilRelativeLabel) Text(item.updatedAt.sybilRelativeLabel)
.font(.sybil(.caption2)) .font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
@@ -241,17 +320,17 @@ private struct SybilPhoneSidebarRow: View {
} }
} }
.foregroundStyle(SybilTheme.text) .foregroundStyle(SybilTheme.text)
.padding(.horizontal, 12) .padding(18.0)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background( .background(
RoundedRectangle(cornerRadius: 12) Rectangle()
.fill(LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing)) .fill(
) isHighlighted
.overlay( ? SybilTheme.selectedRowGradient
RoundedRectangle(cornerRadius: 12) : LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing)
.stroke(SybilTheme.border.opacity(0.72), lineWidth: 1) )
) )
} }
} }
@@ -265,16 +344,16 @@ private struct SybilPhoneDestinationView: View {
SybilWorkspaceView( SybilWorkspaceView(
viewModel: viewModel, viewModel: viewModel,
composerFocusRequest: composerFocusRequest, composerFocusRequest: composerFocusRequest,
usesCustomChatNavigation: route.isChatTranscript onRequestNewChat: {
) { viewModel.startNewChat()
viewModel.startNewChat() composerFocusRequest += 1
composerFocusRequest += 1 if path.isEmpty {
if path.isEmpty { path = [.draftChat]
path = [.draftChat] } else {
} else { path[path.index(before: path.endIndex)] = .draftChat
path[path.index(before: path.endIndex)] = .draftChat }
} }
} )
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.task(id: route) { .task(id: route) {
@@ -285,8 +364,14 @@ private struct SybilPhoneDestinationView: View {
private func applyRoute() { private func applyRoute() {
switch route { switch route {
case let .chat(chatID): case let .chat(chatID):
guard viewModel.draftKind != nil || viewModel.selectedItem != .chat(chatID) else {
return
}
viewModel.select(.chat(chatID)) viewModel.select(.chat(chatID))
case let .search(searchID): case let .search(searchID):
guard viewModel.draftKind != nil || viewModel.selectedItem != .search(searchID) else {
return
}
viewModel.select(.search(searchID)) viewModel.select(.search(searchID))
case .draftChat: case .draftChat:
viewModel.startNewChat() viewModel.startNewChat()
@@ -297,14 +382,3 @@ private struct SybilPhoneDestinationView: View {
} }
} }
} }
private extension PhoneRoute {
var isChatTranscript: Bool {
switch self {
case .chat, .draftChat:
return true
case .search, .draftSearch, .settings:
return false
}
}
}

View File

@@ -6,6 +6,8 @@ struct SybilSearchResultsView: View {
var isLoading: Bool var isLoading: Bool
var isRunning: Bool var isRunning: Bool
var isStartingChat: Bool = false var isStartingChat: Bool = false
var topContentInset: CGFloat = 0
var bottomContentInset: CGFloat = 0
var onStartChat: (() -> Void)? = nil var onStartChat: (() -> Void)? = nil
var body: some View { var body: some View {
@@ -98,7 +100,8 @@ struct SybilSearchResultsView: View {
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14) .padding(.horizontal, 14)
.padding(.vertical, 20) .padding(.top, 20 + topContentInset)
.padding(.bottom, 20 + bottomContentInset)
} }
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)

View File

@@ -9,7 +9,7 @@ enum SybilFontRegistry {
} }
private static let registeredFonts: Void = { private static let registeredFonts: Void = {
for fontName in ["Inter", "Orbitron"] { for fontName in ["Inter", "Orbitron", "StalinistOne-Regular"] {
guard let url = Bundle.main.url(forResource: fontName, withExtension: "ttf", subdirectory: "Fonts") ?? guard let url = Bundle.main.url(forResource: fontName, withExtension: "ttf", subdirectory: "Fonts") ??
Bundle.main.url(forResource: fontName, withExtension: "ttf") Bundle.main.url(forResource: fontName, withExtension: "ttf")
else { else {
@@ -203,7 +203,7 @@ struct SybilWordmark: View {
var body: some View { var body: some View {
Text("SYBIL") Text("SYBIL")
.font(.custom("Orbitron", size: size)) .font(.custom("Stalinist One", size: size))
.fontWeight(.black) .fontWeight(.black)
.tracking(0) .tracking(0)
.foregroundStyle(SybilTheme.brandGradient) .foregroundStyle(SybilTheme.brandGradient)

View File

@@ -42,6 +42,22 @@ private struct PendingChatState {
var messages: [Message] var messages: [Message]
} }
private enum ActiveSendContext: Equatable {
case draftChat(UUID)
case chat(String)
case draftSearch(UUID)
case search(String)
var isSearch: Bool {
switch self {
case .draftSearch, .search:
return true
case .draftChat, .chat:
return false
}
}
}
private actor CompletionStreamStatus { private actor CompletionStreamStatus {
private var streamError: String? private var streamError: String?
@@ -99,6 +115,8 @@ final class SybilViewModel {
@ObservationIgnored @ObservationIgnored
private var hasBootstrapped = false private var hasBootstrapped = false
private var pendingChatState: PendingChatState? private var pendingChatState: PendingChatState?
private var activeSendContext: ActiveSendContext?
private var draftIdentity = UUID()
@ObservationIgnored @ObservationIgnored
private var selectionTask: Task<Void, Never>? private var selectionTask: Task<Void, Never>?
@ObservationIgnored @ObservationIgnored
@@ -157,7 +175,7 @@ final class SybilViewModel {
switch selectedItem { switch selectedItem {
case .chat: case .chat:
if let selectedChat { if let selectedChat = currentSelectedChat {
return chatTitle(title: selectedChat.title, messages: selectedChat.messages) return chatTitle(title: selectedChat.title, messages: selectedChat.messages)
} }
if let summary = selectedChatSummary { if let summary = selectedChatSummary {
@@ -166,7 +184,7 @@ final class SybilViewModel {
return "Chat" return "Chat"
case .search: case .search:
if let selectedSearch { if let selectedSearch = currentSelectedSearch {
return searchTitle(title: selectedSearch.title, query: selectedSearch.query) return searchTitle(title: selectedSearch.title, query: selectedSearch.query)
} }
if let summary = selectedSearchSummary { if let summary = selectedSearchSummary {
@@ -233,7 +251,7 @@ final class SybilViewModel {
} }
var displayedMessages: [Message] { var displayedMessages: [Message] {
let canonical = displayableMessages(selectedChat?.messages ?? []) let canonical = displayableMessages(currentSelectedChat?.messages ?? [])
guard let pending = pendingChatState else { guard let pending = pendingChatState else {
return canonical return canonical
} }
@@ -252,6 +270,40 @@ final class SybilViewModel {
return canonical return canonical
} }
var displayedSearch: SearchDetail? {
currentSelectedSearch
}
var isSendingVisibleChat: Bool {
guard isSending, pendingChatState != nil else {
return false
}
switch activeSendContext {
case let .draftChat(identity):
return draftKind == .chat && identity == draftIdentity
case let .chat(chatID):
return selectedItem == .chat(chatID)
case .draftSearch, .search, nil:
return false
}
}
var isRunningVisibleSearch: Bool {
guard isSending else {
return false
}
switch activeSendContext {
case let .draftSearch(identity):
return draftKind == .search && identity == draftIdentity
case let .search(searchID):
return selectedItem == .search(searchID)
case .draftChat, .chat, nil:
return false
}
}
var sidebarItems: [SidebarItem] { var sidebarItems: [SidebarItem] {
let chatItems: [SidebarItem] = chats.map { chat in let chatItems: [SidebarItem] = chats.map { chat in
let initiatedLabel: String? let initiatedLabel: String?
@@ -280,7 +332,7 @@ final class SybilViewModel {
kind: .search, kind: .search,
title: searchTitle(title: search.title, query: search.query), title: searchTitle(title: search.title, query: search.query),
updatedAt: search.updatedAt, updatedAt: search.updatedAt,
initiatedLabel: nil initiatedLabel: "exa"
) )
} }
@@ -324,7 +376,10 @@ final class SybilViewModel {
isCheckingSession = true isCheckingSession = true
authError = nil authError = nil
errorMessage = nil errorMessage = nil
resetSelectionLoading()
pendingChatState = nil pendingChatState = nil
activeSendContext = nil
draftIdentity = UUID()
composerAttachments = [] composerAttachments = []
settings.persist() settings.persist()
@@ -396,10 +451,13 @@ final class SybilViewModel {
func startNewChat() { func startNewChat() {
SybilLog.debug(SybilLog.ui, "Starting draft chat") SybilLog.debug(SybilLog.ui, "Starting draft chat")
resetSelectionLoading()
draftIdentity = UUID()
draftKind = .chat draftKind = .chat
selectedItem = nil selectedItem = nil
selectedChat = nil selectedChat = nil
selectedSearch = nil selectedSearch = nil
pendingChatState = nil
errorMessage = nil errorMessage = nil
composer = "" composer = ""
composerAttachments = [] composerAttachments = []
@@ -407,10 +465,13 @@ final class SybilViewModel {
func startNewSearch() { func startNewSearch() {
SybilLog.debug(SybilLog.ui, "Starting draft search") SybilLog.debug(SybilLog.ui, "Starting draft search")
resetSelectionLoading()
draftIdentity = UUID()
draftKind = .search draftKind = .search
selectedItem = nil selectedItem = nil
selectedChat = nil selectedChat = nil
selectedSearch = nil selectedSearch = nil
pendingChatState = nil
errorMessage = nil errorMessage = nil
composer = "" composer = ""
composerAttachments = [] composerAttachments = []
@@ -418,33 +479,72 @@ final class SybilViewModel {
func openSettings() { func openSettings() {
SybilLog.debug(SybilLog.ui, "Opening settings") SybilLog.debug(SybilLog.ui, "Opening settings")
resetSelectionLoading()
draftKind = nil draftKind = nil
selectedItem = .settings selectedItem = .settings
selectedChat = nil selectedChat = nil
selectedSearch = nil selectedSearch = nil
pendingChatState = nil
errorMessage = nil errorMessage = nil
composerAttachments = [] composerAttachments = []
} }
func select(_ selection: SidebarSelection) { func select(_ selection: SidebarSelection) {
SybilLog.debug(SybilLog.ui, "Selecting \(selection.id)") _ = beginSelecting(selection)
draftKind = nil }
selectedItem = selection
errorMessage = nil
if case .search = selection {
composerAttachments = []
}
if case .settings = selection { func selectForNavigation(_ selection: SidebarSelection, preloadTimeout: Duration = .seconds(3)) async {
selectedChat = nil guard beginSelecting(selection) != nil else {
selectedSearch = nil
return return
} }
selectionTask?.cancel() await waitForSelectionLoad(timeout: preloadTimeout)
selectionTask = Task { [weak self] in }
await self?.refreshSelectionIfNeeded()
@discardableResult
private func beginSelecting(_ selection: SidebarSelection) -> Task<Void, Never>? {
SybilLog.debug(SybilLog.ui, "Selecting \(selection.id)")
if draftKind == nil, selectedItem == selection {
errorMessage = nil
if case .search = selection {
composerAttachments = []
}
if needsSelectionLoad(selection) {
return startSelectionRefreshTask()
}
return selectionTask
} }
resetSelectionLoading()
draftKind = nil
selectedItem = selection
errorMessage = nil
switch selection {
case let .chat(chatID):
if selectedChat?.id != chatID {
selectedChat = nil
}
selectedSearch = nil
case let .search(searchID):
if selectedSearch?.id != searchID {
selectedSearch = nil
}
selectedChat = nil
composerAttachments = []
case .settings:
selectedChat = nil
selectedSearch = nil
pendingChatState = nil
return nil
}
return startSelectionRefreshTask()
} }
func selectPreviousSidebarItem() { func selectPreviousSidebarItem() {
@@ -565,12 +665,13 @@ final class SybilViewModel {
func sendComposer() async { func sendComposer() async {
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines) let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
let attachments = composerAttachments let attachments = composerAttachments
let sendContext = currentSendContext
guard !isSending else { guard !isSending else {
return return
} }
if isSearchMode { if sendContext.isSearch {
guard !content.isEmpty else { return } guard !content.isEmpty else { return }
} else if content.isEmpty && attachments.isEmpty { } else if content.isEmpty && attachments.isEmpty {
return return
@@ -579,10 +680,11 @@ final class SybilViewModel {
composer = "" composer = ""
composerAttachments = [] composerAttachments = []
errorMessage = nil errorMessage = nil
activeSendContext = sendContext
isSending = true isSending = true
do { do {
if isSearchMode { if sendContext.isSearch {
SybilLog.info(SybilLog.ui, "Sending search query") SybilLog.info(SybilLog.ui, "Sending search query")
try await sendSearch(query: content) try await sendSearch(query: content)
} else { } else {
@@ -590,35 +692,43 @@ final class SybilViewModel {
try await sendChat(content: content, attachments: attachments) try await sendChat(content: content, attachments: attachments)
} }
} catch { } catch {
errorMessage = normalizeAPIError(error) let shouldSurfaceError = isSendContextVisible(sendContext) || (activeSendContext.map { isSendContextVisible($0) } ?? false)
if shouldSurfaceError {
errorMessage = normalizeAPIError(error)
}
SybilLog.error(SybilLog.ui, "Send failed", error: error) SybilLog.error(SybilLog.ui, "Send failed", error: error)
if case let .chat(chatID) = selectedItem { if shouldSurfaceError, case let .chat(chatID) = selectedItem {
do { do {
let chat = try await client().getChat(chatID: chatID) let chat = try await client().getChat(chatID: chatID)
selectedChat = chat if selectedItem == .chat(chatID), draftKind == nil {
selectedChat = chat
}
} catch { } catch {
SybilLog.error(SybilLog.ui, "Fallback chat refresh after failure failed", error: error) SybilLog.error(SybilLog.ui, "Fallback chat refresh after failure failed", error: error)
} }
} }
if case let .search(searchID) = selectedItem { if shouldSurfaceError, case let .search(searchID) = selectedItem {
do { do {
let search = try await client().getSearch(searchID: searchID) let search = try await client().getSearch(searchID: searchID)
selectedSearch = search if selectedItem == .search(searchID), draftKind == nil {
selectedSearch = search
}
} catch { } catch {
SybilLog.error(SybilLog.ui, "Fallback search refresh after failure failed", error: error) SybilLog.error(SybilLog.ui, "Fallback search refresh after failure failed", error: error)
} }
} }
if !isSearchMode { if !sendContext.isSearch, shouldSurfaceError {
composer = content composer = content
composerAttachments = attachments composerAttachments = attachments
pendingChatState = nil
} }
pendingChatState = nil
} }
isSending = false isSending = false
activeSendContext = nil
} }
func appendComposerAttachments(_ attachments: [ChatAttachment]) throws { func appendComposerAttachments(_ attachments: [ChatAttachment]) throws {
@@ -644,16 +754,25 @@ final class SybilViewModel {
} }
func startChatFromSelectedSearch() async { func startChatFromSelectedSearch() async {
guard let search = selectedSearch, !isCreatingSearchChat, !isSending else { guard let search = currentSelectedSearch, !isCreatingSearchChat, !isSending else {
return return
} }
let sourceSelection = SidebarSelection.search(search.id)
isCreatingSearchChat = true isCreatingSearchChat = true
errorMessage = nil errorMessage = nil
do { do {
let client = try client() let client = try client()
let chat = try await client.createChatFromSearch(searchID: search.id, title: nil) let chat = try await client.createChatFromSearch(searchID: search.id, title: nil)
guard selectedItem == sourceSelection, draftKind == nil else {
chats.removeAll(where: { $0.id == chat.id })
chats.insert(chat, at: 0)
isCreatingSearchChat = false
return
}
draftKind = nil draftKind = nil
pendingChatState = nil pendingChatState = nil
composer = "" composer = ""
@@ -767,6 +886,11 @@ final class SybilViewModel {
) )
errorMessage = nil errorMessage = nil
if draftKind != nil {
isLoadingCollections = false
return
}
if case .settings = selectedItem { if case .settings = selectedItem {
isLoadingCollections = false isLoadingCollections = false
return return
@@ -797,26 +921,68 @@ final class SybilViewModel {
isLoadingCollections = false isLoadingCollections = false
} }
private func resetSelectionLoading() {
selectionTask?.cancel()
selectionTask = nil
isLoadingSelection = false
}
private func startSelectionRefreshTask() -> Task<Void, Never> {
isLoadingSelection = true
let task = Task { [weak self] in
guard let self else {
return
}
await self.refreshSelectionIfNeeded()
}
selectionTask = task
return task
}
private func waitForSelectionLoad(timeout: Duration) async {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: timeout)
while isLoadingSelection, clock.now < deadline {
try? await Task.sleep(for: .milliseconds(10))
}
}
private func needsSelectionLoad(_ selection: SidebarSelection) -> Bool {
switch selection {
case let .chat(chatID):
return selectedChat?.id != chatID
case let .search(searchID):
return selectedSearch?.id != searchID
case .settings:
return false
}
}
private func refreshSelectionIfNeeded() async { private func refreshSelectionIfNeeded() async {
guard let selectedItem else { guard let target = selectedItem else {
selectedChat = nil selectedChat = nil
selectedSearch = nil selectedSearch = nil
isLoadingSelection = false
return return
} }
guard case .settings = selectedItem else { guard case .settings = target else {
isLoadingSelection = true isLoadingSelection = true
do { do {
let client = try client() let client = try client()
switch selectedItem { switch target {
case let .chat(chatID): case let .chat(chatID):
SybilLog.debug(SybilLog.app, "Refreshing chat \(chatID)") SybilLog.debug(SybilLog.app, "Refreshing chat \(chatID)")
selectedChat = try await client.getChat(chatID: chatID) let chat = try await client.getChat(chatID: chatID)
guard selectedItem == target, draftKind == nil else {
return
}
selectedChat = chat
selectedSearch = nil selectedSearch = nil
if let detail = selectedChat, if let provider = chat.lastUsedProvider,
let provider = detail.lastUsedProvider, let model = chat.lastUsedModel,
let model = detail.lastUsedModel,
!model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { !model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.provider = provider self.provider = provider
self.model = model self.model = model
@@ -824,7 +990,11 @@ final class SybilViewModel {
case let .search(searchID): case let .search(searchID):
SybilLog.debug(SybilLog.app, "Refreshing search \(searchID)") SybilLog.debug(SybilLog.app, "Refreshing search \(searchID)")
selectedSearch = try await client.getSearch(searchID: searchID) let search = try await client.getSearch(searchID: searchID)
guard selectedItem == target, draftKind == nil else {
return
}
selectedSearch = search
selectedChat = nil selectedChat = nil
case .settings: case .settings:
@@ -833,20 +1003,24 @@ final class SybilViewModel {
errorMessage = nil errorMessage = nil
} catch { } catch {
if isCancellation(error) { if isCancellation(error) {
SybilLog.debug(SybilLog.app, "Selection refresh cancelled for \(selectedItem.id)") SybilLog.debug(SybilLog.app, "Selection refresh cancelled for \(target.id)")
} else if shouldSuppressInactiveTransportError(error) { } else if shouldSuppressInactiveTransportError(error) {
SybilLog.info(SybilLog.app, "Suppressing selection refresh transport interruption while app is inactive") SybilLog.info(SybilLog.app, "Suppressing selection refresh transport interruption while app is inactive")
} else { } else if selectedItem == target, draftKind == nil {
errorMessage = normalizeAPIError(error) errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.app, "Selection refresh failed", error: error) SybilLog.error(SybilLog.app, "Selection refresh failed", error: error)
} }
} }
isLoadingSelection = false if selectedItem == target, draftKind == nil {
isLoadingSelection = false
selectionTask = nil
}
return return
} }
selectedChat = nil selectedChat = nil
selectedSearch = nil selectedSearch = nil
isLoadingSelection = false
} }
private func sendChat(content: String, attachments: [ChatAttachment]) async throws { private func sendChat(content: String, attachments: [ChatAttachment]) async throws {
@@ -869,7 +1043,7 @@ final class SybilViewModel {
pendingChatState = PendingChatState( pendingChatState = PendingChatState(
chatID: currentChatID, chatID: currentChatID,
messages: (selectedChat?.messages ?? []) + [optimisticUser, optimisticAssistant] messages: (currentSelectedChat?.messages ?? []) + [optimisticUser, optimisticAssistant]
) )
let client = try client() let client = try client()
@@ -878,24 +1052,29 @@ final class SybilViewModel {
if chatID == nil { if chatID == nil {
let created = try await client.createChat(title: nil) let created = try await client.createChat(title: nil)
chatID = created.id chatID = created.id
draftKind = nil let shouldShowCreatedChat = activeSendContext.map { isSendContextVisible($0) } ?? true
selectedItem = .chat(created.id) activeSendContext = .chat(created.id)
chats.removeAll(where: { $0.id == created.id }) chats.removeAll(where: { $0.id == created.id })
chats.insert(created, at: 0) chats.insert(created, at: 0)
selectedChat = ChatDetail( if shouldShowCreatedChat {
id: created.id, draftKind = nil
title: created.title, selectedItem = .chat(created.id)
createdAt: created.createdAt,
updatedAt: created.updatedAt, selectedChat = ChatDetail(
initiatedProvider: created.initiatedProvider, id: created.id,
initiatedModel: created.initiatedModel, title: created.title,
lastUsedProvider: created.lastUsedProvider, createdAt: created.createdAt,
lastUsedModel: created.lastUsedModel, updatedAt: created.updatedAt,
messages: [] initiatedProvider: created.initiatedProvider,
) initiatedModel: created.initiatedModel,
selectedSearch = nil lastUsedProvider: created.lastUsedProvider,
lastUsedModel: created.lastUsedModel,
messages: []
)
selectedSearch = nil
}
SybilLog.info(SybilLog.app, "Created chat \(created.id)") SybilLog.info(SybilLog.app, "Created chat \(created.id)")
} }
@@ -907,7 +1086,7 @@ final class SybilViewModel {
pendingChatState?.chatID = chatID pendingChatState?.chatID = chatID
let baseChat: ChatDetail let baseChat: ChatDetail
if let selectedChat, selectedChat.id == chatID { if let selectedChat = currentSelectedChat, selectedChat.id == chatID {
baseChat = selectedChat baseChat = selectedChat
} else { } else {
baseChat = try await client.getChat(chatID: chatID) baseChat = try await client.getChat(chatID: chatID)
@@ -929,7 +1108,7 @@ final class SybilViewModel {
let streamLifecycleGeneration = appLifecycleGeneration let streamLifecycleGeneration = appLifecycleGeneration
let streamStartedWhileInactive = !isAppActive let streamStartedWhileInactive = !isAppActive
if isUntitledChat(chatID: chatID, detail: selectedChat) { if isUntitledChat(chatID: chatID, detail: currentSelectedChat) {
Task { [weak self] in Task { [weak self] in
guard let self else { return } guard let self else { return }
do { do {
@@ -1001,9 +1180,25 @@ final class SybilViewModel {
return return
} }
await refreshCollections(preferredSelection: .chat(chatID)) let sentChatSelection = SidebarSelection.chat(chatID)
let shouldKeepSentChatSelected = selectedItem == sentChatSelection && draftKind == nil
await refreshCollections(
preferredSelection: shouldKeepSentChatSelected ? sentChatSelection : selectedItem,
refreshSelection: false
)
guard selectedItem == sentChatSelection, draftKind == nil else {
pendingChatState = nil
return
}
do { do {
selectedChat = try await client.getChat(chatID: chatID) let refreshedChat = try await client.getChat(chatID: chatID)
guard selectedItem == sentChatSelection, draftKind == nil else {
pendingChatState = nil
return
}
selectedChat = refreshedChat
} catch { } catch {
if shouldSuppressLifecycleTransportError( if shouldSuppressLifecycleTransportError(
error, error,
@@ -1057,12 +1252,17 @@ final class SybilViewModel {
if searchID == nil { if searchID == nil {
let created = try await client.createSearch(title: String(query.prefix(80)), query: query) let created = try await client.createSearch(title: String(query.prefix(80)), query: query)
searchID = created.id searchID = created.id
draftKind = nil let shouldShowCreatedSearch = activeSendContext.map { isSendContextVisible($0) } ?? true
selectedItem = .search(created.id) activeSendContext = .search(created.id)
searches.removeAll(where: { $0.id == created.id }) searches.removeAll(where: { $0.id == created.id })
searches.insert(created, at: 0) searches.insert(created, at: 0)
if shouldShowCreatedSearch {
draftKind = nil
selectedItem = .search(created.id)
}
SybilLog.info(SybilLog.app, "Created search \(created.id)") SybilLog.info(SybilLog.app, "Created search \(created.id)")
} }
@@ -1071,21 +1271,23 @@ final class SybilViewModel {
} }
let now = Date() let now = Date()
selectedSearch = SearchDetail( if selectedItem == .search(searchID), draftKind == nil {
id: searchID, selectedSearch = SearchDetail(
title: String(query.prefix(80)), id: searchID,
query: query, title: String(query.prefix(80)),
createdAt: selectedSearch?.createdAt ?? now, query: query,
updatedAt: now, createdAt: currentSelectedSearch?.createdAt ?? now,
requestId: nil, updatedAt: now,
latencyMs: nil, requestId: nil,
error: nil, latencyMs: nil,
answerText: nil, error: nil,
answerRequestId: nil, answerText: nil,
answerCitations: nil, answerRequestId: nil,
answerError: nil, answerCitations: nil,
results: [] answerError: nil,
) results: []
)
}
let streamStatus = SearchStreamStatus() let streamStatus = SearchStreamStatus()
let streamLifecycleGeneration = appLifecycleGeneration let streamLifecycleGeneration = appLifecycleGeneration
@@ -1123,7 +1325,12 @@ final class SybilViewModel {
return return
} }
await refreshCollections(preferredSelection: .search(searchID)) let sentSearchSelection = SidebarSelection.search(searchID)
let shouldKeepSentSearchSelected = selectedItem == sentSearchSelection && draftKind == nil
await refreshCollections(
preferredSelection: shouldKeepSentSearchSelected ? sentSearchSelection : selectedItem,
refreshSelection: false
)
} }
private func applySearchEvent( private func applySearchEvent(
@@ -1131,8 +1338,10 @@ final class SybilViewModel {
searchID: String, searchID: String,
streamStatus: SearchStreamStatus streamStatus: SearchStreamStatus
) async { ) async {
guard let current = selectedSearch, current.id == searchID else { guard let current = currentSelectedSearch, current.id == searchID else {
if case let .done(payload) = event { if case let .done(payload) = event,
selectedItem == .search(searchID),
draftKind == nil {
selectedSearch = payload.search selectedSearch = payload.search
} }
return return
@@ -1239,6 +1448,22 @@ final class SybilViewModel {
return nil return nil
} }
private var currentSelectedChat: ChatDetail? {
guard case let .chat(chatID) = selectedItem,
selectedChat?.id == chatID else {
return nil
}
return selectedChat
}
private var currentSelectedSearch: SearchDetail? {
guard case let .search(searchID) = selectedItem,
selectedSearch?.id == searchID else {
return nil
}
return selectedSearch
}
private var currentSearchID: String? { private var currentSearchID: String? {
if draftKind == .search { if draftKind == .search {
return nil return nil
@@ -1249,6 +1474,33 @@ final class SybilViewModel {
return nil return nil
} }
private var currentSendContext: ActiveSendContext {
if isSearchMode {
if let searchID = currentSearchID {
return .search(searchID)
}
return .draftSearch(draftIdentity)
}
if let chatID = currentChatID {
return .chat(chatID)
}
return .draftChat(draftIdentity)
}
private func isSendContextVisible(_ context: ActiveSendContext) -> Bool {
switch context {
case let .draftChat(identity):
return draftKind == .chat && draftIdentity == identity
case let .chat(chatID):
return selectedItem == .chat(chatID)
case let .draftSearch(identity):
return draftKind == .search && draftIdentity == identity
case let .search(searchID):
return selectedItem == .search(searchID)
}
}
private func hasSelection(_ selection: SidebarSelection, chats: [ChatSummary], searches: [SearchSummary]) -> Bool { private func hasSelection(_ selection: SidebarSelection, chats: [ChatSummary], searches: [SearchSummary]) -> Bool {
switch selection { switch selection {
case let .chat(chatID): case let .chat(chatID):

View File

@@ -5,10 +5,18 @@ import SwiftUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
import UIKit import UIKit
enum SybilWorkspaceNavigationLeadingControl {
case back
case hidden
case showSidebar
}
struct SybilWorkspaceView: View { struct SybilWorkspaceView: View {
@Bindable var viewModel: SybilViewModel @Bindable var viewModel: SybilViewModel
var composerFocusRequest: Int = 0 var composerFocusRequest: Int = 0
var usesCustomChatNavigation: Bool = false var usesCustomWorkspaceNavigation: Bool = true
var navigationLeadingControl: SybilWorkspaceNavigationLeadingControl = .back
var onShowSidebar: (() -> Void)? = nil
var onRequestNewChat: (() -> Void)? = nil var onRequestNewChat: (() -> Void)? = nil
@FocusState private var composerFocused: Bool @FocusState private var composerFocused: Bool
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -26,7 +34,8 @@ struct SybilWorkspaceView: View {
@State private var newChatSwipeDidTriggerHaptic = false @State private var newChatSwipeDidTriggerHaptic = false
@State private var newChatSwipeFeedbackGenerator: UIImpactFeedbackGenerator? @State private var newChatSwipeFeedbackGenerator: UIImpactFeedbackGenerator?
private let customChatNavigationContentInset: CGFloat = 96 private let customWorkspaceNavigationContentInset: CGFloat = 96
private let composerOverlayContentInset: CGFloat = 112
private var isSettingsSelected: Bool { private var isSettingsSelected: Bool {
if case .settings = viewModel.selectedItem { if case .settings = viewModel.selectedItem {
@@ -39,8 +48,8 @@ struct SybilWorkspaceView: View {
viewModel.errorMessage != nil viewModel.errorMessage != nil
} }
private var showsCustomChatNavigation: Bool { private var showsCustomWorkspaceNavigation: Bool {
usesCustomChatNavigation && !isSettingsSelected && !viewModel.isSearchMode usesCustomWorkspaceNavigation && !isSettingsSelected
} }
private var transcriptScrollContextID: String { private var transcriptScrollContextID: String {
@@ -53,6 +62,14 @@ struct SybilWorkspaceView: View {
return "chat:none" return "chat:none"
} }
private var shouldAutoFocusComposer: Bool {
viewModel.draftKind == .chat && viewModel.displayedMessages.isEmpty
}
private var composerFocusPolicyID: String {
"\(transcriptScrollContextID):\(composerFocusRequest):\(shouldAutoFocusComposer)"
}
private var canSwipeToCreateChat: Bool { private var canSwipeToCreateChat: Bool {
guard onRequestNewChat != nil else { guard onRequestNewChat != nil else {
return false return false
@@ -93,12 +110,12 @@ struct SybilWorkspaceView: View {
} }
.offset(x: newChatSwipeCompletionOffset) .offset(x: newChatSwipeCompletionOffset)
.background(SybilTheme.background) .background(SybilTheme.background)
.navigationTitle(showsCustomChatNavigation ? "" : viewModel.selectedTitle) .navigationTitle(showsCustomWorkspaceNavigation ? "" : viewModel.selectedTitle)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbarRole(.editor) .toolbarRole(.editor)
.toolbar(showsCustomChatNavigation ? .hidden : .visible, for: .navigationBar) .toolbar(showsCustomWorkspaceNavigation ? .hidden : .visible, for: .navigationBar)
.toolbar { .toolbar {
if !isSettingsSelected && !showsCustomChatNavigation { if !isSettingsSelected && !showsCustomWorkspaceNavigation {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
if viewModel.isSearchMode { if viewModel.isSearchMode {
searchModeChip searchModeChip
@@ -115,8 +132,8 @@ struct SybilWorkspaceView: View {
} }
resetNewChatSwipe(animated: false) resetNewChatSwipe(animated: false)
} }
.task(id: composerFocusRequest) { .task(id: composerFocusPolicyID) {
await focusComposerIfRequested() await applyComposerFocusPolicy()
} }
} }
@@ -124,10 +141,10 @@ struct SybilWorkspaceView: View {
ZStack(alignment: .top) { ZStack(alignment: .top) {
workspaceContentStack workspaceContentStack
if showsCustomChatNavigation { if showsCustomWorkspaceNavigation {
SybilChatCharacterBackdrop(isBusy: viewModel.isSending) SybilWorkspaceCharacterBackdrop(isBusy: viewModel.isSending)
.allowsHitTesting(false) .allowsHitTesting(false)
customChatNavigationBar customWorkspaceNavigationBar
} }
} }
} }
@@ -141,15 +158,17 @@ struct SybilWorkspaceView: View {
.overlay(SybilTheme.border) .overlay(SybilTheme.border)
} }
Group { ZStack(alignment: .bottom) {
if isSettingsSelected { if isSettingsSelected {
SybilSettingsView(viewModel: viewModel) SybilSettingsView(viewModel: viewModel)
} else if viewModel.isSearchMode { } else if viewModel.isSearchMode {
SybilSearchResultsView( SybilSearchResultsView(
search: viewModel.selectedSearch, search: viewModel.displayedSearch,
isLoading: viewModel.isLoadingSelection, isLoading: viewModel.isLoadingSelection,
isRunning: viewModel.isSending, isRunning: viewModel.isRunningVisibleSearch,
isStartingChat: viewModel.isCreatingSearchChat isStartingChat: viewModel.isCreatingSearchChat,
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0
) { ) {
Task { Task {
await viewModel.startChatFromSelectedSearch() await viewModel.startChatFromSelectedSearch()
@@ -159,11 +178,16 @@ struct SybilWorkspaceView: View {
SybilChatTranscriptView( SybilChatTranscriptView(
messages: viewModel.displayedMessages, messages: viewModel.displayedMessages,
isLoading: viewModel.isLoadingSelection, isLoading: viewModel.isLoadingSelection,
isSending: viewModel.isSending, isSending: viewModel.isSendingVisibleChat,
topContentInset: showsCustomChatNavigation ? customChatNavigationContentInset : 0 topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0
) )
.id(transcriptScrollContextID) .id(transcriptScrollContextID)
} }
if viewModel.showsComposer {
composerBar
}
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background { .background {
@@ -184,24 +208,12 @@ struct SybilWorkspaceView: View {
} }
) )
} }
if viewModel.showsComposer {
Divider()
.overlay(SybilTheme.border)
composerBar
}
} }
} }
private var customChatNavigationBar: some View { private var customWorkspaceNavigationBar: some View {
HStack(spacing: 14) { HStack(spacing: 14) {
Button { workspaceNavigationLeadingControl
dismiss()
} label: {
SybilNavigationIcon(systemImage: "chevron.left")
}
.buttonStyle(.plain)
.accessibilityLabel("Back")
Text(viewModel.selectedTitle) Text(viewModel.selectedTitle)
.font(.sybil(size: 16, weight: .semibold)) .font(.sybil(size: 16, weight: .semibold))
@@ -211,7 +223,7 @@ struct SybilWorkspaceView: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
providerModelNavigationMenu workspaceNavigationTrailingControl
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.top, 10) .padding(.top, 10)
@@ -222,6 +234,32 @@ struct SybilWorkspaceView: View {
} }
} }
@ViewBuilder
private var workspaceNavigationLeadingControl: some View {
switch navigationLeadingControl {
case .back:
Button {
dismiss()
} label: {
SybilNavigationIcon(systemImage: "chevron.left")
}
.buttonStyle(.plain)
.accessibilityLabel("Back")
case .showSidebar:
Button {
onShowSidebar?()
} label: {
SybilNavigationIcon(systemImage: "sidebar.left")
}
.buttonStyle(.plain)
.accessibilityLabel("Show sidebar")
case .hidden:
EmptyView()
}
}
private func beginNewChatSwipe(containerWidth: CGFloat) { private func beginNewChatSwipe(containerWidth: CGFloat) {
let update = { let update = {
newChatSwipeContainerWidth = max(containerWidth, 1) newChatSwipeContainerWidth = max(containerWidth, 1)
@@ -316,15 +354,13 @@ struct SybilWorkspaceView: View {
} }
@MainActor @MainActor
private func focusComposerIfRequested() async { private func applyComposerFocusPolicy() async {
guard composerFocusRequest > 0 else { guard shouldAutoFocusComposer else {
composerFocused = false
return return
} }
await Task.yield() guard shouldAutoFocusComposer, viewModel.showsComposer else {
try? await Task.sleep(for: .milliseconds(80))
guard viewModel.showsComposer, !viewModel.isSearchMode else {
return return
} }
composerFocused = true composerFocused = true
@@ -367,6 +403,22 @@ struct SybilWorkspaceView: View {
} }
} }
@ViewBuilder
private var workspaceNavigationTrailingControl: some View {
if viewModel.isSearchMode {
searchModeNavigationLabel
} else {
providerModelNavigationMenu
}
}
private var searchModeNavigationLabel: some View {
Label("Search", systemImage: "globe")
.font(.sybil(.caption, weight: .medium))
.foregroundStyle(SybilTheme.accent)
.lineLimit(1)
}
private func providerModelMenu<Label: View>(@ViewBuilder label: @escaping () -> Label) -> some View { private func providerModelMenu<Label: View>(@ViewBuilder label: @escaping () -> Label) -> some View {
Menu { Menu {
providerModelMenuItems providerModelMenuItems
@@ -456,7 +508,7 @@ struct SybilWorkspaceView: View {
} }
TextField( TextField(
viewModel.isSearchMode ? "Search the web" : "Message Sybil", viewModel.isSearchMode ? "Search the web" : "Enter Prompt",
text: $viewModel.composer, text: $viewModel.composer,
axis: .vertical axis: .vertical
) )
@@ -473,10 +525,7 @@ struct SybilWorkspaceView: View {
.background( .background(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.composerGradient) .fill(SybilTheme.composerGradient)
.overlay( .opacity(0.98)
RoundedRectangle(cornerRadius: 12)
.stroke(SybilTheme.primary.opacity(0.34), lineWidth: 1)
)
) )
.foregroundStyle(SybilTheme.text) .foregroundStyle(SybilTheme.text)
@@ -501,23 +550,19 @@ struct SybilWorkspaceView: View {
} }
} }
.padding(.horizontal, 14) .padding(.horizontal, 14)
.padding(.vertical, 12) .padding(.top, 34)
.background( .padding(.bottom, 12)
LinearGradient( .background(alignment: .bottom) {
colors: [ SybilComposerFadeBackground()
SybilTheme.background.opacity(0.18), .allowsHitTesting(false)
SybilTheme.background.opacity(0.96) }
],
startPoint: .top,
endPoint: .bottom
)
)
.overlay { .overlay {
if isComposerDropTargeted && !viewModel.isSearchMode { if isComposerDropTargeted && !viewModel.isSearchMode {
RoundedRectangle(cornerRadius: 18) RoundedRectangle(cornerRadius: 18)
.stroke(SybilTheme.accent.opacity(0.78), style: StrokeStyle(lineWidth: 1.5, dash: [7, 5])) .stroke(SybilTheme.accent.opacity(0.78), style: StrokeStyle(lineWidth: 1.5, dash: [7, 5]))
.padding(.horizontal, 14) .padding(.horizontal, 14)
.padding(.vertical, 10) .padding(.top, 32)
.padding(.bottom, 10)
} }
} }
.onDrop(of: [UTType.fileURL.identifier, UTType.image.identifier], isTargeted: $isComposerDropTargeted) { providers in .onDrop(of: [UTType.fileURL.identifier, UTType.image.identifier], isTargeted: $isComposerDropTargeted) { providers in
@@ -937,6 +982,60 @@ private extension UIView {
} }
} }
private struct SybilComposerFadeBackground: View {
var body: some View {
ZStack(alignment: .bottomLeading) {
LinearGradient(
colors: [
Color.clear,
SybilTheme.background.opacity(0.30),
SybilTheme.background.opacity(0.86),
SybilTheme.background.opacity(0.86),
SybilTheme.background.opacity(0.98)
],
startPoint: .top,
endPoint: .bottom
)
LinearGradient(
colors: [
SybilTheme.primary.opacity(0.18),
SybilTheme.surface.opacity(0.16),
SybilTheme.accent.opacity(0.08)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.mask(
LinearGradient(
colors: [
Color.clear,
Color.black.opacity(0.42),
Color.black
],
startPoint: .top,
endPoint: .bottom
)
)
.blendMode(.screen)
RadialGradient(
colors: [
SybilTheme.primary.opacity(0.28),
SybilTheme.primary.opacity(0.08),
Color.clear
],
center: .bottomLeading,
startRadius: 8,
endRadius: 180
)
.blendMode(.screen)
.offset(x: -42, y: 42)
}
.ignoresSafeArea(edges: .bottom)
}
}
private struct SybilNavigationIcon: View { private struct SybilNavigationIcon: View {
var systemImage: String var systemImage: String
@@ -979,11 +1078,12 @@ private struct SybilNavigationFadeBackground: View {
.blendMode(.screen) .blendMode(.screen)
.offset(x: -44, y: -46) .offset(x: -44, y: -46)
} }
.frame(height: 200.0)
.ignoresSafeArea(edges: .top) .ignoresSafeArea(edges: .top)
} }
} }
private struct SybilChatCharacterBackdrop: View { private struct SybilWorkspaceCharacterBackdrop: View {
var isBusy: Bool var isBusy: Bool
var body: some View { var body: some View {
@@ -1207,15 +1307,10 @@ private struct NewChatSwipeBackdrop: View {
.frame(width: 72, height: 72) .frame(width: 72, height: 72)
.blur(radius: 10) .blur(radius: 10)
Image(systemName: hasLatched ? "checkmark" : "plus") Image(systemName: "plus")
.font(.system(size: 31, weight: .bold, design: .rounded)) .font(.system(size: 31, weight: .bold, design: .rounded))
.foregroundStyle(SybilTheme.text) .foregroundStyle(SybilTheme.text)
.symbolEffect(.bounce, value: hasLatched) .symbolEffect(.bounce, value: hasLatched)
Image(systemName: "sparkle")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle((hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.90))
.offset(x: -26, y: -25)
} }
.frame(width: 92, height: 92) .frame(width: 92, height: 92)
.background( .background(

View File

@@ -17,8 +17,11 @@ private actor MockSybilClient: SybilAPIClienting {
private let searchesResponse: [SearchSummary] private let searchesResponse: [SearchSummary]
private let chatDetails: [String: ChatDetail] private let chatDetails: [String: ChatDetail]
private let searchDetails: [String: SearchDetail] private let searchDetails: [String: SearchDetail]
private let createChatResponse: ChatSummary?
private var snapshot = MockClientCallSnapshot() private var snapshot = MockClientCallSnapshot()
private var getChatDelayNanoseconds: UInt64 = 0
private var getSearchDelayNanoseconds: UInt64 = 0
private var completionStreamNetworkErrorMessage: String? private var completionStreamNetworkErrorMessage: String?
private var completionStreamDelayNanoseconds: UInt64 = 0 private var completionStreamDelayNanoseconds: UInt64 = 0
private var searchStreamNetworkErrorMessage: String? private var searchStreamNetworkErrorMessage: String?
@@ -28,12 +31,14 @@ private actor MockSybilClient: SybilAPIClienting {
chatsResponse: [ChatSummary] = [], chatsResponse: [ChatSummary] = [],
searchesResponse: [SearchSummary] = [], searchesResponse: [SearchSummary] = [],
chatDetails: [String: ChatDetail] = [:], chatDetails: [String: ChatDetail] = [:],
searchDetails: [String: SearchDetail] = [:] searchDetails: [String: SearchDetail] = [:],
createChatResponse: ChatSummary? = nil
) { ) {
self.chatsResponse = chatsResponse self.chatsResponse = chatsResponse
self.searchesResponse = searchesResponse self.searchesResponse = searchesResponse
self.chatDetails = chatDetails self.chatDetails = chatDetails
self.searchDetails = searchDetails self.searchDetails = searchDetails
self.createChatResponse = createChatResponse
} }
func currentSnapshot() -> MockClientCallSnapshot { func currentSnapshot() -> MockClientCallSnapshot {
@@ -45,6 +50,14 @@ private actor MockSybilClient: SybilAPIClienting {
completionStreamDelayNanoseconds = delayNanoseconds completionStreamDelayNanoseconds = delayNanoseconds
} }
func setGetChatDelay(_ delayNanoseconds: UInt64) {
getChatDelayNanoseconds = delayNanoseconds
}
func setGetSearchDelay(_ delayNanoseconds: UInt64) {
getSearchDelayNanoseconds = delayNanoseconds
}
func setSearchStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) { func setSearchStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
searchStreamNetworkErrorMessage = message searchStreamNetworkErrorMessage = message
searchStreamDelayNanoseconds = delayNanoseconds searchStreamDelayNanoseconds = delayNanoseconds
@@ -60,11 +73,17 @@ private actor MockSybilClient: SybilAPIClienting {
} }
func createChat(title: String?) async throws -> ChatSummary { func createChat(title: String?) async throws -> ChatSummary {
if let createChatResponse {
return createChatResponse
}
throw UnexpectedClientCall() throw UnexpectedClientCall()
} }
func getChat(chatID: String) async throws -> ChatDetail { func getChat(chatID: String) async throws -> ChatDetail {
snapshot.getChat += 1 snapshot.getChat += 1
if getChatDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: getChatDelayNanoseconds)
}
guard let detail = chatDetails[chatID] else { guard let detail = chatDetails[chatID] else {
throw UnexpectedClientCall() throw UnexpectedClientCall()
} }
@@ -90,6 +109,9 @@ private actor MockSybilClient: SybilAPIClienting {
func getSearch(searchID: String) async throws -> SearchDetail { func getSearch(searchID: String) async throws -> SearchDetail {
snapshot.getSearch += 1 snapshot.getSearch += 1
if getSearchDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: getSearchDelayNanoseconds)
}
guard let detail = searchDetails[searchID] else { guard let detail = searchDetails[searchID] else {
throw UnexpectedClientCall() throw UnexpectedClientCall()
} }
@@ -293,6 +315,100 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
#expect(viewModel.selectedSearch?.answerText == "fresh answer") #expect(viewModel.selectedSearch?.answerText == "fresh answer")
} }
@MainActor
@Test func selectingChatClearsStaleTranscriptUntilNewDetailLoads() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_210)
let staleDetail = makeChatDetail(id: "chat-old", date: date, body: "stale transcript")
let freshDetail = makeChatDetail(id: "chat-new", date: date, body: "fresh transcript")
let client = MockSybilClient(chatDetails: ["chat-new": freshDetail])
await client.setGetChatDelay(50_000_000)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.selectedItem = .chat("chat-old")
viewModel.selectedChat = staleDetail
viewModel.select(.chat("chat-new"))
#expect(viewModel.displayedMessages.isEmpty)
#expect(viewModel.isLoadingSelection)
try await Task.sleep(nanoseconds: 90_000_000)
#expect(viewModel.displayedMessages.first?.content == "fresh transcript")
#expect(!viewModel.isLoadingSelection)
}
@MainActor
@Test func navigationSelectionWaitsForFastTranscriptLoad() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_220)
let detail = makeChatDetail(id: "chat-fast", date: date, body: "loaded before push")
let client = MockSybilClient(chatDetails: ["chat-fast": detail])
await client.setGetChatDelay(20_000_000)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
await viewModel.selectForNavigation(.chat("chat-fast"), preloadTimeout: .milliseconds(500))
#expect(viewModel.selectedItem == .chat("chat-fast"))
#expect(viewModel.displayedMessages.first?.content == "loaded before push")
#expect(!viewModel.isLoadingSelection)
}
@MainActor
@Test func navigationSelectionTimesOutAndKeepsLoadingTranscript() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_230)
let detail = makeChatDetail(id: "chat-slow", date: date, body: "loaded after push")
let client = MockSybilClient(chatDetails: ["chat-slow": detail])
await client.setGetChatDelay(100_000_000)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
await viewModel.selectForNavigation(.chat("chat-slow"), preloadTimeout: .milliseconds(10))
#expect(viewModel.selectedItem == .chat("chat-slow"))
#expect(viewModel.displayedMessages.isEmpty)
#expect(viewModel.isLoadingSelection)
try await Task.sleep(nanoseconds: 150_000_000)
#expect(viewModel.displayedMessages.first?.content == "loaded after push")
#expect(!viewModel.isLoadingSelection)
}
@MainActor
@Test func newDraftChatDoesNotShowTypingStateFromPreviousSend() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_240)
let detail = makeChatDetail(id: "chat-typing", date: date, body: "existing transcript")
let client = MockSybilClient(chatDetails: ["chat-typing": detail])
await client.setCompletionStreamNetworkError(
"Network error -1005 while requesting POST: The network connection was lost.",
delayNanoseconds: 50_000_000
)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.selectedItem = .chat("chat-typing")
viewModel.selectedChat = detail
viewModel.composer = "continue"
let sendTask = Task {
await viewModel.sendComposer()
}
try await Task.sleep(nanoseconds: 10_000_000)
#expect(viewModel.isSendingVisibleChat)
viewModel.startNewChat()
#expect(viewModel.displayedMessages.isEmpty)
#expect(!viewModel.isSendingVisibleChat)
await sendTask.value
}
@MainActor @MainActor
@Test func backgroundChatStreamInterruptionIsSuppressedUntilForegroundRefresh() async throws { @Test func backgroundChatStreamInterruptionIsSuppressedUntilForegroundRefresh() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_300) let date = Date(timeIntervalSince1970: 1_700_000_300)

View File

@@ -1,10 +1,28 @@
simulator := "platform=iOS Simulator,name=iPhone 16e,OS=latest"
simulator_name := "iPhone 16e"
derived_data := "build/DerivedData"
default: default:
@just build @just build
build: build:
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
if command -v xcbeautify >/dev/null 2>&1; then \ if command -v xcbeautify >/dev/null 2>&1; then \
xcodebuild -scheme Sybil -destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest' | xcbeautify; \ xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
else \ else \
xcodebuild -scheme Sybil -destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest'; \ xcodebuild -scheme Sybil -destination '{{simulator}}'; \
fi fi
test:
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -parallel-testing-enabled NO
run:
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
xcrun simctl boot '{{simulator_name}}' 2>/dev/null || true
xcodebuild -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}'
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
xcrun simctl launch booted net.buzzert.sybil2
screenshot path="build/sybil-screenshot.png":
mkdir -p "$(dirname '{{path}}')"
xcrun simctl io booted screenshot '{{path}}'

Binary file not shown.

View File

@@ -4,6 +4,14 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@font-face {
font-family: "StalinistOne";
src: url("/StalinistOne-Regular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
:root { :root {
color-scheme: dark; color-scheme: dark;
--background: 235 45% 4%; --background: 235 45% 4%;
@@ -57,8 +65,8 @@ textarea {
} }
.sybil-wordmark { .sybil-wordmark {
font-family: "Orbitron", "Inter", sans-serif; font-family: "StalinistOne", "Orbitron", "Inter", sans-serif;
font-weight: 900; font-weight: 400;
letter-spacing: 0; letter-spacing: 0;
line-height: 1; line-height: 1;
} }