Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 154 additions & 24 deletions CodegiOS/Features/SessionDetail/ComposeBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import SwiftUI
import PhotosUI

/// The pinned bottom compose bar. A leading "+" sits to the left of a growing
/// multiline field; a send button (which becomes Stop while a turn streams) sits
/// on the right. Attached-image thumbnails appear above the field. The "agent is
/// multiline field; a send button sits on the right, and becomes Stop while a
/// turn streams. Sessions whose agent can take a message mid-turn get a second
/// send beside Stop, with a line above the field saying which of the two things
/// it does — insert into the running turn, or leave a note for the agent's next
/// check. Attached-image thumbnails appear above the field. The "agent is
/// working" state is shown as a node at the tail of the transcript timeline (a
/// thinking tick, a running tool, a streaming reply) — not as a status line here.
///
Expand All @@ -21,6 +24,14 @@ struct ComposeBar: View {
let onNotice: (String) -> Void
let onSend: () -> Void
let onStop: () -> Void
/// How a message typed while the reply streams would reach the agent, or nil
/// when this session has no channel for one — the bar then keeps its historic
/// Stop-only form. Never inferred here: the view model reads it off the
/// connection's attach snapshot.
let midTurnChannel: SessionDetailViewModel.MidTurnChannel?
/// A mid-turn message is in flight; the button greys out until it settles.
let isSendingMidTurn: Bool
let onSendMidTurn: () -> Void
let onDismissNotice: () -> Void
/// Backs the "+" menu's text-insert pickers (quick messages / experts / commands).
let insertModel: ComposeInsertModel
Expand All @@ -38,13 +49,36 @@ struct ComposeBar: View {
private var hasText: Bool {
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var hasDraft: Bool {
hasText || !attachments.isEmpty
}
private var canSend: Bool {
(hasText || !attachments.isEmpty) && !isInFlight
hasDraft && !isInFlight
}
/// The mid-turn send greys out with nothing to send, exactly like Send does,
/// rather than appearing and disappearing as the user types.
private var canSendMidTurn: Bool {
hasDraft && !isSendingMidTurn
}
private var remainingSlots: Int {
max(0, AttachmentPrep.maxCount - attachments.count)
}

/// What sending right now would actually do, said before the tap. The two
/// channels promise different things and the composer must not blur them; a
/// draft carrying images promises neither, because it waits for the turn.
///
/// Shown from the moment the user engages the field, which is also the only
/// way to reach a sendable draft — so the button is never actionable without
/// its explanation on screen, and an idle streaming turn keeps its layout.
private var midTurnHint: LocalizedStringKey? {
guard isInFlight, focused || hasDraft, let channel = midTurnChannel else { return nil }
guard attachments.isEmpty else {
return "Images can't join a running turn. Sending waits for the reply to finish."
}
return channel.composerHint
}

var body: some View {
VStack(spacing: 8) {
if let notice {
Expand All @@ -56,6 +90,10 @@ struct ComposeBar: View {
.transition(.opacity.combined(with: .move(edge: .bottom)))
}

if let hint = midTurnHint {
MidTurnHint(text: hint)
}

GlassEffectContainer(spacing: 8) {
HStack(alignment: .bottom, spacing: 8) {
addButton
Expand Down Expand Up @@ -119,6 +157,9 @@ struct ComposeBar: View {
.animation(Theme.Motion.expand, value: isInFlight)
.animation(Theme.Motion.expand, value: notice)
.animation(Theme.Motion.expand, value: attachments)
// The mid-turn affordance arrives with the attach snapshot, which can land
// after the turn has started — fade it in rather than popping it in.
.animation(Theme.Motion.expand, value: midTurnChannel)
// Width + keyboard-gap shift on focus change, kept just slightly slower
// than the keyboard's own animation so the bar settles into place.
.animation(.snappy(duration: 0.26), value: focused)
Expand Down Expand Up @@ -170,38 +211,85 @@ struct ComposeBar: View {
@ViewBuilder
private var actionButton: some View {
if isInFlight {
Button(action: onStop) {
Image(systemName: "stop.fill")
.font(.system(size: 16, weight: .bold))
.frame(width: 26, height: 26)
// Stop keeps its place and its role; the mid-turn send joins it only
// on sessions that actually have somewhere to put the message, so a
// channel-less session stays pixel-identical to before.
HStack(spacing: 8) {
stopButton
if let channel = midTurnChannel {
midTurnSendButton(channel)
}
}
.buttonStyle(.glassProminent)
.tint(Theme.danger)
.clipShape(Circle())
.transition(.scale.combined(with: .opacity))
.accessibilityLabel("Stop")
} else {
Button(action: send) {
Image(systemName: "arrow.up")
.font(.system(size: 16, weight: .bold))
.frame(width: 26, height: 26)
}
.buttonStyle(.glassProminent)
.tint(Theme.accent)
.clipShape(Circle())
.disabled(!canSend)
.opacity(canSend ? 1 : 0.5)
.transition(.scale.combined(with: .opacity))
.accessibilityLabel("Send")
sendButton
}
}

private var stopButton: some View {
Button(action: onStop) {
Image(systemName: "stop.fill")
.font(.system(size: 16, weight: .bold))
.frame(width: 26, height: 26)
}
.buttonStyle(.glassProminent)
.tint(Theme.danger)
.clipShape(Circle())
.transition(.scale.combined(with: .opacity))
.accessibilityLabel("Stop")
}

private var sendButton: some View {
Button(action: send) {
Image(systemName: "arrow.up")
.font(.system(size: 16, weight: .bold))
.frame(width: 26, height: 26)
}
.buttonStyle(.glassProminent)
.tint(Theme.accent)
.clipShape(Circle())
.disabled(!canSend)
.opacity(canSend ? 1 : 0.5)
.transition(.scale.combined(with: .opacity))
.accessibilityLabel("Send")
}

/// Send into the running turn. Same shape as Send, because it is a send —
/// the hint above the field carries what makes it different, and the
/// accessibility label spells it out per channel.
private func midTurnSendButton(_ channel: SessionDetailViewModel.MidTurnChannel) -> some View {
Button(action: sendMidTurn) {
Image(systemName: "arrow.up")
.font(.system(size: 16, weight: .bold))
.frame(width: 26, height: 26)
}
.buttonStyle(.glassProminent)
.tint(Theme.accent)
.clipShape(Circle())
.disabled(!canSendMidTurn)
.opacity(canSendMidTurn ? 1 : 0.5)
.transition(.scale.combined(with: .opacity))
.accessibilityLabel(Text(midTurnLabel(channel)))
}

/// The action's name for the draft as it stands: one with images is queued
/// for the end of the turn rather than delivered into it, so the label must
/// not claim to insert anything.
private func midTurnLabel(_ channel: SessionDetailViewModel.MidTurnChannel) -> LocalizedStringKey {
attachments.isEmpty ? channel.sendLabel : "Send when the current reply finishes"
}

private func send() {
guard canSend else { return }
sendHaptic &+= 1
onSend()
}

private func sendMidTurn() {
guard canSendMidTurn else { return }
sendHaptic &+= 1
onSendMidTurn()
}

// MARK: - Attachment intake

private func handlePhotoItems(_ items: [PhotosPickerItem]) {
Expand Down Expand Up @@ -252,6 +340,48 @@ struct ComposeBar: View {
}
}

private extension SessionDetailViewModel.MidTurnChannel {
/// What a message sent right now would do, in the user's terms. The native
/// push lands in the reply the agent is writing; the pull tool leaves a note
/// it collects whenever it next looks. Promising the first on a pull session
/// would be a lie, so the copy is keyed on the channel, never shared.
var composerHint: LocalizedStringKey {
switch self {
case .native: return "Messages you send now go into the current turn."
case .pull: return "Messages you send now wait for the agent's next check."
}
}

/// The action's own name, for VoiceOver and the button's label.
var sendLabel: LocalizedStringKey {
switch self {
case .native: return "Send into the current turn"
case .pull: return "Send a note for the next check"
}
}
}

/// A quiet line above the field explaining what sending during a reply does,
/// shown from the moment the user engages the composer — so the extra button
/// beside Stop is never actionable without its explanation on screen.
private struct MidTurnHint: View {
let text: LocalizedStringKey

var body: some View {
HStack(spacing: 6) {
Image(systemName: "bolt.horizontal")
.font(.system(size: 11, weight: .semibold))
Text(text)
.font(.caption)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
.foregroundStyle(Theme.textTertiary)
.padding(.horizontal, 14)
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
}

/// A dismissible non-fatal notice (e.g. "a turn is already running").
private struct NoticeBanner: View {
let message: String
Expand Down
Loading