diff --git a/apps/ios/Sources/HomeToolbar.swift b/apps/ios/Sources/HomeToolbar.swift new file mode 100644 index 000000000..924d95d79 --- /dev/null +++ b/apps/ios/Sources/HomeToolbar.swift @@ -0,0 +1,223 @@ +import SwiftUI + +struct HomeToolbar: View { + var gateway: StatusPill.GatewayState + var voiceWakeEnabled: Bool + var activity: StatusPill.Activity? + var brighten: Bool + var talkButtonEnabled: Bool + var talkActive: Bool + var talkTint: Color + var onStatusTap: () -> Void + var onChatTap: () -> Void + var onTalkTap: () -> Void + var onSettingsTap: () -> Void + + @Environment(\.colorSchemeContrast) private var contrast + + var body: some View { + VStack(spacing: 0) { + Rectangle() + .fill(.white.opacity(self.contrast == .increased ? 0.46 : (self.brighten ? 0.18 : 0.12))) + .frame(height: self.contrast == .increased ? 1.0 : 0.6) + .allowsHitTesting(false) + + HStack(spacing: 12) { + HomeToolbarStatusButton( + gateway: self.gateway, + voiceWakeEnabled: self.voiceWakeEnabled, + activity: self.activity, + brighten: self.brighten, + onTap: self.onStatusTap) + + Spacer(minLength: 0) + + HStack(spacing: 8) { + HomeToolbarActionButton( + systemImage: "text.bubble.fill", + accessibilityLabel: "Chat", + brighten: self.brighten, + action: self.onChatTap) + + if self.talkButtonEnabled { + HomeToolbarActionButton( + systemImage: self.talkActive ? "waveform.circle.fill" : "waveform.circle", + accessibilityLabel: self.talkActive ? "Talk Mode On" : "Talk Mode Off", + brighten: self.brighten, + tint: self.talkTint, + isActive: self.talkActive, + action: self.onTalkTap) + } + + HomeToolbarActionButton( + systemImage: "gearshape.fill", + accessibilityLabel: "Settings", + brighten: self.brighten, + action: self.onSettingsTap) + } + } + .padding(.horizontal, 12) + .padding(.top, 10) + .padding(.bottom, 8) + } + .frame(maxWidth: .infinity) + .background(.ultraThinMaterial) + .overlay(alignment: .top) { + LinearGradient( + colors: [ + .white.opacity(self.brighten ? 0.10 : 0.06), + .clear, + ], + startPoint: .top, + endPoint: .bottom) + .allowsHitTesting(false) + } + } +} + +private struct HomeToolbarStatusButton: View { + @Environment(\.scenePhase) private var scenePhase + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.colorSchemeContrast) private var contrast + + var gateway: StatusPill.GatewayState + var voiceWakeEnabled: Bool + var activity: StatusPill.Activity? + var brighten: Bool + var onTap: () -> Void + + @State private var pulse: Bool = false + + var body: some View { + Button(action: self.onTap) { + HStack(spacing: 8) { + HStack(spacing: 6) { + Circle() + .fill(self.gateway.color) + .frame(width: 8, height: 8) + .scaleEffect( + self.gateway == .connecting && !self.reduceMotion + ? (self.pulse ? 1.15 : 0.85) + : 1.0 + ) + .opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0) + + Text(self.gateway.title) + .font(.footnote.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + } + + if let activity { + Image(systemName: activity.systemImage) + .font(.footnote.weight(.semibold)) + .foregroundStyle(activity.tint ?? .primary) + .transition(.opacity.combined(with: .move(edge: .top))) + } else { + Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash") + .font(.footnote.weight(.semibold)) + .foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.black.opacity(self.brighten ? 0.12 : 0.18)) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder( + .white.opacity(self.contrast == .increased ? 0.46 : (self.brighten ? 0.22 : 0.16)), + lineWidth: self.contrast == .increased ? 1.0 : 0.6) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel("Connection Status") + .accessibilityValue(self.accessibilityValue) + .accessibilityHint(self.gateway == .connected ? "Double tap for gateway actions" : "Double tap to open settings") + .onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion) } + .onDisappear { self.pulse = false } + .onChange(of: self.gateway) { _, newValue in + self.updatePulse(for: newValue, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion) + } + .onChange(of: self.scenePhase) { _, newValue in + self.updatePulse(for: self.gateway, scenePhase: newValue, reduceMotion: self.reduceMotion) + } + .onChange(of: self.reduceMotion) { _, newValue in + self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: newValue) + } + .animation(.easeInOut(duration: 0.18), value: self.activity?.title) + } + + private var accessibilityValue: String { + if let activity { + return "\(self.gateway.title), \(activity.title)" + } + return "\(self.gateway.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")" + } + + private func updatePulse(for gateway: StatusPill.GatewayState, scenePhase: ScenePhase, reduceMotion: Bool) { + guard gateway == .connecting, scenePhase == .active, !reduceMotion else { + withAnimation(reduceMotion ? .none : .easeOut(duration: 0.2)) { self.pulse = false } + return + } + + guard !self.pulse else { return } + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { + self.pulse = true + } + } +} + +private struct HomeToolbarActionButton: View { + @Environment(\.colorSchemeContrast) private var contrast + + let systemImage: String + let accessibilityLabel: String + let brighten: Bool + var tint: Color? + var isActive: Bool = false + let action: () -> Void + + var body: some View { + Button(action: self.action) { + Image(systemName: self.systemImage) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(self.isActive ? (self.tint ?? .primary) : .primary) + .frame(width: 40, height: 40) + .background { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.black.opacity(self.brighten ? 0.12 : 0.18)) + .overlay { + if let tint { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill( + LinearGradient( + colors: [ + tint.opacity(self.isActive ? 0.22 : 0.14), + tint.opacity(self.isActive ? 0.08 : 0.04), + .clear, + ], + startPoint: .topLeading, + endPoint: .bottomTrailing)) + .blendMode(.overlay) + } + } + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder( + (self.tint ?? .white).opacity( + self.isActive + ? 0.34 + : (self.contrast == .increased ? 0.4 : (self.brighten ? 0.22 : 0.16)) + ), + lineWidth: self.contrast == .increased ? 1.0 : (self.isActive ? 0.8 : 0.6)) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel(self.accessibilityLabel) + } +} diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index bbb79d98d..babb6b449 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -1639,11 +1639,9 @@ extension NodeAppModel { } var chatSessionKey: String { - let base = "ios" - let agentId = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if agentId.isEmpty || (!defaultId.isEmpty && agentId == defaultId) { return base } - return SessionKey.makeAgentSessionKey(agentId: agentId, baseKey: base) + // Keep chat aligned with the gateway's resolved main session key. + // A hardcoded "ios" base creates synthetic placeholder sessions in the chat UI. + self.mainSessionKey } var activeAgentName: String { diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index 64e3d739b..3a078f271 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -534,63 +534,6 @@ private struct CanvasContent: View { } } -private struct OverlayButton: View { - let systemImage: String - let brighten: Bool - var tint: Color? - var isActive: Bool = false - let action: () -> Void - - var body: some View { - Button(action: self.action) { - Image(systemName: self.systemImage) - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(self.isActive ? (self.tint ?? .primary) : .primary) - .padding(10) - .background { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(.ultraThinMaterial) - .overlay { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill( - LinearGradient( - colors: [ - .white.opacity(self.brighten ? 0.26 : 0.18), - .white.opacity(self.brighten ? 0.08 : 0.04), - .clear, - ], - startPoint: .topLeading, - endPoint: .bottomTrailing)) - .blendMode(.overlay) - } - .overlay { - if let tint { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill( - LinearGradient( - colors: [ - tint.opacity(self.isActive ? 0.22 : 0.14), - tint.opacity(self.isActive ? 0.10 : 0.06), - .clear, - ], - startPoint: .topLeading, - endPoint: .bottomTrailing)) - .blendMode(.overlay) - } - } - .overlay { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder( - (self.tint ?? .white).opacity(self.isActive ? 0.34 : (self.brighten ? 0.24 : 0.18)), - lineWidth: self.isActive ? 0.7 : 0.5) - } - .shadow(color: .black.opacity(0.35), radius: 12, y: 6) - } - } - .buttonStyle(.plain) - } -} - private struct CameraFlashOverlay: View { var nonce: Int diff --git a/apps/ios/Sources/Screen/ScreenTab.swift b/apps/ios/Sources/Screen/ScreenTab.swift index 16b5f8574..deabd3833 100644 --- a/apps/ios/Sources/Screen/ScreenTab.swift +++ b/apps/ios/Sources/Screen/ScreenTab.swift @@ -7,7 +7,7 @@ struct ScreenTab: View { var body: some View { ZStack(alignment: .top) { ScreenWebView(controller: self.appModel.screen) - .ignoresSafeArea() + .ignoresSafeArea(.container, edges: [.top, .leading, .trailing]) .overlay(alignment: .top) { if let errorText = self.appModel.screen.errorText, self.appModel.gatewayServerName == nil diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 7186c7205..a48bc82ae 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -340,9 +340,9 @@ struct SettingsTab: View { .foregroundStyle(.secondary) } self.featureToggle( - "Show Talk Button", + "Show Talk Control", isOn: self.$talkButtonEnabled, - help: "Shows the floating Talk button in the main interface.") + help: "Shows the Talk control in the main toolbar.") TextField("Default Share Instruction", text: self.$defaultShareInstruction, axis: .vertical) .lineLimit(2 ... 6) .textInputAutocapitalization(.sentences) diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index 7413b0295..d2ec7039a 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -83,16 +83,16 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer #expect(json.contains("\"value\"")) } - @Test @MainActor func chatSessionKeyDefaultsToIOSBase() { + @Test @MainActor func chatSessionKeyDefaultsToMainBase() { let appModel = NodeAppModel() - #expect(appModel.chatSessionKey == "ios") + #expect(appModel.chatSessionKey == "main") } @Test @MainActor func chatSessionKeyUsesAgentScopedKeyForNonDefaultAgent() { let appModel = NodeAppModel() appModel.gatewayDefaultAgentId = "main" appModel.setSelectedAgentId("agent-123") - #expect(appModel.chatSessionKey == SessionKey.makeAgentSessionKey(agentId: "agent-123", baseKey: "ios")) + #expect(appModel.chatSessionKey == SessionKey.makeAgentSessionKey(agentId: "agent-123", baseKey: "main")) #expect(appModel.mainSessionKey == "agent:agent-123:main") } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasScaffold/scaffold.html b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasScaffold/scaffold.html index 52ec996e9..684d5a9f1 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasScaffold/scaffold.html +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasScaffold/scaffold.html @@ -113,18 +113,23 @@ inset: 0; z-index: 2; display: flex; - align-items: stretch; + align-items: flex-start; justify-content: center; - padding: calc(var(--safe-top) + 22px) 18px calc(var(--safe-bottom) + 18px); + padding: calc(var(--safe-top) + 18px) 16px calc(var(--safe-bottom) + 18px); box-sizing: border-box; + overflow-y: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; } .shell { width: min(100%, 760px); display: flex; flex-direction: column; - justify-content: center; - gap: 18px; + justify-content: flex-start; + gap: 16px; + min-height: 100%; + box-sizing: border-box; } .hero { @@ -161,9 +166,12 @@ font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; + max-width: 100%; + box-sizing: border-box; } .eyebrow-dot { + flex: 0 0 auto; width: 9px; height: 9px; border-radius: 999px; @@ -171,6 +179,13 @@ box-shadow: 0 0 18px color-mix(in srgb, var(--state) 68%, transparent); } + #openclaw-home-eyebrow { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .hero h1 { margin: 18px 0 0; font-size: clamp(32px, 7vw, 52px); @@ -218,6 +233,7 @@ font-size: 24px; font-weight: 700; letter-spacing: -0.03em; + overflow-wrap: anywhere; } .meta-subtitle { @@ -229,8 +245,9 @@ .agent-focus { display: flex; - align-items: center; + align-items: flex-start; gap: 14px; + margin-top: 8px; } .agent-badge { @@ -252,6 +269,7 @@ font-size: 22px; font-weight: 700; letter-spacing: -0.03em; + overflow-wrap: anywhere; } .agent-focus .caption { @@ -323,6 +341,7 @@ font-size: 15px; font-weight: 700; line-height: 1.2; + overflow-wrap: anywhere; } .agent-row .caption { @@ -384,8 +403,8 @@ @media (max-width: 640px) { #openclaw-home { - padding-left: 14px; - padding-right: 14px; + padding-left: 12px; + padding-right: 12px; } .hero { @@ -403,6 +422,64 @@ } } + @media (max-height: 760px) { + #openclaw-home { + padding-top: calc(var(--safe-top) + 14px); + padding-bottom: calc(var(--safe-bottom) + 12px); + } + + .shell { + gap: 12px; + } + + .hero { + border-radius: 24px; + padding: 16px 15px 15px; + } + + .hero h1 { + margin-top: 14px; + font-size: clamp(28px, 8vw, 38px); + } + + .hero p { + margin-top: 10px; + font-size: 15px; + line-height: 1.42; + } + + .hero-grid { + margin-top: 18px; + } + + .meta-card { + padding: 14px 14px 13px; + } + + .meta-value { + font-size: 22px; + } + + .agent-badge { + width: 50px; + height: 50px; + border-radius: 16px; + font-size: 22px; + } + + .agent-focus .name { + font-size: 20px; + } + + .section { + padding: 14px 14px 12px; + } + + .section-header { + margin-bottom: 10px; + } + } + @media (prefers-reduced-motion: reduce) { body::before, body::after {