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>
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) |
| + } |
| +} |
| + |