Add Codex chat integration to desktop app - #6
Conversation
📝 WalkthroughWalkthroughThis PR introduces a comprehensive Codex app-server integration into the desktop application. It adds a new backend service that spawns and manages the Codex process via JSON-RPC over stdio, implements lane/thread lifecycle management with persistent storage, exposes IPC handlers for all Codex operations, creates a new CodexChatPage UI component for chat interactions, and integrates Codex chat capabilities into existing views. Changes
Sequence Diagram(s)sequenceDiagram
participant Renderer as Renderer Process
participant IPC as IPC Bridge
participant Service as CodexAppServerService
participant ChildProcess as Codex App Server
Renderer->>IPC: codexThreadStart({ laneId, model })
IPC->>Service: threadStart(args)
Service->>ChildProcess: thread/start (JSON-RPC)
ChildProcess-->>Service: thread/start response
Service->>Service: Store lane-thread binding
Service-->>IPC: Return CodexThread
IPC-->>Renderer: Promise<CodexThread>
Renderer->>IPC: codexTurnStart({ threadId, prompt, effort })
IPC->>Service: turnStart(args)
Service->>ChildProcess: turn/start (JSON-RPC)
ChildProcess-->>Service: turn/start response
ChildProcess-->>Service: item/commandExecution (notification)
Service-->>Renderer: codexEvent (via IPC)
Renderer->>Renderer: Update chat UI with turn
ChildProcess-->>Service: item/fileChange (approval request)
Service->>Service: Store pending approval
Service-->>Renderer: codexEvent (approval request)
Renderer->>Renderer: Render approval card
Renderer->>IPC: codexRespondApproval({ requestId, decision })
IPC->>Service: respondToApprovalRequest(requestId, decision)
Service->>ChildProcess: approval/response (JSON-RPC)
ChildProcess-->>Service: acknowledgement
Service->>Service: Clear pending approval
sequenceDiagram
participant User as User
participant CodexChatUI as CodexChatPage Component
participant ChatState as Chat State (Reducer)
participant API as Codex API (IPC)
User->>CodexChatUI: Select lane, write prompt
CodexChatUI->>API: getLaneBinding(laneId)
API-->>CodexChatUI: CodexLaneThreadBinding
alt Thread exists
CodexChatUI->>API: threadResume({ laneId, threadId })
else No thread
CodexChatUI->>API: threadStart({ laneId, model })
end
API-->>CodexChatUI: CodexThread
CodexChatUI->>ChatState: hydrate-thread action
ChatState-->>CodexChatUI: Updated state with turns
CodexChatUI->>CodexChatUI: Render thread turns
User->>CodexChatUI: Submit prompt with model/effort
CodexChatUI->>API: turnStart({ threadId, prompt, model, effort })
CodexChatUI->>API: onEvent(callback)
API-->>CodexChatUI: Turn initiated
API-->>CodexChatUI: codexEvent (turn-started, item notifications, completion)
CodexChatUI->>ChatState: notification action (streaming deltas)
ChatState-->>CodexChatUI: Updated with turn items
CodexChatUI->>CodexChatUI: Re-render active turn
alt Item requires approval
API-->>CodexChatUI: codexEvent (approval request)
CodexChatUI->>CodexChatUI: Render ApprovalCard
User->>CodexChatUI: Accept/decline
CodexChatUI->>API: codexRespondApproval({ requestId, decision })
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Note 🎁 Summarized by CodeRabbit FreeYour organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login. Comment |
Ports items #6, #8, and #10 from .factory/library/mobile-chat-port-plan.md. #6 — Streaming shimmer on the active assistant bubble: - New ADEStreamingShimmer view modifier (Views/Components/): gentle one-direction gradient sweep + accent glow + stroke, gated on isActive and respecting Reduce Motion (becomes a no-op). - WorkChatMessageBubble gains an `isLive: Bool = false` prop; the timeline call site passes `isLatestAssistantMessageLive(message)` so only the newest assistant bubble of an active session shimmers. - Liveness derivation lives in WorkChatSessionView+MessageLiveness.swift (scans visibleTimeline tail for the most recent assistant message id). #8 — Tool-result truncation, preview, and show-all toggle: - WorkToolCardView now shows a first-line preview in the collapsed header. - Long results (> 500 chars) render truncated with a "Show all (N chars)" toggle; copy affordance is already provided by WorkOutputBlockHeader. - Helpers: workToolResultPreview, workToolResultTruncate, workToolResultByteLabel. #10 — Context-compact divider: - WorkEventCardView special-cases kind == "contextCompact" and renders the new WorkContextCompactDivider: a horizontal hairline flanking a warning-tinted chip with the rectangle.compress.vertical icon, tokens freed (when parseable) and an AUTO/MANUAL trigger tag. - Parsing handled by WorkContextCompactSummary. Tests (appended to the end of ADETests.swift per coordination rule): 8 new unit tests covering the truncation boundary, preview extraction, byte-label formatting, and context-compact summary parsing.
* Add sticky commit bar to lane detail * WIP: lanes tab, sync pairing, and settings refactor Staging in-progress work on mobile lanes tab, desktop sync pairing (PIN store), iOS design system/haptics, and connection settings screen before merging in work tab branch. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * mobile work tab Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fixing review agent comments * Sync active lane presence across devices * Restore the green iOS baseline validation path Make PR list hydration tolerate older/local test schemas, restore lane list ordering, and fix the manifest desktop test command so baseline validation can run again. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * Promote shared iOS glass primitives and cache code highlighting * Polish cached Lanes state and gating Keep cached lane context visible while making offline, hydrating, and syncing states explicit so live git actions never fail silently on iPhone. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * Restore cached lane diff inspection offline * Synthesize foundation scrutiny rerun Record the passing foundation scrutiny rerun after restoring the iOS-only hard gate and verifying the offline lane diff regression fix. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * Extend Work sync parity metadata Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * Prioritize mobile Work session triage and chat creation Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * Split mobile Work session detail views Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * Synthesize work scrutiny findings Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * Fix Work follow-up parity gaps Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * Synthesize work scrutiny rerun Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * Add read-only Files mobile cache contracts Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * Rebuild the iOS Files mobile browser Split the Files tab into focused browser/detail slices so workspace switching, breadcrumbs, root-state messaging, and search flows stay readable and explicit on iPhone. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * Fix Work crash on duplicate transcript merge keys The cached base transcript can occasionally hold two envelopes with the same merge key (hosts replay activity events during resume), and the previous Dictionary(uniqueKeysWithValues:) init fatal-errors on that. Replace it with an in-place dedupe that keeps the later envelope and harden laneById against duplicate lane ids with uniquingKeysWith. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Rework Files detail around content-first layout Replaces the five-stacked-glass-cards surface with a pinned thin header (icon + name + language/size/read-only chips), an inline mode and diff picker, and a content-hero region that lets the code/diff/image fill the screen instead of competing with metadata chrome. Metadata and history move into a Details sheet (info.circle in the toolbar) so they stay one tap away without dominating the read flow. Compact banners replace ADENoticeCard for disconnected / load-failure states, and the binary and image-pending fallbacks share a single centered FilesContentFallback. Transition IDs (files-container / -icon / -title) stay stable so the zoom-push from FilesDirectoryScreen keeps working. All read-only framing is preserved. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Reshape Settings into a mobile shell with grouped sections Split the 1156-line ConnectionSettingsView.swift into focused sub-files under Views/Settings/ (all < 500 lines), and reorganize the screen into a proper settings shell: connection status header with inline reconnect/ disconnect quick action, pairing section (Discover/Scan/Manual), theme tiles, and a diagnostics/about section with app version and paired-host details. Preserves Bonjour discovery, QR scanning, manual entry, and PIN pairing flows end-to-end via the existing SyncService API — no new service methods. - ConnectionSettingsView.swift: top-level shell + aurora background - SettingsSupportTypes.swift: PinPreset, PairSheetRoute, status tint helpers - SettingsConnectionHeader.swift: live indicator + host + Reconnect action - SettingsPairingSection.swift: pair rows + Discover/QR/Manual sheets - SettingsPinSheet.swift: PIN entry + digit box + keypad - SettingsAppearanceSection.swift: theme tiles - SettingsDiagnosticsSection.swift: version, paired host, last sync * Cache Work activity transcripts and flatten filter chips - Memoize parseWorkChatTranscript per session + buffer fingerprint so activityFeed stops re-parsing every localStateRevision tick - Split WorkChatSessionView timeline switch into @ViewBuilder helpers in a new WorkChatSessionView+Timeline.swift (keeps parent under 500 lines and lets the compiler skip card branches that haven't changed) - Replace LaneMicroChip glass chips inside the filter card with a flat WorkFlatCountChip to avoid glass-on-glass nesting * Add PR mobile snapshot sync contract for iOS parity Introduce prs.getMobileSnapshot: a single viewer-allowed sync command that returns stack metadata, create-PR eligibility, workflow cards (queue/integration/rebase), and per-PR capability gates in one payload so the iOS PRs rebuild can render its list/detail/workflow surfaces without fanning out across several commands. Contract is additive — existing desktop consumers of the PR service and sync registry are untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Harden Dictionary merges and drop duplicate matched-geometry sources Part A — replace Dictionary(uniqueKeysWithValues:) with coalescing merges so sync reconciliation overlaps cannot fatal the app: - Database.swift lane-row keying - LaneTreeView.swift lane-by-id memo Part B — remove the destination-side matchedGeometryEffect emissions in LaneDetailHeaderCard and WorkSessionHeader. The container's navigationTransition(.zoom(sourceID:)) interpolates child layouts during the push, so having the detail header ALSO emit isSource=true for lane-icon/title/status and work-icon/title/status groups is what SwiftUI warns about ("Multiple inserted views ... have isSource: true, results are undefined"). The list rows remain the sole source. Init signatures are preserved for call-site compatibility. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Split PRsTabView.swift into per-file view modules Pure mechanical file split of the 2480-line monolith into 14 focused files under apps/ios/ADE/Views/PRs/ so the PRs mobile rebuild can operate on clean per-file surfaces. No behavior, type, data-access, or UI changes. Visibility narrowed from private to internal where types are shared across the new files. New files (all under 500 lines): - PrModels.swift — shared structs/enums - PrHelpers.swift — free functions + ISO date parsing - PrListRowModifier.swift — .prListRow() modifier - PrFiltersCard.swift — filters card + signal chip - PrRowCard.swift — list row card - PrsRootScreen.swift — PRsTabView root - PrDetailScreen.swift — PrDetailView root - PrDetailOverviewTab.swift — overview tab, header, section card, chip wrap, cleanup banner - PrDetailFilesTab.swift — files tab, file diff card, unified diff view - PrDetailChecksTab.swift — checks tab, check row - PrDetailActivityTab.swift — activity tab, timeline row - PrWorkflowCards.swift — integration/queue/rebase workflow cards - PrStackSheet.swift — stack members sheet - CreatePrWizardView.swift — wizard + step indicator + markdown renderer PRsTabView.swift is emptied to a forwarding comment to preserve the pbxproj reference without further edits. * Wire PrMobileSnapshot into PRs root screen and unified workflow cards Adopt the new prs.getMobileSnapshot contract (commit ad17c74) on the root PRs surface. The snapshot replaces the three-fan-out fetch for queue/integration/rebase state with a single unified `workflowCards` array, and its per-PR capability map drives the row swipe actions so Close/Reopen respect the host's actual gating instead of guessing from the stored PR state. Create PR is now disabled when createCapabilities reports canCreateAny=false, and the previously dead status notice for disconnected / hydrating / failed phases finally renders at the top of the list. Adds a new PrMobileWorkflowCardView that dispatches on card.kind (queue | integration | rebase) so one ForEach covers all three. Legacy per-kind fetches remain as a fallback so the list still renders when a paired host predates the mobile-snapshot command. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Gate PR detail actions and Create wizard by host capabilities Thread PrActionCapabilities through PrDetailScreen → PrOverviewTab so merge/close/reopen/request-reviewers/rerun-checks/comment gates come from the host snapshot when available, and fall back to the legacy supportsRemoteAction probe + PrActionAvailability when the mobile snapshot hasn't arrived (offline / pre-contract host). Surface mergeBlockedReason under the merge button so users see the specific blocker (draft, failing checks, closed) instead of a silent disabled state. Accept optional PrCreateCapabilities in CreatePrWizardView. When present, the lane picker shows only eligible lanes, the target branch defaults from the host, and blocked lanes are listed separately with their blockedReason. Nil fallback keeps the existing LaneSummary flow intact so offline create still works. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Forward createCapabilities from root into Create PR wizard The wizard's new createCapabilities prop was landed with a nil default so the root-screen wire-up could follow. Pass mobileSnapshot?.create Capabilities through so the wizard actually filters to canCreate lanes, fills the default base branch from host metadata, and surfaces each lane's blockedReason. With a nil snapshot the wizard still falls back to the raw lanes list unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Polish PR stack sheet, workflow cards, and create wizard Stack sheet: join PrGroupMemberSummary with snapshot.stacks data so the chain renders with role badges (BASE/BODY/HEAD), depth indentation (capped at 4 levels to stay readable on iPhone), dirty-worktree warnings per lane, and live PR state pills. Each member with a PR is now a tap target that pushes PrDetailView inside the sheet's own NavigationStack — users no longer have to dismiss + re-navigate from the root to open a stack member. Falls back to position-derived depth when the snapshot is unavailable so offline stacks still render. Workflow cards: - queue: position chip (3/5) now renders next to the title instead of at the bottom, so progress is visible before the action buttons - integration: "Open linked PR" upgraded from ghost glass to prominent glass with a full-width label + icon so the escape hatch reads as a primary action - rebase: the CONFLICT badge is a new PrConflictBadge with a solid red background and warning icon so a predicted conflict can't be glanced past alongside the other tinted status pills Create wizard: - PrStepIndicator: active step title shown beside the counter, segment labels align to segments and highlight up-to-current-step so users see where they are at a glance - Blocked-lane list got a lock icon header, per-row minus-circle icons, and a subtle warning-tinted background so "not eligible" reads as a deliberate state, not greyed-out content Tests: two new cases for buildStackRows covering snapshot-joined and fallback paths. Build + targeted tests green on iPhone 17 Pro. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add chat activity indicator pill + per-turn provider badge Ports items #2 and #3 from .factory/library/mobile-chat-port-plan.md. - WorkActivityIndicator.swift: new view that scans the transcript tail for the most recent running command / tool call / file change / named activity / web search / subagent event and renders a one-line pill ("Running: ls -la", "Editing .../foo.ts", "Searching", etc.) with a pulsing dot. Falls back to "Thinking" when nothing specific is streaming. Respects Reduce Motion. Drop-in for WorkChatSessionView.streamingStatusSection. - WorkChatHeaderAndMessageViews.swift: WorkChatMessageBubble now picks up the active session's provider from a new `workChatProvider` environment value and renders a compact provider chip (icon + label tinted by providerTint) next to the "Assistant" role label. No change to call sites — ancestor views opt in via .environment(\.workChatProvider, chatSummary?.provider). * Add reasoning card and smart-autoscroll jump-to-latest pill to Work chat Ports items #1 and #5 from the desktop chat port plan. A new WorkReasoningCard replaces the generic event-card rendering for reasoning entries: brain icon pulses while a turn is streaming, the body stays open, three staggered dots signal live thinking, and once the turn settles the card auto-collapses behind a "Reasoning" label so it stops competing with the final assistant message. Smart autoscroll now tracks an unreadBelowCount instead of always yanking the view: when the user scrolls up and new entries arrive, the count accumulates and a floating WorkJumpToLatestPill appears at the bottom-trailing edge showing "N new" with the accent tint. Tap it or naturally scroll back to the end to clear the count. Reduce-motion is honored throughout via ADEMotion + static-dot fallbacks. Also lands .factory/library/mobile-chat-port-plan.md so future work on the other eight port-plan items has the same inventory to work from. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Upgrade queued-steer strip with collapsible rows and haptic cancel Chat composer plan item #7 — queued-steer strip. The mobile strip now mirrors desktop's PendingSteerItem treatment: - Header collapses by default to a compact "N queued · preview" row with a chevron; taps toggle the list. Strip auto-expands whenever a row enters edit mode so edits aren't hidden behind a collapse tap. - Each row ribbon starts with a discreet "Queued" pill (accent tint) and a relative timestamp so the queued state reads at a glance without competing with the body text. - Body text now clips at 2 lines with tail truncation. Long messages stay reachable through the Edit button's inline TextEditor. - Cancel drives a light-weight .sensoryFeedback(.impact) so the strip confirms destructive actions through haptics, not just visuals. File stays under the 500-line ceiling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Rebuild Work sidebar to match desktop SessionListPane architecture Replaces the phone-only filter card + hard-coded section stack with the same information architecture the desktop uses: a compact toolbar (Search + New Chat accent button + Filter funnel toggle), an expandable Group/Lane filter panel, and a grouped session list driven by the user's chosen organization — byLane / byStatus / byTime — with collapsible section headers showing icon, label, and count badge. Organization and per-section collapse state persist via @AppStorage so the sidebar reopens the way it was left. Iconography matches desktop: magnifyingglass for search, line.3.horizontal.decrease.circle for the funnel, plus for New Chat, arrow.triangle.branch for lane grouping, and colored status dots for the byStatus sections. Removes the always-on status chip strip and the standalone "0 live" chip in favor of the filter-panel-scoped live/waiting count chips that only appear when the panel is open. Session row logic extracted into a reusable WorkSessionListRow so the new grouped loop can render any organization with consistent swipe and context-menu actions. New WorkSessionGrouping.swift holds the pure grouping helpers (byStatus / byLane / byTime bucketers plus the AppStorage serialization for collapsed-section ids). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Wire WorkActivityIndicator + provider env into the chat session view Drops the spinner-only streamingStatusSection in favor of the activity indicator settings-worker shipped at 24db48b, so the tail of the transcript always surfaces the current tool/command/file edit as a "Label · detail" pill when a turn is streaming. Also injects \.workChatProvider down the transcript ForEach so every assistant bubble can render the per-turn provider chip without each row having to resolve it independently. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Chat polish: streaming shimmer, tool result truncation, compact divider Ports items #6, #8, and #10 from .factory/library/mobile-chat-port-plan.md. #6 — Streaming shimmer on the active assistant bubble: - New ADEStreamingShimmer view modifier (Views/Components/): gentle one-direction gradient sweep + accent glow + stroke, gated on isActive and respecting Reduce Motion (becomes a no-op). - WorkChatMessageBubble gains an `isLive: Bool = false` prop; the timeline call site passes `isLatestAssistantMessageLive(message)` so only the newest assistant bubble of an active session shimmers. - Liveness derivation lives in WorkChatSessionView+MessageLiveness.swift (scans visibleTimeline tail for the most recent assistant message id). #8 — Tool-result truncation, preview, and show-all toggle: - WorkToolCardView now shows a first-line preview in the collapsed header. - Long results (> 500 chars) render truncated with a "Show all (N chars)" toggle; copy affordance is already provided by WorkOutputBlockHeader. - Helpers: workToolResultPreview, workToolResultTruncate, workToolResultByteLabel. #10 — Context-compact divider: - WorkEventCardView special-cases kind == "contextCompact" and renders the new WorkContextCompactDivider: a horizontal hairline flanking a warning-tinted chip with the rectangle.compress.vertical icon, tokens freed (when parseable) and an AUTO/MANUAL trigger tag. - Parsing handled by WorkContextCompactSummary. Tests (appended to the end of ADETests.swift per coordination rule): 8 new unit tests covering the truncation boundary, preview extraction, byte-label formatting, and context-compact summary parsing. * Use branded provider logos + fix Work sidebar row and chat header layout User flagged three concrete problems in the mobile Work surface: the session rows all shared a generic grey brain icon instead of the branded model logos the desktop uses, timestamps leaked as raw ISO strings (`2026-04-16T07:40:08.095Z`) because the parser did not accept fractional seconds, and the chat detail header wrapped its chip row off-screen while showing a useless "—" duration and a truncated "Summa..." column on mobile. Bundle the LobeHub static SVGs (claude-color, codex-color, cursor, opencode + anthropic / openai family marks) into Assets.xcassets as Provider* imagesets with preserves-vector-representation. A new WorkProviderLogo view renders the branded asset when one exists and falls back to the tinted SF Symbol we already had. Rewrite WorkSessionRow around the desktop SessionListPane cadence: one line for status-dot + title + relative time, an optional preview line, and a horizontal-scrolling chip strip (status pill, lane, model, device presence). Drop the standalone duration column that was only ever rendering "—" on ended sessions. Rewrite WorkSessionHeader to a single collapsed meta line ("3h ago · sonnet"), a horizontal chip ScrollView so "Claude / sonnet / Primary" never clips past the edge again, and a summary line that only renders when there is a real summary — no more empty "Duration —" placeholder. Session starts now parse cleanly thanks to workDateFormatterFractional, so "Started" reads as relative time everywhere else too. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Polish Work logo avatar container + row chip strip + ended-session control bar Wrap the branded provider SVG in a tinted rounded-card container so the mobile mark reads the way desktop's Claude.Avatar / Codex.Avatar do — logo centered on a tinted squircle with a hairline border — instead of a raw glyph floating on the background. Drop the redundant ENDED / closed status pill from session rows whose status matches the section they already live in. Needs-input, running, idle, and archived rows keep their pill because those differ from the group header. Rewrite WorkSessionControlBar around the actual session states. On an ENDED session the only sensible action is Resume, so the old "Close session" filled button (closing an already-closed session) is gone and Resume chat takes the whole row as the prominent CTA. On active turns Interrupt is the prominent action; End is the small escape hatch. Idle and awaiting-input get a Resume + End pair. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Drop the Turn finished card that dumped raw usage JSON into the transcript The Turn status card already marks completion and the session usage summary card at the top of the chat aggregates token spend. Rendering a second "Turn finished" event card for every .done envelope just dumped the host's raw summary string — usually a JSON usage blob — into the transcript alongside the real messages. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add inline model picker to the chat composer Composer chip strip now surfaces Model / Access / Profile as three labeled pills with the branded provider logo on the model chip, and tapping the model chip opens a WorkModelPickerSheet that mirrors the desktop ProviderModelSelector mobile variant — grouped by provider, branded logos inline, tier badge, one-line tagline, and a checkmark for the currently applied model. Committing a selection calls updateChatSession on the paired host and marks the change with a light haptic. Unknown models (e.g. a freshly released id the mobile catalog doesn't know about yet) still render as the active entry so the picker never hides the live host model. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Replace New Chat modal with a pushed welcome screen Desktop's "+ New Chat" flows straight into a dedicated welcome page: big ADE word-mark, tagline, workspace pill, and a prominent composer at the bottom. Typing and sending turns the page into the live chat without bouncing back to a sidebar. Mobile currently opens a six-step slide-up sheet for the same intent, which the user called out as wrong for this flow. Add WorkNewChatScreen and a WorkNewChatRoute hashable value. The "+" toolbar button, the inline "New Chat" button in the sidebar toolbar, and the empty-state CTA now push the route onto the NavigationStack path instead of opening WorkNewChatSheet. Inside the screen the composer defaults to claude-sonnet-4-6 but the same WorkModelPickerSheet the chat composer uses is one tap away, so users can still switch provider/model before sending. Submitting creates the host chat session and replaces the path with the live session route — Back goes to the sidebar, not to an empty form. The old WorkNewChatSheet stays in the codebase for now so the sync surfaces it still references keep compiling; it can be pulled once nothing else presents it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Streamline Work chat composer, picker, and chrome Drop the bulky two-line composer chips for a single-row strip of compact desktop-style pills (access dot + runtime label, model logo + short name). The access pill flips runtime modes through an inline menu instead of routing users through a separate Chat Settings sheet, which is removed from the UI entirely. Redesign the model picker to match the desktop ProviderModelSelector — "Select Model" title, search field, provider tab strip with count badges, and grouped rows with active/Ready chips. Hide the tab bar once a session is pushed so the transcript and composer get the full screen. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Collapse Work composer into a single desktop-style container Fold the text field, pill strip, and send button into one rounded container so the composer reads as one unit like the desktop "Type to vibecode…" box. Drop the redundant ENDED/RUNNING status pill row with its inline play/stop button — the session control bar above the transcript already owns the Resume/Interrupt CTA and the "Resume this chat before sending another message." feedback line below communicates status in prose. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Flow Work chat transcript like a document Drop the heavy card chrome the timeline used on every message, turn status, and system notice. User messages stay right-aligned with a small tinted bubble; assistant messages shed the box entirely and render as provider-chipped prose. Low-value event kinds (status / activity / notice / todo / autoApproval / webSearch / promptSuggestion / toolUseSummary / pendingInputResolved) collapse into a single-line ribbon instead of a full card, matching the desktop's document-style feel. The session header drops its adeListCard chrome for a compact status dot · lane · meta line, and the reasoning card now defaults to collapsed with a slimmer background so it stops competing with the final assistant turn for attention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Tune composer typing, markdown pills, and model catalog - Composer now accepts keystrokes while the session is ended so the user can draft before hitting Resume. The feedback line below still explains why send is blocked, and Send itself stays disabled via a new canSend gate. - Give inline markdown code runs the desktop pill treatment — tinted accent background, monospaced font, accent foreground — so identifiers, branch names, and file paths pop out of prose like they do on desktop. - Expand the mobile model catalog to mirror apps/desktop/src/shared/modelRegistry.ts: Claude Opus 4.6 / Opus 4.6 1M / Sonnet 4.6 / Haiku 4.5; the seven shipping Codex CLI tiers; Cursor Auto/Sonnet-thinking/Sonnet/GPT-5/Codex; and a broad OpenCode row set (Anthropic / OpenAI / Google / xAI / DeepSeek / LM Studio / Ollama). Ids match the sync-contract shape the host accepts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Ship a properly defined composer card Rewrite composerInset so the composer reads as one bold rounded container rather than a faint tinted rectangle. The new card gets a deeper recessedBackground fill, a 22pt radius, a visible border stroke, and a subtle drop shadow so it sits cleanly over the chat surface. The send button shows a visible disabled state (accent ring outline + secondary glyph) instead of fading into the background, and takes on an accent fill with a soft purple halo the moment typing begins, mirroring the desktop "Send" affordance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Give composer desktop-shaped Send pill and full model name The composer now renders the model pill with the desktop display name ("Claude Sonnet 4.6" instead of just "Sonnet"), and replaces the icon-only circle with a desktop-shaped Send pill — paperplane glyph plus a "Send" label in a capsule that fills with accent and halos purple the moment the draft has content. Wrap the card in ultra-thin material over a darker tint so the composer sits cleanly against the transcript background and reads as one container. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Drop outer composer inset bubble The composerInset used to paint a faint glass rectangle around the whole bottom area (composer card + feedback text), which read as a second "bubble" overlapping the composer card itself. Since the composerCard now owns its own clearly-defined rounded surface we can drop the outer background + glassEffect and let the feedback text sit plainly below the card. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Move composer feedback above the composer card The "Resume this chat before sending another message." (and similar status-blocked messages) now render above the composer card instead of below, and the bottom padding is dropped to zero so the card sits flush with the safe-area edge. Feedback text is also centered to read as a banner hint rather than a footnote. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Match desktop model picker organization and new chat composer - Model picker now uses the desktop ModelCatalogPanel's 2-level hierarchy: CLAUDE / CODEX / CURSOR / OPENCODE group tab strip, then a provider badge row inside the active group. OpenCode exposes its full provider ladder (Anthropic, OpenAI, Google, xAI, DeepSeek, LM Studio, Ollama) as horizontal badges just like the desktop panel, with count chips on each. Search mode flattens results and renders them grouped by group header, mirroring desktop behavior. - New chat welcome page composer rebuilt to share the same rounded glass card, model pill logic (full "Claude Sonnet 4.6" name), and desktop-shaped Send capsule as the in-session composer, dropping the prior ad-hoc chip + round-button layout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * WIP: in-progress mobile droid work before merging main Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * WIP: iOS chat redesign + unified per-provider chat accent iOS: - New chatSurfaceAccent with per-provider table (Claude amber, Codex warm-white, etc.) mirroring the shared desktop mapping so each chat reads the same on both surfaces. - Per-model reasoningTiers lookup matching MODEL_REGISTRY — effort pill only renders for models that advertise tiers (Opus: low/med/high/max, Sonnet: low/med/high, Haiku hides entirely, Codex tiers: low/med/high/xhigh, mini: med/high). - Compact "…" nav toolbar menu (lane name + Go to lane) replacing the inline status row under the chat title. - Stop button folded into the composer Send slot while the assistant streams; removed the full-width yellow WorkSessionControlBar. - Turn-separator pill between user turns, assistant message card surface, dropped redundant model-badge chip above each response and inside the USAGE row, compact Thought pill, k/M-abbreviated usage numbers with separate Cache / New cache. - Retired unused LaneChatSessionView and its pbxproj references. Desktop: - PROVIDER_CHAT_ACCENTS + providerChatAccent() in chatSurfaceTheme.ts; AgentChatPane's draftAccent routes through provider first so Claude/Codex chats share a single tone across model variants. - resolveModelAlias now falls back to bySdkModelId so provider-model-id forms like "claude-sonnet-4-6" or "gpt-5.4-codex" resolve — fixes mid-turn model switches from iOS which sends that form. Known build issue (pre-existing, not mine): apps/ios PRs area has uncommitted WIP referencing PrSingleLineEditSheet / PrMultilineEditSheet / PrSubmitReviewSheet (files don't exist) and ADEColor.surface (field doesn't exist — should be surfaceBackground). Chat work compiles fine in isolation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Finalize pass: simplify sync/chat services, align iOS schema, docs - Drop dead code across sync host/remote-command/pairing services, chat message builder, agent chat service, registerIpc, and PackedSessionGrid - Mirror iOS files-cache schema (files_workspaces + 4 snapshot tables) into desktop kvDb migrations so generate-ios-bootstrap-sql stays in sync - Stabilize CommandPalette test by making getDetail's default resolved value survive mockClear between tests - Refresh internal docs for chat slash-command discovery, provider accents, PackedSessionGrid resize model, lane device presence, Linear dispatch replay, and sync pairing/pin stores Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix PR review and CI issues * Retry buffered sync updates on transient failures * Include project root in PTY broadcast tests * Preserve local Claude commands and orchestrator compaction --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…ss (#696) * ade code: per-provider Chat/CLI interface choice Add an Interface: Chat | CLI row to the ADE Code (Ink TUI) new-chat and /model setup panes, matching the desktop/iOS switcher. Chat creates an SDK chat via chat.createSession (all providers, including Claude — a new path); CLI starts a tracked provider CLI terminal via start_cli_session (claude/codex/cursor/droid/opencode). Defaults to Chat; editable on a draft, read-only once a session exists. - Generalize the Claude-only terminal paths: startClaudeTerminalSession -> provider-generic startCliTerminalSession; listTerminalSessions and remoteLauncher.isTerminalSessionLaunchable now surface every tracked CLI provider (not just Claude); terminalSessionToChatSummary, the Ctrl+T control gates, TerminalPane status ("<PROVIDER> CONTROL"), FooterControls label, and grid control hint are provider-neutral. - Submit-path branching: focused terminal -> pty send/resume (Claude keeps its double-enter, other providers use pty.sendToSession); draft Interface=CLI -> tracked CLI terminal; otherwise chat.createSession. - Interface-aware Cursor model gating in the model picker (Chat disables CLI-only Cursor models and vice versa). - Keep Claude-only chrome: closed-transcript stripping, naming hint, and /model + /effort writing into a running Claude terminal. - Tests: provider-generic start payloads (5 providers), trackedCli provider resolution, listTerminalSessions/isTerminalSessionLaunchable inclusivity, interface-row state machine + defaulting, Cursor gating. - Docs: ADE Code README chat-setup + terminal-control sections. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ade cli: remote/sync reliability hardening (audit batch) Fixes remote/sync audit findings #1-#4, #6-#20, plus perf M6, bug-sweep #13, and perf L9. Skips #5 per disposition because TUI app/component files are owned by concurrent perf work. Implements #6 as the requested partial version/capability validation only. * ade code: perf — single-pass rows, memoized children, debounced background refresh Findings: - H1: derive selectable transcript rows once per render and build copy text lazily. - H2: reuse pending steers, gate full Chat Info scans, and collapse cheap latest scans. - H3/M8: memoize hot TUI children and stabilize high-churn props without adding hover throttles. - H4: debounce/coalesce background session refreshes with an in-flight guard. - M5: LRU-cache assistant markdown parses by message text. - M7: tighten terminal grid reads and avoid trailing blank cell/row rendering. - L10: reconcile local Ink install to locked 7.1.0 and validate ADE CLI. * ade code: fix tui bug and parity batch * ade code + sync: review-wave fixups (R1-R5, T1-T2) * Persist Work Chat/CLI interface choice on iOS (+ desktop verify) The in-project New Chat screen and the all-projects hub composer both defaulted the Chat/CLI switcher to .chat on every open, so the choice was forgotten across app restarts and project switches. Add a shared per-project store (WorkNewSessionModePreferences, keyed by project id in the app-group UserDefaults) that mirrors desktop's per-project WorkProjectViewState.draftKind. - Seed sessionMode from the store in each composer's init (not onAppear) so the sessionMode onChange never fires to reset runtimeMode to the provider default. - Persist only on an explicit Chat/CLI switcher tap via a new onUserSelect callback; programmatic model-availability fallbacks never write the store. - A stored CLI choice is honored only when the restored model can run in CLI; otherwise the session opens on chat without discarding the preference. Desktop already persists this per project (WorkProjectViewState.draftKind in localStorage ade.workViewState.v1, preserved across new-draft/close-tab/lane- refresh/project-switch); no desktop change needed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ade code: interface persistence, closed-session browsing, /secrets, picker digit fix * Hub composer: reload per-project Chat/CLI mode on project switch The hub composer seeded sessionMode once in init and never reloaded it when the destination project changed, so switching from a project saved as Chat to one saved as CLI (or vice versa) left submit branching on the stale mode. Centralize the availability-aware fallback in a pure WorkNewSessionModePreferences.resolvedMode(stored:modelId:provider:) (reused by both composers' init) and add HubComposerDrawer.reloadSessionMode(forProjectId:) called wherever pickedProjectId changes — the projectRow tap and the reconcileDestination fallback. Reload is read-only; explicit switcher taps remain the only writes, and a stored CLI choice still drops to chat for the session when the current model can't run in CLI without discarding the pref. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ade code: quality synthesis — digit-picker fixes, app.tsx extractions, UI polish * ade code: closed-session glyph — user-initiated closes are not failures Re-review finding: exit 130/143 and runtimeState 'killed' are the daemon's user-initiated close classification (ptyService.statusFromExit), so the closed-session drawer was marking most intentionally closed CLI sessions with the failed glyph. Only terminalStatus === 'failed' or a genuine non-{0,130,143} exit code renders failed now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ade cli: parity with hardening branch * docs: sync internal docs with ade-code hardening branch * test: steward pass — prune, consolidate ade-code suite * ship: iteration 1 — address 10 review comments (greptile P1, codex P1, coderabbit x8) - remoteBridge + cli.ts transports: normalize JSON-RPC response ids (class sweep) - connection.ts: unlink stale socket before daemon spawn retry; owner-aware spawn-lock cleanup - eventBuffer: oversized skipped events mark replay gaps - syncHostService: queued-message watchdog timeout + warning metadata - TerminalPane: full-column wide-glyph scan - displayWidth: splitByDisplayCells single-pass grapheme partition (+ new test file) - modelState: ollama/lmstudio use OpenCode permission behavior (not Cursor) - state.ts: final state write flushed + awaited on signal exit - preload: gap polling no longer fires same-binding project refresh Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ship: iteration 2 — address 2 codex P3s (grid claudeChrome, CJK truncate overflow) - MultiChatGrid passes claudeChrome by derived terminal provider so non-Claude grid tiles keep neutral closed-transcript cleanup - truncateDisplayEnd uses a cluster-boundary-safe prefix so a leading wide grapheme can no longer overflow the allocated cell width (+ regression tests) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ship: iteration 3 — quote-aware VISUAL/EDITOR parsing (codex P3) splitEditorCommand now tokenizes with quote/escape support so editors like 'emacsclient -a ""' or app paths with spaces keep working after the shell:false hardening; quoted empty strings survive as argv entries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ship: iteration 4 — provider-aware terminal hydrate + local-provider permission cycle (codex P1/P2) - selecting a non-Claude tracked CLI session now hydrates model state from terminalSessionProvider instead of forcing claude (footer/model targets the right provider for subsequent changes and launches) - cyclePermission + updateChatModel payload route ollama/lmstudio through the OpenCode permission branch via runtimeProviderForUiProvider (class sweep) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* chat: stop ADE overriding the user's Claude settings at flag tier ADE passes its Claude settings to the Agent SDK at flag tier, which outranks every settings.json the SDK reads. Three keys were being sent unconditionally, so a value ADE invented always beat the user's own configuration: - outputStyle: resolved from the lane's settings.local.json with a `?? "Default"` fallback. "Default" is a real style, so a style configured in ~/.claude never applied to any ADE chat. The resolver also wrote that substituted value back into the session cache and read the cache first on the next build, which pinned it permanently once a session had started. - workflowSizeGuideline: hardcoded "medium", so the user's /config choice had no effect. ADE keeps supplying "medium" as its own default, but only while no settings file states one. - The user-tier root ignored CLAUDE_CONFIG_DIR, unlike the six other ADE modules that read Claude config, so a relocated config dir was invisible here. The rule: name a settings key only when ADE genuinely owns it, and otherwise leave it absent so the SDK's own local > project > user precedence resolves it. ADE already opts into that precedence via settingSources. enabledPlugins stays unconditional — the CLI merges it per plugin key rather than replacing the map. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nv4uczqGgnQBnrz1tNz5Lj * codex: stop forcing service tier to "default" over the user's config.toml codexServiceTierArgs returned an explicit null whenever fast mode was not on, which includes every session where the user never touched the fast toggle. Verified against a live `codex app-server`, with service_tier = "priority" in config.toml: omit -> serviceTier = priority (the user's value survives) null -> serviceTier = default (the user's value is erased) and with no service_tier configured at all: omit -> no tier null -> "default" fast -> "priority" So null is a real downgrade rather than a neutral "no opinion", and ADE shows no service tier anywhere for the user to notice or undo it. Fast-off cannot mean "force default" either: fastMode is persisted only when true and rehydrated as `persisted?.fastMode === true`, so false is indistinguishable from never-set. Omitting is the only honest encoding of "ADE is not forcing a tier"; the app-server re-resolves per request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nv4uczqGgnQBnrz1tNz5Lj * providers: read config from the directory each CLI actually uses Every provider CLI has an env var that relocates its config directory, and ADE ignored some of them — so ADE read one directory while the process it spawned read another, inside a single session. Confirmed with a sentinel custom model: with FACTORY_HOME_OVERRIDE set, `droid` lists the override home's models while droidModelsDiscovery read the real home's. The overrides do not share a shape, which is why this is a helper rather than a find-and-replace: - CLAUDE_CONFIG_DIR and CODEX_HOME name the config directory itself. - FACTORY_HOME_OVERRIDE replaces the HOME that ".factory" is appended to (`join($R(), ".factory")` in the droid v0.70.0 binary, where $R() is `process.env.FACTORY_HOME_OVERRIDE || homedir()`). Read paths only; no behavior changes for anyone without these vars set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nv4uczqGgnQBnrz1tNz5Lj * cursor: stop switching off a sandbox policy the user configured ADE sent `sandboxOptions: { enabled: local.sandboxEnabled }` unconditionally. In the vendored SDK an explicit `false` and an absent key are not equivalent: if (!1 === n?.enabled) return { defaultSandboxPolicy: { type: "insecure_none" } } const o = Q(r) ? r : (!0 === n?.enabled ? Y("workspace_readwrite", ...) : void 0) `false` returns before `perUserSandboxPolicy` — the user's ~/.cursor/sandbox.json — is ever read. ADE sent `false` for agent mode, so a user who wrote a Cursor sandbox policy had it silently switched off. The SDK's own error text ("remove ~/.cursor/sandbox.json to run without sandboxing") shows that file is meant to be authoritative. A boolean cannot express this, so the policy layer now states a directive: enable — ask/plan. ADE asks for a sandbox; a user policy still wins. disable — full access. No sandbox, including for a user who wrote a policy, because full access means full access. Also the retry after a ConfigurationError, where the environment cannot sandbox at all and the alternative is a hard failure. inherit — agent mode. ADE has no sandbox UI here, so it says nothing and the user's file decides. The retry guard now keys off the error and the not-yet-downgraded flag rather than off whether ADE asked for the sandbox, because with "inherit" the unsupported-environment error can now surface through the user's policy instead of ADE's request. The permission fingerprint tracks the directive, since "disable" and "inherit" share a false boolean but produce different options. Note the SDK only loads any sandbox policy when an apiKey is present, so this affects users with a Cursor key configured in ADE or CURSOR_API_KEY set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nv4uczqGgnQBnrz1tNz5Lj * droid: inherit autonomy from the user's settings, and fix the dead model read-back Two defects, found by driving the real @factory/droid-sdk against synthetic FACTORY_HOME_OVERRIDE homes. 1. ADE always stated autonomyLevel and interactionMode, so the user's ~/.factory/settings.json never applied. Same call, two homes differing only in settings.json: home A (model + spec/high configured) -> gemini-3-flash-preview / spec / high home B (empty settings.json) -> claude-opus-4-6 / auto / off Omission resolves from the user's file, per key. Any value ADE states outranks it. ADE's fallback was "auto-low", which permits file edits, while Droid's documented default is autonomyLevel "off" — read-only. So ADE was handing out write access the CLI would not, and then writing that invented value into the session record where it was read back first on the next launch and pinned, exactly as in the Claude output-style bug. The SDK path now says nothing when the user picked no mode, which is what the terminal path already did — droidSettingsJson omits sessionDefaultSettings when permissionMode is null. A chosen mode, plan, and orchestration leads all still state both keys. Keys are OMITTED, never nulled: an explicit null neither clears the key nor restores the default, it wedges the Droid RPC for 30 seconds. 2. buildReady read `initResult.currentModelId`, which does not exist — the SDK reports resolved settings under `initResult.settings`. It always evaluated to null, so applyDroidSdkReadyState's adoption branch had never fired and ADE could never learn what model Droid actually chose. Now reads initResult.settings.modelId. Also fixes providerConfigHomes to resolve its base from the named `homedir` import: test suites mock node:os by spreading the real module, so a default import kept the real homedir and read the developer's own ~/.factory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nv4uczqGgnQBnrz1tNz5Lj * codex: keep reasoning effort on the thread, not on the app-server process ADE pushed `-c model_reasoning_effort="..."` onto the app-server spawn args. Two problems beyond the obvious one: - `-c` is the highest config layer. Per the documented precedence it outranks the user's ~/.codex/config.toml AND their per-project .codex/config.toml. - It is a process argument, so one chat's selection applied to every thread on that app-server. It also defeated ADE's own per-thread overlay, which was already written correctly — codexThreadConfigArgs omits model_reasoning_effort when nothing is set. Dropping the spawn flag makes the composer's effort selector mean what it says: this chat, this thread. The resolved value is still computed for display. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nv4uczqGgnQBnrz1tNz5Lj * opencode: close the plan-mode write hole and stop overriding user config PLAN MODE COULD WRITE FILES. ade-plan set edit:"deny" but deliberately left the native `task` tool enabled so child sessions would appear in the subagents pane. A spawned subagent runs under its OWN ruleset — OpenCode's `general` is merge(base, {todowrite:"deny"}), and the base is {"*":"allow"}, so edit is ALLOWED there. Plan blocked the direct write and permitted the indirect one. Plan now denies `task`. Also, since OPENCODE_CONFIG_CONTENT is merged LAST and per key — only managed/MDM config outranks it — every key ADE names beats the user's own opencode.json: - share and snapshot are no longer sent. Neither has ADE UI, and snapshot's documented default is true: forcing false silently disabled OpenCode's own /undo and /revert, which restore uncommitted in-turn state that git lanes do not cover. - autoupdate moves to OPENCODE_DISABLE_AUTOUPDATE in the server env. ADE does pin the binary, but that does not need the top-precedence config slot. - provider.ollama / provider.lmstudio were emitted for every session with ADE's default baseURL even when the user had never configured them, deep-merging over the endpoint in their own opencode.json and repointing a configured remote host back at localhost. They are now emitted only when the user typed an endpoint or ADE discovered models. lmstudio is in OpenCode's provider catalog with its own npm and baseURL, so only ollama states npm. Two faithfulness fixes while here: - ade-full-auto now states read:"allow". The base ruleset asks before reading *.env, so "full access" still prompted. external_directory stays "ask": that boundary is ADE's lane worktree, not a permission tier the user chose. - ade-* agents are hidden. Without a mode they defaulted to "all" and appeared in the user's Tab-cycle and @-autocomplete. Deprecated spellings replaced: the ade-plan `tools` map becomes explicit permission entries (OpenCode desugars it to exactly those, and an explicit permission block wins), and maxSteps becomes steps. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nv4uczqGgnQBnrz1tNz5Lj * quality: fix the passthrough that was undone downstream, plus review findings Three real defects found by the correctness track, and the first one silently defeated half of the Droid change: - droidSdkWorker rebuilt interactionMode unconditionally. buildDroidSdkSessionSettings correctly omitted it for a session with no chosen mode, and the worker then materialised DroidInteractionMode.Auto and sent it on createSession and every updateSettings — restating at the highest tier exactly what the omission existed to leave alone. toDroidInteractionMode now maps "auto" explicitly and returns undefined otherwise; both call sites spread conditionally. - Isolated (orchestration-lead) OpenCode servers lost autoupdate suppression: buildIsolatedOpenCodeEnv strips every OPENCODE_* var and rebuilds from scratch, so it never saw the var set in buildUserOpenCodeEnv, and a lead's server would self-update the binary ADE pins. - CLAUDE_CONFIG_DIR was outranked by the ancestor walk. A lane normally sits under $HOME, so the walk reached the real ~/.claude and ranked it as a project tier ABOVE the relocated user tier — the normal case, not an edge case. The stale home settings won and ADE passed them at flag tier, the exact class this branch exists to remove. Regression test included; it reports StaleHomeStyle without the fix. discoverClaudePlugins was reading the plugin registry from the same wrong directory. Maintainability findings applied: - Deleted the sandboxEnabled boolean. It survived only to keep one call site compiling, and that call site — providerTaskRunner — still emitted the explicit `false` this branch removed from the worker, so the user's ~/.cursor/sandbox.json was still being suppressed there. One field, one encoding, and the compiler found the straggler. - Deleted resolveSessionDroidPermissionMode (one caller, applying a fallback its own caller had already applied) and the unreachable default branch that hid exhaustiveness from the compiler. - buildDroidSdkSessionSettings computes one `stated` object instead of three overlapping booleans, so spec-mode fields cannot be emitted without the mode that justifies them. - Removed the codex reasoning-effort spawn block: after the flag was dropped it only recomputed a value thread/start overwrites moments later. - claudeOutputStyles uses the shared claudeConfigHome rather than the duplicate resolver this branch had added a few files away. - Narrowed types that carried members which can no longer occur, and gave buildPermissionConfig a keyed union — the OpenCode SDK absorbs unknown permission keys through an index signature, so a typo would have compiled and silently failed to apply. - Collapsed the rationale that had been restated in five adapters into one doc block in providerConfigHomes, and added the test that module never had. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nv4uczqGgnQBnrz1tNz5Lj * quality: re-review pass — Windows path comparison and structural cleanups The re-review's one behavioral finding: claudeRootsByPrecedence compared paths with `===`. On Windows the same directory is reachable through more than one spelling, so a hand-typed CLAUDE_CONFIG_DIR differing only in drive-letter or user-name case would both fail to match the real home root and let one directory enter the precedence list twice, shadowing the tier below it. Now routed through pathsEqual/pathKey, which the repo already had for exactly this. Structural findings applied: - planRequested is now derived from resolveDroidSdkInteractionMode rather than restating its spec rule. The `stated?.interactionMode === "spec"` gate was only correct because the two happened to agree; editing the resolver would have silently stopped emitting spec-mode config with no type error and no failing test. - The cursor log line called buildCursorSdkLocalRunOptions instead of reconstructing the directive from the options it had just built. - `stated` is explicitly typed, which drops an `as const`, and spreads directly. - Restored one trimmed sentence that was carrying a probed fact: when ADE does ask Cursor for a sandbox, a user policy still wins — the SDK only falls back to its own default when the user wrote none. That is stated nowhere else. - Adapter comments now name services/shared/providerConfigHomes.ts, so the rule they follow is reachable by grep from the files that follow it. - Dropped imports orphaned by the earlier fixes. Verified, not changed: the reasoning fields are assigned as possibly-undefined while autonomy/interaction are conditionally spread. These are identical on the wire — settings cross a process boundary as JSON and JSON.stringify drops undefined keys, which the live Droid probe confirmed (updateSettings with an undefined key is a no-op, while null wedges the RPC for 30s). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nv4uczqGgnQBnrz1tNz5Lj * opencode: pin the isolated-lead autoupdate suppression with a test The fix had no coverage, and the failure mode is silent: buildIsolatedOpenCodeEnv rebuilds its env from scratch and drops every OPENCODE_* var, so anything set on the user path never reaches a lead. Without the flag the lead's server updates the binary ADE resolves and pins. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nv4uczqGgnQBnrz1tNz5Lj * droid: restore the only exit from Spec mode, and key one more path comparison Blast radius of the omission fix, caught on re-review. The Droid SDK has no exitSpecMode — `enterSpecMode` is one-way, and the unconditional `DroidInteractionMode.Auto` that the omission fix removed was ADE's only way back out. A session ADE had put into Spec, whose plan mode was later turned off while no permission mode was chosen, states nothing on the next update and stays read-only for the rest of its life with no UI indication. applySettings now tracks whether ADE itself entered Spec, and states Auto once on the way out before returning to saying nothing. That re-adds exactly one statement, in one bounded case, rather than reinstating the blanket override. The flag resets on init and teardown so a recycled worker cannot carry it. Also keys the output-style source labels through pathsEqual: the precedence walk now folds case, so a case-variant CLAUDE_CONFIG_DIR could put the canonical home spelling in the root list while the raw comparison still expected the variant, labelling user styles "project". Metadata only — nothing branches on it — but the two comparisons should not disagree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nv4uczqGgnQBnrz1tNz5Lj * test: pin the Droid settings contract, and align plan mode across both paths Writing the tests exposed that the Droid omission does not fire in practice. ADE always carries a generic permissionMode, and legacyPermissionModeToDroid PermissionMode maps it onto a Droid mode, so chosenMode is effectively never null: a bare Droid session resolves to autonomyLevel "low", not to an omitted key. That is the ADE-owned half of the rule working as intended — the composer chip is real UI, so ADE's value should win — but it means the omission path is reachable only from launches that carry no permission mode at all. The tests now pin what actually happens rather than a claim that cannot be reached. Also found by the same tests: ADE's two Droid paths disagreed about plan mode. droidSettingsJson sends {interactionMode: spec, autonomyLevel: off} on the terminal path, while the SDK path sent spec alongside whatever the permission chip mapped to — "low" for a default session. Spec collapses Droid's compound autonomyMode and reads back as level "off", so the extra claim was discarded and behavior was unaffected, but the two paths should not state different things. The SDK path now sends "off" with spec, and the spec-mode fields stay gated on the stated interaction mode. Three tests added to the existing agentChatService suite rather than a new file: autonomy derived from ADE's chip, an explicitly chosen mode, and plan mapping onto spec with autonomy off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nv4uczqGgnQBnrz1tNz5Lj * test: make the Droid interaction-mode mapping testable and pin it droidSdkWorker.ts has zero exports and attaches a process.on("message") handler at import — it is a fork() entrypoint, so nothing in it can be unit tested and the re-materialisation bug it carried was unpinnable where it lived. Moved the pure mapping to droidSdkProtocol.ts, which is importable and already has a suite, and gave it the enum table rather than the SDK module so it stays a pure function. Two tests now pin the contract that undefined maps to undefined — the exact behavior whose absence let the worker restate a mode the service had deliberately omitted — and that every stated mode still maps to its enum value. No new test file: the assertions extend the existing droidSdkProtocol suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nv4uczqGgnQBnrz1tNz5Lj * docs+tui: record the provider config-ownership rule and honour CLAUDE_CONFIG_DIR Docs now carry the rule itself rather than a list of what changed. agent-routing gains "Provider config ownership" — the rule, plus a per-provider table of what omitting a key does and what an explicit null/false does, each row verified against a live runtime rather than read off a schema — and "Provider config homes" for the three env overrides that do not share a shape. Source file maps and the affected feature READMEs point at it, so provider #6 starts there instead of reverse-engineering the rule from a Cursor comment. TUI parity, both the same bug class this branch is about: - claudeHomePath hardcoded ~/.claude, so the TUI read keybindings, statusLine, vim mode, and agents from a directory Claude Code is not using whenever CLAUDE_CONFIG_DIR moved it. Now goes through claudeConfigHome. - formatOutputStyles keyed the active row off a session value that is now null until a settings file names one, so the listing would have highlighted nothing. It falls back to "Default" for display only, matching what the desktop /output-style handler shows, and never writes it back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nv4uczqGgnQBnrz1tNz5Lj * review: fix four findings from Codex, Cursor Bugbot and CodeRabbit Three bots independently found that the Spec-mode escape flag only covered one of the three ways a session can enter Spec. `sessionOptions` can start the SDK directly in Spec, and the resume-failure fallback creates a session the same way, so a session created in Spec had no recorded way back out — and the SDK has no exitSpecMode. Both paths now record it. The cross-worker case is documented rather than papered over: a session a PREVIOUS worker left in Spec cannot be detected, because that state lives in Droid and the SDK exposes no way to read it back, and assuming Spec on every resume would state a mode ADE does not own. Cursor Bugbot and CodeRabbit both caught that listing `/output-style` with no argument wrote the resolved name onto the session and persisted it. Every later option build then treated that cache as a real selection, so ADE sent outputStyle at flag tier and suppressed Claude's own resolution — reintroducing the exact override this branch removes, through the display path. The listing no longer mutates the session, and reads the settings files before the cache so a newer selection wins. Codex caught that the creation path still substituted "auto-low" for a Droid session that requested no mode, which is why the resolver could never return null. That is the same shape as the `?? "Default"` bug this branch fixes: the substituted value is persisted and read back as a real choice. The desktop composer always sends a mode, so this only changes launches that send nothing — which is precisely the case that should inherit. Codex also caught a regression in the local-provider trim: ollama is not in OpenCode's catalog, so when discovery found models but the user had typed no endpoint, the models were named with no address to reach them — worst for an isolated lead, which inherits no user config at all. A user-typed endpoint still wins; the default only fills the gap. Regression tests added for the output-style listing and both ollama cases; each was verified to fail without its fix. The listing test lives in its own suite because adding it inside the existing block perturbed shared state two Cursor recovery tests depend on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nv4uczqGgnQBnrz1tNz5Lj * droid: read the resumed session's mode instead of assuming it CodeRabbit's remaining case was real and reachable: turn plan off while a Droid chat is idle and its worker has been evicted, and the next message resumes a session Droid still has in Spec while ADE now states nothing — leaving it read-only with no way out, because the SDK has no exitSpecMode. ADE cannot remember this on its own; the state lives in Droid. But the resumed session hands its resolved settings back in initResult, so the worker can simply read the live interactionMode and seed the escape flag from it. That is strictly better than the alternative of assuming Spec on every resume, which would have meant stating a mode ADE does not own — overriding a user who configured interactionMode in their own settings.json. Two Codex P1s on this push were re-anchored copies of findings already fixed in 07b88b6 (the "auto-low" creation fallback is gone; the Spec flag is set on both create paths). Verified against the current code rather than re-fixed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nv4uczqGgnQBnrz1tNz5Lj * pty: honour CLAUDE_CONFIG_DIR for Claude session storage CodeRabbit caught a genuine miss in my own sweep: this file was edited for exactly this bug class, but only the Codex and Factory resolvers were replaced. claudeProjectDirForCwd still built `<homedir>/.claude/projects`, so with CLAUDE_CONFIG_DIR set, Claude storage backfill and runtime-title capture read a directory the CLI is not writing to. The fixtures in ptyService.test.ts now resolve through the same helper the production path uses. The shared test setup already points CLAUDE_CONFIG_DIR at a temp directory, so those cases only pass while the code honours it — verified by reverting the fix, which fails three of them. That is the regression coverage the review asked for, without a new test file. All five provider-config call sites in this file now pass os.homedir() explicitly, so the module's own node:os mock governs the fallback rather than the shared helper's named import. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nv4uczqGgnQBnrz1tNz5Lj * review: don't infer Spec ownership, and don't invent an endpoint over user config Two follow-ups from CodeRabbit, both of which caught my previous fix trading one override for another. Reading the resumed session's interactionMode cannot tell a Spec that ADE entered from one the user configured in ~/.factory/settings.json. Exiting the latter would be precisely the override this branch removes, so the inference is gone. applySettings still records Spec whenever ADE itself states it, which covers every resume ADE drives; the residual — a session left in Spec by a previous worker while ADE now states nothing — stays documented rather than "fixed" by overriding a user setting. CodeRabbit suggested this inference in the prior round and then flagged it here; the flag is right. The ollama endpoint has the same shape. OPENCODE_CONFIG_CONTENT merges last, so an ADE default can replace a remote host in the user's own opencode.json, which ADE cannot read. But an isolated lead inherits no user config at all, so there is nothing to clobber and nothing else to supply the address — which is the case Codex's original finding was actually about. The fallback is now gated on isolatedConfig, so a lead's discovered models stay runnable and an ordinary session keeps deferring to the user's own file. Both directions are pinned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nv4uczqGgnQBnrz1tNz5Lj --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Summary
CodexChatPage, its supporting state slice, renderer UI elements, and startup path wiring so the desktop app can host the Codex chat surfaceTesting
Summary by CodeRabbit
New Features
UI/UX Enhancements
Performance