@@ -651,6 +651,45 @@ struct ADEConnectionDot: View {
651651 fileprivate var a11yLabel : String { " Machine connection · \( accessibilityLabel) " }
652652}
653653
654+ private let projectIconImageCache = NSCache < NSString , UIImage > ( )
655+
656+ /// Decodes a base64 `data:` URL project icon into a `UIImage`, memoised in a
657+ /// process-wide cache. Returns nil when the project has no icon. Shared by the
658+ /// project-home list rows and the root toolbar's projects affordance so both
659+ /// resolve icons through one cache.
660+ func projectIconImage( from dataUrl: String ? ) -> UIImage ? {
661+ guard let dataUrl, !dataUrl. isEmpty else { return nil }
662+ let cacheKey = dataUrl as NSString
663+ if let cached = projectIconImageCache. object ( forKey: cacheKey) {
664+ return cached
665+ }
666+ guard let commaIndex = dataUrl. firstIndex ( of: " , " ) else { return nil }
667+ let base64 = String ( dataUrl [ dataUrl. index ( after: commaIndex) ... ] )
668+ // Project icons are pre-rendered host-side to a 64×64 PNG (a few KB), so a
669+ // payload past ~768 KB of base64 is malformed or hostile — reject it rather
670+ // than drive a large allocation/decode on the UI path.
671+ guard base64. count <= 1_048_576 ,
672+ let data = Data ( base64Encoded: base64) ,
673+ let image = UIImage ( data: data)
674+ else { return nil }
675+ projectIconImageCache. setObject ( image, forKey: cacheKey)
676+ return image
677+ }
678+
679+ extension Image {
680+ /// Shared presentation for a decoded project icon: high-quality fit inside a
681+ /// rounded square. Size and corner radius vary per surface (toolbar capsule,
682+ /// leading disc, project-home rows), so they stay parameters.
683+ func projectIconStyle( size: CGFloat , cornerRadius: CGFloat ) -> some View {
684+ self
685+ . resizable ( )
686+ . interpolation ( . high)
687+ . aspectRatio ( contentMode: . fit)
688+ . frame ( width: size, height: size)
689+ . clipShape ( RoundedRectangle ( cornerRadius: cornerRadius, style: . continuous) )
690+ }
691+ }
692+
654693struct ADEProjectHomeButton : View {
655694 @EnvironmentObject private var syncService : SyncService
656695
@@ -660,9 +699,14 @@ struct ADEProjectHomeButton: View {
660699 Text ( " Projects " )
661700 } icon: {
662701 PrsGlassDisc ( tint: PrsGlass . glowPurple, isAlive: true ) {
663- Image ( systemName: " square.grid.2x2.fill " )
664- . font ( . system( size: 13 , weight: . semibold) )
665- . foregroundStyle ( PrsGlass . accentTop)
702+ if let icon = projectIconImage ( from: syncService. activeProject? . iconDataUrl) {
703+ // Detected project logo replaces the generic grid glyph.
704+ Image ( uiImage: icon) . projectIconStyle ( size: 20 , cornerRadius: 5 )
705+ } else {
706+ Image ( systemName: " square.grid.2x2.fill " )
707+ . font ( . system( size: 13 , weight: . semibold) )
708+ . foregroundStyle ( PrsGlass . accentTop)
709+ }
666710 }
667711 }
668712 . labelStyle ( . iconOnly)
@@ -742,6 +786,7 @@ struct ADERootToolbarControls: View {
742786 icon: " square.grid.2x2.fill " ,
743787 tint: PrsGlass . accentTop,
744788 isAlive: false ,
789+ iconImage: projectIconImage ( from: syncService. activeProject? . iconDataUrl) ,
745790 accessibilityLabel: " Projects " ,
746791 action: { syncService. showProjectHome ( ) }
747792 )
@@ -823,6 +868,7 @@ struct ADERootToolbarControls: View {
823868 icon: String ,
824869 tint: Color ,
825870 isAlive: Bool ,
871+ iconImage: UIImage ? = nil ,
826872 accessibilityLabel: String ,
827873 action: @escaping ( ) -> Void
828874 ) -> some View {
@@ -834,10 +880,15 @@ struct ADERootToolbarControls: View {
834880 . frame ( width: 24 , height: 24 )
835881 . blur ( radius: 3 )
836882 }
837- Image ( systemName: icon)
838- . font ( . system( size: 14 , weight: . semibold) )
839- . foregroundStyle ( tint)
840- . shadow ( color: isAlive ? tint. opacity ( 0.28 ) : . clear, radius: 2 , x: 0 , y: 0 )
883+ if let iconImage {
884+ // Detected project logo replaces the generic grid glyph.
885+ Image ( uiImage: iconImage) . projectIconStyle ( size: 22 , cornerRadius: 5 )
886+ } else {
887+ Image ( systemName: icon)
888+ . font ( . system( size: 14 , weight: . semibold) )
889+ . foregroundStyle ( tint)
890+ . shadow ( color: isAlive ? tint. opacity ( 0.28 ) : . clear, radius: 2 , x: 0 , y: 0 )
891+ }
841892 }
842893 . frame ( width: 38 , height: 34 )
843894 . contentShape ( Rectangle ( ) )
@@ -958,6 +1009,56 @@ struct ADERootToolbarLeadingItems: ToolbarContent {
9581009 }
9591010}
9601011
1012+ /// Canonical chat-composer send affordance: a compact circular button with an
1013+ /// upward arrow, matching the desktop composer (phosphor `ArrowUp` in a white
1014+ /// disc). Shared by the New Chat composer and the in-session chat composer so
1015+ /// every prompt box sends with the same glyph instead of mismatched paperplanes.
1016+ struct ADEComposerSendButton : View {
1017+ /// Whether there is sendable input. Drives the filled vs. recessed treatment.
1018+ let enabled : Bool
1019+ /// While a send is in flight, swaps the arrow for an inline spinner.
1020+ let sending : Bool
1021+ var accessibilityLabelText : String = " Send message "
1022+ /// Optional VoiceOver label used while disabled (e.g. "Enter a message to
1023+ /// send") so users hear *why* the button is unavailable. Falls back to
1024+ /// `accessibilityLabelText` when nil.
1025+ var disabledAccessibilityLabel : String ? = nil
1026+ let action : ( ) -> Void
1027+
1028+ /// Dark glyph color on the light disc (matches the in-session composer).
1029+ private let glyphColor = Color ( red: 0.12 , green: 0.12 , blue: 0.14 )
1030+
1031+ private var resolvedAccessibilityLabel : String {
1032+ if sending { return " Sending message " }
1033+ if !enabled, let disabledAccessibilityLabel { return disabledAccessibilityLabel }
1034+ return accessibilityLabelText
1035+ }
1036+
1037+ var body : some View {
1038+ Button ( action: action) {
1039+ ZStack {
1040+ if sending {
1041+ ProgressView ( )
1042+ . controlSize ( . mini)
1043+ . tint ( enabled ? glyphColor : ADEColor . textSecondary)
1044+ } else {
1045+ Image ( systemName: " arrow.up " )
1046+ . font ( . system( size: 14 , weight: . bold) )
1047+ }
1048+ }
1049+ . frame ( width: 28 , height: 28 )
1050+ . foregroundStyle ( enabled ? glyphColor : ADEColor . textSecondary. opacity ( 0.2 ) )
1051+ . background (
1052+ Circle ( )
1053+ . fill ( enabled ? Color . white. opacity ( 0.9 ) : Color . white. opacity ( 0.06 ) )
1054+ )
1055+ }
1056+ . buttonStyle ( . plain)
1057+ . disabled ( !enabled)
1058+ . accessibilityLabel ( resolvedAccessibilityLabel)
1059+ }
1060+ }
1061+
9611062struct ADEEmptyStateView < Actions: View > : View {
9621063 let symbol : String
9631064 let title : String
0 commit comments