Compare commits
6
Commits
26d917e96a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ef48fad82 | ||
|
|
21beab26f1 | ||
|
|
050a46d7a8 | ||
|
|
3217c3f66e | ||
|
|
3a9c9bb74a | ||
|
|
04ac39499f |
@@ -74,6 +74,16 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
return response.chat
|
return response.chat
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func forkChat(chatID: String, messageID: String? = nil) async throws -> ChatSummary {
|
||||||
|
let response = try await request(
|
||||||
|
"/v1/chats/\(chatID)/fork",
|
||||||
|
method: "POST",
|
||||||
|
body: AnyEncodable(ChatForkBody(messageId: messageID)),
|
||||||
|
responseType: ChatCreateResponse.self
|
||||||
|
)
|
||||||
|
return response.chat
|
||||||
|
}
|
||||||
|
|
||||||
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
|
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
|
||||||
let response = try await request(
|
let response = try await request(
|
||||||
"/v1/chats/\(chatID)",
|
"/v1/chats/\(chatID)",
|
||||||
@@ -704,6 +714,10 @@ private struct ChatTitleUpdateBody: Encodable {
|
|||||||
var title: String
|
var title: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct ChatForkBody: Encodable {
|
||||||
|
var messageId: String?
|
||||||
|
}
|
||||||
|
|
||||||
private struct StarUpdateBody: Encodable {
|
private struct StarUpdateBody: Encodable {
|
||||||
var starred: Bool
|
var starred: Bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ protocol SybilAPIClienting: Sendable {
|
|||||||
messages: [CompletionRequestMessage]?
|
messages: [CompletionRequestMessage]?
|
||||||
) async throws -> ChatSummary
|
) async throws -> ChatSummary
|
||||||
func getChat(chatID: String) async throws -> ChatDetail
|
func getChat(chatID: String) async throws -> ChatDetail
|
||||||
|
func forkChat(chatID: String, messageID: String?) async throws -> ChatSummary
|
||||||
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary
|
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary
|
||||||
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary
|
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary
|
||||||
func deleteChat(chatID: String) async throws
|
func deleteChat(chatID: String) async throws
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import MarkdownUI
|
import MarkdownUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
struct SybilChatTranscriptView: View {
|
struct SybilChatTranscriptView: View {
|
||||||
var messages: [Message]
|
var messages: [Message]
|
||||||
@@ -9,6 +10,8 @@ struct SybilChatTranscriptView: View {
|
|||||||
var topContentInset: CGFloat = 0
|
var topContentInset: CGFloat = 0
|
||||||
var bottomContentInset: CGFloat = 0
|
var bottomContentInset: CGFloat = 0
|
||||||
var bottomPinRequestID: Int = 0
|
var bottomPinRequestID: Int = 0
|
||||||
|
var isForking: Bool = false
|
||||||
|
var onForkResponse: ((String) -> Void)? = nil
|
||||||
|
|
||||||
@State private var hasTrackedToolCallMessages = false
|
@State private var hasTrackedToolCallMessages = false
|
||||||
@State private var knownToolCallMessageIDs: Set<String> = []
|
@State private var knownToolCallMessageIDs: Set<String> = []
|
||||||
@@ -45,7 +48,11 @@ struct SybilChatTranscriptView: View {
|
|||||||
MessageBubble(
|
MessageBubble(
|
||||||
message: message,
|
message: message,
|
||||||
isSending: isSending,
|
isSending: isSending,
|
||||||
streamingAssistantPresentation: streamingAssistantPresentation
|
streamingAssistantPresentation: streamingAssistantPresentation,
|
||||||
|
isForking: isForking,
|
||||||
|
onFork: {
|
||||||
|
onForkResponse?(message.id)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
case let .toolGroup(id, messages):
|
case let .toolGroup(id, messages):
|
||||||
@@ -146,6 +153,8 @@ private struct MessageBubble: View {
|
|||||||
var message: Message
|
var message: Message
|
||||||
var isSending: Bool
|
var isSending: Bool
|
||||||
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
||||||
|
var isForking: Bool
|
||||||
|
var onFork: (() -> Void)?
|
||||||
|
|
||||||
private var toolCallMetadata: ToolCallMetadata? {
|
private var toolCallMetadata: ToolCallMetadata? {
|
||||||
message.toolCallMetadata
|
message.toolCallMetadata
|
||||||
@@ -164,7 +173,37 @@ private struct MessageBubble: View {
|
|||||||
return streamingAssistantPresentation
|
return streamingAssistantPresentation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var canFork: Bool {
|
||||||
|
message.role == .assistant &&
|
||||||
|
!message.id.hasPrefix("temp-") &&
|
||||||
|
onFork != nil
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if canFork {
|
||||||
|
bubbleContent
|
||||||
|
.contextMenu {
|
||||||
|
Button {
|
||||||
|
UIPasteboard.general.string = message.content
|
||||||
|
} label: {
|
||||||
|
Label("Copy", systemImage: "doc.on.doc")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
onFork?()
|
||||||
|
} label: {
|
||||||
|
Label("Fork", systemImage: "arrow.triangle.branch")
|
||||||
|
}
|
||||||
|
.disabled(isForking)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bubbleContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var bubbleContent: some View {
|
||||||
HStack(alignment: .top, spacing: 0) {
|
HStack(alignment: .top, spacing: 0) {
|
||||||
leadingSpacer
|
leadingSpacer
|
||||||
|
|
||||||
|
|||||||
@@ -130,7 +130,10 @@ struct SybilPhoneShellView: View {
|
|||||||
route: route,
|
route: route,
|
||||||
onRequestBack: { _ in showSidebarOverlay() },
|
onRequestBack: { _ in showSidebarOverlay() },
|
||||||
onRequestNewChat: sidebarWorkspaceNewChatAction,
|
onRequestNewChat: sidebarWorkspaceNewChatAction,
|
||||||
onShowSidebar: showSidebarOverlay
|
onShowSidebar: showSidebarOverlay,
|
||||||
|
onOpenForkedChat: { chatID in
|
||||||
|
showRoute(.chat(chatID))
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.background(SybilTheme.background)
|
.background(SybilTheme.background)
|
||||||
.blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress))
|
.blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress))
|
||||||
@@ -511,6 +514,9 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
},
|
},
|
||||||
onSelect: { item in
|
onSelect: { item in
|
||||||
onSelect(item.selection)
|
onSelect(item.selection)
|
||||||
|
},
|
||||||
|
onOpenForkedChat: { chatID in
|
||||||
|
onSelect(.chat(chatID))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -585,6 +591,7 @@ private struct SybilPhoneDestinationView: View {
|
|||||||
let onRequestBack: (_ animateNavigation: Bool) -> Void
|
let onRequestBack: (_ animateNavigation: Bool) -> Void
|
||||||
let onRequestNewChat: (() -> Void)?
|
let onRequestNewChat: (() -> Void)?
|
||||||
let onShowSidebar: () -> Void
|
let onShowSidebar: () -> Void
|
||||||
|
let onOpenForkedChat: (String) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
SybilWorkspaceView(
|
SybilWorkspaceView(
|
||||||
@@ -593,7 +600,8 @@ private struct SybilPhoneDestinationView: View {
|
|||||||
navigationLeadingControl: .showSidebar,
|
navigationLeadingControl: .showSidebar,
|
||||||
onShowSidebar: onShowSidebar,
|
onShowSidebar: onShowSidebar,
|
||||||
onRequestBack: onRequestBack,
|
onRequestBack: onRequestBack,
|
||||||
onRequestNewChat: onRequestNewChat
|
onRequestNewChat: onRequestNewChat,
|
||||||
|
onOpenForkedChat: onOpenForkedChat
|
||||||
)
|
)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.task(id: route) {
|
.task(id: route) {
|
||||||
|
|||||||
@@ -102,13 +102,12 @@ struct SybilQuickQuestionView: View {
|
|||||||
set: { viewModel.updateQuickQuestionPrompt($0) }
|
set: { viewModel.updateQuickQuestionPrompt($0) }
|
||||||
),
|
),
|
||||||
selection: $promptSelection,
|
selection: $promptSelection,
|
||||||
axis: .vertical
|
axis: .horizontal
|
||||||
)
|
)
|
||||||
.focused($promptFocused)
|
.focused($promptFocused)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.textInputAutocapitalization(.sentences)
|
.textInputAutocapitalization(.sentences)
|
||||||
.autocorrectionDisabled(false)
|
.autocorrectionDisabled(false)
|
||||||
.lineLimit(1 ... 6)
|
|
||||||
.submitLabel(.send)
|
.submitLabel(.send)
|
||||||
.onSubmit(submitQuestion)
|
.onSubmit(submitQuestion)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Observation
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
struct SybilSidebarView: View {
|
struct SybilSidebarView: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
@@ -111,6 +112,7 @@ struct SybilSidebarItemList: View {
|
|||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
var isSelected: (SidebarItem) -> Bool
|
var isSelected: (SidebarItem) -> Bool
|
||||||
var onSelect: (SidebarItem) -> Void
|
var onSelect: (SidebarItem) -> Void
|
||||||
|
var onOpenForkedChat: ((String) -> Void)? = nil
|
||||||
@State private var renameTarget: SidebarItem?
|
@State private var renameTarget: SidebarItem?
|
||||||
@State private var renameTitle = ""
|
@State private var renameTitle = ""
|
||||||
|
|
||||||
@@ -168,13 +170,26 @@ struct SybilSidebarItemList: View {
|
|||||||
Label(item.starred ? "Unstar" : "Star", systemImage: item.starred ? "star.slash" : "star")
|
Label(item.starred ? "Unstar" : "Star", systemImage: item.starred ? "star.slash" : "star")
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.kind == .chat {
|
if case let .chat(chatID) = item.selection {
|
||||||
|
Button {
|
||||||
|
forkChat(item)
|
||||||
|
} label: {
|
||||||
|
Label("Fork", systemImage: "arrow.triangle.branch")
|
||||||
|
}
|
||||||
|
.disabled(viewModel.isForkingChat || item.isRunning)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
renameTarget = item
|
renameTarget = item
|
||||||
renameTitle = item.title
|
renameTitle = item.title
|
||||||
} label: {
|
} label: {
|
||||||
Label("Rename", systemImage: "pencil")
|
Label("Rename", systemImage: "pencil")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
UIPasteboard.general.string = chatID
|
||||||
|
} label: {
|
||||||
|
Label("Copy Session ID", systemImage: "doc.on.doc")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
@@ -215,6 +230,28 @@ struct SybilSidebarItemList: View {
|
|||||||
.disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
.disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func forkChat(_ item: SidebarItem) {
|
||||||
|
guard case let .chat(chatID) = item.selection else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectionAtStart = viewModel.selectedItem
|
||||||
|
let draftKindAtStart = viewModel.draftKind
|
||||||
|
Task {
|
||||||
|
guard let forkedChat = await viewModel.forkChat(chatID: chatID),
|
||||||
|
viewModel.selectedItem == selectionAtStart,
|
||||||
|
viewModel.draftKind == draftKindAtStart else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let onOpenForkedChat {
|
||||||
|
onOpenForkedChat(forkedChat.id)
|
||||||
|
} else {
|
||||||
|
viewModel.select(.chat(forkedChat.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SybilSidebarRow: View {
|
struct SybilSidebarRow: View {
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ final class SybilViewModel {
|
|||||||
var isLoadingCollections = false
|
var isLoadingCollections = false
|
||||||
var isLoadingSelection = false
|
var isLoadingSelection = false
|
||||||
var isCreatingSearchChat = false
|
var isCreatingSearchChat = false
|
||||||
|
var isForkingChat = false
|
||||||
var chatBottomPinRequestID = 0
|
var chatBottomPinRequestID = 0
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
|
|
||||||
@@ -958,6 +959,36 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func forkChat(chatID: String, messageID: String? = nil) async -> ChatSummary? {
|
||||||
|
guard isAuthenticated, !isForkingChat else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard messageID != nil || !isChatRowRunning(chatID) else {
|
||||||
|
errorMessage = "Wait for the response to finish before forking this chat."
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let forkDescription = messageID.map { "chat \(chatID) from response \($0)" } ?? "chat \(chatID)"
|
||||||
|
SybilLog.info(SybilLog.ui, "Forking \(forkDescription)")
|
||||||
|
errorMessage = nil
|
||||||
|
isForkingChat = true
|
||||||
|
defer {
|
||||||
|
isForkingChat = false
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let forkedChat = try await client().forkChat(chatID: chatID, messageID: messageID)
|
||||||
|
applyChatSummary(forkedChat, moveToFront: true)
|
||||||
|
return forkedChat
|
||||||
|
} catch {
|
||||||
|
errorMessage = normalizeAPIError(error)
|
||||||
|
SybilLog.error(SybilLog.ui, "Fork failed", error: error)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func renameChat(chatID: String, title: String) async {
|
func renameChat(chatID: String, title: String) async {
|
||||||
guard isAuthenticated else {
|
guard isAuthenticated else {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ struct SybilWorkspaceView: View {
|
|||||||
var onShowSidebar: (() -> Void)? = nil
|
var onShowSidebar: (() -> Void)? = nil
|
||||||
var onRequestBack: ((_ animateNavigation: Bool) -> Void)? = nil
|
var onRequestBack: ((_ animateNavigation: Bool) -> Void)? = nil
|
||||||
var onRequestNewChat: (() -> Void)? = nil
|
var onRequestNewChat: (() -> Void)? = nil
|
||||||
|
var onOpenForkedChat: ((String) -> Void)? = nil
|
||||||
@FocusState private var composerFocused: Bool
|
@FocusState private var composerFocused: Bool
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var isShowingAttachmentOptions = false
|
@State private var isShowingAttachmentOptions = false
|
||||||
@@ -196,7 +197,9 @@ struct SybilWorkspaceView: View {
|
|||||||
streamingAssistantPresentation: viewModel.displayedStreamingAssistantPresentation,
|
streamingAssistantPresentation: viewModel.displayedStreamingAssistantPresentation,
|
||||||
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
||||||
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
|
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
|
||||||
bottomPinRequestID: viewModel.chatBottomPinRequestID
|
bottomPinRequestID: viewModel.chatBottomPinRequestID,
|
||||||
|
isForking: viewModel.isForkingChat,
|
||||||
|
onForkResponse: forkResponse
|
||||||
)
|
)
|
||||||
.id(transcriptScrollContextID)
|
.id(transcriptScrollContextID)
|
||||||
}
|
}
|
||||||
@@ -230,6 +233,28 @@ struct SybilWorkspaceView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func forkResponse(messageID: String) {
|
||||||
|
guard viewModel.draftKind == nil,
|
||||||
|
case let .chat(chatID) = viewModel.selectedItem else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectionAtStart = viewModel.selectedItem
|
||||||
|
Task {
|
||||||
|
guard let forkedChat = await viewModel.forkChat(chatID: chatID, messageID: messageID),
|
||||||
|
viewModel.selectedItem == selectionAtStart,
|
||||||
|
viewModel.draftKind == nil else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let onOpenForkedChat {
|
||||||
|
onOpenForkedChat(forkedChat.id)
|
||||||
|
} else {
|
||||||
|
viewModel.select(.chat(forkedChat.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var customWorkspaceNavigationBar: some View {
|
private var customWorkspaceNavigationBar: some View {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
workspaceNavigationLeadingControl
|
workspaceNavigationLeadingControl
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ private struct MockClientCallSnapshot: Sendable {
|
|||||||
var listSearches = 0
|
var listSearches = 0
|
||||||
var createChat = 0
|
var createChat = 0
|
||||||
var getChat = 0
|
var getChat = 0
|
||||||
|
var forkChat = 0
|
||||||
|
var lastForkChatID: String?
|
||||||
|
var lastForkMessageID: String?
|
||||||
var updateChatTitle = 0
|
var updateChatTitle = 0
|
||||||
var suggestChatTitle = 0
|
var suggestChatTitle = 0
|
||||||
var updateChatStar = 0
|
var updateChatStar = 0
|
||||||
@@ -43,6 +46,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
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 let createChatResponse: ChatSummary?
|
||||||
|
private let forkChatResponse: ChatSummary?
|
||||||
private let updateChatTitleResponses: [String: ChatSummary]
|
private let updateChatTitleResponses: [String: ChatSummary]
|
||||||
private let suggestChatTitleResponses: [String: ChatSummary]
|
private let suggestChatTitleResponses: [String: ChatSummary]
|
||||||
private let updateChatStarResponses: [String: ChatSummary]
|
private let updateChatStarResponses: [String: ChatSummary]
|
||||||
@@ -74,6 +78,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
chatDetails: [String: ChatDetail] = [:],
|
chatDetails: [String: ChatDetail] = [:],
|
||||||
searchDetails: [String: SearchDetail] = [:],
|
searchDetails: [String: SearchDetail] = [:],
|
||||||
createChatResponse: ChatSummary? = nil,
|
createChatResponse: ChatSummary? = nil,
|
||||||
|
forkChatResponse: ChatSummary? = nil,
|
||||||
updateChatTitleResponses: [String: ChatSummary] = [:],
|
updateChatTitleResponses: [String: ChatSummary] = [:],
|
||||||
suggestChatTitleResponses: [String: ChatSummary] = [:],
|
suggestChatTitleResponses: [String: ChatSummary] = [:],
|
||||||
updateChatStarResponses: [String: ChatSummary] = [:],
|
updateChatStarResponses: [String: ChatSummary] = [:],
|
||||||
@@ -87,6 +92,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
self.chatDetails = chatDetails
|
self.chatDetails = chatDetails
|
||||||
self.searchDetails = searchDetails
|
self.searchDetails = searchDetails
|
||||||
self.createChatResponse = createChatResponse
|
self.createChatResponse = createChatResponse
|
||||||
|
self.forkChatResponse = forkChatResponse
|
||||||
self.updateChatTitleResponses = updateChatTitleResponses
|
self.updateChatTitleResponses = updateChatTitleResponses
|
||||||
self.suggestChatTitleResponses = suggestChatTitleResponses
|
self.suggestChatTitleResponses = suggestChatTitleResponses
|
||||||
self.updateChatStarResponses = updateChatStarResponses
|
self.updateChatStarResponses = updateChatStarResponses
|
||||||
@@ -220,6 +226,16 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
return detail
|
return detail
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func forkChat(chatID: String, messageID: String?) async throws -> ChatSummary {
|
||||||
|
snapshot.forkChat += 1
|
||||||
|
snapshot.lastForkChatID = chatID
|
||||||
|
snapshot.lastForkMessageID = messageID
|
||||||
|
guard let forkChatResponse else {
|
||||||
|
throw UnexpectedClientCall()
|
||||||
|
}
|
||||||
|
return forkChatResponse
|
||||||
|
}
|
||||||
|
|
||||||
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
|
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
|
||||||
snapshot.updateChatTitle += 1
|
snapshot.updateChatTitle += 1
|
||||||
guard let summary = updateChatTitleResponses[chatID] else {
|
guard let summary = updateChatTitleResponses[chatID] else {
|
||||||
@@ -775,6 +791,63 @@ private func makeCompletionToolCall(
|
|||||||
#expect(viewModel.errorMessage == nil)
|
#expect(viewModel.errorMessage == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func wholeChatForkAddsReturnedChatToWorkspace() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_160)
|
||||||
|
let source = makeChatSummary(id: "chat-source", date: date)
|
||||||
|
var forked = makeChatSummary(id: "chat-fork", date: date.addingTimeInterval(10))
|
||||||
|
forked.title = "Fork of Chat chat-source"
|
||||||
|
forked.parentChatId = source.id
|
||||||
|
forked.titleGenerationPending = true
|
||||||
|
let client = MockSybilClient(forkChatResponse: forked)
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.isAuthenticated = true
|
||||||
|
viewModel.isCheckingSession = false
|
||||||
|
viewModel.chats = [source]
|
||||||
|
viewModel.workspaceItems = [WorkspaceItem(chat: source)]
|
||||||
|
viewModel.selectedItem = .chat(source.id)
|
||||||
|
|
||||||
|
let result = await viewModel.forkChat(chatID: source.id)
|
||||||
|
|
||||||
|
let snapshot = await client.currentSnapshot()
|
||||||
|
#expect(snapshot.forkChat == 1)
|
||||||
|
#expect(snapshot.lastForkChatID == source.id)
|
||||||
|
#expect(snapshot.lastForkMessageID == nil)
|
||||||
|
#expect(result == forked)
|
||||||
|
#expect(viewModel.chats.first == forked)
|
||||||
|
#expect(viewModel.workspaceItems.first == WorkspaceItem(chat: forked))
|
||||||
|
#expect(viewModel.selectedItem == .chat(source.id))
|
||||||
|
#expect(!viewModel.isForkingChat)
|
||||||
|
#expect(viewModel.errorMessage == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func responseForkPassesAssistantMessageID() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_165)
|
||||||
|
let source = makeChatSummary(id: "chat-source", date: date)
|
||||||
|
var forked = makeChatSummary(id: "response-fork", date: date.addingTimeInterval(10))
|
||||||
|
forked.title = "Fork of 'A useful response'"
|
||||||
|
forked.parentChatId = source.id
|
||||||
|
forked.titleGenerationPending = true
|
||||||
|
let client = MockSybilClient(forkChatResponse: forked)
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.isAuthenticated = true
|
||||||
|
viewModel.isCheckingSession = false
|
||||||
|
viewModel.chats = [source]
|
||||||
|
viewModel.workspaceItems = [WorkspaceItem(chat: source)]
|
||||||
|
|
||||||
|
let result = await viewModel.forkChat(chatID: source.id, messageID: "assistant-message")
|
||||||
|
|
||||||
|
let snapshot = await client.currentSnapshot()
|
||||||
|
#expect(snapshot.forkChat == 1)
|
||||||
|
#expect(snapshot.lastForkChatID == source.id)
|
||||||
|
#expect(snapshot.lastForkMessageID == "assistant-message")
|
||||||
|
#expect(result == forked)
|
||||||
|
#expect(viewModel.sidebarItems.contains(where: { $0.selection == .chat(forked.id) }))
|
||||||
|
#expect(!viewModel.isForkingChat)
|
||||||
|
#expect(viewModel.errorMessage == nil)
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func starringItemsUpdatesSidebarState() async throws {
|
@Test func starringItemsUpdatesSidebarState() async throws {
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_175)
|
let date = Date(timeIntervalSince1970: 1_700_000_175)
|
||||||
|
|||||||
+6
-2
@@ -17,7 +17,9 @@ Hiding the panel preserves the draft and answer and lets an in-flight answer fin
|
|||||||
**Open in Chat** saves the question and answer in the regular workspace.
|
**Open in Chat** saves the question and answer in the regular workspace.
|
||||||
|
|
||||||
The regular Mac chat/search composer uses the same Return/Shift+Return rules.
|
The regular Mac chat/search composer uses the same Return/Shift+Return rules.
|
||||||
Holding Return does not repeatedly submit. The iOS software keyboard is unchanged.
|
Holding Return does not repeatedly submit. On iOS, Return submits from Quick
|
||||||
|
Question, while the normal chat/search composer continues to use Return for a
|
||||||
|
new line.
|
||||||
|
|
||||||
Closing the last Mac window leaves Sybil running, so Option+Space still works.
|
Closing the last Mac window leaves Sybil running, so Option+Space still works.
|
||||||
Use **Sybil → Quit Sybil** or **Command+Q** to quit the application. The Catalyst
|
Use **Sybil → Quit Sybil** or **Command+Q** to quit the application. The Catalyst
|
||||||
@@ -66,7 +68,9 @@ action, and does not build or embed the macOS helper.
|
|||||||
answer remain available. Reopen the workspace from the panel, then explicitly
|
answer remain available. Reopen the workspace from the panel, then explicitly
|
||||||
quit with Command+Q. Rebuild and relaunch after changing lifecycle plist keys.
|
quit with Command+Q. Rebuild and relaunch after changing lifecycle plist keys.
|
||||||
- In both Mac and iOS Quick Question, reopen an existing prompt and type to replace
|
- In both Mac and iOS Quick Question, reopen an existing prompt and type to replace
|
||||||
it. In the Mac workspace, verify Return submits and Shift+Return adds a newline.
|
it. On iOS, verify Return submits Quick Question while Return in the normal
|
||||||
|
prompt adds a newline. In the Mac workspace, verify Return submits and
|
||||||
|
Shift+Return adds a newline.
|
||||||
- Check Command+N both with a workspace open and after closing every workspace.
|
- Check Command+N both with a workspace open and after closing every workspace.
|
||||||
Command+Shift+N must open another window even when one already exists, and
|
Command+Shift+N must open another window even when one already exists, and
|
||||||
Command+Option+N must start a search without opening a window.
|
Command+Option+N must start a search without opening a window.
|
||||||
|
|||||||
+71
-46
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||||
import type { TargetedTouchEvent } from "preact";
|
import type { TargetedTouchEvent } from "preact";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
@@ -654,9 +654,10 @@ type ModelComboboxProps = {
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ModelCombobox({ options, value, onChange, disabled = false }: ModelComboboxProps) {
|
function ModelCombobox({ options, value, onChange, disabled = false, className }: ModelComboboxProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [draftValue, setDraftValue] = useState(value);
|
const [draftValue, setDraftValue] = useState(value);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -710,7 +711,12 @@ function ModelCombobox({ options, value, onChange, disabled = false }: ModelComb
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={rootRef}>
|
<div className="relative" ref={rootRef}>
|
||||||
<div className="flex h-10 min-w-56 items-center rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)]">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 min-w-56 items-center rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={draftValue}
|
value={draftValue}
|
||||||
@@ -948,7 +954,6 @@ export default function App() {
|
|||||||
isCheckingSession,
|
isCheckingSession,
|
||||||
isSigningIn,
|
isSigningIn,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
authMode,
|
|
||||||
authError,
|
authError,
|
||||||
handleAuthFailure: baseHandleAuthFailure,
|
handleAuthFailure: baseHandleAuthFailure,
|
||||||
handleSignIn,
|
handleSignIn,
|
||||||
@@ -1018,6 +1023,7 @@ export default function App() {
|
|||||||
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
||||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const renameChatInputRef = useRef<HTMLInputElement>(null);
|
const renameChatInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const chatSettingsDialogRef = useRef<HTMLDialogElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const dragDepthRef = useRef(0);
|
const dragDepthRef = useRef(0);
|
||||||
const pendingAttachmentsRef = useRef<ChatAttachment[]>([]);
|
const pendingAttachmentsRef = useRef<ChatAttachment[]>([]);
|
||||||
@@ -1930,6 +1936,7 @@ export default function App() {
|
|||||||
if (!isAuthenticated) return;
|
if (!isAuthenticated) return;
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (chatSettingsDialogRef.current?.open) return;
|
||||||
const hasPrimaryModifier = event.metaKey || event.ctrlKey;
|
const hasPrimaryModifier = event.metaKey || event.ctrlKey;
|
||||||
if (!hasPrimaryModifier || event.altKey) return;
|
if (!hasPrimaryModifier || event.altKey) return;
|
||||||
|
|
||||||
@@ -2331,16 +2338,16 @@ export default function App() {
|
|||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [renameChatDialog]);
|
}, [renameChatDialog]);
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!isChatSettingsOpen) return;
|
if (!isChatSettingsOpen) return;
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const dialog = chatSettingsDialogRef.current;
|
||||||
if (event.key !== "Escape" || isSavingChatSettings) return;
|
const previouslyFocused = document.activeElement;
|
||||||
event.preventDefault();
|
dialog?.showModal();
|
||||||
setIsChatSettingsOpen(false);
|
return () => {
|
||||||
|
dialog?.close();
|
||||||
|
if (previouslyFocused instanceof HTMLElement) previouslyFocused.focus({ preventScroll: true });
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
}, [isChatSettingsOpen]);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [isChatSettingsOpen, isSavingChatSettings]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isQuickQuestionOpen) return;
|
if (!isQuickQuestionOpen) return;
|
||||||
@@ -3449,16 +3456,10 @@ export default function App() {
|
|||||||
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="relative min-h-24 px-4 pb-4 pt-5">
|
<div className="relative min-h-24 px-4 pb-4 pt-5 flex items-center">
|
||||||
<div className="pr-24">
|
|
||||||
<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>
|
|
||||||
<SybilCharacter
|
<SybilCharacter
|
||||||
className="absolute right-4 top-3 h-[calc(100%-1.5rem)]"
|
className="absolute right-4 top-3 h-[calc(100%-1.5rem)]"
|
||||||
isBusy={isActiveSelectionSending || isQuickQuestionSending}
|
isBusy={isActiveSelectionSending || isQuickQuestionSending}
|
||||||
@@ -3822,44 +3823,66 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{isChatSettingsOpen ? (
|
{isChatSettingsOpen ? (
|
||||||
<div
|
<dialog
|
||||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
|
ref={chatSettingsDialogRef}
|
||||||
|
aria-labelledby="chat-settings-title"
|
||||||
|
className="fixed inset-0 m-0 flex h-dvh max-h-none w-full max-w-none items-center justify-center overflow-hidden bg-transparent p-0 text-foreground backdrop:bg-black/70 backdrop:backdrop-blur-md md:p-6"
|
||||||
|
onCancel={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!isSavingChatSettings) setIsChatSettingsOpen(false);
|
||||||
|
}}
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
if (event.target === event.currentTarget && !isSavingChatSettings) setIsChatSettingsOpen(false);
|
if (event.target === event.currentTarget && !isSavingChatSettings) setIsChatSettingsOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
role="dialog"
|
className="glass-panel flex h-full min-h-0 w-full flex-col overflow-hidden md:h-auto md:max-h-full md:max-w-2xl md:rounded-2xl md:border md:border-violet-300/24"
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="chat-settings-title"
|
|
||||||
className="glass-panel flex max-h-[88vh] w-full max-w-2xl flex-col rounded-2xl border border-violet-300/24 p-4 shadow-2xl shadow-black/45 md:p-5"
|
|
||||||
onSubmit={(event) => void handleChatSettingsSubmit(event)}
|
onSubmit={(event) => void handleChatSettingsSubmit(event)}
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex items-center justify-between gap-3">
|
<div className="chat-settings-header grid shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2 border-b border-violet-300/16 md:flex md:justify-between md:gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-11 justify-self-start px-2 text-violet-200 hover:bg-violet-400/10 hover:text-violet-100 md:hidden"
|
||||||
|
onClick={() => setIsChatSettingsOpen(false)}
|
||||||
|
disabled={isSavingChatSettings}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 id="chat-settings-title" className="text-sm font-semibold text-violet-50">
|
<h2 id="chat-settings-title" className="text-base font-semibold text-violet-50 md:text-sm">
|
||||||
Chat settings
|
Chat settings
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1 truncate text-xs text-muted-foreground">{chatSettingsTitleDraft.trim() || "New chat"}</p>
|
<p className="mt-1 hidden truncate text-xs text-muted-foreground md:block">{chatSettingsTitleDraft.trim() || "New chat"}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-8 w-8"
|
className="hidden h-8 w-8 shrink-0 md:inline-flex"
|
||||||
onClick={() => setIsChatSettingsOpen(false)}
|
onClick={() => setIsChatSettingsOpen(false)}
|
||||||
disabled={isSavingChatSettings}
|
disabled={isSavingChatSettings}
|
||||||
aria-label="Close chat settings"
|
aria-label="Close chat settings"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-11 justify-self-end gap-1.5 px-2 font-semibold text-violet-200 hover:bg-violet-400/10 hover:text-violet-100 md:hidden"
|
||||||
|
disabled={isSavingChatSettings}
|
||||||
|
>
|
||||||
|
{isSavingChatSettings ? <LoaderCircle className="h-4 w-4 animate-spin" /> : null}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
|
<div className="chat-settings-content min-h-0 flex-1 space-y-5 overflow-y-auto overscroll-contain md:space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Chat title</span>
|
<label for="chat-settings-chat-title" className="mb-1.5 block text-xs font-semibold text-violet-100/72">Chat title</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
|
id="chat-settings-chat-title"
|
||||||
value={chatSettingsTitleDraft}
|
value={chatSettingsTitleDraft}
|
||||||
onInput={(event) => {
|
onInput={(event) => {
|
||||||
setChatSettingsTitleDraft(event.currentTarget.value);
|
setChatSettingsTitleDraft(event.currentTarget.value);
|
||||||
@@ -3867,7 +3890,7 @@ export default function App() {
|
|||||||
}}
|
}}
|
||||||
maxLength={120}
|
maxLength={120}
|
||||||
placeholder={draftKind === null && selectedItem?.kind === "chat" ? "Chat title" : "Optional title"}
|
placeholder={draftKind === null && selectedItem?.kind === "chat" ? "Chat title" : "Optional title"}
|
||||||
className="h-11 min-w-0 flex-1 rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
|
className="h-11 min-w-0 flex-1 rounded-lg border border-violet-300/22 bg-background/70 px-3 text-base text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70 md:text-sm"
|
||||||
disabled={isSavingChatSettings}
|
disabled={isSavingChatSettings}
|
||||||
/>
|
/>
|
||||||
{chatSettingsChatId ? (
|
{chatSettingsChatId ? (
|
||||||
@@ -3895,7 +3918,7 @@ export default function App() {
|
|||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Provider</span>
|
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Provider</span>
|
||||||
<select
|
<select
|
||||||
className="h-10 w-full rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
|
className="h-11 w-full rounded-lg border border-violet-300/22 bg-background/70 px-3 text-base text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70 md:h-10 md:text-sm"
|
||||||
value={chatSettingsProviderDraft}
|
value={chatSettingsProviderDraft}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const nextProvider = event.currentTarget.value as Provider;
|
const nextProvider = event.currentTarget.value as Provider;
|
||||||
@@ -3917,6 +3940,7 @@ export default function App() {
|
|||||||
<label className="block min-w-0">
|
<label className="block min-w-0">
|
||||||
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Model</span>
|
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Model</span>
|
||||||
<ModelCombobox
|
<ModelCombobox
|
||||||
|
className="h-11 min-w-0 bg-background/70 text-base md:h-10 md:text-sm"
|
||||||
options={chatSettingsProviderModelOptions}
|
options={chatSettingsProviderModelOptions}
|
||||||
value={chatSettingsModelDraft}
|
value={chatSettingsModelDraft}
|
||||||
disabled={isSavingChatSettings}
|
disabled={isSavingChatSettings}
|
||||||
@@ -3938,7 +3962,7 @@ export default function App() {
|
|||||||
if (chatSettingsError) setChatSettingsError(null);
|
if (chatSettingsError) setChatSettingsError(null);
|
||||||
}}
|
}}
|
||||||
placeholder="Add per-chat instructions"
|
placeholder="Add per-chat instructions"
|
||||||
className="min-h-32 resize-y border-violet-300/24 bg-background/72 text-sm text-violet-50 placeholder:text-violet-200/45"
|
className="min-h-32 resize-y border-violet-300/24 bg-background/70 text-base text-violet-50 placeholder:text-violet-200/45 md:text-sm"
|
||||||
disabled={isSavingChatSettings}
|
disabled={isSavingChatSettings}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -3952,6 +3976,7 @@ export default function App() {
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
className="h-11 gap-1.5 md:h-8"
|
||||||
onClick={() => setChatSettingsEnabledToolsDraft(getDefaultEnabledTools(availableChatTools))}
|
onClick={() => setChatSettingsEnabledToolsDraft(getDefaultEnabledTools(availableChatTools))}
|
||||||
disabled={isSavingChatSettings}
|
disabled={isSavingChatSettings}
|
||||||
>
|
>
|
||||||
@@ -3962,6 +3987,7 @@ export default function App() {
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
className="h-11 gap-1.5 md:h-8"
|
||||||
onClick={() => setChatSettingsEnabledToolsDraft([])}
|
onClick={() => setChatSettingsEnabledToolsDraft([])}
|
||||||
disabled={isSavingChatSettings}
|
disabled={isSavingChatSettings}
|
||||||
>
|
>
|
||||||
@@ -3971,31 +3997,30 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
{availableChatTools.length ? (
|
{availableChatTools.length ? (
|
||||||
availableChatTools.map((tool) => {
|
availableChatTools.map((tool) => {
|
||||||
const checked = chatSettingsEnabledToolsDraft.includes(tool.name);
|
const checked = chatSettingsEnabledToolsDraft.includes(tool.name);
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={tool.name}
|
key={tool.name}
|
||||||
className="flex cursor-pointer items-start gap-3 rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 transition hover:border-violet-300/34 hover:bg-violet-400/8"
|
title={tool.description}
|
||||||
|
className="flex min-h-11 min-w-0 cursor-pointer items-center gap-3 rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 transition hover:border-violet-300/34 hover:bg-violet-400/8 focus-within:border-violet-300/45 focus-within:ring-1 focus-within:ring-ring/70"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onChange={() => toggleChatSettingsTool(tool.name)}
|
onChange={() => toggleChatSettingsTool(tool.name)}
|
||||||
className="mt-1 h-4 w-4 rounded border-violet-300/35 bg-background/80 accent-violet-400"
|
className="h-4 w-4 shrink-0 rounded border-violet-300/35 bg-background/80 accent-violet-400"
|
||||||
|
aria-description={tool.description}
|
||||||
disabled={isSavingChatSettings}
|
disabled={isSavingChatSettings}
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0 break-words text-sm font-medium text-violet-50">{getToolLabel(tool.name)}</span>
|
||||||
<span className="block text-sm font-medium text-violet-50">{getToolLabel(tool.name)}</span>
|
|
||||||
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">{tool.description}</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<p className="rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 text-sm text-muted-foreground">
|
<p className="rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 text-sm text-muted-foreground md:col-span-2">
|
||||||
No chat tools are available.
|
No chat tools are available.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -4003,8 +4028,8 @@ export default function App() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{chatSettingsError ? <p className="mt-3 text-sm text-rose-300">{chatSettingsError}</p> : null}
|
{chatSettingsError ? <p role="alert" className="chat-settings-error shrink-0 break-words border-t border-violet-300/16 text-sm text-rose-300">{chatSettingsError}</p> : null}
|
||||||
<div className="mt-4 flex justify-end gap-2">
|
<div className="hidden shrink-0 justify-end gap-2 border-t border-violet-300/16 p-5 md:flex">
|
||||||
<Button type="button" variant="secondary" onClick={() => setIsChatSettingsOpen(false)} disabled={isSavingChatSettings}>
|
<Button type="button" variant="secondary" onClick={() => setIsChatSettingsOpen(false)} disabled={isSavingChatSettings}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@@ -4014,7 +4039,7 @@ export default function App() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</dialog>
|
||||||
) : null}
|
) : null}
|
||||||
{renameChatDialog ? (
|
{renameChatDialog ? (
|
||||||
<div
|
<div
|
||||||
@@ -4073,7 +4098,7 @@ export default function App() {
|
|||||||
) : null}
|
) : null}
|
||||||
{isQuickQuestionOpen ? (
|
{isQuickQuestionOpen ? (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
|
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-3 md:p-6"
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
if (event.target === event.currentTarget) setIsQuickQuestionOpen(false);
|
if (event.target === event.currentTarget) setIsQuickQuestionOpen(false);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -113,7 +113,41 @@ textarea {
|
|||||||
max(0.75rem, var(--safe-area-left));
|
max(0.75rem, var(--safe-area-left));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-settings-header {
|
||||||
|
padding:
|
||||||
|
calc(0.5rem + var(--safe-area-top))
|
||||||
|
max(0.75rem, var(--safe-area-right))
|
||||||
|
0.5rem
|
||||||
|
max(0.75rem, var(--safe-area-left));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-settings-content {
|
||||||
|
padding:
|
||||||
|
1rem
|
||||||
|
max(1rem, var(--safe-area-right))
|
||||||
|
max(1.5rem, var(--safe-area-bottom))
|
||||||
|
max(1rem, var(--safe-area-left));
|
||||||
|
scroll-padding-block: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-settings-error {
|
||||||
|
padding:
|
||||||
|
0.75rem
|
||||||
|
max(1rem, var(--safe-area-right))
|
||||||
|
max(0.75rem, var(--safe-area-bottom))
|
||||||
|
max(1rem, var(--safe-area-left));
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
|
.chat-settings-header,
|
||||||
|
.chat-settings-content {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-settings-error {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.app-safe-frame {
|
.app-safe-frame {
|
||||||
padding:
|
padding:
|
||||||
max(0.5rem, var(--safe-area-top))
|
max(0.5rem, var(--safe-area-top))
|
||||||
|
|||||||
Reference in New Issue
Block a user