Add an iPhone client sharing the macOS app's SwiftUI code

This commit is contained in:
Ian Gulliver
2026-10-21 22:00:54 -07:00
parent 57d660c57f
commit 9e81e9107d
46 changed files with 1625 additions and 287 deletions
+142 -57
View File
@@ -1,8 +1,10 @@
import AppKit
import ByreAPI
import OpenAPIRuntime
import SwiftUI
import UniformTypeIdentifiers
#if os(iOS)
import PhotosUI
#endif
let maxImageBytes = 5 << 20
@@ -29,7 +31,7 @@ let models = [
struct PendingImage: Identifiable {
let id = UUID()
let data: Data
let image: NSImage
let image: PlatformImage
}
func imageType(_ data: Data) -> String? {
@@ -49,8 +51,12 @@ func imageType(_ data: Data) -> String? {
return nil
}
func pngFromTIFF(_ data: Data) -> Data? {
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]? {
@@ -116,8 +122,8 @@ final class ImageDrafts {
@discardableResult
func add(_ key: String, _ data: Data, name: String?, _ overlays: Overlays) -> Bool {
let file = imageType(data) != nil ? data : pngFromTIFF(data)
guard let file, let image = NSImage(data: file) else {
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
}
@@ -129,6 +135,7 @@ final class ImageDrafts {
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 }
@@ -147,6 +154,7 @@ final class ImageDrafts {
}
return false
}
#endif
func add(_ key: String, providers: [NSItemProvider], _ overlays: Overlays) {
for p in providers {
@@ -175,6 +183,7 @@ final class ImageDrafts {
}
}
#if os(macOS)
struct ComposerTarget {
let paste: @MainActor (NSPasteboard) -> Bool
}
@@ -221,6 +230,7 @@ struct PasteboardCommands: Commands {
}
}
}
#endif
private struct ImageInput: ViewModifier {
let key: String
@@ -230,7 +240,9 @@ private struct ImageInput: ViewModifier {
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)
@@ -254,6 +266,59 @@ extension View {
}
}
#if os(iOS)
private struct ComposerMenu: View {
let key: String
let photos: Bool
let models: [String]
let selected: String
let choose: @MainActor @Sendable (String) -> Void
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var picking = false
@ViewState private var picked: [PhotosPickerItem] = []
var body: some View {
Menu {
if photos {
Button {
picking = true
} label: {
Label("Attach photos", systemImage: "photo")
}
}
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)
}
} label: {
Image(systemName: "ellipsis")
.frame(height: 14)
}
.buttonStyle(WebButtonStyle())
.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
struct Thumbnails: View {
let key: String
@Environment(Store.self) private var store
@@ -264,7 +329,7 @@ struct Thumbnails: View {
if !imgs.isEmpty {
FlowLayout(spacing: 8) {
ForEach(imgs) { img in
Image(nsImage: img.image)
Image(platformImage: img.image)
.resizable()
.scaledToFit()
.frame(maxWidth: 120, maxHeight: 90)
@@ -325,6 +390,7 @@ struct AgentEditor: View {
.scrollContentBackground(.hidden)
.writingToolsBehavior(.disabled)
.focused(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 {
@@ -333,6 +399,7 @@ struct AgentEditor: View {
send()
return .handled
}
#endif
.padding(.horizontal, 5)
.padding(.vertical, 6)
.frame(height: height)
@@ -410,9 +477,6 @@ struct ComposerView: View {
let m = modelState()
let live = m.live
let sessionState = live?.state ?? .idle
let options = m.options
let modelKey = m.modelKey
let selected = m.selected
let lost = store.drafts?.lostImageCount(draftKey) ?? 0
VStack(alignment: .leading, spacing: 8) {
@@ -436,53 +500,17 @@ struct ComposerView: View {
Thumbnails(key: draftKey)
editor
}
let status = chat.status().map { s in
StatusButton(status: s) {
chat.showStatus(s)
}
}
HStack(spacing: 8) {
HStack(spacing: 8) {
Button(shown ? "⌄" : "⌃") {
if shown {
shownOverride = false
} else {
reveal()
}
}
.buttonStyle(WebButtonStyle())
.help(shown ? "Hide the message box" : "Show the message box")
if !chat.team {
Picker("", selection: Binding(get: { selected }, set: { v in
pick = v
modelChoices[modelKey] = v
})) {
ForEach(options, id: \.self) { m in
Text(m).tag(m)
}
}
.labelsHidden()
.pickerStyle(.menu)
.fixedSize()
.help("Model for the next turn")
}
}
.frame(maxWidth: .infinity, alignment: .leading)
if let status = chat.status() {
StatusButton(status: status) {
chat.showStatus(status)
}
}
HStack(spacing: 8) {
Button("Interrupt") {
if let id = live?.id {
chat.interrupt(id, overlays)
}
}
.buttonStyle(WebButtonStyle(danger: true))
.disabled(sessionState != .running)
Button("Send") {
submit(modelState())
}
.buttonStyle(WebButtonStyle())
.disabled(!shown)
}
.frame(maxWidth: .infinity, alignment: .trailing)
controls(m)
.frame(maxWidth: .infinity, alignment: .leading)
status
actions(live?.id, running: sessionState == .running)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
.padding(.horizontal, 16)
@@ -517,7 +545,7 @@ struct ComposerView: View {
.onChange(of: store.images.images(draftKey).count) {
store.drafts?.setComposerImages(draftKey, store.images.images(draftKey).count)
}
.onChange(of: modelKey) {
.onChange(of: m.modelKey) {
pick = nil
}
.onChange(of: overlays.leadFocus) {
@@ -538,8 +566,65 @@ struct ComposerView: View {
}
}
private func controls(_ m: ModelState) -> some View {
HStack(spacing: 8) {
Button {
if shown {
shownOverride = false
} else {
reveal()
}
} label: {
Image(systemName: shown ? "chevron.down" : "chevron.up")
.frame(height: 14)
}
.buttonStyle(WebButtonStyle())
.help(shown ? "Hide the message box" : "Show the message box")
#if os(iOS)
ComposerMenu(key: draftKey, photos: shown, models: chat.team ? [] : m.options, selected: m.selected) { option in
pick = option
modelChoices[m.modelKey] = option
}
#else
if !chat.team {
Picker("", selection: Binding(get: { m.selected }, set: { v in
pick = v
modelChoices[m.modelKey] = v
})) {
ForEach(m.options, id: \.self) { option in
Text(option).tag(option)
}
}
.labelsHidden()
.pickerStyle(.menu)
.fixedSize()
.help("Model for the next turn")
}
#endif
}
}
private func actions(_ sessionId: String?, running: Bool) -> some View {
HStack(spacing: 8) {
Button("Interrupt") {
if let sessionId {
chat.interrupt(sessionId, overlays)
}
}
.buttonStyle(WebButtonStyle(danger: true))
.disabled(!running)
.fixedSize()
Button("Send") {
submit(modelState())
}
.buttonStyle(WebButtonStyle())
.disabled(!shown)
.fixedSize()
}
}
private var editor: some View {
AgentEditor(text: $text, placeholder: chat.placeholder, key: draftKey, height: 104,
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())