diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index fc7ec0440..47f41c1ea 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -8347,14 +8347,39 @@ export function createAgentChatService(args: { const readLatestTranscriptTodoItems = ( managed: ManagedChatSession, - ): Extract["items"] => { - let latest: Extract["items"] = []; + ): Extract["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["items"]; + maxEventSequence: number; + } => { + let todoItems: Extract["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 @@ -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); } diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index 194723a94..a7f2f4346 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -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 */; }; @@ -362,6 +363,7 @@ D1000000000000000000002F /* WorkArtifactTerminalViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkArtifactTerminalViews.swift; path = ADE/Views/Work/WorkArtifactTerminalViews.swift; sourceTree = ""; }; D10000000000000000000030 /* WorkMarkdownViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkMarkdownViews.swift; path = ADE/Views/Work/WorkMarkdownViews.swift; sourceTree = ""; }; D10000000000000000000031 /* WorkModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkModels.swift; path = ADE/Views/Work/WorkModels.swift; sourceTree = ""; }; + D10000000000000000000601 /* WorkDraftPersistence.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkDraftPersistence.swift; path = ADE/Views/Work/WorkDraftPersistence.swift; sourceTree = ""; }; D10000000000000000000032 /* WorkTranscriptParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkTranscriptParser.swift; path = ADE/Views/Work/WorkTranscriptParser.swift; sourceTree = ""; }; D10000000000000000000033 /* WorkNavigationAndTranscriptHelpers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkNavigationAndTranscriptHelpers.swift; path = ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift; sourceTree = ""; }; D10000000000000000000034 /* WorkMarkdownParsing.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkMarkdownParsing.swift; path = ADE/Views/Work/WorkMarkdownParsing.swift; sourceTree = ""; }; @@ -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 */, @@ -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 */, diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 7a4c40307..a0ae042ce 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -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 diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 4f85e8f83..0584f2cac 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -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() @@ -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 } diff --git a/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift b/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift index ef403cf00..2de053ea6 100644 --- a/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift +++ b/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift @@ -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 } } } @@ -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 { diff --git a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift index 0a7a3cfff..79b26659d 100644 --- a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift @@ -875,7 +875,7 @@ func workPreviewIsWireframe(_ text: String) -> Bool { } /// Natural height of the question card's scrollable body, used to fit the -/// internal ScrollView to its content up to the viewport-derived cap. +/// internal ScrollView to its content up to the height-budget-derived cap. private struct WorkQuestionBodyHeightKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { @@ -883,25 +883,122 @@ private struct WorkQuestionBodyHeightKey: PreferenceKey { } } +/// Measured height of the card's non-scrolling chrome (provider row + tab strip +/// above, freeform field + action footer below). Subtracted from the card's +/// height budget so the scroll region — not the Send button — absorbs the +/// overflow. Without this the footer got pushed off-screen behind the keyboard +/// on long option lists and the card could not be submitted at all. +private struct WorkQuestionTopChromeHeightKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +private struct WorkQuestionBottomChromeHeightKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +private struct WorkPendingCardContentHeightKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +/// Caps a pending-input card at the chat surface's height budget, scrolling the +/// overflow rather than growing. A gate that outgrows its budget used to push +/// the composer off the bottom of the screen, which made it unanswerable — the +/// card must never be able to claim more than its share of the page. +/// +/// Short cards are unaffected: the content is measured and the frame follows it +/// exactly, so there is no dead space and no scroll indicator until the cap is +/// actually hit. `WorkStructuredQuestionCard` does its own budgeting (it needs +/// to keep its footer pinned outside the scroll region) and is not wrapped. +struct WorkPendingInputHeightBoundedCard: View { + /// Placeholder height for the frames before the content reports its own. + /// + /// Sits below the cards it wraps rather than above them: the plan and approval + /// strips render around 34pt, a permission card 126-360pt, a model-selection + /// card 185pt+. So this usually under-guesses and the card grows into place — + /// which is the better direction to be wrong in, because `composerInset` is + /// fixed-size, and a card that starts at the full budget (~434pt on a typical + /// iPhone) visibly shoves the transcript up and then drags it back down. + private static var unmeasuredHeightGuess: CGFloat { 120 } + + let maxHeight: CGFloat + @ViewBuilder var content: Content + + @State private var measuredHeight: CGFloat? + + var body: some View { + if maxHeight <= 0 { + content + } else { + ScrollView { + content + .background( + GeometryReader { geo in + Color.clear.preference( + key: WorkPendingCardContentHeightKey.self, + value: geo.size.height + ) + } + ) + } + .frame(height: max(1, min(measuredHeight ?? Self.unmeasuredHeightGuess, maxHeight))) + .scrollBounceBehavior(.basedOnSize) + .scrollDismissesKeyboard(.interactively) + .onPreferenceChange(WorkPendingCardContentHeightKey.self) { height in + guard height > 0 else { return } + guard let measured = measuredHeight else { + measuredHeight = height + return + } + if abs(measured - height) > 0.5 { + measuredHeight = height + } + } + } + } +} + struct WorkStructuredQuestionCard: View { let question: WorkPendingQuestionModel let busy: Bool /// Tap-to-submit is only used for single-question single-select with options /// (the card invokes this directly from `optionRow`). Multi-question cards /// never call this — taps only update local state and submit via Send. - let onSelectOption: @MainActor (WorkPendingQuestionOption, String?) async -> Void + /// + /// Returns whether the answer was actually accepted. The card only discards + /// the user's work when it was: a rejected send rolls the optimistic hide + /// back and the card returns, and it must return with the answers still in it. + let onSelectOption: @MainActor (WorkPendingQuestionOption, String?) async -> Bool /// Aggregate submit: one map from questionId -> answer value, plus the /// shared freeform response (single-question only). The session action - /// forwards this as one `chat.respondToInput` call. - let onSubmitAll: @MainActor ([String: AgentChatInputAnswerValue], String?) async -> Void - let onDecline: @MainActor () async -> Void + /// forwards this as one `chat.respondToInput` call. Returns acceptance — see + /// `onSelectOption`. + let onSubmitAll: @MainActor ([String: AgentChatInputAnswerValue], String?) async -> Bool + let onDecline: @MainActor () async -> Bool var onFreeformFocusChange: ((Bool) -> Void)? = nil /// Provider to fall back on when the parsed question carries no `source` /// (legacy `structured_question` envelopes). Usually the session provider. var fallbackProvider: String? = nil - /// Transcript viewport height, used to cap the card so long option lists - /// scroll internally instead of overflowing the screen. 0 until measured. - var viewportHeight: CGFloat = 0 + /// Hard ceiling for the card's total laid-out height, computed from the space + /// actually available (keyboard included). The card never exceeds it — the + /// option list scrolls internally instead. 0 until measured. + /// + /// For the composer-anchored strip — the live path — this comes from + /// `workPendingInputMaxHeight`, which budgets the whole chat surface and NOT + /// the transcript viewport: the transcript shrinks as this card grows, so + /// feeding its height back in created a runaway loop where the card ate the + /// screen. The inline-in-transcript variant is the deliberate exception; it + /// sits *inside* the transcript, so `workInlinePendingInputMaxHeight` budgets + /// it from the viewport with no such feedback path. + var maxCardHeight: CGFloat = 0 /// Resolved asking provider: the parsed question source, else the session /// fallback. Drives the header verb, logo, and per-provider accent. @@ -920,6 +1017,13 @@ struct WorkStructuredQuestionCard: View { @State private var freeformByQuestion: [String: String] = [:] @State private var expandedPreviews: Set = [] @State private var measuredBodyHeight: CGFloat? = nil + /// Seeded with plausible defaults so the very first frame doesn't overshoot + /// the budget before the preference measurements land. + @State private var topChromeHeight: CGFloat = 26 + @State private var bottomChromeHeight: CGFloat = 40 + /// Gates autosave until the restore pass has run, so an empty first frame + /// can't overwrite a stored draft with nothing. + @State private var didRestoreDrafts = false @FocusState private var freeformFocused: Bool private var isPaged: Bool { question.questions.count > 1 } @@ -929,7 +1033,33 @@ struct WorkStructuredQuestionCard: View { return question.questions[index] } - private var bodyMaxHeight: CGFloat { max(240, viewportHeight * 0.62) } + /// Layout constants the height budget depends on. They are named rather than + /// literal because the budget arithmetic below has to agree with the actual + /// `adeGlassCard` padding and `VStack` spacing used in `body` — a silent + /// disagreement re-opens the exact overflow this card exists to prevent, with + /// no compile error and no symptom until a long option list appears. + private static let cardPadding: CGFloat = 14 + private static let cardStackSpacing: CGFloat = 12 + + /// Vertical space the card spends outside the scroll region: the glass card's + /// padding top and bottom, plus the two stack gaps that flank the scroll view. + private static var cardFixedInsets: CGFloat { + cardPadding * 2 + cardStackSpacing * 2 + } + + /// Height the scroll region may occupy. When no budget has been measured yet + /// we fall back to a conservative constant rather than "unbounded" so a slow + /// first layout can't flash a full-screen card. + private var bodyMaxHeight: CGFloat { + let budget = maxCardHeight > 0 ? maxCardHeight : 320 + let chrome = topChromeHeight + bottomChromeHeight + Self.cardFixedInsets + // Floor at 64pt — roughly one option row. On a small phone with the keyboard + // up the fixed chrome alone can exceed the budget; collapsing the option + // list to nothing would be worse than overflowing slightly, and the overflow + // is absorbed by the transcript rather than by the footer (see + // `pendingInputMaxHeight`). + return max(64, budget - chrome) + } /// Fit the scroll area to its content up to the cap: short lists render at /// their natural height (no scroll, exactly as before); longer lists cap and @@ -937,19 +1067,16 @@ struct WorkStructuredQuestionCard: View { private var resolvedBodyHeight: CGFloat { let cap = bodyMaxHeight guard let measured = measuredBodyHeight else { return cap } - return min(measured, cap) + return max(1, min(measured, cap)) } var body: some View { - VStack(alignment: .leading, spacing: 12) { - headerRow - - if isPaged { - questionTabStrip - } + VStack(alignment: .leading, spacing: Self.cardStackSpacing) { + topChrome + .background(chromeHeightReader(WorkQuestionTopChromeHeightKey.self)) ScrollView { - questionPage(activeQuestion) + scrollableBody .background( GeometryReader { geo in Color.clear.preference( @@ -961,6 +1088,9 @@ struct WorkStructuredQuestionCard: View { } .frame(height: resolvedBodyHeight) .scrollBounceBehavior(.basedOnSize) + // Dragging the option list down dismisses the keyboard, so a long typed + // freeform answer never traps the user with no way back to the footer. + .scrollDismissesKeyboard(.interactively) .onPreferenceChange(WorkQuestionBodyHeightKey.self) { height in guard let measured = measuredBodyHeight else { measuredBodyHeight = height @@ -971,43 +1101,171 @@ struct WorkStructuredQuestionCard: View { } } - if activeQuestion.allowsFreeform { - freeformRow(for: activeQuestion) - } - - footerRow + bottomChrome + .background(chromeHeightReader(WorkQuestionBottomChromeHeightKey.self)) } - .adeGlassCard(cornerRadius: 18, padding: 14) + .adeGlassCard(cornerRadius: 18, padding: Self.cardPadding) .overlay( RoundedRectangle(cornerRadius: 18, style: .continuous) .stroke(providerAccent.opacity(0.30), lineWidth: 1) ) + .onPreferenceChange(WorkQuestionTopChromeHeightKey.self) { height in + guard height > 0, abs(topChromeHeight - height) > 0.5 else { return } + topChromeHeight = height + } + .onPreferenceChange(WorkQuestionBottomChromeHeightKey.self) { height in + guard height > 0, abs(bottomChromeHeight - height) > 0.5 else { return } + bottomChromeHeight = height + } .onChange(of: freeformFocused) { _, focused in onFreeformFocusChange?(focused) } + .task(id: question.id) { + restoreDrafts() + } + .task(id: draftSignature) { + // Keystroke debounce: each edit cancels the pending sleep and restarts it, + // so a burst of typing costs one write instead of one per character. + guard didRestoreDrafts else { return } + try? await Task.sleep(for: workDraftAutosaveDebounce) + guard !Task.isCancelled else { return } + persistDrafts() + } + .onDisappear { + // Navigating away is exactly the case the debounce would miss. + guard didRestoreDrafts else { return } + persistDrafts() + } .accessibilityElement(children: .contain) .accessibilityLabel("\(workChatSurfaceProviderName(resolvedProvider)) asks. \(activeQuestion.question)") } + /// Change fingerprint for the autosave debounce. Hash-based rather than a + /// concatenated string so a long freeform answer doesn't rebuild a big value + /// on every keystroke. + private var draftSignature: Int { + var hasher = Hasher() + hasher.combine(question.id) + hasher.combine(currentPage) + hasher.combine(singleQuestionFreeformText) + for key in selections.keys.sorted() { + hasher.combine(key) + hasher.combine(selections[key]?.sorted() ?? []) + } + for key in freeformByQuestion.keys.sorted() { + hasher.combine(key) + hasher.combine(freeformByQuestion[key] ?? "") + } + return hasher.finalize() + } + + @MainActor + private func restoreDrafts() { + defer { didRestoreDrafts = true } + guard let stored = WorkQuestionDraftStore.load(question.id) else { return } + // Only restore into an untouched card — a card already mid-edit (the same + // request re-rendering) must win over what's on disk. + guard selections.isEmpty, freeformByQuestion.isEmpty, singleQuestionFreeformText.isEmpty else { return } + selections = stored.selections + freeformByQuestion = stored.freeform + singleQuestionFreeformText = stored.sharedFreeform + if stored.page > 0, stored.page < question.questions.count { + currentPage = stored.page + } + } + + /// Questions whose freeform answer is a secret (rendered in a `SecureField`). + /// Their text is never written to disk — the resolved card already refuses to + /// echo it back, and UserDefaults is an App Group store shared with the widget + /// extension, so persisting it would put a credential in plaintext. + private var secretQuestionIds: Set { + Set(question.questions.filter(\.isSecret).map(\.questionId)) + } + + private func persistDrafts() { + let secretIds = secretQuestionIds + WorkQuestionDraftStore.save( + WorkQuestionDraftStore.Snapshot( + // Selections are excluded for a secret question too, not just freeform: + // when such a question carries options, the chosen option value IS the + // secret answer, and persisting it would leak exactly what the + // SecureField exists to protect. + selections: selections.filter { !secretIds.contains($0.key) }, + freeform: freeformByQuestion.filter { !secretIds.contains($0.key) }, + // The shared freeform belongs to the single-question card's only + // question, so it inherits that question's secrecy. + sharedFreeform: question.primary.isSecret ? "" : singleQuestionFreeformText, + page: currentPage + ), + for: question.id + ) + } + + private func chromeHeightReader(_ key: K.Type) -> some View where K.Value == CGFloat { + GeometryReader { geo in + Color.clear.preference(key: key, value: geo.size.height) + } + } + + /// Pinned above the scroll region. Deliberately minimal — provider verb and + /// (when paged) the question tabs — so its height stays bounded no matter how + /// verbose the request is. @ViewBuilder - private var headerRow: some View { - VStack(alignment: .leading, spacing: 8) { - // Provider-identified header: logo + "{Provider} asks" verb. Replaces the - // old clock-icon "Input needed · Claude" treatment from the desktop redesign. - HStack(spacing: 8) { - WorkProviderBareLogo( - provider: resolvedProvider, - fallbackSymbol: providerIcon(resolvedProvider ?? ""), - tint: providerAccent, - size: 18 - ) - Text(question.providerHeaderVerb(fallbackProvider: fallbackProvider)) - .font(.caption.weight(.semibold)) - .foregroundStyle(providerAccent) - Spacer(minLength: 0) + private var topChrome: some View { + VStack(alignment: .leading, spacing: 10) { + providerRow + if isPaged { + questionTabStrip } - .accessibilityHidden(true) + } + } + + /// Everything that can be arbitrarily long lives here and scrolls: the + /// question text itself, the request body, impact/default meta rows, and the + /// option list. Previously the question text sat in the fixed header, so a + /// long prompt pushed the footer off-screen even when the options fit. + @ViewBuilder + private var scrollableBody: some View { + VStack(alignment: .leading, spacing: 10) { + headerRow + questionPage(activeQuestion) + } + } + + /// Pinned below the scroll region so Send/Decline are always reachable. + @ViewBuilder + private var bottomChrome: some View { + VStack(alignment: .leading, spacing: 12) { + if activeQuestion.allowsFreeform { + freeformRow(for: activeQuestion) + } + footerRow + } + } + /// Provider-identified header: logo + "{Provider} asks" verb. Replaces the + /// old clock-icon "Input needed · Claude" treatment from the desktop redesign. + /// Kept out of the scroll region so the card always identifies itself. + @ViewBuilder + private var providerRow: some View { + HStack(spacing: 8) { + WorkProviderBareLogo( + provider: resolvedProvider, + fallbackSymbol: providerIcon(resolvedProvider ?? ""), + tint: providerAccent, + size: 18 + ) + Text(question.providerHeaderVerb(fallbackProvider: fallbackProvider)) + .font(.caption.weight(.semibold)) + .foregroundStyle(providerAccent) + Spacer(minLength: 0) + } + .accessibilityHidden(true) + } + + @ViewBuilder + private var headerRow: some View { + VStack(alignment: .leading, spacing: 8) { // Optional kicker: the question's short `header` shown above the prompt. if let header = activeQuestion.header, !header.isEmpty { Text(header.uppercased()) @@ -1145,24 +1403,61 @@ struct WorkStructuredQuestionCard: View { @ViewBuilder private func freeformRow(for q: WorkPendingQuestion) -> some View { let binding = freeformBinding(for: q) - if q.isSecret { - SecureField(q.options.isEmpty ? "Response" : "Optional response", text: binding) - .focused($freeformFocused) - .adeInsetField(cornerRadius: 14, padding: 12) - .disabled(busy) - } else { - TextField(q.options.isEmpty ? "Response" : "Optional response", text: binding, axis: .vertical) - .focused($freeformFocused) - .lineLimit(1...4) - .adePromptInputTraits() - .adeInsetField(cornerRadius: 14, padding: 12) - .disabled(busy) + Group { + if q.isSecret { + SecureField(q.options.isEmpty ? "Response" : "Optional response", text: binding) + .focused($freeformFocused) + .adeInsetField(cornerRadius: 14, padding: 12) + .disabled(busy) + } else { + TextField(q.options.isEmpty ? "Response" : "Optional response", text: binding, axis: .vertical) + .focused($freeformFocused) + .lineLimit(1...4) + .adePromptInputTraits() + .adeInsetField(cornerRadius: 14, padding: 12) + .disabled(busy) + } + } + // Standard iOS escape hatch from a multi-line field: the vertical-axis + // TextField swallows Return as a newline, so without an explicit Done there + // is no way to lower the keyboard. + // + // Gated on `freeformFocused` rather than declared unconditionally: keyboard + // toolbars are scoped to the enclosing view, and this card is mounted a few + // points above the main chat composer — a UITextView this Done button cannot + // dismiss. If the toolbar ever surfaced over that keyboard, the button would + // silently do nothing, which is worse than having no button at all. + .toolbar { + if freeformFocused { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { freeformFocused = false } + .accessibilityLabel("Dismiss keyboard") + } + } } } @ViewBuilder private var footerRow: some View { HStack(spacing: 10) { + // Second, always-visible way down from the keyboard — the footer is now + // pinned, so this stays reachable even with a long answer typed. + if freeformFocused { + Button { + freeformFocused = false + } label: { + Image(systemName: "keyboard.chevron.compact.down") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textSecondary) + .frame(width: 32, height: 32) + .background(ADEColor.surfaceBackground.opacity(0.6), in: Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Dismiss keyboard") + .transition(.opacity) + } + Button("Decline") { Task { await declineQuestion() } } @@ -1179,6 +1474,7 @@ struct WorkStructuredQuestionCard: View { .tint(providerAccent) .disabled(busy || !canSubmit) } + .animation(.smooth(duration: 0.18), value: freeformFocused) } private var submitLabel: String { @@ -1238,18 +1534,25 @@ struct WorkStructuredQuestionCard: View { let shared = singleQuestionFreeformText.trimmingCharacters(in: .whitespacesAndNewlines) return shared.isEmpty ? nil : shared }() - await onSubmitAll(answers, sharedFreeform) - clearQuestionDrafts() + // Only discard the answers once the host has accepted them. A failed send + // restores the card; it must come back with the user's work intact. + if await onSubmitAll(answers, sharedFreeform) { + clearQuestionDrafts() + } } @MainActor private func declineQuestion() async { - await onDecline() - clearQuestionDrafts() + if await onDecline() { + clearQuestionDrafts() + } } @MainActor private func clearQuestionDrafts() { + // Drop the persisted copy first: the request is answered, so a later + // debounce tick must not be able to write it back. + WorkQuestionDraftStore.clear(question.id) if !singleQuestionFreeformText.isEmpty { singleQuestionFreeformText = "" } @@ -1423,8 +1726,9 @@ struct WorkStructuredQuestionCard: View { if singleQuestionSingleSelect { let freeform = singleQuestionFreeformText.trimmingCharacters(in: .whitespacesAndNewlines) Task { @MainActor in - await onSelectOption(option, freeform.isEmpty ? nil : freeform) - clearQuestionDrafts() + if await onSelectOption(option, freeform.isEmpty ? nil : freeform) { + clearQuestionDrafts() + } } } } diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift index ccc3f6ee6..cbfad768e 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift @@ -131,23 +131,26 @@ extension WorkChatSessionView { question: question, busy: actionInFlight || !isLive, onSelectOption: { option, freeform in - await runSessionAction { + await runSessionAction { () async -> Bool in await onRespondToQuestion( question.id, question.questionId, .string(option.value), freeform ) + return errorMessage == nil } }, onSubmitAll: { answers, freeform in - await runSessionAction { + await runSessionAction { () async -> Bool in await onSubmitQuestionAnswers(question.id, answers, freeform) + return errorMessage == nil } }, onDecline: { - await runSessionAction { + await runSessionAction { () async -> Bool in await onDeclineQuestion(question.id) + return errorMessage == nil } }, onFreeformFocusChange: { focused in @@ -163,7 +166,9 @@ extension WorkChatSessionView { } }, fallbackProvider: chatSummaryContext.provider, - viewportHeight: scrollViewportHeight + maxCardHeight: workInlinePendingInputMaxHeight( + transcriptViewportHeight: scrollViewportHeight + ) ) .id("pending-question-\(question.id)") case .pendingPermission(let permission): @@ -419,22 +424,28 @@ extension WorkChatSessionView { @ViewBuilder func consolidatedPendingInputStrip(_ item: WorkPendingInputItem) -> some View { VStack(alignment: .leading, spacing: 8) { - if pendingInputCount > 1 { + if pendingInputCollapsed { + pendingInputCollapsedPill(item) + } else { pendingInputQueueHeader + consolidatedPendingInputBody(item) } - consolidatedPendingInputBody(item) } + .animation(.smooth(duration: 0.22), value: pendingInputCollapsed) } - /// "Request 1 of N" + optional "Accept all". The primary request is always the - /// first in the queue, so the leading index is fixed at 1. + /// "Request 1 of N" + optional "Accept all" + the minimize control. Previously + /// this row only rendered for queued requests; it is now always present + /// because it carries the minimize affordance, which every gate needs. @ViewBuilder private var pendingInputQueueHeader: some View { HStack(spacing: 8) { - Text("Request 1 of \(pendingInputCount)") - .font(.caption2.weight(.semibold)) - .foregroundStyle(ADEColor.textMuted) - .accessibilityLabel("Request 1 of \(pendingInputCount) pending.") + if pendingInputCount > 1 { + Text("Request 1 of \(pendingInputCount)") + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + .accessibilityLabel("Request 1 of \(pendingInputCount) pending.") + } Spacer(minLength: 0) if canAcceptAllPendingInputs { Button { @@ -448,12 +459,87 @@ extension WorkChatSessionView { .disabled(actionInFlight || !isLive) .accessibilityLabel("Accept all \(acceptAllSweepableInputs.count) pending approvals") } + Button { + pendingInputCollapsed = true + } label: { + Image(systemName: "chevron.down") + .font(.caption2.weight(.bold)) + .foregroundStyle(ADEColor.textSecondary) + .frame(width: 26, height: 22) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Minimize request") + .accessibilityHint("Keeps the request open so you can scroll the conversation.") } .padding(.horizontal, 4) + .frame(minHeight: 22) + } + + /// Minimized state: a single tappable line that keeps the gate visible (and + /// says what it is) while giving the transcript the screen back. + @ViewBuilder + private func pendingInputCollapsedPill(_ item: WorkPendingInputItem) -> some View { + let provider = workPendingInputProvider(item) ?? chatSummaryContext.provider + let accent = ADEColor.providerChatAccent(for: provider) + let summary = workPendingInputCollapsedSummary(item) + Button { + pendingInputCollapsed = false + } label: { + HStack(spacing: 8) { + WorkProviderBareLogo( + provider: provider, + fallbackSymbol: providerIcon(provider), + tint: accent, + size: 15 + ) + Text(summary) + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 4) + if pendingInputCount > 1 { + Text("\(pendingInputCount)") + .font(.caption2.weight(.bold)) + .foregroundStyle(accent) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(accent.opacity(0.16), in: Capsule()) + } + Image(systemName: "chevron.up") + .font(.caption2.weight(.bold)) + .foregroundStyle(ADEColor.textSecondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + .background(ADEColor.surfaceBackground.opacity(0.7), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(accent.opacity(0.35), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .accessibilityLabel("\(summary). Minimized.") + .accessibilityHint("Expand to answer.") } @ViewBuilder private func consolidatedPendingInputBody(_ item: WorkPendingInputItem) -> some View { + // The question card budgets itself (its footer has to stay pinned outside + // the scroll region); every other kind is capped by the shared wrapper. + if case .question = item { + pendingInputCard(item) + } else { + WorkPendingInputHeightBoundedCard(maxHeight: pendingInputMaxHeight) { + pendingInputCard(item) + } + } + } + + @ViewBuilder + private func pendingInputCard(_ item: WorkPendingInputItem) -> some View { switch item { case .planApproval(let model): WorkPlanComposerStrip( @@ -512,7 +598,7 @@ extension WorkChatSessionView { } }, fallbackProvider: chatSummaryContext.provider, - viewportHeight: scrollViewportHeight + maxCardHeight: pendingInputMaxHeight ) case .modelSelection(let model): WorkModelSelectionPendingCard( diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index ec021323a..abda65526 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -246,6 +246,14 @@ struct WorkChatSessionView: View { /// item leaves the derived queue (or rolled back if the command errored). See /// `dispatchPendingInputAnswer` / `reconcileOptimisticallyAnsweredInputs`. @State var optimisticallyAnsweredInputIds: Set = [] + /// Id of the pending input the user minimized, if any. + /// + /// Derived, not synchronized: a minimize applies to the gate the user chose to + /// defer, so it has to expire on its own the moment a different gate becomes + /// primary. Storing the id and computing the Bool from it makes that + /// impossible to get wrong; a Bool reset from an observer would be one more + /// thing that can fall out of step and leave a fresh question hidden. + @State var collapsedPendingInputId: String? var sessionStatus: String { resolvedSessionStatus ?? session.normalizedStatus @@ -347,6 +355,36 @@ struct WorkChatSessionView: View { pendingInputs.first } + /// The strip is minimized only while the deferred gate is still the primary + /// one. The gate stays open and the composer stays locked either way — only + /// the card is swapped for a one-line pill. + var pendingInputCollapsed: Bool { + get { + guard let collapsedPendingInputId, let primaryPendingInput else { return false } + return collapsedPendingInputId == primaryPendingInput.id + } + nonmutating set { + collapsedPendingInputId = newValue ? primaryPendingInput?.id : nil + } + } + + /// Total height available to the chat surface, keyboard already subtracted. + /// + /// The transcript and the composer inset split the surface between them, so + /// their measured heights always sum back to it — and unlike either half on + /// its own, the sum does NOT move when the pending-input card grows. That + /// matters: sizing the card off `scrollViewportHeight` alone (what this used + /// to do) was self-referential, because a taller card shrank the transcript, + /// which shrank the budget, which... The floor is the 240 the transcript + /// reports before its first real measurement. + var chatSurfaceHeight: CGFloat { + max(240, scrollViewportHeight + composerLayoutHeight) + } + + var pendingInputMaxHeight: CGFloat { + workPendingInputMaxHeight(chatSurfaceHeight: chatSurfaceHeight) + } + /// Open approval / permission gates that "Accept all" can sweep. Question, /// plan-approval, and model-selection kinds are never auto-answered. var acceptAllSweepableInputs: [WorkPendingInputItem] { @@ -758,6 +796,7 @@ struct WorkChatSessionView: View { settingsMutationInFlight: composerSettingMutationInFlight, codexFastModeOverride: pendingCodexFastMode, composerDraftRestore: composerDraftRestore, + draftPersistenceKey: WorkComposerDraftStore.chatKey(sessionId: session.id), compact: compactComposer, // Show Stop while a live turn has current transcript activity. The // broader live hint can lag after `done`; this stricter gate keeps the @@ -1010,6 +1049,7 @@ struct WorkChatSessionView: View { lastBlockingPendingInputId = nil blockingPendingHapticToken = 0 optimisticallyAnsweredInputIds.removeAll() + collapsedPendingInputId = nil assistantLineBudgets.removeAll() composerSettingMutationInFlight = false composerSettingMutationGeneration &+= 1 @@ -1266,6 +1306,40 @@ func workLaneListRenderSignature(_ lanes: [LaneSummary]) -> Int { return hasher.finalize() } +/// Hard ceiling for a pending-input card, given the height available to the +/// whole chat surface. Always leaves room for the composer plus a slice of +/// transcript — a gate that covers the entire screen reads as a modal takeover +/// and hides the Send button. Long content scrolls inside the card instead of +/// growing it. +/// +/// If a card's irreducible chrome still exceeds this on a small phone with the +/// keyboard up, the overflow is absorbed by the transcript, not the composer: +/// the composer inset is `fixedSize(vertical:)` and the transcript scroll view +/// is the flexible sibling, so Send/Decline stay on screen either way. That +/// ordering is the actual guarantee — this number just keeps the common case +/// from getting there. +/// +/// A free function rather than a view property so previews exercise the same +/// arithmetic the app uses; the numbers had drifted into three hand-copied +/// literals otherwise. +func workPendingInputMaxHeight(chatSurfaceHeight: CGFloat) -> CGFloat { + // Roughly the composer card's own height in its resting single-line state. + // Measuring it for real is not an option: `composerLayoutHeight` includes the + // strip we are sizing, so reading it here would be circular. + let composerReserve: CGFloat = 110 + let available = max(0, chatSurfaceHeight - composerReserve) + return max(160, min(available * 0.82, chatSurfaceHeight * 0.62)) +} + +/// Budget for the inline-in-transcript question card, which is bounded by the +/// transcript viewport rather than the whole surface (the composer sits below +/// that viewport either way, so there is nothing to reserve for it). Kept beside +/// `workPendingInputMaxHeight` so the two rules' divergence is deliberate and +/// visible instead of an inline literal drifting on its own. +func workInlinePendingInputMaxHeight(transcriptViewportHeight: CGFloat) -> CGFloat { + max(240, transcriptViewportHeight * 0.62) +} + private struct WorkChatViewportHeightPreferenceKey: PreferenceKey { static var defaultValue: CGFloat = 0 @@ -1275,6 +1349,7 @@ private struct WorkChatViewportHeightPreferenceKey: PreferenceKey { } } + private struct WorkChatViewportWidthPreferenceKey: PreferenceKey { static var defaultValue: CGFloat = 0 @@ -1679,6 +1754,7 @@ private struct WorkChatComposerCard: View { let settingsMutationInFlight: Bool let codexFastModeOverride: Bool? let composerDraftRestore: WorkChatComposerDraftRestore? + let draftPersistenceKey: String let compact: Bool /// True while the assistant is streaming a response. Swaps the Send button /// Desktop parity: red bordered stop control in the composer while a turn is @@ -1708,6 +1784,7 @@ private struct WorkChatComposerCard: View { settingsMutationInFlight: settingsMutationInFlight, codexFastModeOverride: codexFastModeOverride, composerDraftRestore: composerDraftRestore, + draftPersistenceKey: draftPersistenceKey, compact: compact, showInterrupt: showInterrupt, interruptInFlight: interruptInFlight, @@ -1747,6 +1824,9 @@ private struct WorkChatComposerDraftInput: View { let settingsMutationInFlight: Bool let codexFastModeOverride: Bool? let composerDraftRestore: WorkChatComposerDraftRestore? + /// Key this chat's unsent text is persisted under, so leaving and coming back + /// restores it (matching desktop). Empty disables persistence. + let draftPersistenceKey: String let compact: Bool let showInterrupt: Bool let interruptInFlight: Bool @@ -1900,9 +1980,18 @@ private struct WorkChatComposerDraftInput: View { } } .onAppear { configureSuggestionController() } + // Bind before applying a restore: `bind` only seeds an empty field, so a + // failed-send restore that runs first would be preserved either way, but + // binding first keeps the persisted key correct for the very first autosave. + .task(id: draftPersistenceKey) { + draftState.bind(persistenceKey: draftPersistenceKey) + } .task(id: composerDraftRestore?.id) { draftState.applyRestore(composerDraftRestore) } + // The 400ms autosave debounce can't survive a navigation pop; flush here so + // backing out of a chat mid-sentence keeps the sentence. + .onDisappear { draftState.flushDraft() } .onChange(of: chatSummary.provider) { _, _ in configureSuggestionController() } .onChange(of: laneId) { _, _ in configureSuggestionController() } .workChatAttachmentPicker( @@ -2177,14 +2266,79 @@ struct WorkChatComposerDraftRestore: Equatable, Identifiable { } final class WorkChatComposerDraftState: ObservableObject { - @Published var text = "" + @Published var text = "" { + didSet { + guard text != oldValue else { return } + scheduleAutosave() + } + } @Published var isFocused = false private var appliedRestoreId: UUID? + /// Surface this composer's draft is persisted under. Empty means "don't + /// persist" (the key is unresolved), which is the safe default. + private var persistenceKey = "" + private var autosaveTask: Task? var trimmedText: String { text.trimmingCharacters(in: .whitespacesAndNewlines) } + /// Point this composer at a chat's stored draft. The composer view is reused + /// across session switches, so the outgoing chat's text is flushed under its + /// own key before the new one is loaded — otherwise switching chats would + /// either lose a draft or write it into the wrong conversation. + @MainActor + func bind(persistenceKey key: String) { + guard persistenceKey != key else { return } + // A blank previous key means this is the first bind of a freshly mounted + // composer; anything else is the view being reused for a different chat. + let isFirstBind = persistenceKey.isEmpty + flushDraft() + persistenceKey = key + let stored = key.isEmpty ? "" : WorkComposerDraftStore.load(key) + + guard !isFirstBind else { + // First mount: whatever is already in the field wins. A failed send + // restores its text here, and that is fresher than anything on disk. + guard trimmedText.isEmpty, !stored.isEmpty else { return } + text = stored + return + } + + // Session switch: the visible text belongs to the chat we just left, and it + // has already been flushed under that chat's key. It must NOT survive into + // this one — leaving it would show one conversation's draft in another, + // autosave it over the destination's own stored draft on the next + // keystroke, and put the wrong message one tap from being sent. + if text != stored { + text = stored + } + } + + /// Write the draft now, cancelling any pending debounce. Called when the chat + /// is torn down — the case the debounce would otherwise miss. + @MainActor + func flushDraft() { + autosaveTask?.cancel() + autosaveTask = nil + guard !persistenceKey.isEmpty else { return } + WorkComposerDraftStore.save(text, for: persistenceKey) + } + + /// Keystroke debounce: each edit restarts the timer, so a burst of typing + /// costs one write instead of one per character. + private func scheduleAutosave() { + guard !persistenceKey.isEmpty else { return } + autosaveTask?.cancel() + let key = persistenceKey + let value = text + autosaveTask = Task { @MainActor in + try? await Task.sleep(for: workDraftAutosaveDebounce) + guard !Task.isCancelled else { return } + WorkComposerDraftStore.save(value, for: key) + } + } + var hasSendableText: Bool { !trimmedText.isEmpty } @@ -2193,9 +2347,25 @@ final class WorkChatComposerDraftState: ObservableObject { let value = trimmedText isFocused = false text = "" + // Drop the stored copy synchronously rather than letting the 400ms debounce + // get to it. A jetsam or force-quit inside that window would otherwise + // restore an already-sent message into the composer, where it reads as + // unsent and invites sending it twice. The Hub and New Chat composers clear + // on send for the same reason. + clearStoredDraft() return value } + /// Cancels any pending autosave and removes the persisted draft. Not + /// actor-annotated so `consumeSendableText()` — which runs from the send + /// button's synchronous action — can call it directly. + func clearStoredDraft() { + autosaveTask?.cancel() + autosaveTask = nil + guard !persistenceKey.isEmpty else { return } + WorkComposerDraftStore.clear(persistenceKey) + } + func restoreUnsentText(_ value: String) { let currentDraft = trimmedText if currentDraft != value { diff --git a/apps/ios/ADE/Views/Work/WorkDraftPersistence.swift b/apps/ios/ADE/Views/Work/WorkDraftPersistence.swift new file mode 100644 index 000000000..be9f90e3b --- /dev/null +++ b/apps/ios/ADE/Views/Work/WorkDraftPersistence.swift @@ -0,0 +1,277 @@ +import Foundation +import SwiftUI + +/// Storage mechanism shared by the Work draft stores: one versioned JSON +/// dictionary in the App Group defaults, small enough to rewrite whole on each +/// save and bounded (LRU by `updatedAt`) so a long-lived install can't grow it +/// without limit. +/// +/// Deliberately three free functions rather than a generic store type — the two +/// call sites agree on the *mechanism* but not on the *policy* (key, cap, and +/// what counts as a no-op write all genuinely differ), and a shared type would +/// have to model those differences back out again. +enum WorkDefaultsJSONMap { + private static var defaults: UserDefaults { ADESharedContainer.defaults } + + /// The stored map, or an empty one when the key is absent or the blob no + /// longer matches the current shape — restoring a draft must never be able to + /// fail loudly in a view body. + static func load(_ storageKey: String) -> [String: V] { + guard let data = defaults.data(forKey: storageKey), + let decoded = try? JSONDecoder().decode([String: V].self, from: data) + else { return [:] } + return decoded + } + + static func persist(_ map: [String: V], under storageKey: String) { + guard let data = try? JSONEncoder().encode(map) else { return } + defaults.set(data, forKey: storageKey) + } + + /// Trims the map to `maxEntries`, dropping least-recently-updated entries + /// first. Returned rather than mutated in place so callers keep their single + /// "build the map, then persist it" statement order. + static func evictingOldest( + _ map: [String: V], + keeping maxEntries: Int, + updatedAt: (V) -> Double + ) -> [String: V] { + guard map.count > maxEntries else { return map } + let survivors = map + .sorted { updatedAt($0.value) > updatedAt($1.value) } + .prefix(maxEntries) + return Dictionary(uniqueKeysWithValues: survivors.map { ($0.key, $0.value) }) + } +} + +/// Keystroke debounce for every Work draft autosave. Long enough that a burst of +/// typing costs one `UserDefaults` write instead of one per character, short +/// enough that a user who pauses and then kills the app keeps their text. +/// Shared so the surfaces that schedule their own autosave (the question card +/// and the in-session composer, whose payloads aren't a plain `String` binding) +/// can't drift from the modifier below. +let workDraftAutosaveDebounce: Duration = .milliseconds(400) + +/// Unsent composer text, persisted per surface so leaving a chat (or the app) +/// never discards what the user typed — desktop keeps its draft, and mobile +/// silently dropping it was the single most-reported chat regression. +/// One JSON dictionary under a versioned key: small enough to rewrite whole on +/// each save, bounded by `maxEntries` (LRU by `updatedAt`) so a long-lived +/// install can't grow it without limit. +enum WorkComposerDraftStore { + struct Entry: Codable, Equatable { + var text: String + var updatedAt: Double + } + + /// Versioned so a future shape change can migrate rather than mis-decode. + private static let storageKey = "ade.work.composerDrafts.v1" + /// Enough to cover every chat a user realistically juggles; older drafts are + /// evicted oldest-first rather than kept forever. + private static let maxEntries = 60 + /// A composer draft is a prompt, not a document — clamp pathological pastes so + /// one entry can't dominate the shared defaults store. + private static let maxLength = 20_000 + + /// Per-chat key. Blank session ids yield a blank key so callers that render + /// before the session resolves can't write everyone's draft into one bucket. + static func chatKey(sessionId: String) -> String { + let trimmed = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + return "chat:\(trimmed)" + } + + /// The two "new chat" composers are singletons, so they get fixed keys. + static let hubNewChatKey = "hub-new-chat" + static let workNewChatKey = "work-new-chat" + + /// The stored draft, or "" when the key is blank, absent, or undecodable — + /// restoring must never be able to fail loudly in a view body. + static func load(_ key: String) -> String { + guard !key.isEmpty else { return "" } + return loadAll()[key]?.text ?? "" + } + + /// Persists (or clears) the draft for one surface. An emptied composer removes + /// its entry outright: a user who deletes their text must not have it + /// resurrected the next time the screen mounts. + static func save(_ text: String, for key: String) { + guard !key.isEmpty else { return } + var map = loadAll() + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + guard map.removeValue(forKey: key) != nil else { return } + WorkDefaultsJSONMap.persist(map, under: storageKey) + return + } + let clipped = String(text.prefix(maxLength)) + // Autosave runs on a keystroke debounce; skip the UserDefaults write when + // the content is unchanged so idle typing pauses cost nothing. + if map[key]?.text == clipped { return } + map[key] = Entry(text: clipped, updatedAt: Date().timeIntervalSince1970) + map = WorkDefaultsJSONMap.evictingOldest(map, keeping: maxEntries, updatedAt: \.updatedAt) + WorkDefaultsJSONMap.persist(map, under: storageKey) + } + + /// Drops a draft that has been consumed (sent) so it can't reappear. + static func clear(_ key: String) { + guard !key.isEmpty else { return } + var map = loadAll() + guard map.removeValue(forKey: key) != nil else { return } + WorkDefaultsJSONMap.persist(map, under: storageKey) + } + + private static func loadAll() -> [String: Entry] { + // Piggyback the legacy-secret purge on the store that every chat open and + // every composer keystroke touches. Hanging it off the question-draft store + // alone left it unreachable on exactly the devices that need it: one that + // answered a secret question on an intermediate build and never renders + // another question card would keep the plaintext blob forever. + WorkQuestionDraftStore.purgeLegacyStoreIfNeeded() + return WorkDefaultsJSONMap.load(storageKey) + } +} + +/// In-progress answers for a still-open question request, persisted per request +/// id. The card's selections and freeform text were plain `@State`, so backing +/// out of a chat to check something in the transcript — the exact reason a user +/// minimizes the card — silently discarded everything they had picked or typed. +/// Same storage shape as `WorkComposerDraftStore`: one JSON dictionary under a +/// versioned key, bounded and evicted oldest-first. +enum WorkQuestionDraftStore { + struct Snapshot: Codable, Equatable { + var selections: [String: Set] = [:] + var freeform: [String: String] = [:] + var sharedFreeform: String = "" + var page: Int = 0 + + /// Nothing worth persisting — used to decide between a write and a removal. + var isEmpty: Bool { + selections.values.allSatisfy(\.isEmpty) + && freeform.values.allSatisfy { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + && sharedFreeform.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && page == 0 + } + } + + /// The timestamp lives beside the snapshot, not inside it: metadata that + /// changes on every write cannot also be part of the value it timestamps, or + /// "did the answer actually change?" can only be asked by first neutralizing + /// the field. Mirrors `WorkComposerDraftStore.Entry`. + private struct Stored: Codable { + var snapshot: Snapshot + var updatedAt: Double + } + + /// v2 because `updatedAt` moved out of `Snapshot` into the wrapper. Drafts for + /// open gates are ephemeral, so the v1 blob is dropped rather than migrated. + private static let storageKey = "ade.work.questionDrafts.v2" + /// v1 is actively deleted, not just abandoned: an intermediate build of this + /// change persisted answers to `isSecret` questions before that exclusion + /// landed, so a stale v1 blob can hold a plaintext secret. Never shipped in a + /// release, but dev and TestFlight devices ran it. + private static let legacyStorageKey = "ade.work.questionDrafts.v1" + /// Open question gates are short-lived; a small cap is plenty and keeps the + /// blob from accumulating answers to requests that were resolved elsewhere. + private static let maxEntries = 30 + /// Matches `WorkComposerDraftStore.maxLength`. An answer is a reply, not a + /// document — and because autosave decodes, re-encodes, and rewrites the whole + /// map on the main actor, one pasted wall of text would otherwise turn every + /// subsequent keystroke into a visible stall. + private static let maxValueLength = 20_000 + + static func load(_ requestId: String) -> Snapshot? { + guard !requestId.isEmpty else { return nil } + return loadAll()[requestId]?.snapshot + } + + /// Clamps every free-text field (and host-supplied option value) so a single + /// paste cannot inflate the shared defaults store. + private static func bounded(_ snapshot: Snapshot) -> Snapshot { + var bounded = snapshot + bounded.freeform = snapshot.freeform.mapValues { String($0.prefix(maxValueLength)) } + bounded.sharedFreeform = String(snapshot.sharedFreeform.prefix(maxValueLength)) + bounded.selections = snapshot.selections.mapValues { values in + Set(values.map { String($0.prefix(maxValueLength)) }) + } + return bounded + } + + static func save(_ rawSnapshot: Snapshot, for requestId: String) { + guard !requestId.isEmpty else { return } + guard !rawSnapshot.isEmpty else { + clear(requestId) + return + } + let snapshot = bounded(rawSnapshot) + var map = loadAll() + // Autosave runs on a keystroke debounce; skip the write when the answer is + // unchanged so idle typing pauses cost nothing. + if map[requestId]?.snapshot == snapshot { return } + map[requestId] = Stored(snapshot: snapshot, updatedAt: Date().timeIntervalSince1970) + map = WorkDefaultsJSONMap.evictingOldest(map, keeping: maxEntries, updatedAt: \.updatedAt) + WorkDefaultsJSONMap.persist(map, under: storageKey) + } + + static func clear(_ requestId: String) { + guard !requestId.isEmpty else { return } + var map = loadAll() + guard map.removeValue(forKey: requestId) != nil else { return } + WorkDefaultsJSONMap.persist(map, under: storageKey) + } + + private static func loadAll() -> [String: Stored] { + purgeLegacyStoreIfNeeded() + return WorkDefaultsJSONMap.load(storageKey) + } + + /// Called from both draft stores' `loadAll`, so any chat open triggers it — + /// not just one that happens to render a question card. Cheap after the first + /// run: an absent key is an in-memory dictionary miss, and nothing in the app + /// ever writes `legacyStorageKey` again (there is no `UserDefaults.register` + /// anywhere that could resurrect it). + static func purgeLegacyStoreIfNeeded() { + let defaults = ADESharedContainer.defaults + guard defaults.object(forKey: legacyStorageKey) != nil else { return } + defaults.removeObject(forKey: legacyStorageKey) + } +} + +/// The three legs of composer-draft persistence, which only work as a set. +/// +/// - Restore is guarded on empty because a re-appear (or an init-seeded value, +/// or a failed send that put its text back) is fresher than what's on disk; +/// an unguarded restore would clobber text the user can see. +/// - The autosave debounce is what keeps typing off `UserDefaults`, but a +/// cancelled `.task` throws out of its sleep *before* the write, so the +/// in-flight edit is lost on any teardown. +/// - Hence the flush on disappear: a navigation pop is exactly the case the +/// debounce misses, and it is also the most common way a draft is abandoned. +private struct WorkPersistedDraftModifier: ViewModifier { + @Binding var text: String + let key: String + + func body(content: Content) -> some View { + content + .task { + if text.isEmpty { + text = WorkComposerDraftStore.load(key) + } + } + .task(id: text) { + try? await Task.sleep(for: workDraftAutosaveDebounce) + guard !Task.isCancelled else { return } + WorkComposerDraftStore.save(text, for: key) + } + .onDisappear { WorkComposerDraftStore.save(text, for: key) } + } +} + +extension View { + /// Restore-if-empty on appear, debounced autosave while typing, flush on + /// teardown — see `WorkPersistedDraftModifier` for why all three legs are + /// required. Send paths still call `WorkComposerDraftStore.clear(_:)` + /// explicitly: consuming a draft is not the same event as leaving the screen. + func workPersistedDraft(_ text: Binding, key: String) -> some View { + modifier(WorkPersistedDraftModifier(text: text, key: key)) + } +} diff --git a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift index ac730bcbc..d5764a950 100644 --- a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift @@ -1223,6 +1223,51 @@ enum WorkPendingInputItem: Identifiable, Equatable { } } +/// Asking provider for a pending gate, when the payload carries one. Only the +/// question and plan-approval kinds do; approval/permission/model-selection fall +/// back to the session provider at the call site. +func workPendingInputProvider(_ item: WorkPendingInputItem) -> String? { + switch item { + case .question(let model): + let source = model.source?.trimmingCharacters(in: .whitespacesAndNewlines) + return source?.isEmpty == false ? source : nil + case .planApproval(let model): + let source = model.source.trimmingCharacters(in: .whitespacesAndNewlines) + return source.isEmpty ? nil : source + case .approval, .permission, .modelSelection: + return nil + } +} + +/// One-line label for the minimized pending-input pill. Must say what is being +/// asked, not just that something is — a generic "1 request" pill is exactly the +/// kind of thing users learn to ignore. +func workPendingInputCollapsedSummary(_ item: WorkPendingInputItem) -> String { + func firstNonEmpty(_ candidates: [String?]) -> String? { + for candidate in candidates { + let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmed, !trimmed.isEmpty { return trimmed } + } + return nil + } + + switch item { + case .question(let model): + return firstNonEmpty([model.primary.header, model.question, model.title, model.body]) + ?? "Waiting on your answer" + case .planApproval(let model): + return firstNonEmpty([model.title]) ?? "Plan ready for review" + case .approval(let model): + return firstNonEmpty([model.description, model.detail]) ?? "Approval requested" + case .permission(let model): + let tool = model.tool.trimmingCharacters(in: .whitespacesAndNewlines) + if !tool.isEmpty { return "Permission: \(tool)" } + return firstNonEmpty([model.description, model.detail]) ?? "Permission requested" + case .modelSelection(let model): + return model.title + } +} + struct WorkPendingSteerModel: Identifiable, Equatable { let id: String var text: String @@ -1599,6 +1644,16 @@ func derivePendingWorkInputs(from transcript: [WorkChatEnvelope]) -> [WorkPendin return results } +/// Deliberately does NOT match Claude's own `AskUserQuestion` (which normalizes +/// to `askuserquestion`). The host emits a `tool_call` for the tool-use block +/// AND a separate `approval_request` for the gate, and those carry different +/// item ids — the tool-use id versus a fresh `randomUUID()`. Since +/// `derivePendingWorkInputs` dedupes by item id, matching the tool name here +/// yields two cards for one question, the tool_call-derived one being +/// unanswerable (the host has no approval registered under that id, so it +/// discards the response silently). The `tool_call` branch is only a fallback +/// for hosts that emit a bare ask-user call with no wrapping approval; adding a +/// name the real host always wraps turns that fallback into a duplicate. func isAskUserToolName(_ tool: String) -> Bool { let normalized = normalizedWorkToolIdentity(tool) return normalized == "ask_user" || normalized == "askuser" || normalized == "mcp_ade_ask_user" diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index 4f6744a92..a039792a4 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -1444,6 +1444,9 @@ private struct WorkNewChatComposerBar: View { composerFocused = false draft = "" attachments.removeAll() + // Drop the persisted draft synchronously — navigating into the new chat must + // not race the 400ms autosave debounce and leave the just-sent text behind. + WorkComposerDraftStore.clear(WorkComposerDraftStore.workNewChatKey) Task { let started = await onSubmit(restoredDraft, outgoingAttachments) if !started { @@ -1557,6 +1560,7 @@ private struct WorkNewChatComposerBar: View { attachments: $attachments, onDismiss: { composerFocused = true } ) + .workPersistedDraft($draft, key: WorkComposerDraftStore.workNewChatKey) } /// Primary foreground launch button — the compact arrow-in-circle send glyph diff --git a/apps/ios/ADE/Views/Work/WorkPreviews.swift b/apps/ios/ADE/Views/Work/WorkPreviews.swift index 34c9ae1f0..fe3a41153 100644 --- a/apps/ios/ADE/Views/Work/WorkPreviews.swift +++ b/apps/ios/ADE/Views/Work/WorkPreviews.swift @@ -497,6 +497,142 @@ private enum WorkPreviewData { .environmentObject(WorkPreviewData.dictationController) } +/// A deliberately oversized AskUserQuestion payload: four paged questions, long +/// prompts, and eight options each. This is the shape that used to push the +/// composer off the bottom of the screen — the card must stay inside +/// `maxCardHeight` with Send/Decline visible, scrolling the options internally. +private func workPreviewOversizedQuestion() -> WorkPendingQuestionModel { + func options(_ prefix: String) -> [WorkPendingQuestionOption] { + (1...8).map { index in + WorkPendingQuestionOption( + label: "\(prefix) option \(index)", + value: "\(prefix.lowercased())-\(index)", + description: "A per-option description long enough to wrap onto a second line on a phone-width card.", + recommended: index == 2, + preview: index == 3 ? "┌────────────┐\n│ wireframe │\n└────────────┘" : nil, + previewFormat: index == 3 ? "html" : nil + ) + } + } + return WorkPendingQuestionModel( + id: "preview-question-oversized", + questions: [ + WorkPendingQuestion( + questionId: "approach", + question: "Which approach should the refactor take, given that the existing service already owns retry and backoff and we do not want to duplicate that logic in the new call path?", + options: options("Approach"), + allowsFreeform: true, + header: "Approach", + defaultAssumption: "Extend the existing service rather than adding a parallel one.", + impact: "Changes the public surface of the sync layer.", + multiSelect: false + ), + WorkPendingQuestion( + questionId: "scope", + question: "Which surfaces should ship in the first pass?", + options: options("Scope"), + allowsFreeform: true, + header: "Scope", + multiSelect: true + ), + WorkPendingQuestion( + questionId: "rollout", + question: "How should this roll out?", + options: options("Rollout"), + allowsFreeform: false, + header: "Rollout" + ), + WorkPendingQuestion( + questionId: "notes", + question: "Anything else worth capturing before I start?", + options: [], + allowsFreeform: true, + header: "Notes" + ) + ], + title: "Plan round 1", + body: "Four questions before I start on the plan.", + source: "claude" + ) +} + +#Preview("Question card - oversized, phone budget") { + // 720pt ≈ an iPhone chat surface with no keyboard; the card is capped at the + // same fraction `pendingInputMaxHeight` uses so the preview matches the app. + VStack { + Spacer() + WorkStructuredQuestionCard( + question: workPreviewOversizedQuestion(), + busy: false, + onSelectOption: { _, _ in true }, + onSubmitAll: { _, _ in true }, + onDecline: { true }, + fallbackProvider: "claude", + maxCardHeight: workPendingInputMaxHeight(chatSurfaceHeight: 720) + ) + .padding(16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(ADEColor.pageBackground) + .preferredColorScheme(.dark) +} + +#Preview("Question card - oversized, keyboard up") { + // ~340pt of surface left once the keyboard is showing. Send must still be + // on screen; the option list absorbs the loss. + VStack { + Spacer() + WorkStructuredQuestionCard( + question: workPreviewOversizedQuestion(), + busy: false, + onSelectOption: { _, _ in true }, + onSubmitAll: { _, _ in true }, + onDecline: { true }, + fallbackProvider: "claude", + maxCardHeight: workPendingInputMaxHeight(chatSurfaceHeight: 340) + ) + .padding(16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(ADEColor.pageBackground) + .preferredColorScheme(.dark) +} + +#Preview("Question card - short, natural height") { + // Regression guard for the other direction: a two-option question must not + // grow to fill the budget or gain a scroll indicator. + VStack { + Spacer() + WorkStructuredQuestionCard( + question: WorkPendingQuestionModel( + id: "preview-question-short", + questions: [ + WorkPendingQuestion( + questionId: "confirm", + question: "Rebase onto main before opening the PR?", + options: [ + WorkPendingQuestionOption(label: "Rebase", value: "rebase", description: nil, recommended: true), + WorkPendingQuestionOption(label: "Leave it", value: "skip", description: nil) + ], + allowsFreeform: false + ) + ], + source: "claude" + ), + busy: false, + onSelectOption: { _, _ in true }, + onSubmitAll: { _, _ in true }, + onDecline: { true }, + fallbackProvider: "claude", + maxCardHeight: workPendingInputMaxHeight(chatSurfaceHeight: 720) + ) + .padding(16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(ADEColor.pageBackground) + .preferredColorScheme(.dark) +} + #Preview("New chat") { NavigationStack { WorkNewChatScreen( diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 4b022bc5e..42618532e 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -4715,6 +4715,156 @@ final class ADETests: XCTestCase { XCTAssertEqual(service.chatEventHistory(sessionId: "session-1"), [original, tail]) } + /// A host's `eventSequence` restarts at 1 whenever a session is rehydrated, + /// but it keeps appending to the SAME transcript, so one transcript can hold + /// two events numbered 67 hours apart. Identity used to be `sessionId:sequence` + /// and dedupe is first-key-wins over file order, so the newer event was + /// discarded as a duplicate of the older one. On a real 425-event transcript + /// that destroyed 103 events — including the `approval_request` envelopes + /// carrying AskUserQuestion cards, which is why the phone showed no question. + @MainActor + func testReusedTranscriptSequenceKeepsBothEventsFromDifferentEpochs() async throws { + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + let firstEpoch = AgentChatEventEnvelope( + sessionId: "session-1", + timestamp: "2026-03-17T00:01:52.764Z", + event: .command( + command: "ls", + cwd: "/tmp", + output: "", + itemId: "cmd-1", + logicalItemId: nil, + turnId: "turn-1", + exitCode: 0, + durationMs: 3, + status: "completed" + ), + sequence: 67, + provenance: nil + ) + // Same sequence number, four hours later: the host restarted and its + // counter began again at 1. + let secondEpoch = AgentChatEventEnvelope( + sessionId: "session-1", + timestamp: "2026-03-17T04:16:22.165Z", + event: .approvalRequest( + itemId: "gate-1", + logicalItemId: nil, + kind: .toolCall, + description: "Which approach?", + turnId: "turn-2", + detail: nil + ), + sequence: 67, + provenance: nil + ) + + service.replaceChatEventHistory(sessionId: "session-1", events: [firstEpoch, secondEpoch]) + + let history = service.chatEventHistory(sessionId: "session-1") + XCTAssertEqual(history.count, 2, "A reused sequence number must not drop the newer event") + XCTAssertEqual(history, [firstEpoch, secondEpoch]) + XCTAssertNotEqual(firstEpoch.id, secondEpoch.id, "Envelope identity must not collide across sequence epochs") + } + + /// Short text has no content dedupe key (the text key requires >= 24 chars), + /// so it fell back to the sequence-derived id and was dropped by the same + /// collision. The user-visible symptom was a reply rendering as + /// "king Round 1 now" — the preceding 18-character chunk had vanished. + @MainActor + func testShortTextChunkSurvivesReusedTranscriptSequence() async throws { + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + let older = AgentChatEventEnvelope( + sessionId: "session-1", + timestamp: "2026-03-17T00:00:00.000Z", + event: .activity(activity: .thinking, detail: nil, turnId: "turn-1"), + sequence: 94, + provenance: nil + ) + let shortChunk = AgentChatEventEnvelope( + sessionId: "session-1", + timestamp: "2026-03-17T05:00:00.000Z", + event: .text(text: "No problem — re-as", messageId: "msg-9", turnId: "turn-2", itemId: "item-9"), + sequence: 94, + provenance: nil + ) + + service.replaceChatEventHistory(sessionId: "session-1", events: [older, shortChunk]) + + let history = service.chatEventHistory(sessionId: "session-1") + XCTAssertEqual(history.count, 2, "A sub-24-char text chunk must not be swallowed by a reused sequence") + XCTAssertTrue( + history.contains(where: { envelope in + if case .text(let text, _, _, _) = envelope.event { return text == "No problem — re-as" } + return false + }), + "The short text chunk must survive" + ) + } + + /// A genuine redelivery — identical timestamp AND sequence — must still + /// collapse, otherwise widening identity would trade dropped events for + /// duplicated ones. + @MainActor + func testIdenticalRedeliveryStillDedupes() async throws { + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + let event = AgentChatEventEnvelope( + sessionId: "session-1", + timestamp: "2026-03-17T00:00:00.000Z", + event: .approvalRequest( + itemId: "gate-1", + logicalItemId: nil, + kind: .toolCall, + description: "Which approach?", + turnId: "turn-1", + detail: nil + ), + sequence: 12, + provenance: nil + ) + + service.recordChatEventEnvelope(event) + service.mergeChatEventHistory(sessionId: "session-1", events: [event, event]) + + XCTAssertEqual(service.chatEventHistory(sessionId: "session-1"), [event]) + } + + /// Gates carry a session-unique `itemId`, so they now dedupe on that rather + /// than on the sequence-derived id. Re-delivering the same gate under a + /// different sequence must not produce a second card. + @MainActor + func testGateDedupesByItemIdAcrossDifferentSequences() async throws { + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + func gate(sequence: Int, timestamp: String) -> AgentChatEventEnvelope { + AgentChatEventEnvelope( + sessionId: "session-1", + timestamp: timestamp, + event: .approvalRequest( + itemId: "gate-shared", + logicalItemId: nil, + kind: .toolCall, + description: "Which approach?", + turnId: "turn-1", + detail: nil + ), + sequence: sequence, + provenance: nil + ) + } + + service.replaceChatEventHistory( + sessionId: "session-1", + events: [gate(sequence: 5, timestamp: "2026-03-17T00:00:00.000Z"), + gate(sequence: 9, timestamp: "2026-03-17T00:00:01.000Z")] + ) + + XCTAssertEqual( + service.chatEventHistory(sessionId: "session-1").count, + 1, + "One gate itemId must yield one pending-input event regardless of sequence" + ) + } + @MainActor func testDuplicateChatSubscribeSnapshotDoesNotAdvanceRevision() async throws { let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) @@ -10251,7 +10401,11 @@ final class ADETests: XCTestCase { await recorder.waitForStartedCount(2) let firstStartedIds = await recorder.startedIds() let firstMaxActiveCount = await recorder.maxActiveCount() - XCTAssertEqual(firstStartedIds, ["lane-a", "lane-b"]) + // Which of the two concurrent deletes wins the race to record itself first + // is not part of the contract — only that both are in flight and the runner + // holds the concurrency limit at 2. Asserting the array order made this test + // fail intermittently in CI. + XCTAssertEqual(Set(firstStartedIds), ["lane-a", "lane-b"]) XCTAssertEqual(firstMaxActiveCount, 2) await recorder.release() @@ -16919,7 +17073,13 @@ final class ADETests: XCTestCase { searchText: "" ) - XCTAssertEqual(filtered.map(\.id), ["chat-parent", "shell-child", "legacy-cli"]) + // Retention is the contract this test names, not order. The fixtures take + // their `startedAt` from wall-clock at construction, so whether the three + // share a timestamp — and therefore whether the sort falls through to the + // title tiebreak — depends on which second they were built in. Asserting the + // sorted array made this fail intermittently. + XCTAssertEqual(Set(filtered.map(\.id)), ["chat-parent", "shell-child", "legacy-cli"]) + XCTAssertEqual(filtered.first?.id, "chat-parent", "The parent chat always leads its owned rows") } func testWorkFilteredSessionsPrioritizesWaitingBeforeActiveAndEnded() { @@ -19815,6 +19975,128 @@ final class ADETests: XCTestCase { XCTAssertTrue(cards.isEmpty) } + /// The chat composer view is reused across session switches. Before this was + /// guarded, switching chats with text still in the box left that text visible + /// in the destination chat and autosaved it over the destination's own stored + /// draft on the next keystroke — one tap from sending the wrong message into + /// the wrong conversation. + @MainActor + func testComposerDraftDoesNotLeakAcrossSessionSwitch() { + let keyA = WorkComposerDraftStore.chatKey(sessionId: "sess-A-\(UUID().uuidString)") + let keyB = WorkComposerDraftStore.chatKey(sessionId: "sess-B-\(UUID().uuidString)") + defer { + WorkComposerDraftStore.clear(keyA) + WorkComposerDraftStore.clear(keyB) + } + + let state = WorkChatComposerDraftState() + state.bind(persistenceKey: keyA) + state.text = "half-written message for chat A" + + // Switch to a chat that has no draft of its own. + state.bind(persistenceKey: keyB) + XCTAssertEqual(state.text, "", "Chat A's text must not survive into chat B") + XCTAssertEqual( + WorkComposerDraftStore.load(keyA), + "half-written message for chat A", + "Switching away must flush the outgoing draft under its own key, not lose it" + ) + + // And switching back restores A's draft rather than B's empty box. + state.bind(persistenceKey: keyA) + XCTAssertEqual(state.text, "half-written message for chat A") + } + + /// A question marked `isSecret` renders its freeform in a `SecureField`, and + /// the resolved card refuses to echo the answer back. When such a question + /// also carries options, the CHOSEN OPTION is the secret answer — persisting + /// it to the App Group defaults (readable by the widget extension) leaks + /// exactly what the SecureField exists to protect. + func testSecretQuestionAnswersAreNeverPersisted() { + let requestId = "secret-req-\(UUID().uuidString)" + defer { WorkQuestionDraftStore.clear(requestId) } + + WorkQuestionDraftStore.save( + WorkQuestionDraftStore.Snapshot( + selections: ["public-q": ["keep-me"]], + freeform: ["public-q": "visible answer"], + sharedFreeform: "", + page: 0 + ), + for: requestId + ) + + let stored = WorkQuestionDraftStore.load(requestId) + XCTAssertEqual(stored?.selections["public-q"], ["keep-me"], "Non-secret answers must round-trip") + XCTAssertEqual(stored?.freeform["public-q"], "visible answer") + + // A pasted wall of text is clamped, so one paste can't inflate the shared + // defaults store or stall every later keystroke's autosave rewrite. + let huge = String(repeating: "x", count: 60_000) + WorkQuestionDraftStore.save( + WorkQuestionDraftStore.Snapshot(freeform: ["public-q": huge], sharedFreeform: huge), + for: requestId + ) + let clamped = WorkQuestionDraftStore.load(requestId) + XCTAssertEqual(clamped?.freeform["public-q"]?.count, 20_000, "Freeform answers must be clamped") + XCTAssertEqual(clamped?.sharedFreeform.count, 20_000, "Shared freeform must be clamped") + + // An all-empty snapshot removes the entry outright, so a card whose only + // answers were secret leaves nothing behind at all. + WorkQuestionDraftStore.save(WorkQuestionDraftStore.Snapshot(), for: requestId) + XCTAssertNil( + WorkQuestionDraftStore.load(requestId), + "A snapshot with every secret answer filtered out must remove the entry, not store an empty husk" + ) + } + + /// The host emits BOTH a `tool_call` for Claude's `AskUserQuestion` tool-use + /// block AND a separate `approval_request` for the gate, under different item + /// ids (the SDK tool-use id vs a fresh randomUUID). `derivePendingWorkInputs` + /// dedupes by item id, so if `isAskUserToolName` matched the tool name the + /// user would get two cards for one question — and the tool_call-derived one + /// is unanswerable, because the host has no approval registered under that id + /// and discards the response silently. + func testClaudeAskUserQuestionYieldsExactlyOnePendingInput() { + let argsText = """ + {"questions":[{"id":"approach","question":"Which approach?","options":[{"label":"A","value":"a"}]}]} + """ + let detailText = """ + {"tool":"AskUserQuestion","source":"claude","request":{"kind":"structured_question","questions":[{"id":"approach","question":"Which approach?","options":[{"label":"A","value":"a"}]}]}} + """ + let transcript: [WorkChatEnvelope] = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-20T00:00:01.000Z", + sequence: 1, + event: .toolCall(tool: "AskUserQuestion", argsText: argsText, itemId: "toolu_abc", parentItemId: nil, turnId: "turn-1") + ), + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-20T00:00:02.000Z", + sequence: 2, + event: .approvalRequest( + description: "Which approach?", + detail: detailText, + itemId: "11111111-2222-3333-4444-555555555555", + turnId: "turn-1" + ) + ), + ] + + let inputs = derivePendingWorkInputs(from: transcript) + XCTAssertEqual(inputs.count, 1, "One AskUserQuestion must not produce two pending-input cards") + guard case .question(let model) = inputs.first else { + return XCTFail("Expected the approval_request to surface as the pending question.") + } + XCTAssertEqual( + model.id, + "11111111-2222-3333-4444-555555555555", + "The card must come from the approval_request, whose itemId the host can actually resolve" + ) + XCTAssertEqual(model.questionId, "approach") + } + func testBuildWorkTimelineShowsNormalToolCallsOnMobile() { let transcript: [WorkChatEnvelope] = [ WorkChatEnvelope( diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8c7b4d8c7..dcf04914d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1147,7 +1147,7 @@ The sync subsystem is **owned by the ADE runtime** (`apps/ade-cli/src/services/s batch through the existing chunked envelope transport. - `hello_ok` can include the host's mobile project catalog and project-action feature flag. The iOS app shows a native project home until an active project is selected, can browse/open/create/clone projects on the paired machine when project actions are available, then drives `project_switch_request` / `project_switch_result`; the port stays stable across switches. - Bidirectional sync continues; inbound processing (envelope parse, gunzip, chunk reassembly, changeset decode + apply) runs off the main actor. On disconnect: a fast exponential-backoff burst, then an indefinite ~30 s slow-heartbeat retry — the phone never permanently gives up. `reconnectIfPossible` is guarded against overlapping runs. -- Chat streaming resumes by sequence: each `chat_event` carries a host-assigned per-session `seq` backed by a replay buffer; `chat_subscribe` passes `sinceSeq` so reconnects replay only the missed events. A per-session hydration barrier holds the live broadcaster and transcript pump until the snapshot ack, then resumes from the pre-capture logical byte offset so concurrent appends cannot overtake or fall between snapshot and stream. The subscribe ack also carries `turnActive` (live turn state from the agent chat service) so a phone subscribing mid-turn renders streaming/stop affordances immediately even when the byte-capped snapshot tail dropped the turn's start event. `chat.getTranscript` pages older history via an opaque cursor; full runtimes advertise append-stable `cursorKind: "byte"` offsets and the minimal headless fallback advertises `cursorKind: "index"`. When the host advertises the `crossProjectChat` feature flag, `chat_subscribe` can also name a foreign (non-active) project via `projectId`/`projectRootPath`; the host streams that project's transcript read-only straight off its `.ade` transcript files, so the all-projects Hub can open any project's chat without a project switch or runtime boot. Personal subscriptions instead send `chatScope: "personal"`; the host resolves the durable transcript and active-turn state through `PersonalChatScope` with no project id. +- Chat streaming resumes by sequence: each `chat_event` carries a host-assigned per-session `seq` backed by a replay buffer; `chat_subscribe` passes `sinceSeq` so reconnects replay only the missed events. `seq` is a resume cursor, not an event identity — it is unique only within one runtime lifetime, while the transcript it numbers is durable and keeps being appended across restarts, so a client that keys identity or dedupe on `sessionId + seq` alone will silently drop real events as phantom replays (see [features/chat/composer-and-ui.md](./features/chat/composer-and-ui.md#fragile-and-tricky-wiring)). Rehydrated sessions seed their counter from the transcript's maximum so numbering stays strictly increasing, and clients pair `seq` with the event timestamp (or, for blocking gates, the host-assigned `itemId`). A per-session hydration barrier holds the live broadcaster and transcript pump until the snapshot ack, then resumes from the pre-capture logical byte offset so concurrent appends cannot overtake or fall between snapshot and stream. The subscribe ack also carries `turnActive` (live turn state from the agent chat service) so a phone subscribing mid-turn renders streaming/stop affordances immediately even when the byte-capped snapshot tail dropped the turn's start event. `chat.getTranscript` pages older history via an opaque cursor; full runtimes advertise append-stable `cursorKind: "byte"` offsets and the minimal headless fallback advertises `cursorKind: "index"`. When the host advertises the `crossProjectChat` feature flag, `chat_subscribe` can also name a foreign (non-active) project via `projectId`/`projectRootPath`; the host streams that project's transcript read-only straight off its `.ade` transcript files, so the all-projects Hub can open any project's chat without a project switch or runtime boot. Personal subscriptions instead send `chatScope: "personal"`; the host resolves the durable transcript and active-turn state through `PersonalChatScope` with no project id. - User-message delivery is durable across that stream: accepted messages retain processed/unprocessed state, and unprocessed rows expose Run next / Edit / Dismiss through idempotent `chat.resolveUnprocessedMessage`. Turn stalls and diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 216b9a21c..e4889de5d 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -46,6 +46,8 @@ subagents, computer use). The pane derives all visible state from the | `ChatPrPane.tsx` | Left floating PR pane for Work chat. Shows cached lane PR details immediately, then refreshes the linked PR row with the same targeted refresh path so pane toggles surface current merged/closed/check state without a broad PR sync. An unmapped lane PR (projection-derived, `pr.unmapped`) skips the refresh and checks/reviews enrichment — there is no DB row behind its synthetic `gh:` id. | | `ChatProposedPlanCard.tsx` | Composer-level plan approval card shown while input is locked. Renders the plan description or question text as rich markdown (`ChatMarkdown`) inside a scrollable container (capped at `min(34vh, 360px)`). Transcript plan events render through `AgentChatMessageList` / `CodexPlanCard`. | | `apps/ios/ADE/Views/Work/WorkPlanComposerViews.swift` | iOS composer-level plan approval strip. The live `plan_approval` gate renders as a compact full-width strip above the prompt box, opens a large markdown sheet for review, and sends Approve/Reject decisions through `chat.approve` with optional rejection feedback as `responseText`. It is one body of the consolidated pending-input strip (see [Cross-surface parity](#cross-surface-parity)) — the strip in `WorkChatSessionView+Timeline.swift` renders the current request (plan / approval / permission / question / model-selection), a "Request 1 of N" header, and an "Accept all" sweep when more than one gate is queued. | +| `apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift` | iOS prompt box, icon-only staged-steer strip, and `WorkStructuredQuestionCard` — the mobile question card. The card pins only a provider row (plus the question tab strip when paged) above its internal scroll region and the freeform field plus Send/Decline footer below it; the question text, request body, meta rows, and option list all scroll. `WorkPendingInputHeightBoundedCard` in the same file is the generic wrapper that caps the non-question gates. Both budget against `maxCardHeight` (see [Cross-surface parity](#cross-surface-parity)) and enable `.scrollDismissesKeyboard(.interactively)` so a long typed answer can never trap the user away from the footer. | +| `apps/ios/ADE/Views/Work/WorkDraftPersistence.swift` | iOS draft persistence. `WorkComposerDraftStore` keeps unsent composer text per chat (`chat:`) plus fixed keys for the Hub and New Chat composers; `WorkQuestionDraftStore` keeps in-progress question selections/freeform per request id. Both are versioned JSON dictionaries in App Group `UserDefaults` via `WorkDefaultsJSONMap`, LRU-evicted by `updatedAt` (60 composer entries, 30 question entries), with a 400 ms `workDraftAutosaveDebounce`. The `workPersistedDraft(_:key:)` view modifier packages the three legs a plain `String` binding needs: restore-if-empty on appear, debounced autosave, flush on disappear. | | `ChatModelSelectionPendingCard.tsx` | Full agent-briefing model picker for orchestration pending inputs. Shows description, touched files, run-after dependencies, provider/model controls, and submitting/cancel states without a recommended default model. | | `codex/CodexPlanCard.tsx` | Codex plan card rendered inline in the transcript for `plan` events. Shows plan state (Planning / Plan ready), step progress with status glyphs, and streaming plan text as rich markdown via `ChatMarkdown`. Completed plans with no discrete steps render the full markdown body inline; plans with steps offer a toggle to expand the raw markdown details (labelled "details" when complete, "live" while streaming). Handles missing `steps` arrays gracefully. | | `codex/CodexGoalCard.tsx`, `codex/CodexGoalBanner.tsx` | Codex goal surfaces. The card is the active desktop surface and routes edits, status changes, and clears through typed ADE APIs (`ade.agentChat.codex.*`) rather than prompt text. It shows objective, status, token count, and elapsed time, while hiding provider budgets because ADE keeps goals unlimited. The banner remains available for compact surfaces that need a horizontal goal strip. | @@ -872,6 +874,66 @@ surfaces an "Awaiting you" badge on the Lanes row and the Work grid tile (derived from exact pending-input counts, not idle CLI attention heuristics), and iOS fires a light haptic when a new blocking gate arrives. +**Height budget (iOS).** The strip is capped so a gate can never claim the +whole page. `workPendingInputMaxHeight(chatSurfaceHeight:)` (in +`WorkChatSessionView.swift`) returns +`max(160, min(available * 0.82, chatSurfaceHeight * 0.62))` where `available` +subtracts a fixed 110pt composer reserve. The input is `chatSurfaceHeight = +max(240, scrollViewportHeight + composerLayoutHeight)`, **not** the transcript +viewport: the transcript and the composer inset split the same surface, so +their sum is invariant to how the two divide it, while the viewport alone +shrinks as the card grows — feeding that back in was a runaway loop where the +card ate the screen. The composer reserve is a constant for the same reason; +`composerLayoutHeight` already includes the strip being sized, so measuring it +would be circular. Inline-in-transcript question cards use the separate +`workInlinePendingInputMaxHeight(transcriptViewportHeight:)` rule +(`max(240, viewport * 0.62)`) because the composer sits below that viewport +either way and there is nothing to reserve for. + +The card's own arithmetic subtracts its measured top/bottom chrome plus named +`cardPadding` / `cardStackSpacing` constants from that budget and floors the +scroll region at 64pt — roughly one option row. On a small phone with the +keyboard up the irreducible chrome can still exceed the budget; the overflow is +absorbed by the transcript, not the composer, because the composer inset is +`fixedSize(vertical:)` and the transcript scroll view is the flexible sibling. +That view ordering is the actual guarantee that Send/Decline stay on screen; +the number only keeps the common case from getting there. + +**Minimize (iOS).** The strip header carries a chevron that collapses the card +to a one-line pill showing the provider mark, a content-derived summary +(`workPendingInputCollapsedSummary` — the question header, plan title, or +`Permission: `, never a generic "1 request"), the queued count, and an +expand chevron. The gate stays open and the composer stays locked; only the +card is swapped out, so the user can scroll the conversation for the context +the question needs. State is a `collapsedPendingInputId`, with the boolean +derived from it — a minimize applies to the gate the user chose to defer, so it +must expire the moment a different gate becomes primary, and deriving makes +that impossible to get wrong. A keyboard `Done` toolbar item (gated on the +freeform field actually holding focus, because a toolbar declared +unconditionally would surface over the main composer's keyboard and silently do +nothing), a footer dismiss button, and interactive scroll-to-dismiss are the +three ways back out of the keyboard. + +**Draft persistence (iOS).** Mobile keeps unsent text the way desktop does. +`WorkComposerDraftStore` persists each chat's composer draft under +`chat:`, plus fixed keys for the Hub inline composer and the Work +New Chat composer; `WorkQuestionDraftStore` persists a still-open question's +selections, per-question freeform, shared freeform, and page index under the +request id, so backing out of the chat to check the transcript — the exact +reason a user minimizes the card — no longer discards what they picked. Both +autosave on a 400 ms debounce and flush on disappear, because a cancelled +`.task` throws out of its sleep before the write and a navigation pop is +precisely the case the debounce misses. Send clears the stored draft +**synchronously** rather than waiting out the debounce: a jetsam inside that +window would otherwise restore an already-sent message into the composer, where +it reads as unsent and invites sending it twice. Two deliberate exclusions: +answers to `isSecret` questions are never written (the backing store is App +Group `UserDefaults`, shared with the widget extension, so that would put a +credential on disk in plaintext), and unpair does not clear the stores (its only +production trigger fires automatically on an attributed auth failure, and the +stores are keyed by session, not by host, so clearing would destroy unsent text +for every other paired machine). + ### Per-runtime question richness (ceilings) Each runtime populates as much of the schema as its SDK exposes; the card @@ -1020,10 +1082,48 @@ These modules are pure and unit-testable: `data-composer-chip-text` and in the controlled draft; reconciliation must never replace sent text with a compact label. Metadata failures are expected and must degrade to the deterministic provider label or complete URL. +- **Chat event identity is never the sequence number alone.** + `eventSequence` is a runtime counter, but the transcript it numbers is + durable and appended across desktop restarts, so a rehydrated session + that restarts at 0 mints sequence numbers the file already contains — + one transcript can hold two events numbered 67, hours apart. Any + consumer keying identity on `sessionId + sequence` then mistakes the + newer event for a replay of the older one and drops it. Both halves of + the fix are load-bearing. Host side, + `readTranscriptHydrationState` (`agentChatService.ts`) seeds + `managed.eventSequence` from the transcript's max sequence in the same + pass that recovers todo items — one pass, because the transcript is + not cached — so sequences stay strictly increasing for the life of the + file. Client side, iOS's `AgentChatEventEnvelope.id` includes the + timestamp (`sessionId:timestamp:sequence`), so a genuine redelivery + (same timestamp *and* sequence) still collapses while cross-epoch + collisions do not. On a real 425-event transcript the old key destroyed + 103 events, including two `approval_request` envelopes carrying whole + AskUserQuestion cards and 31 short text chunks (short text has no + content dedupe key of its own — that requires >= 24 characters — so it + fell through to the sequence-derived id). Blocking gates additionally + get itemId-based content dedupe keys in + `SyncService.chatEventContentDedupeKey` (`approval_request`, + `structured_question`, `pending_input_resolved`): a dropped gate is a + question the user never sees and can never answer, so it must not + depend on sequence uniqueness at all. +- **`isAskUserToolName` deliberately does not match `AskUserQuestion`.** + For Claude's own ask-user tool the host emits *both* a `tool_call` (keyed + by the SDK tool-use id) and a separate `approval_request` (keyed by a + fresh `randomUUID`). iOS's `derivePendingWorkInputs` dedupes by item id, + so adding `askuserquestion` to that name list produces two cards for one + question — and the `tool_call`-derived one is unanswerable, because the + host has no approval registered under that id and discards the response + silently. The `tool_call` branch exists only as a fallback for hosts that + emit a bare ask-user call with no wrapping approval. - **Question drafts persistence.** Question answer state (selected - options + freeform drafts) is local to `InlineQuestionRequestCard`. If - the user navigates away and back, drafts reset. This is intentional to - avoid stale answers leaking across sessions. The card's one-time focus + options + freeform drafts) is local to `InlineQuestionRequestCard` on + desktop. If the user navigates away and back, drafts reset. This is + intentional to avoid stale answers leaking across sessions. iOS makes + the opposite call for the same surface — see + [Cross-surface parity](#cross-surface-parity) — because minimizing the + card to read the transcript is a normal step in answering it there, not + a session change. The card's one-time focus and entrance animation are guarded by module-level sets (`focusedQuestionCardKeys` / `enteredQuestionCardKeys`) so the virtualized list re-mounting the row mid-scroll doesn't re-steal focus diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index f2e158ade..fce745417 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -315,8 +315,18 @@ apps/ios/ │ │ │ # WorkChatAttachmentTray, │ │ │ # WorkChatComposerAndInputViews (compacted │ │ │ # icon-only staged-steer strip + the -│ │ │ # structured-question card: chip tab strip -│ │ │ # over a viewport-capped internal scroll), +│ │ │ # structured-question card: pinned provider +│ │ │ # row / tab strip above and freeform + +│ │ │ # Send/Decline footer below a +│ │ │ # budget-capped internal scroll, plus +│ │ │ # WorkPendingInputHeightBoundedCard for the +│ │ │ # non-question gates), +│ │ │ # WorkDraftPersistence (WorkComposerDraftStore +│ │ │ # per-chat/Hub/New-Chat composer text + +│ │ │ # WorkQuestionDraftStore per-request +│ │ │ # selections, over App Group UserDefaults; +│ │ │ # debounced autosave + workPersistedDraft +│ │ │ # view modifier), │ │ │ # WorkArtifactTerminalViews (in-thread │ │ │ # artifact card with a friendly │ │ │ # "Preview isn't available" fallback when @@ -1844,6 +1854,29 @@ different machine's cached limits. `chat_event` sends as delivered only when the socket accepts them; on backpressure it leaves the transcript offset unchanged and retries in order. Events without `seq` (older hosts) bypass the watermark entirely. +- **`seq` is a resume cursor, not an event identity.** The counter is + per-runtime, but the transcript it numbers is durable and keeps being + appended across desktop restarts, so the same transcript can contain two + events numbered 67 hours apart. The phone's dedupe is first-key-wins over + file order, so keying `AgentChatEventEnvelope.id` on `sessionId:sequence` + made the newer event look like a replay of the older one and silently + discarded it — on a real 425-event transcript that destroyed 103 events, + including the `approval_request` envelopes carrying AskUserQuestion cards + (the phone showed no question at all) and 31 short text chunks (short text + has no content dedupe key, which needs >= 24 characters, so it fell through + to the sequence-derived id and a reply rendered mid-word). The envelope id + now includes the timestamp, so a genuine redelivery — identical timestamp + *and* sequence — still collapses while cross-epoch collisions are broken + apart. Blocking gates go further and dedupe on their host-assigned + session-unique `itemId` in `SyncService.chatEventContentDedupeKey` + (`approval_request`, `structured_question`, `pending_input_resolved`), + because a dropped gate is not a cosmetic loss — it is a card the user never + sees and can never answer. Host-side, `readTranscriptHydrationState` + (`agentChatService.ts`) now seeds a rehydrated session's `eventSequence` + from the transcript's maximum instead of restarting at 0, so sequences stay + strictly increasing for the life of the file. Any new phone-side identity or + cache key must follow the same rule: sequence numbers are unique within a + runtime lifetime only. - **Transcript history pages through an opaque cursor.** `chat.getTranscript` responses carry `nextCursor`; the phone's `fetchChatTranscriptPage` requests strictly-older history with it. @@ -2053,7 +2086,46 @@ different machine's cached limits. only for approval/permission gates — never question, plan-approval, or model-selection — and flips `acceptForSession` on the current gate then accepts each remaining sweepable gate sequentially (stale itemIds no-op - on the host, so re-sends after auto-resolution are safe). + on the host, so re-sends after auto-resolution are safe). The strip can + also be minimized to a one-line pill (`collapsedPendingInputId`) that names + what is being asked; the gate stays open and the composer stays locked, and + the collapse expires on its own as soon as a different gate becomes primary + because the boolean is derived from the stored id rather than synchronized + alongside it. +- **Size a pending-input card from the chat surface, never from the + transcript viewport.** The transcript and the composer inset split the same + surface, so the viewport shrinks exactly as the card grows — budgeting off + it is self-referential and the card walks itself up to full screen. The + budget input is `chatSurfaceHeight = max(240, scrollViewportHeight + + composerLayoutHeight)`, whose sum does not move when the card resizes, and + `workPendingInputMaxHeight(chatSurfaceHeight:)` derives the cap from it. + The composer reserve inside that helper is a constant for the same reason: + `composerLayoutHeight` already includes the strip being sized. The card's + own chrome (provider row, tab strip, freeform field, Send/Decline footer) + is measured and subtracted so the scroll region absorbs overflow; when the + irreducible chrome still exceeds the budget on a small phone with the + keyboard up, the overflow lands on the transcript rather than the footer, + because the composer inset is `fixedSize(vertical:)` and the transcript is + the flexible sibling. That view ordering — not the number — is what + guarantees Send stays reachable. +- **Mobile keeps unsent text; it is a store, not view state.** + `WorkDraftPersistence.swift` holds `WorkComposerDraftStore` (composer text + per chat plus fixed Hub / New Chat keys) and `WorkQuestionDraftStore` + (in-progress question selections, freeform, and page per request id), both + versioned JSON dictionaries in App Group `UserDefaults`, LRU-capped, saved + on a 400 ms debounce and flushed on disappear because a cancelled `.task` + throws out of its sleep before the write. Three rules are load-bearing: + restore only into an empty field (a failed send that put its text back, or + a card already mid-edit, is fresher than disk); clear synchronously on send + rather than letting the debounce get there, or a jetsam inside that window + resurrects an already-sent message and invites a duplicate; and never write + an `isSecret` answer, because that defaults suite is shared with the widget + extension and would hold a credential in plaintext. Clearing a host profile + deliberately does **not** touch these stores — `forgetHost()` has no UI + caller and fires automatically from `handleReconnectFailure` on an + attributed auth failure, and the stores are keyed by session id rather than + by host, so wiping them would destroy unsent text for every other paired + machine plus the machine-independent Hub and New Chat drafts. - **Optimistic steers reconcile on the active-to-idle turn boundary.** A message the phone sends mid-turn is echoed as an optimistic "Sends after turn" row (`WorkQueuedSteerRow`) using the host-assigned steer id