mac: the typeahead is a drop-up, not a band that moves the transcript (#1508)
Paul: "It's weird that the whole pane moves."
The Mac's typeahead lived inside the composer's VStack, which is the bottom
safeAreaInset's content — and that inset SHRINKS the feed's scroll view. The
strip is one row per candidate, re-ranked on every keystroke, so `/t` → `/th`
→ `/thr` moved the entire transcript a row at a time as the list narrowed.
Transparent was not enough: transparent still occupies layout.
The web has never done this — style.css `.typeahead` is absolutely positioned
against `.composer-float`, a zero-height anchor at the top of the composer
stack, with a comment naming this exact failure ("a drop-UP that OVERLAYS the
feed rather than a flex band that shoves the messages up as you type"). iOS
sidesteps it with a horizontal chip strip whose height never changes. Mac was
the only surface doing it, so this is parity, not a new idea.
Now an `.overlay(alignment: .topLeading)` on the band, plus the web's height
cap. Three things measured off-screen rather than reasoned about, each of
which was wrong on the first attempt:
- `.overlay(alignment: .top)` CENTRES its child and lets it take its ideal
width; the in-flow VStack had given it the full width, left-aligned. Needs
.topLeading and an explicit maxWidth.
- An `alignmentGuide` set INSIDE a conditional never reaches the overlay: the
overlay aligns the _ConditionalContent container and reads its default
guide, so the card was laid out with its top on the band's top and ran down
over the composer (measured: 360…458 over a band at 360…400, vs 262…360
with the guide outside the `if`).
- A ScrollView is greedy in its scroll axis, so `.frame(maxHeight: 216)` alone
gives a full-height card for two candidates (228pt for 2 rows AND for 10).
`.fixedSize(vertical:)` first, then the clamp: 2 rows → 69pt, 10 → 228pt.
The staged-attachment strip deliberately stays in flow, on both platforms —
the web's CSS records why: floating it covered tappable things at the feed's
bottom edge ("Skip for now" became dead text under a thumbnail).
Mouse-clicking a row was verified by hand (the card draws outside its inset
region, so hit-testing there was the one thing frames couldn't answer);
keyboard ↑↓/Tab/Return/Esc is untouched, and ↑↓ now keeps the selection in
view once the list scrolls, like the web's scrollIntoView({block:"nearest"}).
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2 files changed,
+83
−18
ios/LChatCore/Sources/LChatCore/TypeaheadStrip.swift+38 −13ios/LChatMac/NativeFeedView.swift+45 −5
diff --git a/ios/LChatCore/Sources/LChatCore/TypeaheadStrip.swift b/ios/LChatCore/Sources/LChatCore/TypeaheadStrip.swift| index 18c3ef4..0eb1dca 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/TypeaheadStrip.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/TypeaheadStrip.swift |
| @@ -27,21 +27,46 @@ public struct TypeaheadStrip: View { |
| self.onPick = onPick |
| } |
| + /// The tallest the card gets before it scrolls — the web's |
| + /// `.typeahead { max-height: 216px }` (style.css). A bare ":" or "@" ranks |
| + /// dozens of candidates, and an uncapped card covers the conversation it is |
| + /// floating over. |
| + private static let maxHeight: CGFloat = 216 |
| + |
| public var body: some View { |
| - VStack(alignment: .leading, spacing: 1) { |
| - ForEach(Array(items.enumerated()), id: \.element.id) { i, item in |
| - Button { |
| - onPick(item) |
| - } label: { |
| - row(item) |
| - .padding(.horizontal, 10) |
| - .padding(.vertical, 7) |
| - .frame(maxWidth: .infinity, alignment: .leading) |
| - .background(RoundedRectangle(cornerRadius: 8, style: .continuous) |
| - .fill(i == selected ? theme.accent.resolved(scheme).opacity(0.18) : .clear)) |
| - .contentShape(Rectangle()) |
| + // Capped BUT hugging: a ScrollView is greedy in its scroll axis, so |
| + // `.frame(maxHeight:)` alone gives a full 216pt card for two |
| + // candidates (measured off-screen: 228pt for 2 rows and for 10). |
| + // `.fixedSize(vertical:)` sizes it to its content first, and the frame |
| + // then clamps — 2 rows → 69pt, 10 rows → 228pt. |
| + ScrollViewReader { proxy in |
| + ScrollView { |
| + VStack(alignment: .leading, spacing: 1) { |
| + ForEach(Array(items.enumerated()), id: \.element.id) { i, item in |
| + Button { |
| + onPick(item) |
| + } label: { |
| + row(item) |
| + .padding(.horizontal, 10) |
| + .padding(.vertical, 7) |
| + .frame(maxWidth: .infinity, alignment: .leading) |
| + .background(RoundedRectangle(cornerRadius: 8, style: .continuous) |
| + .fill(i == selected ? theme.accent.resolved(scheme).opacity(0.18) : .clear)) |
| + .contentShape(Rectangle()) |
| + } |
| + .buttonStyle(.plain) |
| + .id(item.id) |
| + } |
| } |
| - .buttonStyle(.plain) |
| + } |
| + .frame(maxHeight: Self.maxHeight) |
| + .fixedSize(horizontal: false, vertical: true) |
| + // Keep the ↑↓ selection in view once the list scrolls — the web's |
| + // scrollIntoView({block:"nearest"}) on every index move. A nil |
| + // anchor is SwiftUI's "scroll the least that works". |
| + .onChange(of: selected) { _, i in |
| + guard items.indices.contains(i) else { return } |
| + proxy.scrollTo(items[i].id, anchor: nil) |
| } |
| } |
| .padding(6) |
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift| index 6c6fc0d..e456eba 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -3212,11 +3212,6 @@ struct MessageComposerBar<Panel: View>: View { |
| 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 ?? "")", |
| @@ -3257,6 +3252,39 @@ struct MessageComposerBar<Panel: View>: View { |
| // banners above stay transparent floaters. |
| .background(FrostedBand()) |
| } |
| + // The typeahead is a drop-UP that OVERLAYS the feed, never a row in |
| + // this stack. This whole VStack is the bottom safeAreaInset's content, |
| + // which SHRINKS the feed's scroll view — and the strip is one row per |
| + // candidate, re-ranked on every keystroke, so `/t` → `/th` → `/thr` |
| + // moved the entire transcript a row at a time as the list narrowed. |
| + // Transparent was not enough: transparent still occupies layout. |
| + // Web parity — style.css `.typeahead` is absolutely positioned against |
| + // `.composer-float`, a zero-height anchor at the top of the composer |
| + // stack, for exactly this reason. (The staged-attachment strip stays in |
| + // flow on purpose, on both platforms: floating THAT covered tappable |
| + // things at the feed's bottom edge.) |
| + // |
| + // The alignment-guide flip is the whole trick: aligned `.top`, the |
| + // overlay reports its own BOTTOM as its top edge, so it sits directly |
| + // above the band and rises with it — a reply banner arming or a photo |
| + // staging pushes the strip up, with no height measured anywhere. |
| + // .topLEADING, and the width claimed explicitly: measured off-screen, a |
| + // plain `.top` overlay centres its child on the band and lets it take |
| + // its ideal width, where the in-flow VStack (alignment: .leading) gave |
| + // it the full width, left-aligned. Without both of these the card |
| + // floats centred and narrow instead of tracking the composer's edges. |
| + .overlay(alignment: .topLeading) { |
| + // The guide flip MUST sit outside the conditional. Measured |
| + // off-screen: a guide set on a view INSIDE an `if` never reaches |
| + // the overlay — the overlay aligns the _ConditionalContent |
| + // container and reads its default `.top`, so the card was laid out |
| + // with its top on the band's top and ran down over the composer |
| + // and off the bottom of the window. Outside the `if` it shifts by |
| + // the card's full height (measured: 360…458 vs 262…360 for a 98pt |
| + // card over a band at 360…400). |
| + typeaheadDropUp |
| + .alignmentGuide(.top) { $0[.bottom] } |
| + } |
| .onChange(of: draft) { _, text in |
| refreshTypeahead() |
| onDraftChanged(text) |
| @@ -3273,6 +3301,18 @@ struct MessageComposerBar<Panel: View>: View { |
| // 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. |
| + // The typeahead card, or nothing. Split out so the drop-up's alignment |
| + // guide can be applied OUTSIDE this conditional (see the overlay above). |
| + @ViewBuilder private var typeaheadDropUp: some View { |
| + if !taItems.isEmpty, let token = taToken { |
| + TypeaheadStrip(items: taItems, selected: taIndex, |
| + mentionAvatar: mentionAvatar) { pick($0, token: token) } |
| + .frame(maxWidth: .infinity, alignment: .leading) |
| + .padding(.horizontal, 10) |
| + .padding(.bottom, 4) |
| + } |
| + } |
| + |
| private func refreshTypeahead() { |
| let utf16 = min(max(composerCaret, 0), draft.utf16.count) |
| let idx = String.Index(utf16Offset: utf16, in: draft) |