Skip to content

Commit 9ff9c64

Browse files
arul28claude
andcommitted
ship: iteration 3 — address coderabbit + greptile review comments
Addresses 20 actionable comments across TS (3) and iOS Swift (17): - ade-cli/adeRpcServer: decouple auto-title meta from goal - ade-cli/syncRemoteCommandService: validate confidenceThreshold 0..1 - desktop/kvDb: PK arity check runs before byte-typed PK accept - iOS Database: empty title skips title only, not whole update - iOS SyncService: contain activeProjectId remap; scope cached model catalog; fast-fail sendTestPush offline; indentation normalize - iOS Views Cto: clear stale budget/linearStatus/coreMemory on refresh failure - iOS Views Lanes: don't disable Push during sync-status loading - iOS Views PRs: neutral/skipped checks counted; 44pt touch targets; case-sensitive branch match; validate linkedLaneId against availableLanes - iOS Views Work: one-write terminal submit; don't skip idle transcript reconcile - iOS Tests: explicit SyncService.shared binding in deep-link test Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d741fe4 commit 9ff9c64

16 files changed

Lines changed: 150 additions & 67 deletions

apps/ade-cli/src/adeRpcServer.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5251,15 +5251,17 @@ async function runTool(args: {
52515251
}
52525252
}
52535253

5254-
if (initialInputMeta.goal) {
5254+
const autoTitleApplied = Boolean(initialInputMeta.promptTitle) && title === initialInputMeta.promptTitle;
5255+
if (initialInputMeta.goal || autoTitleApplied) {
52555256
const session = runtime.sessionService.get(created.sessionId) as TerminalSessionSummary | null;
5256-
runtime.sessionService.updateMeta({
5257+
const metaPatch: Parameters<typeof runtime.sessionService.updateMeta>[0] = {
52575258
sessionId: created.sessionId,
5258-
...(session?.goal?.trim().length ? {} : { goal: initialInputMeta.goal }),
5259-
...(initialInputMeta.promptTitle && title === initialInputMeta.promptTitle
5260-
? { title: initialInputMeta.promptTitle, manuallyNamed: false }
5261-
: {}),
5262-
});
5259+
...(initialInputMeta.goal && !session?.goal?.trim().length ? { goal: initialInputMeta.goal } : {}),
5260+
...(autoTitleApplied ? { title: initialInputMeta.promptTitle!, manuallyNamed: false } : {}),
5261+
};
5262+
if (metaPatch.goal !== undefined || metaPatch.title !== undefined || metaPatch.manuallyNamed !== undefined) {
5263+
runtime.sessionService.updateMeta(metaPatch);
5264+
}
52635265
}
52645266

52655267
const session = runtime.sessionService.get(created.sessionId) as TerminalSessionSummary | null;

apps/ade-cli/src/services/sync/syncRemoteCommandService.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,13 @@ function asOptionalNumber(value: unknown): number | undefined {
233233
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
234234
}
235235

236+
function asConfidenceThreshold(value: unknown): number | undefined {
237+
const numeric = asOptionalNumber(value);
238+
if (numeric == null) return undefined;
239+
if (numeric < 0 || numeric > 1) return undefined;
240+
return numeric;
241+
}
242+
236243
function asStringArray(value: unknown): string[] {
237244
if (!Array.isArray(value)) return [];
238245
return value.map((entry) => asTrimmedString(entry)).filter((entry): entry is string => Boolean(entry));
@@ -1274,7 +1281,7 @@ function parseLandQueueNextArgs(value: Record<string, unknown>): LandQueueNextAr
12741281
method,
12751282
...(typeof value.archiveLane === "boolean" ? { archiveLane: value.archiveLane } : {}),
12761283
...(typeof value.autoResolve === "boolean" ? { autoResolve: value.autoResolve } : {}),
1277-
...(asOptionalNumber(value.confidenceThreshold) != null ? { confidenceThreshold: asOptionalNumber(value.confidenceThreshold)! } : {}),
1284+
...(asConfidenceThreshold(value.confidenceThreshold) != null ? { confidenceThreshold: asConfidenceThreshold(value.confidenceThreshold)! } : {}),
12781285
};
12791286
}
12801287

