949 lines
31 KiB
Swift
949 lines
31 KiB
Swift
import ByreAPI
|
||
import OpenAPIRuntime
|
||
import SwiftUI
|
||
import UniformTypeIdentifiers
|
||
#if os(iOS)
|
||
import PhotosUI
|
||
#endif
|
||
|
||
let maxImageBytes = 5 << 20
|
||
|
||
let models = [
|
||
"claude-opus-5-5",
|
||
"claude-sonnet-5-5",
|
||
"claude-haiku-5-5",
|
||
"claude-fable-5-1",
|
||
"gpt-6.1-sol",
|
||
"gpt-6-astra",
|
||
"gpt-6-sol",
|
||
"gpt-6-luna",
|
||
"gpt-5.6-sol",
|
||
"gpt-5.6-terra",
|
||
"gpt-5.6-luna",
|
||
"gemini-4-argon",
|
||
"gemini-3.8-flash",
|
||
]
|
||
|
||
struct PendingImage: Identifiable {
|
||
let id = UUID()
|
||
let data: Data
|
||
let image: PlatformImage
|
||
}
|
||
|
||
func imageType(_ data: Data) -> String? {
|
||
let b = [UInt8](data.prefix(12))
|
||
if b.starts(with: [0x89, 0x50, 0x4E, 0x47]) {
|
||
return "png"
|
||
}
|
||
if b.starts(with: [0xFF, 0xD8, 0xFF]) {
|
||
return "jpeg"
|
||
}
|
||
if b.starts(with: Array("GIF8".utf8)) {
|
||
return "gif"
|
||
}
|
||
if b.count == 12, b.starts(with: Array("RIFF".utf8)), Array(b[8..<12]) == Array("WEBP".utf8) {
|
||
return "webp"
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func convertImage(_ data: Data) -> Data? {
|
||
#if os(macOS)
|
||
NSBitmapImageRep(data: data)?.representation(using: .png, properties: [:])
|
||
#else
|
||
UIImage(data: data)?.jpegData(compressionQuality: 0.85)
|
||
#endif
|
||
}
|
||
|
||
func uploadImages(_ api: API, _ imgs: [PendingImage]) async throws -> [String]? {
|
||
if imgs.isEmpty {
|
||
return nil
|
||
}
|
||
var ids: [String] = []
|
||
for img in imgs {
|
||
let body = HTTPBody(img.data)
|
||
let typed: Operations.UploadBlob.Input.Body = switch imageType(img.data) {
|
||
case "png": .png(body)
|
||
case "jpeg": .jpeg(body)
|
||
case "gif": .imageGif(body)
|
||
default: .imageWebp(body)
|
||
}
|
||
ids.append(try await api.client.uploadBlob(body: typed).created.body.json.id)
|
||
}
|
||
return ids
|
||
}
|
||
|
||
@MainActor
|
||
var modelChoices: [String: String] = [:]
|
||
|
||
@MainActor
|
||
var effortChoices: [String: String] = [:]
|
||
|
||
typealias Vendor = Components.Schemas.AgentSession.VendorPayload
|
||
|
||
func vendorOf(_ model: String) -> Vendor {
|
||
if model.hasPrefix("gpt-") {
|
||
return .codex
|
||
}
|
||
if model.hasPrefix("gemini-") {
|
||
return .antigravity
|
||
}
|
||
return .claude
|
||
}
|
||
|
||
func efforts(_ model: String) -> [String] {
|
||
switch vendorOf(model) {
|
||
case .codex: ["", "low", "medium", "high", "xhigh"]
|
||
case .claude: ["", "low", "medium", "high", "xhigh", "max"]
|
||
case .antigravity: ["low", "medium", "high"]
|
||
}
|
||
}
|
||
|
||
func effortLabel(_ effort: String) -> String {
|
||
effort.isEmpty ? "default effort" : "\(effort) effort"
|
||
}
|
||
|
||
func effortFor(_ model: String, _ effort: String) -> String {
|
||
efforts(model).contains(effort) ? effort : (vendorOf(model) == .antigravity ? "medium" : "")
|
||
}
|
||
|
||
@MainActor
|
||
@Observable
|
||
final class ImageDrafts {
|
||
private var byKey: [String: [PendingImage]] = [:]
|
||
|
||
func images(_ key: String) -> [PendingImage] {
|
||
byKey[key] ?? []
|
||
}
|
||
|
||
func has(_ key: String) -> Bool {
|
||
byKey[key] != nil
|
||
}
|
||
|
||
func remove(_ key: String, _ id: UUID) {
|
||
let rest = images(key).filter { $0.id != id }
|
||
byKey[key] = rest.isEmpty ? nil : rest
|
||
}
|
||
|
||
func take(_ key: String) -> [PendingImage] {
|
||
let imgs = images(key)
|
||
byKey[key] = nil
|
||
return imgs
|
||
}
|
||
|
||
func restore(_ key: String, _ imgs: [PendingImage]) {
|
||
if !imgs.isEmpty {
|
||
byKey[key] = imgs + images(key)
|
||
}
|
||
}
|
||
|
||
@discardableResult
|
||
func add(_ key: String, _ data: Data, name: String?, _ overlays: Overlays) -> Bool {
|
||
let file = imageType(data) != nil ? data : convertImage(data)
|
||
guard let file, let image = PlatformImage(data: file) else {
|
||
overlays.showAlert("\(name ?? "That file") isn't a PNG, JPEG, GIF or WebP image.")
|
||
return false
|
||
}
|
||
if file.count > maxImageBytes {
|
||
overlays.showAlert("\(name ?? "That image") is over \(maxImageBytes >> 20) MiB.")
|
||
return false
|
||
}
|
||
byKey[key] = images(key) + [PendingImage(data: file, image: image)]
|
||
return true
|
||
}
|
||
|
||
#if os(macOS)
|
||
func add(_ key: String, pasteboard pb: NSPasteboard, _ overlays: Overlays) -> Bool {
|
||
if let urls = pb.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL], !urls.isEmpty {
|
||
let images = urls.filter { (try? $0.resourceValues(forKeys: [.contentTypeKey]).contentType?.conforms(to: .image)) == true }
|
||
for url in images {
|
||
if let data = try? Data(contentsOf: url) {
|
||
add(key, data, name: url.lastPathComponent, overlays)
|
||
}
|
||
}
|
||
return !images.isEmpty
|
||
}
|
||
for t in [NSPasteboard.PasteboardType.png, NSPasteboard.PasteboardType(UTType.jpeg.identifier), NSPasteboard.PasteboardType(UTType.gif.identifier), NSPasteboard.PasteboardType(UTType.webP.identifier), .tiff] {
|
||
if let data = pb.data(forType: t) {
|
||
add(key, data, name: nil, overlays)
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
#else
|
||
func addPasteboard(_ key: String, _ overlays: Overlays) {
|
||
let images = UIPasteboard.general.images ?? []
|
||
if images.isEmpty {
|
||
overlays.showAlert("The clipboard has no image.")
|
||
return
|
||
}
|
||
for image in images {
|
||
if let data = image.jpegData(compressionQuality: 0.85) {
|
||
add(key, data, name: nil, overlays)
|
||
}
|
||
}
|
||
}
|
||
#endif
|
||
|
||
func add(_ key: String, providers: [NSItemProvider], _ overlays: Overlays) {
|
||
for p in providers {
|
||
if p.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {
|
||
_ = p.loadDataRepresentation(for: .fileURL) { data, _ in
|
||
Task { @MainActor in
|
||
guard let data, let url = URL(dataRepresentation: data, relativeTo: nil), let file = try? Data(contentsOf: url) else {
|
||
return
|
||
}
|
||
self.add(key, file, name: url.lastPathComponent, overlays)
|
||
}
|
||
}
|
||
continue
|
||
}
|
||
guard let t = [UTType.png, .jpeg, .gif, .webP, .tiff].first(where: { p.hasItemConformingToTypeIdentifier($0.identifier) }) else {
|
||
continue
|
||
}
|
||
_ = p.loadDataRepresentation(for: t) { data, _ in
|
||
Task { @MainActor in
|
||
if let data {
|
||
self.add(key, data, name: nil, overlays)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#if os(iOS)
|
||
private final class ImageTextView: UITextView {
|
||
var pasteImages: () -> Void = {}
|
||
|
||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||
if action == #selector(paste(_:)) && UIPasteboard.general.hasImages {
|
||
return true
|
||
}
|
||
return super.canPerformAction(action, withSender: sender)
|
||
}
|
||
|
||
override func paste(_ sender: Any?) {
|
||
if UIPasteboard.general.hasImages {
|
||
pasteImages()
|
||
return
|
||
}
|
||
super.paste(sender)
|
||
}
|
||
}
|
||
|
||
private struct ImageTextEditor: UIViewRepresentable {
|
||
@Binding var text: String
|
||
let size: CGFloat
|
||
var focus: FocusState<Bool>.Binding
|
||
let paste: () -> Void
|
||
|
||
func makeCoordinator() -> Coordinator {
|
||
Coordinator(self)
|
||
}
|
||
|
||
func makeUIView(context: Context) -> ImageTextView {
|
||
let v = ImageTextView()
|
||
v.delegate = context.coordinator
|
||
v.backgroundColor = .clear
|
||
v.textColor = .label
|
||
v.textContainerInset = .zero
|
||
v.writingToolsBehavior = .none
|
||
return v
|
||
}
|
||
|
||
func updateUIView(_ v: ImageTextView, context: Context) {
|
||
context.coordinator.parent = self
|
||
v.pasteImages = paste
|
||
let font = Fonts.native(size)
|
||
if v.font != font {
|
||
v.font = font
|
||
}
|
||
if v.text != text {
|
||
v.text = text
|
||
}
|
||
if focus.wrappedValue != v.isFirstResponder {
|
||
DispatchQueue.main.async {
|
||
if focus.wrappedValue && !v.isFirstResponder {
|
||
v.becomeFirstResponder()
|
||
} else if !focus.wrappedValue && v.isFirstResponder {
|
||
v.resignFirstResponder()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
final class Coordinator: NSObject, UITextViewDelegate {
|
||
var parent: ImageTextEditor
|
||
|
||
init(_ parent: ImageTextEditor) {
|
||
self.parent = parent
|
||
}
|
||
|
||
func textViewDidChange(_ v: UITextView) {
|
||
parent.text = v.text
|
||
}
|
||
|
||
func textViewDidBeginEditing(_ v: UITextView) {
|
||
parent.focus.wrappedValue = true
|
||
}
|
||
|
||
func textViewDidEndEditing(_ v: UITextView) {
|
||
parent.focus.wrappedValue = false
|
||
}
|
||
}
|
||
}
|
||
#endif
|
||
|
||
struct MessageTextEditor: View {
|
||
@Binding var text: String
|
||
let size: CGFloat
|
||
let key: String
|
||
var focus: FocusState<Bool>.Binding
|
||
@Environment(Store.self) private var store
|
||
@Environment(Overlays.self) private var overlays
|
||
|
||
var body: some View {
|
||
#if os(iOS)
|
||
ImageTextEditor(text: $text, size: size, focus: focus) {
|
||
store.images.addPasteboard(key, overlays)
|
||
}
|
||
.focused(focus)
|
||
#else
|
||
TextEditor(text: $text)
|
||
.font(Fonts.font(size))
|
||
.scrollContentBackground(.hidden)
|
||
.writingToolsBehavior(.disabled)
|
||
.focused(focus)
|
||
#endif
|
||
}
|
||
}
|
||
|
||
#if os(macOS)
|
||
struct ComposerTarget {
|
||
let paste: @MainActor (NSPasteboard) -> Bool
|
||
}
|
||
|
||
struct ComposerFocusKey: FocusedValueKey {
|
||
typealias Value = ComposerTarget
|
||
}
|
||
|
||
extension FocusedValues {
|
||
var composer: ComposerTarget? {
|
||
get {
|
||
self[ComposerFocusKey.self]
|
||
}
|
||
set {
|
||
self[ComposerFocusKey.self] = newValue
|
||
}
|
||
}
|
||
}
|
||
|
||
struct PasteboardCommands: Commands {
|
||
@FocusedValue(\.composer) private var composer
|
||
|
||
var body: some Commands {
|
||
CommandGroup(replacing: .pasteboard) {
|
||
Button("Cut") {
|
||
NSApp.sendAction(#selector(NSText.cut(_:)), to: nil, from: nil)
|
||
}
|
||
.keyboardShortcut("x")
|
||
Button("Copy") {
|
||
NSApp.sendAction(#selector(NSText.copy(_:)), to: nil, from: nil)
|
||
}
|
||
.keyboardShortcut("c")
|
||
Button("Paste") {
|
||
if let composer, composer.paste(.general) {
|
||
return
|
||
}
|
||
NSApp.sendAction(#selector(NSText.paste(_:)), to: nil, from: nil)
|
||
}
|
||
.keyboardShortcut("v")
|
||
Button("Select All") {
|
||
NSApp.sendAction(#selector(NSText.selectAll(_:)), to: nil, from: nil)
|
||
}
|
||
.keyboardShortcut("a")
|
||
}
|
||
}
|
||
}
|
||
#endif
|
||
|
||
private struct ImageInput: ViewModifier {
|
||
let key: String
|
||
@Environment(Store.self) private var store
|
||
@Environment(Overlays.self) private var overlays
|
||
@ViewState private var targeted = false
|
||
|
||
func body(content: Content) -> some View {
|
||
content
|
||
#if os(macOS)
|
||
.focusedValue(\.composer, ComposerTarget(paste: { pb in store.images.add(key, pasteboard: pb, overlays) }))
|
||
#endif
|
||
.overlay {
|
||
if targeted {
|
||
RoundedRectangle(cornerRadius: 6).stroke(Palette.accent)
|
||
}
|
||
}
|
||
.overlay {
|
||
Color.clear
|
||
.contentShape(Rectangle())
|
||
.allowsHitTesting(false)
|
||
.onDrop(of: [.image, .fileURL], isTargeted: $targeted) { providers in
|
||
store.images.add(key, providers: providers, overlays)
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
extension View {
|
||
func imageInput(_ key: String) -> some View {
|
||
modifier(ImageInput(key: key))
|
||
}
|
||
}
|
||
|
||
struct ComposerMenu: View {
|
||
let key: String
|
||
let photos: Bool
|
||
var models: [String] = []
|
||
var selected = ""
|
||
var effort = ""
|
||
var choose: @MainActor @Sendable (String) -> Void = { _ in }
|
||
var chooseEffort: @MainActor @Sendable (String) -> Void = { _ in }
|
||
@Environment(Store.self) private var store
|
||
@Environment(Overlays.self) private var overlays
|
||
#if os(iOS)
|
||
@ViewState private var picking = false
|
||
@ViewState private var picked: [PhotosPickerItem] = []
|
||
#endif
|
||
|
||
var body: some View {
|
||
menu
|
||
#if os(iOS)
|
||
.photosPicker(isPresented: $picking, selection: $picked, matching: .images)
|
||
.onChange(of: picked) {
|
||
let items = picked
|
||
if items.isEmpty {
|
||
return
|
||
}
|
||
picked = []
|
||
Task {
|
||
for item in items {
|
||
if let data = try? await item.loadTransferable(type: Data.self) {
|
||
store.images.add(key, data, name: nil, overlays)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
|
||
private var menu: some View {
|
||
Menu {
|
||
#if os(iOS)
|
||
if photos {
|
||
Button {
|
||
picking = true
|
||
} label: {
|
||
Label("Attach photos", systemImage: "photo")
|
||
}
|
||
Button {
|
||
store.images.addPasteboard(key, overlays)
|
||
} label: {
|
||
Label("Paste image", systemImage: "doc.on.clipboard")
|
||
}
|
||
}
|
||
#endif
|
||
if !models.isEmpty {
|
||
Picker("Model for the next turn", selection: Binding(get: { selected }, set: choose)) {
|
||
ForEach(models, id: \.self) { option in
|
||
Text(option).tag(option)
|
||
}
|
||
}
|
||
.pickerStyle(.menu)
|
||
Picker("Effort for the next turn", selection: Binding(get: { effort }, set: chooseEffort)) {
|
||
ForEach(efforts(selected), id: \.self) { option in
|
||
Text(effortLabel(option)).tag(option)
|
||
}
|
||
}
|
||
.pickerStyle(.menu)
|
||
}
|
||
} label: {
|
||
Image(systemName: "ellipsis")
|
||
.frame(height: 14)
|
||
}
|
||
.menuStyle(.button)
|
||
.menuIndicator(.hidden)
|
||
.buttonStyle(WebButtonStyle())
|
||
.fixedSize()
|
||
}
|
||
}
|
||
|
||
struct Thumbnails: View {
|
||
let key: String
|
||
@Environment(Store.self) private var store
|
||
|
||
var body: some View {
|
||
let drafts = store.images
|
||
let imgs = drafts.images(key)
|
||
if !imgs.isEmpty {
|
||
FlowLayout(spacing: 8) {
|
||
ForEach(imgs) { img in
|
||
Image(platformImage: img.image)
|
||
.resizable()
|
||
.scaledToFit()
|
||
.frame(maxWidth: 120, maxHeight: 90)
|
||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border))
|
||
.overlay(alignment: .topTrailing) {
|
||
Button("×") {
|
||
drafts.remove(key, img.id)
|
||
}
|
||
.buttonStyle(ThumbRemoveStyle())
|
||
.help("Remove image")
|
||
.offset(x: 6, y: -6)
|
||
}
|
||
}
|
||
}
|
||
.padding(.top, 6)
|
||
}
|
||
}
|
||
}
|
||
|
||
struct ThumbRemoveStyle: ButtonStyle {
|
||
func makeBody(configuration: ButtonStyleConfiguration) -> some View {
|
||
ThumbRemove(configuration: configuration)
|
||
}
|
||
}
|
||
|
||
private struct ThumbRemove: View {
|
||
let configuration: ButtonStyleConfiguration
|
||
@ViewState private var hover = false
|
||
|
||
var body: some View {
|
||
configuration.label
|
||
.font(Fonts.font(13))
|
||
.foregroundStyle(Palette.text)
|
||
.padding(.horizontal, 6)
|
||
.frame(height: 18)
|
||
.padding(1)
|
||
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 9))
|
||
.overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(hover ? Palette.muted : Palette.border))
|
||
.contentShape(RoundedRectangle(cornerRadius: 9))
|
||
.onHover { hover = $0 }
|
||
}
|
||
}
|
||
|
||
struct AgentEditor: View {
|
||
@Binding var text: String
|
||
let placeholder: String
|
||
let key: String
|
||
let height: CGFloat
|
||
var focus: FocusState<Bool>.Binding
|
||
let border: Color
|
||
var borderWidth: CGFloat = 1
|
||
let send: () -> Void
|
||
|
||
var body: some View {
|
||
MessageTextEditor(text: $text, size: 13, key: key, focus: focus)
|
||
#if os(macOS)
|
||
.onKeyPress(.return, phases: .down) { press in
|
||
let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false
|
||
if press.modifiers.contains(.shift) || marked {
|
||
return .ignored
|
||
}
|
||
send()
|
||
return .handled
|
||
}
|
||
#endif
|
||
.padding(.horizontal, 5)
|
||
.padding(.vertical, 6)
|
||
.frame(height: height)
|
||
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 6))
|
||
.overlay(alignment: .topLeading) {
|
||
if text.isEmpty {
|
||
Text(placeholder)
|
||
.foregroundStyle(Palette.muted)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 6)
|
||
.allowsHitTesting(false)
|
||
}
|
||
}
|
||
.overlay(RoundedRectangle(cornerRadius: 6).stroke(border, lineWidth: borderWidth))
|
||
.imageInput(key)
|
||
}
|
||
}
|
||
|
||
private struct ComposerBar: View, Equatable {
|
||
let chat: ChatModel
|
||
let status: ChatStatus?
|
||
let shown: Bool
|
||
let draftKey: String
|
||
let modelKey: String
|
||
let options: [String]
|
||
let selected: String
|
||
let selectedEffort: String
|
||
let sessionId: String?
|
||
let running: Bool
|
||
let paused: Bool
|
||
let closed: Bool
|
||
let toggle: () -> Void
|
||
let choose: @MainActor @Sendable (String) -> Void
|
||
let chooseEffort: @MainActor @Sendable (String) -> Void
|
||
let submit: () -> Void
|
||
@Environment(Overlays.self) private var overlays
|
||
|
||
// Leaves out the closures: they read their state when they run.
|
||
nonisolated static func == (a: ComposerBar, b: ComposerBar) -> Bool {
|
||
a.chat === b.chat && a.status == b.status && a.shown == b.shown && a.draftKey == b.draftKey && a.modelKey == b.modelKey && a.options == b.options
|
||
&& a.selected == b.selected && a.selectedEffort == b.selectedEffort && a.sessionId == b.sessionId && a.running == b.running && a.paused == b.paused
|
||
&& a.closed == b.closed
|
||
}
|
||
|
||
var body: some View {
|
||
let status = status.map { s in
|
||
StatusButton(status: s) {
|
||
chat.showStatus(s)
|
||
}
|
||
}
|
||
let buttons = actions
|
||
ViewThatFits(in: .horizontal) {
|
||
HStack(spacing: 8) {
|
||
controls()
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
status
|
||
buttons
|
||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||
}
|
||
VStack(alignment: .trailing, spacing: 8) {
|
||
status
|
||
HStack(spacing: 8) {
|
||
controls()
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
buttons
|
||
}
|
||
}
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
controls()
|
||
HStack(spacing: 8) {
|
||
Spacer(minLength: 0)
|
||
status
|
||
buttons
|
||
}
|
||
}
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
controls(stacked: true)
|
||
HStack(spacing: 8) {
|
||
Spacer(minLength: 0)
|
||
status
|
||
buttons
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func controls(stacked: Bool = false) -> some View {
|
||
(stacked ? AnyLayout(VStackLayout(alignment: .leading, spacing: 8)) : AnyLayout(HStackLayout(spacing: 8))) {
|
||
Button(action: toggle) {
|
||
Image(systemName: shown ? "chevron.down" : "chevron.up")
|
||
.frame(height: 14)
|
||
}
|
||
.buttonStyle(WebButtonStyle())
|
||
.help(shown ? "Hide the composer" : "Show the composer")
|
||
#if os(macOS)
|
||
if !chat.team {
|
||
menu
|
||
.help("Model and effort for the next turn")
|
||
}
|
||
#else
|
||
menu
|
||
#endif
|
||
}
|
||
}
|
||
|
||
private var menu: some View {
|
||
ComposerMenu(key: draftKey, photos: shown, models: chat.team ? [] : options, selected: selected, effort: selectedEffort, choose: choose, chooseEffort: chooseEffort)
|
||
}
|
||
|
||
private var actions: some View {
|
||
HStack(spacing: 8) {
|
||
Button {
|
||
if let sessionId {
|
||
chat.interrupt(sessionId, overlays)
|
||
}
|
||
} label: {
|
||
Image(systemName: "stop.fill")
|
||
.frame(width: 14, height: 14)
|
||
}
|
||
.buttonStyle(WebButtonStyle(danger: true))
|
||
.disabled(!running)
|
||
.help("Interrupt the agent's turn")
|
||
if case .task = chat.kind, !chat.team, !closed {
|
||
Button {
|
||
if let sessionId {
|
||
chat.setPaused(sessionId, !paused, overlays)
|
||
}
|
||
} label: {
|
||
Image(systemName: paused ? "play.fill" : "pause.fill")
|
||
.frame(width: 14, height: 14)
|
||
}
|
||
.buttonStyle(WebButtonStyle())
|
||
.disabled(sessionId == nil)
|
||
.help(paused ? "Resume the agent" : "Pause the agent after its current step")
|
||
}
|
||
Button(closed ? "Continue" : "Send", action: submit)
|
||
.buttonStyle(WebButtonStyle())
|
||
.disabled(!shown)
|
||
.fixedSize()
|
||
.help(closed ? "Start a new task from the default branch whose agent continues this conversation with your message" : "Send the message")
|
||
}
|
||
}
|
||
}
|
||
|
||
struct ComposerView: View {
|
||
let chat: ChatModel
|
||
let key: String
|
||
@Environment(Store.self) private var store
|
||
@Environment(Overlays.self) private var overlays
|
||
@Environment(\.paneAccent) private var accent
|
||
@Environment(\.inTask) private var inTask
|
||
@ViewState private var text = ""
|
||
@ViewState private var pick: String?
|
||
@ViewState private var effortPick: String?
|
||
@ViewState private var shownOverride: Bool?
|
||
@FocusState private var focused: Bool
|
||
|
||
private var shown: Bool {
|
||
shownOverride ?? !chat.composerHiddenByDefault
|
||
}
|
||
|
||
private func reveal() {
|
||
shownOverride = true
|
||
focused = true
|
||
}
|
||
|
||
private func takeFocus() {
|
||
let asked = chat.taskId != nil && store.composerFocus == chat.taskId
|
||
if !chat.takeFocus() {
|
||
return
|
||
}
|
||
if asked {
|
||
reveal()
|
||
} else if shown {
|
||
focused = true
|
||
}
|
||
}
|
||
|
||
private var draftKey: String {
|
||
"message:\(key)"
|
||
}
|
||
|
||
private struct ModelState {
|
||
let live: AgentSession?
|
||
let current: String
|
||
let currentEffort: String
|
||
let options: [String]
|
||
let modelKey: String
|
||
let selected: String
|
||
let selectedEffort: String
|
||
|
||
var changed: Bool {
|
||
selected != current || selectedEffort != currentEffort
|
||
}
|
||
}
|
||
|
||
private func modelState() -> ModelState {
|
||
let live = chat.closed ? chat.session : chat.liveSession
|
||
let current = live.map { $0.model.isEmpty ? chat.builder.context[$0.id]?.model ?? "" : $0.model } ?? ""
|
||
let currentEffort = live?.effort ?? ""
|
||
let offered = live.map { s in models.filter { vendorOf($0) == s.vendor } } ?? models
|
||
let options = !current.isEmpty && !offered.contains(current) ? [current] + offered : offered
|
||
let modelKey = "message:\(live?.id ?? key)"
|
||
let fresh = live == nil ? store.defaultModel : options[0]
|
||
let selected = pick ?? modelChoices[modelKey] ?? (current.isEmpty ? fresh : current)
|
||
let effort = effortPick ?? effortChoices[modelKey] ?? (live == nil ? store.defaultEffort : currentEffort)
|
||
return ModelState(live: live, current: current, currentEffort: currentEffort, options: options, modelKey: modelKey, selected: selected, selectedEffort: effortFor(selected, effort))
|
||
}
|
||
|
||
var body: some View {
|
||
let m = modelState()
|
||
let live = m.live
|
||
let sessionState = live?.state ?? .idle
|
||
let lost = store.drafts?.lostImageCount(draftKey) ?? 0
|
||
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
if shown && lost > 0 {
|
||
HStack(spacing: 8) {
|
||
Text("\(lost == 1 ? "1 image" : "\(lost) images") from before the reload \(lost == 1 ? "needs" : "need") to be reattached.")
|
||
.font(Fonts.font(12))
|
||
Spacer()
|
||
Button("×") {
|
||
store.drafts?.forgetLostImages(draftKey)
|
||
}
|
||
.buttonStyle(WebButtonStyle())
|
||
.help("Dismiss")
|
||
}
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 6)
|
||
.background(Palette.warnTint, in: RoundedRectangle(cornerRadius: 6))
|
||
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border))
|
||
}
|
||
if shown {
|
||
Thumbnails(key: draftKey)
|
||
editor
|
||
}
|
||
ComposerBar(chat: chat, status: chat.status(), shown: shown, draftKey: draftKey, modelKey: m.modelKey, options: m.options, selected: m.selected,
|
||
selectedEffort: m.selectedEffort, sessionId: live?.id, running: sessionState == .running, paused: live?.paused ?? false, closed: chat.closed) {
|
||
if shown {
|
||
shownOverride = false
|
||
} else {
|
||
reveal()
|
||
}
|
||
} choose: { option in
|
||
pick = option
|
||
modelChoices[m.modelKey] = option
|
||
} chooseEffort: { option in
|
||
effortPick = option
|
||
effortChoices[m.modelKey] = option
|
||
} submit: {
|
||
submit(modelState())
|
||
}
|
||
.equatable()
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 12)
|
||
.background(Palette.surface)
|
||
.overlay(alignment: .top) {
|
||
Rectangle().fill(Palette.border).frame(height: 1)
|
||
}
|
||
.onAppear {
|
||
text = store.drafts?.composerText(draftKey) ?? ""
|
||
takeFocus()
|
||
}
|
||
.onChange(of: chat.visible) {
|
||
if chat.visible {
|
||
takeFocus()
|
||
reportDraft()
|
||
} else {
|
||
focused = false
|
||
if inTask {
|
||
store.paneFocus.setDraft(draftKey, false)
|
||
}
|
||
}
|
||
}
|
||
.onChange(of: store.composerFocus) {
|
||
takeFocus()
|
||
}
|
||
.onChange(of: chat.composerHiddenByDefault) {
|
||
shownOverride = nil
|
||
}
|
||
.onChange(of: key) {
|
||
shownOverride = nil
|
||
}
|
||
.onChange(of: text) {
|
||
store.drafts?.setComposerText(draftKey, text)
|
||
store.drafts?.setComposerImages(draftKey, store.images.images(draftKey).count)
|
||
reportDraft()
|
||
}
|
||
.onChange(of: store.drafts?.composerText(draftKey)) { _, new in
|
||
if let new, new != text {
|
||
text = new
|
||
}
|
||
}
|
||
.onChange(of: store.images.images(draftKey).count) {
|
||
store.drafts?.setComposerImages(draftKey, store.images.images(draftKey).count)
|
||
}
|
||
.onChange(of: m.modelKey) {
|
||
pick = nil
|
||
effortPick = nil
|
||
}
|
||
.onChange(of: overlays.leadFocus) {
|
||
if chat.kind == .lead {
|
||
reveal()
|
||
}
|
||
}
|
||
.onChange(of: focused) {
|
||
if !focused {
|
||
store.drafts?.flush()
|
||
}
|
||
}
|
||
.onDisappear {
|
||
if inTask {
|
||
store.paneFocus.setDraft(draftKey, false)
|
||
}
|
||
store.drafts?.flush()
|
||
}
|
||
}
|
||
|
||
private var editor: some View {
|
||
AgentEditor(text: $text, placeholder: chat.placeholder, key: draftKey, height: touchOnly ? 72 : 104,
|
||
focus: $focused, border: inTask ? accent : focused ? Palette.accent : Palette.border,
|
||
borderWidth: inTask && focused ? 2 : 1) {
|
||
submit(modelState())
|
||
}
|
||
}
|
||
|
||
private func reportDraft() {
|
||
if inTask {
|
||
store.paneFocus.setDraft(draftKey, chat.visible && !trimmed(text).isEmpty)
|
||
}
|
||
}
|
||
|
||
private func submit(_ m: ModelState) {
|
||
let value = text
|
||
let images = store.images
|
||
if trimmed(value).isEmpty && !images.has(draftKey) {
|
||
return
|
||
}
|
||
guard let drafts = store.drafts else {
|
||
return
|
||
}
|
||
let imgs = images.take(draftKey)
|
||
text = ""
|
||
let sent = drafts.startSend(draftKey, value, imgs.count)
|
||
let sendID = newID("snd")
|
||
let cancel = chat.team || chat.closed ? { true } : chat.builder.showPending(value, imgs, sendID)
|
||
let sessionId = m.live?.id
|
||
Task {
|
||
var ids: [String]? = nil
|
||
var uploaded = false
|
||
while true {
|
||
do {
|
||
if !uploaded {
|
||
ids = try await uploadImages(store.api, imgs)
|
||
uploaded = true
|
||
}
|
||
try await chat.send(sessionId: sessionId, text: value, images: ids, model: m.changed ? m.selected : nil, effort: m.changed ? m.selectedEffort : nil, firstModel: m.selected, firstEffort: m.selectedEffort, sendID: sendID)
|
||
modelChoices[m.modelKey] = nil
|
||
effortChoices[m.modelKey] = nil
|
||
pick = nil
|
||
effortPick = nil
|
||
drafts.endSend(sent, true)
|
||
return
|
||
} catch {
|
||
let refused = (rootError(error) as? ApiError).map { $0.status < 500 } ?? false
|
||
if (chat.team && !chat.closed) || refused {
|
||
guard cancel() else {
|
||
log.notice("send failed after the server confirmed it: \(errorText(error), privacy: .public)")
|
||
drafts.endSend(sent, true)
|
||
return
|
||
}
|
||
drafts.endSend(sent, false)
|
||
text = drafts.composerText(draftKey)
|
||
images.restore(draftKey, imgs)
|
||
overlays.showAlert(errorText(error))
|
||
return
|
||
}
|
||
guard chat.closed || chat.builder.unconfirmed(sendID) else {
|
||
drafts.endSend(sent, true)
|
||
return
|
||
}
|
||
log.notice("send failed, retrying: \(errorText(error), privacy: .public)")
|
||
try? await Task.sleep(for: .seconds(2))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|