Skip to content

Commit 9d051fa

Browse files
committed
Merge branch 'main' of https://github.com/Pyiner/garyx
2 parents 26f6f06 + 7f9ca41 commit 9d051fa

13 files changed

Lines changed: 550 additions & 97 deletions

mobile/garyx-mobile/App/GaryxMobile/GaryxMobileAutomationViews.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -873,6 +873,7 @@ struct GaryxAutomationThreadPickerSheet: View {
873873
let onSelect: (GaryxThreadSummary) -> Void
874874
@State private var searchText = ""
875875
@State private var isRefreshing = false
876+
@State private var openSwipeActionRowId: String?
876877

877878
var body: some View {
878879
VStack(spacing: 0) {
@@ -944,6 +945,7 @@ struct GaryxAutomationThreadPickerSheet: View {
944945
.presentationDetents([.fraction(0.93), .large])
945946
.presentationDragIndicator(.hidden)
946947
.presentationCornerRadius(38)
948+
.environment(\.garyxOpenSwipeActionRowId, $openSwipeActionRowId)
947949
.task {
948950
await refreshThreadOptions()
949951
}
@@ -1011,7 +1013,7 @@ struct GaryxAutomationThreadPickerRow: View {
10111013

10121014
var body: some View {
10131015
VStack(spacing: 0) {
1014-
GaryxSwipeActionRow(actions: threadSwipeActions) {
1016+
GaryxSwipeActionRow(id: "thread:\(thread.id)", actions: threadSwipeActions) {
10151017
GaryxSidebarThreadRowView(
10161018
model: GaryxSidebarThreadRowPresentation(
10171019
thread: thread,

mobile/garyx-mobile/App/GaryxMobile/GaryxMobileConversationViews.swift

Lines changed: 72 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,29 @@ private final class GaryxConversationScrollStateBox {
117117
var state = GaryxConversationScrollState()
118118
}
119119

120+
private struct GaryxMessageBubbleActions {
121+
var model: GaryxMobileModel?
122+
var localFilePreview: @MainActor (_ target: String, _ reportsError: Bool) async -> GaryxWorkspaceFilePreview?
123+
var retryFailedUserMessage: @MainActor (_ messageId: String) async -> Bool
124+
125+
static let empty = GaryxMessageBubbleActions(
126+
model: nil,
127+
localFilePreview: { _, _ in nil },
128+
retryFailedUserMessage: { _ in false }
129+
)
130+
}
131+
132+
private struct GaryxMessageBubbleActionsKey: EnvironmentKey {
133+
static let defaultValue = GaryxMessageBubbleActions.empty
134+
}
135+
136+
private extension EnvironmentValues {
137+
var garyxMessageBubbleActions: GaryxMessageBubbleActions {
138+
get { self[GaryxMessageBubbleActionsKey.self] }
139+
set { self[GaryxMessageBubbleActionsKey.self] = newValue }
140+
}
141+
}
142+
120143
struct GaryxConversationView: View {
121144
@EnvironmentObject private var model: GaryxMobileModel
122145
@Environment(\.garyxSidebarDragActive) private var sidebarDragActive
@@ -246,6 +269,19 @@ struct GaryxConversationView: View {
246269
.garyxAdaptiveTopBar {
247270
GaryxConversationHeader()
248271
}
272+
.environment(\.garyxMessageBubbleActions, messageBubbleActions)
273+
}
274+
275+
private var messageBubbleActions: GaryxMessageBubbleActions {
276+
GaryxMessageBubbleActions(
277+
model: model,
278+
localFilePreview: { target, reportsError in
279+
await model.localFilePreview(target, reportsError: reportsError)
280+
},
281+
retryFailedUserMessage: { messageId in
282+
await model.retryFailedUserMessage(messageId)
283+
}
284+
)
249285
}
250286

251287
private func messageScroll(proxy: ScrollViewProxy) -> some View {
@@ -309,12 +345,10 @@ struct GaryxConversationView: View {
309345
.padding(.top, 18)
310346
.padding(.bottom, 24)
311347
.garyxVerticalScrollContentWidth(alignment: .topLeading)
312-
// A short entrance animation keyed to cheap insertion signals
313-
// (message count, indicator visibility), so new bubbles and tool
314-
// rows ease in instead of popping. Streaming text growth and
315-
// scroll measurements never re-key it.
316-
.animation(.easeOut(duration: 0.2), value: model.messages.count)
317-
.animation(.easeOut(duration: 0.2), value: model.showsTailThinkingIndicator)
348+
// Do not attach a count-driven animation to the transcript
349+
// container. A send changes the message count, composer height,
350+
// spacer, and bottom anchor in the same layout pass; animating the
351+
// whole stack makes the scroll view visibly wobble.
318352

319353
Color.clear
320354
.frame(height: conversationBottomChromeClearance)
@@ -459,10 +493,7 @@ struct GaryxConversationView: View {
459493
let identity = conversationScrollIdentity
460494
// Long transcripts re-layout while scrolling, so a single scrollTo
461495
// can land short; the later attempts converge on the true bottom.
462-
let delays: [DispatchTimeInterval] = [
463-
.milliseconds(0), .milliseconds(16), .milliseconds(40), .milliseconds(140),
464-
.milliseconds(320), .milliseconds(650), .milliseconds(1_000),
465-
]
496+
let delays = tailScrollRetryDelays(for: request.reason)
466497

467498
for (index, delay) in delays.enumerated() {
468499
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
@@ -484,6 +515,23 @@ struct GaryxConversationView: View {
484515
}
485516
}
486517

518+
private func tailScrollRetryDelays(
519+
for reason: GaryxConversationScrollState.TailScrollReason
520+
) -> [DispatchTimeInterval] {
521+
switch reason {
522+
case .tailUpdate:
523+
// Ordinary tail growth during send/streaming should stay pinned,
524+
// but long retry chains make the transcript visibly wobble while
525+
// the composer and bottom spacer are also settling.
526+
return [.milliseconds(0), .milliseconds(40), .milliseconds(140)]
527+
case .openingThread, .manual, .repair:
528+
return [
529+
.milliseconds(0), .milliseconds(16), .milliseconds(40), .milliseconds(140),
530+
.milliseconds(320), .milliseconds(650), .milliseconds(1_000),
531+
]
532+
}
533+
}
534+
487535
private var conversationScrollIdentity: String {
488536
model.selectedThread?.id ?? "garyx-draft-thread"
489537
}
@@ -1573,6 +1621,15 @@ private func garyxConfiguredBot(
15731621
}
15741622

15751623
private extension View {
1624+
@ViewBuilder
1625+
func garyxOptionalEnvironmentObject<Object: ObservableObject>(_ object: Object?) -> some View {
1626+
if let object {
1627+
environmentObject(object)
1628+
} else {
1629+
self
1630+
}
1631+
}
1632+
15761633
/// Opens the transcript anchored to its bottom from the very first
15771634
/// layout pass and keeps the tail pinned through content growth while
15781635
/// positioned there — no post-load programmatic scroll-down. The
@@ -1767,7 +1824,7 @@ private struct GaryxSelectedThreadEmptyConversationView: View {
17671824
struct GaryxMessageBubble: View {
17681825
let message: GaryxMobileMessage
17691826
@Environment(\.colorScheme) private var colorScheme
1770-
@EnvironmentObject private var model: GaryxMobileModel
1827+
@Environment(\.garyxMessageBubbleActions) private var actions
17711828
@State private var retrying = false
17721829
@State private var filePreviewSheet: GaryxMessageFilePreviewSheet?
17731830

@@ -1784,7 +1841,7 @@ struct GaryxMessageBubble: View {
17841841
GaryxFullscreenWorkspaceFilePreview(preview: sheet.preview) {
17851842
filePreviewSheet = nil
17861843
}
1787-
.environmentObject(model)
1844+
.garyxOptionalEnvironmentObject(actions.model)
17881845
}
17891846
}
17901847

@@ -1954,14 +2011,14 @@ struct GaryxMessageBubble: View {
19542011

19552012
private func openMessageFileLink(_ target: String) {
19562013
Task {
1957-
guard let preview = await model.localFilePreview(target) else { return }
2014+
guard let preview = await actions.localFilePreview(target, true) else { return }
19582015
filePreviewSheet = GaryxMessageFilePreviewSheet(preview: preview)
19592016
}
19602017
}
19612018

19622019
@MainActor
19632020
private func messageImageFilePreview(_ target: String) async -> GaryxWorkspaceFilePreview? {
1964-
await model.localFilePreview(target, reportsError: false)
2021+
await actions.localFilePreview(target, false)
19652022
}
19662023

19672024
@ViewBuilder
@@ -1973,7 +2030,7 @@ struct GaryxMessageBubble: View {
19732030
guard !retrying else { return }
19742031
retrying = true
19752032
Task {
1976-
_ = await model.retryFailedUserMessage(message.id)
2033+
_ = await actions.retryFailedUserMessage(message.id)
19772034
retrying = false
19782035
}
19792036
} label: {

mobile/garyx-mobile/App/GaryxMobile/GaryxMobileListComponents.swift

Lines changed: 94 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -257,17 +257,32 @@ struct GaryxRowActionMenu<Content: View>: View {
257257
}
258258
}
259259

260+
private struct GaryxOpenSwipeActionRowIdKey: EnvironmentKey {
261+
static let defaultValue: Binding<String?> = .constant(nil)
262+
}
263+
264+
extension EnvironmentValues {
265+
var garyxOpenSwipeActionRowId: Binding<String?> {
266+
get { self[GaryxOpenSwipeActionRowIdKey.self] }
267+
set { self[GaryxOpenSwipeActionRowIdKey.self] = newValue }
268+
}
269+
}
270+
260271
struct GaryxSwipeActionRow<Content: View>: View {
272+
var id: String?
261273
let actions: [GaryxRowAction]
262274
let content: Content
275+
@Environment(\.garyxOpenSwipeActionRowId) private var openSwipeActionRowId
263276
@GestureState private var dragTranslation: CGFloat = 0
264-
@State private var settledOffset: CGFloat = 0
277+
@State private var localIsOpen = false
278+
@State private var didPlayFullRevealFeedback = false
265279

266280
private let actionButtonDiameter: CGFloat = 38
267281
private let actionButtonSpacing: CGFloat = 10
268282
private let actionTrailingPadding: CGFloat = 10
269283

270-
init(actions: [GaryxRowAction], @ViewBuilder content: () -> Content) {
284+
init(id: String? = nil, actions: [GaryxRowAction], @ViewBuilder content: () -> Content) {
285+
self.id = id
271286
self.actions = actions
272287
self.content = content()
273288
}
@@ -282,10 +297,16 @@ struct GaryxSwipeActionRow<Content: View>: View {
282297
content
283298
.background(GaryxTheme.surface)
284299
.offset(x: currentOffset)
300+
.animation(GaryxMobileMotion.rowSwipe, value: isOpen)
285301
}
286302
.contentShape(Rectangle())
287303
.clipped()
288304
.simultaneousGesture(rowDragGesture)
305+
.onChange(of: isOpen) { _, open in
306+
if !open {
307+
didPlayFullRevealFeedback = false
308+
}
309+
}
289310
.accessibilityHint("Swipe left for thread actions.")
290311
.modifier(GaryxRowMenuAccessibilityActions(actions: actions, onAction: perform))
291312
}
@@ -300,6 +321,7 @@ struct GaryxSwipeActionRow<Content: View>: View {
300321
Image(systemName: action.systemImage)
301322
.font(GaryxFont.system(size: 15, weight: .semibold))
302323
.foregroundStyle(Color.white)
324+
.rotationEffect(.degrees(action.iconRotationDegrees))
303325
.frame(width: actionButtonDiameter, height: actionButtonDiameter)
304326
.background(action.tone.background, in: Circle())
305327
.contentShape(Circle())
@@ -317,19 +339,41 @@ struct GaryxSwipeActionRow<Content: View>: View {
317339
.updating($dragTranslation) { value, state, _ in
318340
state = horizontalSwipeTranslation(value.translation)
319341
}
342+
.onChanged { value in
343+
let translation = horizontalSwipeTranslation(value.translation)
344+
closeOtherOpenRowIfNeeded(translation: translation)
345+
updateFullRevealFeedback(for: clampedOffset(baseOffset + translation))
346+
}
320347
.onEnded { value in
321348
let translation = horizontalSwipeTranslation(value.translation)
322-
guard translation != 0 || settledOffset != 0 else { return }
349+
guard translation != 0 || isOpen else { return }
323350
let predicted = horizontalSwipeTranslation(value.predictedEndTranslation)
324-
let projectedOffset = clampedOffset(settledOffset + (predicted == 0 ? translation : predicted))
351+
let projectedOffset = clampedOffset(baseOffset + (predicted == 0 ? translation : predicted))
352+
let nextIsOpen = projectedOffset < -maxRevealWidth * 0.35
325353
withAnimation(GaryxMobileMotion.rowSwipe) {
326-
settledOffset = projectedOffset < -maxRevealWidth * 0.35 ? -maxRevealWidth : 0
354+
setOpen(nextIsOpen)
355+
}
356+
if nextIsOpen {
357+
playFullRevealFeedbackIfNeeded()
358+
} else {
359+
didPlayFullRevealFeedback = false
327360
}
328361
}
329362
}
330363

331364
private var currentOffset: CGFloat {
332-
clampedOffset(settledOffset + dragTranslation)
365+
clampedOffset(baseOffset + dragTranslation)
366+
}
367+
368+
private var baseOffset: CGFloat {
369+
isOpen ? -maxRevealWidth : 0
370+
}
371+
372+
private var isOpen: Bool {
373+
if let id {
374+
return openSwipeActionRowId.wrappedValue == id
375+
}
376+
return localIsOpen
333377
}
334378

335379
private var maxRevealWidth: CGFloat {
@@ -344,7 +388,7 @@ struct GaryxSwipeActionRow<Content: View>: View {
344388
let horizontalMagnitude = abs(horizontal)
345389
let verticalMagnitude = abs(vertical)
346390
guard horizontalMagnitude > verticalMagnitude * 1.15 else { return 0 }
347-
if settledOffset == 0 {
391+
if !isOpen {
348392
return min(0, horizontal)
349393
}
350394
return horizontal
@@ -356,10 +400,48 @@ struct GaryxSwipeActionRow<Content: View>: View {
356400

357401
private func perform(_ action: GaryxRowAction) {
358402
withAnimation(GaryxMobileMotion.rowSwipe) {
359-
settledOffset = 0
403+
setOpen(false)
360404
}
361405
action.action()
362406
}
407+
408+
private func setOpen(_ open: Bool) {
409+
if let id {
410+
if open {
411+
openSwipeActionRowId.wrappedValue = id
412+
} else if openSwipeActionRowId.wrappedValue == id {
413+
openSwipeActionRowId.wrappedValue = nil
414+
}
415+
} else {
416+
localIsOpen = open
417+
}
418+
}
419+
420+
private func closeOtherOpenRowIfNeeded(translation: CGFloat) {
421+
guard let id,
422+
translation < -4,
423+
let openId = openSwipeActionRowId.wrappedValue,
424+
openId != id else {
425+
return
426+
}
427+
withAnimation(GaryxMobileMotion.rowSwipe) {
428+
openSwipeActionRowId.wrappedValue = nil
429+
}
430+
}
431+
432+
private func updateFullRevealFeedback(for offset: CGFloat) {
433+
if offset <= -maxRevealWidth + 0.5 {
434+
playFullRevealFeedbackIfNeeded()
435+
} else if offset > -maxRevealWidth + 8 {
436+
didPlayFullRevealFeedback = false
437+
}
438+
}
439+
440+
private func playFullRevealFeedbackIfNeeded() {
441+
guard !didPlayFullRevealFeedback else { return }
442+
didPlayFullRevealFeedback = true
443+
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
444+
}
363445
}
364446

365447
struct GaryxItemActionMenuButtonStyle: ButtonStyle {
@@ -377,6 +459,10 @@ private extension GaryxRowAction {
377459
var menuRole: ButtonRole? {
378460
tone == .destructive ? .destructive : nil
379461
}
462+
463+
var iconRotationDegrees: Double {
464+
systemImage.hasPrefix("pin") ? -28 : 0
465+
}
380466
}
381467

382468
private struct GaryxRowMenuAccessibilityActions: ViewModifier {

0 commit comments

Comments
 (0)