eleven / ocho Public

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>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev 340b330a8734431babbb80398f681138ac1650bc parent 7a0c37d
2 files changed, +83 −18
  • ios/LChatCore/Sources/LChatCore/TypeaheadStrip.swift +38 −13
  • ios/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)