Skip to content

Commit df016c7

Browse files
authored
ios: fix attached image turn input and paste support (#42)
Co-authored-by: dnakov <3777433+dnakov@users.noreply.github.com>
1 parent d21ef76 commit df016c7

13 files changed

+449
-38
lines changed

apps/ios/Litter.xcodeproj/project.pbxproj

Lines changed: 22 additions & 0 deletions
Large diffs are not rendered by default.

apps/ios/Sources/Litter/Bridge/CodexProtocol.swift

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ struct UserInput: Encodable {
265265
case text
266266
case path
267267
case name
268-
case imageURL = "image_url"
268+
case url
269269
}
270270

271271
init(type: String, text: String? = nil, path: String? = nil, name: String? = nil, imageURL: String? = nil) {
@@ -275,6 +275,17 @@ struct UserInput: Encodable {
275275
self.name = name
276276
self.imageURL = imageURL
277277
}
278+
279+
func encode(to encoder: Encoder) throws {
280+
var container = encoder.container(keyedBy: CodingKeys.self)
281+
try container.encode(type, forKey: .type)
282+
try container.encodeIfPresent(text, forKey: .text)
283+
try container.encodeIfPresent(path, forKey: .path)
284+
try container.encodeIfPresent(name, forKey: .name)
285+
if type == "image" {
286+
try container.encodeIfPresent(imageURL, forKey: .url)
287+
}
288+
}
278289
}
279290

280291
struct TurnStartParams: Encodable {
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import Foundation
2+
import UIKit
3+
4+
struct PreparedImageAttachment {
5+
let data: Data
6+
let mimeType: String
7+
8+
var userInput: UserInput {
9+
UserInput(type: "image", imageURL: dataURI)
10+
}
11+
12+
var chatImage: ChatImage {
13+
ChatImage(data: data)
14+
}
15+
16+
private var dataURI: String {
17+
"data:\(mimeType);base64,\(data.base64EncodedString())"
18+
}
19+
}
20+
21+
enum ConversationAttachmentSupport {
22+
static func prepareImage(_ image: UIImage) -> PreparedImageAttachment? {
23+
guard let encodedImage = encodedImageData(for: image) else { return nil }
24+
return PreparedImageAttachment(data: encodedImage.data, mimeType: encodedImage.mimeType)
25+
}
26+
27+
static func buildTurnInputs(text: String, additionalInput: [UserInput]) -> [UserInput] {
28+
var inputs: [UserInput] = []
29+
if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
30+
inputs.append(UserInput(type: "text", text: text))
31+
}
32+
inputs.append(contentsOf: additionalInput)
33+
return inputs
34+
}
35+
36+
private static func encodedImageData(for image: UIImage) -> (data: Data, mimeType: String)? {
37+
if image.litterHasAlpha, let pngData = image.pngData() {
38+
return (pngData, "image/png")
39+
}
40+
if let jpegData = image.jpegData(compressionQuality: 0.85) {
41+
return (jpegData, "image/jpeg")
42+
}
43+
if let pngData = image.pngData() {
44+
return (pngData, "image/png")
45+
}
46+
return nil
47+
}
48+
}
49+
50+
private extension UIImage {
51+
var litterHasAlpha: Bool {
52+
guard let alphaInfo = cgImage?.alphaInfo else { return false }
53+
switch alphaInfo {
54+
case .first, .last, .premultipliedFirst, .premultipliedLast:
55+
return true
56+
default:
57+
return false
58+
}
59+
}
60+
}

apps/ios/Sources/Litter/Models/ServerConnection.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -329,8 +329,14 @@ final class ServerConnection: Identifiable {
329329
serviceTier: String? = nil,
330330
additionalInput: [UserInput] = []
331331
) async throws -> TurnStartResponse {
332-
var inputs: [UserInput] = [UserInput(type: "text", text: text)]
333-
inputs.append(contentsOf: additionalInput)
332+
let inputs = ConversationAttachmentSupport.buildTurnInputs(text: text, additionalInput: additionalInput)
333+
guard !inputs.isEmpty else {
334+
throw NSError(
335+
domain: "Litter",
336+
code: 1020,
337+
userInfo: [NSLocalizedDescriptionKey: "Cannot send an empty turn"]
338+
)
339+
}
334340
return try await routedSendRequest(
335341
method: "turn/start",
336342
params: TurnStartParams(

apps/ios/Sources/Litter/Models/ServerManager.swift

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1098,6 +1098,7 @@ final class ServerManager {
10981098

10991099
func send(
11001100
_ text: String,
1101+
attachmentImage: UIImage? = nil,
11011102
skillMentions: [SkillMentionSelection] = [],
11021103
cwd: String,
11031104
model: String? = nil,
@@ -1106,6 +1107,10 @@ final class ServerManager {
11061107
approvalPolicy: String = "never",
11071108
sandboxMode: String? = nil
11081109
) async {
1110+
let preparedAttachment = attachmentImage.flatMap(ConversationAttachmentSupport.prepareImage)
1111+
let images = preparedAttachment.map { [$0.chatImage] } ?? []
1112+
let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
1113+
guard !trimmedText.isEmpty || !images.isEmpty || !skillMentions.isEmpty else { return }
11091114
var key = activeThreadKey
11101115
if key == nil {
11111116
guard let serverId = connections.values.first(where: { $0.isConnected })?.id else { return }
@@ -1127,7 +1132,7 @@ final class ServerManager {
11271132
serverName: conn?.server.name ?? "Server",
11281133
serverSource: conn?.server.source ?? .local
11291134
)
1130-
state.items.append(makeUserItem(text: text, sourceTurnId: nil, sourceTurnIndex: nil, isBoundary: true))
1135+
state.items.append(makeUserItem(text: text, images: images, sourceTurnId: nil, sourceTurnIndex: nil, isBoundary: true))
11311136
state.items.append(makeErrorItem(message: error.localizedDescription, sourceTurnId: nil, sourceTurnIndex: nil))
11321137
state.status = .error(error.localizedDescription)
11331138
threads[errorKey] = state
@@ -1137,15 +1142,23 @@ final class ServerManager {
11371142
}
11381143
guard let key, let thread = threads[key], let conn = connections[key.serverId] else { return }
11391144
let nextTurnIndex = threadTurnCounts[key] ?? inferredTurnCount(from: thread.items)
1140-
thread.items.append(makeUserItem(text: text, sourceTurnId: nil, sourceTurnIndex: nextTurnIndex, isBoundary: true))
1145+
thread.items.append(makeUserItem(text: text, images: images, sourceTurnId: nil, sourceTurnIndex: nextTurnIndex, isBoundary: true))
11411146
thread.status = .thinking
11421147
thread.updatedAt = Date()
11431148
requestNotificationPermissionIfNeeded()
1144-
startLiveActivity(key: key, model: thread.model, cwd: thread.cwd, prompt: text)
1149+
startLiveActivity(
1150+
key: key,
1151+
model: thread.model,
1152+
cwd: thread.cwd,
1153+
prompt: !trimmedText.isEmpty ? text : (!images.isEmpty ? "Shared image" : text)
1154+
)
11451155
do {
1146-
let skillInputs = skillMentions.map { mention in
1156+
var additionalInputs = skillMentions.map { mention in
11471157
UserInput(type: "skill", path: mention.path, name: mention.name)
11481158
}
1159+
if let preparedAttachment {
1160+
additionalInputs.append(preparedAttachment.userInput)
1161+
}
11491162
let resp = try await conn.sendTurn(
11501163
threadId: key.threadId,
11511164
text: text,
@@ -1154,7 +1167,7 @@ final class ServerManager {
11541167
model: model,
11551168
effort: effort,
11561169
serviceTier: serviceTier,
1157-
additionalInput: skillInputs
1170+
additionalInput: additionalInputs
11581171
)
11591172
NSLog("[send] sendTurn succeeded, turnId=%@", resp.turnId ?? "nil")
11601173
thread.activeTurnId = resp.turnId
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import SwiftUI
2+
3+
struct ConversationComposerAttachSheet: View {
4+
let onPickPhotoLibrary: () -> Void
5+
let onTakePhoto: () -> Void
6+
7+
var body: some View {
8+
VStack(spacing: 12) {
9+
Text("Attach")
10+
.litterFont(.headline, weight: .semibold)
11+
.foregroundColor(LitterTheme.textPrimary)
12+
.frame(maxWidth: .infinity, alignment: .leading)
13+
14+
Button(action: onPickPhotoLibrary) {
15+
sheetButtonLabel("Photo Library", systemImage: "photo.on.rectangle")
16+
}
17+
18+
Button(action: onTakePhoto) {
19+
sheetButtonLabel("Take Photo", systemImage: "camera")
20+
}
21+
22+
Spacer(minLength: 0)
23+
}
24+
.padding(.horizontal, 16)
25+
.padding(.top, 12)
26+
.padding(.bottom, 20)
27+
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
28+
.background(LitterTheme.backgroundGradient.ignoresSafeArea())
29+
}
30+
31+
@ViewBuilder
32+
private func sheetButtonLabel(_ title: String, systemImage: String) -> some View {
33+
HStack(spacing: 10) {
34+
Image(systemName: systemImage)
35+
.litterFont(.body, weight: .medium)
36+
.foregroundColor(LitterTheme.accent)
37+
.frame(width: 20)
38+
39+
Text(title)
40+
.litterFont(.body, weight: .medium)
41+
.foregroundColor(LitterTheme.textPrimary)
42+
43+
Spacer()
44+
}
45+
.padding(.horizontal, 16)
46+
.frame(height: 52)
47+
.modifier(GlassRoundedRectModifier(cornerRadius: 18))
48+
}
49+
}

apps/ios/Sources/Litter/Views/ConversationComposerContentView.swift

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,16 @@ struct ConversationComposerContentView: View {
88
let contextPercent: Int64?
99
let isTurnActive: Bool
1010
let voiceManager: VoiceTranscriptionManager
11+
@Binding var showAttachMenu: Bool
1112
let onClearAttachment: () -> Void
1213
let onRespondToPendingUserInput: ([String: [String]]) -> Void
13-
let onShowAttachMenu: () -> Void
14+
let onPasteImage: (UIImage) -> Void
1415
let onSendText: () -> Void
1516
let onStopRecording: () -> Void
1617
let onStartRecording: () -> Void
1718
let onInterrupt: () -> Void
1819
@Binding var inputText: String
19-
let isComposerFocused: FocusState<Bool>.Binding
20+
@Binding var isComposerFocused: Bool
2021

2122
var body: some View {
2223
VStack(spacing: 0) {
@@ -52,11 +53,13 @@ struct ConversationComposerContentView: View {
5253
}
5354

5455
ConversationComposerEntryRowView(
56+
showAttachMenu: $showAttachMenu,
5557
inputText: $inputText,
56-
isComposerFocused: isComposerFocused,
58+
isComposerFocused: $isComposerFocused,
5759
voiceManager: voiceManager,
5860
isTurnActive: isTurnActive,
59-
onShowAttachMenu: onShowAttachMenu,
61+
hasAttachment: attachedImage != nil,
62+
onPasteImage: onPasteImage,
6063
onSendText: onSendText,
6164
onStopRecording: onStopRecording,
6265
onStartRecording: onStartRecording,

apps/ios/Sources/Litter/Views/ConversationComposerEntryRowView.swift

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import SwiftUI
2+
import UIKit
23

34
struct ConversationComposerEntryRowView: View {
5+
@Binding var showAttachMenu: Bool
46
@Binding var inputText: String
5-
let isComposerFocused: FocusState<Bool>.Binding
7+
@Binding var isComposerFocused: Bool
68
let voiceManager: VoiceTranscriptionManager
79
let isTurnActive: Bool
8-
let onShowAttachMenu: () -> Void
10+
let hasAttachment: Bool
11+
let onPasteImage: (UIImage) -> Void
912
let onSendText: () -> Void
1013
let onStopRecording: () -> Void
1114
let onStartRecording: () -> Void
@@ -15,10 +18,16 @@ struct ConversationComposerEntryRowView: View {
1518
!inputText.trimmingCharacters(in: .whitespaces).isEmpty
1619
}
1720

21+
private var canSend: Bool {
22+
hasText || hasAttachment
23+
}
24+
1825
var body: some View {
1926
HStack(alignment: .center, spacing: 8) {
2027
if !voiceManager.isRecording && !voiceManager.isTranscribing && !isTurnActive {
21-
Button(action: onShowAttachMenu) {
28+
Button {
29+
showAttachMenu = true
30+
} label: {
2231
Image(systemName: "plus")
2332
.litterFont(.body, weight: .semibold)
2433
.foregroundColor(LitterTheme.textPrimary)
@@ -29,17 +38,24 @@ struct ConversationComposerEntryRowView: View {
2938
}
3039

3140
HStack(spacing: 0) {
32-
TextField("Message litter...", text: $inputText, axis: .vertical)
33-
.litterFont(.body)
34-
.foregroundColor(LitterTheme.textPrimary)
35-
.lineLimit(1...5)
36-
.focused(isComposerFocused)
37-
.textInputAutocapitalization(.never)
38-
.autocorrectionDisabled(true)
39-
.padding(.leading, 16)
40-
.padding(.vertical, 10)
41+
ZStack(alignment: .topLeading) {
42+
ConversationComposerTextView(
43+
text: $inputText,
44+
isFocused: $isComposerFocused,
45+
onPasteImage: onPasteImage
46+
)
47+
48+
if inputText.isEmpty {
49+
Text("Message litter...")
50+
.litterFont(.body)
51+
.foregroundColor(LitterTheme.textMuted)
52+
.padding(.leading, 16)
53+
.padding(.top, 10)
54+
.allowsHitTesting(false)
55+
}
56+
}
4157

42-
if hasText {
58+
if canSend {
4359
Button(action: onSendText) {
4460
Image(systemName: "arrow.up.circle.fill")
4561
.litterFont(.title2)

apps/ios/Sources/Litter/Views/ConversationComposerModalCoordinator.swift

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,19 @@ struct ConversationComposerModalCoordinator<Content: View>: View {
6161

6262
var body: some View {
6363
content
64-
.confirmationDialog("Attach", isPresented: $showAttachMenu) {
65-
Button("Photo Library") { showPhotoPicker = true }
66-
Button("Take Photo") { showCamera = true }
64+
.sheet(isPresented: $showAttachMenu) {
65+
ConversationComposerAttachSheet(
66+
onPickPhotoLibrary: {
67+
showAttachMenu = false
68+
showPhotoPicker = true
69+
},
70+
onTakePhoto: {
71+
showAttachMenu = false
72+
showCamera = true
73+
}
74+
)
75+
.presentationDetents([.height(210)])
76+
.presentationDragIndicator(.visible)
6777
}
6878
.photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhoto, matching: .images)
6979
.onChange(of: selectedPhoto) { _, item in

0 commit comments

Comments
 (0)