diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index b05b117..1698841 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -291,6 +291,8 @@ final class RemoteStore: ObservableObject { } func integrate(_ id: SessionID, _ mode: IntegrationMode) { send(.integrate(id, mode)) } func interrupt(_ id: SessionID) { send(.interrupt(id)) } + /// Cancel one queued (not-yet-sent) follow-up by id — the phone's per-message ✕. + func cancelQueuedMessage(_ id: SessionID, _ messageID: UUID) { send(.cancelQueuedMessage(id, messageID)) } // Mid-session model / reasoning / automation — the same affordances the Mac header exposes. // `nil` model/effort resets the session to the host/app default. diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 0b95763..1fd2c07 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -170,17 +170,16 @@ struct SessionDetailView: View { let running = status == .running let canCompose = running || status == .awaitingInput || status.isTerminal VStack(spacing: 8) { - // A follow-up submitted mid-turn, held by the host to send when the current - // turn finishes. - if let queued = summary?.queuedMessage, !queued.isEmpty { - HStack(spacing: 6) { - Image(systemName: "clock").foregroundStyle(.secondary) - Text(queued).lineLimit(1).truncationMode(.tail) - .foregroundStyle(.secondary) - Spacer(minLength: 8) + // Follow-ups submitted mid-turn, held by the host to send when the current turn + // finishes. Each is shown as its own pill (with its attachments) and — at control + // scope — can be cancelled individually, matching the Mac composer. (They still + // send as one combined turn.) + if let queued = summary?.queuedMessages, !queued.isEmpty { + VStack(spacing: 6) { + ForEach(queued) { message in + queuedMessageRow(message) + } } - .font(.footnote) - .frame(maxWidth: .infinity, alignment: .leading) } if running { HStack(spacing: 8) { @@ -211,6 +210,56 @@ struct SessionDetailView: View { .padding() } } + + /// A queued follow-up shown above the composer: its text (or attachment names) with the + /// attachments as chips, and — at control scope — a ✕ that cancels just this message. + @ViewBuilder + private func queuedMessageRow(_ message: QueuedMessage) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(systemName: "clock").foregroundStyle(.secondary) + Text(queuedRowText(message)).lineLimit(1).truncationMode(.tail) + .foregroundStyle(.secondary) + Spacer(minLength: 8) + if store.canControl { + Button { + // Refill the composer for editing (text only — the phone can't stage the + // host's files) unless the user has already started typing; then drop it. + if draft.trimmingCharacters(in: .whitespaces).isEmpty { draft = message.text } + store.cancelQueuedMessage(sessionID, message.id) + } label: { + Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .disabled(!store.connectivity.isLive) + } + } + .font(.footnote) + if !message.attachments.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(message.attachments) { attachment in + Label(attachment.filename, systemImage: "doc") + .font(.caption2) + .lineLimit(1) + .padding(.horizontal, 7).padding(.vertical, 3) + .background(.quaternary, in: .capsule) + } + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8).padding(.vertical, 6) + .background(.quaternary.opacity(0.4), in: .rect(cornerRadius: 10)) + } + + /// The one-line label for a queued pill: the message text, or — for an attachment-only + /// message — the attachment names (the chips below repeat them), so it's never blank. + private func queuedRowText(_ message: QueuedMessage) -> String { + if !message.text.isEmpty { return message.text } + return message.attachments.map(\.filename).joined(separator: ", ") + } } struct TranscriptList: View {