@@ -1288,7 +1295,7 @@ function parseStartQueueAutomationArgs(value: Record<string, unknown>): StartQue
12881295
const resolverModel = asTrimmedString(value.resolverModel);
12891296
const reasoningEffort = asTrimmedString(value.reasoningEffort);
12901297
const permissionMode = asTrimmedString(value.permissionMode);
1291-
const confidenceThreshold = asOptionalNumber(value.confidenceThreshold);
1298+
const confidenceThreshold = asConfidenceThreshold(value.confidenceThreshold);
12921299
const originLabel = asTrimmedString(value.originLabel);
12931300
return {
12941301
groupId: requireString(value.groupId, "prs.startQueueAutomation requires groupId."),
@@ -1330,7 +1337,7 @@ function parseResumeQueueAutomationArgs(value: Record<string, unknown>): ResumeQ
13301337
...(typeof value.archiveLane === "boolean" ? { archiveLane: value.archiveLane } : {}),
13311338
...(typeof value.autoResolve === "boolean" ? { autoResolve: value.autoResolve } : {}),
13321339
...(typeof value.ciGating === "boolean" ? { ciGating: value.ciGating } : {}),
1333-
...(asOptionalNumber(value.confidenceThreshold) != null ? { confidenceThreshold: asOptionalNumber(value.confidenceThreshold)! } : {}),
1340+
...(asConfidenceThreshold(value.confidenceThreshold) != null ? { confidenceThreshold: asConfidenceThreshold(value.confidenceThreshold)! } : {}),
13341341
...(asTrimmedString(value.originLabel) ? { originLabel: asTrimmedString(value.originLabel)! } : {}),
13351342
};
13361343
}

apps/desktop/src/main/services/state/kvDb.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -736,26 +736,28 @@ function packedCrsqlPrimaryKey(value: SyncScalar): SyncScalar | null {
736736
}
737737

