This repository was archived by the owner on Nov 10, 2026. It is now read-only.
Repository navigation
Expand file tree
/
Copy pathapp.contract
More file actions
464 lines (460 loc) · 31.5 KB
/
Copy pathapp.contract
File metadata and controls
464 lines (460 loc) · 31.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
use Snapshot, NativeGlass, card from "./shapes.contract"
use Change from "./shapes.contract"
use Model from "./shapes.contract"
use Viewport from "./shapes.contract"
use Time from "./shapes.contract"
use Ink from "./shapes.contract"
use Secondary from "./shapes.contract"
use Canvas from "./shapes.contract"
use Screen from "./shapes.contract"
use GlassButton from "./shapes.contract"
use Avatar from "./shapes.contract"
use MessageRow from "./bubble.contract"
use Balloon from "./bubble.contract"
use Typing from "./bubble.contract"
// The native stack owns the edge swipe and keyboard cancellation (LLP 1038).
routes nav
inbox "/"
thread "/t/:thread"
reader "/t/:thread/read"
component Messages
state query = ""
state generation = 0
state time = 0
state draft = ""
state draftThread = ""
state editing = false
state pull = 0
state dragging = false
state endRequest = 0
state selected = ""
state selectedBody = ""
state selectedOutgoing = false
state selectedY = 0
state selectedHeight = 0
state replyBody = ""
state fullText = ""
state panel = false
state choosing = false
state newConversation = false
state modelQuery = ""
state swiped = ""
state swipeOffset = 70
state inboxEditing = false
state flightBody = ""
state flightId = ""
state flightPhase = 0
state flightAt = 0
state flightRevision = 0
state fieldX = 0
state fieldY = 0
state fieldWidth = 0
state flightX = 0
state flightY = 0
state flightWidth = 100
state flightDelta = 0
state flightScale = 1
state flightThread = ""
state seenChange = 0
mutation changed as shape Change refreshes data
mutation heartbeat as shape Change refreshes data
derive thread = top(nav).params.thread
derive revision = generation + (match changed { case some(c) => c.revision, case none => 0 }) + (match heartbeat { case some(c) => c.revision, case none => 0 })
derive changeId = match changed { case some(c) => c.id, case none => "" }
derive changeRevision = match changed { case some(c) => c.revision, case none => 0 }
derive textValue = draftThread == thread ? draft : data.draft
derive travel = pull > 0 ? 66 * (1 - 1 / (1 + pull / 66)) : 0
resource viewport = exactViewport() as shape Viewport
resource wallTime = exactTime() as shape Time
resource data = snapshot(query, thread, revision) as shape Snapshot
// The date is the view's: a snapshot asked with it would have no
// first-frame answer, since the bake's date is unknown (LLP 1027.000.000 D2).
derive now = wallTime.epochAtZero + time
derive receiptId = match data.messages.filter((m) => m.outgoing and (m.status != "" or now - m.at >= 1130)).at(-1) { case some(m) => m.id, case none => "" }
resource models = catalog(modelQuery, choosing ? 1 : 0) as shape Model[]
derive maximum = min(viewport.width - 121.333, (viewport.width - 32) * 0.85)
action openThread(id: string, saved: string)
nav = open(nav, path("thread", id))
draft = saved
draftThread = id
endRequest = endRequest + 1
replyBody = ""
panel = false
selected = ""
pull = 0
swiped = ""
flightPhase = 0
send changed = command("read", id, "", wallTime.epochAtZero + performanceNow())
action back
nav = back(nav)
panel = false
selected = ""
choosing = false
editing = false
flightPhase = 0
action navigate(location: string)
nav = open(nav, location)
draftThread = ""
flightPhase = 0
action search(value: string)
query = value
action write(value: string)
draft = value
draftThread = thread
send changed = command("draft", thread, value, wallTime.epochAtZero + performanceNow())
action focusComposer
editing = true
endRequest = endRequest + 1
action blurComposer
editing = false
action send
if data.busy
send changed = command("stop", thread, "", wallTime.epochAtZero + performanceNow())
else
if textValue != ""
flightBody = textValue
flightThread = thread
flightId = ""
flightAt = performanceNow()
flightRevision = changeRevision
fieldX = frame("composer-pill").x
fieldY = frame("composer-pill").y
fieldWidth = frame("composer-pill").width
flightPhase = viewport.prefersReducedMotion ? 0 : 1
send changed = sendMessage(thread, textValue, replyBody, wallTime.epochAtZero + performanceNow())
draft = ""
draftThread = thread
panel = false
replyBody = ""
endRequest = endRequest + 1
action drag(dx: number, dy: number)
pull = max(0, pull - dx)
dragging = true
action release(vx: number, vy: number)
pull = 0
dragging = false
action select(id: string, body: string, outgoing: bool)
selectedY = frame(`bubble-${id}`).y
selectedHeight = min(320, frame(`bubble-${id}`).height)
selected = id
selectedBody = body
selectedOutgoing = outgoing
panel = false
blur("composer")
focus("message-menu-reply")
action menuKey(k: string)
if k == "Escape"
dismiss()
preventDefault()
action dismiss
if selected != ""
focus(`message-actions-${selected}`)
selected = ""
panel = false
choosing = false
action react(glyph: string)
send changed = command("reaction", thread, `${selected}|${glyph}`, wallTime.epochAtZero + performanceNow())
dismiss()
action copy
copyText(selectedBody)
dismiss()
action reply(id: string, body: string)
replyBody = body
selected = ""
focus("composer")
action cancelReply
replyBody = ""
action remove
send changed = command("delete", thread, selected, wallTime.epochAtZero + performanceNow())
selected = ""
action read(body: string)
fullText = body
selected = ""
nav = push(nav, path("reader", thread))
action togglePanel
panel = not panel
action operation(op: string)
send changed = command(op, thread, "", wallTime.epochAtZero + performanceNow())
panel = false
action jump(end: bool)
endRequest = end ? max(1, endRequest + 1) : min(-1, endRequest - 1)
panel = false
action choose(new: bool)
choosing = true
newConversation = new
modelQuery = ""
panel = false
blur("composer")
action searchModels(value: string)
modelQuery = value
action model(id: string)
send changed = command(newConversation ? "new" : "model", thread, id, wallTime.epochAtZero + performanceNow())
choosing = false
action swipe(id: string)
swiped = swiped == id ? "" : id
swipeOffset = 200
action inboxScrolled(id: string, x: number, y: number)
swiped = id
swipeOffset = x
action editInbox
inboxEditing = not inboxEditing
swiped = ""
action inboxOperation(op: string, id: string)
send changed = command(op, id, "", wallTime.epochAtZero + performanceNow())
swiped = ""
action poll
time = performanceNow()
if not pending(heartbeat)
send heartbeat = pulse(wallTime.epochAtZero + performanceNow())
task network mount
every(250, poll)
// Geometry is read once after the new balloon has laid out. Motion is
// then evaluated by Exact's spring, with the reference's 55 ms throw delay.
action animate
let stamp = performanceNow()
let startFlight = flightPhase == 1 and changeRevision != flightRevision and changeId != "" and stamp - flightAt > 30 and not frame(`bubble-${changeId}`).unavailable
let phase = startFlight ? 2 : flightPhase
let startedAt = startFlight ? stamp : flightAt
if flightBody != "" and stamp - startedAt < 6000
time = stamp
if changeRevision != seenChange
seenChange = changeRevision
if newConversation and changeId != ""
nav = open(nav, path("thread", changeId))
draft = ""
draftThread = changeId
newConversation = false
if startFlight
flightId = changeId
flightX = frame(`bubble-${changeId}`).x
flightY = frame(`bubble-${changeId}`).y
flightWidth = frame(`bubble-${changeId}`).width
flightDelta = fieldY - frame(`bubble-${changeId}`).y
flightScale = min(2.5, fieldWidth / max(48, frame(`bubble-${changeId}`).width))
flightPhase = phase
flightAt = startedAt
if phase == 2 and stamp - startedAt >= 16
flightScale = 0.77
flightPhase = 3
if phase == 3 and stamp - startedAt >= 55
flightDelta = 0
flightPhase = 4
if phase == 4 and stamp - startedAt >= 177
flightScale = 1
flightPhase = 5
if phase == 5 and stamp - startedAt >= 800
flightPhase = 0
if phase == 1 and stamp - startedAt > 2000
flightPhase = 0
task motion mount
every(frame, animate)
view
main testId="messages" id="messages-root" navigationKey=`${top(nav).id}` navigationBack="back" navigate=navigate viewport-fit="cover" interactive-widget="resizes-content" width="100%" height="100%" display="flex" flex-direction="column" overflow="hidden" class=Canvas font-family="system-ui" color="light-dark(#000000, #ffffff)"
for entry in stack(nav) key=entry.id
// Every screen draws its own chrome (the thread's glass header and
// Back), so it says it has no system bar (LLP 1115.000 D5.2).
column navigationKey=`${entry.id}` navigationBar="none" class=Screen height=viewport.height
if entry.name == "inbox"
column testId="inbox" flex=1 min-height=0 width="100%" inert=(selected != "" or choosing or panel)
row height=48 padding-left=20 padding-right=16 justify-content="space-between" align-items="center" flex-shrink=0
button appearance="none" press=editInbox testId="edit-inbox" padding=4
text (inboxEditing ? "Done" : "Edit") font-size=17
button press=choose(true) testId="compose" aria-label="New message" class=NativeGlass
image "symbol:compose"
text "Messages" font-size=34 font-weight=700 letter-spacing=-0.7 margin-left=20 margin-bottom=12 margin-top=4
row margin-left=16 margin-right=16 margin-bottom=12 border-radius=12 height=36 background-color="light-dark(#efeff0, #1c1c1e)" align-items="center" gap=7 padding-left=10 padding-right=10 flex-shrink=0
image "symbol:search" font-size=16 color="#8e8e93"
input appearance="none" value=query input=search placeholder="Search" testId="search" aria-label="Search conversations" font-size=17 flex=1 min-width=0 border-width=0 background-color="#00000000" class=Ink
scroll flex=1 min-height=0 testId="inbox-list"
for t in data.threads key=t.id
if inboxEditing
row height=86.667 padding-left=16 padding-right=16 gap=12 align-items="center" box-sizing="border-box"
Avatar(initials=t.initials, color=t.color, size=44)
text t.name font-size=17 font-weight=600 flex=1 min-width=0 line-clamp=1
button press=inboxOperation("mute", t.id) testId=`mute-${t.id}` aria-label=(t.muted ? "Show alerts" : "Hide alerts") class=GlassButton backgroundMaterial="glass"
image "symbol:notifications" font-size=18 color="#5856d6"
button press=inboxOperation("delete-thread", t.id) testId=`delete-thread-${t.id}` aria-label=`Delete ${t.name}` class=GlassButton backgroundMaterial="glass"
image "symbol:delete" font-size=18 color="#ff3b30"
else
scroll swipeContent=`conversation-${t.id}` swipeLeading=`unread-${t.id}` swipeTrailing=`delete-thread-${t.id} mute-${t.id}` scroll=inboxScrolled(t.id) testId=`thread-swipe-${t.id}` width="100%" height=86.667 overflow-x="scroll" overflow-y="hidden" scrollbar-width="none" scroll-snap-type="x mandatory" scrollLeft=(inboxEditing ? 200 : (swiped == t.id ? swipeOffset : 70))
row width="100%" height=86.667
row width=70 height=86.667 flex-shrink=0 scroll-snap-align="start" align-items="center" justify-content="center"
button press=inboxOperation("unread", t.id) id=`unread-${t.id}` testId=`unread-${t.id}` aria-label=(t.unread ? "Mark as read" : "Mark as unread") width=50 height=50 border-radius=25 background-color="#0088ff"
text "●" font-size=24 color="#ffffff"
button press=openThread(t.id, t.draft) contextmenu=swipe(t.id) id=`conversation-${t.id}` testId=`conversation-${t.id}` aria-label=`${t.name}, ${t.preview}` width="100%" height=86.667 flex-shrink=0 scroll-snap-align="start" display="flex" flex-direction="row" align-items="center" gap=12 padding-left=16 padding-right=16 box-sizing="border-box" class=Canvas text-align="start"
Avatar(initials=t.initials, color=t.color, size=54)
column flex=1 min-width=0 align-self="stretch" justify-content="center" border-bottom-width=0.333 border-bottom-style="solid" border-color="light-dark(#d1d1d6, #38383a)" padding-top=12 padding-bottom=12
row align-items="center" gap=4 margin-bottom=4
if t.unread
box width=7 height=7 border-radius=4 background-color="#0088ff"
text t.name font-size=17 font-weight=600 flex=1 min-width=0 line-clamp=1 text-align="left"
if t.muted
image "symbol:notifications" font-size=12 color="#8e8e93"
text (t.at > 0 ? formatTime(t.at, wallTime.utcOffset, "short") : "") font-size=14 class=Secondary
text "›" font-size=19 class=Secondary
text t.preview font-size=15 line-height="20px" class=Secondary line-clamp=2 text-align="left"
row width=130 height=86.667 flex-shrink=0 scroll-snap-align="start" padding-left=10 padding-right=10 gap=10 box-sizing="border-box" align-items="center"
// The platform's own buttons (LLP 1069.011.000 D6): on iOS the swipe's
// actions, drawn from their symbols and accents; elsewhere filled circles.
button appearance="auto" buttonStyle="filled" accent-color="#5856d6" press=inboxOperation("mute", t.id) id=`mute-${t.id}` testId=`mute-${t.id}` aria-label=(t.muted ? "Show alerts" : "Hide alerts") width=50 height=50 flex-shrink=0
image "symbol:notifications"
button appearance="auto" buttonStyle="filled" accent-color="#ff3b30" destructive=true press=inboxOperation("delete-thread", t.id) id=`delete-thread-${t.id}` testId=`delete-thread-${t.id}` aria-label=`Delete ${t.name}` width=50 height=50 flex-shrink=0
image "symbol:delete"
if data.threads.length == 0
text "No Results" font-size=20 font-weight=600 text-align="center" margin-top=70
text (data.connected ? "OpenRouter connected" : "AI connects through your Mac") font-size=11 class=Secondary text-align="center" padding=12
box height="env(safe-area-inset-bottom)" flex-shrink=0
if entry.name == "thread"
column testId="conversation" flex=1 min-height=0 width="100%" inert=(selected != "" or choosing or panel)
row height=44 padding-left=16 padding-right=16 align-items="center" justify-content="space-between" flex-shrink=0 z-index=3
button press=back testId="back" aria-label="Back to Messages" class=NativeGlass
image "symbol:back"
button appearance="none" press=choose(false) disabled=(thread == "demo" or data.busy) testId="model-button" max-width="70%" display="flex" flex-direction="row" align-items="center" justify-content="center" gap=5
text data.name font-size=17 font-weight=600 line-clamp=1
if thread != "demo"
text "⌄" font-size=14 class=Secondary
box width=40 height=40
scroll id="transcript" testId="transcript" flex=1 min-height=0 overflow-x="hidden" keyboardDismissMode="interactive" scrollFollowEnd=true scrollTop=(endRequest >= 0 ? 1000000 + endRequest : endRequest) pan=drag panrelease=release touch-action="pan-y"
column padding-top=15.667 padding-bottom=16
for m in data.messages key=m.id
MessageRow(m=m, now=now, offset=wallTime.utcOffset, receiptId=receiptId, maximum=maximum, group=(thread == "demo"), travel=travel, dragging=dragging, reduced=viewport.prefersReducedMotion, hidden=(selected == m.id or (flightPhase >= 2 and flightId == m.id and flightThread == thread)), select=select, reply=reply, read=read)
if data.typing
Typing(reduced=viewport.prefersReducedMotion)
if data.error != ""
column margin-top=12 margin-left=24 margin-right=24 gap=8 align-items="center"
text data.error font-size=13 line-height="18px" color="#ff3b30" text-align="center"
button appearance="none" press=operation("retry") testId="retry" padding=8
text "Try Again" font-size=15 color="#0088ff"
if data.count == 0
column padding-top=40 padding-left=36 padding-right=36 align-items="center" gap=12
Avatar(initials=data.initials, color=data.color, size=64)
text data.name font-size=20 font-weight=600
text "Start a conversation. Your messages are sent to this model through OpenRouter." font-size=15 line-height="21px" class=Secondary text-align="center"
column id="composer-bar" testId="composer-bar" position="relative" flex-shrink=0 padding-left=16 padding-right=16 padding-top=0 padding-bottom=(editing ? 16 : 12) backgroundMaterial="ultra-thin"
if data.notice != ""
text data.notice font-size=11 color="#ff3b30" text-align="center" margin-bottom=8
if replyBody != ""
row align-items="center" gap=8 margin-left=52 margin-bottom=8 padding-left=12 border-left-width=2 border-left-style="solid" border-color="#0088ff"
column flex=1 min-width=0
text "Replying to" font-size=11 color="#0088ff"
text replyBody font-size=13 class=Secondary line-clamp=2
button appearance="none" press=cancelReply testId="cancel-reply" width=28 height=28
text "×" font-size=22 class=Secondary
row align-items="flex-end" gap=12
button press=togglePanel retainFocus=true testId="add" aria-label="Add" class=GlassButton backgroundMaterial="glass"
box width=15.333 height=15.333 position="relative" rotate=(panel ? 45 : 0) transition="rotate 180ms ease-out"
box position="absolute" left=6.542 top=0 width=2.25 height=15.333 border-radius=1 background-color="light-dark(#858e99, #b0b5bd)"
box position="absolute" top=6.542 left=0 height=2.25 width=15.333 border-radius=1 background-color="light-dark(#858e99, #b0b5bd)"
row id="composer-pill" testId="composer-pill" position="relative" flex=1 min-width=0 min-height=40 border-radius=20 backgroundMaterial="glass" padding-right=6.5 align-items="flex-end" box-sizing="border-box"
textarea appearance="none" id="composer" testId="composer" aria-label="Message" value=textValue input=write focus=focusComposer blur=blurComposer placeholder=(thread == "demo" ? "iMessage" : "Message") font-family="system-ui" font-size=17 line-height="20.2871px" field-sizing="content" min-height=40 max-height=263.158 flex=1 min-width=0 padding-left=16 padding-right=5 padding-top=9.85645 padding-bottom=9.85645 border-width=0 box-sizing="border-box" background-color="#00000000" class=Ink
button press=send retainFocus=true disabled=(textValue == "" and not data.busy) testId="send" aria-label=(data.busy ? "Stop response" : "Send message") width=38 height=28 margin-bottom=6 border-radius=14 background-color=(textValue != "" or data.busy ? "light-dark(#0088ff, #0091ff)" : "#00000000") display="flex" align-items="center" justify-content="center" flex-shrink=0
if data.busy
box width=10 height=10 border-radius=2 background-color="#ffffff"
else
if textValue != ""
text "↑" font-size=22 font-weight=600 line-height="28px" color="#ffffff"
else
row align-items="center" gap=2 margin-right=5.4
box width=2 height=4 border-radius=1 background-color="#8e8e93"
box width=2 height=8 border-radius=1 background-color="#8e8e93"
box width=2 height=16 border-radius=1 background-color="#8e8e93"
box width=2 height=8 border-radius=1 background-color="#8e8e93"
box width=2 height=4 border-radius=1 background-color="#8e8e93"
if not editing
box height="calc(env(safe-area-inset-bottom) - 18px)" min-height=0 flex-shrink=0
if entry.name == "reader"
column testId="reader" flex=1 min-height=0 width="100%" inert=(selected != "" or choosing or panel)
row height=44 padding-left=16 padding-right=16 align-items="center" gap=16
button press=back testId="reader-back" aria-label="Back" class=GlassButton backgroundMaterial="glass"
image "symbol:back" font-size=20 color="light-dark(#000000, #ffffff)"
text "Message" font-size=17 font-weight=600
scroll flex=1 min-height=0 padding=20
textarea appearance="none" readonly=true value=fullText aria-label="Full message text" testId="reader-text" field-sizing="content" width="100%" min-height=300 padding=0 border-width=0 background-color="#00000000" class=Ink font-size=17 line-height="24px"
if entry.id == top(nav).id
if flightPhase >= 2 and flightThread == thread
box testId="send-flight" position="absolute" left=flightX top=flightY width=flightWidth translate=`0px ${flightDelta}px` scale=flightScale transform-origin="100% 100%" transition=(flightPhase == 2 ? "none" : (flightPhase == 5 ? "translate -exact-spring(141.75909, 23.8125, 1), scale -exact-spring(320, 50.5964, 2)" : "translate -exact-spring(141.75909, 23.8125, 1), scale 177ms cubic-bezier(0.41, 0.2, 0.6, 1)")) pointer-events="none" z-index=20
Balloon(body=flightBody, outgoing=true, tail=true, maximum=flightWidth, identifier="flying-balloon")
if panel
box position="absolute" top=0 left=0 right=0 bottom=0 z-index=25
button press=dismiss retainFocus=true testId="dismiss-panel" aria-label="Close Add menu" position="absolute" top=0 left=0 width="100%" height="100%"
column testId="add-panel" position="absolute" left=11 bottom=80 width="calc(100% - 22px)" max-width=360 border-radius=30 padding-top=11 padding-bottom=11 backgroundMaterial="glass" box-shadow="0px 8px 40px #00000026" animation=(viewport.prefersReducedMotion ? "none" : "card 260ms cubic-bezier(0.2, 0.05, 0.15, 1)")
button press=choose(true) testId="new-ai-chat" display="flex" flex-direction="row" align-items="center" gap=27 height=66 padding-left=34
Avatar(initials="✦", color="#5856d6", size=38)
text "New AI chat" font-size=24
button press=choose(false) disabled=(thread == "demo" or data.busy) testId="choose-model" display="flex" flex-direction="row" align-items="center" gap=27 height=66 padding-left=34
Avatar(initials="⇄", color="#0088ff", size=38)
text "Change model" font-size=24
if thread == "demo"
button press=operation("receive") testId="receive" display="flex" flex-direction="row" align-items="center" gap=27 height=66 padding-left=34
Avatar(initials="↓", color="#34c759", size=38)
text "Receive message" font-size=24
button press=operation("fifty") testId="add-fifty" display="flex" flex-direction="row" align-items="center" gap=27 height=66 padding-left=34
Avatar(initials="+", color="#ff9500", size=38)
text "Add 50 messages" font-size=24
button press=operation("typing") testId="toggle-typing" display="flex" flex-direction="row" align-items="center" gap=27 height=66 padding-left=34
Avatar(initials="…", color="#8e8e93", size=38)
text "Typing indicator" font-size=24
button press=operation("reset") testId="reset-demo" display="flex" flex-direction="row" align-items="center" gap=27 height=66 padding-left=34
Avatar(initials="↺", color="#ff3b30", size=38)
text "Reset chat" font-size=24
else
button press=jump(false) testId="earliest" display="flex" flex-direction="row" align-items="center" gap=27 height=66 padding-left=34
Avatar(initials="↑", color="#8e8e93", size=38)
text "First message" font-size=24
button press=jump(true) testId="latest" display="flex" flex-direction="row" align-items="center" gap=27 height=66 padding-left=34
Avatar(initials="↓", color="#34c759", size=38)
text "Latest message" font-size=24
if selected != ""
column keycapture=menuKey position="absolute" top=0 left=0 right=0 bottom=0 z-index=30 background-color="#00000030" padding-left=16 padding-right=16
button tabindex=0 press=dismiss testId="dismiss-context" aria-label="Dismiss message menu" position="absolute" top=0 left=0 width="100%" height="100%"
column position="absolute" left=(not selectedOutgoing and thread == "demo" ? 47 : 16) right=16 top=max(76, min(selectedY - 68, viewport.height - selectedHeight - 292)) gap=12 align-items=(selectedOutgoing ? "flex-end" : "flex-start")
row border-radius=30 backgroundMaterial="glass" padding=8 gap=4 animation=(viewport.prefersReducedMotion ? "none" : "card 180ms ease-out")
button tabindex=0 press=react("❤️") testId="react-heart" aria-label="Heart" width=40 height=40
image "assets/tapbacks/heart.png" width=34 height=34 object-fit="contain"
button tabindex=0 press=react("👍") testId="react-like" aria-label="Like" width=40 height=40
image "assets/tapbacks/like.png" width=34 height=34 object-fit="contain"
button tabindex=0 press=react("👎") testId="react-dislike" aria-label="Dislike" width=40 height=40
image "assets/tapbacks/dislike.png" width=34 height=34 object-fit="contain"
button tabindex=0 press=react("😂") testId="react-laugh" aria-label="Laugh" width=40 height=40
image "assets/tapbacks/laugh.png" width=34 height=34 object-fit="contain"
button tabindex=0 press=react("‼️") testId="react-emphasis" aria-label="Emphasize" width=40 height=40
image "assets/tapbacks/emphasis.png" width=34 height=34 object-fit="contain"
button tabindex=0 press=react("❓") testId="react-question" aria-label="Question" width=40 height=40
image "assets/tapbacks/question.png" width=34 height=34 object-fit="contain"
column contextTarget=`bubble-${selected}` contextMagnify=true max-height=320 overflow="hidden" padding-bottom=6.65
Balloon(body=selectedBody, outgoing=selectedOutgoing, tail=true, maximum=maximum, identifier="context-balloon")
column width=250 padding-top=6 padding-bottom=6 border-radius=26 backgroundMaterial="glass"
button tabindex=0 appearance="none" press=reply(selected, selectedBody) id="message-menu-reply" testId="reply" height=44 padding-left=20 display="flex" flex-direction="row" align-items="center" gap=16
text "↩" font-size=22
text "Reply" font-size=17
button tabindex=0 appearance="none" press=copy testId="copy-message" height=44 padding-left=20 display="flex" flex-direction="row" align-items="center" gap=16
image "symbol:copy" font-size=20 color="light-dark(#000000, #ffffff)"
text "Copy" font-size=17
button tabindex=0 appearance="none" press=read(selectedBody) testId="read-message" height=44 padding-left=20 display="flex" flex-direction="row" align-items="center" gap=16
text "Aa" font-size=18
text "Select Text" font-size=17
button tabindex=0 appearance="none" press=remove disabled=data.busy testId="delete-message" height=44 padding-left=20 display="flex" flex-direction="row" align-items="center" gap=16
image "symbol:delete" font-size=20 color="#ff3b30"
text "Delete" font-size=17 color="#ff3b30"
if choosing
column position="absolute" top=0 left=0 right=0 bottom=0 z-index=40 background-color="#00000030" justify-content="flex-end"
button press=dismiss testId="dismiss-models" aria-label="Close model picker" position="absolute" top=0 left=0 width="100%" height="100%"
column testId="model-picker" height="88%" border-top-left-radius=28 border-top-right-radius=28 class=Canvas padding-top=10 padding-bottom="env(safe-area-inset-bottom)" animation=(viewport.prefersReducedMotion ? "none" : "card 260ms cubic-bezier(0.2, 0.05, 0.15, 1)")
box width=36 height=5 border-radius=3 background-color="light-dark(#d1d1d6, #48484a)" align-self="center" margin-bottom=12
row padding-left=20 padding-right=16 height=44 align-items="center" justify-content="space-between"
text (newConversation ? "New Message" : "Choose a Model") font-size=24 font-weight=700
button press=dismiss testId="close-models" class=GlassButton backgroundMaterial="glass" aria-label="Close"
text "×" font-size=26 class=Secondary
text "Chat with any model on OpenRouter" font-size=14 class=Secondary margin-left=20 margin-bottom=16
input appearance="none" value=modelQuery input=searchModels placeholder="Search models" aria-label="Search models" testId="model-search" font-size=17 min-height=40 margin-left=16 margin-right=16 margin-bottom=12 padding-left=12 padding-right=12 border-radius=12 border-width=0 background-color="light-dark(#efeff0, #1c1c1e)" class=Ink
scroll flex=1 min-height=0 testId="models-list"
for m in models key=m.id
button press=model(m.id) testId=`model-${m.id}` disabled=(data.busy and not newConversation) display="flex" flex-direction="row" align-items="center" gap=12 padding-left=20 padding-right=20 padding-top=12 padding-bottom=12 width="100%" box-sizing="border-box"
Avatar(initials=m.initials, color=m.color, size=44)
column flex=1 min-width=0 gap=3
text m.name font-size=17 font-weight=600 line-clamp=1 text-align="left"
text m.maker font-size=13 class=Secondary text-align="left"
if m.id == data.model and not newConversation
text "✓" font-size=20 color="#0088ff"
if models.length == 0
text "No models found" font-size=17 class=Secondary text-align="center" margin-top=24