M7 #9+#10: Memory editor keyboard + Saved pill above keyboard

Pass-1 complaints:
- Typing near the bottom of MEMORY.md → keyboard covered the cursor,
  user lost track of where they were editing (M7 #9).
- Tapping Save → "Saved" pill was never visible because it sat at
  .bottom with a fixed 16pt padding, behind the still-raised keyboard
  (M7 #10).

Fixes:
- `.scrollDismissesKeyboard(.interactively)` on the TextEditor so
  scrolling the editor drags the keyboard down smoothly.
- Move the error banner + Saved pill into `.safeAreaInset(edge: .bottom)`
  so SwiftUI draws them above whatever is presenting the keyboard.
  The pill is now a full-width material strip (easier to hit/notice)
  instead of a floating capsule.
- Saved pill holds for 2.5s (up from 1.5s — the old timer was too
  tight to read mid-thought).
- Any in-flight hide task is cancelled when a new save lands, so
  rapid-fire saves don't produce stacked fade timers.

No Mac equivalent needed — Mac memory editor is a separate
MemoryView with different layout and a non-mobile keyboard concern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-24 13:24:23 +02:00
parent c802e1189f
commit f2f6c4e50b
+69 -38
View File
@@ -1,29 +1,66 @@
import SwiftUI import SwiftUI
import ScarfCore import ScarfCore
/// Editor for a single memory file (MEMORY.md or USER.md). Owns an /// Editor for a single memory file (MEMORY.md / USER.md / SOUL.md).
/// `IOSMemoryViewModel` instance, renders its `text` in a TextEditor, /// Owns an `IOSMemoryViewModel` instance, renders its `text` in a
/// and exposes Save + Revert toolbar buttons. /// TextEditor, and exposes Save + Revert toolbar buttons.
///
/// Keyboard layout (pass-1 M7 #9 + #10):
/// - TextEditor uses `.scrollDismissesKeyboard(.interactively)` so
/// the keyboard tracks the user's drag, keeping the cursor visible
/// when editing near the bottom.
/// - The error banner + Saved pill live in `.safeAreaInset(edge: .bottom)`
/// so they're drawn ABOVE the keyboard, not behind it. The Saved
/// pill now holds for 2.5s (up from 1.5s) and any in-flight hide
/// task is cancelled when a new save lands so rapid saves stack
/// predictably.
struct MemoryEditorView: View { struct MemoryEditorView: View {
@State private var vm: IOSMemoryViewModel @State private var vm: IOSMemoryViewModel
@State private var showSavedConfirmation = false @State private var showSavedConfirmation = false
@State private var savedHideTask: Task<Void, Never>?
init(kind: IOSMemoryViewModel.Kind, context: ServerContext) { init(kind: IOSMemoryViewModel.Kind, context: ServerContext) {
_vm = State(initialValue: IOSMemoryViewModel(kind: kind, context: context)) _vm = State(initialValue: IOSMemoryViewModel(kind: kind, context: context))
} }
var body: some View { var body: some View {
VStack(spacing: 0) { Group {
if vm.isLoading { if vm.isLoading {
VStack {
Spacer() Spacer()
ProgressView("Loading \(vm.kind.displayName)") ProgressView("Loading \(vm.kind.displayName)")
Spacer() Spacer()
}
} else { } else {
TextEditor(text: $vm.text) TextEditor(text: $vm.text)
.font(.system(.body, design: .monospaced)) .font(.system(.body, design: .monospaced))
.autocorrectionDisabled() .autocorrectionDisabled()
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.scrollDismissesKeyboard(.interactively)
.padding(.horizontal, 8) .padding(.horizontal, 8)
}
}
.navigationTitle(vm.kind.displayName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Save") {
Task { await performSave() }
}
.disabled(!vm.hasUnsavedChanges || vm.isSaving)
}
ToolbarItem(placement: .topBarLeading) {
if vm.hasUnsavedChanges {
Button("Revert") { vm.revert() }
}
}
}
// Pin feedback + error strips to the bottom safe area so they
// draw above the keyboard. Previously they floated inside the
// VStack and the keyboard covered both the save pill and the
// cursor the user was typing into.
.safeAreaInset(edge: .bottom) {
VStack(spacing: 0) {
if let err = vm.lastError { if let err = vm.lastError {
HStack(spacing: 6) { HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill") Image(systemName: "exclamationmark.triangle.fill")
@@ -34,47 +71,41 @@ struct MemoryEditorView: View {
Spacer() Spacer()
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 8)
.background(.regularMaterial) .frame(maxWidth: .infinity, alignment: .leading)
.background(.orange.opacity(0.12))
}
if showSavedConfirmation {
Label("Saved", systemImage: "checkmark.circle.fill")
.font(.callout)
.foregroundStyle(.green)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.frame(maxWidth: .infinity)
.background(.thinMaterial)
.transition(.move(edge: .bottom).combined(with: .opacity))
} }
} }
} }
.navigationTitle(vm.kind.displayName) .animation(.easeInOut(duration: 0.2), value: showSavedConfirmation)
.navigationBarTitleDisplayMode(.inline) .animation(.easeInOut(duration: 0.2), value: vm.lastError)
.toolbar { .task { await vm.load() }
ToolbarItem(placement: .topBarTrailing) { .onDisappear { savedHideTask?.cancel() }
Button("Save") { }
Task {
private func performSave() async {
let ok = await vm.save() let ok = await vm.save()
if ok { guard ok else { return }
// Cancel any in-flight hide task so rapid saves don't drop
// the pill mid-fade (the previous implementation stacked
// overlapping sleep tasks).
savedHideTask?.cancel()
showSavedConfirmation = true showSavedConfirmation = true
Task { savedHideTask = Task {
try? await Task.sleep(nanoseconds: 1_500_000_000) try? await Task.sleep(nanoseconds: 2_500_000_000)
if !Task.isCancelled {
showSavedConfirmation = false showSavedConfirmation = false
} }
} }
} }
} }
.disabled(!vm.hasUnsavedChanges || vm.isSaving)
}
ToolbarItem(placement: .topBarLeading) {
if vm.hasUnsavedChanges {
Button("Revert") { vm.revert() }
}
}
}
.overlay(alignment: .bottom) {
if showSavedConfirmation {
Label("Saved", systemImage: "checkmark.circle.fill")
.font(.callout)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(.thinMaterial, in: Capsule())
.padding(.bottom, 16)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.easeInOut(duration: 0.2), value: showSavedConfirmation)
.task { await vm.load() }
}
}