macOS client panes and transcript

This commit is contained in:
Ian Gulliver
2026-10-16 23:30:57 -07:00
parent 14d7ad4631
commit 00cd1ed958
23 changed files with 5059 additions and 739 deletions
+316 -275
View File
@@ -5,6 +5,20 @@ import UniformTypeIdentifiers
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",
]
struct PendingImage: Identifiable {
let id = UUID()
let data: Data
@@ -32,150 +46,128 @@ func pngFromTIFF(_ data: Data) -> Data? {
NSBitmapImageRep(data: data)?.representation(using: .png, properties: [:])
}
@MainActor
@Observable
final class Attachments {
var images: [PendingImage] = []
let spike: SpikeLog
let api: API
let name: String
init(spike: SpikeLog, api: API, name: String) {
self.spike = spike
self.api = api
self.name = name
func uploadImages(_ api: API, _ imgs: [PendingImage]) async throws -> [String]? {
if imgs.isEmpty {
return nil
}
func add(_ data: Data, from source: String) {
guard let kind = imageType(data), let image = NSImage(data: data) else {
spike.log("\(name): \(source) gave \(data.count) bytes that aren't PNG, JPEG, GIF or WebP")
return
}
if data.count > maxImageBytes {
spike.log("\(name): \(source) image is over \(maxImageBytes >> 20) MiB")
return
}
spike.log("\(name): \(source) attached a \(kind), \(data.count) bytes")
images.append(PendingImage(data: data, image: image))
}
func add(pasteboard pb: NSPasteboard, from source: String) -> Bool {
if let urls = pb.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL], !urls.isEmpty {
var any = false
for url in urls {
if let data = try? Data(contentsOf: url), imageType(data) != nil || pngFromTIFF(data) != nil && NSImage(data: data) != nil {
add(imageType(data) != nil ? data : pngFromTIFF(data)!, from: "\(source) file \(url.lastPathComponent)")
any = true
}
}
return any
}
for t in [NSPasteboard.PasteboardType.png, NSPasteboard.PasteboardType(UTType.jpeg.identifier), NSPasteboard.PasteboardType(UTType.gif.identifier), NSPasteboard.PasteboardType(UTType.webP.identifier)] {
if let data = pb.data(forType: t) {
add(data, from: "\(source) \(t.rawValue)")
return true
}
}
if let tiff = pb.data(forType: .tiff), let png = pngFromTIFF(tiff) {
add(png, from: "\(source) public.tiff converted to PNG")
return true
}
return false
}
func add(providers: [NSItemProvider], from source: String) {
spike.log("\(name): \(source) with types \(providers.map { $0.registeredTypeIdentifiers })")
for p in providers {
let types = [UTType.png, .jpeg, .gif, .webP, .tiff, .fileURL].filter { p.hasItemConformingToTypeIdentifier($0.identifier) }
guard let t = types.first else {
continue
}
_ = p.loadDataRepresentation(for: t) { data, error in
Task { @MainActor in
guard let data else {
self.spike.log("\(self.name): \(source) load failed: \(String(describing: error))")
return
}
if t == .fileURL, let url = URL(dataRepresentation: data, relativeTo: nil), let file = try? Data(contentsOf: url) {
self.add(imageType(file) != nil ? file : (pngFromTIFF(file) ?? file), from: "\(source) file \(url.lastPathComponent)")
} else if t == .tiff, let png = pngFromTIFF(data) {
self.add(png, from: "\(source) tiff converted to PNG")
} else {
self.add(data, from: "\(source) \(t.identifier)")
}
}
}
}
}
func send(_ text: String) {
let images = self.images
self.images = []
spike.log("\(name): send \(text.debugDescription) with \(images.count) images")
Task {
for img in images {
do {
spike.log("\(name): uploaded blob \(try await upload(img.data))")
} catch {
spike.log("\(name): upload failed: \(errorText(error))")
}
}
}
}
private func upload(_ data: Data) async throws -> String {
let body = HTTPBody(data)
let typed: Operations.UploadBlob.Input.Body = switch imageType(data) {
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)
}
return try await api.client.uploadBlob(body: typed).created.body.json.id
ids.append(try await api.client.uploadBlob(body: typed).created.body.json.id)
}
return ids
}
struct Thumbnails: View {
@Bindable var attachments: Attachments
@MainActor
var modelChoices: [String: String] = [:]
var body: some View {
HStack {
ForEach(attachments.images) { img in
ZStack(alignment: .topTrailing) {
Image(nsImage: img.image)
.resizable()
.scaledToFit()
.frame(width: 64, height: 64)
Button("×") {
attachments.images.removeAll { $0.id == img.id }
func vendorOf(_ model: String) -> String {
model.hasPrefix("gpt-") ? "codex" : "claude"
}
@MainActor
@Observable
final class ImageDrafts {
private var byKey: [String: [PendingImage]] = [:]
func images(_ key: String) -> [PendingImage] {
byKey[key] ?? []
}
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 : pngFromTIFF(data)
guard let file, let image = NSImage(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
}
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
}
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)
}
}
}
}
.frame(height: attachments.images.isEmpty ? 0 : 64)
}
}
struct ComposerSpike: View {
@Environment(Store.self) private var store
@Environment(SpikeLog.self) private var spike
var body: some View {
HStack(alignment: .top, spacing: 16) {
SwiftUIComposer(attachments: Attachments(spike: spike, api: store.api, name: "TextEditor"))
AppKitComposerPanel(attachments: Attachments(spike: spike, api: store.api, name: "NSTextView"))
RoutedComposer(attachments: Attachments(spike: spike, api: store.api, name: "Routed"))
}
.padding(8)
}
struct ComposerTarget {
let paste: @MainActor (NSPasteboard) -> Bool
}
struct ComposerFocusKey: FocusedValueKey {
typealias Value = Attachments
typealias Value = ComposerTarget
}
extension FocusedValues {
var composer: Attachments? {
var composer: ComposerTarget? {
get {
self[ComposerFocusKey.self]
}
@@ -199,7 +191,7 @@ struct PasteboardCommands: Commands {
}
.keyboardShortcut("c")
Button("Paste") {
if let composer, composer.add(pasteboard: .general, from: "Edit ▸ Paste") {
if let composer, composer.paste(.general) {
return
}
NSApp.sendAction(#selector(NSText.paste(_:)), to: nil, from: nil)
@@ -213,184 +205,233 @@ struct PasteboardCommands: Commands {
}
}
struct RoutedComposer: View {
@ViewState var attachments: Attachments
@ViewState private var text = ""
@ViewState private var overlayHits = false
@ViewState private var targeted = false
struct Thumbnails: View {
let key: String
@Environment(Store.self) private var store
var body: some View {
VStack(alignment: .leading) {
Text("SwiftUI TextEditor, paste through Edit ▸ Paste, drop overlay")
Toggle("Drop overlay takes hits (off: clicks pass through to the editor)", isOn: $overlayHits)
Thumbnails(attachments: attachments)
TextEditor(text: $text)
.frame(minHeight: 160)
.border(targeted ? Color.accentColor : Color.secondary.opacity(0.4))
.focusedValue(\.composer, attachments)
.onKeyPress(.return, phases: .down) { press in
let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false
attachments.spike.log("Routed: onKeyPress return, modifiers \(press.modifiers.rawValue), hasMarkedText \(marked)")
if press.modifiers.contains(.shift) || marked {
return .ignored
}
attachments.send(text)
text = ""
return .handled
}
.overlay {
Color.clear
.contentShape(Rectangle())
.allowsHitTesting(overlayHits)
.onDrop(of: [.image, .fileURL], isTargeted: $targeted) { providers in
attachments.add(providers: providers, from: "overlay onDrop")
return true
let drafts = store.images
let imgs = drafts.images(key)
if !imgs.isEmpty {
FlowLayout(spacing: 8) {
ForEach(imgs) { img in
Image(nsImage: 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(WebButtonStyle())
.help("Remove image")
.offset(x: 6, y: -6)
}
}
.onChange(of: targeted) {
attachments.spike.log("Routed: overlay drop targeted \(targeted)")
}
}
.padding(.top, 6)
}
}
}
struct SwiftUIComposer: View {
@ViewState var attachments: Attachments
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(\.inRightPane) private var inRightPane
@ViewState private var text = ""
@ViewState private var checkMarked = false
@ViewState private var pick: String?
@ViewState private var targeted = false
@FocusState private var focused: Bool
private var draftKey: String {
"message:\(key)"
}
private struct ModelState {
let live: AgentSession?
let current: String
let options: [String]
let modelKey: String
let selected: String
}
private func modelState() -> ModelState {
let live = chat.liveSession
let current = live.map { $0.model.isEmpty ? chat.builder.context[$0.id]?.model ?? "" : $0.model } ?? ""
let offered = live.map { s in models.filter { vendorOf($0) == s.vendor.rawValue } } ?? models
let options = !current.isEmpty && !offered.contains(current) ? [current] + offered : offered
let modelKey = "message:\(live?.id ?? key)"
return ModelState(live: live, current: current, options: options, modelKey: modelKey, selected: pick ?? modelChoices[modelKey] ?? (current.isEmpty ? options[0] : current))
}
var body: some View {
VStack(alignment: .leading) {
Text("SwiftUI TextEditor + onKeyPress, onPasteCommand, onDrop")
Toggle("In onKeyPress, also ignore Return while the first responder hasMarkedText()", isOn: $checkMarked)
Thumbnails(attachments: attachments)
TextEditor(text: $text)
.frame(minHeight: 160)
.border(Color.secondary.opacity(0.4))
.onKeyPress(.return, phases: .down) { press in
let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false
attachments.spike.log("TextEditor: onKeyPress return, modifiers \(press.modifiers.rawValue), hasMarkedText \(marked)")
if press.modifiers.contains(.shift) {
return .ignored
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) {
if lost > 0 {
HStack(spacing: 8) {
Text("\(lost == 1 ? "1 image" : "\(lost) images") from before the reload need re-attaching.")
.font(Fonts.font(12))
Spacer()
Button("×") {
store.drafts?.forgetLostImages(draftKey)
}
if checkMarked && marked {
return .ignored
.buttonStyle(WebButtonStyle())
.help("Dismiss")
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Palette.warnTint, in: RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border))
}
Thumbnails(key: draftKey)
editor
HStack(spacing: 8) {
Picker("", selection: Binding(get: { selected }, set: { v in
pick = v
modelChoices[modelKey] = v
})) {
ForEach(options, id: \.self) { m in
Text(m).tag(m)
}
attachments.send(text)
text = ""
return .handled
}
.onPasteCommand(of: [.png, .jpeg, .gif, .webP, .tiff, .fileURL]) { providers in
attachments.add(providers: providers, from: "onPasteCommand")
.labelsHidden()
.pickerStyle(.menu)
.fixedSize()
.help("Model for the next turn")
Spacer()
Button("Interrupt") {
if let id = live?.id {
chat.interrupt(id, overlays)
}
}
.onDrop(of: [.image, .fileURL], isTargeted: nil) { providers in
attachments.add(providers: providers, from: "onDrop")
return true
.buttonStyle(WebButtonStyle(danger: true))
.disabled(sessionState != .running)
Button("Send") {
submit(modelState())
}
.buttonStyle(WebButtonStyle())
}
}
.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) ?? ""
if chat.takeFocusStart() {
focused = true
}
}
.onChange(of: text) {
store.drafts?.setComposerText(draftKey, text)
store.drafts?.setComposerImages(draftKey, store.images.images(draftKey).count)
reportDraft()
}
.onChange(of: store.images.images(draftKey).count) {
store.drafts?.setComposerImages(draftKey, store.images.images(draftKey).count)
}
.onChange(of: modelKey) {
pick = nil
}
.onChange(of: focused) {
if !focused {
store.drafts?.flush()
}
}
.onDisappear {
if inRightPane {
store.paneFocus.setDraft(draftKey, false)
}
store.drafts?.flush()
}
}
}
struct AppKitComposerPanel: View {
@ViewState var attachments: Attachments
var body: some View {
VStack(alignment: .leading) {
Text("NSTextView in NSViewRepresentable")
Text("doCommand(insertNewline:), paste(_:), performDragOperation")
Thumbnails(attachments: attachments)
AppKitComposer(attachments: attachments)
.frame(minHeight: 160)
.border(Color.secondary.opacity(0.4))
}
}
}
final class ComposerTextView: NSTextView {
var attachments: Attachments!
override func keyDown(with event: NSEvent) {
if event.keyCode == 36 || event.keyCode == 76 {
attachments.spike.log("NSTextView: keyDown return, modifiers \(event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue), hasMarkedText \(hasMarkedText())")
}
super.keyDown(with: event)
private var editor: some View {
TextEditor(text: $text)
.font(Fonts.font(13))
.scrollContentBackground(.hidden)
.focused($focused)
.focusedValue(\.composer, ComposerTarget(paste: { pb in store.images.add(draftKey, pasteboard: pb, overlays) }))
.onKeyPress(.return, phases: .down) { press in
let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false
if press.modifiers.contains(.shift) || marked {
return .ignored
}
submit(modelState())
return .handled
}
.padding(.horizontal, 5)
.padding(.vertical, 6)
.frame(height: 104)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 6))
.overlay(alignment: .topLeading) {
if text.isEmpty {
Text(chat.placeholder)
.foregroundStyle(Palette.muted)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.allowsHitTesting(false)
}
}
.overlay(RoundedRectangle(cornerRadius: 6).stroke(targeted ? Palette.accent : inRightPane ? accent : focused ? Palette.accent : Palette.border, lineWidth: inRightPane && focused ? 2 : 1))
.overlay {
Color.clear
.contentShape(Rectangle())
.allowsHitTesting(false)
.onDrop(of: [.image, .fileURL], isTargeted: $targeted) { providers in
store.images.add(draftKey, providers: providers, overlays)
return true
}
}
}
override func doCommand(by selector: Selector) {
let shift = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false
if selector == #selector(insertNewline(_:)) && !shift {
attachments.send(string)
string = ""
private func reportDraft() {
if inRightPane {
store.paneFocus.setDraft(draftKey, !trimmed(text).isEmpty)
}
}
private func submit(_ m: ModelState) {
let value = text
let images = store.images
if trimmed(value).isEmpty && images.images(draftKey).isEmpty {
return
}
if selector == #selector(insertNewline(_:)) {
insertNewlineIgnoringFieldEditor(nil)
guard let drafts = store.drafts else {
return
}
super.doCommand(by: selector)
}
override func paste(_ sender: Any?) {
if attachments.add(pasteboard: .general, from: "paste(_:)") {
return
let imgs = images.take(draftKey)
text = ""
let sent = drafts.startSend(draftKey, value, imgs.count)
let cancel = chat.builder.showPending(value, imgs)
Task {
do {
let ids = try await uploadImages(store.api, imgs)
try await chat.send(sessionId: m.live?.id, text: value, images: ids, model: m.selected != m.current ? m.selected : nil, firstModel: m.selected)
modelChoices[m.modelKey] = nil
pick = nil
drafts.endSend(sent, true)
} catch {
cancel()
drafts.endSend(sent, false)
text = drafts.composerText(draftKey)
images.restore(draftKey, imgs)
overlays.showAlert(errorText(error))
}
}
super.paste(sender)
}
override var readablePasteboardTypes: [NSPasteboard.PasteboardType] {
super.readablePasteboardTypes + [.png, .tiff, .fileURL]
}
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
if hasImage(sender.draggingPasteboard) {
return .copy
}
return super.draggingEntered(sender)
}
override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation {
if hasImage(sender.draggingPasteboard) {
return .copy
}
return super.draggingUpdated(sender)
}
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
if hasImage(sender.draggingPasteboard) {
return attachments.add(pasteboard: sender.draggingPasteboard, from: "drop")
}
return super.performDragOperation(sender)
}
private func hasImage(_ pb: NSPasteboard) -> Bool {
if let urls = pb.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL], !urls.isEmpty {
return urls.contains { (try? Data(contentsOf: $0)).map { imageType($0) != nil || NSImage(data: $0) != nil } ?? false }
}
return pb.availableType(from: [.png, .tiff, NSPasteboard.PasteboardType(UTType.jpeg.identifier), NSPasteboard.PasteboardType(UTType.gif.identifier), NSPasteboard.PasteboardType(UTType.webP.identifier)]) != nil
}
}
struct AppKitComposer: NSViewRepresentable {
let attachments: Attachments
func makeNSView(context: Context) -> NSScrollView {
let scroll = NSScrollView()
scroll.hasVerticalScroller = true
let tv = ComposerTextView(frame: .zero)
tv.attachments = attachments
tv.isRichText = false
tv.allowsUndo = true
tv.font = Fonts.ct(13) as NSFont
tv.minSize = .zero
tv.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
tv.isVerticallyResizable = true
tv.isHorizontallyResizable = false
tv.autoresizingMask = [.width]
tv.textContainer?.widthTracksTextView = true
tv.registerForDraggedTypes([.png, .tiff, .fileURL])
scroll.documentView = tv
return scroll
}
func updateNSView(_ nsView: NSScrollView, context: Context) {}
}