Skip to content
Closed
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import SwiftUI

/// The horizontal shortcut row above the composer field.
public struct ChatAccessoryChipRow: View {
private static let scrollEdgeFadeWidth: CGFloat = 34
private static let scrollLeadingHardStopWidth: CGFloat = 14
private static let scrollTrailingFadeWidth: CGFloat = 34

private let agentState: ChatAgentState
private let leadingShortcuts: [ChatAccessoryShortcut]
Expand Down Expand Up @@ -42,34 +43,54 @@ public struct ChatAccessoryChipRow: View {
}

public var body: some View {
HStack(spacing: 10) {
if !displayedLeadingShortcuts.isEmpty {
leadingShortcutCluster
.layoutPriority(2)
.zIndex(1)
}
if !displayedScrollableShortcuts.isEmpty {
scrollableShortcutStrip
}
}
.frame(height: 32)
}

private var leadingShortcutCluster: some View {
HStack(spacing: 6) {
ForEach(displayedLeadingShortcuts) { shortcut in
chip(shortcut)
}
}
.fixedSize(horizontal: true, vertical: false)
}

if !displayedScrollableShortcuts.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(displayedScrollableShortcuts) { shortcut in
chip(shortcut)
}
}
.onGeometryChange(for: CGFloat.self, of: { $0.size.width }) { width in
scrollContentWidth = width
}
.padding(.horizontal, scrollNeedsEdgeFade ? Self.scrollEdgeFadeWidth : 2)
}
.frame(height: 32)
.layoutPriority(1)
.onGeometryChange(for: CGFloat.self, of: { $0.size.width }) { width in
scrollViewportWidth = width
}
.mask {
scrollEdgeFadeMask
private var scrollableShortcutStrip: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(displayedScrollableShortcuts) { shortcut in
chip(shortcut)
}
}
.onGeometryChange(for: CGFloat.self, of: { $0.size.width }) { width in
scrollContentWidth = width
}
.padding(.leading, Self.scrollLeadingHardStopWidth)
.padding(.trailing, scrollNeedsTrailingFade ? Self.scrollTrailingFadeWidth : 2)
}
.frame(height: 32)
.layoutPriority(1)
.onGeometryChange(for: CGFloat.self, of: { $0.size.width }) { width in
scrollViewportWidth = width
}
.compositingGroup()
.clipShape(.rect)
.mask {
scrollTrailingFadeMask
}
.overlay(alignment: .leading) {
scrollLeadingHardStop
}
}

private var displayedLeadingShortcuts: [ChatAccessoryShortcut] {
Expand All @@ -96,7 +117,7 @@ public struct ChatAccessoryChipRow: View {
!leadingShortcuts.isEmpty || !shortcuts.isEmpty
}

private var scrollNeedsEdgeFade: Bool {
private var scrollNeedsTrailingFade: Bool {
scrollContentWidth > scrollViewportWidth + 1
}

Expand Down Expand Up @@ -156,6 +177,7 @@ public struct ChatAccessoryChipRow: View {
.contentShape(.capsule)
}
.buttonStyle(.plain)
.fixedSize(horizontal: true, vertical: false)
.accessibilityIdentifier(shortcut.id)
.accessibilityLabel(shortcut.accessibilityLabel ?? shortcut.title)
}
Expand All @@ -170,17 +192,22 @@ public struct ChatAccessoryChipRow: View {
}
}

