From f0dd11b5a5a9030ffb826706eed35a4fd942b46a Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:12:47 -0400 Subject: [PATCH 1/5] ios(work): register Droid model group and tighten question card UX - Add Droid provider tab with Anthropic/OpenAI/Google/Factory sub-providers to the model picker registry. - Drop the standalone reasoning-effort segmented row; gpt-5 mini exposes only medium/high. - Hide redundant single-provider filter rows for Claude/Codex tabs. - Auto-scroll the structured-question card above the keyboard when its freeform field gains focus, swap the page dots for inline indicators, and always suppress question input tool cards. --- .../Work/WorkChatComposerAndInputViews.swift | 51 +++- .../Work/WorkChatSessionView+Timeline.swift | 15 +- .../ADE/Views/Work/WorkChatSessionView.swift | 6 +- .../ios/ADE/Views/Work/WorkModelCatalog.swift | 75 +++++- .../ADE/Views/Work/WorkModelPickerSheet.swift | 226 ++++++++---------- .../WorkNavigationAndTranscriptHelpers.swift | 6 +- .../Work/WorkStatusAndFormattingHelpers.swift | 10 + 7 files changed, 249 insertions(+), 140 deletions(-) diff --git a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift index cc647ac76..a7419a0bc 100644 --- a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift @@ -676,12 +676,14 @@ struct WorkStructuredQuestionCard: View { /// forwards this as one `chat.respondToInput` call. let onSubmitAll: @MainActor ([String: AgentChatInputAnswerValue], String?) async -> Void let onDecline: @MainActor () async -> Void + var onFreeformFocusChange: ((Bool) -> Void)? = nil @State private var currentPage: Int = 0 @State private var singleQuestionFreeformText: String = "" @State private var selections: [String: Set] = [:] @State private var freeformByQuestion: [String: String] = [:] @State private var expandedPreviews: Set = [] + @FocusState private var freeformFocused: Bool private var isPaged: Bool { question.questions.count > 1 } private var activeQuestion: WorkPendingQuestion { @@ -699,12 +701,11 @@ struct WorkStructuredQuestionCard: View { ForEach(Array(question.questions.enumerated()), id: \.offset) { index, q in questionPage(q) .tag(index) - .padding(.bottom, 24) + .padding(.bottom, 4) } } - .tabViewStyle(.page(indexDisplayMode: .always)) - .indexViewStyle(.page(backgroundDisplayMode: .always)) - .frame(minHeight: 280) + .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(minHeight: 240) } else { questionPage(activeQuestion) } @@ -716,6 +717,9 @@ struct WorkStructuredQuestionCard: View { footerRow } .adeGlassCard(cornerRadius: 18, padding: 14) + .onChange(of: freeformFocused) { _, focused in + onFreeformFocusChange?(focused) + } } @ViewBuilder @@ -797,10 +801,12 @@ struct WorkStructuredQuestionCard: View { let binding = freeformBinding(for: q) if q.isSecret { SecureField(q.options.isEmpty ? "Response" : "Optional response", text: binding) + .focused($freeformFocused) .adeInsetField(cornerRadius: 14, padding: 12) .disabled(busy) } else { TextField(q.options.isEmpty ? "Response" : "Optional response", text: binding, axis: .vertical) + .focused($freeformFocused) .lineLimit(1...4) .autocorrectionDisabled(false) .textInputAutocapitalization(.sentences) @@ -812,22 +818,45 @@ struct WorkStructuredQuestionCard: View { @ViewBuilder private var footerRow: some View { HStack(spacing: 10) { + Button("Decline") { + Task { await declineQuestion() } + } + .buttonStyle(.glass) + .tint(ADEColor.danger) + .disabled(busy) + + Spacer(minLength: 8) + + if isPaged { + pageIndicator + Spacer(minLength: 8) + } + Button(submitLabel) { Task { await submitAll() } } .buttonStyle(.glassProminent) .tint(ADEColor.accent) .disabled(busy || !canSubmit) + } + } - Spacer(minLength: 0) - - Button("Decline") { - Task { await declineQuestion() } + @ViewBuilder + private var pageIndicator: some View { + HStack(spacing: 6) { + ForEach(0.. some View { + func timelineEntryView(for entry: WorkTimelineEntry, proxy: ScrollViewProxy) -> some View { switch entry.payload { case .message(let message): WorkChatMessageBubble(message: message, isLive: isLatestAssistantMessageLive(message)) @@ -57,8 +57,21 @@ extension WorkChatSessionView { await runSessionAction { await onDeclineQuestion(question.id) } + }, + onFreeformFocusChange: { focused in + guard focused else { return } + // Wait for the keyboard to start animating in so the ScrollView's + // safe-area inset is updated before we ask it to scroll the focused + // card above the keyboard. + Task { @MainActor in + try? await Task.sleep(nanoseconds: 300_000_000) + withAnimation(.easeInOut(duration: 0.25)) { + proxy.scrollTo("pending-question-\(question.id)", anchor: .bottom) + } + } } ) + .id("pending-question-\(question.id)") case .pendingPermission(let permission): WorkPermissionCard( permission: permission, diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 655f5e8dd..551d46564 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -195,7 +195,7 @@ struct WorkChatSessionView: View { } @ViewBuilder - var timelineSection: some View { + func timelineSection(proxy: ScrollViewProxy) -> some View { if timeline.isEmpty { ADEEmptyStateView( symbol: "bubble.left.and.bubble.right", @@ -221,7 +221,7 @@ struct WorkChatSessionView: View { } ForEach(visibleTimeline) { entry in - timelineEntryView(for: entry) + timelineEntryView(for: entry, proxy: proxy) } } } @@ -383,7 +383,7 @@ struct WorkChatSessionView: View { if !timelineSnapshot.subagentSnapshots.isEmpty { WorkSubagentStrip(snapshots: timelineSnapshot.subagentSnapshots) } - timelineSection + timelineSection(proxy: proxy) streamingStatusSection Color.clear diff --git a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift index 47348f438..af5f68413 100644 --- a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift +++ b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift @@ -61,7 +61,7 @@ struct WorkModelCatalogGroupLegacyView: Identifiable, Hashable { let models: [WorkModelOption] } -private let workModelGroupOrder = ["claude", "codex", "cursor", "opencode"] +private let workModelGroupOrder = ["claude", "codex", "cursor", "droid", "opencode"] /// Flat view of the curated catalog: every model in a single provider tab so /// legacy call sites keep functioning. Prefer `workModelCatalogGroups` for @@ -153,6 +153,61 @@ private func workCuratedModelCatalogGroups() -> [WorkModelCatalogGroup] { ] )) + groups.append(WorkModelCatalogGroup( + key: "droid", + displayName: "Droid", + providers: [ + WorkModelProvider( + key: "anthropic", + displayName: "Anthropic (Droid)", + models: [ + WorkModelOption(id: "claude-opus-4-6", displayName: "Opus 4.6 (2x)", tier: .flagship, tagline: "Flagship reasoning · 2x usage", provider: "claude"), + WorkModelOption(id: "claude-opus-4-6-fast", displayName: "Opus 4.6 Fast Mode (12x)", tier: .flagship, tagline: "Faster Opus · 12x usage", provider: "claude"), + WorkModelOption(id: "claude-opus-4-5-20251101", displayName: "Opus 4.5 (2x)", tier: .flagship, tagline: "Prior-gen Opus", provider: "claude"), + WorkModelOption(id: "claude-sonnet-4-6", displayName: "Sonnet 4.6 (1.2x)", tier: .balanced, tagline: "Balanced default", provider: "claude"), + WorkModelOption(id: "claude-sonnet-4-5-20250929", displayName: "Sonnet 4.5 (1.2x)", tier: .balanced, tagline: "Prior-gen Sonnet", provider: "claude"), + WorkModelOption(id: "claude-haiku-4-5-20251001", displayName: "Haiku 4.5 (0.4x)", tier: .fast, tagline: "Fastest Anthropic", provider: "claude"), + ] + ), + WorkModelProvider( + key: "openai", + displayName: "OpenAI (Droid)", + models: [ + WorkModelOption(id: "gpt-5.4", displayName: "GPT-5.4", tier: .flagship, tagline: "OpenAI flagship", provider: "codex"), + WorkModelOption(id: "gpt-5.4-fast", displayName: "GPT-5.4 Fast", tier: .flagship, tagline: "Faster GPT-5.4", provider: "codex"), + WorkModelOption(id: "gpt-5.4-mini", displayName: "GPT-5.4 Mini", tier: .fast, tagline: "Cheaper general-purpose", provider: "codex"), + WorkModelOption(id: "gpt-5.3-codex", displayName: "GPT-5.3-Codex (0.7x)", tier: .balanced, tagline: "Tuned for code edits", provider: "codex"), + WorkModelOption(id: "gpt-5.3-codex-fast", displayName: "GPT-5.3-Codex Fast", tier: .balanced, tagline: "Faster Codex variant", provider: "codex"), + WorkModelOption(id: "gpt-5.2", displayName: "GPT-5.2 (0.7x)", tier: .balanced, tagline: "Prior-gen GPT-5", provider: "codex"), + WorkModelOption(id: "gpt-5.2-codex", displayName: "GPT-5.2-Codex (0.7x)", tier: .balanced, tagline: "Prior-gen Codex", provider: "codex"), + WorkModelOption(id: "gpt-5.1", displayName: "GPT-5.1 (0.5x)", tier: .balanced, tagline: "Older GPT-5", provider: "codex"), + WorkModelOption(id: "gpt-5.1-codex", displayName: "GPT-5.1-Codex (0.5x)", tier: .balanced, tagline: "Older Codex", provider: "codex"), + WorkModelOption(id: "gpt-5.1-codex-max", displayName: "GPT-5.1-Codex-Max (0.5x)", tier: .flagship, tagline: "Long-running Codex turns", provider: "codex"), + ] + ), + WorkModelProvider( + key: "google", + displayName: "Google (Droid)", + models: [ + WorkModelOption(id: "gemini-3-pro-preview", displayName: "Gemini 3 Pro", tier: .flagship, tagline: "Google flagship", provider: "google"), + WorkModelOption(id: "gemini-3.1-pro-preview", displayName: "Gemini 3.1 Pro (0.8x)", tier: .flagship, tagline: "Updated Gemini 3", provider: "google"), + WorkModelOption(id: "gemini-3-flash-preview", displayName: "Gemini 3 Flash (0.2x)", tier: .fast, tagline: "Fast Gemini", provider: "google"), + ] + ), + WorkModelProvider( + key: "factory", + displayName: "Droid Core", + models: [ + WorkModelOption(id: "glm-5.1", displayName: "Droid Core (GLM-5.1)", tier: .balanced, tagline: "Latest Droid Core", provider: "factory"), + WorkModelOption(id: "glm-5", displayName: "Droid Core (GLM-5) (0.4x)", tier: .balanced, tagline: "GLM-5 backbone", provider: "factory"), + WorkModelOption(id: "glm-4.7", displayName: "Droid Core (GLM-4.7) (0.25x)", tier: .fast, tagline: "Lightweight GLM", provider: "factory"), + WorkModelOption(id: "kimi-k2.5", displayName: "Droid Core (Kimi K2.5) (0.25x)", tier: .fast, tagline: "Kimi backbone", provider: "factory"), + WorkModelOption(id: "minimax-m2.5", displayName: "Droid Core (MiniMax M2.5) (0.12x)", tier: .fast, tagline: "MiniMax backbone", provider: "factory"), + ] + ) + ] + )) + groups.append(WorkModelCatalogGroup( key: "opencode", displayName: "OpenCode", @@ -324,6 +379,7 @@ private func workProviderDisplayName( case "ollama": return "Ollama" case "together": return "Together" case "cursor": return "Cursor" + case "factory": return "Droid Core" default: return providerKey.capitalized } } @@ -361,6 +417,20 @@ private func workModelProviderKey(for model: AgentChatModelInfo, topLevelProvide return "anthropic" case "codex": return "openai" + case "droid": + if normalizedFamily == "factory" || normalizedId.hasPrefix("glm-") || normalizedId.hasPrefix("kimi-") || normalizedId.hasPrefix("minimax-") || normalizedId.hasPrefix("custom:") { + return "factory" + } + if normalizedFamily == "anthropic" || normalizedId.contains("claude") || normalizedId.contains("sonnet") || normalizedId.contains("opus") || normalizedId.contains("haiku") { + return "anthropic" + } + if normalizedFamily == "openai" || normalizedId.contains("gpt") || normalizedId.contains("codex") { + return "openai" + } + if normalizedFamily == "google" || normalizedId.contains("gemini") { + return "google" + } + return normalizedFamily.isEmpty ? "factory" : normalizedFamily case "opencode": if normalizedId.hasPrefix("opencode/") { let parts = normalizedId.split(separator: "/", omittingEmptySubsequences: true) @@ -538,6 +608,9 @@ func workModelCatalogGroupKey(for currentModelId: String, currentProvider: Strin if modelId.hasPrefix("opencode/") || provider == "opencode" { return "opencode" } + if provider == "droid" || modelId.hasPrefix("droid/") { + return "droid" + } if provider == "cursor" || modelId.contains("cursor/") || modelId.contains("cursor-") || modelId.contains("composer") { return "cursor" } diff --git a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift index 4ac45df34..03e37f4b2 100644 --- a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift @@ -33,7 +33,6 @@ struct WorkModelPickerSheet: View { @State private var activeGroup: String = "" @State private var activeProvider: String = "" @State private var searchText: String = "" - @State private var reasoningEffort: String = "" @State private var liveCatalog: [WorkModelCatalogGroup]? @State private var isLoadingCatalog = false @State private var usingCuratedFallback = false @@ -119,7 +118,6 @@ struct WorkModelPickerSheet: View { } else if isSearching { searchList } else { - reasoningRow groupTabStrip providerBadgeRow Divider().overlay(ADEColor.border.opacity(0.18)) @@ -145,9 +143,6 @@ struct WorkModelPickerSheet: View { .presentationDragIndicator(.visible) .onAppear { syncSelectionStateToCatalog() - if reasoningEffort.isEmpty { - reasoningEffort = currentReasoningEffort - } } .onChange(of: catalogIdentity) { _, _ in syncSelectionStateToCatalog() @@ -184,7 +179,7 @@ struct WorkModelPickerSheet: View { usingCuratedFallback = false liveCatalog = nil - let providers = ["claude", "codex", "cursor", "opencode"] + let providers = ["claude", "codex", "cursor", "droid", "opencode"] var availableModelsByProvider: [String: [AgentChatModelInfo]] = [:] var successCount = 0 @@ -259,15 +254,6 @@ struct WorkModelPickerSheet: View { return workModelCatalogGroupKey(for: model.id, currentProvider: currentProvider) } - private func reasoningEffortForSelection(_ model: WorkModelOption) -> String? { - let supportedTiers = supportedReasoningTiers(for: model) - if supportedTiers.isEmpty { return nil } - let trimmed = reasoningEffort.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return nil } - let normalized = trimmed.lowercased() - return supportedTiers.contains(where: { $0.lowercased() == normalized }) ? normalized : nil - } - private func supportedReasoningTiers(for model: WorkModelOption) -> [String] { if let tiers = ADEColor.reasoningTiers(for: model.id), !tiers.isEmpty { return tiers @@ -282,29 +268,11 @@ struct WorkModelPickerSheet: View { return ["low", "medium", "high"] } if lower.contains("gpt-5") { - return ["low", "medium", "high", "xhigh"] + return lower.contains("mini") ? ["medium", "high"] : ["low", "medium", "high", "xhigh"] } return [] } - private func reasoningLevelsForVisibleContext() -> [(String, String)] { - let visibleModels = filteredModels - let preferredOrder = ["low", "medium", "high", "xhigh", "max"] - var tierSet = Set() - for model in visibleModels { - for tier in supportedReasoningTiers(for: model) { - tierSet.insert(tier.lowercased()) - } - } - let current = reasoningEffort.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if !current.isEmpty { - tierSet.insert(current) - } - let orderedTiers = preferredOrder.filter { tierSet.contains($0) } - + tierSet.filter { !preferredOrder.contains($0) }.sorted() - return [("", "Off")] + orderedTiers.map { ($0, reasoningLabel(for: $0)) } - } - private func reasoningLabel(for tier: String) -> String { switch tier.lowercased() { case "xhigh": return "XHigh" @@ -313,6 +281,7 @@ struct WorkModelPickerSheet: View { } } + @ViewBuilder private var loadingState: some View { VStack(spacing: 12) { @@ -383,62 +352,6 @@ struct WorkModelPickerSheet: View { .padding(.bottom, 10) } - /// Reasoning-effort segmented control, displayed above the group/provider - /// tabs. Users pick the effort level here and it is applied to any - /// reasoning-capable model they subsequently tap in the list; for models - /// that don't accept the chosen tier the value is ignored at the call site. - @ViewBuilder - private var reasoningRow: some View { - let levels = reasoningLevelsForVisibleContext() - HStack(spacing: 8) { - Text("REASONING") - .font(.caption2.weight(.bold)) - .tracking(0.4) - .foregroundStyle(ADEColor.textMuted) - HStack(spacing: 4) { - ForEach(levels, id: \.0) { entry in - let (id, label) = entry - let isActive = id.lowercased() == reasoningEffort.lowercased() - Button { - withAnimation(.easeInOut(duration: 0.14)) { - reasoningEffort = id - } - } label: { - Text(label) - .font(.caption2.weight(.semibold)) - .foregroundStyle(isActive ? ADEColor.textPrimary : ADEColor.textSecondary.opacity(0.7)) - .lineLimit(1) - .minimumScaleFactor(0.75) - .frame(maxWidth: .infinity) - .padding(.vertical, 6) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(isActive ? ADEColor.accent.opacity(0.18) : Color.clear) - ) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(isActive ? ADEColor.accent.opacity(0.35) : Color.clear, lineWidth: 0.6) - ) - } - .buttonStyle(.plain) - .accessibilityAddTraits(isActive ? .isSelected : []) - .accessibilityLabel("Reasoning effort \(label)") - } - } - .padding(3) - .background( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .fill(ADEColor.surfaceBackground.opacity(0.3)) - ) - .overlay( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .stroke(ADEColor.border.opacity(0.12), lineWidth: 0.5) - ) - } - .padding(.horizontal, 16) - .padding(.bottom, 10) - } - @ViewBuilder private var groupTabStrip: some View { HStack(spacing: 4) { @@ -502,7 +415,8 @@ struct WorkModelPickerSheet: View { @ViewBuilder private var providerBadgeRow: some View { - if let block = activeGroupBlock, block.providers.count > 1 || block.key == "opencode" { + if let block = activeGroupBlock, !singleFamilyGroup(block.key), + block.providers.count > 1 || block.key == "opencode" { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(block.providers) { prov in @@ -512,16 +426,16 @@ struct WorkModelPickerSheet: View { .padding(.horizontal, 16) .padding(.bottom, 10) } - } else if let block = activeGroupBlock, let only = block.providers.first { - HStack(spacing: 8) { - providerBadge(only) - Spacer(minLength: 0) - } - .padding(.horizontal, 16) - .padding(.bottom, 10) } } + /// Groups whose entries all come from a single brand (Claude, Codex) don't + /// need a redundant filter row beneath the group tab — every model is from + /// that brand by definition. + private func singleFamilyGroup(_ key: String) -> Bool { + key == "claude" || key == "codex" + } + @ViewBuilder private func providerBadge(_ prov: WorkModelProvider) -> some View { let isActive = activeProviderBlock?.key == prov.key @@ -639,24 +553,43 @@ struct WorkModelPickerSheet: View { @ViewBuilder private func modelButton(model: WorkModelOption) -> some View { - Button { - let reasoningToSend = reasoningEffortForSelection(model) - let reasoningChanged = (reasoningToSend ?? "") != currentReasoningEffort - if model.id == currentModelId && !reasoningChanged { - dismiss() - } else { - onSelect(model, reasoningToSend, runtimeProvider(for: model)) + let tiers = supportedReasoningTiers(for: model) + let isSelected = model.id == currentModelId + VStack(alignment: .leading, spacing: 0) { + // Card header: name, tier, tagline. For models without reasoning the whole + // card is a tap target — picking commits the model with no effort. + Button { + if !tiers.isEmpty { return } + commit(model: model, effort: nil) + } label: { + modelHeaderRow(model: model, isSelected: isSelected) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isBusy || !tiers.isEmpty) + .allowsHitTesting(tiers.isEmpty) + + if !tiers.isEmpty { + reasoningPills(model: model, tiers: tiers) + .padding(.top, 2) } - } label: { - modelRow(model: model) } - .buttonStyle(.plain) - .disabled(isBusy) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(isSelected ? ADEColor.accent.opacity(0.08) : ADEColor.surfaceBackground.opacity(0.55)) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(isSelected ? ADEColor.accent.opacity(0.35) : ADEColor.border.opacity(0.14), lineWidth: isSelected ? 1 : 0.5) + ) + .contentShape(Rectangle()) } @ViewBuilder - private func modelRow(model: WorkModelOption) -> some View { - let isSelected = model.id == currentModelId + private func modelHeaderRow(model: WorkModelOption, isSelected: Bool) -> some View { HStack(alignment: .center, spacing: 12) { WorkProviderLogo(provider: model.provider, size: 30) @@ -707,17 +640,66 @@ struct WorkModelPickerSheet: View { } } } - .padding(.horizontal, 14) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(isSelected ? ADEColor.accent.opacity(0.08) : ADEColor.surfaceBackground.opacity(0.55)) - ) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(isSelected ? ADEColor.accent.opacity(0.35) : ADEColor.border.opacity(0.14), lineWidth: isSelected ? 1 : 0.5) - ) - .contentShape(Rectangle()) .accessibilityLabel("\(model.displayName), \(workModelTierLabel(model.tier)). \(model.tagline)\(isSelected ? ". Currently selected." : "")") } + + /// Reasoning level pill row shown inline under a model card. Tapping a pill + /// commits both the model selection and the chosen effort. Highlights the + /// currently-active effort for the active model so users see what's set. + @ViewBuilder + private func reasoningPills(model: WorkModelOption, tiers: [String]) -> some View { + let isActiveModel = model.id == currentModelId + let normalizedCurrent = currentReasoningEffort + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + HStack(spacing: 6) { + Text("REASONING") + .font(.system(size: 9, weight: .bold)) + .tracking(0.4) + .foregroundStyle(ADEColor.textMuted) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 5) { + ForEach(tiers, id: \.self) { tier in + let normalized = tier.lowercased() + let isActive = isActiveModel && normalized == normalizedCurrent + Button { + commit(model: model, effort: normalized) + } label: { + Text(reasoningLabel(for: tier)) + .font(.caption2.weight(.semibold)) + .foregroundStyle(isActive ? Color.white : ADEColor.textSecondary) + .lineLimit(1) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background( + Capsule(style: .continuous) + .fill(isActive ? ADEColor.accent : ADEColor.surfaceBackground.opacity(0.6)) + ) + .overlay( + Capsule(style: .continuous) + .stroke(isActive ? ADEColor.accent : ADEColor.border.opacity(0.18), lineWidth: 0.6) + ) + } + .buttonStyle(.plain) + .disabled(isBusy) + .accessibilityLabel("\(model.displayName) · reasoning \(reasoningLabel(for: tier))") + .accessibilityAddTraits(isActive ? .isSelected : []) + } + } + } + Spacer(minLength: 0) + } + .padding(.top, 8) + } + + private func commit(model: WorkModelOption, effort: String?) { + let normalizedEffort = effort?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let nextEffort: String? = normalizedEffort.isEmpty ? nil : normalizedEffort + let effortChanged = (nextEffort ?? "") != currentReasoningEffort + if model.id == currentModelId && !effortChanged { + dismiss() + return + } + onSelect(model, nextEffort, runtimeProvider(for: model)) + } } diff --git a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift index 269bfbc73..80463bc46 100644 --- a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift @@ -684,8 +684,7 @@ func buildWorkToolCards( if suppressedPendingItemIds.contains(itemId) { continue } - if isQuestionInputToolName(tool), - pendingWorkQuestionFromAskUserToolCall(argsText: argsText, itemId: itemId) != nil { + if isQuestionInputToolName(tool) { continue } if cards[itemId] == nil { @@ -701,6 +700,9 @@ func buildWorkToolCards( resultText: cards[itemId]?.resultText ) case .toolResult(let tool, let resultText, let itemId, _, _, let status): + if isQuestionInputToolName(tool) { + continue + } let existing = cards[itemId] if existing == nil { orderedIds.append(itemId) diff --git a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift index 4e357d668..4cd70625e 100644 --- a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift @@ -48,6 +48,7 @@ func providerLabel(_ provider: String) -> String { case "claude": return "Claude" case "opencode": return "OpenCode" case "cursor": return "Cursor" + case "droid": return "Droid" default: return provider.capitalized } } @@ -117,6 +118,12 @@ func providerTint(_ provider: String?) -> Color { return .teal case "cursor": return .indigo + case "droid": + return .gray + case "google": + return .yellow + case "factory": + return .gray default: return ADEColor.accent } @@ -138,6 +145,9 @@ func providerFamilyKey(_ provider: String) -> String { if raw == "cursor" || raw.hasPrefix("cursor") { return "cursor" } + if raw == "droid" || raw == "factory" || raw.hasPrefix("droid") { + return "droid" + } return raw } From 6bab7ba3ac420477d0dfba73856b925dcae40aaf Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:31:49 -0400 Subject: [PATCH 2/5] =?UTF-8?q?ios(work):=20address=20Greptile=20P2=20nits?= =?UTF-8?q?=20=E2=80=94=20tappable=20reasoning=20header=20+=20family-first?= =?UTF-8?q?=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Model picker: tapping the header of a reasoning-capable card now commits the model with `effort: nil` (server default) instead of requiring a tier pill. Users who don't care about effort aren't forced to pick one. - Droid provider routing: prefer `family` over ID substring matches so an Anthropic model whose ID happens to contain "codex" still resolves to the anthropic bucket; substring fallbacks only run when family is empty. --- apps/ios/ADE/Views/Work/WorkModelCatalog.swift | 18 ++++++++++++++---- .../ADE/Views/Work/WorkModelPickerSheet.swift | 9 ++++----- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift index af5f68413..3f8e79e15 100644 --- a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift +++ b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift @@ -418,16 +418,26 @@ private func workModelProviderKey(for model: AgentChatModelInfo, topLevelProvide case "codex": return "openai" case "droid": - if normalizedFamily == "factory" || normalizedId.hasPrefix("glm-") || normalizedId.hasPrefix("kimi-") || normalizedId.hasPrefix("minimax-") || normalizedId.hasPrefix("custom:") { + // Prefer the explicit `family` field over ID substring matches so an + // Anthropic model whose ID happens to contain "codex" (or similar) is + // still routed to the right bucket. + switch normalizedFamily { + case "anthropic": return "anthropic" + case "openai": return "openai" + case "google": return "google" + case "factory": return "factory" + default: break + } + if normalizedId.hasPrefix("glm-") || normalizedId.hasPrefix("kimi-") || normalizedId.hasPrefix("minimax-") || normalizedId.hasPrefix("custom:") { return "factory" } - if normalizedFamily == "anthropic" || normalizedId.contains("claude") || normalizedId.contains("sonnet") || normalizedId.contains("opus") || normalizedId.contains("haiku") { + if normalizedId.contains("claude") || normalizedId.contains("sonnet") || normalizedId.contains("opus") || normalizedId.contains("haiku") { return "anthropic" } - if normalizedFamily == "openai" || normalizedId.contains("gpt") || normalizedId.contains("codex") { + if normalizedId.contains("gpt") || normalizedId.contains("codex") { return "openai" } - if normalizedFamily == "google" || normalizedId.contains("gemini") { + if normalizedId.contains("gemini") { return "google" } return normalizedFamily.isEmpty ? "factory" : normalizedFamily diff --git a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift index 03e37f4b2..ef18330c1 100644 --- a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift @@ -556,10 +556,10 @@ struct WorkModelPickerSheet: View { let tiers = supportedReasoningTiers(for: model) let isSelected = model.id == currentModelId VStack(alignment: .leading, spacing: 0) { - // Card header: name, tier, tagline. For models without reasoning the whole - // card is a tap target — picking commits the model with no effort. + // Card header is always tappable: tapping the header commits the model + // with `effort: nil` (server default) even for reasoning-capable models, + // so users who don't care about a specific tier aren't forced to pick one. Button { - if !tiers.isEmpty { return } commit(model: model, effort: nil) } label: { modelHeaderRow(model: model, isSelected: isSelected) @@ -567,8 +567,7 @@ struct WorkModelPickerSheet: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .disabled(isBusy || !tiers.isEmpty) - .allowsHitTesting(tiers.isEmpty) + .disabled(isBusy) if !tiers.isEmpty { reasoningPills(model: model, tiers: tiers) From 34deb04f87abb0ac0ed7bd7f82bdef2263db5cf7 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:55:13 -0400 Subject: [PATCH 3/5] fix(chat): clear staged steer chip on dispatch + allow attaching mid-turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deriveRuntimeState resolves a steer on any non-queued user_message with the same steerId, so the chip clears after Send-Now (inline dispatch) and other delivery paths. cancelSteer now emits the cancelled notice even when the queue is empty server-side, so the delete button clears stale chips after a dispatch race. - AgentChatComposer no longer short-circuits image paste / file attach when turnActive — the steer endpoint already accepts attachments, the guard just blocked the legitimate steer-with-image case. - shipLane skill + playbook: clarify that ScheduleWakeup and run_in_background task notifications do not autonomously wake an agent inside a Claude Agent SDK v2 chat (e.g. ADE Work chats); SDKSession only advances on a fresh session.send(...). Either poll synchronously inside the active turn or stop and ask the user to re-invoke /shipLane. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/commands/shipLane.md | 24 ++++++++++++++++--- .../main/services/chat/agentChatService.ts | 9 ++++--- .../components/chat/AgentChatComposer.tsx | 2 -- .../components/chat/AgentChatPane.tsx | 14 ++++++++--- docs/playbooks/ship-lane.md | 9 ++++++- 5 files changed, 46 insertions(+), 12 deletions(-) diff --git a/.claude/commands/shipLane.md b/.claude/commands/shipLane.md index 260ab0fd2..c9b7af359 100644 --- a/.claude/commands/shipLane.md +++ b/.claude/commands/shipLane.md @@ -78,7 +78,11 @@ If `TeamCreate` is genuinely not in scope for this session: ## Scheduling wake-ups -This wrapper is Claude Code-specific. Use `ScheduleWakeup` at the end of each iteration (playbook §5.3) with the same command re-invocation as the `prompt`: +The right primitive depends on the harness this command is running under. Pick one and stick with it for the whole run. + +### Claude Code CLI (interactive terminal) + +`ScheduleWakeup` is a CLI-native primitive — the CLI scheduler re-invokes the command later without the user typing. Use it at the end of each iteration (playbook §5.3): ``` ScheduleWakeup({ @@ -90,9 +94,19 @@ ScheduleWakeup({ Pass `$ARGUMENTS` through so a PR-number argument is preserved across wake-ups. -Waiting must be token-idle. After scheduling a wake-up, stop the active agent turn completely and let the scheduler re-invoke this command later. Do not keep agents alive in polling loops, do not run `--watch` commands, and do not ask sub-agents to sleep while holding context. Poll only once per scheduled invocation, then either fix, exit, or schedule the next wake. +### Claude Agent SDK chat (e.g. ADE Work chats) + +When this command runs inside a `claude-agent-sdk` v2 chat session (`unstable_v2_createSession` / `SDKSession.send` / `stream`), the SDK has **no scheduled-wakeup primitive**. `ScheduleWakeup` accepts the call but never re-invokes — the session only advances when the host calls `session.send(...)`, which only happens on a fresh user message. `Bash run_in_background: true` task notifications are queued in the SDK message stream and only flushed on the next user message; they do **not** start an autonomous turn either. + +So in an SDK-driven chat, do not pretend you can self-resume: +- Do all polling synchronously inside the current turn (`until ! ... ; do sleep N; done` in a foreground bash). One bounded sleep + one bounded poll per turn — no `run_in_background` if you actually need the result before turn end. +- Or stop the turn cleanly and tell the user to re-ping you when they want the next iteration. Write the updated state file with `status: running` so the next `/shipLane $ARGUMENTS` invocation continues from the right phase. -Other agent CLIs have their own sleep/resume mechanisms. If a Claude Code scheduler is not available, follow the playbook's generic guidance instead of copying `ScheduleWakeup` literally. For Codex-style terminal work, the recommended fallback is a shell sleep that does not involve the model, followed by one one-shot status command, for example: +Do not start a `run_in_background` poller and claim it will wake you — it won't. Do not "probe" the wake mechanism by starting a 30s background sleep and waiting silently; nothing will arrive. + +### Other agent CLIs + +If neither a CLI scheduler nor an SDK wake is available, fall back to a shell sleep that does not involve the model, followed by one one-shot status command, for example: ```bash sleep 720 && gh pr checks 185 && gh run list --branch ade/cli-prs-fixes-747d7096 --limit 5 @@ -100,6 +114,10 @@ sleep 720 && gh pr checks 185 && gh run list --branch ade/cli-prs-fixes-747d7096 That shell process can wait without spending model tokens; the agent should only resume reasoning after the command produces output. +### Common rules (all harnesses) + +Waiting must be token-idle. After scheduling a wake-up (or running a foreground sleep), do not keep agents alive in polling loops, do not run `--watch` commands, and do not ask sub-agents to sleep while holding context. Poll only once per scheduled invocation, then either fix, exit, or schedule the next wake. + Do NOT schedule a wake if `status` is `done-clean`, `done-max`, or `blocked` — print the summary and stop. --- diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index cbb45a76a..dc3541ef1 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -14052,9 +14052,12 @@ export function createAgentChatService(args: { const queue = runtime.pendingSteers; const idx = queue.findIndex((s) => s.steerId === steerId); - if (idx === -1) return; - - queue.splice(idx, 1); + if (idx !== -1) { + queue.splice(idx, 1); + } + // Always emit the cancelled notice — even when the steer already left the + // server-side queue (e.g. dispatched inline before this call landed) — so + // the client display clears the staged chip on the delete-button path. emitChatEvent(managed, { type: "system_notice", noticeKind: "info", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index fcea7481d..adc434032 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -795,7 +795,6 @@ export function AgentChatComposer({ const addFileAttachments = async (files: FileList | null | undefined) => { if (!files?.length) return; - if (turnActive) return; if (parallelChatMode && attachments.length >= PARALLEL_CHAT_MAX_ATTACHMENTS) return; if (fileAddInProgressRef.current) return; fileAddInProgressRef.current = true; @@ -847,7 +846,6 @@ export function AgentChatComposer({ const addNativeClipboardImageAttachment = async () => { if (!canAttach) return; - if (turnActive) return; if (parallelChatMode && attachments.length >= PARALLEL_CHAT_MAX_ATTACHMENTS) return; if (fileAddInProgressRef.current) return; fileAddInProgressRef.current = true; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 7e42fd695..f6219df6d 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -473,9 +473,17 @@ export function deriveRuntimeState(events: AgentChatEventEnvelope[]): { turnActive = event.turnStatus === "started"; } else if (event.type === "done") { turnActive = false; - } else if (event.type === "user_message" && event.steerId && event.deliveryState === "queued") { - if (!resolvedSteerIds.has(event.steerId)) { - steerMap.set(event.steerId, { steerId: event.steerId, text: event.text }); + } else if (event.type === "user_message" && event.steerId) { + if (event.deliveryState === "queued") { + if (!resolvedSteerIds.has(event.steerId)) { + steerMap.set(event.steerId, { steerId: event.steerId, text: event.text }); + } + } else { + // "inline" / "delivered" / "failed" — the steer left the queue, so + // clear it from the display. Without this the chip stays staged after + // the user clicks "Send Now" or after a queued steer is delivered. + steerMap.delete(event.steerId); + resolvedSteerIds.add(event.steerId); } } else if (event.type === "system_notice" && event.steerId) { // "cancelled" or "Delivering" notices resolve the steer diff --git a/docs/playbooks/ship-lane.md b/docs/playbooks/ship-lane.md index bf5246289..0c78ac587 100644 --- a/docs/playbooks/ship-lane.md +++ b/docs/playbooks/ship-lane.md @@ -620,7 +620,14 @@ These are separate comments (not a single body) so each bot handler parses its o ### 5.3 Self-pace the next wake -Agent-CLI-agnostic guidance (Claude Code maps this to `ScheduleWakeup`; Codex in a terminal should usually use shell `sleep ... && `; other CLIs map it to their native sleep/resume): +Agent-CLI-agnostic guidance. Pick the right primitive for the harness: + +- **Claude Code CLI** maps this to `ScheduleWakeup` (CLI scheduler re-invokes the command later). +- **Claude Agent SDK v2** (e.g. ADE Work chats using `unstable_v2_createSession`) has **no scheduled-wakeup primitive**. `SDKSession` only advances when the host calls `send(...)`, which fires on a fresh user message. `run_in_background` bash `task_notification` events are queued in the SDK message stream until the next user turn — they will not start an autonomous turn. In an SDK chat, either poll synchronously inside the current turn (foreground bash with one bounded `until ... ; do sleep N; done`) or stop with `status: running` written to the state file and ask the user to re-invoke the command. +- **Codex in a terminal** should usually use shell `sleep ... && `. +- **Other CLIs** map this to their native sleep/resume. + +Cadence (applies once you've picked a primitive): - Just pushed, neither CI nor review has started yet → **270 seconds** (stay in prompt cache; next poll only confirms things have kicked off) - CI running OR review bots still pending → **720 seconds** (12 min). This is the spec floor: CI shards typically finish in 3–5 min, Greptile in 5–10 min, Copilot within a few minutes of its ping. 12 min is what lets **both** land before the next poll. From 8253ca2da32220e5651e7bfcc244b7230949e50b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 30 Apr 2026 00:29:36 -0400 Subject: [PATCH 4/5] =?UTF-8?q?ship:=20iter=203=20=E2=80=94=20runtime-envi?= =?UTF-8?q?ronment=20banner=20+=20rebase=20onto=20main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Runtime Environment" block to the chat session system prompt that names the host (ADE Work chat) and the backing tool (Claude Agent SDK v2, Codex CLI, Cursor ACP, Droid ACP, OpenCode), plus the wake-up semantics for that runtime. This fixes the original confusion that produced the chaotic ship-lane chat on April 30: the Claude Agent SDK v2 chat uses the `claude_code` preset verbatim, so the model believed it was inside the Claude Code CLI and called ScheduleWakeup expecting an autonomous re-invocation. Now the append explicitly says "you are NOT in the CLI; ScheduleWakeup is not honored; Bash run_in_background task notifications are queued until the next user message". - systemPrompt.ts: optional runtime parameter on buildCodingAgentSystemPrompt + describeRuntime() helper covering all five ADE chat runtimes. - agentChatService.ts: pass runtime: "codex-cli" through Codex's developer instructions; prepend the SDK-v2 runtime banner to the Claude V2 systemPrompt append array (above ## ADE Workspace). - systemPrompt.test.ts: 6 new tests pinning the banner content per runtime, and asserting the block is omitted when runtime is not passed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/commands/shipLane.md | 15 ++++++ .../services/ai/tools/systemPrompt.test.ts | 37 +++++++++++++ .../main/services/ai/tools/systemPrompt.ts | 53 +++++++++++++++++++ .../main/services/chat/agentChatService.ts | 6 +++ 4 files changed, 111 insertions(+) diff --git a/.claude/commands/shipLane.md b/.claude/commands/shipLane.md index c9b7af359..7688a5077 100644 --- a/.claude/commands/shipLane.md +++ b/.claude/commands/shipLane.md @@ -134,6 +134,21 @@ If any rail fails, exit `blocked` with a clear reason in the state file and stop --- +## Worktree path discipline (CRITICAL — every iteration) + +ADE invokes `/shipLane` from a worktree like `/Users//Projects//.ade/worktrees//`. The project root (`/Users//Projects//`) **also** exists as a separate git checkout, usually on `main`, with the same files at the same relative paths. + +Every `Edit`/`Write` you do MUST target the worktree-prefixed absolute path. Editing the project-root copy lands changes on the wrong branch, leaves the worktree clean, and the iteration's commit silently picks up nothing. + +How to keep yourself honest: + +- Anchor every edit on the env's working directory: if `pwd` shows `.ade/worktrees//`, every Edit `file_path` must start with `.../.ade/worktrees//`. If a path begins anywhere else under the project root, that's the wrong target. +- `Read` tool result paths are not authoritative. If a Read resolved to the project-root copy (because of an earlier `cd` to project root for a `gh` or `git fetch` call), re-resolve to the worktree before editing. +- After any sequence of edits and before commit, run `git status` from the worktree. If it's empty but you "just edited" several files, you wrote to the wrong tree — recover via `cd && git diff > /tmp/x.patch && git checkout -- `, then `git apply /tmp/x.patch` from the worktree. +- Stay in the worktree directory. Use `git -C ...` for one-off project-root reads instead of `cd `, so subsequent edits don't accidentally use cached project-root paths. + +--- + ## ADE CLI discovery (Claude Code specific) This wrapper consumes `ade` everywhere the playbook says to use it. If `command -v ade` returns nothing, do NOT immediately fall back to `gh`. Try the local build first: diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts index c5ad9b1e7..64792ca10 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts @@ -71,6 +71,43 @@ describe("buildCodingAgentSystemPrompt", () => { expect(result).toContain("Use the available tools deliberately"); }); + describe("runtime environment banner", () => { + it("omits the runtime block when runtime is not provided", () => { + const result = buildCodingAgentSystemPrompt({ cwd: "/x" }); + expect(result).not.toContain("## Runtime Environment"); + }); + + it("describes the Codex CLI runtime", () => { + const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "codex-cli" }); + expect(result).toContain("## Runtime Environment"); + expect(result).toContain("Codex CLI"); + expect(result).toContain("No autonomous wake from ADE"); + }); + + it("describes the Claude Agent SDK v2 runtime with wake-up caveat", () => { + const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "claude-agent-sdk-v2" }); + expect(result).toContain("## Runtime Environment"); + expect(result).toContain("Claude Agent SDK v2"); + expect(result).toContain("ScheduleWakeup"); + expect(result).toContain("not available"); + }); + + it("describes the Cursor ACP runtime", () => { + const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "cursor-acp" }); + expect(result).toContain("Cursor agent via ACP"); + }); + + it("describes the Droid ACP runtime", () => { + const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "droid-acp" }); + expect(result).toContain("Factory Droid agent via ACP"); + }); + + it("describes the OpenCode runtime", () => { + const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "opencode" }); + expect(result).toContain("OpenCode session"); + }); + }); + it("includes interactive question guidance by default", () => { const result = buildCodingAgentSystemPrompt({ cwd: "/x" }); expect(result).toContain("ask one concise question"); diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts index eb9cc04c4..681862c48 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts @@ -3,6 +3,50 @@ import { ADE_CLI_AGENT_GUIDANCE } from "../../../../shared/adeCliGuidance"; type HarnessMode = "chat" | "coding" | "planning"; type HarnessPermissionMode = "plan" | "edit" | "full-auto"; +/** + * Identifier for the runtime that's actually executing the model. Used to tell + * the agent which harness it's in so it doesn't assume CLI-only primitives + * (like ScheduleWakeup) are available, and so it knows whether autonomous + * wake-ups are possible. + */ +export type AdeRuntimeKind = + | "claude-agent-sdk-v2" + | "codex-cli" + | "cursor-acp" + | "droid-acp" + | "opencode"; + +function describeRuntime(runtime: AdeRuntimeKind): string[] { + switch (runtime) { + case "claude-agent-sdk-v2": + return [ + "**Runtime:** ADE Work chat hosted on the Claude Agent SDK v2 (`unstable_v2_createSession` / `SDKSession`).", + "**Wake-up semantics:** The session only advances when the host calls `session.send(...)`, which fires on a fresh user message. There is no autonomous wake. `ScheduleWakeup` is **not available** in this harness — the model may know the tool name from training, but the host does not honor it. `Bash run_in_background: true` task notifications are queued in the SDK message stream and only flushed on the next user turn; they do not start an autonomous turn either.", + "**To wait:** Either poll synchronously inside the active turn (foreground bash with one bounded `until ... ; do sleep N; done`) or stop the turn cleanly and ask the user to re-ping when ready. Do not run a background poller and claim it will wake you — it will not.", + ]; + case "codex-cli": + return [ + "**Runtime:** ADE Work chat wrapping the Codex CLI as a subprocess. Your turns are driven through the Codex agent loop, but the orchestration host is ADE — slash commands, attachments, and lane scoping come from ADE.", + "**Wake-up semantics:** No autonomous wake from ADE. If you need to wait, prefer `sleep ... && ` so the shell holds the wait without burning model tokens, then resume reasoning when the command produces output.", + ]; + case "cursor-acp": + return [ + "**Runtime:** ADE Work chat wrapping the Cursor agent via ACP (Agent Client Protocol).", + "**Wake-up semantics:** Each turn is a discrete ACP `prompt` request. There is no autonomous wake; if you need to wait, use a shell `sleep` and surface results in the next user turn.", + ]; + case "droid-acp": + return [ + "**Runtime:** ADE Work chat wrapping the Factory Droid agent via ACP.", + "**Wake-up semantics:** Each turn is a discrete ACP `prompt` request. There is no autonomous wake; if you need to wait, use a shell `sleep` and surface results in the next user turn.", + ]; + case "opencode": + return [ + "**Runtime:** ADE Work chat wrapping an OpenCode session.", + "**Wake-up semantics:** Turns are driven by ADE through the OpenCode HTTP session. There is no autonomous wake; use a shell `sleep` for waits.", + ]; + } +} + function describePermissionMode(mode: HarnessPermissionMode): string { switch (mode) { case "plan": @@ -31,11 +75,13 @@ export function buildCodingAgentSystemPrompt(args: { permissionMode?: HarnessPermissionMode; toolNames?: string[]; interactive?: boolean; + runtime?: AdeRuntimeKind; }): string { const mode = args.mode ?? "coding"; const permissionMode = args.permissionMode ?? "edit"; const toolNames = [...new Set((args.toolNames ?? []).filter((entry) => entry.trim().length > 0))]; const interactive = args.interactive !== false; + const runtime = args.runtime; const hasMemoryTools = toolNames.some((name) => name === "memorySearch" || name === "memoryAdd" @@ -71,6 +117,13 @@ export function buildCodingAgentSystemPrompt(args: { return [ `You are ADE's software engineering agent working in ${args.cwd}.`, "This session is bound to that worktree. Read, edit, and run commands only inside this path unless ADE explicitly relaunches you in a different lane.", + ...(runtime + ? [ + "", + "## Runtime Environment", + ...describeRuntime(runtime), + ] + : []), "", "## Mission", describeMode(mode), diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index dc3541ef1..db837a8bb 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -2637,6 +2637,7 @@ function buildCodexDeveloperInstructions(args: { mode: promptMode, permissionMode: toHarnessPermissionMode(args.session.permissionMode), interactive: true, + runtime: "codex-cli", }); } @@ -10539,6 +10540,11 @@ export function createAgentChatService(args: { type: "preset", preset: "claude_code", append: [ + "## Runtime Environment", + "**Runtime:** ADE Work chat hosted on the Claude Agent SDK v2 (`unstable_v2_createSession` / `SDKSession`). The `claude_code` preset above is the same system prompt the Claude Code CLI uses, so you may think you're in the CLI — you are NOT. You are inside an ADE-hosted SDK session.", + "**Wake-up semantics:** The session only advances when ADE calls `session.send(...)`, which fires on a fresh user message. There is no autonomous wake. `ScheduleWakeup` is **not honored** in this harness — the host accepts the call but never re-invokes you. `Bash run_in_background: true` task notifications are queued in the SDK message stream and only flushed on the next user turn; they do not start an autonomous turn either.", + "**To wait:** Either poll synchronously inside the active turn (foreground bash with one bounded `until ... ; do sleep N; done`) or stop the turn cleanly and ask the user to re-ping when ready. Do not run a background poller and claim it will wake you — it will not.", + "", "## ADE Workspace", `ADE launched this session in lane worktree: ${managed.laneWorktreePath}.`, "Read, edit, and run commands only inside that worktree. Do not switch to project root, another lane, or another repo unless ADE explicitly relaunches you there.", From 04e9449c22753fbbd5c714b36427acdad328bdce Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 30 Apr 2026 00:55:45 -0400 Subject: [PATCH 5/5] =?UTF-8?q?ship:=20iter=204=20=E2=80=94=20address=20Co?= =?UTF-8?q?deRabbit=20review=20on=20iter=203=20(4=20comments)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - systemPrompt.ts (Major): align ScheduleWakeup wording with the shipLane playbook. "not available" → "not honored — the host accepts the call but never re-invokes you". Test updated. - WorkModelCatalog.swift (Minor): map provider == "factory" into the "droid" group key so Factory-provider models (glm-*, kimi-*, minimax-*) land in the Droid group instead of forming a separate one. - WorkModelPickerSheet.swift (Minor): normalize currentReasoningEffort the same way as the incoming effort before diffing — avoids spurious "changed" comparisons from casing/whitespace drift. - WorkNavigationAndTranscriptHelpers.swift (Major): preserve the malformed-question fallback. Tool-call suppression now only fires when the structured-question payload parses cleanly; on toolResult, skip only when no fallback card exists, otherwise let the result update the rendered card. Mirrors the conditional logic already used in WorkErrorAndMessageHelpers.swift. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/main/services/ai/tools/systemPrompt.test.ts | 3 ++- .../src/main/services/ai/tools/systemPrompt.ts | 2 +- apps/ios/ADE/Views/Work/WorkModelCatalog.swift | 2 +- apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift | 9 +++++++-- .../Work/WorkNavigationAndTranscriptHelpers.swift | 12 ++++++++++-- 5 files changed, 21 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts index 64792ca10..c2263b178 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts @@ -89,7 +89,8 @@ describe("buildCodingAgentSystemPrompt", () => { expect(result).toContain("## Runtime Environment"); expect(result).toContain("Claude Agent SDK v2"); expect(result).toContain("ScheduleWakeup"); - expect(result).toContain("not available"); + expect(result).toContain("not honored"); + expect(result).toContain("never re-invokes"); }); it("describes the Cursor ACP runtime", () => { diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts index 681862c48..ab1f63e4d 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts @@ -21,7 +21,7 @@ function describeRuntime(runtime: AdeRuntimeKind): string[] { case "claude-agent-sdk-v2": return [ "**Runtime:** ADE Work chat hosted on the Claude Agent SDK v2 (`unstable_v2_createSession` / `SDKSession`).", - "**Wake-up semantics:** The session only advances when the host calls `session.send(...)`, which fires on a fresh user message. There is no autonomous wake. `ScheduleWakeup` is **not available** in this harness — the model may know the tool name from training, but the host does not honor it. `Bash run_in_background: true` task notifications are queued in the SDK message stream and only flushed on the next user turn; they do not start an autonomous turn either.", + "**Wake-up semantics:** The session only advances when the host calls `session.send(...)`, which fires on a fresh user message. There is no autonomous wake. `ScheduleWakeup` is **not honored** in this harness — the host accepts the call but never re-invokes you. `Bash run_in_background: true` task notifications are queued in the SDK message stream and only flushed on the next user turn; they do not start an autonomous turn either.", "**To wait:** Either poll synchronously inside the active turn (foreground bash with one bounded `until ... ; do sleep N; done`) or stop the turn cleanly and ask the user to re-ping when ready. Do not run a background poller and claim it will wake you — it will not.", ]; case "codex-cli": diff --git a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift index 3f8e79e15..8cd625be7 100644 --- a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift +++ b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift @@ -618,7 +618,7 @@ func workModelCatalogGroupKey(for currentModelId: String, currentProvider: Strin if modelId.hasPrefix("opencode/") || provider == "opencode" { return "opencode" } - if provider == "droid" || modelId.hasPrefix("droid/") { + if provider == "droid" || provider == "factory" || modelId.hasPrefix("droid/") { return "droid" } if provider == "cursor" || modelId.contains("cursor/") || modelId.contains("cursor-") || modelId.contains("composer") { diff --git a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift index ef18330c1..4a436f0f9 100644 --- a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift @@ -692,9 +692,14 @@ struct WorkModelPickerSheet: View { } private func commit(model: WorkModelOption, effort: String?) { - let normalizedEffort = effort?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let normalizedEffort = effort? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() ?? "" + let normalizedCurrentEffort = currentReasoningEffort + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() let nextEffort: String? = normalizedEffort.isEmpty ? nil : normalizedEffort - let effortChanged = (nextEffort ?? "") != currentReasoningEffort + let effortChanged = (nextEffort ?? "") != normalizedCurrentEffort if model.id == currentModelId && !effortChanged { dismiss() return diff --git a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift index 80463bc46..c370efe22 100644 --- a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift @@ -684,7 +684,11 @@ func buildWorkToolCards( if suppressedPendingItemIds.contains(itemId) { continue } - if isQuestionInputToolName(tool) { + // Suppress only when the structured-question payload parses cleanly. If + // parsing fails (malformed args), fall through and render the raw tool + // card so the user can see what the model emitted. + if isQuestionInputToolName(tool), + pendingWorkQuestionFromAskUserToolCall(argsText: argsText, itemId: itemId) != nil { continue } if cards[itemId] == nil { @@ -700,7 +704,11 @@ func buildWorkToolCards( resultText: cards[itemId]?.resultText ) case .toolResult(let tool, let resultText, let itemId, _, _, let status): - if isQuestionInputToolName(tool) { + // Skip results only when the corresponding call was intentionally + // suppressed as a structured-question card (no fallback card exists). + // If a fallback tool card was kept (malformed args), let the result + // update it. + if isQuestionInputToolName(tool), cards[itemId] == nil { continue } let existing = cards[itemId]