eleven / ocho Public

mac: extract MessageComposerBar — composer as an instantiable view (#1465)

Near-pure refactor: the chat composer — typeahead strip, reply/edit
banners, staged-attachment chips, the NSTextView field row, send button —
moves out of NativeFeedView into one instantiable MessageComposerBar view
(same file), so the docked reply-thread pane (thread-pane slice 5) can
mount a SECOND composer with its own draft beside the main bar.

The bar owns only composer-internal state (caret, measured field height,
the typeahead); everything code outside the composer reaches — the draft
(startEdit writes it), focus (pane switches claim/release it), reply/edit
staging (thread open/close, ⌘R), the staged uploads (the window drop zone
feeds them), the command host/panel and the whole send path — stays with
NativeFeedView and arrives as bindings/values/closures, so a second
instance is just a second container state set. fixedReply + allowCommands
are the spec's two presentation knobs, both at today's values (nil /
true) at the sole call site; Staged hoists to file scope so both types
share it.

One deliberate behaviour delta rides along: host command rows now adopt
via .onChange(of: hostRows) instead of the sink's one-shot refresh, so on
a re-mounted thread the first "/" shows the host's commands immediately
instead of one keystroke late — the cached-rows adoption used to skip the
re-rank. Equal-rows re-reports no longer reset a keyboard-arrowed
typeahead selection either. Both strictly better; named here so a
bisector isn't surprised.


Claude-Session: https://claude.ai/code/session_01EXy6kzmYE9rChy3KgciJvZ
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev 6ca847dcd3de1a3f4cf46f5b96a40fe2724ee060 parent 485958b
1 file changed, +656 −509
  • ios/LChatMac/NativeFeedView.swift +656 −509
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index c1a67c3..add7485 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -230,26 +230,13 @@ struct NativeFeedView: View {
@State private var integrityReport: String? // /integrity read-out (tamper evidence)
// Plain @State: MacComposerField drives first responder from it directly.
@State private var composerFocused = false
- // The caret as a UTF-16 offset into `draft` — real caret tracking for the
- // typeahead (the old TextField could only assume caret-at-end).
- @State private var composerCaret = 0
- // The field's measured height (1–9 lines), pushed by the field on every
- // content/width/font change — SwiftUI never re-asks a representable.
- @State private var composerHeight: CGFloat = 22
// Programmatic edits (typeahead splice, panel inserts) act on the view via
// the controller, so the caret survives them.
@State private var composerController = MacComposerController()
@AppStorage("formatAsYouType") private var formatAsYouType = true
- // Attachments staged on the draft: picked → uploading (spinner) → ready.
- // Several can ride one message, like the web's pending list.
- private struct Staged: Identifiable {
- let id = UUID()
- let name: String
- let isImage: Bool
- var preview: PlatformImage? // image picks show a real thumbnail (web-style)
- var progress = 0.0 // request-body bytes sent, 0…1
- var attachment: Attachment? // nil while the encrypt+upload is in flight
- }
+ // Attachments staged on the draft (Staged is file-scope, shared with
+ // MessageComposerBar, which renders the chips; the upload pipeline that
+ // fills them stays here — the window drop zone feeds it).
@State private var staged: [Staged] = []
// Send pressed mid-upload (the web's PR #269): the button swaps its arrow
// for a spinner and the message goes out the moment the uploads finish.
@@ -1263,14 +1250,6 @@ struct NativeFeedView: View {
return f.minY >= 0 && f.maxY <= frames.viewport.height
}
- // The native composer — the first native SEND path: the message is
- // encrypted in Swift (ChatFeedModel.send) and framed straight onto the
- // native WebSocket. Return sends (desktop shouldSend), ⌥Return newlines.
- // The active typeahead: the token under the caret + its ranked candidates.
- @State private var taToken: ComposerToken?
- @State private var taItems: [TypeaheadItem] = []
- @State private var taIndex = 0
-
// The ended-DM banner that takes the composer's place (#578): inline in
// the composer's layout slot — never floating over the feed. A refused
// reopen swaps the line for its reason (their DM door / a blip), the
@@ -1309,477 +1288,211 @@ struct NativeFeedView: View {
.background(FrostedBand())
}
+ // The native composer — the first native SEND path: the message is
+ // encrypted in Swift (ChatFeedModel.send) and framed straight onto the
+ // native WebSocket. Return sends (desktop shouldSend), ⌥Return newlines.
+ // The bar itself is MessageComposerBar (file scope, below) — one
+ // instantiable view, so the docked thread pane can mount a second one
+ // with its own draft. This is the MAIN mount: user-armed reply/edit
+ // banners (fixedReply nil), commands on, the command-host panel in the
+ // slot, and the send/staging/host machinery below supplied as closures.
private var composer: some View {
- VStack(alignment: .leading, spacing: 4) {
- if !taItems.isEmpty, let token = taToken {
- TypeaheadStrip(items: taItems, selected: taIndex,
- mentionAvatar: mentionAvatar) { pick($0, token: token) }
- .padding(.horizontal, 10)
- }
- // A command's results panel (the command-host webview — the page
- // draws its own header + ✕): in-flow above the banners, occupying
- // layout like the web's .cmd-panel, sized by the page's measure.
- // Mounted PERMANENTLY at height 0 once the host exists, for three
- // reasons: revealing by height (not insertion) animates as a real
- // ease-up instead of SwiftUI's insertion fade; the page always has
- // the pane's true width, so its height measure is right the first
- // time (no post-reveal jump); and an attached webview keeps its
- // rendering callbacks live.
- if let host = commandHost {
- // The command-host page suppresses its own border/shadow (the
- // webview is edge-to-edge there) — this frame is the panel's
- // chrome: a hairline and a subtle lift, matching the web's
- // .cmd-panel. Both fade with visibility so the parked 0-height
- // frame leaves no ghost line above the composer.
- CommandPanelWebView(host: host)
- .frame(height: panelVisible ? panelHeight : 0)
- .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
- .overlay(
- RoundedRectangle(cornerRadius: 10, style: .continuous)
- .strokeBorder(.primary.opacity(panelVisible ? 0.08 : 0), lineWidth: 1)
- )
- .shadow(color: .black.opacity(panelVisible ? 0.10 : 0), radius: 7, y: 2)
- .padding(.horizontal, 8)
- .allowsHitTesting(panelVisible)
- .accessibilityHidden(!panelVisible)
- }
- if let replyTo {
- composerBanner("Replying to \(replyTo.name ?? "")",
- detail: replyTo.snippet) { cancelReply() }
- }
- if editingID != nil {
- composerBanner("Editing message", detail: nil) { cancelEdit() }
- }
- VStack(spacing: 0) {
- // A default command's push-back ("when should I remind you?")
- // — inline above the composer, occupying layout, never floating.
- if let routeNotice {
- Text(routeNotice)
- .font(.caption)
- .foregroundStyle(.secondary)
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.horizontal, 12)
- .padding(.top, 6)
- }
- composerRow
- // Transient feedback only (downloads, upload errors) — no
- // permanent status line.
- if let busyText {
- Text(busyText)
- .font(.caption2)
- .foregroundStyle(.secondary)
- .frame(maxWidth: .infinity)
- .padding(4)
+ MessageComposerBar(
+ model: model,
+ theme: theme,
+ fixedReply: nil,
+ allowCommands: true,
+ placeholder: defaultCommand?.placeholder ?? "Message",
+ isActive: isActive,
+ settled: settled,
+ formatAsYouType: formatAsYouType,
+ roster: roster,
+ myUserId: myUserId,
+ hasBot: hasBot,
+ appCommands: appCommands,
+ hostRows: hostRows,
+ mentionAvatar: mentionAvatar,
+ draft: $draft,
+ composerFocused: $composerFocused,
+ controller: composerController,
+ staged: staged,
+ sendQueued: sendQueued,
+ replyTo: replyTo,
+ editingID: editingID,
+ routeNotice: routeNotice,
+ busyText: busyText,
+ onSend: sendDraft,
+ // The "+" and paste/drop staging are gone in a default-command
+ // thread (To-do) — text-only surface, matching the web and iOS.
+ onPickFiles: defaultCommand == nil ? pickFiles : nil,
+ onUnstage: { s in
+ staged.removeAll { $0.id == s.id }
+ fireQueuedSend() // cancels or releases a queued send
+ },
+ onCancelReply: cancelReply,
+ onCancelEdit: cancelEdit,
+ onDraftChanged: { text in
+ // The typing beats: alive while there's a draft, an immediate
+ // clear when it empties (edits don't announce themselves).
+ model.typingBeat(active: !text.isEmpty && editingID == nil)
+ // A "/" draft pre-warms the command host, so the page has booted
+ // by the time Enter runs the command (a cold page queues it).
+ if text.first == "/", commandHost == nil { ensureCommandHost() }
+ },
+ onEscapeFallback: {
+ // An open command panel is the most recently summoned layer —
+ // Escape peels it (after the bar's own typeahead) before
+ // reply/edit below (the page posts {panel: null} back, which
+ // collapses the frame, so the panel's onClose hook always runs).
+ if panelVisible {
+ commandHost?.dismissPanel()
+ return true
}
- }
- .frame(maxWidth: .infinity)
- // iMessage-style: a within-window frost (FrostedBand) — matches the
- // chat stream's surface exactly at rest (blurring the same window
- // background it sits on) while messages scrolling underneath ghost
- // through the glass. Only the input row wears it — the typeahead/
- // banners above stay transparent floaters.
- .background(FrostedBand())
- }
- .onChange(of: draft) { _, text in
- refreshTypeahead()
- // The typing beats: alive while there's a draft, an immediate
- // clear when it empties (edits don't announce themselves).
- model.typingBeat(active: !text.isEmpty && editingID == nil)
- // A "/" draft pre-warms the command host, so the page has booted
- // by the time Enter runs the command (a cold page queues it).
- if text.first == "/", commandHost == nil { ensureCommandHost() }
+ return false
+ },
+ startEdit: startEdit,
+ // Paste an image (or a copied file) straight onto the draft — it
+ // stages as an upload, like the web composer's paste; a file DRAG
+ // landing on the field stages too. Absent in a default-command
+ // thread (text-only surface), so a text paste stays a plain paste.
+ onStagePaste: defaultCommand == nil ? { stagePasteboard($0) } : nil,
+ onStageDrop: defaultCommand == nil ? { stageDrop($0) } : nil,
+ panel: commandPanel
+ )
+ }
+
+ // A command's results panel (the command-host webview — the page draws its
+ // own header + ✕): in-flow above the composer's banners (the bar's panel
+ // slot), occupying layout like the web's .cmd-panel, sized by the page's
+ // measure. Mounted PERMANENTLY at height 0 once the host exists, for three
+ // reasons: revealing by height (not insertion) animates as a real ease-up
+ // instead of SwiftUI's insertion fade; the page always has the pane's true
+ // width, so its height measure is right the first time (no post-reveal
+ // jump); and an attached webview keeps its rendering callbacks live.
+ @ViewBuilder private var commandPanel: some View {
+ if let host = commandHost {
+ // The command-host page suppresses its own border/shadow (the
+ // webview is edge-to-edge there) — this frame is the panel's
+ // chrome: a hairline and a subtle lift, matching the web's
+ // .cmd-panel. Both fade with visibility so the parked 0-height
+ // frame leaves no ghost line above the composer.
+ CommandPanelWebView(host: host)
+ .frame(height: panelVisible ? panelHeight : 0)
+ .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .strokeBorder(.primary.opacity(panelVisible ? 0.08 : 0), lineWidth: 1)
+ )
+ .shadow(color: .black.opacity(panelVisible ? 0.10 : 0), radius: 7, y: 2)
+ .padding(.horizontal, 8)
+ .allowsHitTesting(panelVisible)
+ .accessibilityHidden(!panelVisible)
}
- // Caret moves (clicks / arrows) can enter or leave a token with the
- // text unchanged — re-check, like the web composer's keyup handler.
- .onChange(of: composerCaret) { _, _ in refreshTypeahead() }
}
- // The token under the REAL caret (MacComposerField tracks it). The field
- // reports UTF-16, the tokenizer speaks Characters — convert at the
- // boundary, exactly as the iOS screens do.
- private func refreshTypeahead() {
- let utf16 = min(max(composerCaret, 0), draft.utf16.count)
- let idx = String.Index(utf16Offset: utf16, in: draft)
- let charCaret = draft.distance(from: draft.startIndex, to: idx)
- taToken = composerToken(draft, caret: charCaret)
- taItems = taToken.map {
- typeaheadItems($0, roster: roster, myUserId: myUserId, hasBot: hasBot,
- appCommands: appCommands, displayNames: model.senderNames,
- rows: hostRows)
- } ?? []
- taIndex = 0
+ private var hasContent: Bool {
+ !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !staged.isEmpty
}
- // Accept a candidate: splice it over the token (trigger → caret), exactly
- // like the web's acceptTypeahead.
- private func pick(_ item: TypeaheadItem, token: ComposerToken) {
- // Splice trigger → caret, PRESERVING the tail after the caret (the old
- // TextField assumed caret-at-end and dropped it). Characters → UTF-16
- // at the boundary; the field edits itself via the controller, so the
- // caret lands right after the insert.
- let chars = Array(draft)
- let utf16 = min(max(composerCaret, 0), draft.utf16.count)
- let idx = String.Index(utf16Offset: utf16, in: draft)
- let charCaret = draft.distance(from: draft.startIndex, to: idx)
- let start = min(token.start, chars.count)
- let end = min(max(charCaret, start), chars.count)
- let head = String(chars[0..<start])
- let span = String(chars[start..<end])
- composerController.insert(item.insert, replacingUTF16: NSRange(
- location: (head as NSString).length, length: (span as NSString).length))
- taToken = nil
- taItems = []
- composerFocused = true
+ private var uploadsInFlight: Bool {
+ staged.contains { $0.attachment == nil }
}
- // The staged attachments, Messages-style: INSIDE the input pill, above
- // the text, so the attachment and its caption visibly travel as one
- // message. Images show a real rounded thumbnail with the ✕ overlapping
- // its corner and the upload ring over the middle; files keep a pill.
- private var stagedChips: some View {
- HStack(alignment: .top, spacing: 8) {
- ForEach(staged) { s in
- if let preview = s.preview {
- imageChip(s, preview: preview)
- } else {
- pillChip(s)
- }
- }
+ // Show (or clear, with nil) the default-command push-back. Every showing
+ // bumps the generation, so an in-flight clear timer from an EARLIER
+ // showing — even of the identical string — can never wipe this one early.
+ private func setRouteNotice(_ text: String?) {
+ routeNoticeGen += 1
+ routeNotice = text
+ guard text != nil else { return }
+ let gen = routeNoticeGen
+ DispatchQueue.main.asyncAfter(deadline: .now() + 8) {
+ if routeNoticeGen == gen { routeNotice = nil }
}
- .padding(.horizontal, 12)
- .padding(.top, 10)
}
- private func imageChip(_ s: Staged, preview: PlatformImage) -> some View {
- ZStack {
- Image(platform: preview)
- .resizable()
- .scaledToFill()
- .frame(width: 96, height: 96)
- .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
- if s.attachment == nil {
- uploadRing(s.progress)
- .frame(width: 26, height: 26)
- }
- }
- // Video thumbs wear a small play badge so they read as clips.
- .overlay(alignment: .bottomLeading) {
- if !s.isImage {
- Image(systemName: "play.fill")
- .font(.system(size: 9))
- .foregroundStyle(.white)
- .padding(4)
- .background(Circle().fill(.black.opacity(0.55)))
- .padding(4)
- }
+ // The thread's command host, created on first use (and pre-warmed the
+ // moment a draft turns into a "/" — see onChange(of: draft)). Re-points
+ // `sink` at THIS mount every call, like the model-cache re-point pattern,
+ // so a revisited thread's cached host talks to the pane on screen.
+ @discardableResult
+ // The app-host projection queries (docs/native-apps.md phase 5): the
+ // model asks the thread's command host to project app events it has no
+ // built-in projection for. Lazy — the host webview only spins up when a
+ // thread actually contains such an event (or a command is typed).
+ private func installProjector() {
+ guard let instance else { return }
+ let thread = thread, myUserId = myUserId, ikB64 = instanceKeyB64
+ model.projector = { eventJSON, group in
+ let host = CommandHostCache.shared.host(for: instance, thread: thread,
+ myUserId: myUserId, instanceKeyB64: ikB64)
+ return await host.projectEvent(eventJSON, group: group)
}
- .overlay(alignment: .topTrailing) { unstageButton(s).offset(x: 7, y: -7) }
- .padding(.top, 7) // room for the overlapping ✕
- .shadow(color: .black.opacity(0.18), radius: 5, y: 2)
}
- private func pillChip(_ s: Staged) -> some View {
- HStack(spacing: 6) {
- if s.attachment == nil {
- uploadRing(s.progress).frame(width: 15, height: 15)
- } else {
- Text(fileIcon(s.name)).font(.system(size: 15))
+ private func ensureCommandHost() -> CommandHost? {
+ guard let instance else { return nil }
+ let host = commandHost ?? CommandHostCache.shared.host(
+ for: instance, thread: thread, myUserId: myUserId, instanceKeyB64: instanceKeyB64)
+ if commandHost == nil { commandHost = host }
+ host.sink = { event in
+ switch event {
+ case .panel(_, let height, let animate): // the web panel draws its own header
+ // The app's call (spec.animate): ease the reveal, or appear
+ // instantly. The web panel skips its in-page ease in command-
+ // host mode, so this is the ONE animation either way.
+ panelAnimates = animate
+ withAnimation(animate ? .easeOut(duration: 0.22) : nil) {
+ panelHeight = min(height, 340)
+ panelVisible = true
+ }
+ case .panelClosed:
+ withAnimation(panelAnimates ? .easeIn(duration: 0.2) : nil) {
+ panelVisible = false
+ }
+ case .panelHeight(let h):
+ // Late re-measures (fonts, images) ease too — an abrupt snap
+ // here read as a "jump" after the reveal.
+ if panelVisible {
+ withAnimation(panelAnimates ? .easeInOut(duration: 0.18) : nil) {
+ panelHeight = min(h, 340)
+ }
+ }
+ case .composerInsert(let text):
+ // A panel acting on the composer (an /emoji tap) — inserted at
+ // the caret, like the web's insertAtCaret.
+ composerController.insert(text, replacingUTF16: nil)
+ composerFocused = true
+ case .notice(let from, let text):
+ // A command's private "only visible to you" reply — the same
+ // inline slot the default-command push-back uses.
+ setRouteNotice(from.isEmpty ? text : "\(from) — \(text)")
+ case .commands(let rows):
+ // The thread's live command catalogue landed — the forwarding
+ // decision follows it from here, and the composer bar re-ranks
+ // its typeahead on the rows change (onChange(of: hostRows)).
+ hostRows = rows
}
- Text(s.name).font(.system(size: 13)).lineLimit(1).truncationMode(.middle)
- unstageButton(s)
}
- .padding(.horizontal, 9)
- .padding(.vertical, 6)
- .background(Capsule().fill(.primary.opacity(0.06)))
- .frame(maxWidth: 200)
- }
-
- private func uploadRing(_ progress: Double) -> some View {
- ZStack {
- Circle().stroke(.white.opacity(0.45), lineWidth: 3)
- Circle()
- .trim(from: 0, to: max(0.03, progress))
- .stroke(.white, style: StrokeStyle(lineWidth: 3, lineCap: .round))
- .rotationEffect(.degrees(-90))
- .animation(.linear(duration: 0.1), value: progress)
+ // Cross-thread command routing (docs/native-apps.md phase 3): the page
+ // asks for one narrow thread key ({needKey}) or an app's personal
+ // thread ({needAppThread}); answer from what THIS member already
+ // holds — never the private key.
+ let origin = instance.origin
+ host.keyProvider = { tid in
+ guard let e = SharedThreadKeys.lookup(threadID: tid), e.origin == origin else { return nil }
+ return e.key.base64urlEncodedString()
}
- .shadow(color: .black.opacity(0.4), radius: 2)
- }
-
- private func unstageButton(_ s: Staged) -> some View {
- Button {
- staged.removeAll { $0.id == s.id }
- fireQueuedSend() // cancels or releases a queued send
- } label: {
- Image(systemName: "xmark.circle.fill")
- .font(.system(size: 15))
- .symbolRenderingMode(.palette)
- .foregroundStyle(.white, .gray)
- }
- .buttonStyle(.plain)
- }
-
- private var composerRow: some View {
- HStack(alignment: .bottom, spacing: 8) {
- // The web composer's "+": stage photos and allowlisted documents.
- // Gone in a default-command thread (To-do) — text-only surface,
- // matching the web and iOS composers.
- if defaultCommand == nil {
- Button(action: pickFiles) {
- Image(systemName: "plus.circle.fill")
- .font(.system(size: 28)) // same weight as the send button
- // Messages.app: a real glyph on the disc — white in
- // dark mode, dark grey in light — not the knocked-out
- // transparent plus the monochrome fill gave.
- .symbolRenderingMode(.palette)
- .foregroundStyle(scheme == .dark ? Color.white : Color(white: 0.3),
- .tertiary)
- }
- .buttonStyle(.plain)
- .padding(.bottom, 4)
- }
- // ONE pill around attachments and text, the Messages read: what's
- // staged and what's typed are visibly the same message.
- VStack(alignment: .leading, spacing: 0) {
- if !staged.isEmpty {
- stagedChips
- }
- // The NSTextView-backed field (MacComposerField): a real caret —
- // mid-text typeahead, Shift/⌥+Return newlines AT the caret (#819),
- // caret-preserving splices — and the base the rich stages of #890
- // build on. Plain text at this stage. Typeahead keys route through
- // the handlers: ↑↓ move, Tab/Return accept, Esc dismisses — Return
- // falls through to send when no menu is up; ↑ on an empty draft
- // edits the last message.
- MacComposerField(
- text: $draft,
- focused: $composerFocused,
- height: $composerHeight,
- caret: $composerCaret,
- placeholder: defaultCommand?.placeholder ?? "Message",
- fontSize: chatFontSize, // the feed's reading size, like the web composer
- // Queued behind an upload = the message is committed; editing
- // what's about to auto-send would race it.
- isDisabled: sendQueued,
- formatAsYouType: formatAsYouType,
- controller: composerController,
- onSend: sendDraft,
- onMoveUp: {
- if !taItems.isEmpty {
- taIndex = (taIndex + taItems.count - 1) % taItems.count
- return true
- }
- if draft.isEmpty, editingID == nil,
- let last = model.messages.last(where: { $0.mine && !$0.action }) {
- startEdit(last)
- return true
- }
- return false
- },
- onMoveDown: {
- guard !taItems.isEmpty else { return false }
- taIndex = (taIndex + 1) % taItems.count
- return true
- },
- onAccept: {
- guard !taItems.isEmpty, let token = taToken else { return false }
- pick(taItems[taIndex], token: token)
- return true
- },
- onEscape: {
- if !taItems.isEmpty {
- taToken = nil
- taItems = []
- return true
- }
- // An open command panel is the most recently summoned
- // layer — Escape peels it before reply/edit below (the
- // page posts {panel: null} back, which collapses the
- // frame, so the panel's onClose hook always runs).
- if panelVisible {
- commandHost?.dismissPanel()
- return true
- }
- return false
- },
- // Paste an image (or a copied file) straight onto the draft —
- // it stages as an upload, like the web composer's paste.
- // Absent in a default-command thread (text-only surface), so a
- // text paste there stays a plain text paste.
- onStagePaste: defaultCommand == nil ? { stagePasteboard($0) } : nil,
- // A file DRAG landing on the field stages too — the backstop
- // behind the window-wide drop zone, so NSTextView's default
- // (paste the file's PATH as text) is never the outcome.
- onStageDrop: defaultCommand == nil ? { stageDrop($0) } : nil
- )
- // The field's height is pushed by the field itself (measured
- // 1–9 lines) — SwiftUI never re-asks a representable when only
- // its content changes.
- .frame(height: composerHeight)
- // Claim the row's full width (the + and send buttons keep
- // their fixed sizes) — without this the HStack centres a
- // short pill around the representable's measured width.
- .frame(maxWidth: .infinity)
- .padding(.horizontal, 14)
- .padding(.vertical, 9)
- // Regaining the app drops the caret into the message box, the
- // same courtesy the webview shell extends (focusComposer) —
- // but ONLY from the pane actually on screen (every instance's
- // pane stays mounted at opacity 0, and their writes were
- // stealing focus from the visible composer), and never over
- // an open ⌘K palette (its own handler re-asserts its field).
- .onReceive(NotificationCenter.default.publisher(
- for: NSApplication.didBecomeActiveNotification)) { _ in
- guard isActive, !app.showPalette else { return }
- DispatchQueue.main.async {
- if !app.showPalette { composerFocused = true }
- }
- }
- // Focus on open — asserted again a beat later AND when the
- // feed reveals: a FocusState write in onAppear races the
- // freshly-inserted (still invisible) field and loses.
- .onAppear {
- guard isActive else { return }
- composerFocused = true
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
- if isActive { composerFocused = true }
- }
- }
- .onChange(of: settled) { _, isSettled in
- if isSettled, isActive { composerFocused = true }
- }
- }
- // The web composer: a bordered pill on the panel background,
- // not a filled grey field.
- .background(RoundedRectangle(cornerRadius: 19, style: .continuous)
- .fill(.background))
- .overlay(RoundedRectangle(cornerRadius: 19, style: .continuous)
- .strokeBorder(.quaternary, lineWidth: 1))
- Button(action: sendDraft) {
- if sendQueued {
- // Queued behind an in-flight upload — the web's .send-waiting.
- ProgressView()
- .controlSize(.small)
- .frame(width: 28, height: 28)
- } else {
- Image(systemName: "arrow.up.circle.fill")
- .font(.system(size: 28))
- .foregroundStyle(hasContent ? AnyShapeStyle(Color.accentColor)
- : AnyShapeStyle(.tertiary))
- }
- }
- .buttonStyle(.plain)
- .disabled(!hasContent)
- .padding(.bottom, 4)
- }
- // Messages.app gives the input pill real breathing room below — the
- // old 6pt read as cramped against the window edge.
- .padding(EdgeInsets(top: 8, leading: 10, bottom: 12, trailing: 10))
- }
-
- private var hasContent: Bool {
- !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !staged.isEmpty
- }
-
- private var uploadsInFlight: Bool {
- staged.contains { $0.attachment == nil }
- }
-
- // Show (or clear, with nil) the default-command push-back. Every showing
- // bumps the generation, so an in-flight clear timer from an EARLIER
- // showing — even of the identical string — can never wipe this one early.
- private func setRouteNotice(_ text: String?) {
- routeNoticeGen += 1
- routeNotice = text
- guard text != nil else { return }
- let gen = routeNoticeGen
- DispatchQueue.main.asyncAfter(deadline: .now() + 8) {
- if routeNoticeGen == gen { routeNotice = nil }
- }
- }
-
- // The thread's command host, created on first use (and pre-warmed the
- // moment a draft turns into a "/" — see onChange(of: draft)). Re-points
- // `sink` at THIS mount every call, like the model-cache re-point pattern,
- // so a revisited thread's cached host talks to the pane on screen.
- @discardableResult
- // The app-host projection queries (docs/native-apps.md phase 5): the
- // model asks the thread's command host to project app events it has no
- // built-in projection for. Lazy — the host webview only spins up when a
- // thread actually contains such an event (or a command is typed).
- private func installProjector() {
- guard let instance else { return }
- let thread = thread, myUserId = myUserId, ikB64 = instanceKeyB64
- model.projector = { eventJSON, group in
- let host = CommandHostCache.shared.host(for: instance, thread: thread,
- myUserId: myUserId, instanceKeyB64: ikB64)
- return await host.projectEvent(eventJSON, group: group)
- }
- }
-
- private func ensureCommandHost() -> CommandHost? {
- guard let instance else { return nil }
- let host = commandHost ?? CommandHostCache.shared.host(
- for: instance, thread: thread, myUserId: myUserId, instanceKeyB64: instanceKeyB64)
- if commandHost == nil { commandHost = host }
- host.sink = { event in
- switch event {
- case .panel(_, let height, let animate): // the web panel draws its own header
- // The app's call (spec.animate): ease the reveal, or appear
- // instantly. The web panel skips its in-page ease in command-
- // host mode, so this is the ONE animation either way.
- panelAnimates = animate
- withAnimation(animate ? .easeOut(duration: 0.22) : nil) {
- panelHeight = min(height, 340)
- panelVisible = true
- }
- case .panelClosed:
- withAnimation(panelAnimates ? .easeIn(duration: 0.2) : nil) {
- panelVisible = false
- }
- case .panelHeight(let h):
- // Late re-measures (fonts, images) ease too — an abrupt snap
- // here read as a "jump" after the reveal.
- if panelVisible {
- withAnimation(panelAnimates ? .easeInOut(duration: 0.18) : nil) {
- panelHeight = min(h, 340)
- }
- }
- case .composerInsert(let text):
- // A panel acting on the composer (an /emoji tap) — inserted at
- // the caret, like the web's insertAtCaret.
- composerController.insert(text, replacingUTF16: nil)
- composerFocused = true
- case .notice(let from, let text):
- // A command's private "only visible to you" reply — the same
- // inline slot the default-command push-back uses.
- setRouteNotice(from.isEmpty ? text : "\(from) — \(text)")
- case .commands(let rows):
- // The thread's live command catalogue landed — the typeahead
- // and the forwarding decision follow it from here.
- hostRows = rows
- refreshTypeahead()
- }
- }
- // Cross-thread command routing (docs/native-apps.md phase 3): the page
- // asks for one narrow thread key ({needKey}) or an app's personal
- // thread ({needAppThread}); answer from what THIS member already
- // holds — never the private key.
- let origin = instance.origin
- host.keyProvider = { tid in
- guard let e = SharedThreadKeys.lookup(threadID: tid), e.origin == origin else { return nil }
- return e.key.base64urlEncodedString()
- }
- host.appThreadProvider = { [weak store] app, title in
- guard let store, let id = await store.openOrCreateAppThread(app: app, title: title)
- else { return nil }
- if let key = store.threads.first(where: { $0.id == id })?.threadKeyB64, !key.isEmpty {
- return (id, key)
- }
- return nil
- }
- if !host.commandRows.isEmpty, hostRows.isEmpty { hostRows = host.commandRows }
- return host
+ host.appThreadProvider = { [weak store] app, title in
+ guard let store, let id = await store.openOrCreateAppThread(app: app, title: title)
+ else { return nil }
+ if let key = store.threads.first(where: { $0.id == id })?.threadKeyB64, !key.isEmpty {
+ return (id, key)
+ }
+ return nil
+ }
+ if !host.commandRows.isEmpty, hostRows.isEmpty { hostRows = host.commandRows }
+ return host
}
// The wire form of the draft: serialized markdown when the field is
@@ -2079,38 +1792,6 @@ struct NativeFeedView: View {
if threadRootID != nil { stageThreadReply() }
}
- private func composerBanner(_ title: String, detail: String?,
- onCancel: @escaping () -> Void) -> some View {
- HStack(spacing: 8) {
- VStack(alignment: .leading, spacing: 1) {
- Text(title).font(.system(size: 12, weight: .semibold))
- if let detail, !detail.isEmpty {
- Text(detail).font(.system(size: 12)).foregroundStyle(.secondary).lineLimit(1)
- }
- }
- Spacer()
- Button(action: onCancel) {
- Image(systemName: "xmark.circle.fill")
- .font(.system(size: 13))
- .foregroundStyle(.tertiary)
- }
- .buttonStyle(.plain)
- .keyboardShortcut(.cancelAction)
- }
- .padding(.horizontal, 12)
- .padding(.vertical, 8)
- // Opaque panel card: the banner floats over the feed since the inset
- // lost its .bar slab, and messages/typing showed straight through it.
- // A stroked edge, NOT a shadow — a shadow inside safeAreaInset made
- // the hosted scroll content vanish entirely on macOS.
- .background(RoundedRectangle(cornerRadius: 10, style: .continuous)
- .fill(theme.panel.resolved(scheme)))
- .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous)
- .strokeBorder(.primary.opacity(0.10)))
- .padding(.horizontal, 10)
- .padding(.top, 6)
- }
-
// A queued send resolves whenever the staged set changes: nothing left to
// send cancels it; the last upload finishing releases it.
private func fireQueuedSend() {
@@ -2402,3 +2083,469 @@ struct NativeFeedView: View {
}
}
+// Attachments staged on the draft: picked → uploading (spinner) → ready.
+// Several can ride one message, like the web's pending list. File scope:
+// NativeFeedView owns the upload pipeline that fills these; MessageComposerBar
+// renders them as chips.
+struct Staged: Identifiable {
+ let id = UUID()
+ let name: String
+ let isImage: Bool
+ var preview: PlatformImage? // image picks show a real thumbnail (web-style)
+ var progress = 0.0 // request-body bytes sent, 0…1
+ var attachment: Attachment? // nil while the encrypt+upload is in flight
+}
+
+// The chat composer as ONE instantiable view — extracted from NativeFeedView
+// so the docked reply-thread pane can mount a SECOND composer, with its own
+// draft and typeahead, beside the main bar. The bar owns only what is
+// genuinely composer-internal (caret, measured field height, the typeahead);
+// everything code outside the composer reaches — the draft (startEdit writes
+// it), focus (pane switches claim/release it), reply/edit staging (thread
+// open/close, ⌘R), the staged uploads (the window drop zone feeds them), the
+// command host — stays with the container and arrives as bindings/values/
+// closures, so a second instance is just a second container state set.
+//
+// The two presentation knobs (the thread-pane spec §6):
+// - fixedReply: nil = the MAIN bar — user-armed reply/edit banners. Non-nil =
+// a pane bar: the reply target derives fresh at send time (inside the
+// container's onSend), so no reply banner and no cancel-✕ render here.
+// - allowCommands: false = no slash-command typeahead — "/" text sends
+// verbatim (the container's onSend skips command routing and passes no
+// panel). @mention and :emoji: completion still work.
+struct MessageComposerBar<Panel: View>: View {
+ let model: ChatFeedModel
+ let theme: ChatTheme
+ let fixedReply: (() -> ReplyRef?)?
+ let allowCommands: Bool
+ let placeholder: String
+ // The container's pane-visibility + reveal gates the focus handlers key
+ // off — every opened instance's pane stays mounted hidden (RootView's
+ // ZStack), so only the on-screen bar may claim first responder.
+ let isActive: Bool
+ let settled: Bool
+ let formatAsYouType: Bool
+ // Typeahead inputs (the web shell.js typeaheadItems' arguments).
+ let roster: [RosterEntry]
+ let myUserId: String
+ let hasBot: Bool
+ let appCommands: [(name: String, desc: String)]
+ let hostRows: [CommandRow]
+ let mentionAvatar: (String) -> AvatarSource?
+ // Container-owned composer state (written from outside the bar — see the
+ // header comment).
+ @Binding var draft: String
+ @Binding var composerFocused: Bool
+ // Programmatic edits (typeahead splice, panel inserts) act on the view via
+ // the controller, so the caret survives them. Container-held: the command
+ // host's sink inserts through it.
+ let controller: MacComposerController
+ let staged: [Staged]
+ let sendQueued: Bool
+ let replyTo: ReplyRef?
+ let editingID: Int64?
+ let routeNotice: String?
+ let busyText: String?
+ // Actions the container supplies — the send path, the staging pipeline
+ // and the command host stay with it (they reach thread/instance state).
+ let onSend: () -> Void
+ let onPickFiles: (() -> Void)? // nil hides the "+" (default-command threads)
+ let onUnstage: (Staged) -> Void
+ let onCancelReply: () -> Void
+ let onCancelEdit: () -> Void
+ let onDraftChanged: (String) -> Void // typing beat + "/" host pre-warm
+ let onEscapeFallback: () -> Bool // the container's next Escape layer (command panel)
+ let startEdit: (ChatFeedModel.Message) -> Void // ↑ on an empty draft
+ let onStagePaste: ((NSPasteboard) -> Bool)?
+ let onStageDrop: ((NSPasteboard) -> Bool)?
+ // The container's command-panel slot, between the typeahead and the
+ // banners (the main bar mounts the command-host webview here; a
+ // commands-off bar passes an EmptyView).
+ let panel: Panel
+
+ // ---- bar-owned state ----------------------------------------------------
+ // The caret as a UTF-16 offset into `draft` — real caret tracking for the
+ // typeahead (the old TextField could only assume caret-at-end).
+ @State private var composerCaret = 0
+ // The field's measured height (1–9 lines), pushed by the field on every
+ // content/width/font change — SwiftUI never re-asks a representable.
+ @State private var composerHeight: CGFloat = 22
+ // The active typeahead: the token under the caret + its ranked candidates.
+ @State private var taToken: ComposerToken?
+ @State private var taItems: [TypeaheadItem] = []
+ @State private var taIndex = 0
+
+ @EnvironmentObject private var app: MacAppState
+ @Environment(\.chatFontSize) private var chatFontSize
+ @Environment(\.colorScheme) private var scheme
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ if !taItems.isEmpty, let token = taToken {
+ TypeaheadStrip(items: taItems, selected: taIndex,
+ mentionAvatar: mentionAvatar) { pick($0, token: token) }
+ .padding(.horizontal, 10)
+ }
+ panel
+ if fixedReply == nil, let replyTo {
+ composerBanner("Replying to \(replyTo.name ?? "")",
+ detail: replyTo.snippet) { onCancelReply() }
+ }
+ if editingID != nil {
+ composerBanner("Editing message", detail: nil) { onCancelEdit() }
+ }
+ VStack(spacing: 0) {
+ // A default command's push-back ("when should I remind you?")
+ // — inline above the composer, occupying layout, never floating.
+ if let routeNotice {
+ Text(routeNotice)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 12)
+ .padding(.top, 6)
+ }
+ composerRow
+ // Transient feedback only (downloads, upload errors) — no
+ // permanent status line.
+ if let busyText {
+ Text(busyText)
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity)
+ .padding(4)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ // iMessage-style: a within-window frost (FrostedBand) — matches the
+ // chat stream's surface exactly at rest (blurring the same window
+ // background it sits on) while messages scrolling underneath ghost
+ // through the glass. Only the input row wears it — the typeahead/
+ // banners above stay transparent floaters.
+ .background(FrostedBand())
+ }
+ .onChange(of: draft) { _, text in
+ refreshTypeahead()
+ onDraftChanged(text)
+ }
+ // Caret moves (clicks / arrows) can enter or leave a token with the
+ // text unchanged — re-check, like the web composer's keyup handler.
+ .onChange(of: composerCaret) { _, _ in refreshTypeahead() }
+ // The thread's live command rows landing re-ranks an open "/" menu —
+ // this stands in for the direct refreshTypeahead() the container's
+ // command-host sink used to make when the state all lived together.
+ .onChange(of: hostRows) { _, _ in refreshTypeahead() }
+ }
+
+ // The token under the REAL caret (MacComposerField tracks it). The field
+ // reports UTF-16, the tokenizer speaks Characters — convert at the
+ // boundary, exactly as the iOS screens do.
+ private func refreshTypeahead() {
+ let utf16 = min(max(composerCaret, 0), draft.utf16.count)
+ let idx = String.Index(utf16Offset: utf16, in: draft)
+ let charCaret = draft.distance(from: draft.startIndex, to: idx)
+ taToken = composerToken(draft, caret: charCaret)
+ // A commands-off bar never menus a "/" draft — the text sends
+ // verbatim, matching the web pane composer (cfg.commands:false).
+ if !allowCommands, taToken?.kind == .command { taToken = nil }
+ taItems = taToken.map {
+ typeaheadItems($0, roster: roster, myUserId: myUserId, hasBot: hasBot,
+ appCommands: appCommands, displayNames: model.senderNames,
+ rows: hostRows)
+ } ?? []
+ taIndex = 0
+ }
+
+ // Accept a candidate: splice it over the token (trigger → caret), exactly
+ // like the web's acceptTypeahead.
+ private func pick(_ item: TypeaheadItem, token: ComposerToken) {
+ // Splice trigger → caret, PRESERVING the tail after the caret (the old
+ // TextField assumed caret-at-end and dropped it). Characters → UTF-16
+ // at the boundary; the field edits itself via the controller, so the
+ // caret lands right after the insert.
+ let chars = Array(draft)
+ let utf16 = min(max(composerCaret, 0), draft.utf16.count)
+ let idx = String.Index(utf16Offset: utf16, in: draft)
+ let charCaret = draft.distance(from: draft.startIndex, to: idx)
+ let start = min(token.start, chars.count)
+ let end = min(max(charCaret, start), chars.count)
+ let head = String(chars[0..<start])
+ let span = String(chars[start..<end])
+ controller.insert(item.insert, replacingUTF16: NSRange(
+ location: (head as NSString).length, length: (span as NSString).length))
+ taToken = nil
+ taItems = []
+ composerFocused = true
+ }
+
+ // The staged attachments, Messages-style: INSIDE the input pill, above
+ // the text, so the attachment and its caption visibly travel as one
+ // message. Images show a real rounded thumbnail with the ✕ overlapping
+ // its corner and the upload ring over the middle; files keep a pill.
+ private var stagedChips: some View {
+ HStack(alignment: .top, spacing: 8) {
+ ForEach(staged) { s in
+ if let preview = s.preview {
+ imageChip(s, preview: preview)
+ } else {
+ pillChip(s)
+ }
+ }
+ }
+ .padding(.horizontal, 12)
+ .padding(.top, 10)
+ }
+
+ private func imageChip(_ s: Staged, preview: PlatformImage) -> some View {
+ ZStack {
+ Image(platform: preview)
+ .resizable()
+ .scaledToFill()
+ .frame(width: 96, height: 96)
+ .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
+ if s.attachment == nil {
+ uploadRing(s.progress)
+ .frame(width: 26, height: 26)
+ }
+ }
+ // Video thumbs wear a small play badge so they read as clips.
+ .overlay(alignment: .bottomLeading) {
+ if !s.isImage {
+ Image(systemName: "play.fill")
+ .font(.system(size: 9))
+ .foregroundStyle(.white)
+ .padding(4)
+ .background(Circle().fill(.black.opacity(0.55)))
+ .padding(4)
+ }
+ }
+ .overlay(alignment: .topTrailing) { unstageButton(s).offset(x: 7, y: -7) }
+ .padding(.top, 7) // room for the overlapping ✕
+ .shadow(color: .black.opacity(0.18), radius: 5, y: 2)
+ }
+
+ private func pillChip(_ s: Staged) -> some View {
+ HStack(spacing: 6) {
+ if s.attachment == nil {
+ uploadRing(s.progress).frame(width: 15, height: 15)
+ } else {
+ Text(fileIcon(s.name)).font(.system(size: 15))
+ }
+ Text(s.name).font(.system(size: 13)).lineLimit(1).truncationMode(.middle)
+ unstageButton(s)
+ }
+ .padding(.horizontal, 9)
+ .padding(.vertical, 6)
+ .background(Capsule().fill(.primary.opacity(0.06)))
+ .frame(maxWidth: 200)
+ }
+
+ private func uploadRing(_ progress: Double) -> some View {
+ ZStack {
+ Circle().stroke(.white.opacity(0.45), lineWidth: 3)
+ Circle()
+ .trim(from: 0, to: max(0.03, progress))
+ .stroke(.white, style: StrokeStyle(lineWidth: 3, lineCap: .round))
+ .rotationEffect(.degrees(-90))
+ .animation(.linear(duration: 0.1), value: progress)
+ }
+ .shadow(color: .black.opacity(0.4), radius: 2)
+ }
+
+ private func unstageButton(_ s: Staged) -> some View {
+ Button {
+ onUnstage(s)
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .font(.system(size: 15))
+ .symbolRenderingMode(.palette)
+ .foregroundStyle(.white, .gray)
+ }
+ .buttonStyle(.plain)
+ }
+
+ private var composerRow: some View {
+ HStack(alignment: .bottom, spacing: 8) {
+ // The web composer's "+": stage photos and allowlisted documents.
+ // The container passes nil in a default-command thread (To-do) —
+ // text-only surface, matching the web and iOS composers.
+ if let onPickFiles {
+ Button(action: onPickFiles) {
+ Image(systemName: "plus.circle.fill")
+ .font(.system(size: 28)) // same weight as the send button
+ // Messages.app: a real glyph on the disc — white in
+ // dark mode, dark grey in light — not the knocked-out
+ // transparent plus the monochrome fill gave.
+ .symbolRenderingMode(.palette)
+ .foregroundStyle(scheme == .dark ? Color.white : Color(white: 0.3),
+ .tertiary)
+ }
+ .buttonStyle(.plain)
+ .padding(.bottom, 4)
+ }
+ // ONE pill around attachments and text, the Messages read: what's
+ // staged and what's typed are visibly the same message.
+ VStack(alignment: .leading, spacing: 0) {
+ if !staged.isEmpty {
+ stagedChips
+ }
+ // The NSTextView-backed field (MacComposerField): a real caret —
+ // mid-text typeahead, Shift/⌥+Return newlines AT the caret (#819),
+ // caret-preserving splices — and the base the rich stages of #890
+ // build on. Plain text at this stage. Typeahead keys route through
+ // the handlers: ↑↓ move, Tab/Return accept, Esc dismisses — Return
+ // falls through to send when no menu is up; ↑ on an empty draft
+ // edits the last message.
+ MacComposerField(
+ text: $draft,
+ focused: $composerFocused,
+ height: $composerHeight,
+ caret: $composerCaret,
+ placeholder: placeholder,
+ fontSize: chatFontSize, // the feed's reading size, like the web composer
+ // Queued behind an upload = the message is committed; editing
+ // what's about to auto-send would race it.
+ isDisabled: sendQueued,
+ formatAsYouType: formatAsYouType,
+ controller: controller,
+ onSend: onSend,
+ onMoveUp: {
+ if !taItems.isEmpty {
+ taIndex = (taIndex + taItems.count - 1) % taItems.count
+ return true
+ }
+ if draft.isEmpty, editingID == nil,
+ let last = model.messages.last(where: { $0.mine && !$0.action }) {
+ startEdit(last)
+ return true
+ }
+ return false
+ },
+ onMoveDown: {
+ guard !taItems.isEmpty else { return false }
+ taIndex = (taIndex + 1) % taItems.count
+ return true
+ },
+ onAccept: {
+ guard !taItems.isEmpty, let token = taToken else { return false }
+ pick(taItems[taIndex], token: token)
+ return true
+ },
+ onEscape: {
+ if !taItems.isEmpty {
+ taToken = nil
+ taItems = []
+ return true
+ }
+ // Deeper Escape layers belong to the container (the
+ // main bar peels an open command panel here, before
+ // its reply/edit banners).
+ return onEscapeFallback()
+ },
+ onStagePaste: onStagePaste,
+ onStageDrop: onStageDrop
+ )
+ // The field's height is pushed by the field itself (measured
+ // 1–9 lines) — SwiftUI never re-asks a representable when only
+ // its content changes.
+ .frame(height: composerHeight)
+ // Claim the row's full width (the + and send buttons keep
+ // their fixed sizes) — without this the HStack centres a
+ // short pill around the representable's measured width.
+ .frame(maxWidth: .infinity)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 9)
+ // Regaining the app drops the caret into the message box, the
+ // same courtesy the webview shell extends (focusComposer) —
+ // but ONLY from the pane actually on screen (every instance's
+ // pane stays mounted at opacity 0, and their writes were
+ // stealing focus from the visible composer), and never over
+ // an open ⌘K palette (its own handler re-asserts its field).
+ .onReceive(NotificationCenter.default.publisher(
+ for: NSApplication.didBecomeActiveNotification)) { _ in
+ guard isActive, !app.showPalette else { return }
+ DispatchQueue.main.async {
+ if !app.showPalette { composerFocused = true }
+ }
+ }
+ // Focus on open — asserted again a beat later AND when the
+ // feed reveals: a FocusState write in onAppear races the
+ // freshly-inserted (still invisible) field and loses.
+ .onAppear {
+ guard isActive else { return }
+ composerFocused = true
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ if isActive { composerFocused = true }
+ }
+ }
+ .onChange(of: settled) { _, isSettled in
+ if isSettled, isActive { composerFocused = true }
+ }
+ }
+ // The web composer: a bordered pill on the panel background,
+ // not a filled grey field.
+ .background(RoundedRectangle(cornerRadius: 19, style: .continuous)
+ .fill(.background))
+ .overlay(RoundedRectangle(cornerRadius: 19, style: .continuous)
+ .strokeBorder(.quaternary, lineWidth: 1))
+ Button(action: onSend) {
+ if sendQueued {
+ // Queued behind an in-flight upload — the web's .send-waiting.
+ ProgressView()
+ .controlSize(.small)
+ .frame(width: 28, height: 28)
+ } else {
+ Image(systemName: "arrow.up.circle.fill")
+ .font(.system(size: 28))
+ .foregroundStyle(hasContent ? AnyShapeStyle(Color.accentColor)
+ : AnyShapeStyle(.tertiary))
+ }
+ }
+ .buttonStyle(.plain)
+ .disabled(!hasContent)
+ .padding(.bottom, 4)
+ }
+ // Messages.app gives the input pill real breathing room below — the
+ // old 6pt read as cramped against the window edge.
+ .padding(EdgeInsets(top: 8, leading: 10, bottom: 12, trailing: 10))
+ }
+
+ // The container's send path keeps its own twin (fireQueuedSend) — both
+ // read the same draft + staged, so they can't disagree.
+ private var hasContent: Bool {
+ !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !staged.isEmpty
+ }
+
+ private func composerBanner(_ title: String, detail: String?,
+ onCancel: @escaping () -> Void) -> some View {
+ HStack(spacing: 8) {
+ VStack(alignment: .leading, spacing: 1) {
+ Text(title).font(.system(size: 12, weight: .semibold))
+ if let detail, !detail.isEmpty {
+ Text(detail).font(.system(size: 12)).foregroundStyle(.secondary).lineLimit(1)
+ }
+ }
+ Spacer()
+ Button(action: onCancel) {
+ Image(systemName: "xmark.circle.fill")
+ .font(.system(size: 13))
+ .foregroundStyle(.tertiary)
+ }
+ .buttonStyle(.plain)
+ .keyboardShortcut(.cancelAction)
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ // Opaque panel card: the banner floats over the feed since the inset
+ // lost its .bar slab, and messages/typing showed straight through it.
+ // A stroked edge, NOT a shadow — a shadow inside safeAreaInset made
+ // the hosted scroll content vanish entirely on macOS.
+ .background(RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .fill(theme.panel.resolved(scheme)))
+ .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .strokeBorder(.primary.opacity(0.10)))
+ .padding(.horizontal, 10)
+ .padding(.top, 6)
+ }
+}
+