Skip to content

Mobile droid pass: iOS redesign, sync rewrite, chat refactor#161

Merged
arul28 merged 57 commits intomainfrom
ade/mobile-droid-attempt-bbdcd095
Apr 17, 2026
Merged

Mobile droid pass: iOS redesign, sync rewrite, chat refactor#161
arul28 merged 57 commits intomainfrom
ade/mobile-droid-attempt-bbdcd095

Conversation

@arul28
Copy link
Copy Markdown
Owner

@arul28 arul28 commented Apr 17, 2026

Summary

  • iOS mobile app redesign — Work tab split into per-file view modules (transcript parser, reasoning card, session grouping, slash commands sheet, model picker, new chat sheet, session settings). Files, PRs, Lanes, and Settings each rebuilt with a mobile-shell layout. Unified per-provider chat accent, branded provider logos, streaming shimmer, compact dividers, activity-indicator pill, and per-turn provider badges.
  • Desktop sync rewritesyncHostService, syncRemoteCommandService, syncPairingStore, and syncPinStore substantially reworked. New PrMobileSnapshot sync contract + files read-only cache (files_workspaces + directory/content/diff/history snapshots) mirrored between desktop kvDb.ts and iOS DatabaseBootstrap.sql.
  • Chat composer refactor — unified desktop/iOS composer shell, inline model picker, slash-command discovery (.claude/commands/** + user skills), provider-aware routing, /clear local interception.
  • Linear CTO polish — concurrent issue-update coalescing via pendingIssueIds/replayPendingIssues, realpath-based path-traversal guard on outbound, worker-heartbeat dispose hardening.
  • Finalize pass — dead-code removal across sync/chat services, registerIpc, PackedSessionGrid; internal docs refreshed; bootstrap-SQL parity test fixed; CommandPalette test stabilized against stray setTimeouts.

Test plan

  • Pair an iOS device and verify Work tab, PR stack sheet, Files browser, Settings, and Lanes
  • Exercise sync remote commands (file read/diff/history, session listing) from iOS against a paired desktop
  • Verify new chat composer on desktop: inline model picker, slash commands, /clear, streaming shimmer
  • Create a PR from desktop, observe mobile-snapshot payload on iOS
  • Run Linear intake/dispatch pipeline and confirm replay queue handles concurrent updates
  • Confirm CI green: desktop/mcp-server/web typecheck + lint + unit tests (8 shards) + builds
  • Verify iOS bootstrap SQL matches generator output (node apps/desktop/scripts/generate-ios-bootstrap-sql.mjs is a no-op)

🤖 Generated with Claude Code

Greptile Summary

This large-scale PR ships an iOS mobile redesign, a substantial sync-service rewrite (new PIN-based pairing, files read-only cache, mobile snapshot contract), a unified chat composer, and Linear CTO concurrency fixes. The architecture is coherent and most of the new code is well-guarded — the realpathSync-based path-traversal guard in linearOutboundService, the per-IP brute-force cooldown, and the missedHeartbeatCount tolerance are all solid additions.

  • The discoverLegacyCommands helper traverses ~/.claude/commands and ./.claude/commands recursively without a depth cap; a crafted or accidentally deep directory tree (e.g. circular symlinks) will exhaust the call stack.
  • pairFailures entries for IPs that have never successfully paired are never evicted, causing unbounded map growth over long-running sessions.
  • The new syncPinStore persists the raw 6-digit PIN in a plaintext JSON file; 0o600 permissions are the sole protection, and any process running as the same user (or root) can read it silently.

Confidence Score: 5/5

Safe to merge — all findings are P2 style/hardening suggestions with no current defects on any changed path.

No P0 or P1 issues found. The three flagged items are: an unbounded recursion risk in discoverLegacyCommands (requires an adversarially crafted user directory, unlikely in practice), an unbounded map growth in pairFailures (memory-only, no correctness impact), and plaintext PIN storage (mitigated by 0o600 and brute-force rate limiting). The security-critical paths (path traversal, mobile write guard, brute-force cooldown) are all correctly implemented.

apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts (recursion depth), apps/desktop/src/main/services/sync/syncPinStore.ts (plaintext PIN), apps/desktop/src/main/services/sync/syncHostService.ts (pairFailures eviction)

Security Review

  • Plaintext PIN storage (syncPinStore.ts): The 6-digit pairing PIN is written to a JSON file protected only by 0o600 permissions. Any same-user process can read it without triggering a file-access alert.
  • Path-traversal guard added (linearOutboundService.ts): realpathSync + relative-path prefix check correctly closes the symlink-bypass vector for outbound artifact uploads. ✅
  • Per-IP brute-force cooldown (syncHostService.ts): 5 attempts → 10-minute cooldown per remote address; socket is dropped on each failure to force a new TCP handshake. ✅
  • Mobile file mutation guard (syncHostService.ts): assertMobileFileMutationAllowed and isMobileLaneFileMutationBlocked correctly block write/rename/delete operations from iOS peers on read-only workspaces. ✅

Important Files Changed

Filename Overview
apps/desktop/src/main/services/sync/syncPinStore.ts New file: stores a static 6-digit PIN in plaintext JSON with 0o600 permissions; in-memory cache is never invalidated on external file changes.
apps/desktop/src/main/services/sync/syncHostService.ts Major rewrite: adds lane presence tracking, per-IP brute-force cooldown, missed-heartbeat tolerance (2 strikes), and chat-event dedup; pairFailures map never evicts expired entries.
apps/desktop/src/main/services/sync/syncRemoteCommandService.ts Large expansion of supported remote commands with well-structured payload parsers; new summarizeChatSessionForRemote helper looks correct.
apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts New file: discovers slash commands by recursively walking .claude/commands; no depth limit in visit(), risking stack overflow on deeply nested or symlink-loop directories.
apps/desktop/src/main/services/cto/linearSyncService.ts Adds pendingIssueIds queue and replayPendingIssues to coalesce concurrent issue-update calls; mutual exclusion via inFlight flag is correct in JS single-threaded model.
apps/desktop/src/main/services/cto/linearOutboundService.ts Path-traversal guard added: realpathSync on both project root and artifact path, followed by relative-path prefix check — correctly handles symlinks.
apps/desktop/src/main/services/cto/workerHeartbeatService.ts dispose() changed to async; callers already await it. Tracked-dispatch draining prevents orphaned in-flight agents on shutdown.
apps/desktop/src/main/services/prs/prService.ts Adds buildMobileSnapshot() and stack-building helpers; collectStackMembers recurses the lane tree but circular parent refs are prevented by root-only traversal start.
apps/desktop/src/main/services/sync/syncPairingStore.ts Pairing model shifted from time-limited OTP to static PIN; structured error codes (pin_not_set, invalid_pin) drive client UX correctly.
apps/desktop/src/main/services/state/kvDb.ts Five new files_* tables added with FK cascade deletes and covering indexes; schema matches iOS DatabaseBootstrap.sql exactly.

Sequence Diagram

sequenceDiagram
    participant iOS as iOS Client
    participant Host as SyncHostService
    participant Pairing as SyncPairingStore
    participant Pin as SyncPinStore

    iOS->>Host: WS connect + hello
    iOS->>Host: pairing_request {peer, pin}
    Host->>Host: pairingCooldownMsRemaining(ip)?
    alt IP in cooldown
        Host-->>iOS: pairing_result {ok:false, cooldown}
        Host->>Host: ws.close(4004)
    else Not in cooldown
        Host->>Pairing: pairPeer(peer, pin)
        Pairing->>Pin: getPin()
        Pin-->>Pairing: storedPin
        alt PIN matches
            Pairing-->>Host: {deviceId, secret}
            Host->>Host: pairFailures.delete(ip)
            Host-->>iOS: pairing_result {ok:true, secret}
            iOS->>Host: hello {deviceId, secret HMAC}
            Host-->>iOS: authenticated
        else PIN wrong / not set
            Pairing-->>Host: Error(pin_not_set or invalid_pin)
            Host->>Host: registerPairFailure(ip)
            Host-->>iOS: pairing_result {ok:false, error}
            Host->>Host: ws.close(4003)
        end
    end
Loading

Fix All in Claude Code

Prompt To Fix All With AI
This is a comment left during a code review.
Path: apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts
Line: 68-88

Comment:
**Unbounded recursive directory traversal**

`visit` recurses into every subdirectory without a depth cap. If `~/.claude/commands` or `./.claude/commands` contains a circular symlink (or simply an unexpectedly deep tree), this will exhaust the JS call stack and crash the renderer process.

```suggestion
  const MAX_DEPTH = 10;
  const visit = (dir: string, depth: number = 0): void => {
    if (depth > MAX_DEPTH) return;
    let entries: fs.Dirent[];
    try {
      entries = fs.readdirSync(dir, { withFileTypes: true });
    } catch {
      return;
    }
    for (const entry of entries) {
      const entryPath = path.join(dir, entry.name);
      if (entry.isDirectory()) {
        visit(entryPath, depth + 1);
        continue;
      }
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/desktop/src/main/services/sync/syncHostService.ts
Line: 344-358

Comment:
**`pairFailures` map never evicts expired entries**

After the 10-minute cooldown expires, the entry remains in the map indefinitely with `count = 0` and a stale `cooldownUntilMs`. In a long-running desktop session with many probing IPs (e.g., LAN scanners), this accumulates without bound. Successful pairings already call `pairFailures.delete(ip)`, but failed-then-abandoned IPs are never cleaned up.

A simple sweep during the heartbeat tick (or a max-size eviction) would contain the growth:
```ts
// e.g., in the heartbeat timer, prune entries that are past their cooldown and have count 0
const now = Date.now();
for (const [ip, entry] of pairFailures) {
  if (entry.count === 0 && entry.cooldownUntilMs < now) pairFailures.delete(ip);
}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/desktop/src/main/services/sync/syncPinStore.ts
Line: 38-46

Comment:
**PIN persisted in plaintext**

The raw 6-digit PIN is written to disk as a plain JSON string. `0o600` permissions are the only protection — any process running as the same UNIX user (e.g., a compromised npm package, a VS Code extension, or malware) can silently read it. Because the PIN space is only 100 000 values, hashing with SHA-256 would not stop offline brute-force, but deriving a salted hash with PBKDF2/bcrypt (even with low iterations) raises the offline cost meaningfully while `setPin` still validates via `PIN_PATTERN` before storing.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "Finalize pass: simplify sync/chat servic..." | Re-trigger Greptile

Greptile also left 3 inline comments on this PR.

arul28 and others added 30 commits April 15, 2026 23:27
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>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
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>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
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>
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>
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>
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
- 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
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>
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>
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.
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>
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>
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>
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>
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).
shortId: "opus-1m",
aliases: ["opus[1m]", "claude-opus-4-6[1m]"],
displayName: "Claude Opus 4.6 1M",
aliases: ["opus[1m]", "claude-opus-4-7[1m]"],
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

The registry now replaces every claude-opus-4-6* id with 4.7, but it does not keep compatibility aliases for the old stored ids. Verified downstream: automationService validates rules with getModelById(requestedModelId) only and throws Unknown model for unresolved ids, and mission preflight likewise marks unresolved model ids as failures.

// apps/desktop/src/shared/modelRegistry.ts
{
  id: "anthropic/claude-opus-4-7-1m",
  shortId: "opus-1m",
  aliases: ["opus[1m]", "claude-opus-4-7[1m]"],

Any existing automation rule, mission config, or other persisted setting that still stores anthropic/claude-opus-4-6 / anthropic/claude-opus-4-6-1m will become invalid after this upgrade. Keep the legacy ids as aliases (or migrate them on load) before removing them from the registry.

Comment thread apps/ios/ADE/Services/SyncService.swift Outdated
return deduplicatedAddresses(liveLastSuccessful + liveLan + liveTailscale)
}

return []
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

automaticReconnectAddresses(for:) now returns an empty list whenever Bonjour/Tailscale discovery has no current match, even if the saved profile still has a valid lastSuccessfulAddress or manual IP. ```swift
// apps/ios/ADE/Services/SyncService.swift
if !matchingDiscovery.isEmpty {
return deduplicatedAddresses(liveLastSuccessful + liveLan + liveTailscale)
}

return []
``` Because @apps/ios/ADE/App/ADEApp.swift now routes launch/foreground recovery through handleForegroundTransition() → `reconnectIfPossible(userInitiated: false)`, previously paired users on direct-IP/manual-host setups will never auto-reconnect after a cold start or foreground unless discovery also succeeds; the app just stays offline until they tap reconnect manually. Keep the new live-discovery preference, but fall back to the persisted saved addresses when no live discovery row is available.

func runRebaseAndPush() async throws {
try await syncService.startLaneRebase(laneId: laneId, scope: "lane_only", pushMode: "none")
// Best-effort fetch — continue to push even if offline or the remote is unreachable.
try? await syncService.fetchGit(laneId: laneId)
try await syncService.fetchGit(laneId: laneId)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

runRebaseAndPush() changed fetchGit from best-effort to throwing, and SyncService.fetchGit is a throwing remote command. That means any fetch failure now exits the workflow before fetchSyncStatus / pushGit run, reintroducing the exact regression this screen previously avoided: a lane that rebased successfully no longer reaches the push step when the fetch itself fails. Restore best-effort fetch handling here (or catch and continue specifically for fetch errors) so rebase-and-push still performs the publish path when local sync status can be inspected. ```swift
// apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift
func runRebaseAndPush() async throws {
try await syncService.startLaneRebase(laneId: laneId, scope: "lane_only", pushMode: "none")
try await syncService.fetchGit(laneId: laneId)
let syncStatus = try await syncService.fetchSyncStatus(laneId: laneId)



```suggestion
    try? await syncService.fetchGit(laneId: laneId)

color: "#D97706",
providerRoute: "claude-cli",
providerModelId: "opus",
providerModelId: "claude-opus-4-7",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

These providerModelId changes switch Claude Opus from the CLI-safe aliases (opus / opus[1m]) to versioned ids, but the non-chat Anthropic execution path still shells out with args.descriptor.providerModelId verbatim in runClaudeTask(). That path is reached from aiIntegrationService.executeProviderTaskPath(), and claudeModelUtils.ts explicitly documents that Claude Code expects opus, opus[1m], sonnet, or haiku for CLI invocation. As a result, AI-integration features that are configured to use Opus will now spawn claude --model claude-opus-4-7 (or claude-opus-4-7[1m]) instead of the normalized alias, which breaks those tasks even though chat/orchestrator paths were updated to normalize separately. Keep Claude providerModelId values CLI-safe here, or normalize them inside runClaudeTask() before building argv.

// apps/desktop/src/shared/modelRegistry.ts
providerRoute: "claude-cli",
providerModelId: "claude-opus-4-7",
cliCommand: "claude",
isCliWrapped: true,

maxRowSpan: GRID_MAX_ROW_SPAN,
});
// rowSpan is height-driven (viewport-fit); only colSpan persists.
next[tile.id] = { colSpan: clamped.colSpan, rowSpan: defaults.rowSpan };
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

This rewrite stops honoring persisted rowSpan values in the renderer, but the saved rowStart positions are still reused from the existing dock layout. Users who previously resized terminal rows will therefore keep their old, taller start positions while each tile now renders at the new default height, which opens the grid with blank rows until some later resize rewrites the layout. Reset the old row-based placement data when migrating to height-driven rows, or reconcile placements with the same default row span that the renderer now uses. ```ts
// apps/desktop/src/renderer/components/terminals/PackedSessionGrid.tsx
const persisted = readPackedGridSpan(layout, tile.id, defaults);
const clamped = clampPackedGridSpan({
span: persisted,
...
});
next[tile.id] = { colSpan: clamped.colSpan, rowSpan: defaults.rowSpan };

modelDescriptor: descriptor,
system: COMPACTION_SYSTEM_PROMPT,
const summaryResult = await opts.aiIntegrationService.executeTask({
feature: "terminal_summaries",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

compactConversation() now routes both compaction passes through executeTask() as feature: "terminal_summaries", but executeTask() hard-rejects disabled features. That means turning off the user-facing "Chat & terminal summaries" setting now also disables coordinator context compaction, even though compaction is an internal safety mechanism for long-running orchestrator sessions. Once this throws, compactHistory() only logs coordinator_agent.compaction_failed, so the run keeps growing without its intended context-reduction path. Route compaction through an always-on internal feature (for example orchestrator or a dedicated context_compaction feature) instead of reusing the terminal-summary toggle.

// apps/desktop/src/main/services/ai/compactionEngine.ts
const summaryResult = await opts.aiIntegrationService.executeTask({
  feature: "terminal_summaries",
  taskType: "context_compaction",
  cwd: process.cwd(),

<button type="button" style={primaryButton()} disabled={busy} onClick={handleConnect}>
Connect
</button>
{status.client.state === "connected" ? (
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

The disconnect button is now gated on status.client.state === "connected" only, but SyncClientStatus also has connecting and error states and the underlying syncService.disconnectFromBrain() path clears the saved draft regardless of current socket health. After a failed or errored host link, the user remains in viewer mode yet loses the only in-page way to abandon that remote link and return to local hosting. The button should be available for viewer/error states as well, not just healthy connections. ```tsx
// apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx

Connect

{status.client.state === "connected" ? (

Comment on lines +1314 to +1318
const summaries: NonNullable<NonNullable<AiConfig["sessionIntelligence"]>["summaries"]> = {};
if (featureModelOverrides?.terminal_summaries) {
summaries.modelId = featureModelOverrides.terminal_summaries;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

In coerceAiConfig, the legacy migration only copies the terminal-summary model override into sessionIntelligence.summaries and never carries over ai.features.terminal_summaries. That means an existing project with terminal_summaries: false is parsed with no summaries.enabled, and the verified callers in @apps/desktop/src/main/services/pty/ptyService.ts and @apps/desktop/src/main/services/chat/agentChatService.ts both treat a missing flag as enabled (si?.summaries?.enabled !== false / ?? true). Opening such a project therefore re-enables AI summaries for terminal and chat sessions without any config change. Migrate the legacy boolean into summaries.enabled and add a regression test for a disabled legacy config.

// apps/desktop/src/main/services/config/projectConfigService.ts
const summaries: NonNullable<NonNullable<AiConfig["sessionIntelligence"]>["summaries"]> = {};
if (featureModelOverrides?.terminal_summaries) {
  summaries.modelId = featureModelOverrides.terminal_summaries;
}
Suggested change
const summaries: NonNullable<NonNullable<AiConfig["sessionIntelligence"]>["summaries"]> = {};
if (featureModelOverrides?.terminal_summaries) {
summaries.modelId = featureModelOverrides.terminal_summaries;
}
const summaries: NonNullable<NonNullable<AiConfig["sessionIntelligence"]>["summaries"]> = {};
const terminalSummariesEnabled = asBool(featuresRaw?.terminal_summaries);
if (terminalSummariesEnabled != null) summaries.enabled = terminalSummariesEnabled;
if (featureModelOverrides?.terminal_summaries) {
summaries.modelId = featureModelOverrides.terminal_summaries;
}

}
}
if (failedIssueIds.length > 0) {
for (const issueId of failedIssueIds) addPendingIssue(issueId);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

replayPendingIssues() now retains failed IDs, but it requeues them after processIssueUpdateNow() has already had a chance to persist the latest snapshot. In this file, processIssueSnapshot() still calls args.intakeService.persistSnapshot(issue) before routing/create-run work, so a transient failure in routeIssue(), createRun(), or advanceRun() leaves the snapshot hash updated and then hits this replay path:

// apps/desktop/src/main/services/cto/linearSyncService.ts
try {
  await processIssueUpdateNow(issueId);
} catch (error) {
  failedIssueIds.push(issueId);
}
if (failedIssueIds.length > 0) {
  for (const issueId of failedIssueIds) addPendingIssue(issueId);

On the next retry, _previousSnapshotHash now matches the current hash, snapshotChanged() returns false, and the route/run-creation branch is skipped entirely, so the buffered webhook update is still lost. To make the replay durable, either defer persistSnapshot() until after the workflow-routing/queue-creation path succeeds, or restore the previous snapshot state when requeueing a failed replay.

const emitted = new Set<string>();

for (const lane of lanes) {
if (lane.parentLaneId) continue;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

buildStackInfos() only starts traversal from lanes whose parentLaneId is null, so any active lane whose parent was archived is skipped entirely. That state is legal here because laneService.archive() does not block archiving a lane with active children, and iOS stack rendering relies on snapshot.stacks to recover stack metadata for a PR group. The result is that a live child PR disappears from the mobile stack sheet even though the PR itself is still synced. Treat lanes whose parent is missing from the current non-archived lane set as synthetic roots before collecting members.

// apps/desktop/src/main/services/prs/prService.ts
for (const lane of lanes) {
  if (lane.parentLaneId) continue;
  const member = collectStackMembers(lane, childrenByParent, prByLaneId, 0);
  const prCount = member.filter((m) => m.prId !== null).length;

laneName: laneRow?.name ?? suggestion.laneId,
baseBranch: parentName ?? "",
behindBy: suggestion.behindCount,
conflictPredicted: false,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

The new snapshot always serializes rebase cards with conflictPredicted: false, but iOS now prefers mobileSnapshot.workflowCards over the legacy lane-snapshot path, and the card UI only shows the red blocker badge when this field is true. That means a lane already in autoRebaseStatus.state == "rebaseConflict" is downgraded to an ordinary behind-by-N card on mobile, hiding the conflict warning that the previous path surfaced. Populate this field from autoRebaseService.listStatuses() (or equivalent status data) instead of hardcoding false.

// apps/desktop/src/main/services/prs/prService.ts
cards.push({
  kind: "rebase",
  id: `rebase:${suggestion.laneId}`,
  laneId: suggestion.laneId,
  laneName: laneRow?.name ?? suggestion.laneId,
  baseBranch: parentName ?? "",
  behindBy: suggestion.behindCount,
  conflictPredicted: false,

guard isTabActive else { return }
await loadProofArtifacts()
}
.onChange(of: selectedWorkspaceId) { _, _ in
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

handleRequestedNavigation() in @apps/ios/ADE/Views/Files/FilesRootScreen+Actions.swift sets selectedWorkspaceId and then pushes navigationPath = routesForFile(...), but this root view clears navigationPath on every workspace-id change. Any Files deep link coming from Work/Lanes/PRs that targets a different workspace than the current selection (including first load, where the selection is nil) will therefore lose the pushed route and land on the Files root instead of the requested file. Scope this reset to user-initiated workspace switches, or suppress it while applying a programmatic navigation request.

// apps/ios/ADE/Views/Files/FilesRootScreen.swift
.onChange(of: selectedWorkspaceId) { _, _ in
  navigationPath = []
  quickOpenResults = []
  textSearchResults = []
}

if lower.contains("sonnet") || lower.contains("thinking") {
return ["low", "medium", "high"]
}
if lower.contains("gpt-5") {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

When workModelCatalogGroups injects the live host model because it is not in the curated picker list, this sheet derives tiers from supportedReasoningTiers(for:). The new fallback treats every unmatched gpt-5 id as supporting low/medium/high/xhigh, but the shared registry already defines narrower sets for some models (for example openai/gpt-5.1-codex-mini only supports medium/high). That means an existing session on opencode/openai/gpt-5.1-codex-mini becomes editable on iOS with invalid low or xhigh options, and tapping one will send an unsupported reasoningEffort back through onSelect/SyncService.updateChatSession. Normalize OpenCode ids and retry the shared ADEColor.reasoningTiers(for:) lookup before falling back to broad substring heuristics, or avoid offering heuristic tiers for unmatched OpenCode models.

// apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift
if lower.contains("gpt-5") {
  return ["low", "medium", "high", "xhigh"]
}
return []


let lanes: LaneSummary[] = [];
try {
lanes = await laneService.list({ includeArchived: false, includeStatus: true });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

The new mobile snapshot builds its stack context from laneService.list({ includeArchived: false, includeStatus: true }), then only emits stacks starting from lanes with no parentLaneId. That combination drops any active child lane whose parent was archived: the archived parent is missing from lanes, but the child still has parentLaneId, so collectStackMembers() is never reached for that subtree and the mobile stacks payload loses those PRs entirely.

// apps/desktop/src/main/services/prs/prService.ts
lanes = await laneService.list({ includeArchived: false, includeStatus: true });
...
for (const lane of lanes) {
  if (lane.parentLaneId) continue;
  const member = collectStackMembers(lane, childrenByParent, prByLaneId, 0);

Build the stack traversal with archived parents available as context, or treat lanes whose parent is absent from the active set as synthetic roots so active descendants still appear in the snapshot.

guard isCurrentConnectAttempt(connectAttemptGeneration) else { return }
handleReconnectFailure(
error,
shouldScheduleRetry: false,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

reconnectIfPossible() is the entry point used for background/foreground automatic reconnects, but its failure path now disables the reconnect loop unconditionally. In the current code, any transient socket-open or hello failure leaves the phone permanently disconnected until the user manually retries or another foreground event calls back into this path.

// apps/ios/ADE/Services/SyncService.swift
    } catch {
      guard isCurrentConnectAttempt(connectAttemptGeneration) else { return }
      handleReconnectFailure(
        error,
        shouldScheduleRetry: false,

handleReconnectFailure() only calls scheduleReconnectIfNeeded(after:) when shouldScheduleRetry is true, so this changed line removes the old retry behavior for automatic reconnects. Preserve retries for non-user-initiated reconnects while still keeping manual reconnect failures one-shot.

Suggested change
shouldScheduleRetry: false,
shouldScheduleRetry: !userInitiated,

setLocalActiveLanePresence(laneIds);
},

refreshLanDiscovery(): void {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

The PR adds a dedicated republish hook but never wires it into the local-device update path, so host discovery metadata now goes stale until the desktop app restarts. In @apps/desktop/src/main/services/sync/syncHostService.ts the Bonjour payload is built from the mutable local device record (name, ipAddresses, tailscaleIp), and the new export is:

// apps/desktop/src/main/services/sync/syncHostService.ts
refreshLanDiscovery(): void {
  const address = server.address();
  if (typeof address === "object" && address) {
    publishLanDiscovery(address.port);
  }
}

I verified there are no references to refreshLanDiscovery(), while Settings still calls sync.updateLocalDevice({ name }), and syncService.updateLocalDevice() only updates presence/status. iOS consumes the advertised deviceName/addresses from mDNS when showing and connecting to hosts, so after renaming the desktop in Settings the phone will keep seeing the old host name (and any other stale announced metadata) until the host is restarted. Call hostService.refreshLanDiscovery() after local device updates so the wire advertisement stays in sync with the edited device record.


func splitMarkdownTableRow(_ row: String) -> [String] {
var cells = row
.split(separator: "|", omittingEmptySubsequences: false)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

GitHub-flavored markdown allows literal pipes inside table cells when they are escaped as \|, but this parser still tokenizes every | with a raw String.split. A row such as | cmd | a \| b | is therefore broken into three cells, and @apps/ios/ADE/Views/Work/WorkMarkdownViews.swift then renders the wrong values under each header because it iterates by header count. Make splitMarkdownTableRow escape-aware (or delegate table parsing to a markdown parser) so only unescaped separators start a new cell.

// apps/ios/ADE/Views/Work/WorkMarkdownParsing.swift
var cells = row
  .split(separator: "|", omittingEmptySubsequences: false)
  .map { $0.trimmingCharacters(in: .whitespaces) }
if cells.first == "" {

@arul28 arul28 merged commit 71b941d into main Apr 17, 2026
22 checks passed
@arul28 arul28 deleted the ade/mobile-droid-attempt-bbdcd095 branch April 17, 2026 17:36
NavigationStack {
ScrollView {
LazyVStack(spacing: 18) {
SettingsConnectionHeader()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

This redesign rebuilt Settings out of SettingsConnectionHeader, SettingsPairingSection, SettingsAppearanceSection, and SettingsDiagnosticsSection, but none of those views call syncService.forgetHost(). I verified there is no remaining iOS view-level reference to forgetHost(), whereas the removed settings screen exposed a destructive "Forget host" action next to Disconnect. swift // apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift LazyVStack(spacing: 18) { SettingsConnectionHeader() SettingsPairingSection(presentedSheet: $presentedSheet) SettingsAppearanceSection() SettingsDiagnosticsSection() } That leaves users unable to clear a saved pairing/token from the UI when they want to revoke trust or remove an old Mac, which is a regression in the connection-management flow. Restore a destructive action for saved profiles that calls syncService.forgetHost().

if (autoTitleRefreshOnComplete != null) titles.refreshOnComplete = autoTitleRefreshOnComplete;

const summaries: NonNullable<NonNullable<AiConfig["sessionIntelligence"]>["summaries"]> = {};
if (featureModelOverrides?.terminal_summaries) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

coerceAiConfig() migrates legacy title settings into sessionIntelligence.titles, but for summaries it only carries over the model override and never maps the legacy ai.features.terminal_summaries boolean into sessionIntelligence.summaries.enabled. A legacy project that had summaries disabled therefore loads with no summaries.enabled, and the runtime falls back to true (agentChatService.resolveChatConfig()), so chat/terminal summaries resume after migration; once the config is saved, the old legacy field is gone and the re-enable becomes permanent. Fix by copying featuresRaw?.terminal_summaries into summaries.enabled in this migration block before merging explicit sessionIntelligence overrides.

// apps/desktop/src/main/services/config/projectConfigService.ts
const summaries: NonNullable<NonNullable<AiConfig["sessionIntelligence"]>["summaries"]> = {};
if (featureModelOverrides?.terminal_summaries) {
  summaries.modelId = featureModelOverrides.terminal_summaries;
}

.glassEffect()

case .error, .disconnected:
if syncService.activeHostProfile?.hostIdentity != nil {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

The new Settings UI treats hostIdentity != nil as the signal that a saved pairing exists, but the manual pairing path explicitly saves profiles with hostIdentity: nil, so a host paired through “Enter host details” is shown as unpaired after any disconnect/error and loses the Reconnect button even though reconnectIfPossible() can reconnect from savedAddressCandidates and lastSuccessfulAddress. swift // apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift case .error, .disconnected: if syncService.activeHostProfile?.hostIdentity != nil { ADEGlassActionButton( title: "Reconnect", SettingsPinSheet’s manual case passes hostIdentity: nil, and pairAndConnect() still persists a valid HostConnectionProfile, so this predicate is now wrong for one of the supported pairing flows. Use the existence of a saved profile (for example activeHostProfile != nil) rather than hostIdentity when deciding whether to show paired-state copy/actions.

.font(.caption2.weight(.semibold))
}
ScrollView(.horizontal, showsIndicators: false) {
Text(SyntaxHighlighter.highlightedAttributedString(code, as: detectedLanguage))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

WorkCodeBlockView currently highlights the entire fenced block and feeds it to a single Text, whereas the existing file and diff viewers split code into lazily rendered lines. In the Work transcript, that means a large pasted/generated snippet is fully regex-tokenized, turned into one large AttributedString, and laid out in one pass as soon as the message row appears, which will noticeably stall scrolling and increase memory use for long code blocks. swift // apps/ios/ADE/Views/Work/WorkMarkdownViews.swift ScrollView(.horizontal, showsIndicators: false) { Text(SyntaxHighlighter.highlightedAttributedString(code, as: detectedLanguage)) .frame(maxWidth: .infinity, alignment: .leading) .textSelection(.enabled) } Reuse the line-based SyntaxHighlightedCodeView pattern here, or split code into lines inside a LazyVStack, so only visible rows are laid out.

lhs.lane.createdAt < rhs.lane.createdAt
}
return children.flatMap { child in
[child] + visit(parentId: child.lane.id)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

laneStackGraphOrder builds the root list with an unguarded recursive visit, so a cyclic cached graph (A.parentLaneId = B, B.parentLaneId = A) will recurse indefinitely while the Lanes tab is computing stackOrderedSnapshots. This is a real regression in the new mobile lane shell because the surrounding sync pipeline already expects inconsistent parent data during reconciliation: Database.fetchLaneListSnapshots and LaneTreeView.depthFor both carry cycle guards, but the root ordering path does not. The result is that the Lanes tab can hang or crash before it ever renders its offline/error fallback. Fix by threading a visited set through visit (or reusing a cycle-safe ordering helper) and bailing when a lane id repeats.

// apps/ios/ADE/Views/Lanes/LaneHelpers.swift
func visit(parentId: String?) -> [LaneListSnapshot] {
  let key = parentId ?? "__root__"
  let children = (childrenByParent[key] ?? []).sorted { lhs, rhs in
    lhs.lane.createdAt < rhs.lane.createdAt
  }
  return children.flatMap { child in
    [child] + visit(parentId: child.lane.id)
  }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants