Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions apps/desktop/src/main/services/chat/agentChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8347,14 +8347,39 @@ export function createAgentChatService(args: {

const readLatestTranscriptTodoItems = (
managed: ManagedChatSession,
): Extract<AgentChatEvent, { type: "todo_update" }>["items"] => {
let latest: Extract<AgentChatEvent, { type: "todo_update" }>["items"] = [];
): Extract<AgentChatEvent, { type: "todo_update" }>["items"] =>
readTranscriptHydrationState(managed).todoItems;

/** Everything a rehydrated session has to recover from its own transcript,
* read in one pass (the transcript is not cached, so this is deliberately not
* two separate scans).
*
* `maxEventSequence` is the load-bearing part. `eventSequence` is a runtime
* counter, but the transcript it numbers is durable and appended across
* restarts — so starting a rehydrated session back at 0 mints sequence
* numbers that already exist in the file. Consumers that treat
* `sessionId + sequence` as an event identity then mistake the new events for
* replays of the old ones and drop them; that is exactly how AskUserQuestion
* cards silently vanished on iOS for sessions reopened after a desktop
* restart. Seeding from the file keeps sequences strictly increasing for the
* life of the transcript. */
const readTranscriptHydrationState = (
managed: ManagedChatSession,
): {
todoItems: Extract<AgentChatEvent, { type: "todo_update" }>["items"];
maxEventSequence: number;
} => {
let todoItems: Extract<AgentChatEvent, { type: "todo_update" }>["items"] = [];
let maxEventSequence = 0;
for (const entry of readTranscriptEnvelopes(managed)) {
if (entry.event.type === "todo_update") {
latest = entry.event.items;
todoItems = entry.event.items;
}
if (typeof entry.sequence === "number" && entry.sequence > maxEventSequence) {
maxEventSequence = entry.sequence;
}
}
return latest;
return { todoItems, maxEventSequence };
};

/** Runtime-lifetime TaskCreate/TaskUpdate tracker, lazily seeded from the
Expand Down Expand Up @@ -15722,7 +15747,11 @@ export function createAgentChatService(args: {
claudeBackgroundLogText: persisted?.claudeBackgroundLogText ?? "",
compactionEmitterState: createCompactionEmitterState(),
};
managed.todoItems = readLatestTranscriptTodoItems(managed);
const transcriptHydration = readTranscriptHydrationState(managed);
managed.todoItems = transcriptHydration.todoItems;
// Continue the transcript's numbering instead of restarting at 1 — see
// `readTranscriptHydrationState`.
managed.eventSequence = transcriptHydration.maxEventSequence;
if (!managed.session.interactionMode && managed.session.orchestrationRole) {
managed.session.interactionMode = orchestrationInteractionModeForRole(managed.session.orchestrationRole);
}
Expand Down
4 changes: 4 additions & 0 deletions apps/ios/ADE.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
E1000000000000000000002F /* WorkArtifactTerminalViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000002F /* WorkArtifactTerminalViews.swift */; };
E10000000000000000000030 /* WorkMarkdownViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000030 /* WorkMarkdownViews.swift */; };
E10000000000000000000031 /* WorkModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000031 /* WorkModels.swift */; };
E10000000000000000000601 /* WorkDraftPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000601 /* WorkDraftPersistence.swift */; };
E10000000000000000000032 /* WorkTranscriptParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000032 /* WorkTranscriptParser.swift */; };
E10000000000000000000033 /* WorkNavigationAndTranscriptHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000033 /* WorkNavigationAndTranscriptHelpers.swift */; };
E10000000000000000000034 /* WorkMarkdownParsing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000034 /* WorkMarkdownParsing.swift */; };
Expand Down Expand Up @@ -362,6 +363,7 @@
D1000000000000000000002F /* WorkArtifactTerminalViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkArtifactTerminalViews.swift; path = ADE/Views/Work/WorkArtifactTerminalViews.swift; sourceTree = "<group>"; };
D10000000000000000000030 /* WorkMarkdownViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkMarkdownViews.swift; path = ADE/Views/Work/WorkMarkdownViews.swift; sourceTree = "<group>"; };
D10000000000000000000031 /* WorkModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkModels.swift; path = ADE/Views/Work/WorkModels.swift; sourceTree = "<group>"; };
D10000000000000000000601 /* WorkDraftPersistence.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkDraftPersistence.swift; path = ADE/Views/Work/WorkDraftPersistence.swift; sourceTree = "<group>"; };
D10000000000000000000032 /* WorkTranscriptParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkTranscriptParser.swift; path = ADE/Views/Work/WorkTranscriptParser.swift; sourceTree = "<group>"; };
D10000000000000000000033 /* WorkNavigationAndTranscriptHelpers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkNavigationAndTranscriptHelpers.swift; path = ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift; sourceTree = "<group>"; };
D10000000000000000000034 /* WorkMarkdownParsing.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkMarkdownParsing.swift; path = ADE/Views/Work/WorkMarkdownParsing.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -829,6 +831,7 @@
D1000000000000000000002F /* WorkArtifactTerminalViews.swift */,
D10000000000000000000030 /* WorkMarkdownViews.swift */,
D10000000000000000000031 /* WorkModels.swift */,
D10000000000000000000601 /* WorkDraftPersistence.swift */,
D10000000000000000000032 /* WorkTranscriptParser.swift */,
D10000000000000000000033 /* WorkNavigationAndTranscriptHelpers.swift */,
D10000000000000000000034 /* WorkMarkdownParsing.swift */,
Expand Down Expand Up @@ -1508,6 +1511,7 @@
E1000000000000000000002F /* WorkArtifactTerminalViews.swift in Sources */,
E10000000000000000000030 /* WorkMarkdownViews.swift in Sources */,
E10000000000000000000031 /* WorkModels.swift in Sources */,
E10000000000000000000601 /* WorkDraftPersistence.swift in Sources */,
E10000000000000000000032 /* WorkTranscriptParser.swift in Sources */,
E10000000000000000000033 /* WorkNavigationAndTranscriptHelpers.swift in Sources */,
E10000000000000000000034 /* WorkMarkdownParsing.swift in Sources */,
Expand Down
16 changes: 14 additions & 2 deletions apps/ios/ADE/Models/RemoteModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1929,9 +1929,21 @@ struct AgentChatEventProvenance: Decodable, Equatable {
}

