import AVFAudio import HapiClient import HapiProtocol import HapiUI import PhotosUI import SwiftUI import UIKit import UniformTypeIdentifiers /// The chat input card (A-M3a, extended in A-M3f): attachments and the /// multiline text field grow above a fixed bottom action row. The trailing /// primary action is stateful: Stop while a turn runs with an empty draft, /// otherwise Send (long-press offers "Send & steer" during a turn). The card /// also hosts dictation and attachment controls, and a `/` command list /// above the card while the input is a lone slash token. struct ChatComposerView: View { @Environment(\.hapiTypography) private var typography let interactor: ChatInteractor /// nil ⇒ dictation unavailable (no controller wired) — mic button hidden. var dictation: DictationController? /// Folded iPhone Duo: the short outer screen keeps more transcript by /// letting the composer sit closer to the home indicator. var hugsBottomEdge = false var onOpenScratchlist: (ScratchlistEntry?, Bool) -> Void = { _, _ in } // Attachment pickers (the launchers live here; policy + upload live in // HapiKit — `AttachmentPreparer` / `ComposerAttachments`). @State private var attachDialogOpen = false @State private var photosPickerOpen = false @State private var photoSelection: [PhotosPickerItem] = [] @State private var cameraOpen = false @State private var filePickerOpen = false @FocusState private var textFocused: Bool @State private var preparingAttachment = false @State private var schedulePickerOpen = false private var scratchlistMode: Bool { interactor.composerDestination == .scratchlist } private var composerPrompt: LocalizedStringKey { scratchlistMode ? "Keep an instruction for later…" : "Message the agent…" } private var text: Binding { Binding( get: { interactor.composerText }, set: { interactor.setComposerText($0) } ) } /// Elevated iOS surface: white in light mode, lifted charcoal in dark /// mode. `secondarySystemBackground` made the whole composer read like a /// grouped form cell instead of a floating iOS control. private var composerSurfaceColor: Color { Color(uiColor: UIColor { traits in traits.userInterfaceStyle == .dark ? UIColor(white: 0.12, alpha: 1) : .white }) } var body: some View { let composer = interactor.composer let attachments = interactor.attachments.items let slashSuggestions = interactor.slashSuggestions VStack(spacing: 0) { if !slashSuggestions.isEmpty { SlashCommandList(commands: slashSuggestions, surface: composerSurfaceColor) { command in interactor.selectSlashCommand(command) } .padding(.bottom, 6) } VStack(spacing: 0) { if scratchlistMode, let store = interactor.scratchlist { ScratchlistDrawerView(store: store, sessionId: interactor.sessionId, interactor: interactor, keyboardFocused: textFocused, onOpen: onOpenScratchlist) } if let error = interactor.scratchlistError { ScratchlistErrorBanner(message: error, actionTitle: interactor.scratchlistErrorDestination == nil ? "Dismiss" : "Retry", retry: interactor.retryScratchlistComposerOperation) .padding(.horizontal, 12) } if !attachments.isEmpty { attachmentsRow(attachments) .padding(.horizontal, 12) .padding(.top, 8) } if !scratchlistMode, let schedule = interactor.pendingSchedule { ScheduledSendChip(schedule: schedule) { interactor.setPendingSchedule(nil) } .padding(.horizontal, 12) .padding(.top, 8) } if let dictation, case .recording(let startedAtMs) = dictation.state { RecordingChipView(startedAtMs: startedAtMs) { dictation.cancel() } .padding(.horizontal, 12) .padding(.top, 8) } TextField(composerPrompt, text: text, prompt: Text(composerPrompt).foregroundColor(.secondary), axis: .vertical) .focused($textFocused) .font(typography.bodyFont) .lineLimit(1...6) .textFieldStyle(.plain) .padding(.horizontal, 12) .padding(.top, 12) .padding(.bottom, 4) HStack(spacing: 2) { addAttachmentButton if interactor.scratchlist != nil { scratchlistButton } if !scratchlistMode { scheduleButton(hasAttachments: !attachments.isEmpty) } Spacer() if scratchlistMode, composer.canSteer { Button { interactor.abortSession() } label: { Image(systemName: "stop.fill").foregroundStyle(.red).frame(width: 44, height: 44) }.accessibilityLabel("Stop the current turn") } if let dictation, dictation.isAvailable { micButton(dictation) } if scratchlistMode { parkButton } else { primaryActionButton(composer, attachments: attachments) } } // 44 pt touch slot with a centered 38 pt circle: // 9 + 3 = the shared 12 pt visual inset. .padding(.horizontal, 9) .padding(.bottom, 4) } .background( RoundedRectangle(cornerRadius: 20, style: .continuous) .fill(composerSurfaceColor) ) .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) .shadow(color: .black.opacity(0.06), radius: 6, y: 2) } .hapiReadingColumn() .padding(.top, 8) // Overlap the home-indicator safe area only while the software // keyboard is down; above a keyboard the normal gap applies. .padding(.bottom, hugsBottomEdge && !textFocused ? -8 : 8) .animation(.snappy, value: hugsBottomEdge && !textFocused) .onChange(of: interactor.composerFocusRequest) { textFocused = true } .sheet(isPresented: $schedulePickerOpen) { ScheduleTimeSheet { date in interactor.setPendingSchedule(.at(Int(date.timeIntervalSince1970 * 1000))) } } .task(id: dictation.map { ObjectIdentifier($0) }) { await dictation?.refreshAvailability() } .photosPicker( isPresented: $photosPickerOpen, selection: $photoSelection, matching: .any(of: [.images, .videos]) ) .onChange(of: photoSelection) { let items = photoSelection guard !items.isEmpty else { return } photoSelection = [] Task { for item in items { await ingestPhotoItem(item) } } } .fullScreenCover(isPresented: $cameraOpen) { CameraCaptureView { jpeg in Task { await handle(AttachmentPreparer.prepare( data: jpeg, filename: AttachmentPreparer.cameraFilename(), mimeType: "image/jpeg" )) } } .ignoresSafeArea() } .fileImporter( isPresented: $filePickerOpen, allowedContentTypes: [.item], allowsMultipleSelection: true ) { outcome in guard case .success(let urls) = outcome else { return } Task { for url in urls { await handle(AttachmentPreparer.prepare(fileURL: url)) } } } } // MARK: - Attachment ingestion private func ingestPhotoItem(_ item: PhotosPickerItem) async { let type = item.supportedContentTypes.first if type?.conforms(to: .audiovisualContent) == true { // Videos import as a temp file (capped read in the preparer) so a // multi-GB pick never has to fit in memory. guard let movie = try? await item.loadTransferable(type: PickedMovie.self) else { interactor.postNotice(String(localized: "Couldn't read the selected video")) return } let result = await AttachmentPreparer.prepare(fileURL: movie.url) try? FileManager.default.removeItem(at: movie.url) await handle(result) return } let naming = AttachmentPreparer.photoFilename(for: type) guard let data = try? await item.loadTransferable(type: Data.self) else { interactor.postNotice(String(format: String(localized: "Couldn't read %@"), naming.filename)) return } await handle(AttachmentPreparer.prepare( data: data, filename: naming.filename, mimeType: naming.mimeType )) } private func handle(_ result: PrepareResult) async { switch result { case .ready(let prepared): if scratchlistMode, let store = interactor.scratchlist { preparingAttachment = true defer { preparingAttachment = false } let limits = await store.limits(sessionId: interactor.sessionId) let existing = interactor.attachments.scratchlistBudget let outcome = await Task.detached(priority: .userInitiated) { ScratchlistAttachmentImport.prepare(data: prepared.bytes, filename: prepared.filename, mimeType: prepared.mimeType, existing: existing, limits: limits) }.value switch outcome { case .ready(let attachment): interactor.attachments.add(PreparedAttachment(filename: attachment.filename, mimeType: attachment.mimeType, bytes: attachment.data, previewBytes: prepared.previewBytes), toScratchlist: true) case .rejected(let message): interactor.reportScratchlistError(message) } } else { interactor.attachments.add(prepared) } case .tooLarge(let filename, _): interactor.postNotice(String(format: String(localized: "%@ is over the 50 MB upload limit"), filename)) case .unreadable(let filename): interactor.postNotice(String(format: String(localized: "Couldn't read %@"), filename)) } } // MARK: - Attachment chips private func attachmentsRow(_ attachments: [ComposerAttachmentUI]) -> some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { ForEach(attachments) { attachment in ComposerAttachmentChipView( attachment: attachment, onRetry: { interactor.attachments.retry(attachment.id) }, onRemove: { interactor.attachments.remove(attachment.id) } ) .disabled(interactor.scratchlistBusy || interactor.isSending) } } } } /// Web clock: relative presets (resolved at Send) or a specific time /// within 7 days. Unavailable with attachments — the hub rejects them. private func scheduleButton(hasAttachments: Bool) -> some View { let active = interactor.pendingSchedule != nil return Menu { Section("Schedule send") { ForEach(PendingSchedule.presets, id: \.self) { preset in Button(ScheduledSendChip.presetLabel(preset)) { interactor.setPendingSchedule(preset) } } Button("Pick a Time…", systemImage: "calendar") { schedulePickerOpen = true } } if active { Button("Don't Schedule", systemImage: "xmark") { interactor.setPendingSchedule(nil) } } } label: { actionCircle( background: active ? AnyShapeStyle(.tint.opacity(0.18)) : AnyShapeStyle(.fill.tertiary), foreground: active ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary) ) { Image(systemName: "clock") .font(.system(size: 15, weight: .medium)) } } .disabled(hasAttachments || interactor.scratchlistBusy) .accessibilityLabel("Schedule send") .accessibilityIdentifier("chat.schedule-send") } private var addAttachmentButton: some View { Button { attachDialogOpen = true } label: { actionCircle( background: AnyShapeStyle(.fill.tertiary), foreground: AnyShapeStyle(.secondary) ) { Image(systemName: "plus") .font(.system(size: 15, weight: .medium)) } } .buttonStyle(.plain) .disabled(preparingAttachment || interactor.scratchlistBusy) .accessibilityLabel("Add attachment") .accessibilityIdentifier("chat.add-attachment") .confirmationDialog("Attach", isPresented: $attachDialogOpen, titleVisibility: .visible) { Button("Photo library") { photosPickerOpen = true } if CameraCaptureView.isAvailable { Button("Camera") { cameraOpen = true } } Button("Files") { filePickerOpen = true } } } private var scratchlistButton: some View { Button { interactor.setComposerDestination(scratchlistMode ? .chat : .scratchlist) } label: { HStack(spacing: 3) { Image(systemName: "tray") if interactor.scratchlistCount > 0 { Text(verbatim: interactor.scratchlistCount > 99 ? "99+" : "\(interactor.scratchlistCount)") .font(.caption.weight(.medium)).monospacedDigit() } } .foregroundStyle(scratchlistMode ? AnyShapeStyle(.orange) : AnyShapeStyle(.secondary)) .padding(.horizontal, 6) .frame(minWidth: 44, minHeight: 44) .contentShape(Rectangle()) } .buttonStyle(.plain) .disabled(interactor.scratchlistBusy || interactor.isSending || preparingAttachment) .accessibilityLabel(String(format: String(localized: "Scratchlist (%lld)"), Int64(interactor.scratchlistCount))) .accessibilityValue(scratchlistMode ? String(localized: "Held — not sent") : String(localized: "Chat")) .accessibilityIdentifier("scratchlist.toggle") } private var parkButton: some View { Button { interactor.parkComposerDraft() } label: { HStack(spacing: 5) { if interactor.scratchlistBusy { ProgressView().controlSize(.small) } else { Image(systemName: "tray.and.arrow.down") } Text("Save draft").font(.subheadline.weight(.semibold)) } .padding(.horizontal, 12) .frame(minHeight: 38) .background(.orange.opacity(0.16), in: Capsule()) .frame(minHeight: 44) } .buttonStyle(.plain) .disabled(!interactor.hasComposerDraft || interactor.scratchlistBusy || interactor.isSending || interactor.attachments.hasUnsettled || preparingAttachment) .accessibilityIdentifier("scratchlist.park") } // MARK: - Dictation private func micButton(_ dictation: DictationController) -> some View { let recording: Bool = { if case .recording = dictation.state { return true } return false }() let busy = dictation.state == .starting || dictation.state == .transcribing return Button { toggleDictation(dictation) } label: { actionCircle( background: recording ? AnyShapeStyle(.red.opacity(0.15)) : AnyShapeStyle(.fill.tertiary), foreground: recording ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary) ) { if busy { ProgressView() .controlSize(.small) } else { Image(systemName: recording ? "stop.fill" : "mic.fill") .font(.system(size: 15)) } } } .buttonStyle(.plain) .disabled(busy) .accessibilityLabel(recording ? String(localized: "Stop dictation") : String(localized: "Dictate")) } private func toggleDictation(_ dictation: DictationController) { // Stopping never needs the permission; starting checks + requests it. guard dictation.state == .idle else { dictation.toggle() return } switch AVAudioApplication.shared.recordPermission { case .granted: dictation.toggle() case .denied: interactor.postNotice(String(localized: "Microphone access is disabled — allow it in Settings to dictate")) case .undetermined: Task { let granted = await AVAudioApplication.requestRecordPermission() if granted { dictation.toggle() } else { interactor.postNotice(String(localized: "Microphone permission is needed for dictation")) } } @unknown default: dictation.toggle() } } // MARK: - Buttons @ViewBuilder private func primaryActionButton(_ composer: ComposerState, attachments: [ComposerAttachmentUI]) -> some View { let hasText = !composer.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty let hasDraft = hasText || !attachments.isEmpty let scheduled = interactor.pendingSchedule != nil // Attachments gate the send like the web: every chip must settle // Ready (uploading waits, failed must be retried or removed); a ready // tray allows an attachments-only send (wire: text or attachments). // A scheduled send is text-only and never steers. let attachmentsBusy = attachments.contains { $0.status != .ready } let attachmentsReady = !attachments.isEmpty && !attachmentsBusy let canSubmit = scheduled ? hasText && attachments.isEmpty && !composer.isSending : (hasText || attachmentsReady) && !attachmentsBusy && !composer.isSending let action: ComposerPrimaryAction = if composer.isSending { .sending } else if composer.canSteer && !hasDraft { .stop } else { .send(enabled: canSubmit) } switch action { case .sending: Button {} label: { actionCircle( background: AnyShapeStyle(.fill.tertiary), foreground: AnyShapeStyle(.secondary) ) { ProgressView() .controlSize(.small) } } .buttonStyle(.plain) .disabled(true) .accessibilityLabel("Send") case .stop: Button { interactor.abortSession() } label: { actionCircle( background: AnyShapeStyle(.red), foreground: AnyShapeStyle(.white) ) { Image(systemName: "stop.fill") .font(.system(size: 15)) } } .buttonStyle(.plain) .accessibilityLabel("Stop the current turn") case .send(let enabled): let label = sendLabel(enabled: enabled) if scheduled { Button { interactor.sendMessage() } label: { label } .buttonStyle(.plain) .disabled(!enabled) .accessibilityLabel("Schedule send") } else if composer.canSteer && enabled { // Tap sends (queue); long-press opens the deliberate steer intent. Menu { Button("Send & steer into current turn") { interactor.sendMessage(steer: true) } } label: { label } primaryAction: { interactor.sendMessage() } .accessibilityLabel("Send — long-press to steer") } else { Button { interactor.sendMessage() } label: { label } .buttonStyle(.plain) .disabled(!enabled) .accessibilityLabel("Send") } } } private func sendLabel(enabled: Bool) -> some View { actionCircle( background: enabled ? AnyShapeStyle(.tint) : AnyShapeStyle(.fill.tertiary), foreground: enabled ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary) ) { Image(systemName: "arrow.up") .font(.system(size: 15, weight: .semibold)) } } // Icon-only controls keep their 38pt visuals / 44pt hit areas. Their // symbols use fixed sizes; editable text remains fully Dynamic Type. private func actionCircle( background: AnyShapeStyle, foreground: AnyShapeStyle, @ViewBuilder content: () -> Content ) -> some View { content() .frame(width: 38, height: 38) .background(background, in: Circle()) .foregroundStyle(foreground) .frame(width: 44, height: 44) .contentShape(Rectangle()) } } private enum ComposerPrimaryAction { case sending case stop case send(enabled: Bool) } // MARK: - Attachment chip /// One tray chip: 36 pt thumb (image preview / MIME glyph, spinner while /// uploading), filename + status line, ✕ to remove. A failed chip tints red /// and taps to retry. /// Tap inserts `/name ` for arguments. Short lists size to their rows; /// longer ones scroll inside a bounded card. private struct SlashCommandList: View { let commands: [SlashCommand] let surface: Color let onSelect: (SlashCommand) -> Void var body: some View { Group { if commands.count > 4 { ScrollView { rows } .frame(height: 220) } else { rows } } .background(RoundedRectangle(cornerRadius: 16, style: .continuous).fill(surface)) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .shadow(color: .black.opacity(0.06), radius: 6, y: 2) .accessibilityIdentifier("composer.slash-commands") } private var rows: some View { LazyVStack(alignment: .leading, spacing: 0) { ForEach(Array(commands.enumerated()), id: \.element.name) { index, command in if index > 0 { Divider().padding(.leading, 12) } Button { onSelect(command) } label: { VStack(alignment: .leading, spacing: 2) { Text(verbatim: "/\(command.name)") .font(.body.monospaced()) .foregroundStyle(.primary) if let description = command.description, !description.isEmpty { Text(verbatim: description) .font(.footnote) .foregroundStyle(.secondary) .lineLimit(2) } } .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) .padding(.horizontal, 12) .padding(.vertical, 4) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityIdentifier("composer.slash.\(command.name)") } } } } /// The pending send time above the input, with × to send normally again. private struct ScheduledSendChip: View { let schedule: PendingSchedule let onClear: () -> Void static func presetLabel(_ schedule: PendingSchedule) -> String { switch schedule { case .delay(let minutes) where minutes % 60 == 0: let hours = minutes / 60 return hours == 1 ? String(localized: "In 1 hour") : String(format: String(localized: "In %lld hours"), Int64(hours)) case .delay(let minutes): return String(format: String(localized: "In %lld minutes"), Int64(minutes)) case .at(let ms): return Date(timeIntervalSince1970: Double(ms) / 1000) .formatted(date: .abbreviated, time: .shortened) } } var body: some View { HStack(spacing: 6) { Image(systemName: "clock") Text(String(format: String(localized: "Scheduled · %@"), Self.presetLabel(schedule))) .lineLimit(1) Spacer(minLength: 4) Button(action: onClear) { Image(systemName: "xmark.circle.fill") .foregroundStyle(.secondary) .frame(minWidth: 44, minHeight: 32) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel("Don't Schedule") } .font(.footnote) .foregroundStyle(.tint) .padding(.leading, 10) .background(.tint.opacity(0.1), in: Capsule()) .accessibilityElement(children: .contain) .accessibilityIdentifier("chat.scheduled-send") } } /// Specific send time: now through the hub's 7-day limit. private struct ScheduleTimeSheet: View { @Environment(\.dismiss) private var dismiss let onSchedule: (Date) -> Void @State private var date = Date().addingTimeInterval(30 * 60) var body: some View { let now = Date() NavigationStack { Form { Section { DatePicker( "Send at", selection: $date, in: now...now.addingTimeInterval(TimeInterval(PendingSchedule.maxDays * 24 * 60 * 60)), displayedComponents: [.date, .hourAndMinute] ) .datePickerStyle(.graphical) } footer: { Text("Max 7 days. Requires the hub running; the CLI catches up the next time it connects after that time.") } } .navigationTitle("Schedule send") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("Schedule") { onSchedule(date) dismiss() } } } } .presentationDetents([.large]) } } private struct ComposerAttachmentChipView: View { let attachment: ComposerAttachmentUI let onRetry: () -> Void let onRemove: () -> Void private var failed: Bool { attachment.status == .failed } var body: some View { HStack(spacing: 8) { thumb VStack(alignment: .leading, spacing: 0) { Text(attachment.filename) .font(.caption.weight(.medium)) .lineLimit(1) Text(statusLine) .font(.caption2) .foregroundStyle(failed ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary)) .lineLimit(1) } .frame(maxWidth: 132, alignment: .leading) Button { onRemove() } label: { Image(systemName: "xmark") .font(.caption2.weight(.semibold)) .foregroundStyle(.secondary) .padding(6) } .buttonStyle(.plain) .accessibilityLabel("Remove \(attachment.filename)") } .padding(6) .background( failed ? AnyShapeStyle(.red.opacity(0.12)) : AnyShapeStyle(.fill.tertiary), in: RoundedRectangle(cornerRadius: 10, style: .continuous) ) .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .onTapGesture { if failed { onRetry() } } } private var statusLine: String { switch attachment.status { case .uploading: return String(localized: "Uploading…") case .failed: return String(localized: "Failed — tap to retry") case .ready: return formatChipSize(attachment.sizeBytes) } } private var thumb: some View { ZStack { // Preparer-made ≤ 512 px JPEGs — cheap enough to decode inline. if let previewBytes = attachment.previewBytes, let image = UIImage(data: previewBytes) { Image(uiImage: image) .resizable() .scaledToFill() .frame(width: 36, height: 36) .clipped() .opacity(attachment.status == .uploading ? 0.4 : 1) } else { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(.fill.secondary) .frame(width: 36, height: 36) Image(systemName: attachment.mimeType.hasPrefix("image/") ? "photo" : "doc") .font(.footnote) .foregroundStyle(.secondary) } if attachment.status == .uploading { ProgressView() .controlSize(.small) } } .frame(width: 36, height: 36) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) } } /// `12.3 MB` / `456 KB` / `789 B` chip size label (Android `formatChipSize`). func formatChipSize(_ bytes: Int) -> String { if bytes >= 1024 * 1024 { return String(format: "%.1f MB", Double(bytes) / (1024.0 * 1024.0)) } if bytes >= 1024 { return String(format: "%.0f KB", Double(bytes) / 1024.0) } return "\(bytes) B" } // MARK: - Recording chip /// Elapsed-time recording chip with a cancel affordance (discards the take). private struct RecordingChipView: View { let startedAtMs: Int let onCancel: () -> Void var body: some View { TimelineView(.periodic(from: .now, by: 0.25)) { context in HStack(spacing: 8) { Text("● Recording… \(formatElapsed(elapsedSeconds(at: context.date)))") .font(.caption.weight(.medium)) Spacer(minLength: 8) Button("Cancel") { onCancel() } .font(.caption.weight(.semibold)) } .padding(.horizontal, 12) .padding(.vertical, 6) .background(.red.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .foregroundStyle(.red) } } private func elapsedSeconds(at date: Date) -> Int { max(0, (Int(date.timeIntervalSince1970 * 1000) - startedAtMs) / 1000) } } /// `m:ss` elapsed-time label for the recording chip. func formatElapsed(_ totalSeconds: Int) -> String { String(format: "%d:%02d", totalSeconds / 60, totalSeconds % 60) } // MARK: - Video import /// Photo-picker videos land as a temp file (`FileRepresentation`) so the /// preparer's capped read — not a full in-memory `Data` — bounds them. private struct PickedMovie: Transferable { let url: URL static var transferRepresentation: some TransferRepresentation { FileRepresentation(contentType: .movie) { movie in SentTransferredFile(movie.url) } importing: { received in let ext = received.file.pathExtension.isEmpty ? "mov" : received.file.pathExtension let destination = FileManager.default.temporaryDirectory .appendingPathComponent("video-\(UUID().uuidString.prefix(8)).\(ext)") try FileManager.default.copyItem(at: received.file, to: destination) return PickedMovie(url: destination) } } }