@@ -50,10 +50,18 @@ private enum GaryxComposerLayout {
5050
5151
5252struct GaryxComposer : View {
53+ @Environment ( \. isEnabled) private var isEnabled
5354 @EnvironmentObject private var model : GaryxMobileModel
5455 let isFocused : FocusState < Bool > . Binding
5556 @State private var draftText = " "
5657 @State private var draftContextVersion = 0
58+ /// Identity generation for the draft field. While a CJK keyboard still
59+ /// reports an input session, SwiftUI can skip pushing a programmatic
60+ /// clear into the focused vertical-axis `TextField`, leaving the sent
61+ /// text visible until the next layout pass. Re-assigning the same empty
62+ /// string cannot dirty state, so the only deterministic flush is
63+ /// recreating the field when the draft context resets.
64+ @State private var draftFieldGeneration = 0
5765 @State private var isPickingAttachments = false
5866 @State private var isPickingPhotos = false
5967 @State private var selectedPhotoItems : [ PhotosPickerItem ] = [ ]
@@ -68,7 +76,7 @@ struct GaryxComposer: View {
6876 }
6977
7078 private var showsSendButton : Bool {
71- !model. isSelectedThreadVisiblyRunning || hasLocalPayload
79+ !model. isSelectedThreadSending || hasLocalPayload
7280 }
7381
7482 private var canChangeWorkspaceMode : Bool {
@@ -79,16 +87,13 @@ struct GaryxComposer: View {
7987 }
8088
8189 private var showsWorkspaceModeStrip : Bool {
82- canChangeWorkspaceMode && model . newThreadWorkspaceCanUseWorktree
90+ canChangeWorkspaceMode
8391 }
8492
8593 var body : some View {
8694 GaryxAdaptiveGlassContainer ( spacing: GaryxComposerLayout . composerSpacing) {
8795 composerStack
8896 }
89- // No drop shadow: the input box is a bare glass card, defined only by its
90- // thin glass-edge border. Nothing extends below the card to clip against
91- // the home-indicator safe area.
9297 . padding ( . horizontal, 12 )
9398 . padding ( . top, 10 )
9499 . padding ( . bottom, 6 )
@@ -134,18 +139,32 @@ struct GaryxComposer: View {
134139 }
135140 . onAppear {
136141 draftContextVersion = model. composerContextVersion
137- draftText = model. draft
142+ draftText = model. activeComposerDraft
138143 #if DEBUG
139144 presentDebugWorkspaceModeSheetIfNeeded ( )
140145 #endif
141146 }
142147 . onChange ( of: model. composerContextVersion) { _, newValue in
143148 draftContextVersion = newValue
144- draftText = model. draft
149+ draftText = model. activeComposerDraft
150+ let wasFocused = isFocused. wrappedValue
151+ draftFieldGeneration &+= 1
152+ if wasFocused {
153+ // The recreated field starts unfocused; re-attach the
154+ // keyboard on the next runloop so sending keeps the
155+ // composer ready for a follow-up message.
156+ DispatchQueue . main. async {
157+ isFocused. wrappedValue = true
158+ }
159+ }
145160 }
146- . onChange ( of: model. draft) { _, newValue in
147- guard newValue != draftText else { return }
148- draftText = newValue
161+ . onChange ( of: draftText) { _, newValue in
162+ // Bind the live text to the thread it is being typed in, so a thread
163+ // switch preserves it. The store is not @Published, so this is cheap
164+ // per keystroke. Skip the brief window before a context reload so the
165+ // outgoing thread's text is never written onto the incoming one.
166+ guard draftContextVersion == model. composerContextVersion else { return }
167+ model. setComposerDraft ( newValue)
149168 }
150169 . onChange ( of: model. sidebarVisible) { _, visible in
151170 if visible {
@@ -174,9 +193,7 @@ struct GaryxComposer: View {
174193 #endif
175194 . onDisappear {
176195 guard draftContextVersion == model. composerContextVersion else { return }
177- if model. draft != draftText {
178- model. draft = draftText
179- }
196+ model. setComposerDraft ( draftText)
180197 }
181198 }
182199
@@ -205,48 +222,34 @@ struct GaryxComposer: View {
205222 }
206223
207224 private var newThreadComposerCard : some View {
208- // Keep the card above the workspace strip in the overlapping deck.
209225 composerCard
210226 . zIndex ( 1 )
227+ . shadow ( color: GaryxComposerLayout . composerShadow, radius: 22 , x: 0 , y: 12 )
228+ . shadow ( color: GaryxComposerLayout . composerLiftShadow, radius: 12 , x: 0 , y: 7 )
229+ . shadow ( color: Color . black. opacity ( 0.035 ) , radius: 2 , x: 0 , y: 1 )
211230 }
212231
213232 private var composerCard : some View {
214233 composerCardContent
215234 . frame ( maxWidth: . infinity, alignment: . leading)
216- // Opaque backing in all cases so the skirt never bleeds through, then
217- // the real Liquid Glass material on top — genuine glassification of
218- // the input box rather than a faux border.
235+ . background ( GaryxComposerLayout . composerMaterialTint, in: composerCardShape)
219236 . background ( GaryxComposerLayout . composerOcclusionFill, in: composerCardShape)
220237 . garyxAdaptiveGlass ( . regular, isInteractive: false , fallbackMaterial: . ultraThinMaterial, in: composerCardShape)
221- }
222-
223- private var composerGlassEdgeGradient : LinearGradient {
224- LinearGradient (
225- colors: [
226- Color . primary. opacity ( 0.13 ) ,
227- Color . primary. opacity ( 0.045 ) ,
228- ] ,
229- startPoint: . top,
230- endPoint: . bottom
231- )
238+ . overlay {
239+ composerCardShape
240+ . stroke ( GaryxComposerLayout . composerMaterialHighlight, lineWidth: 0.7 )
241+ . blendMode ( . plusLighter)
242+ }
243+ . overlay {
244+ composerCardShape
245+ . stroke ( GaryxComposerLayout . composerMaterialStroke, lineWidth: 0.7 )
246+ }
232247 }
233248
234249 private var composerCardShape : RoundedRectangle {
235250 RoundedRectangle ( cornerRadius: GaryxComposerLayout . composerCornerRadius, style: . continuous)
236251 }
237252
238- private var composerTopEdgeMask : some View {
239- LinearGradient (
240- stops: [
241- . init( color: . white, location: 0 ) ,
242- . init( color: . white, location: 0.45 ) ,
243- . init( color: . clear, location: 0.82 ) ,
244- ] ,
245- startPoint: . top,
246- endPoint: . bottom
247- )
248- }
249-
250253 private var composerCardContent : some View {
251254 VStack ( spacing: 0 ) {
252255 if !model. composerAttachments. isEmpty {
@@ -259,8 +262,8 @@ struct GaryxComposer: View {
259262 }
260263
261264 private var workspaceModeStrip : some View {
262- // The gray strip is a non-interactive backdrop; only the leading " Local"
263- // control is tappable, not the whole apron width .
265+ // The gray apron is a non-interactive backdrop; only the leading Local
266+ // select control (icon + label + chevron) is tappable, not the whole strip .
264267 HStack ( spacing: 0 ) {
265268 Button {
266269 guard canChangeWorkspaceMode else { return }
@@ -292,6 +295,7 @@ struct GaryxComposer: View {
292295 . padding ( . top, GaryxComposerLayout . workspaceBaseTopPadding)
293296 . padding ( . bottom, GaryxComposerLayout . workspaceBaseBottomPadding)
294297 . background ( GaryxComposerLayout . workspaceBaseFill, in: workspaceBaseShape)
298+ . garyxAdaptiveGlass ( . regular, isInteractive: false , fallbackMaterial: . ultraThinMaterial, in: workspaceBaseShape)
295299 . overlay {
296300 workspaceBaseShape
297301 . stroke ( GaryxComposerLayout . workspaceBaseHighlight, lineWidth: 0.6 )
@@ -314,6 +318,7 @@ struct GaryxComposer: View {
314318 . clipShape ( workspaceBaseShape)
315319 . allowsHitTesting ( false )
316320 }
321+ . shadow ( color: Color . black. opacity ( 0.07 ) , radius: 28 , x: 0 , y: 10 )
317322 }
318323
319324 private var workspaceBaseShape : UnevenRoundedRectangle {
@@ -366,7 +371,7 @@ struct GaryxComposer: View {
366371 }
367372
368373 TextField ( " " , text: $draftText, axis: . vertical)
369- . id ( GaryxComposerLayout . draftFieldIdentity)
374+ . id ( " \( GaryxComposerLayout . draftFieldIdentity) - \( draftFieldGeneration ) " )
370375 . font ( GaryxFont . subheadline ( ) )
371376 . foregroundStyle ( . primary)
372377 . focused ( isFocused)
@@ -382,6 +387,9 @@ struct GaryxComposer: View {
382387 . padding ( . bottom, GaryxComposerLayout . inputBottomPadding)
383388 . contentShape ( Rectangle ( ) )
384389 . onTapGesture {
390+ // `.onTapGesture` ignores `.disabled`; do not pop the keyboard
391+ // from a finger-up while the drawer drag has content disabled.
392+ guard isEnabled else { return }
385393 isFocused. wrappedValue = true
386394 }
387395 }
@@ -396,7 +404,7 @@ struct GaryxComposer: View {
396404
397405 Spacer ( minLength: 0 )
398406
399- if model. isSelectedThreadVisiblyRunning {
407+ if model. isSelectedThreadSending {
400408 Button {
401409 Task { await model. interruptActiveRun ( ) }
402410 } label: {
@@ -480,7 +488,7 @@ struct GaryxComposer: View {
480488 private func insertSlashCommand( _ command: GaryxSlashCommand ) {
481489 let normalizedName = command. name. hasPrefix ( " / " ) ? command. name : " / \( command. name) "
482490 draftText = normalizedName + " "
483- model. draft = draftText
491+ model. setComposerDraft ( draftText)
484492 isFocused. wrappedValue = true
485493 }
486494
0 commit comments