Mobile droid pass: iOS redesign, sync rewrite, chat refactor#161
Mobile droid pass: iOS redesign, sync rewrite, chat refactor#161
Conversation
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]"], |
There was a problem hiding this comment.
[🟡 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.
| return deduplicatedAddresses(liveLastSuccessful + liveLan + liveTailscale) | ||
| } | ||
|
|
||
| return [] |
There was a problem hiding this comment.
[🟡 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) |
There was a problem hiding this comment.
[🟡 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", |
There was a problem hiding this comment.
[🟡 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 }; |
There was a problem hiding this comment.
[🟡 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", |
There was a problem hiding this comment.
[🟡 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" ? ( |
There was a problem hiding this comment.
[🟡 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" ? (
| const summaries: NonNullable<NonNullable<AiConfig["sessionIntelligence"]>["summaries"]> = {}; | ||
| if (featureModelOverrides?.terminal_summaries) { | ||
| summaries.modelId = featureModelOverrides.terminal_summaries; | ||
| } | ||
|
|
There was a problem hiding this comment.
[🟡 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;
}| 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); |
There was a problem hiding this comment.
[🟡 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; |
There was a problem hiding this comment.
[🟡 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, |
There was a problem hiding this comment.
[🟡 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 |
There was a problem hiding this comment.
[🟡 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") { |
There was a problem hiding this comment.
[🟡 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 }); |
There was a problem hiding this comment.
[🟡 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, |
There was a problem hiding this comment.
[🟡 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.
| shouldScheduleRetry: false, | |
| shouldScheduleRetry: !userInitiated, |
| setLocalActiveLanePresence(laneIds); | ||
| }, | ||
|
|
||
| refreshLanDiscovery(): void { |
There was a problem hiding this comment.
[🟡 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) |
There was a problem hiding this comment.
[🟡 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 == "" {| NavigationStack { | ||
| ScrollView { | ||
| LazyVStack(spacing: 18) { | ||
| SettingsConnectionHeader() |
There was a problem hiding this comment.
[🟡 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) { |
There was a problem hiding this comment.
[🟡 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 { |
There was a problem hiding this comment.
[🟡 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)) |
There was a problem hiding this comment.
[🟡 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) |
There was a problem hiding this comment.
[🟡 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)
}
}
Summary
syncHostService,syncRemoteCommandService,syncPairingStore, andsyncPinStoresubstantially reworked. NewPrMobileSnapshotsync contract + files read-only cache (files_workspaces+ directory/content/diff/history snapshots) mirrored between desktopkvDb.tsand iOSDatabaseBootstrap.sql..claude/commands/**+ user skills), provider-aware routing,/clearlocal interception.pendingIssueIds/replayPendingIssues, realpath-based path-traversal guard on outbound, worker-heartbeat dispose hardening.Test plan
/clear, streaming shimmernode apps/desktop/scripts/generate-ios-bootstrap-sql.mjsis 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 inlinearOutboundService, the per-IP brute-force cooldown, and themissedHeartbeatCounttolerance are all solid additions.discoverLegacyCommandshelper traverses~/.claude/commandsand./.claude/commandsrecursively without a depth cap; a crafted or accidentally deep directory tree (e.g. circular symlinks) will exhaust the call stack.pairFailuresentries for IPs that have never successfully paired are never evicted, causing unbounded map growth over long-running sessions.syncPinStorepersists the raw 6-digit PIN in a plaintext JSON file;0o600permissions 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 inpairFailures(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
syncPinStore.ts): The 6-digit pairing PIN is written to a JSON file protected only by0o600permissions. Any same-user process can read it without triggering a file-access alert.linearOutboundService.ts):realpathSync+ relative-path prefix check correctly closes the symlink-bypass vector for outbound artifact uploads. ✅syncHostService.ts): 5 attempts → 10-minute cooldown per remote address; socket is dropped on each failure to force a new TCP handshake. ✅syncHostService.ts):assertMobileFileMutationAllowedandisMobileLaneFileMutationBlockedcorrectly block write/rename/delete operations from iOS peers on read-only workspaces. ✅Important Files Changed
pairFailuresmap never evicts expired entries.summarizeChatSessionForRemotehelper looks correct..claude/commands; no depth limit invisit(), risking stack overflow on deeply nested or symlink-loop directories.pendingIssueIdsqueue andreplayPendingIssuesto coalesce concurrent issue-update calls; mutual exclusion viainFlightflag is correct in JS single-threaded model.realpathSyncon both project root and artifact path, followed by relative-path prefix check — correctly handles symlinks.dispose()changed toasync; callers alreadyawaitit. Tracked-dispatch draining prevents orphaned in-flight agents on shutdown.buildMobileSnapshot()and stack-building helpers;collectStackMembersrecurses the lane tree but circular parent refs are prevented by root-only traversal start.pin_not_set,invalid_pin) drive client UX correctly.files_*tables added with FK cascade deletes and covering indexes; schema matches iOSDatabaseBootstrap.sqlexactly.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 endPrompt To Fix All With AI
Reviews (1): Last reviewed commit: "Finalize pass: simplify sync/chat servic..." | Re-trigger Greptile