From d7baaf85af4e798ffcce86356c6bb394ccb5fa2b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:31:43 -0400 Subject: [PATCH 1/3] Refs ADE-110: feat: polish CTO UI and control surface --- .../services/chat/agentChatService.test.ts | 26 +++ .../src/main/services/cto/ctoState.test.ts | 2 + .../src/main/services/cto/ctoStateService.ts | 2 + .../chat/AgentChatComposer.test.tsx | 14 ++ .../components/chat/AgentChatComposer.tsx | 5 +- .../components/chat/AgentChatPane.tsx | 4 + .../src/renderer/components/cto/CtoPage.tsx | 57 ++++--- .../components/cto/CtoSettingsPanel.tsx | 6 + .../renderer/components/cto/ctoUi.test.tsx | 53 +++++- .../components/cto/useCtoModelOptions.ts | 8 +- .../ios/ADE/Views/Cto/CtoIdentityEditor.swift | 32 ---- apps/ios/ADE/Views/Cto/CtoRootScreen.swift | 9 - .../Views/Cto/CtoSessionDestinationView.swift | 3 +- .../ios/ADE/Views/Cto/CtoSettingsScreen.swift | 157 +++++++++++++++++- .../ADE/Views/Work/WorkChatSessionView.swift | 138 +++++++++------ .../Work/WorkComposerTypedTriggers.swift | 3 +- .../Work/WorkSessionDestinationView.swift | 4 + docs/features/agents/identity-and-personas.md | 6 +- docs/features/cto/README.md | 18 +- .../sync-and-multi-device/ios-companion.md | 4 +- 20 files changed, 406 insertions(+), 145 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 2772fbf2d..8ca70a34e 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -5903,6 +5903,32 @@ describe("createAgentChatService", () => { db.close(); }); + it("keeps CTO full access when a model switch crosses providers", async () => { + vi.mocked(mapPermissionToCodex).mockImplementation((mode) => { + if (mode === "full-auto") return { approvalPolicy: "never", sandbox: "danger-full-access" }; + return { approvalPolicy: "on-request", sandbox: "read-only" }; + }); + const { db, ctoStateService, ctoMemoryService } = await createCtoServices(); + const { service } = createService({ ctoStateService, ctoMemoryService }); + const session = await service.ensureIdentitySession({ + identityKey: "cto", + laneId: "lane-1", + }); + + const updated = await service.updateSession({ + sessionId: session.id, + modelId: "openai/gpt-5.5", + }); + + expect(updated.provider).toBe("codex"); + expect(updated.permissionMode).toBe("full-auto"); + expect(updated.codexApprovalPolicy).toBe("never"); + expect(updated.codexSandbox).toBe("danger-full-access"); + expect(ctoStateService.getIdentity().modelPreferences.modelId).toBe("openai/gpt-5.5"); + + db.close(); + }); + it("injects durable memory into the CTO reconstruction context", async () => { const { db, ctoStateService, ctoMemoryService } = await createCtoServices(); ctoMemoryService.appendMemoryFact("The build long-pole is the Windows runner."); diff --git a/apps/desktop/src/main/services/cto/ctoState.test.ts b/apps/desktop/src/main/services/cto/ctoState.test.ts index cd4b2ca26..ef24a2a61 100644 --- a/apps/desktop/src/main/services/cto/ctoState.test.ts +++ b/apps/desktop/src/main/services/cto/ctoState.test.ts @@ -272,6 +272,8 @@ describe("ctoStateService", () => { expect(preview.sections[4]?.content).toContain("spawnChat"); expect(preview.sections[4]?.content).toContain("createTerminal"); expect(preview.sections[4]?.content).toContain("Model Selection"); + expect(preview.sections[4]?.content).toContain("ade actions run "); + expect(preview.sections[4]?.content).toContain("bundled `ade-*` skills"); // Capabilities section: organized tool reference with descriptions expect(preview.sections[5]?.content).toContain("ADE Operator Tools"); expect(preview.sections[5]?.content).toContain("listLanes"); diff --git a/apps/desktop/src/main/services/cto/ctoStateService.ts b/apps/desktop/src/main/services/cto/ctoStateService.ts index a1712a289..d6494e5b6 100644 --- a/apps/desktop/src/main/services/cto/ctoStateService.ts +++ b/apps/desktop/src/main/services/cto/ctoStateService.ts @@ -187,6 +187,8 @@ function buildCtoEnvironmentKnowledge(): string { "Tool calling convention:", " - Use the `ade` CLI per the ADE CLI operating guidance in your doctrine.", " - If a tool from the manifest below is not in your immediate tool list, use the closest ADE CLI command or report the missing capability clearly.", + " - Every ADE service action exposed to agents is manageable from the CLI. Prefer a typed `ade ...` command, then use the runtime-generated `ade actions list --text` catalog and `ade actions run ` for exact read, view, edit, and control coverage.", + " - Never rely on a memorized action inventory: the live action catalog and bundled `ade-*` skills are the source of truth for the installed ADE version.", "", "## PR Lifecycle in ADE", "", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index ddf137cac..0ab41dbfa 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -855,6 +855,20 @@ describe("AgentChatComposer", () => { expect(screen.queryByRole("button", { name: "Fast mode" })).toBeNull(); }); + it("hides model, reasoning, and fast controls when the host surface owns them", () => { + renderComposer({ + sessionProvider: "codex", + modelId: "openai/gpt-5.5", + availableModelIds: ["openai/gpt-5.5"], + fastMode: true, + hideModelControls: true, + }); + + expect(screen.queryByRole("button", { name: /Select model/i })).toBeNull(); + expect(screen.queryByRole("button", { name: "Reasoning effort" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Fast mode" })).toBeNull(); + }); + it("renders Droid autonomy controls without OpenCode permission labels", () => { const onDroidPermissionModeChange = vi.fn(); renderComposer({ diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 0c3686159..00717a063 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -1174,6 +1174,7 @@ export function AgentChatComposer({ onModelPickerOpenRequestHandled, permissionModeLocked = false, hideNativeControls = false, + hideModelControls = false, orchestrationRole = null, messagePlaceholder, inputLockMessage, @@ -1310,6 +1311,8 @@ export function AgentChatComposer({ onModelPickerOpenRequestHandled?: () => void; permissionModeLocked?: boolean; hideNativeControls?: boolean; + /** Hide model, reasoning, and fast-mode controls when the host surface owns them. */ + hideModelControls?: boolean; /** * Orchestration role lock (see `goal.md` §10.10). * - `"lead"`: hide permission picker AND model picker once the lead @@ -4024,7 +4027,7 @@ export function AgentChatComposer({ /> ) : null} - {!parallelChatMode && (orchestrationRole !== "lead" || !sessionId) ? ( + {!hideModelControls && !parallelChatMode && (orchestrationRole !== "lead" || !sessionId) ? ( <> { if (!window.ade?.cto || switchingModel) return; const selection = resolveModelSelection(modelId, reasoningEffort); @@ -175,6 +173,7 @@ export function CtoPage({ active = true }: { active?: boolean } = {}) { sessionId: session.id, modelId: selection.modelId, reasoningEffort: selection.reasoningEffort, + fastMode: selection.supportsFastMode && currentFastMode, }); ctoPrimarySession = updated; setSession(updated); @@ -198,6 +197,26 @@ export function CtoPage({ active = true }: { active?: boolean } = {}) { } finally { setSwitchingModel(false); } + }, [currentFastMode, refreshSession, session, switchingModel]); + + const handleFastModeChange = useCallback(async (enabled: boolean) => { + if (!window.ade?.cto || switchingModel) return; + setSwitchingModel(true); + setError(null); + try { + const targetSession = session ?? await refreshSession(); + if (!targetSession) throw new Error("The CTO chat is still waking up."); + const updated = await window.ade.agentChat.updateSession({ + sessionId: targetSession.id, + fastMode: enabled, + }); + ctoPrimarySession = updated; + setSession(updated); + } catch (err) { + setError(err instanceof Error ? err.message : "Couldn't update Fast mode."); + } finally { + setSwitchingModel(false); + } }, [refreshSession, session, switchingModel]); const handleOnboardingComplete = useCallback(async () => { @@ -242,6 +261,7 @@ export function CtoPage({ active = true }: { active?: boolean } = {}) { title: null, goal: null, reasoningEffort: session.reasoningEffort ?? null, + fastMode: session.fastMode === true, executionMode: session.executionMode ?? null, identityKey: session.identityKey, capabilityMode: session.capabilityMode, @@ -300,29 +320,9 @@ export function CtoPage({ active = true }: { active?: boolean } = {}) { {avatarInitial} {ctoDisplayName} - - {personalityLabel} -
- {currentModelId ? ( - { - const selection = resolveModelSelection(modelId, currentReasoningEffort); - void handleModelChange(modelId, selection?.reasoningEffort ?? null); - }} - onOpenSignIn={openProviderSettings} - /> - ) : null} + ModelPicker: (props: { + value: string; + onChange: (id: string) => void; + fastModeActive?: boolean; + onFastModeToggle?: (next: boolean) => void; + }) => ( + <> + + {props.onFastModeToggle ? ( + + ) : null} + ), })); @@ -64,6 +81,7 @@ const SESSION = { modelId: "anthropic/claude-sonnet-5", sessionProfile: "persistent_identity", reasoningEffort: null, + fastMode: false, executionMode: null, identityKey: "cto", capabilityMode: "full_tooling", @@ -73,7 +91,7 @@ const SESSION = { threadId: "thread-1", } as const; -describe("CtoPage model badge", () => { +describe("CtoPage settings", () => { const originalAde = globalThis.window.ade; const updateSession = vi.fn().mockResolvedValue({ ...SESSION, modelId: "anthropic/claude-opus-4-8" }); @@ -111,10 +129,19 @@ describe("CtoPage model badge", () => { expect(await screen.findByTestId("cto-agent-chat-pane")).toBeTruthy(); }); - it("routes a header model switch through agentChat.updateSession on the locked session", async () => { + it("keeps personality and model controls off the main chat header", async () => { render(); await screen.findByTestId("cto-agent-chat-pane"); + expect(screen.queryByText("Strategic")).toBeNull(); + expect(screen.queryByTestId("model-picker")).toBeNull(); + }); + + it("routes a settings model switch through agentChat.updateSession on the locked session", async () => { + render(); + await screen.findByTestId("cto-agent-chat-pane"); + + fireEvent.click(screen.getByRole("button", { name: "CTO settings" })); fireEvent.click(screen.getByTestId("model-picker")); await waitFor(() => expect(updateSession).toHaveBeenCalledTimes(1)); @@ -122,6 +149,20 @@ describe("CtoPage model badge", () => { expect.objectContaining({ sessionId: "cto-session", modelId: "anthropic/claude-opus-4-8" }), ); }); + + it("keeps Fast mode in settings and updates the locked CTO session", async () => { + render(); + await screen.findByTestId("cto-agent-chat-pane"); + + expect(screen.queryByTestId("model-fast-toggle")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "CTO settings" })); + fireEvent.click(screen.getByTestId("model-fast-toggle")); + + await waitFor(() => expect(updateSession).toHaveBeenCalledWith({ + sessionId: "cto-session", + fastMode: true, + })); + }); }); describe("CtoOnboardingCard", () => { diff --git a/apps/desktop/src/renderer/components/cto/useCtoModelOptions.ts b/apps/desktop/src/renderer/components/cto/useCtoModelOptions.ts index de39ac493..979cf6343 100644 --- a/apps/desktop/src/renderer/components/cto/useCtoModelOptions.ts +++ b/apps/desktop/src/renderer/components/cto/useCtoModelOptions.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { getModelById, selectSupportedReasoningEffort } from "../../../shared/modelRegistry"; +import { getModelById, modelSupportsFastMode, selectSupportedReasoningEffort } from "../../../shared/modelRegistry"; import { deriveConfiguredModelIds } from "../../lib/modelOptions"; export type CtoModelSelection = { @@ -8,6 +8,7 @@ export type CtoModelSelection = { model: string; modelId: string; reasoningEffort: string | null; + supportsFastMode: boolean; }; /** Reasoning tier the model supports closest to the caller's preference. */ @@ -36,13 +37,14 @@ export function resolveModelSelection( model: descriptor.shortId ?? descriptor.id.split("/").pop() ?? descriptor.id, modelId: descriptor.id, reasoningEffort: pickReasoningEffort(descriptor.id, preferredReasoning), + supportsFastMode: modelSupportsFastMode(descriptor), }; } /** * Loads the models the user has configured (API keys / signed-in CLIs) so the - * CTO model badge and Settings model row draw from the same catalog the composer - * does. Also exposes a jump to the provider settings for the empty-catalog case. + * CTO Settings draws from the same configured catalog as the chat composer. + * Also exposes a jump to provider settings for the empty-catalog case. */ export function useCtoModelOptions(): { availableModelIds: string[]; diff --git a/apps/ios/ADE/Views/Cto/CtoIdentityEditor.swift b/apps/ios/ADE/Views/Cto/CtoIdentityEditor.swift index 1e9893941..d6bda3454 100644 --- a/apps/ios/ADE/Views/Cto/CtoIdentityEditor.swift +++ b/apps/ios/ADE/Views/Cto/CtoIdentityEditor.swift @@ -11,8 +11,6 @@ struct CtoIdentityEditor: View { @State private var localPersonality: String = "strategic" @State private var localVerbosity: String = CtoWorkStyle.defaultStyle.verbosity @State private var localProactivity: String = CtoWorkStyle.defaultStyle.proactivity - @State private var localProvider: String = "" - @State private var localModel: String = "" @State private var localExtension: String = "" @State private var isSaving = false @@ -62,17 +60,6 @@ struct CtoIdentityEditor: View { .padding(.vertical, 4) } - Section("Model") { - TextField("anthropic", text: $localProvider) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .font(.system(.body, design: .monospaced)) - TextField("claude-sonnet-5", text: $localModel) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .font(.system(.body, design: .monospaced)) - } - Section { TextEditor(text: $localExtension) .font(.system(.body)) @@ -134,8 +121,6 @@ struct CtoIdentityEditor: View { localVerbosity = style.verbosity localProactivity = style.proactivity } - localProvider = identity.modelPreferences.provider - localModel = identity.modelPreferences.model localExtension = identity.systemPromptExtension ?? "" } @@ -173,23 +158,6 @@ struct CtoIdentityEditor: View { ) } - let trimmedProvider = localProvider.trimmingCharacters(in: .whitespacesAndNewlines) - let trimmedModel = localModel.trimmingCharacters(in: .whitespacesAndNewlines) - let currentProvider = identity.modelPreferences.provider - let currentModel = identity.modelPreferences.model - - // Only send modelPreferences if at least one of the two changed, and - // always send both fields together so the server doesn't see a malformed - // partial (the desktop type requires both). - if !trimmedProvider.isEmpty, !trimmedModel.isEmpty, - trimmedProvider != currentProvider || trimmedModel != currentModel { - patch.modelPreferences = CtoModelPreferences( - provider: trimmedProvider, - model: trimmedModel, - reasoningEffort: identity.modelPreferences.reasoningEffort - ) - } - let trimmedExt = localExtension.trimmingCharacters(in: .whitespacesAndNewlines) let existingExt = (identity.systemPromptExtension ?? "").trimmingCharacters(in: .whitespacesAndNewlines) if trimmedExt != existingExt { diff --git a/apps/ios/ADE/Views/Cto/CtoRootScreen.swift b/apps/ios/ADE/Views/Cto/CtoRootScreen.swift index 8b97226c8..4088365c8 100644 --- a/apps/ios/ADE/Views/Cto/CtoRootScreen.swift +++ b/apps/ios/ADE/Views/Cto/CtoRootScreen.swift @@ -53,15 +53,6 @@ struct CtoRootScreen: View { private var topBar: some View { ADERootTopBar(title: topBarTitle) { if isOnboarded { - Text(ctoPersonalityLabel(for: snapshot?.identity.personality)) - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.ctoAccent) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background(ADEColor.ctoAccent.opacity(0.14), in: Capsule()) - .overlay(Capsule().stroke(ADEColor.ctoAccent.opacity(0.28), lineWidth: 0.5)) - .accessibilityLabel("Personality: \(ctoPersonalityLabel(for: snapshot?.identity.personality))") - Button { showingSettings = true } label: { diff --git a/apps/ios/ADE/Views/Cto/CtoSessionDestinationView.swift b/apps/ios/ADE/Views/Cto/CtoSessionDestinationView.swift index f2954ac86..2f04d72d0 100644 --- a/apps/ios/ADE/Views/Cto/CtoSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Cto/CtoSessionDestinationView.swift @@ -51,7 +51,8 @@ struct CtoSessionDestinationView: View { isLive: isLive, navigationChrome: navigationChrome, showsLaneActions: false, - navigationTitleOverride: navigationTitle + navigationTitleOverride: navigationTitle, + compactComposer: true ) .environmentObject(syncService) } diff --git a/apps/ios/ADE/Views/Cto/CtoSettingsScreen.swift b/apps/ios/ADE/Views/Cto/CtoSettingsScreen.swift index 19b84c63a..1ddf55b87 100644 --- a/apps/ios/ADE/Views/Cto/CtoSettingsScreen.swift +++ b/apps/ios/ADE/Views/Cto/CtoSettingsScreen.swift @@ -1,6 +1,6 @@ import SwiftUI -/// CTO settings sheet: identity (name, personality, work style, model), a +/// CTO settings sheet: identity, live model selection, a /// read-only Linear connection status, a "what the CTO remembers" memory /// summary, and an advanced re-run-onboarding action. Presented as a sheet /// from the CTO tab's gear button. @@ -18,6 +18,9 @@ struct CtoSettingsScreen: View { @State private var isResettingOnboarding = false @State private var errorMessage: String? @State private var showingIdentityEditor = false + @State private var showingModelPicker = false + @State private var modelUpdateInFlight = false + @State private var ctoSession: AgentChatSessionSummary? init(snapshot: CtoSnapshot? = nil, onSnapshotChanged: @escaping (CtoSnapshot) -> Void) { _snapshot = State(initialValue: snapshot) @@ -48,6 +51,7 @@ struct CtoSettingsScreen: View { if let snapshot { identitySection(snapshot) + modelSection(snapshot) } integrationsSection @@ -75,6 +79,7 @@ struct CtoSettingsScreen: View { // Still refresh the side data (Linear, memory) even if identity was // seeded from the parent snapshot. await loadSideData() + await loadCtoSession() return } await reload() @@ -86,6 +91,26 @@ struct CtoSettingsScreen: View { } .environmentObject(syncService) } + .sheet(isPresented: $showingModelPicker) { + WorkModelPickerSheet( + currentModelId: currentModelId, + currentProvider: currentProvider, + currentReasoningEffort: currentReasoningEffort, + currentCodexFastMode: currentFastMode, + lanes: [], + commandScope: .project, + isBusy: modelUpdateInFlight, + onSelect: { option, reasoningEffort, _, fastMode in + Task { @MainActor in + await updateModel( + modelId: option.id, + reasoningEffort: reasoningEffort ?? "", + fastMode: option.supportsCodexFastMode ? fastMode : false + ) + } + } + ) + } } } @@ -102,6 +127,126 @@ struct CtoSettingsScreen: View { } } + // MARK: - Model + + private func modelSection(_ snapshot: CtoSnapshot) -> some View { + VStack(alignment: .leading, spacing: 6) { + SectionHeader(title: "Model") + Button { + showingModelPicker = true + } label: { + HStack(spacing: 12) { + Image(systemName: "cpu") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(ADEColor.ctoAccent) + .frame(width: 32, height: 32) + .background( + ADEColor.ctoAccent.opacity(0.12), + in: RoundedRectangle(cornerRadius: 9, style: .continuous) + ) + + VStack(alignment: .leading, spacing: 2) { + Text(prettyWorkChatModelName(currentModelId)) + .font(.system(size: 13.5, weight: .semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + Text(modelDetailText(snapshot)) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } + + Spacer(minLength: 8) + + if modelUpdateInFlight { + ProgressView().controlSize(.mini) + } else { + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(ADEColor.textMuted) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(modelUpdateInFlight) + .accessibilityLabel("Change CTO model") + .adeListCard(padding: 0) + } + } + + private var currentModelId: String { + ctoSession?.modelId + ?? ctoSession?.model + ?? snapshot?.identity.modelPreferences.model + ?? "" + } + + private var currentProvider: String { + ctoSession?.provider + ?? snapshot?.identity.modelPreferences.provider + ?? "claude" + } + + private var currentReasoningEffort: String { + ctoSession?.reasoningEffort + ?? snapshot?.identity.modelPreferences.reasoningEffort + ?? "" + } + + private var currentFastMode: Bool { + ctoSession?.effectiveFastMode ?? false + } + + private func modelDetailText(_ snapshot: CtoSnapshot) -> String { + var details = [currentProvider] + if !currentReasoningEffort.isEmpty { + details.append(currentReasoningEffort) + } + if currentFastMode { + details.append("fast") + } + return details.isEmpty + ? snapshot.identity.modelPreferences.model + : details.joined(separator: " · ") + } + + @MainActor + private func updateModel(modelId: String, reasoningEffort: String, fastMode: Bool) async { + guard !modelUpdateInFlight else { return } + modelUpdateInFlight = true + errorMessage = nil + defer { modelUpdateInFlight = false } + + do { + let session = try await currentCtoSession() + _ = try await syncService.updateChatSession( + sessionId: session.sessionId, + modelId: modelId, + reasoningEffort: reasoningEffort, + codexFastMode: fastMode + ) + ctoSession = try await syncService.ensureCtoSession() + let updated = try await syncService.fetchCtoState() + snapshot = updated + onSnapshotChanged(updated) + ADEHaptics.light() + } catch { + ADEHaptics.error() + errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + } + } + + @MainActor + private func currentCtoSession() async throws -> AgentChatSessionSummary { + if let ctoSession { return ctoSession } + let ensured = try await syncService.ensureCtoSession() + ctoSession = ensured + return ensured + } + // MARK: - Integrations (read-only) private var integrationsSection: some View { @@ -207,6 +352,16 @@ struct CtoSettingsScreen: View { } } await loadSideData() + await loadCtoSession() + } + + /// The live CTO chat is the source of truth for model, reasoning, and fast + /// mode. Keep identity preferences as an offline fallback, but do not let a + /// failed session refresh make the rest of CTO settings unusable. + private func loadCtoSession() async { + if let session = try? await syncService.ensureCtoSession() { + ctoSession = session + } } /// Linear status + memory. Both tolerate failure: Linear falls back to diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 335f2bf11..30a8d1fd8 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -212,6 +212,7 @@ struct WorkChatSessionView: View { /// still says idle while chat events are already streaming — without it /// the chat renders output with no stop button or working indicator. var liveTurnActiveHint: Bool? = nil + var compactComposer = false var isPersonalChat: Bool = false var personalAttachmentsAvailable: Bool = true var personalModelCatalogAvailable: Bool = true @@ -739,6 +740,7 @@ struct WorkChatSessionView: View { sending: sending && !sendWillQueue, settingsMutationInFlight: composerSettingMutationInFlight, codexFastModeOverride: pendingCodexFastMode, + compact: compactComposer, // Show Stop while a live turn has current transcript activity. The // broader live hint can lag after `done`; this stricter gate keeps the // composer from showing Stop after the completed-turn separator appears. @@ -767,7 +769,7 @@ struct WorkChatSessionView: View { } ) } - .padding(.horizontal, 16) + .padding(.horizontal, compactComposer ? 12 : 16) .padding(.top, 4) .padding(.bottom, 0) } @@ -1644,6 +1646,7 @@ private struct WorkChatComposerCard: View { let sending: Bool let settingsMutationInFlight: Bool let codexFastModeOverride: Bool? + let compact: Bool /// True while the assistant is streaming a response. Swaps the Send button /// Desktop parity: red bordered stop control in the composer while a turn is /// active (`border-red-500/25 bg-red-500/[0.08] text-red-400/80`). @@ -1670,6 +1673,7 @@ private struct WorkChatComposerCard: View { sending: sending, settingsMutationInFlight: settingsMutationInFlight, codexFastModeOverride: codexFastModeOverride, + compact: compact, showInterrupt: showInterrupt, interruptInFlight: interruptInFlight, onInterrupt: onInterrupt, @@ -1679,7 +1683,7 @@ private struct WorkChatComposerCard: View { onSent: onSent ) .padding(.horizontal, 12) - .padding(.vertical, 10) + .padding(.vertical, compact ? 8 : 10) .background(composerSurface) } @@ -1706,6 +1710,7 @@ private struct WorkChatComposerDraftInput: View { let sending: Bool let settingsMutationInFlight: Bool let codexFastModeOverride: Bool? + let compact: Bool let showInterrupt: Bool let interruptInFlight: Bool let onInterrupt: @MainActor () async -> Void @@ -1728,31 +1733,56 @@ private struct WorkChatComposerDraftInput: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - WorkComposerSuggestionStrip(controller: suggestionController) - .animation(.smooth(duration: 0.16), value: suggestionController.isVisible) + if compact { + HStack(alignment: .center, spacing: 8) { + if !isDictating { + WorkChatComposerTextField( + draftState: draftState, + controller: suggestionController, + canCompose: canCompose, + placeholder: composerPlaceholder, + maxLines: 1 + ) + } - WorkChatInputAttachmentTray(attachments: $inputAttachments) + DictationMicButton( + draft: $draftState.text, + coordinator: dictationCoordinator, + targetId: dictationTargetId, + onRecordingChange: { isDictating = $0 } + ) + .frame(maxWidth: isDictating ? .infinity : nil) - WorkChatComposerTextField( - draftState: draftState, - controller: suggestionController, - canCompose: canCompose, - placeholder: composerPlaceholder, - onPasteImages: { images in - guard canUploadAttachments else { return } - workChatInputPasteImages(images, into: $inputAttachments) + if !isDictating { + sendOrInterruptControls() + } } - ) + } else { + WorkComposerSuggestionStrip(controller: suggestionController) + .animation(.smooth(duration: 0.16), value: suggestionController.isVisible) + + WorkChatInputAttachmentTray(attachments: $inputAttachments) + + WorkChatComposerTextField( + draftState: draftState, + controller: suggestionController, + canCompose: canCompose, + placeholder: composerPlaceholder, + onPasteImages: { images in + guard canUploadAttachments else { return } + workChatInputPasteImages(images, into: $inputAttachments) + } + ) - if showInterrupt && hasSendableDraftOrAttachment { - Text("Message will stage behind the active turn.") - .font(.caption2) - .foregroundStyle(ADEColor.textMuted) - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityIdentifier("Work.Chat.Composer.StagingHint") - } + if showInterrupt && hasSendableDraftOrAttachment { + Text("Message will stage behind the active turn.") + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("Work.Chat.Composer.StagingHint") + } - HStack(alignment: .center, spacing: 8) { + HStack(alignment: .center, spacing: 8) { // Leading controls collapse while dictating so the recording pill can // expand into the row without a layout jump. if !isDictating { @@ -1804,33 +1834,8 @@ private struct WorkChatComposerDraftInput: View { ) .frame(maxWidth: isDictating ? .infinity : nil) - if !isDictating { - if showInterrupt { - if hasSendableDraftOrAttachment { - stopButton() - WorkChatComposerSendButton( - draftState: draftState, - attachments: $inputAttachments, - canSend: canSend, - canUploadAttachments: canUploadAttachments, - sending: sending, - accessibilityLabelText: "Stage message", - onSend: onSend, - onSent: onSent - ) - } else { - stopButton() - } - } else { - WorkChatComposerSendButton( - draftState: draftState, - attachments: $inputAttachments, - canSend: canSend, - canUploadAttachments: canUploadAttachments, - sending: sending, - onSend: onSend, - onSent: onSent - ) + if !isDictating { + sendOrInterruptControls() } } } @@ -1845,6 +1850,37 @@ private struct WorkChatComposerDraftInput: View { .onChange(of: laneId) { _, _ in configureSuggestionController() } } + @ViewBuilder + private func sendOrInterruptControls() -> some View { + if showInterrupt { + if hasSendableDraftOrAttachment { + stopButton() + WorkChatComposerSendButton( + draftState: draftState, + attachments: $inputAttachments, + canSend: canSend, + canUploadAttachments: canUploadAttachments, + sending: sending, + accessibilityLabelText: "Stage message", + onSend: onSend, + onSent: onSent + ) + } else { + stopButton() + } + } else { + WorkChatComposerSendButton( + draftState: draftState, + attachments: $inputAttachments, + canSend: canSend, + canUploadAttachments: canUploadAttachments, + sending: sending, + onSend: onSend, + onSent: onSent + ) + } + } + private func configureSuggestionController() { suggestionController.provider = chatSummary.provider suggestionController.laneId = laneId.isEmpty ? nil : laneId @@ -2098,6 +2134,7 @@ private struct WorkChatComposerTextField: View { let canCompose: Bool let placeholder: String var onPasteImages: (([UIImage]) -> Void)? = nil + var maxLines = 6 @State private var measuredHeight: CGFloat = 24 var body: some View { @@ -2107,7 +2144,8 @@ private struct WorkChatComposerTextField: View { canCompose: canCompose, placeholder: placeholder, measuredHeight: $measuredHeight, - onPasteImages: onPasteImages + onPasteImages: onPasteImages, + maxLines: maxLines ) .frame(height: measuredHeight) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift b/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift index ce1f08270..8ebcdb043 100644 --- a/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift +++ b/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift @@ -470,9 +470,10 @@ struct WorkComposerTextView: UIViewRepresentable { let placeholder: String @Binding var measuredHeight: CGFloat var onPasteImages: (([UIImage]) -> Void)? = nil + var maxLines = 6 private var maxHeight: CGFloat { - ceil(UIFont.preferredFont(forTextStyle: .body).lineHeight * 6) + 8 + ceil(UIFont.preferredFont(forTextStyle: .body).lineHeight * CGFloat(max(1, maxLines))) + 8 } func makeCoordinator() -> Coordinator { diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index d52a457c1..3415a7f0f 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -335,6 +335,9 @@ struct WorkSessionDestinationView: View { /// composer as Work while routing every chat operation through /// `personalChats.*` and hiding project-only chrome. var personalChat = false + /// CTO uses the Work transcript pipeline with a single-line voice/send + /// composer. Model, reasoning, fast mode, and identity live in CTO settings. + var compactComposer = false /// Whether this view is a cross-project "quick look" (see `crossProjectContext`). var isCrossProject: Bool { crossProjectContext != nil } @@ -1153,6 +1156,7 @@ struct WorkSessionDestinationView: View { prBadge: chatPrBadge, onOpenPrDetails: openPrDetails, liveTurnActiveHint: liveTurnActiveHint, + compactComposer: compactComposer, isPersonalChat: personalChat, personalAttachmentsAvailable: !personalChat || syncService.canInvokeRemoteAction("personalChats.saveTempAttachment"), diff --git a/docs/features/agents/identity-and-personas.md b/docs/features/agents/identity-and-personas.md index 1f42046ba..8963dd023 100644 --- a/docs/features/agents/identity-and-personas.md +++ b/docs/features/agents/identity-and-personas.md @@ -62,8 +62,8 @@ These fields drive prompt adjustments for detail level, initiative, and when to - Selected personality overlay (+ `customPersonality` for the `custom` preset). - `CTO_CONTINUITY_OPERATING_MODEL` — how ADE re-grounds the CTO across compaction and resumes. - `CTO_MEMORY_SYSTEM_GUIDANCE` — teaches the CTO that it has durable, model-agnostic memory and how to use the memory tools. -- Environment knowledge — ADE surfaces, tools, task-routing rules, and the live model registry. -- `CTO_CAPABILITY_MANIFEST` — the available operator tools, injected verbatim. +- Environment knowledge — ADE surfaces, tools, task-routing rules, the live model registry, and instructions to discover installed-version capabilities through bundled `ade-*` skills plus `ade actions list --text` / `ade actions run`. +- `CTO_CAPABILITY_MANIFEST` — the available CTO operator tools, generated from `createCtoOperatorTools()` and injected verbatim. `previewSystemPrompt()` returns exactly these sections, which the settings and onboarding UI render. @@ -124,7 +124,7 @@ The CTO maintains an append-only session log with hash chaining. Each entry incl - **Custom personality text size.** `customPersonality` is injected as-is; very long values consume prompt budget. - **Deterministic flush is the guarantee.** The LLM summary upgrade is best-effort — never make the durable memory write depend on it. - **Injected memory is authoritative.** The prompt tells the CTO not to claim memory it does not have injected; injection caps/order in `ctoMemoryService`/`ctoStateService` directly change what the CTO "knows." -- **Capability manifest is hand-synced.** `buildCtoCapabilityManifest()` must stay aligned with `ctoOperatorTools.ts` registrations. +- **Capability knowledge is generated and runtime-aware.** `buildCtoCapabilityManifest()` derives its curated operator-tool inventory from `createCtoOperatorTools()`. Service actions outside that set must be discovered from the installed runtime's `ade actions list --text` catalog and bundled `ade-*` skills rather than copied into a second static list. - **Daily log permission.** Files under `.ade/cto/daily/` are written with the default umask. Keep `.ade/` out of shared paths. ## Related docs diff --git a/docs/features/cto/README.md b/docs/features/cto/README.md index 9e622a439..d2581e19b 100644 --- a/docs/features/cto/README.md +++ b/docs/features/cto/README.md @@ -10,7 +10,7 @@ The whole surface is built around one contract: the CTO is a daily chat you can - `ctoStateService.ts` — identity (name, personality, work style, model preferences), session logs, onboarding state, and the system-prompt preview. Owns the immutable doctrine, personality overlays, continuity model, memory-system guidance, environment knowledge, and capability manifest constants. `buildReconstructionContext()` assembles the memory-enriched context injected on session start, compaction, and model switch; `previewSystemPrompt()` returns the same layered prompt the settings UI renders verbatim. - `ctoMemoryService.ts` — the smart-memory file store under `.ade/cto/`. Reads/writes `MEMORY.md` and `thread-state.md` (atomic writes), appends per-turn lines to `daily/.md`, exposes `searchMemory(query)` (bounded, file-based, most-recent-first), `getSnapshot()`, and `buildMemoryContextSections()` (the capped copies used for injection). No new database or vector dependency. -- `ctoPromptContent.ts` — `buildCtoCapabilityManifest()`, the operator-tool manifest injected into the prompt. Kept in sync with `ctoOperatorTools.ts` by hand, not auto-generated. +- `ctoPromptContent.ts` — `buildCtoCapabilityManifest()`, the operator-tool manifest injected into the prompt. It is generated directly from `createCtoOperatorTools()` so registered tools and prompt documentation stay aligned. - `linearClient.ts` — Linear GraphQL client (shared by desktop and the headless ADE CLI). Reads: `fetchIssueById`, `listProjects`, `searchIssues`, `getQuickView`, `fetchIssueComments`, `listLabels`, `listUsers`. Writes: `updateIssueState`, `updateIssueAssignee`, `createComment`, `addIssueLabel` / `removeIssueLabel`. - `linearIssueTracker.ts` / `issueTracker.ts` — issue cache, change detection, and the `getQuickView` / `searchIssues` / `fetchIssueComments` read shims plus the `updateIssueState` / `updateIssueAssignee` / `createComment` / `addLabel` write surface renderer surfaces call through. - `linearGraphQLInput.ts` — GraphQL input builders shared by the client and tracker. @@ -23,14 +23,14 @@ The Linear services above are shared plumbing, not CTO-owned workflow machinery. ### Renderer (`apps/desktop/src/renderer/components/cto/`) -- `CtoPage.tsx` — the `/cto` shell. A single full-bleed chat thread (`AgentChatPane` with a locked session), not tabs. Slim header: name, personality chip, an interactive model badge (a `ModelPicker` that live-switches the running thread), and a Settings gear. When onboarding is incomplete the thread is replaced by a single `CtoOnboardingCard`. The primary session is cached module-side so it stays warm across tab switches, and is obtained via `window.ade.cto.ensureSession()`. -- `CtoSettingsPanel.tsx` — the right-side settings sheet. Sections, in order: Identity (`IdentityEditor`), Model (`ModelPicker` + reasoning-effort picker), Memory (`CtoMemoryPanel`), Prompt (collapsible `CtoPromptPreview`), and Setup (re-run setup + collapsible session history). +- `CtoPage.tsx` — the `/cto` shell. A single full-bleed chat thread (`AgentChatPane` with a locked session), not tabs. The slim header shows only the CTO name/avatar and Settings gear; personality and model controls stay in settings. The CTO composer also hides lane, permission, model, reasoning, and fast-mode controls because the session is project-level, always full-access, and settings-owned. When onboarding is incomplete the thread is replaced by a single `CtoOnboardingCard`. The primary session is cached module-side so it stays warm across tab switches, and is obtained via `window.ade.cto.ensureSession()`. +- `CtoSettingsPanel.tsx` — the right-side settings sheet. Sections, in order: Identity (`IdentityEditor`), Model (`ModelPicker` + reasoning-effort + supported Fast toggle), Memory (`CtoMemoryPanel`), Prompt (collapsible `CtoPromptPreview`), and Setup (re-run setup + collapsible session history). - `CtoMemoryPanel.tsx` — "what the CTO remembers": an editable `MEMORY.md` textarea (save via `window.ade.cto.updateMemory`), a read-only current thread-state, and a collapsible today's daily log. Loads via `window.ade.cto.getMemory`. - `CtoOnboardingCard.tsx` — the one-card first-run setup: personality preset (with a custom-overlay textarea for `custom`), work style (verbosity / proactivity / escalation via `Segmented`), and an optional name. Completing it saves identity and marks the `identity` onboarding step done. - `IdentityEditor.tsx` — edits name, personality preset, custom overlay, and work style. It does not edit the model (that lives in the Model section). - `CtoPromptPreview.tsx` — renders the effective, layered system prompt (doctrine, personality overlay, continuity, memory guidance, environment knowledge, capabilities). - `personalityTheme.ts` — maps each personality preset to a hue/icon used across the avatar, chip, and selected tiles; also owns `DEFAULT_COMMUNICATION_STYLE`, `WORK_STYLE_ROWS`, and `normalizeCommunicationStyle`. -- `Segmented.tsx` — the compact three-option control used for work-style rows. `useCtoModelOptions.ts` — loads the user's configured model IDs so the header badge and Model section share the composer's catalog. `ctoSessionViewState.ts` — view-state helpers. `identityPresets.ts` — re-export of `shared/ctoPersonalityPresets`. `shared/designTokens.ts` + `shared/TimelineEntry.tsx` — shared class tokens and the session-history timeline row. +- `Segmented.tsx` — the compact three-option control used for work-style rows. `useCtoModelOptions.ts` — loads the user's configured model IDs for the settings Model section. `ctoSessionViewState.ts` — view-state helpers. `identityPresets.ts` — re-export of `shared/ctoPersonalityPresets`. `shared/designTokens.ts` + `shared/TimelineEntry.tsx` — shared class tokens and the session-history timeline row. ### Shared and tools @@ -42,9 +42,9 @@ The Linear services above are shared plumbing, not CTO-owned workflow machinery. ### iOS companion (`apps/ios/ADE/Views/Cto/`) - `CtoRootScreen.swift` — renders the CTO chat inline as the tab body (single thread, kind `.cto`) with a top-bar gear that opens settings as a sheet. No Team/Workflows navigation. -- `CtoSessionDestinationView.swift` — resolves the always-on CTO session (`ensureCtoSession()`) and reuses the Work chat pipeline to render it. +- `CtoSessionDestinationView.swift` — resolves the always-on CTO session (`ensureCtoSession()`) and reuses the Work chat pipeline with a compact one-line voice/send composer. - `CtoSetup.swift` — the first-run card (name, personality preset, work-style rows) shown when onboarding is incomplete. -- `CtoSettingsScreen.swift` — sections: Identity (edit via `CtoIdentityEditor`), Integrations (read-only Linear connection status), Memory (durable facts + thread summary via `cto.getMemory`), and Advanced (re-run setup). +- `CtoSettingsScreen.swift` — sections: Identity (including personality/work style via `CtoIdentityEditor`), Model (live model/reasoning/Fast selection), Integrations (read-only Linear connection status), Memory (durable facts + thread summary via `cto.getMemory`), and Advanced (re-run setup). - `CtoIdentityEditor.swift` / `CtoReloadHelpers.swift` — the identity edit sheet and reload plumbing. The CTO tab icon is the SF Symbol `brain` (`apps/ios/ADE/App/ContentView.swift`), matching the desktop Phosphor Brain glyph. @@ -95,7 +95,7 @@ The guarantee is that a deterministic flush always runs before anything can be l ### Model switching is first-class -- Changing the model from the header badge or the Settings Model section routes through `agentChatService.updateSession` for a live session, moving the same ADE session and transcript to the new provider/model. Before a session exists, the picker writes `identity.modelPreferences` so `ensureSession` reconciles to it. +- Changing the model from the Settings Model section routes through `agentChatService.updateSession` for a live session, moving the same ADE session and transcript to the new provider/model. Before a session exists, the picker writes `identity.modelPreferences` so `ensureSession` reconciles to it. - The live selection is persisted back into `identity.modelPreferences` (`persistCtoModelPreference`) so the identity file stays the single source of truth in both directions. - Switch order: flush durable memory → `refreshReconstructionContext` (now memory-rich) → `teardownRuntime` → rebind. Claude→Claude keeps the fast `setModel` path. @@ -105,7 +105,7 @@ The guarantee is that a deterministic flush always runs before anything can be l ## Tab model -The CTO tab is a single persistent thread plus a settings sheet — there is no Chat/Team/Workflows/Settings tab bar. The header exposes the name, personality, live model badge, and a gear that slides in `CtoSettingsPanel` from the right. Onboarding, when incomplete, takes over the whole surface as one card. +The CTO tab is a single persistent thread plus a settings sheet — there is no Chat/Team/Workflows/Settings tab bar. The header exposes only the name/avatar and a gear that slides in `CtoSettingsPanel` from the right. Personality, model, reasoning, and Fast mode live in settings. Onboarding, when incomplete, takes over the whole surface as one card. ## IPC surface @@ -137,7 +137,7 @@ First run is one card. The user picks a personality preset, optionally adjusts t - **The deterministic flush is the guarantee.** `flushIdentityContinuityDeterministic` runs synchronously and unconditionally before teardown and after compaction; the LLM summary upgrade is best-effort and may be skipped or fail without affecting correctness. Never make the durable write depend on the LLM path. - **Cursor and Droid emit no compaction signal.** For those runtimes there is no pre-compaction flush hook, so the turn-end daily journal plus the switch-time flush are what make any provider reset recoverable. Treat the daily log as the safety net there. - **Injected memory is authoritative.** The prompt tells the CTO never to claim memory it does not have injected — changes to injection caps or ordering in `ctoMemoryService`/`ctoStateService` directly change what the CTO "knows." -- **Capability manifest stays hand-synced.** `ctoPromptContent.buildCtoCapabilityManifest()` must be kept aligned with `ctoOperatorTools.ts` registrations; it is injected in full and is not generated from the tool list. +- **Capability knowledge has two live sources.** `ctoPromptContent.buildCtoCapabilityManifest()` is generated from `createCtoOperatorTools()`. For service actions outside that curated tool set, the CTO prompt directs the model to the installed runtime's `ade actions list --text` catalog and bundled `ade-*` skills instead of a stale hard-coded inventory. - **One CTO session.** Do not create a second CTO session on a foreign lane; `ensureIdentitySession` rebinds the existing one. Session-creation paths that bypass it would fork the thread. ## Cross-links diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index da5e05951..04d600c5b 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -1080,7 +1080,7 @@ navigating so replication latency cannot produce a blank destination screen. | **Files** | `doc.text` | `/files` | Lane-backed workspace picker (`FilesWorkspacePickerDropdown`, a desktop-shaped searchable dropdown that replaced the horizontal workspace chip row), live file tree/read. Search is a single full-screen page (`FilesSearchScreen`) opened from the magnifying-glass button in the Files top bar (desktop `SearchOverlay` parity): one query searches file *names* (quick open) and file *contents* (text search) together — name matches surface first under "Files", content hits are grouped per file with collapsible line previews, and tapping a line opens the file at that line. The inline `FilesQueryCard` quick-open / text-search cards (and their 40-row caps) were removed. Files are freely editable — the mobile read-only file-mutation gate (`mobileReadOnly` / edit-protection) was removed on both the host and the phone, matching the desktop change. | | **Work** | `terminal` | `/work` | Terminal + chat session list (standalone CLI sessions stay listed after they end, matching desktop — `workSessionShouldAppearInWorkList` in `WorkBrowserHelpers.swift` hides only `run-shell` infrastructure rows and orphaned chat-owned child shells that are no longer live), 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. The composer's last-used selection (model + access mode + reasoning effort + fast mode) persists across surfaces through `WorkComposerPreferences` (App Group `UserDefaults`, versioned key): the New Chat screen seeds its initial state from the saved selection instead of hardcoded defaults, and every change or send — from the New Chat composer, the in-session inline picker (`WorkSessionDestinationView`), or the session settings sheet — writes it back. Because the inline picker is cross-provider, the persisted provider is re-derived from the picked model, and a provider change resets the coupled access mode / sub-settings to that provider's defaults. Droid (Factory) is in the new-chat provider allowlist (`workNormalizedNewChatProvider`), so Droid Core models (GLM / Kimi / MiniMax) keep the `droid` provider instead of silently collapsing to the Claude runtime. The new-chat send button is the shared `ADEComposerSendButton` (an arrow-in-circle disc matching the in-session composer), replacing the earlier paperplane capsule. Each session row carries a minimal per-lane PR status indicator (`WorkLanePrIndicator`: a state-colored dot + `#num` + Open/Draft/Closed/Merged) beside the lane name. It and the Lanes tab chip both render the unified `LanePrTag` (`LaneHelpers.swift`, `selectLaneTabPrTag`, desktop parity), which merges ADE-mapped PRs (the synced `pull_requests` table) with GitHub PRs opened outside ADE — matched to a lane by branch and fetched into the shared `SyncService.laneGithubPrItems` cache (`refreshLaneGithubPrItems`, best-effort, throttled, reset on project switch / reconnect). When a row resolves a `LanePrTag` (mapped or GitHub-by-branch), its long-press context menu (`WorkSessionListRow`) also offers **"Open in PRs tab"**; `WorkRootScreen+Actions.openPullRequest` waits out the menu-dismiss animation, then publishes `syncService.requestedPrNavigation` (a `PrNavigationRequest` carrying the PR id + number + lane id, or just the GitHub PR number for an unmapped tag), and `ContentView`'s `onChange(of: requestedPrNavigation?.id)` flips the app to the PRs tab and opens that PR — the same cross-tab handoff the deep-link router and the in-chat PR menu use. 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. Only real user drags can un-pin the viewport: layout-driven geometry changes (keyboard show/hide, key bar, pinch font changes) re-assert the live tail after the pass settles, so a pinned terminal with large scrollback keeps the prompt visible above the keyboard instead of stranding it (SwiftTerm only re-snaps when cols/rows change, and a mouse-mode TUI repainting in place emits no scroll events to self-heal). 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`). The chat composer input is a `UITextView`-backed field (`WorkComposerTextView` in `WorkComposerTypedTriggers.swift`) rather than a plain SwiftUI `TextField`, because it needs the cursor position and inline styled runs. `WorkComposerTriggerDetector` runs the same cursor-relative regexes as the shared desktop/TUI `composerTriggers.ts` (slash `(?:^|\s)/([^\s/]*)$`, at `(?:^|\s)@([^\s@]*)$`), so a `/command` or `@file` trigger is detected anywhere in the draft, not just at position 0. `WorkComposerSuggestionController` drives an inline suggestion strip (`WorkComposerSuggestionStrip`) above the input — a curated per-provider slash catalog (`WorkComposerSlashCatalog`) resolved locally, and `@file` quick-open resolved over sync via `SyncService.quickOpen` against the lane's files workspace (40 ms debounce, workspace id cached per lane, invalidated on lane change). Its visibility derives purely from the active trigger match, never from `@FocusState`. Committing a suggestion splices exactly the trigger span on the live text view, and confirmed `/command` / `@path` tokens render as tinted chip pills drawn by a custom TextKit 1 `WorkComposerChipLayoutManager` (provider-accent tint, monospace for slash, semibold for at) while `draftState.text` stays the plain-text source of truth that is sent. This replaced the modal `WorkMentionsPickerSheet` and `WorkSlashCommandsSheet` (both deleted). | | **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. The PR detail screen (`PrDetailView`) is a single-column adaptation of the desktop Timeline+Rails layout — its Overview is emitted as sibling `List` rows so the list virtualizes offscreen content, and it stays live off a warm-cache freshness gate (see [PR detail screen](#pr-detail-screen)). | -| **CTO** | `brain` | `/cto` | The CTO chat thread rendered inline as the tab body (single persistent session via `CtoSessionDestinationView`), with a top-bar gear opening the settings sheet (identity, read-only Linear status, memory via `cto.getMemory`, re-run setup). | +| **CTO** | `brain` | `/cto` | The CTO chat thread rendered inline as the tab body (single persistent session via `CtoSessionDestinationView`) with a compact one-line voice/send composer. The top-bar gear opens settings for identity/personality, live model/reasoning/Fast selection, read-only Linear status, memory via `cto.getMemory`, and re-run setup. | | **Settings** | `gearshape` | `/settings` (sync subset) | Pairing — scan the QR (`SettingsPairingScannerSheet`), discover on network, or enter machine details manually — plus PIN entry (`SettingsPinSheet`), appearance, diagnostics, connection header with QR payload and address candidates, reconnect, forget, and a **Push delivery** panel (`SettingsPushDeliverySection`: registration/permission state, APNs environment, relay reachability from `push.getStatus`, and notification / Live-Activity / quiet-hours toggles). `ConnectionSettingsView` binds to `SettingsConnectionPresentationModel`, which feeds plain `SettingsConnectionSnapshot` / `SettingsPairingSnapshot` / `SettingsDiagnosticsSnapshot` / `SettingsPushDeliverySnapshot` DTOs into the section views (`SettingsConnectionHeader`, `SettingsPairingSection`, `SettingsDiagnosticsSection`, `SettingsPushDeliverySection`) instead of having them reach into `SyncService` directly. | `WorkModelPickerSheet` shows the same Claude authentication affordance @@ -1359,7 +1359,7 @@ different machine's cached limits. | Work tab | Implemented; live chat-event push from runtime, subscribed terminal input/resize control with `terminal_unsubscribe` on view disappear, in-app CLI session launcher (`work.startCliSession`), external provider-session browse/import (`work.listExternalSessions` / `work.importExternalSession`), message-to-continue on ended agent CLI rows, fixed cross-client activity carousel above the new-chat composer | | PRs tab | Implemented; driven by `prs.getMobileSnapshot` | | Settings tab (pairing / appearance / diagnostics) | Implemented | -| CTO / Automations / Graph / History tabs | Planned | +| Automations / Graph / History tabs | Planned | | Full Settings parity | Planned | | Lock Screen widget | Implemented; single prioritized status across agents, PRs, sync, offline, and idle states | | Push notifications (APNs alerts + deep links) | Implemented (on-device E2E needs a physical iPhone) | From 0c5663138597d6e18f2c79693ca811ce6a42e230 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:56:37 -0400 Subject: [PATCH 2/3] =?UTF-8?q?Refs=20ADE-110:=20ship:=20iteration=201=20?= =?UTF-8?q?=E2=80=94=20address=20CodeRabbit=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/chat/agentChatService.test.ts | 34 ++++++++++--------- .../chat/AgentChatComposer.test.tsx | 18 ++++++++++ .../components/chat/AgentChatComposer.tsx | 2 +- .../renderer/components/cto/ctoUi.test.tsx | 32 ++++++++++++++--- 4 files changed, 64 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 8ca70a34e..92e544fd7 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -5909,24 +5909,26 @@ describe("createAgentChatService", () => { return { approvalPolicy: "on-request", sandbox: "read-only" }; }); const { db, ctoStateService, ctoMemoryService } = await createCtoServices(); - const { service } = createService({ ctoStateService, ctoMemoryService }); - const session = await service.ensureIdentitySession({ - identityKey: "cto", - laneId: "lane-1", - }); - - const updated = await service.updateSession({ - sessionId: session.id, - modelId: "openai/gpt-5.5", - }); + try { + const { service } = createService({ ctoStateService, ctoMemoryService }); + const session = await service.ensureIdentitySession({ + identityKey: "cto", + laneId: "lane-1", + }); - expect(updated.provider).toBe("codex"); - expect(updated.permissionMode).toBe("full-auto"); - expect(updated.codexApprovalPolicy).toBe("never"); - expect(updated.codexSandbox).toBe("danger-full-access"); - expect(ctoStateService.getIdentity().modelPreferences.modelId).toBe("openai/gpt-5.5"); + const updated = await service.updateSession({ + sessionId: session.id, + modelId: "openai/gpt-5.5", + }); - db.close(); + expect(updated.provider).toBe("codex"); + expect(updated.permissionMode).toBe("full-auto"); + expect(updated.codexApprovalPolicy).toBe("never"); + expect(updated.codexSandbox).toBe("danger-full-access"); + expect(ctoStateService.getIdentity().modelPreferences.modelId).toBe("openai/gpt-5.5"); + } finally { + db.close(); + } }); it("injects durable memory into the CTO reconstruction context", async () => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 0ab41dbfa..66c7cf6a3 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -869,6 +869,24 @@ describe("AgentChatComposer", () => { expect(screen.queryByRole("button", { name: "Fast mode" })).toBeNull(); }); + it("hides parallel slot model, reasoning, and fast controls when the host surface owns them", () => { + renderComposer({ + sessionProvider: "codex", + availableModelIds: ["openai/gpt-5.5", "anthropic/claude-sonnet-5"], + hideModelControls: true, + parallelChatMode: true, + parallelConfiguringIndex: 0, + parallelModelSlots: [ + { modelId: "openai/gpt-5.5", reasoningEffort: "high", fastMode: true }, + { modelId: "anthropic/claude-sonnet-5", reasoningEffort: "medium" }, + ], + }); + + expect(screen.queryByRole("button", { name: /Select model/i })).toBeNull(); + expect(screen.queryByRole("button", { name: "Reasoning effort" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Fast mode" })).toBeNull(); + }); + it("renders Droid autonomy controls without OpenCode permission labels", () => { const onDroidPermissionModeChange = vi.fn(); renderComposer({ diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 00717a063..63036f831 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -3995,7 +3995,7 @@ export function AgentChatComposer({ })}
) : null} - {parallelChatMode && parallelConfiguringIndex != null && parallelModelSlots[parallelConfiguringIndex] ? ( + {!hideModelControls && parallelChatMode && parallelConfiguringIndex != null && parallelModelSlots[parallelConfiguringIndex] ? ( <> ({ model: "sonnet", modelId, reasoningEffort: null, - supportsFastMode: true, + supportsFastMode: modelId !== "anthropic/claude-opus-4-8", }), })); @@ -93,9 +93,11 @@ const SESSION = { describe("CtoPage settings", () => { const originalAde = globalThis.window.ade; + const ensureSession = vi.fn().mockResolvedValue(SESSION); const updateSession = vi.fn().mockResolvedValue({ ...SESSION, modelId: "anthropic/claude-opus-4-8" }); beforeEach(() => { + ensureSession.mockReset().mockResolvedValue(SESSION); updateSession.mockClear(); useAppStore.setState({ lanes: [{ id: "lane-primary", name: "Primary", laneType: "primary" } as never], @@ -111,7 +113,7 @@ describe("CtoPage settings", () => { completedSteps: ["identity"], dismissedAt: null, }), - ensureSession: vi.fn().mockResolvedValue(SESSION), + ensureSession, updateIdentity: vi.fn().mockResolvedValue({ identity: IDENTITY, recentSessions: [] }), }, } as never; @@ -138,6 +140,7 @@ describe("CtoPage settings", () => { }); it("routes a settings model switch through agentChat.updateSession on the locked session", async () => { + ensureSession.mockResolvedValueOnce({ ...SESSION, fastMode: true }); render(); await screen.findByTestId("cto-agent-chat-pane"); @@ -145,9 +148,12 @@ describe("CtoPage settings", () => { fireEvent.click(screen.getByTestId("model-picker")); await waitFor(() => expect(updateSession).toHaveBeenCalledTimes(1)); - expect(updateSession).toHaveBeenCalledWith( - expect.objectContaining({ sessionId: "cto-session", modelId: "anthropic/claude-opus-4-8" }), - ); + expect(updateSession).toHaveBeenCalledWith({ + sessionId: "cto-session", + modelId: "anthropic/claude-opus-4-8", + reasoningEffort: null, + fastMode: false, + }); }); it("keeps Fast mode in settings and updates the locked CTO session", async () => { @@ -163,6 +169,22 @@ describe("CtoPage settings", () => { fastMode: true, })); }); + + it("turns Fast mode off from settings on the locked CTO session", async () => { + ensureSession.mockResolvedValueOnce({ ...SESSION, fastMode: true }); + render(); + await screen.findByTestId("cto-agent-chat-pane"); + + fireEvent.click(screen.getByRole("button", { name: "CTO settings" })); + const fastToggle = screen.getByTestId("model-fast-toggle"); + expect(fastToggle.getAttribute("aria-pressed")).toBe("true"); + fireEvent.click(fastToggle); + + await waitFor(() => expect(updateSession).toHaveBeenCalledWith({ + sessionId: "cto-session", + fastMode: false, + })); + }); }); describe("CtoOnboardingCard", () => { From bb8ef897df39922c00aa23e9cc3daf0eb3d67469 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:12:26 -0400 Subject: [PATCH 3/3] =?UTF-8?q?Refs=20ADE-110:=20ship:=20iteration=202=20?= =?UTF-8?q?=E2=80=94=20preserve=20compact=20composer=20inputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/ios/ADE/Views/Work/WorkChatSessionView.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 30a8d1fd8..018fff5de 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -1734,6 +1734,11 @@ private struct WorkChatComposerDraftInput: View { var body: some View { VStack(alignment: .leading, spacing: 8) { if compact { + WorkComposerSuggestionStrip(controller: suggestionController) + .animation(.smooth(duration: 0.16), value: suggestionController.isVisible) + + WorkChatInputAttachmentTray(attachments: $inputAttachments) + HStack(alignment: .center, spacing: 8) { if !isDictating { WorkChatComposerTextField( @@ -1741,6 +1746,10 @@ private struct WorkChatComposerDraftInput: View { controller: suggestionController, canCompose: canCompose, placeholder: composerPlaceholder, + onPasteImages: { images in + guard canUploadAttachments else { return } + workChatInputPasteImages(images, into: $inputAttachments) + }, maxLines: 1 ) }