eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import SwiftUI
2
3// πŸ€– The native surface for Eleven's onboarding conversations. The transcript
4// renders through the CANONICAL components β€” MessageBubble for bubbles (same
5// theme, reading size, selection, and look as every native feed), TypingBubble
6// for the beat, and the feeds' exact action-line recipe β€” so this view adds
7// only what's unique to a conversation: tappable chips and a composer pill
8// built to the Mac feed composer's recipe. Logic lives in
9// ElevenConversationModel/FlowEngine; this view only draws.
10public struct ElevenChatView: View {
11 @ObservedObject var model: ElevenConversationModel
12 var theme: ChatTheme = .classic
13 @State private var input = ""
14 @FocusState private var focused: Bool
15 @Environment(\.chatFontSize) private var fontSize
16 @Environment(\.colorScheme) private var scheme
17
18 public init(model: ElevenConversationModel, theme: ChatTheme = .classic) {
19 self.model = model
20 self.theme = theme
21 }
22
23 public var body: some View {
24 VStack(spacing: 0) {
25 ScrollViewReader { proxy in
26 ScrollView {
27 LazyVStack(alignment: .leading, spacing: 2) {
28 if model.rows.isEmpty && model.ready {
29 Text("This is the start of your chat with Eleven.")
30 .font(.footnote)
31 .foregroundStyle(.secondary)
32 .frame(maxWidth: .infinity, alignment: .center)
33 .padding(.top, 24)
34 }
35 ForEach(model.rows) { row in
36 rowView(row)
37 }
38 if model.typing {
39 typingRow.id("typing")
40 }
41 Color.clear.frame(height: 1).id("bottom")
42 }
43 .padding(.horizontal, 14)
44 .padding(.vertical, 12)
45 }
46 .onChange(of: model.rows.count) {
47 withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo("bottom") }
48 }
49 .onChange(of: model.typing) {
50 withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo("bottom") }
51 }
52 }
53 composer
54 }
55 }
56
57 @ViewBuilder private func rowView(_ row: ElevenConversationModel.Row) -> some View {
58 switch row.kind {
59 case .eleven(let text):
60 VStack(alignment: .leading, spacing: 6) {
61 MessageBubble(message: bubbleMessage(row, text: text, mine: false),
62 showSender: false, theme: theme)
63 if let chips = row.chips {
64 FlowChipWrap(chips: chips, accent: theme.accent.resolved(scheme))
65 .padding(.leading, 4)
66 }
67 }
68 .frame(maxWidth: .infinity, alignment: .leading)
69 .padding(.vertical, 2)
70 case .mine(let text):
71 MessageBubble(message: bubbleMessage(row, text: text, mine: true),
72 showSender: false, theme: theme)
73 .frame(maxWidth: .infinity, alignment: .trailing)
74 .padding(.vertical, 2)
75 case .action(let text):
76 // The feeds' exact /me action-line recipe (NativeFeedView.row).
77 Text(text)
78 .font(.system(size: 13))
79 .foregroundStyle(.secondary)
80 .multilineTextAlignment(.center)
81 .frame(maxWidth: .infinity)
82 .padding(EdgeInsets(top: 3, leading: 14, bottom: 3, trailing: 14))
83 }
84 }
85
86 private func bubbleMessage(_ row: ElevenConversationModel.Row, text: String, mine: Bool) -> ChatFeedModel.Message {
87 ChatFeedModel.Message(id: row.seq, name: mine ? model.instance.screenName : "Eleven",
88 text: text, mine: mine, at: row.at, edited: false)
89 }
90
91 private var typingRow: some View {
92 HStack(alignment: .bottom, spacing: 8) {
93 TypingBubble(theme: theme)
94 Spacer()
95 }
96 .transition(.opacity)
97 }
98
99 // The Mac feed composer's pill recipe (NativeFeedView.composerRow), shared
100 // here by both platforms: a plain field in a bordered pill on the panel
101 // background, with the same send disc. A scripted question's inset field
102 // label ("Your name:") sits INSIDE the pill before the text β€” the twin of
103 // the web composer's .composer-label.
104 private var composer: some View {
105 HStack(alignment: .bottom, spacing: 8) {
106 fieldPill
107 // A labelled ask's dismiss X (#1375) borrows the send disc's slot
108 // while it's free β€” typing swaps Send back in, the exact feel of
109 // the web's .composer-label-x.
110 if model.composerLabel != nil && input.isEmpty {
111 dismissAskButton
112 } else {
113 sendButton
114 }
115 }
116 .padding(.horizontal, 12)
117 .padding(.vertical, 8)
118 .background(askEscapeCatcher)
119 }
120
121 private var sendButton: some View {
122 Button(action: send) {
123 Image(systemName: "arrow.up.circle.fill")
124 .font(.system(size: 28))
125 .symbolRenderingMode(.palette)
126 .foregroundStyle(.white, input.trimmingCharacters(in: .whitespaces).isEmpty
127 ? AnyShapeStyle(.gray.opacity(0.5))
128 : AnyShapeStyle(theme.accent.resolved(scheme)))
129 }
130 .buttonStyle(.plain)
131 .disabled(input.trimmingCharacters(in: .whitespaces).isEmpty)
132 .accessibilityLabel("Send")
133 .padding(.bottom, 2)
134 }
135
136 // Back to a regular input without answering: quiet ink in the send disc's
137 // exact geometry (28pt β€” comfortably over the 24pt floor). Escape is the
138 // keyboard twin wherever a hardware keyboard exists β€” primarily the Mac.
139 private var dismissAskButton: some View {
140 Button(action: dismissAsk) {
141 Image(systemName: "xmark.circle.fill")
142 .font(.system(size: 28))
143 .symbolRenderingMode(.hierarchical)
144 .foregroundStyle(.secondary)
145 }
146 .buttonStyle(.plain)
147 .keyboardShortcut(.cancelAction)
148 .accessibilityLabel("Dismiss question")
149 .padding(.bottom, 2)
150 }
151
152 // Escape with text already typed still dismisses (the web's Escape branch
153 // isn't gated on an empty field): while Send occupies the slot, a
154 // zero-size stand-in carries the cancel action β€” never rendered, never a
155 // focus stop. Exactly one cancel-action button exists at a time.
156 @ViewBuilder private var askEscapeCatcher: some View {
157 if model.composerLabel != nil && !input.isEmpty {
158 Button(action: dismissAsk) { Color.clear }
159 .buttonStyle(.plain)
160 .keyboardShortcut(.cancelAction)
161 .frame(width: 0, height: 0)
162 .opacity(0)
163 .focusable(false)
164 .accessibilityHidden(true)
165 }
166 }
167
168 // Wave the question off: disarm the flow's waiter and drop the pill
169 // (cancelAnswer clears label + hint), so the next plain message is an
170 // ordinary message, never a swallowed answer β€” the parked step re-arms on
171 // the next cold open, exactly like the web after a reload. Clearing the
172 // label re-keys the field's .id (the AppKit recreate-on-prompt-change
173 // dance), so the onChange handlers below hand focus back; the explicit
174 // set covers the button tap itself stealing first responder on the Mac.
175 private func dismissAsk() {
176 model.cancelAnswer()
177 DispatchQueue.main.async { focused = true }
178 }
179
180 // The bordered pill: optional inset label + the field (split out β€” one
181 // expression here sends the type-checker into the weeds).
182 private var fieldPill: some View {
183 HStack(alignment: .firstTextBaseline, spacing: 8) {
184 if let label = model.composerLabel {
185 insetLabel(label)
186 }
187 composerField
188 }
189 .padding(.horizontal, 14)
190 .padding(.vertical, 9)
191 .background(RoundedRectangle(cornerRadius: 19, style: .continuous)
192 .fill(.background))
193 .overlay(RoundedRectangle(cornerRadius: 19, style: .continuous)
194 .strokeBorder(.quaternary, lineWidth: 1))
195 }
196
197 private func insetLabel(_ label: String) -> some View {
198 Text(label)
199 .font(.system(size: 12, weight: .semibold))
200 .lineLimit(1)
201 .padding(.horizontal, 9)
202 .padding(.vertical, 2)
203 .background(Capsule().fill(theme.accent.resolved(scheme)))
204 .foregroundStyle(.white)
205 .accessibilityHidden(true) // the field's label carries the words
206 }
207
208 private var fieldAccessibilityLabel: String {
209 guard let label = model.composerLabel else { return "Message" }
210 return label.trimmingCharacters(in: CharacterSet(charactersIn: ":: "))
211 }
212
213 private var composerField: some View {
214 TextField(model.composerHint ?? "Message Eleven", text: $input, axis: .vertical)
215 .textFieldStyle(.plain)
216 .font(.system(size: fontSize))
217 .lineLimit(1...9)
218 .focused($focused)
219 .onSubmit(send)
220 // A prompt change under an ACTIVE field editor wedges the
221 // AppKit-backed field (caret blinks, keys ignored). Recreate
222 // the field on each hint/label and hand focus straight back.
223 .id("composer-" + (model.composerHint ?? "") + "|" + (model.composerLabel ?? ""))
224 .onChange(of: model.composerHint) {
225 DispatchQueue.main.async { focused = true }
226 }
227 .onChange(of: model.composerLabel) {
228 DispatchQueue.main.async { focused = true }
229 }
230 .accessibilityLabel(fieldAccessibilityLabel)
231 }
232
233 private func send() {
234 let text = input
235 input = ""
236 Task { await model.send(text) }
237 }
238}
239
240// A wrapping row of chips; each greys to its spent label once tapped or stale.
241// Trailing chips (the quiet alternative path, e.g. the quick form) render in
242// their own right-aligned row BELOW the others β€” the simplest correct twin of
243// the web's end-of-row alignment; a step with no trailing chips renders through
244// the exact pre-existing single-layout path, pixel-identical.
245struct FlowChipWrap: View {
246 let chips: [FlowChip]
247 var accent: Color = .accentColor
248 @Environment(\.chatFontSize) private var fontSize
249 @State private var tapped: Set<UUID> = []
250 @State private var beat = 0
251
252 var body: some View {
253 let leading = chips.filter { !$0.trailing }
254 let trailing = chips.filter { $0.trailing }
255 Group {
256 if trailing.isEmpty {
257 FlowLayoutHStack {
258 ForEach(chips) { chipButton($0) }
259 }
260 } else {
261 VStack(alignment: .leading, spacing: 6) {
262 if !leading.isEmpty {
263 FlowLayoutHStack {
264 ForEach(leading) { chipButton($0) }
265 }
266 }
267 HStack(spacing: 6) {
268 Spacer(minLength: 0)
269 ForEach(trailing) { chipButton($0) }
270 }
271 }
272 }
273 }
274 // Staleness is a closure over engine state β€” re-read it on a slow beat.
275 .task {
276 while !Task.isCancelled {
277 try? await Task.sleep(nanoseconds: 500_000_000)
278 beat += 1
279 }
280 }
281 }
282
283 @ViewBuilder private func chipButton(_ chip: FlowChip) -> some View {
284 // `beat < 0` is always false β€” it exists to make body READ the
285 // beat so staleness re-evaluates, WITHOUT the identity-destroying
286 // .id(beat) (which re-created subtrees every 500ms and fought
287 // the composer's field editor for first responder).
288 let spent = tapped.contains(chip.id) || chip.isStale() || beat < 0
289 Button {
290 guard !spent else { return }
291 tapped.insert(chip.id)
292 chip.run()
293 } label: {
294 Text(spent ? (chip.spentLabel ?? chip.label) : chip.label)
295 .font(.system(size: max(12, fontSize - 2)))
296 .padding(.horizontal, 12)
297 .padding(.vertical, 6)
298 .background(Capsule().fill(spent ? AnyShapeStyle(.quaternary.opacity(0.4))
299 : AnyShapeStyle(accent.opacity(0.14))))
300 .foregroundStyle(spent ? Color.secondary : accent)
301 }
302 .buttonStyle(.plain)
303 .disabled(spent)
304 }
305}
306
307// A minimal wrapping layout (chips can outgrow one line).
308struct FlowLayoutHStack: Layout {
309 func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
310 let width = proposal.width ?? 400
311 var x: CGFloat = 0, y: CGFloat = 0, rowH: CGFloat = 0
312 for sub in subviews {
313 let size = sub.sizeThatFits(.unspecified)
314 if x + size.width > width, x > 0 {
315 x = 0
316 y += rowH + 6
317 rowH = 0
318 }
319 x += size.width + 6
320 rowH = max(rowH, size.height)
321 }
322 return CGSize(width: width, height: y + rowH)
323 }
324 func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
325 var x = bounds.minX, y = bounds.minY, rowH: CGFloat = 0
326 for sub in subviews {
327 let size = sub.sizeThatFits(.unspecified)
328 if x + size.width > bounds.maxX, x > bounds.minX {
329 x = bounds.minX
330 y += rowH + 6
331 rowH = 0
332 }
333 sub.place(at: CGPoint(x: x, y: y), proposal: .unspecified)
334 x += size.width + 6
335 rowH = max(rowH, size.height)
336 }
337 }
338}
339