From dc52f5005f78cde69eef1f755b8fef51d8c64138 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:09:25 -0400 Subject: [PATCH 1/4] fix(ios): unify new-chat composer controls with in-session chat Bring the new-chat pane's prompt-box controls to parity with an existing chat thread, all rendered from a shared WorkComposerControlsRow used by both composers: - permission/access control collapses to a single tone-dot dropdown when space is tight (segmented chips when wide), replacing the horizontally scrolling chip row - model pill + fast-mode lightning toggle, identical to the in-session strip Thread fast mode into chat.create (codexFastMode) for fast-capable models in Chat mode; hidden in CLI mode since the launcher has no fast-mode param. Remove the desktop-flavored "Tools use {lane} until the lane is created" auto-create banner. Co-Authored-By: Claude Opus 4.8 --- .../Work/WorkChatComposerAndInputViews.swift | 372 ++++++++++-------- .../ADE/Views/Work/WorkNewChatScreen.swift | 182 +++------ .../sync-and-multi-device/ios-companion.md | 2 +- 3 files changed, 258 insertions(+), 298 deletions(-) diff --git a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift index 49c04a9dd..a7e850a80 100644 --- a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift @@ -99,15 +99,28 @@ func workReasoningChipLabel(_ effort: String?) -> String? { func workChatComposerSupportsFastMode(_ summary: AgentChatSessionSummary) -> Bool { if summary.effectiveFastMode { return true } - if workChatComposerModelOption(summary)?.supportsServiceTier("fast") == true { return true } - return workChatComposerModelRefLooksFastCapable(summary) + if workComposerModelOption(modelId: summary.modelId ?? summary.model, provider: summary.provider)? + .supportsServiceTier("fast") == true { return true } + return workModelRefsLookFastCapable([summary.modelId, summary.model]) } -private func workChatComposerModelOption(_ summary: AgentChatSessionSummary) -> WorkModelOption? { - let currentModelId = (summary.modelId ?? summary.model).trimmingCharacters(in: .whitespacesAndNewlines) +/// Whether a model (by raw id + provider family) can use the "fast" service +/// tier. Shared by the in-session and new-chat composers so both surfaces show +/// the fast-mode lightning toggle for the same models. +func workComposerSupportsFastMode(modelId: String, provider: String) -> Bool { + if workComposerModelOption(modelId: modelId, provider: provider)?.supportsServiceTier("fast") == true { + return true + } + return workModelRefsLookFastCapable([modelId]) +} + +/// Resolve the catalog `WorkModelOption` for a raw model id, preferring the +/// model's own provider-family group before falling back to a global search. +func workComposerModelOption(modelId: String, provider: String) -> WorkModelOption? { + let currentModelId = modelId.trimmingCharacters(in: .whitespacesAndNewlines) guard !currentModelId.isEmpty else { return nil } - let family = providerFamilyKey(summary.provider) - let groups = workModelCatalogGroups(currentModelId: currentModelId, currentProvider: summary.provider) + let family = providerFamilyKey(provider) + let groups = workModelCatalogGroups(currentModelId: currentModelId, currentProvider: provider) let familyGroups = groups.filter { $0.key == family } let searchGroups = familyGroups.isEmpty ? groups : familyGroups @@ -121,8 +134,10 @@ private func workChatComposerModelOption(_ summary: AgentChatSessionSummary) -> return nil } -private func workChatComposerModelRefLooksFastCapable(_ summary: AgentChatSessionSummary) -> Bool { - let refs = [summary.modelId, summary.model] +/// Hardcoded fallback for hosts that don't advertise service tiers yet: a small +/// allow-list of fast-capable model refs plus any "-fast" suffixed id. +private func workModelRefsLookFastCapable(_ rawRefs: [String?]) -> Bool { + let refs = rawRefs .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } .filter { !$0.isEmpty } let fastRefs: Set = [ @@ -175,98 +190,75 @@ struct WorkComposerInputBanner: View { } } -/// Compact horizontal strip matching the desktop composer toolbar: small -/// single-line pills for access, model, fast mode, pending status chips, -/// and nothing else. Reasoning is summarized in the model chip and changed -/// through the full model picker. -struct WorkComposerChipStrip: View { - let chatSummary: AgentChatSessionSummary? - let pendingInputCount: Int +/// Width below which the access/permission control collapses from the full +/// segmented chip row to a single dot-only Menu button — mirroring the desktop +/// composer's container query, tuned down for the phone. Shared so the +/// in-session and new-chat composers collapse at the same point. +let workComposerControlsCollapseThreshold: CGFloat = 360 + +/// The composer's access/model/fast-mode controls, shared verbatim by the +/// in-session composer (`WorkComposerChipStrip`) and the new-chat composer +/// (`WorkNewChatComposerBar`) so the two surfaces stay visually and +/// behaviorally identical: a permission/access control (single tone-dot Menu +/// when space is tight, segmented chips when wide), a model pill, and a +/// fast-mode lightning toggle. The caller owns width measurement and passes +/// `isCollapsed` so the GeometryReader can sit on the scroll viewport. +struct WorkComposerControlsRow: View { + let provider: String + let modelDisplayName: String + let reasoningEffort: String + let currentMode: String + let modeOptions: [WorkRuntimeModeOption] + let modeLabel: String + let isCollapsed: Bool + let fastModeSupported: Bool + let fastModeEnabled: Bool let settingsMutationInFlight: Bool - let codexFastModeOverride: Bool? let onOpenModelPicker: (() -> Void)? - let onSelectRuntimeMode: ((String) -> Void)? - let onToggleCodexFastMode: ((Bool) -> Void)? - - /// Width below which the access control collapses from the full segmented - /// chip row to a single dot-only Menu button — mirroring the desktop - /// composer's ≤560px container query (tuned down for the phone, where the full - /// strip still wants to leave room for the model pill + Send button). - private let collapseThreshold: CGFloat = 360 - - /// Live measurement of the strip's available width, fed by a background - /// GeometryReader so the collapse decision tracks rotation / split-view. - @State private var availableWidth: CGFloat = 0 - - private var isCollapsed: Bool { - availableWidth > 0 && availableWidth <= collapseThreshold - } + let onSelectMode: ((String) -> Void)? + let onToggleFastMode: ((Bool) -> Void)? var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - if let chatSummary { - accessControl(summary: chatSummary) - modelPill(summary: chatSummary) - fastModeToggle(summary: chatSummary) - } - - if pendingInputCount > 0 { - statusChip(icon: "hand.raised.circle.fill", label: "\(pendingInputCount) waiting", tint: ADEColor.warning) - } - } - .padding(.horizontal, 2) + HStack(spacing: 8) { + accessControl + modelPill + fastModeToggle } - .frame(maxWidth: .infinity, alignment: .leading) - .disabled(settingsMutationInFlight) - .opacity(settingsMutationInFlight ? 0.72 : 1) - .background( - GeometryReader { proxy in - Color.clear - .onAppear { availableWidth = proxy.size.width } - .onChange(of: proxy.size.width) { _, newValue in - availableWidth = newValue - } - } - ) } /// Access/permission control: full segmented chips above the threshold, a /// single tone-dot Menu button below it (label + caret hidden, color encodes /// the safety tier via `workRuntimeModeTint`). @ViewBuilder - private func accessControl(summary: AgentChatSessionSummary) -> some View { + private var accessControl: some View { if isCollapsed { - collapsedAccessControl(summary: summary) + collapsedAccessControl } else { - accessPill(summary: summary) + accessPill } } @ViewBuilder - private func collapsedAccessControl(summary: AgentChatSessionSummary) -> some View { - let options = workRuntimeModeOptions(provider: summary.provider) - let currentMode = workInitialRuntimeMode(summary) + private var collapsedAccessControl: some View { let tint = workRuntimeModeTint(currentMode) - let label = workRuntimeModeLabel(provider: summary.provider, mode: currentMode) - if options.isEmpty || onSelectRuntimeMode == nil { + if modeOptions.isEmpty || onSelectMode == nil { collapsedDotButton(tint: tint, glyph: nil) - .accessibilityLabel("Access mode: \(label)") + .accessibilityLabel("Access mode: \(modeLabel)") } else { Menu { Picker("Access mode", selection: Binding( get: { currentMode }, - set: { onSelectRuntimeMode?($0) } + set: { onSelectMode?($0) } )) { - ForEach(options) { option in + ForEach(modeOptions) { option in Text(option.title).tag(option.id) } } } label: { collapsedDotButton(tint: tint, glyph: nil) } - .accessibilityLabel("Access mode: \(label). Tap to change.") + .accessibilityLabel("Access mode: \(modeLabel). Tap to change.") } } @@ -294,103 +286,19 @@ struct WorkComposerChipStrip: View { } @ViewBuilder - private func modelPill(summary: AgentChatSessionSummary) -> some View { - let reasoning = (summary.reasoningEffort ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let reasoningLabel = workReasoningChipLabel(reasoning) - Button { - onOpenModelPicker?() - } label: { - HStack(spacing: 6) { - WorkProviderLogo( - provider: summary.provider, - fallbackSymbol: providerIcon(summary.provider), - tint: providerTint(summary.provider), - size: 16 - ) - Text(prettyModelName(summary.model)) - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - if let reasoningLabel { - Text("·") - .font(.caption2) - .foregroundStyle(ADEColor.textMuted.opacity(0.5)) - Text(reasoningLabel) - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(1) - } - Image(systemName: "chevron.down") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(ADEColor.textMuted) - } - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background(Color.clear, in: Capsule(style: .continuous)) - .overlay( - Capsule(style: .continuous) - .stroke(ADEColor.border.opacity(0.22), lineWidth: 0.5) - ) - } - .buttonStyle(.plain) - .disabled(onOpenModelPicker == nil) - .accessibilityLabel("Model: \(summary.model)\(reasoning.isEmpty ? "" : ", reasoning \(reasoning)"). Tap to switch.") - } - - @ViewBuilder - private func fastModeToggle(summary: AgentChatSessionSummary) -> some View { - if supportsFastMode(summary: summary) { - let persistedEnabled = summary.effectiveFastMode - let isEnabled = codexFastModeOverride ?? persistedEnabled - Button { - let next = !isEnabled - onToggleCodexFastMode?(next) - } label: { - Image(systemName: "bolt.fill") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(isEnabled ? ADEColor.warning : ADEColor.textMuted) - .frame(width: 28, height: 28) - .background( - Capsule(style: .continuous) - .fill(ADEColor.surfaceBackground.opacity(isEnabled ? 0.56 : 0.34)) - ) - .overlay( - Capsule(style: .continuous) - .stroke(isEnabled ? ADEColor.warning.opacity(0.38) : ADEColor.border.opacity(0.22), lineWidth: 0.5) - ) - } - .buttonStyle(.plain) - .disabled(onToggleCodexFastMode == nil || settingsMutationInFlight) - .contentShape(Rectangle()) - .accessibilityLabel("Fast mode \(isEnabled ? "on" : "off"). Tap to turn \(isEnabled ? "off" : "on").") - .accessibilityValue(settingsMutationInFlight ? "\(isEnabled ? "On" : "Off"), saving" : (isEnabled ? "On" : "Off")) - .accessibilityIdentifier("Work.Chat.Composer.FastModeToggle") - } - } - - private func supportsFastMode(summary: AgentChatSessionSummary) -> Bool { - workChatComposerSupportsFastMode(summary) - } - - @ViewBuilder - private func accessPill(summary: AgentChatSessionSummary) -> some View { - let options = workRuntimeModeOptions(provider: summary.provider) - let currentMode = workInitialRuntimeMode(summary) - let tint = workRuntimeModeTint(currentMode) - - if options.isEmpty || onSelectRuntimeMode == nil { - pillContent(dotColor: tint, label: workRuntimeModeLabel(provider: summary.provider, mode: currentMode), showChevron: false) + private var accessPill: some View { + if modeOptions.isEmpty || onSelectMode == nil { + pillContent(dotColor: workRuntimeModeTint(currentMode), label: modeLabel, showChevron: false) } else { HStack(spacing: 6) { - ForEach(options) { option in + ForEach(modeOptions) { option in composerOptionChip( title: option.title, - systemImage: nil, tint: workRuntimeModeTint(option.id), isSelected: option.id == currentMode, accessibilityPrefix: "Access mode" ) { - onSelectRuntimeMode?(option.id) + onSelectMode?(option.id) } } } @@ -399,7 +307,6 @@ struct WorkComposerChipStrip: View { private func composerOptionChip( title: String, - systemImage: String?, tint: Color, isSelected: Bool, accessibilityPrefix: String, @@ -410,11 +317,6 @@ struct WorkComposerChipStrip: View { Circle() .fill(tint) .frame(width: 6, height: 6) - if let systemImage { - Image(systemName: systemImage) - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(isSelected ? tint : ADEColor.textMuted) - } Text(title) .font(.caption.weight(.semibold)) .foregroundStyle(isSelected ? ADEColor.textPrimary : ADEColor.textSecondary) @@ -466,6 +368,148 @@ struct WorkComposerChipStrip: View { ) } + @ViewBuilder + private var modelPill: some View { + let reasoning = reasoningEffort.trimmingCharacters(in: .whitespacesAndNewlines) + let reasoningLabel = workReasoningChipLabel(reasoning) + Button { + onOpenModelPicker?() + } label: { + HStack(spacing: 6) { + WorkProviderLogo( + provider: provider, + fallbackSymbol: providerIcon(provider), + tint: providerTint(provider), + size: 16 + ) + Text(modelDisplayName) + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + if let reasoningLabel { + Text("·") + .font(.caption2) + .foregroundStyle(ADEColor.textMuted.opacity(0.5)) + Text(reasoningLabel) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(ADEColor.textMuted) + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(Color.clear, in: Capsule(style: .continuous)) + .overlay( + Capsule(style: .continuous) + .stroke(ADEColor.border.opacity(0.22), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + .disabled(onOpenModelPicker == nil) + .accessibilityLabel("Model: \(modelDisplayName)\(reasoning.isEmpty ? "" : ", reasoning \(reasoning)"). Tap to switch.") + } + + @ViewBuilder + private var fastModeToggle: some View { + if fastModeSupported { + Button { + onToggleFastMode?(!fastModeEnabled) + } label: { + Image(systemName: "bolt.fill") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(fastModeEnabled ? ADEColor.warning : ADEColor.textMuted) + .frame(width: 28, height: 28) + .background( + Capsule(style: .continuous) + .fill(ADEColor.surfaceBackground.opacity(fastModeEnabled ? 0.56 : 0.34)) + ) + .overlay( + Capsule(style: .continuous) + .stroke(fastModeEnabled ? ADEColor.warning.opacity(0.38) : ADEColor.border.opacity(0.22), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + .disabled(onToggleFastMode == nil || settingsMutationInFlight) + .contentShape(Rectangle()) + .accessibilityLabel("Fast mode \(fastModeEnabled ? "on" : "off"). Tap to turn \(fastModeEnabled ? "off" : "on").") + .accessibilityValue(settingsMutationInFlight ? "\(fastModeEnabled ? "On" : "Off"), saving" : (fastModeEnabled ? "On" : "Off")) + .accessibilityIdentifier("Work.Chat.Composer.FastModeToggle") + } + } +} + +/// Compact horizontal strip matching the desktop composer toolbar: small +/// single-line pills for access, model, fast mode, pending status chips, +/// and nothing else. Reasoning is summarized in the model chip and changed +/// through the full model picker. +struct WorkComposerChipStrip: View { + let chatSummary: AgentChatSessionSummary? + let pendingInputCount: Int + let settingsMutationInFlight: Bool + let codexFastModeOverride: Bool? + let onOpenModelPicker: (() -> Void)? + let onSelectRuntimeMode: ((String) -> Void)? + let onToggleCodexFastMode: ((Bool) -> Void)? + + /// Width below which the access control collapses from the full segmented + /// chip row to a single dot-only Menu button — mirroring the desktop + /// composer's ≤560px container query (tuned down for the phone, where the full + /// strip still wants to leave room for the model pill + Send button). + private let collapseThreshold: CGFloat = workComposerControlsCollapseThreshold + + /// Live measurement of the strip's available width, fed by a background + /// GeometryReader so the collapse decision tracks rotation / split-view. + @State private var availableWidth: CGFloat = 0 + + private var isCollapsed: Bool { + availableWidth > 0 && availableWidth <= collapseThreshold + } + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + if let chatSummary { + let currentMode = workInitialRuntimeMode(chatSummary) + WorkComposerControlsRow( + provider: chatSummary.provider, + modelDisplayName: prettyModelName(chatSummary.model), + reasoningEffort: chatSummary.reasoningEffort ?? "", + currentMode: currentMode, + modeOptions: workRuntimeModeOptions(provider: chatSummary.provider), + modeLabel: workRuntimeModeLabel(provider: chatSummary.provider, mode: currentMode), + isCollapsed: isCollapsed, + fastModeSupported: workChatComposerSupportsFastMode(chatSummary), + fastModeEnabled: codexFastModeOverride ?? chatSummary.effectiveFastMode, + settingsMutationInFlight: settingsMutationInFlight, + onOpenModelPicker: onOpenModelPicker, + onSelectMode: onSelectRuntimeMode, + onToggleFastMode: onToggleCodexFastMode + ) + } + + if pendingInputCount > 0 { + statusChip(icon: "hand.raised.circle.fill", label: "\(pendingInputCount) waiting", tint: ADEColor.warning) + } + } + .padding(.horizontal, 2) + } + .frame(maxWidth: .infinity, alignment: .leading) + .disabled(settingsMutationInFlight) + .opacity(settingsMutationInFlight ? 0.72 : 1) + .background( + GeometryReader { proxy in + Color.clear + .onAppear { availableWidth = proxy.size.width } + .onChange(of: proxy.size.width) { _, newValue in + availableWidth = newValue + } + } + ) + } + @ViewBuilder private func statusChip(icon: String, label: String, tint: Color) -> some View { HStack(spacing: 5) { diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index 22bdcf8c4..e1a101de6 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -118,6 +118,7 @@ struct WorkNewChatScreen: View { @State private var modelPickerPresented = false @State private var runtimeMode: String = "default" @State private var reasoningEffort: String = "" + @State private var codexFastMode: Bool = false @State private var sessionMode: WorkNewSessionMode = .chat /// Whether the synthetic "Auto-create lane" entry is the current selection. @@ -125,13 +126,11 @@ struct WorkNewChatScreen: View { selectedLaneId == workAutoCreateLaneSentinelId } - /// The fallback lane whose tools run until the auto-created lane is ready — - /// the preferred lane if available, otherwise the first known lane. - private var autoCreateToolsLane: LaneSummary? { - if let preferredLaneId, let match = lanes.first(where: { $0.id == preferredLaneId }) { - return match - } - return lanes.first + /// Fast mode only applies to in-app chat sessions on fast-tier models — the + /// CLI launcher has no fast-mode parameter — so the lightning toggle (and the + /// value we send) is gated on both. + private var fastModeSupported: Bool { + sessionMode == .chat && workComposerSupportsFastMode(modelId: modelId, provider: provider) } var body: some View { @@ -153,7 +152,6 @@ struct WorkNewChatScreen: View { } laneSelector - autoCreateHelperText } .padding(.horizontal, 20) .padding(.vertical, 16) @@ -203,6 +201,9 @@ struct WorkNewChatScreen: View { if !modelSupportsReasoning(modelId: modelId, provider: newProvider) { reasoningEffort = "" } + if !fastModeSupported { + codexFastMode = false + } } .onChange(of: sessionMode) { _, newMode in normalizeSelection(for: newMode) @@ -211,6 +212,9 @@ struct WorkNewChatScreen: View { if !modelSupportsReasoning(modelId: newModel, provider: provider) { reasoningEffort = "" } + if !fastModeSupported { + codexFastMode = false + } } .sheet(isPresented: $modelPickerPresented) { WorkModelPickerSheet( @@ -266,28 +270,6 @@ struct WorkNewChatScreen: View { } } - /// Helper text shown when auto-create is selected, mirroring desktop's - /// "Tools use {lane} until the lane is created" notice. Falls back to a generic - /// phrasing when there is no existing lane to run tools against yet. - @ViewBuilder - private var autoCreateHelperText: some View { - if isAutoCreateLane { - HStack(spacing: 6) { - Image(systemName: "info.circle") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(ADEColor.accent) - Text(autoCreateToolsLane.map { "Tools use \($0.name) until the lane is created." } - ?? "A fresh lane is created on launch.") - .font(.caption2) - .foregroundStyle(ADEColor.textSecondary) - .multilineTextAlignment(.leading) - Spacer(minLength: 0) - } - .padding(.horizontal, 4) - .transition(.opacity) - } - } - @ViewBuilder private var composerBar: some View { WorkNewChatComposerBar( @@ -299,6 +281,8 @@ struct WorkNewChatScreen: View { canStart: !busy && (isAutoCreateLane || !selectedLaneId.isEmpty) && !modelId.isEmpty, runtimeMode: $runtimeMode, reasoningEffort: $reasoningEffort, + fastModeSupported: fastModeSupported, + codexFastMode: $codexFastMode, onOpenModelPicker: { modelPickerPresented = true }, onSubmit: submit(openingMessage:) ) @@ -421,6 +405,7 @@ struct WorkNewChatScreen: View { provider: provider, model: modelId, reasoningEffort: normalizedReasoning.isEmpty ? nil : normalizedReasoning, + codexFastMode: (fastModeSupported && codexFastMode) ? true : nil, permissionMode: wire.permissionMode, interactionMode: wire.interactionMode, claudePermissionMode: wire.claudePermissionMode, @@ -489,6 +474,9 @@ struct WorkNewChatScreen: View { if !modelSupportsReasoning(modelId: modelId, provider: provider) { reasoningEffort = "" } + if !fastModeSupported { + codexFastMode = false + } } } @@ -568,6 +556,8 @@ private struct WorkNewChatComposerBar: View { let canStart: Bool @Binding var runtimeMode: String @Binding var reasoningEffort: String + let fastModeSupported: Bool + @Binding var codexFastMode: Bool let onOpenModelPicker: () -> Void let onSubmit: @MainActor (String) async -> Bool @@ -575,6 +565,9 @@ private struct WorkNewChatComposerBar: View { @FocusState private var composerFocused: Bool @StateObject private var dictationCoordinator = DictationInsertionCoordinator() @State private var isDictating = false + /// Live viewport width of the controls scroll area, so the access control + /// collapses to the in-session composer's dot-Menu at the same threshold. + @State private var controlsWidth: CGFloat = 0 private let dictationTargetId = "work-new-chat-screen" private var trimmedDraft: String { @@ -589,12 +582,8 @@ private struct WorkNewChatComposerBar: View { workRuntimeModeOptions(provider: provider) } - private var runtimeLabel: String { - workRuntimeModeLabel(provider: provider, mode: runtimeMode) - } - - private var runtimeTint: Color { - workRuntimeModeTint(runtimeMode) + private var isControlsCollapsed: Bool { + controlsWidth > 0 && controlsWidth <= workComposerControlsCollapseThreshold } private var placeholder: String { @@ -633,27 +622,32 @@ private struct WorkNewChatComposerBar: View { HStack(alignment: .center, spacing: 8) { if !isDictating { ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .center, spacing: 10) { - modelPickerButton - - if !runtimeOptions.isEmpty { - HStack(spacing: 6) { - ForEach(runtimeOptions) { option in - compactChoiceChip( - title: option.title, - systemImage: nil, - tint: workRuntimeModeTint(option.id), - isSelected: option.id == runtimeMode, - accessibilityPrefix: "Access mode" - ) { - runtimeMode = option.id - } - } - } - } - } + WorkComposerControlsRow( + provider: provider, + modelDisplayName: modelName, + reasoningEffort: reasoningEffort, + currentMode: runtimeMode, + modeOptions: runtimeOptions, + modeLabel: workRuntimeModeLabel(provider: provider, mode: runtimeMode), + isCollapsed: isControlsCollapsed, + fastModeSupported: fastModeSupported, + fastModeEnabled: codexFastMode, + settingsMutationInFlight: busy, + onOpenModelPicker: onOpenModelPicker, + onSelectMode: { runtimeMode = $0 }, + onToggleFastMode: { codexFastMode = $0 } + ) .padding(.trailing, 4) } + .background( + GeometryReader { proxy in + Color.clear + .onAppear { controlsWidth = proxy.size.width } + .onChange(of: proxy.size.width) { _, newValue in + controlsWidth = newValue + } + } + ) DictationRawUndoChip(coordinator: dictationCoordinator, draft: $draft) } @@ -733,84 +727,6 @@ private struct WorkNewChatComposerBar: View { .accessibilityLabel(canSend ? "Send" : "Enter a message to send") } - private func compactChoiceChip( - title: String, - systemImage: String?, - tint: Color, - isSelected: Bool, - accessibilityPrefix: String, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - HStack(spacing: 6) { - Circle().fill(tint).frame(width: 6, height: 6) - if let systemImage { - Image(systemName: systemImage) - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(isSelected ? tint : ADEColor.textMuted) - } - Text(title) - .font(.caption.weight(.semibold)) - .foregroundStyle(isSelected ? ADEColor.textPrimary : ADEColor.textSecondary) - .lineLimit(1) - if isSelected { - Image(systemName: "checkmark") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(tint) - } - } - .padding(.horizontal, 9) - .padding(.vertical, 6) - .background((isSelected ? tint.opacity(0.12) : Color.clear), in: Capsule(style: .continuous)) - .overlay( - Capsule(style: .continuous) - .stroke(isSelected ? tint.opacity(0.4) : ADEColor.border.opacity(0.22), lineWidth: 0.5) - ) - } - .buttonStyle(.plain) - .accessibilityLabel("\(accessibilityPrefix): \(title)") - .accessibilityValue(isSelected ? "Selected" : "") - } - - private var modelPickerButton: some View { - Button { - onOpenModelPicker() - } label: { - HStack(spacing: 6) { - WorkProviderLogo( - provider: provider, - fallbackSymbol: providerIcon(provider), - tint: providerTint(provider), - size: 16 - ) - Text(modelName) - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - if !reasoningEffort.isEmpty { - Text("·") - .font(.caption2) - .foregroundStyle(ADEColor.textMuted.opacity(0.5)) - Text(reasoningEffort.capitalized) - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(1) - } - Image(systemName: "chevron.down") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(ADEColor.textMuted) - } - .padding(.horizontal, 9) - .padding(.vertical, 6) - .background(Color.clear, in: Capsule(style: .continuous)) - .overlay( - Capsule(style: .continuous) - .stroke(ADEColor.border.opacity(0.22), lineWidth: 0.5) - ) - } - .buttonStyle(.plain) - .accessibilityLabel("Model: \(modelName). Tap to change.") - } } struct WorkNewChatRoute: Hashable { diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index f2f7f67ee..49413988c 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -807,7 +807,7 @@ Opening or selecting the project again clears those hidden keys. |---|---|---|---| | **Lanes** | `square.stack.3d.up` | `/lanes` | Full lane surface: search/filter chips, open/create/attach/manage, multi-attach for unregistered worktrees, stack canvas, git/diff/rebase/conflicts, template-backed environment setup progress, lane-scoped sessions and AI chats. `devicesOpen` presence chips show which other devices currently have the lane open. The lane detail screen (full-screen, custom tab bar hidden) embeds `LaneDetailGitActionsPane`, a port of desktop's git actions pane: commit message field with amend toggle and an AI "Suggest message" button (gated by runtime capability, with a setup-hint when the runtime reports "AI commit messages are off"), pull (rebase/merge mode) / push (with force-with-lease) / fetch, staged + unstaged file lists with per-file and bulk stage / unstage / discard / restore / open-diff / open-files, stash push/apply/pop/drop, recent-commit history with context-menu view-files / copy-message / revert / cherry-pick, and a "more actions" menu holding switch branch plus the destructive escape hatches (rebase lane, rebase + descendants, rebase and push, force push). A conflict banner offers rebase **and merge** continue/abort (`git.rebaseContinue`/`Abort`, `git.mergeContinue`/`Abort`), and a rescue sheet creates a new lane from uncommitted changes. The lane options menu copies shareable deeplinks (`LaneDeeplinkHelpers`: `ade://lane/`, `ade://repo///branch/`) and opens `LaneManageSheet`, now a tabbed manage dialog (delete / appearance / stack / archive) mirroring desktop's `ManageLaneDialog`. The previous `LaneAdvancedScreen`, `LaneCommitSheet`, `LaneStashesScreen`, and `LaneCommitHistoryScreen` destinations were deleted in favor of this single pane. | | **Files** | `doc.text` | `/files` | Lane-backed workspace picker (`FilesWorkspacePickerDropdown`, a desktop-shaped searchable dropdown that replaced the horizontal workspace chip row), live file tree/search/read, protected-workspace read-only parity. `mobileReadOnly` on the workspace payload gates mutating file actions on the phone via `ensureMobileFileMutationsAllowed`; quick-open and text-search result lists cap visible rows at 40 and ask the user to refine when more matches exist. | -| **Work** | `terminal` | `/work` | Terminal + chat session list, cached history with persisted lane names, output streaming, native key-passthrough terminal input (keystrokes from the iOS keyboard flow straight into the PTY as `terminal_input`, coalesced ~16 ms; PTY echo is the only source of truth), Ctrl-C forwarding for subscribed live PTYs, in-app CLI session launcher (Claude / Codex / Cursor / OpenCode / Droid), message-to-continue on ended agent CLI rows, session pinning, live chat-event push from the runtime (no polling lag once subscribed). The new-session screen (`WorkNewChatScreen`) toggles between **Chat** and **CLI** via a compact nav-bar pill toggle (desktop `ModeSwitcherPills` parity); the lane is chosen through `WorkLanePickerDropdown` (searchable, with an auto-create-lane row), and in CLI mode the provider is derived from the picked model via `workResolveCliProvider` instead of a separate provider row — the explicit `workCliProviderOptions` picker (and its plain "Shell" launch option) was removed. CLI mode submits `work.startCliSession` with the resolved provider, permission mode (Claude additionally supports `auto`), an optional `reasoningEffort`, and an optional opening message. For most providers the runtime types the opening message into the spawned PTY; for Codex the opening message is forwarded as the final argv positional through `buildTrackedCliLaunchCommand`, so the prompt is treated as a real first turn instead of a typed shell line. The terminal viewer (`TerminalSessionScreen` + `SwiftTermSessionView`) is a full-bleed SwiftTerm (real VT100/xterm) emulator: tap-to-focus raises the iOS keyboard for direct passthrough, a single-row key bar provides esc/tab/latching-Ctrl/arrows/return plus an overflow menu, pinch adjusts font size, and the phone owns the PTY's cols×rows while the screen is open (sent as `terminal_resize`; the runtime restores the desktop size on detach). Live output streams via offset-stamped `terminal_data` with gap detection + `sinceOffset` delta resume (no snapshot polling); scrolling near the top auto-pages older transcript via `terminal_history`, and a floating "↓ Live N" pill snaps back to the live tail. When the hosted program enables mouse reporting (Claude Code, htop), vertical pans are translated into SGR wheel events so the TUI scrolls itself; mouse-off sessions scroll native scrollback. Against pre-offset hosts (older brains, whose PTY→sync bridge never pushed terminal output) the screen detects the missing offsets and falls back to a 2s tail-refresh poll until offsets appear. The screen unsubscribes via `terminal_unsubscribe` on disappear. The legacy `WorkTerminalEmulatorView`/`WorkTerminalScreen` mini-parser remains only for inline preview cards. The earlier "activity feed" section was retired — running chats are surfaced through the session list and a Work tab badge bound to `SyncService.runningChatSessionCount`. In chat sessions, user-message attachments render through `WorkChatAttachmentTray` (image thumbnails embedded in the bubble, desktop `ChatAttachmentTray` parity, placeholder tiles when the image bytes have not synced from the host yet), and the chat header's PR menu opens the lane's open PR on GitHub, copies its link, or launches the create-PR wizard in `singleModeOnly` mode (eligibility read from `prs.getMobileSnapshot.createCapabilities`). | +| **Work** | `terminal` | `/work` | Terminal + chat session list, cached history with persisted lane names, output streaming, native key-passthrough terminal input (keystrokes from the iOS keyboard flow straight into the PTY as `terminal_input`, coalesced ~16 ms; PTY echo is the only source of truth), Ctrl-C forwarding for subscribed live PTYs, in-app CLI session launcher (Claude / Codex / Cursor / OpenCode / Droid), message-to-continue on ended agent CLI rows, session pinning, live chat-event push from the runtime (no polling lag once subscribed). The new-session screen (`WorkNewChatScreen`) toggles between **Chat** and **CLI** via a compact nav-bar pill toggle (desktop `ModeSwitcherPills` parity); the lane is chosen through `WorkLanePickerDropdown` (searchable, with an auto-create-lane row), and in CLI mode the provider is derived from the picked model via `workResolveCliProvider` instead of a separate provider row — the explicit `workCliProviderOptions` picker (and its plain "Shell" launch option) was removed. The new-chat composer shares the in-session chat composer's `WorkComposerControlsRow` (the same controls strip used by `WorkComposerChipStrip`): a permission/access control that collapses to a single tone-dot dropdown when space is tight and expands to segmented chips when wide, a model pill, and a fast-mode lightning toggle. The fast-mode toggle is shown only in **Chat** mode for fast-capable models (threaded into `chat.create` via `codexFastMode`) and is hidden in CLI mode, where the launcher has no fast-mode parameter. CLI mode submits `work.startCliSession` with the resolved provider, permission mode (Claude additionally supports `auto`), an optional `reasoningEffort`, and an optional opening message. For most providers the runtime types the opening message into the spawned PTY; for Codex the opening message is forwarded as the final argv positional through `buildTrackedCliLaunchCommand`, so the prompt is treated as a real first turn instead of a typed shell line. The terminal viewer (`TerminalSessionScreen` + `SwiftTermSessionView`) is a full-bleed SwiftTerm (real VT100/xterm) emulator: tap-to-focus raises the iOS keyboard for direct passthrough, a single-row key bar provides esc/tab/latching-Ctrl/arrows/return plus an overflow menu, pinch adjusts font size, and the phone owns the PTY's cols×rows while the screen is open (sent as `terminal_resize`; the runtime restores the desktop size on detach). Live output streams via offset-stamped `terminal_data` with gap detection + `sinceOffset` delta resume (no snapshot polling); scrolling near the top auto-pages older transcript via `terminal_history`, and a floating "↓ Live N" pill snaps back to the live tail. When the hosted program enables mouse reporting (Claude Code, htop), vertical pans are translated into SGR wheel events so the TUI scrolls itself; mouse-off sessions scroll native scrollback. Against pre-offset hosts (older brains, whose PTY→sync bridge never pushed terminal output) the screen detects the missing offsets and falls back to a 2s tail-refresh poll until offsets appear. The screen unsubscribes via `terminal_unsubscribe` on disappear. The legacy `WorkTerminalEmulatorView`/`WorkTerminalScreen` mini-parser remains only for inline preview cards. The earlier "activity feed" section was retired — running chats are surfaced through the session list and a Work tab badge bound to `SyncService.runningChatSessionCount`. In chat sessions, user-message attachments render through `WorkChatAttachmentTray` (image thumbnails embedded in the bubble, desktop `ChatAttachmentTray` parity, placeholder tiles when the image bytes have not synced from the host yet), and the chat header's PR menu opens the lane's open PR on GitHub, copies its link, or launches the create-PR wizard in `singleModeOnly` mode (eligibility read from `prs.getMobileSnapshot.createCapabilities`). | | **PRs** | `arrow.triangle.pull` | `/prs` | PR list/detail driven by `prs.getMobileSnapshot`: stack visibility (`PrStackSheet`), create-PR wizard (`CreatePrWizardView`) gated by per-lane eligibility, workflow cards (queue / integration / rebase) rendered from `PrWorkflowCard`, per-PR action capabilities. | | **CTO** | `brain.head.profile` | `/cto` | CTO snapshot: Chat / Team / Workflows segments, with the mobile workflows screen mirroring the desktop workflow policy/dashboard and preserving the shared glass navigation chrome. Drills into per-worker chat sessions via `CtoSessionDestinationView`. | | **Settings** | `gearshape` | `/settings` (sync subset) | PIN pairing (`SettingsPinSheet`), notification preferences (`NotificationsCenterView`), quiet hours, per-session overrides, appearance, diagnostics, connection header with QR payload and address candidates, reconnect, forget. `ConnectionSettingsView` binds to `SettingsConnectionPresentationModel`, which feeds plain `SettingsConnectionSnapshot` / `SettingsPairingSnapshot` / `SettingsDiagnosticsSnapshot` DTOs into the section views (`SettingsConnectionHeader`, `SettingsPairingSection`, `SettingsDiagnosticsSection`) instead of having them reach into `SyncService` directly. `sendTestPush` is now `async` and returns a `SyncSendTestPushResult` (`ok`, `message`); the Notifications section renders that message verbatim so APNs-not-configured / in-app-only / wire failure cases all surface to the user. | From 41af69263edbfdffce4733e7e3c2a72256df30fc Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:50:11 -0400 Subject: [PATCH 2/4] =?UTF-8?q?ship:=20iteration=201=20=E2=80=94=20honor?= =?UTF-8?q?=20explicit=20fast-mode=20OFF=20on=20new-chat=20create?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Greptile P1: send an explicit codexFastMode (true/false) when the selected model supports fast mode, instead of nil-on-off. Sending nil let the host apply its own default, which could create a fast-on session against a user who explicitly toggled fast OFF. nil is now reserved for the N/A case (model doesn't support fast mode). Co-Authored-By: Claude Opus 4.8 --- apps/ios/ADE/Views/Work/WorkNewChatScreen.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index e1a101de6..e29f206b5 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -405,7 +405,10 @@ struct WorkNewChatScreen: View { provider: provider, model: modelId, reasoningEffort: normalizedReasoning.isEmpty ? nil : normalizedReasoning, - codexFastMode: (fastModeSupported && codexFastMode) ? true : nil, + // Send an explicit true/false when the model supports fast mode so the + // user's choice (including an explicit OFF) is honored rather than + // falling back to the host default; nil only when fast mode is N/A. + codexFastMode: fastModeSupported ? codexFastMode : nil, permissionMode: wire.permissionMode, interactionMode: wire.interactionMode, claudePermissionMode: wire.claudePermissionMode, From 42d423dc0cdb108fd75095889aa49d827b0e28f4 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:06:22 -0400 Subject: [PATCH 3/4] =?UTF-8?q?ship:=20iteration=202=20=E2=80=94=20preserv?= =?UTF-8?q?e=20live=20fast-tier=20capability=20from=20the=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Codex P2: capture the WorkModelOption the model picker hands onSelect and read fast-tier support from its live serviceTiers when it still matches the chosen model, falling back to the catalog derivation only otherwise. This keeps the lightning toggle (and the codexFastMode sent to chat.create) correct for host-advertised fast models that aren't in the curated iOS catalog yet. Co-Authored-By: Claude Opus 4.8 --- apps/ios/ADE/Views/Work/WorkNewChatScreen.swift | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index e29f206b5..bc9023f34 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -119,6 +119,11 @@ struct WorkNewChatScreen: View { @State private var runtimeMode: String = "default" @State private var reasoningEffort: String = "" @State private var codexFastMode: Bool = false + /// The catalog option the picker handed us, kept so fast-tier support is read + /// from the live host-advertised model (its `serviceTiers`) rather than + /// re-derived from the curated iOS catalog — which can miss a freshly + /// advertised fast model and wrongly hide the toggle. + @State private var selectedModelOption: WorkModelOption? @State private var sessionMode: WorkNewSessionMode = .chat /// Whether the synthetic "Auto-create lane" entry is the current selection. @@ -128,9 +133,15 @@ struct WorkNewChatScreen: View { /// Fast mode only applies to in-app chat sessions on fast-tier models — the /// CLI launcher has no fast-mode parameter — so the lightning toggle (and the - /// value we send) is gated on both. + /// value we send) is gated on both. When the picker's option still matches the + /// current model, trust its live service tiers; otherwise fall back to the + /// catalog derivation (covers the initial default before any pick). private var fastModeSupported: Bool { - sessionMode == .chat && workComposerSupportsFastMode(modelId: modelId, provider: provider) + guard sessionMode == .chat else { return false } + if let option = selectedModelOption, workModelIdsEquivalent(option.id, modelId) { + return option.supportsServiceTier("fast") + } + return workComposerSupportsFastMode(modelId: modelId, provider: provider) } var body: some View { @@ -224,6 +235,7 @@ struct WorkNewChatScreen: View { cursorAvailabilityMode: sessionMode == .cli ? .cli : .chat, isBusy: false, onSelect: { option, pickedReasoning, runtimeProvider in + selectedModelOption = option modelId = option.id provider = sessionMode == .chat ? workNormalizedNewChatProvider(runtimeProvider) From 37066465204d86ce2db6d4c3774ed43532f5cb84 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:21:21 -0400 Subject: [PATCH 4/4] =?UTF-8?q?ship:=20iteration=203=20=E2=80=94=20option?= =?UTF-8?q?=20only=20adds=20fast=20support,=20never=20suppresses=20fallbac?= =?UTF-8?q?k?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Codex P2: the captured picker option's empty serviceTiers was treated as authoritative false, skipping workComposerSupportsFastMode's curated/allow- list fallback and hiding the toggle for known-fast models a host left untagged. The option now only short-circuits to true when it affirmatively carries the fast tier; otherwise the catalog/heuristic fallback runs. Co-Authored-By: Claude Opus 4.8 --- apps/ios/ADE/Views/Work/WorkNewChatScreen.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index bc9023f34..014f576fb 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -133,13 +133,16 @@ struct WorkNewChatScreen: View { /// Fast mode only applies to in-app chat sessions on fast-tier models — the /// CLI launcher has no fast-mode parameter — so the lightning toggle (and the - /// value we send) is gated on both. When the picker's option still matches the - /// current model, trust its live service tiers; otherwise fall back to the - /// catalog derivation (covers the initial default before any pick). + /// value we send) is gated on both. The picker's option can only *add* support + /// (a live host-advertised fast tier the curated catalog may miss); it never + /// suppresses the catalog/allow-list fallback, so a known-fast model whose + /// option ships empty `serviceTiers` still shows the toggle. private var fastModeSupported: Bool { guard sessionMode == .chat else { return false } - if let option = selectedModelOption, workModelIdsEquivalent(option.id, modelId) { - return option.supportsServiceTier("fast") + if let option = selectedModelOption, + workModelIdsEquivalent(option.id, modelId), + option.supportsServiceTier("fast") { + return true } return workComposerSupportsFastMode(modelId: modelId, provider: provider) }