private var scrollLeadingHardStop: some View {
Rectangle()
.fill(Color.black)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The leading hard-stop overlay is painted black, not used as a mask, so it can render a visible black bar in the chip row. Use a background-matching style/material or remove the visible fill instead of hard-coding black.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatAccessoryChipRow.swift, line 197:

<comment>The leading hard-stop overlay is painted black, not used as a mask, so it can render a visible black bar in the chip row. Use a background-matching style/material or remove the visible fill instead of hard-coding black.</comment>

<file context>
@@ -170,17 +192,22 @@ public struct ChatAccessoryChipRow: View {
 
+    private var scrollLeadingHardStop: some View {
+        Rectangle()
+            .fill(Color.black)
+            .frame(width: Self.scrollLeadingHardStopWidth)
+            .overlay(alignment: .trailing) {
</file context>

.frame(width: Self.scrollLeadingHardStopWidth)
.overlay(alignment: .trailing) {
Rectangle()
.fill(Color.white.opacity(0.18))
.frame(width: 0.5)
}
.allowsHitTesting(false)
}

@ViewBuilder
private var scrollEdgeFadeMask: some View {
if scrollNeedsEdgeFade {
private var scrollTrailingFadeMask: some View {
if scrollNeedsTrailingFade {
HStack(spacing: 0) {
LinearGradient(
colors: [.clear, .black],
startPoint: .leading,
endPoint: .trailing
)
.frame(width: Self.scrollEdgeFadeWidth)

Rectangle()
.fill(.black)

Expand All @@ -189,7 +216,7 @@ public struct ChatAccessoryChipRow: View {
startPoint: .leading,
endPoint: .trailing
)
.frame(width: Self.scrollEdgeFadeWidth)
.frame(width: Self.scrollTrailingFadeWidth)
}
} else {
Rectangle()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,7 @@ public struct ChatComposerView: View {
#if os(iOS)
@ViewBuilder
private var composerSurface: some View {
if #available(iOS 26.0, *) {
GlassEffectContainer {
composerStack
}
} else {
composerStack
}
composerStack
}
#endif

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ struct AgentChatDemoScreen: View {
} else {
ProgressView()
.task {
let (messages, descriptor) = ChatFixtureConversation().make()
let (messages, fixtureDescriptor) = ChatFixtureConversation().make()
let descriptor = demoDescriptor(from: fixtureDescriptor)
let source = FixtureChatEventSource(backlog: messages, replyToSends: true)
stack = DemoStack(
store: ChatConversationStore(descriptor: descriptor, source: source)
Expand Down Expand Up @@ -68,6 +69,7 @@ struct AgentChatDemoScreen: View {
badgeContrast: .darkBackground,
action: {}
)
.mobileIsolatedGlassCompactToolbarControl(horizontalPadding: 16)
}
ToolbarItem(placement: .principal) {
WorkspaceTitleMenu(
Expand Down Expand Up @@ -122,6 +124,15 @@ struct AgentChatDemoScreen: View {
}
}

private func demoDescriptor(from descriptor: ChatSessionDescriptor) -> ChatSessionDescriptor {
let demoState = ProcessInfo.processInfo.environment["CMUX_UITEST_AGENT_CHAT_STATE"]?
.trimmingCharacters(in: .whitespacesAndNewlines)
guard demoState == "idle" else {
return descriptor
}
return descriptor.withState(.idle)
}
Comment on lines +127 to +134

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 UI-test env-var hook in production Sources/

demoDescriptor(from:) reads the CMUX_UITEST_AGENT_CHAT_STATE environment variable — a named UI-test integration point — and lives under Packages/iOS/CmuxMobileShellUI/Sources/, a production Sources/ path. The no-test-debug-seam-in-production-source rule flags debug-only seams in **/Sources/** regardless of a surrounding #if DEBUG guard, and the pass condition for #if DEBUG blocks is "real product behavior" — screenshots and simulator fixture overrides are test behavior, not product behavior. The conventional fix is to place this configuration in a dedicated debug module, a UI-test support target, or a test-only launch-argument resolver that is never compiled into the shipping product directory.

Rule Used: Flag Swift files under a production Sources path (... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +127 to +134

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm UITestConfig.value's trimming/matching semantics before reuse.
rg -n -A 15 'struct UITestConfig|enum UITestConfig' --type=swift
rg -n -A 15 'func value\(' --type=swift -g '*UITestConfig*'

Repository: manaflow-ai/cmux

Length of output: 154


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- AgentChatDemoScreen.swift slice ---'
sed -n '1,220p' Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift

echo
echo '--- Search for UITestConfig and value(for:) ---'
rg -n 'UITestConfig|value\(for:|CMUX_UITEST_AGENT_CHAT_STATE|inlineWorkspaceTitle|inlineWorkspaceSubtitle' Packages -g '*.swift'

Repository: manaflow-ai/cmux

Length of output: 34268


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- UITestConfig.swift ---'
sed -n '1,260p' Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift

echo
echo '--- UITestConfigTests.swift ---'
sed -n '1,220p' Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/UITestConfigTests.swift

Repository: manaflow-ai/cmux

Length of output: 13404


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '1,260p' Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift

Repository: manaflow-ai/cmux

Length of output: 7795


Use UITestConfig.value(for:env:) here too.
inlineWorkspaceTitle and inlineWorkspaceSubtitle already route UI-test overrides through the shared helper, which handles the trim + mock-data gating consistently. This keeps the demo-state override aligned with the rest of the file.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift`
around lines 127 - 134, The demo-state override in demoDescriptor(from:) is
reading the environment directly instead of using the shared
UITestConfig.value(for:env:) helper, which is inconsistent with
inlineWorkspaceTitle and inlineWorkspaceSubtitle. Update demoDescriptor(from:)
to fetch CMUX_UITEST_AGENT_CHAT_STATE through UITestConfig.value(for:env:), then
keep the existing trim and idle-state check so the demo-state behavior matches
the rest of AgentChatDemoScreen.


private func header(for stack: DemoStack) -> some View {
ChatSessionHeaderView(
descriptor: stack.store.descriptor,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ struct WorkspaceChatPane<TitleMenuContent: View>: View {
badgeContrast: backButtonConfiguration.badgeContrast,
action: backButtonConfiguration.action
)
.mobileGlassCompactToolbarControl()
.mobileIsolatedGlassCompactToolbarControl(horizontalPadding: 16)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,26 @@ public extension View {
#endif
}

/// Compact toolbar glass that must remain visually separate from adjacent
/// glass controls.
@ViewBuilder
func mobileIsolatedGlassCompactToolbarControl(horizontalPadding: CGFloat = 14) -> some View {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The default horizontalPadding value of 14 is never exercised in practice — both call sites in WorkspaceChatPane and AgentChatDemoScreen explicitly pass 16. An unused default creates ambiguity about which value is correct for the back-button context and makes the API misleading for future callers. If 16 is the intended standard for toolbar back controls, the default should match; if the default is reserved for a different, narrower use case, a comment explaining that distinction would prevent accidental misuse.

Suggested change
func mobileIsolatedGlassCompactToolbarControl(horizontalPadding: CGFloat = 14) -> some View {
func mobileIsolatedGlassCompactToolbarControl(horizontalPadding: CGFloat = 16) -> some View {

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

#if os(iOS)
if #available(iOS 26.0, *) {
self
.buttonStyle(.plain)
.controlSize(.small)
.padding(.horizontal, horizontalPadding)
.frame(minHeight: 44)
.glassEffect(.regular.interactive(), in: .capsule)
} else {
self
}
#else
self
#endif
}

/// Non-interactive compact glass title backing.
@ViewBuilder
func mobileGlassCompactNavigationTitle() -> some View {
Expand Down
Loading