struct AgentChatEventEnvelope: Decodable, Identifiable, Equatable {
/// Identity must include the timestamp, not just the sequence.
///
/// A host's `eventSequence` counter restarts at 1 whenever a session is
/// rehydrated, but it keeps appending to the SAME transcript file — so one
/// transcript can hold two events numbered 67, hours apart. Keying identity on
/// `sessionId:sequence` alone made the newer event look like a duplicate of
/// the older one, and dedupe (first-key-wins) silently dropped it. That is how
/// an `approval_request` carrying a whole AskUserQuestion card disappeared
/// from a phone while the rest of the turn rendered fine.
///
/// A genuine redelivery carries the same timestamp AND sequence, so dedupe
/// still catches it; only cross-epoch collisions are broken apart.
var id: String {
let sequencePart = sequence.map(String.init) ?? timestamp
return "\(sessionId):\(sequencePart)"
guard let sequence else { return "\(sessionId):\(timestamp)" }
return "\(sessionId):\(timestamp):\(sequence)"
}

var sessionId: String
Expand Down
27 changes: 27 additions & 0 deletions apps/ios/ADE/Services/SyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10407,6 +10407,15 @@ final class SyncService: ObservableObject {
} else {
UserDefaults.standard.removeObject(forKey: profileKey)
UserDefaults.standard.removeObject(forKey: legacyDraftKey)
// Deliberately does NOT clear the composer/question draft stores. Reaching
// here does not mean the user asked to forget anything: the only
// production trigger is `forgetHost()`, which has no UI caller and fires
// automatically from `handleReconnectFailure` on an attributed auth
// failure — a desktop reinstall or token rotation is enough. And the
// stores are keyed by session id, not by host, so wiping them would
// destroy unsent text for every OTHER machine still paired, plus the
// machine-independent Hub and New Chat drafts. Losing a user's typed words
// on a background reconnect is far worse than a stale draft lingering.
activeHostProfile = nil
hostName = nil
hiddenProjectKeys = loadHiddenProjectKeys()
Expand Down Expand Up @@ -15638,6 +15647,24 @@ final class SyncService: ObservableObject {
processed.map { $0 ? "1" : "0" } ?? "",
text.trimmingCharacters(in: .whitespacesAndNewlines)
].joined(separator: "|")
// Blocking gates carry a host-assigned `itemId` that is unique for the life
// of the session, so key them on that rather than falling through to the
// sequence-derived envelope id. A dropped gate is not a cosmetic loss — it
// is a question card the user never sees and can never answer — so it must
// not depend on sequence numbers being unique, which they are not across a
// host restart.
case .approvalRequest(let itemId, _, _, _, _, _):
let normalizedItemId = itemId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedItemId.isEmpty else { return nil }
return [envelope.sessionId, "approval_request", normalizedItemId].joined(separator: "|")
case .structuredQuestion(_, _, let itemId, _):
let normalizedItemId = itemId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedItemId.isEmpty else { return nil }
return [envelope.sessionId, "structured_question", normalizedItemId].joined(separator: "|")
case .pendingInputResolved(let itemId, let resolution, _):
let normalizedItemId = itemId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedItemId.isEmpty else { return nil }
return [envelope.sessionId, "pending_input_resolved", normalizedItemId, resolution].joined(separator: "|")
default:
return nil
}
Expand Down
4 changes: 4 additions & 0 deletions apps/ios/ADE/Views/Hub/HubComposerDrawer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ struct HubInlineComposer: View {
}
)
.onAppear { onAppearSetup() }
.workPersistedDraft($draft, key: WorkComposerDraftStore.hubNewChatKey)
.onChange(of: composerFocused) { _, focused in
if focused { withAnimation(hubComposerSpring) { expanded = true } }
}
Expand Down Expand Up @@ -770,6 +771,9 @@ struct HubInlineComposer: View {
collapse()
draft = ""
attachments.removeAll()
// Drop the persisted draft synchronously — the collapse must not race the
// 400ms autosave debounce and leave the just-sent text behind.
WorkComposerDraftStore.clear(WorkComposerDraftStore.hubNewChatKey)
Task {
let started = await submit(opener: restoredDraft, attachments: outgoingAttachments)
if !started {
Expand Down
Loading