738738
function normalizeIncomingCrsqlChange(db: DatabaseSyncType, change: CrsqlChangeRow): CrsqlChangeRow {
739-
if (isSyncScalarBytes(change.pk)) {
740-
const packedPk = packedCrsqlPrimaryKey(change.pk);
741-
if (packedPk) return change;
742-
throw new Error(`Unsupported incoming CRSQL primary key for ${change.table}.${change.cid}: invalid packed key.`);
743-
}
744-
745739
const tableInfo = allRows<{ pk: number }>(
746740
db,
747741
`pragma table_info('${change.table.replace(/'/g, "''")}')`
748742
);
749743
const primaryKeyColumns = tableInfo.filter((column) => Number(column.pk) > 0);
750-
if (primaryKeyColumns.length === 1) {
744+
if (primaryKeyColumns.length !== 1) {
745+
const shape = primaryKeyColumns.length === 0
746+
? "no primary key"
747+
: `${primaryKeyColumns.length} primary key columns`;
748+
throw new Error(`Unsupported incoming CRSQL primary key for ${change.table}.${change.cid}: ${shape}.`);
749+
}
750+
751+
if (isSyncScalarBytes(change.pk)) {
751752
const packedPk = packedCrsqlPrimaryKey(change.pk);
752-
if (packedPk) return { ...change, pk: packedPk };
753+
if (packedPk) return change;
754+
throw new Error(`Unsupported incoming CRSQL primary key for ${change.table}.${change.cid}: invalid packed key.`);
753755
}
754756

755-
const shape = primaryKeyColumns.length === 0
756-
? "no primary key"
757-
: `${primaryKeyColumns.length} primary key columns`;
758-
throw new Error(`Unsupported incoming CRSQL primary key for ${change.table}.${change.cid}: ${shape}.`);
757+
const packedPk = packedCrsqlPrimaryKey(change.pk);
758+
if (packedPk) return { ...change, pk: packedPk };
759+
760+
throw new Error(`Unsupported incoming CRSQL primary key for ${change.table}.${change.cid}: unsupported scalar shape.`);
759761
}
760762

761763
function rebuildUnifiedMemoriesFts(db: DatabaseSyncType): void {

apps/ios/ADE/Services/Database.swift

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1568,10 +1568,13 @@ final class DatabaseService {
15681568

15691569
if let title {
15701570
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
1571-
guard !trimmedTitle.isEmpty else { return }
1572-
assignments.append("title = ?")
1573-
binders.append { statement, index in
1574-
try self.bindText(trimmedTitle, to: statement, index: index)
1571+
// Skip just the title write when it's blank — other field updates
1572+
// (pinned, manually_named) must still apply on the same call.
1573+
if !trimmedTitle.isEmpty {
1574+
assignments.append("title = ?")
1575+
binders.append { statement, index in
1576+
try self.bindText(trimmedTitle, to: statement, index: index)
1577+
}
15751578
}
15761579
}
15771580

apps/ios/ADE/Services/SyncService.swift

Lines changed: 55 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1652,21 +1652,28 @@ final class SyncService: ObservableObject {
16521652

16531653
private func normalizeActiveProjectSelection(allowSingleProjectFallback: Bool) {
16541654
let projectIds = Set(projects.map(\.id))
1655+
1656+
// Keep a still-valid active project before considering any remote-catalog
1657+
// remap. If the cached row is in `projects`, we must not override it with
1658+
// a different id from `remoteProjectCatalog` — `refreshProjectCatalog`
1659+
// intentionally preserves the existing selection when it keeps a cached
1660+
// row for the same root.
1661+
if let activeProjectId, projectIds.contains(activeProjectId) {
1662+
database.setActiveProjectId(activeProjectId)
1663+
return
1664+
}
1665+
16551666
if let activeProjectId,
16561667
let activeProjectRootPath,
16571668
let remoteProject = deduplicatedRemoteProjectCatalog().first(where: { project in
16581669
project.id != activeProjectId
16591670
&& normalizedProjectRoot(project.rootPath) == activeProjectRootPath
1660-
}) {
1671+
}),
1672+
projectIds.contains(remoteProject.id) {
16611673
setActiveProjectId(remoteProject.id, rootPath: remoteProject.rootPath)
16621674
return
16631675
}
16641676

1665-
if let activeProjectId, projectIds.contains(activeProjectId) {
1666-
database.setActiveProjectId(activeProjectId)
1667-
return
1668-
}
1669-
16701677
if let activeProjectId,
16711678
let activeProjectRootPath,
16721679
let matchingProject = projects.first(where: { normalizedProjectRoot($0.rootPath) == activeProjectRootPath }) {
@@ -3751,7 +3758,25 @@ final class SyncService: ObservableObject {
37513758

37523759
@MainActor
37533760
func cachedChatModelCatalog() -> AgentChatModelCatalog? {
3754-
guard let cached = chatModelCatalogCache.values.sorted(by: { $0.fetchedAt > $1.fetchedAt }).first else { return nil }
3761+
// Scope the fallback lookup to the current host/project. `chatModelsCacheKey`
3762+
// mixes host + project root into every key it writes, so returning the newest
3763+
// entry from *any* cache row could leak a different host's catalog into the
3764+
// active session. Match the key prefix instead and only consider entries that
3765+
// belong to the same host/project scope.
3766+
let scopePrefix = chatModelsCacheKey(provider: "")
3767+
let scopedEntries = chatModelCatalogCache
3768+
.filter { key, _ in
3769+
// Strip the provider component from the stored key and compare scopes.
3770+
// Both `scopePrefix` and the stored key use `\u{1f}` separators between
3771+
// provider, host, and project root; everything after the first separator
3772+
// is the host+project portion we care about.
3773+
guard let sepIndex = scopePrefix.firstIndex(of: "\u{1f}"),
3774+
let storedSepIndex = key.firstIndex(of: "\u{1f}")
3775+
else { return false }
3776+
return scopePrefix[sepIndex...] == key[storedSepIndex...]
3777+
}
3778+
.values
3779+
guard let cached = scopedEntries.sorted(by: { $0.fetchedAt > $1.fetchedAt }).first else { return nil }
37553780
guard Date().timeIntervalSince(cached.fetchedAt) < chatModelsCacheTTL else { return nil }
37563781
return cached.catalog
37573782
}
@@ -7383,6 +7408,12 @@ extension SyncService {
73837408
/// which token kind (alert vs activity) to target based on what it last saw
73847409
/// from us.
73857410
func sendTestPush() async -> SyncSendTestPushResult {
7411+
// Fail fast when the socket is offline. Without this guard, `sendEnvelope`
7412+
// silently drops the frame and `awaitResponse` would sit until timeout,
7413+
// making the test-push button look unresponsive.
7414+
guard canSendLiveRequests() else {
7415+
return SyncSendTestPushResult(ok: false, message: "The paired machine is offline.")
7416+
}
73867417
let requestId = makeRequestId()
73877418
do {
73887419
let raw = try await awaitResponse(
@@ -7609,15 +7640,15 @@ extension SyncService {
76097640
laneName: session.laneName.isEmpty ? nil : session.laneName,
76107641
title: session.title.isEmpty ? session.goal : session.title,
76117642
status: isFailedStatus ? "failed" : (isRunningRuntime ? "running" : (isIdleRuntime ? "idle" : status)),
7612-
awaitingInput: isAwaiting,
7613-
lastActivityAt: lastActivity,
7614-
elapsedSeconds: elapsed,
7615-
preview: session.lastOutputPreview,
7643+
awaitingInput: isAwaiting,
7644+
lastActivityAt: lastActivity,
7645+
elapsedSeconds: elapsed,
7646+
preview: session.lastOutputPreview,
76167647
pendingInputItemId: isAwaiting ? (session.pendingInputItemId ?? pendingInputItemIdForSnapshot(sessionId: session.id)) : nil,
7617-
progress: nil,
7618-
phase: nil,
7619-
toolCalls: 0
7620-
)
7648+
progress: nil,
7649+
phase: nil,
7650+
toolCalls: 0
7651+
)
76217652

76227653
allAgents.append(snap)
76237654

@@ -7672,17 +7703,17 @@ extension SyncService {
76727703
)
76737704
}
76747705

7675-
scheduleWorkspaceSnapshotWrite()
7676-
}
7706+
scheduleWorkspaceSnapshotWrite()
7707+
}
76777708

7678-
private func pendingInputItemIdForSnapshot(sessionId: String) -> String? {
7679-
let events = chatEventEnvelopesBySession[sessionId] ?? []
7680-
guard !events.isEmpty else { return nil }
7681-
let transcript = makeWorkChatTranscript(from: events)
7682-
return derivePendingWorkInputs(from: transcript).last?.itemId
7683-
}
7709+
private func pendingInputItemIdForSnapshot(sessionId: String) -> String? {
7710+
let events = chatEventEnvelopesBySession[sessionId] ?? []
7711+
guard !events.isEmpty else { return nil }
7712+
let transcript = makeWorkChatTranscript(from: events)
7713+
return derivePendingWorkInputs(from: transcript).last?.itemId
7714+
}
76847715

7685-
/// Debounced writer for the App Group `WorkspaceSnapshot`. Bounces for 2s
7716+
/// Debounced writer for the App Group `WorkspaceSnapshot`. Bounces for 2s
76867717
/// so bursty sync traffic collapses into a single widget-timeline reload.
76877718
private func scheduleWorkspaceSnapshotWrite() {
76887719
snapshotDebouncerTask?.cancel()

apps/ios/ADE/Views/Cto/CtoSettingsScreen.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,9 +348,15 @@ struct CtoSettingsScreen: View {
348348

349349
if let value = try? await syncService.fetchCtoBudget() {
350350
self.budget = value
351+
} else {
352+
// Drop stale budget rather than render outdated numbers under a fresh load.
353+
self.budget = nil
351354
}
352355
if let value = try? await syncService.fetchLinearConnectionStatus() {
353356
self.linearStatus = value
357+
} else {
358+
// Same reasoning — a failed refresh should not preserve the previous integration state.
359+
self.linearStatus = nil
354360
}
355361
}
356362
}

apps/ios/ADE/Views/Cto/CtoWorkerDetailScreen.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,9 @@ struct CtoWorkerDetailScreen: View {
531531
coreMemory = mem
532532
memoryLoadError = nil
533533
case .failure(let err):
534+
// Clear the previous snapshot so the unavailable error renders alone instead of
535+
// showing stale memory content under a fresh failure banner.
536+
coreMemory = nil
534537
memoryLoadError = err.localizedDescription
535538
}
536539
if case .success(let fetched) = runsResult { runs = fetched }

apps/ios/ADE/Views/Lanes/LaneDetailContentSections.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,10 @@ struct LaneDetailHeaderCard<Footer: View>: View {
7070
let hasUpstream = syncStatus?.hasUpstream ?? true
7171
let diverged = syncStatus?.diverged ?? false
7272
let shouldPull = hasUpstream && remoteBehind > 0 && !diverged
73-
let shouldPush = !hasUpstream || remoteAhead > 0
73+
// While detail is still loading (syncStatus nil) we don't know the ahead count yet, so
74+
// keep Push enabled instead of disabling an already-ahead lane until the fetch lands.
75+
let syncStatusLoaded = syncStatus != nil
76+
let shouldPush = !syncStatusLoaded || !hasUpstream || remoteAhead > 0
7477

7578
HStack(spacing: 8) {
7679
Image(systemName: "arrow.triangle.2.circlepath")

apps/ios/ADE/Views/PRs/PrDetailChecksTab.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ func prChecksSummaryStats(checks: [PrCheck], overallChecksStatus: String?) -> Pr
1414
case .success: pass += 1
1515
case .failure: fail += 1
1616
case .pending: pending += 1
17-
case .neutral: break
17+
// Neutral/skipped checks are non-failing outcomes; bucket them with pass so the
18+
// stat strip's total always equals the sum of pass + fail + pending.
19+
case .neutral: pass += 1
1820
}
1921
}
2022
if !checks.isEmpty {

apps/ios/ADE/Views/PRs/PrDetailFilesTab.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,8 @@ private struct PrFileRowLabel: View {
397397

398398
private func prFileInlineAction(symbol: String, label: String, action: @escaping () -> Void) -> some View {
399399
Button(action: action) {
400+
// Visual chip stays compact (24pt circle) but the outer frame expands the tap target
401+
// to Apple's HIG-minimum 44pt so these inline actions don't punish thumb taps in the row.
400402
ZStack {
401403
Circle()
402404
.fill(Color.white.opacity(0.06))
@@ -407,6 +409,8 @@ private struct PrFileRowLabel: View {
407409
.foregroundStyle(ADEColor.textSecondary)
408410
}
409411
.frame(width: 24, height: 24)
412+
.frame(width: 44, height: 44)
413+
.contentShape(Rectangle())
410414
}
411415
.buttonStyle(.plain)
412416
.accessibilityLabel(label)

0 commit comments

Comments
 (0)