From 7bfeeddb38cb4e14b1ec897601ab90550ec2109f Mon Sep 17 00:00:00 2001 From: researchoor <72546100+researchoor@users.noreply.github.com> Date: Sun, 22 Mar 2026 12:49:17 -0700 Subject: [PATCH] feat: last-message preview, idle indicator, and rename UX polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cache last assistant message per session (UserDefaults) and show as one-line preview on the session list — same pattern as iMessage - Show muted idle dot on completed session rows instead of invisible spacer - Pre-fill rename dialog with current session title for easier editing - Guard Live Activity background updates for already-completed sessions Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/ios/Litter.xcodeproj/project.pbxproj | 10 ++++++ .../Models/LocalSessionLastMessage.swift | 35 +++++++++++++++++++ .../Sources/Litter/Models/ServerManager.swift | 2 ++ .../Sources/Litter/Views/SessionsModel.swift | 17 +++++++++ .../Sources/Litter/Views/SessionsScreen.swift | 18 ++++++++-- 5 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 apps/ios/Sources/Litter/Models/LocalSessionLastMessage.swift diff --git a/apps/ios/Litter.xcodeproj/project.pbxproj b/apps/ios/Litter.xcodeproj/project.pbxproj index ccbe8fd7..1252da14 100644 --- a/apps/ios/Litter.xcodeproj/project.pbxproj +++ b/apps/ios/Litter.xcodeproj/project.pbxproj @@ -97,6 +97,7 @@ 3BEE4213B5E4E91944903E8C /* CodexTurnLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D15D6BDA456B49C319AB4F8 /* CodexTurnLiveActivity.swift */; }; 3E2EEDE5F9D0CC0EE517AF52 /* ToolCallCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BA392F9C2104B8B643C0F9E /* ToolCallCardView.swift */; }; 3F40B95032B31B87D8DD870F /* dark-plus-B1yOZ-Hy.json in Resources */ = {isa = PBXBuildFile; fileRef = 9A59EFF7219CFC315A13C34D /* dark-plus-B1yOZ-Hy.json */; }; + 4032EC2160ED621DCBFC0150 /* ChatGPTOAuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBD60330AED68A3D18BEA1EB /* ChatGPTOAuthTests.swift */; }; 42454D4A731C96CF7E34D8BF /* vesper.json in Resources */ = {isa = PBXBuildFile; fileRef = A10240CEA11F064EBD138D97 /* vesper.json */; }; 42D7EF8EF9BEA8799B14CA6B /* github-dark.json in Resources */ = {isa = PBXBuildFile; fileRef = 87E1D2E7E6CB4DA678C6605E /* github-dark.json */; }; 43085FEE6339B734DE71C448 /* ThemeDefinition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CD31922D3510DA506B0EC85 /* ThemeDefinition.swift */; }; @@ -186,6 +187,7 @@ 769626EC75C1553F28FAFF64 /* SessionsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17099E066405571104CB9A37 /* SessionsScreen.swift */; }; 76D59A3607F33A97008334CA /* material-theme-lighter.json in Resources */ = {isa = PBXBuildFile; fileRef = 76B9969D2D4C7AA23FC9DB93 /* material-theme-lighter.json */; }; 789655FCED880AC5B438EBBF /* WidgetWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF1C08AB8EA89D69A67AA7B2 /* WidgetWebView.swift */; }; + 78B53FFD2DDC1F219EE4056B /* LocalSessionLastMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A013C3B5005B72C0626E11AA /* LocalSessionLastMessage.swift */; }; 78E0E5FAADB2ED8404E29221 /* ConversationWarmupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD02327C52F16587C973B70F /* ConversationWarmupCoordinator.swift */; }; 78E6867AD363C6B010CBEC2D /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BB7D40730BFA8569737636E /* ThemeManager.swift */; }; 79979BB2F64E9429CB78709D /* CodexTurnAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D887FC87082710CC7B75CC8A /* CodexTurnAttributes.swift */; }; @@ -264,6 +266,7 @@ A65B6860C6DCB2E71F5ACFEB /* PreviewSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = E744000053715A5866060B15 /* PreviewSupport.swift */; }; A6FF1F7811A4B6626BD440F5 /* ConversationComposerModalCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED83627330437842851BBC07 /* ConversationComposerModalCoordinator.swift */; }; A77B4456D03F50B2EC91C0E6 /* houston.json in Resources */ = {isa = PBXBuildFile; fileRef = 89172C83E4194291FF873A88 /* houston.json */; }; + A8FBF372819B1F3F685A3359 /* LocalSessionLastMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A013C3B5005B72C0626E11AA /* LocalSessionLastMessage.swift */; }; A92D17A3F0721234F4275C9C /* min-dark-.json in Resources */ = {isa = PBXBuildFile; fileRef = 52BB963EAE95E8BF49FA4F19 /* min-dark-.json */; }; A9A6910D6C1FF7AC8E99DC0C /* oscurange-C.json in Resources */ = {isa = PBXBuildFile; fileRef = 51C3C78C013D314F7F09AB6F /* oscurange-C.json */; }; AA10EE43A455D870C2B01F65 /* DynamicTools.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4874A6712A57F780064DB0E /* DynamicTools.swift */; }; @@ -622,6 +625,7 @@ 9D216CEBFB1B4B71CFC2C0F8 /* AppearanceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingsView.swift; sourceTree = ""; }; 9DEE7E6F96B1BE1235356947 /* CodexIOSTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = CodexIOSTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 9F7DE27E2CB2ACF5158BCF99 /* diagram_types.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = diagram_types.md; sourceTree = ""; }; + A013C3B5005B72C0626E11AA /* LocalSessionLastMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalSessionLastMessage.swift; sourceTree = ""; }; A062692D27F0F6966BBD1AE5 /* catppuccin-frappe.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "catppuccin-frappe.json"; sourceTree = ""; }; A10240CEA11F064EBD138D97 /* vesper.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = vesper.json; sourceTree = ""; }; A32EC379763C60302C66E8F3 /* JSONRPCClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONRPCClient.swift; sourceTree = ""; }; @@ -651,6 +655,7 @@ C822FD11C8902B0EDED54723 /* LiveActivityPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityPreview.swift; sourceTree = ""; }; C8BA1DF8D1BBF028ACFBA7E0 /* SubagentCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubagentCardView.swift; sourceTree = ""; }; C91A1B7B1207FAD81D940DA8 /* ConversationScreenModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationScreenModel.swift; sourceTree = ""; }; + CBD60330AED68A3D18BEA1EB /* ChatGPTOAuthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatGPTOAuthTests.swift; sourceTree = ""; }; CD02327C52F16587C973B70F /* ConversationWarmupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationWarmupCoordinator.swift; sourceTree = ""; }; CEA3937208B56CDD6E4E7406 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; CECEF29277C1DB1AD1EF304D /* text.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = text.xcframework; path = Frameworks/ios_system/text.xcframework; sourceTree = ""; }; @@ -867,6 +872,7 @@ 96523EF86C13A1CBD171F262 /* CodexIOSTests */ = { isa = PBXGroup; children = ( + CBD60330AED68A3D18BEA1EB /* ChatGPTOAuthTests.swift */, 733401E26505A989A02B291D /* ConversationAttachmentSupportTests.swift */, B36FEBF9CE861A652C25B7F6 /* ConversationPlanSemanticsTests.swift */, 89D9552DC45A94A8F6F33671 /* HomeDashboardSupportTests.swift */, @@ -993,6 +999,7 @@ 56597C90314B4DF5587C3949 /* ExperimentalFeatures.swift */, 78C802F9A5B896D6415A236B /* GenerativeUITools.swift */, F05E1F150502D4C5682EBBFD /* LitterPalette.swift */, + A013C3B5005B72C0626E11AA /* LocalSessionLastMessage.swift */, E2C54AE62D2865D1C7750F7A /* NetworkDiscovery.swift */, CEA3937208B56CDD6E4E7406 /* NetworkMonitor.swift */, 93677A51659C08505EA4CE10 /* OnDeviceCodexFeature.swift */, @@ -1446,6 +1453,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4032EC2160ED621DCBFC0150 /* ChatGPTOAuthTests.swift in Sources */, C5F25BE88D32A0FC1FD72449 /* ConversationAttachmentSupportTests.swift in Sources */, 49005F651AE38A7F3F3C89F7 /* ConversationPlanSemanticsTests.swift in Sources */, 7A803091FAF581F8FD256C2A /* HomeDashboardSupportTests.swift in Sources */, @@ -1517,6 +1525,7 @@ E8E75D80AB816ABCFBE01609 /* LitterApp.swift in Sources */, 4E829E34565DCE0468982923 /* LitterPalette.swift in Sources */, E36B30554FEEE6838CAA6120 /* LiveActivityPreview.swift in Sources */, + A8FBF372819B1F3F685A3359 /* LocalSessionLastMessage.swift in Sources */, 1F5F939015D83641B6E06256 /* LockScreenCardView.swift in Sources */, 56DBF9B40EDB38539AD3F363 /* MessageBubbleView.swift in Sources */, 878D5AD377941EA75B08D110 /* MessageRenderCache.swift in Sources */, @@ -1610,6 +1619,7 @@ 0AC3B09D832DD47A0C781EE5 /* LitterApp.swift in Sources */, 055E4E6F482808FF3A6FE4AD /* LitterPalette.swift in Sources */, 8162FA999156618F9B3D7ADF /* LiveActivityPreview.swift in Sources */, + 78B53FFD2DDC1F219EE4056B /* LocalSessionLastMessage.swift in Sources */, 568C0B1181B1ACFFBD7B165D /* LockScreenCardView.swift in Sources */, 31EB5932CBEDDB0E7FFB1158 /* MessageBubbleView.swift in Sources */, 1AF5B0CFD0F28F0A7E787A04 /* MessageRenderCache.swift in Sources */, diff --git a/apps/ios/Sources/Litter/Models/LocalSessionLastMessage.swift b/apps/ios/Sources/Litter/Models/LocalSessionLastMessage.swift new file mode 100644 index 00000000..d6fc3d1a --- /dev/null +++ b/apps/ios/Sources/Litter/Models/LocalSessionLastMessage.swift @@ -0,0 +1,35 @@ +import Foundation +import Observation + +/// Caches the last assistant message for each session, persisted to UserDefaults. +/// Populated when a thread is opened and items stream in. Used to show a preview +/// on the session list without requiring the thread to be open. +@MainActor +@Observable +final class LocalSessionLastMessage { + private let defaultsKey = "localSessionLastMessages" + private(set) var messages: [String: String] + + init() { + messages = (UserDefaults.standard.dictionary(forKey: defaultsKey) as? [String: String]) ?? [:] + } + + func update(_ text: String, for threadKey: ThreadKey) { + let snippet = String(text.prefix(200)) + .replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespaces) + guard !snippet.isEmpty else { return } + let k = storageKey(for: threadKey) + guard messages[k] != snippet else { return } + messages[k] = snippet + UserDefaults.standard.set(messages, forKey: defaultsKey) + } + + func lastMessage(for threadKey: ThreadKey) -> String? { + messages[storageKey(for: threadKey)] + } + + private func storageKey(for key: ThreadKey) -> String { + "\(key.serverId):\(key.threadId)" + } +} diff --git a/apps/ios/Sources/Litter/Models/ServerManager.swift b/apps/ios/Sources/Litter/Models/ServerManager.swift index a5df89b5..8252a857 100644 --- a/apps/ios/Sources/Litter/Models/ServerManager.swift +++ b/apps/ios/Sources/Litter/Models/ServerManager.swift @@ -3760,6 +3760,8 @@ final class ServerManager { private func updateLiveActivityBGWake(key: ThreadKey) { guard let activity = liveActivities[key] else { return } let thread = threads[key] + // If session already completed, don't update — let endLiveActivity's dismissal finish + guard thread?.hasTurnActive == true else { return } let elapsed = Int(Date().timeIntervalSince(liveActivityStartDates[key] ?? Date())) let toolCount = liveActivityToolCallCounts[key, default: 0] diff --git a/apps/ios/Sources/Litter/Views/SessionsModel.swift b/apps/ios/Sources/Litter/Views/SessionsModel.swift index a19ba720..dac94ad5 100644 --- a/apps/ios/Sources/Litter/Views/SessionsModel.swift +++ b/apps/ios/Sources/Litter/Views/SessionsModel.swift @@ -19,6 +19,7 @@ final class SessionsModel { private(set) var derivedData: SessionsDerivedData = .empty private(set) var connectedServerOptions: [DirectoryPickerServerOption] = [] private(set) var ephemeralStateByThreadKey: [ThreadKey: ThreadEphemeralState] = [:] + let localLastMessages = LocalSessionLastMessage() @ObservationIgnored private weak var serverManager: ServerManager? @ObservationIgnored private weak var appState: AppState? @@ -59,6 +60,7 @@ final class SessionsModel { observationGeneration &+= 1 let generation = observationGeneration + var pendingCacheUpdates: [(ThreadKey, String)] = [] let snapshot = withObservationTracking { let selectedServerFilterId = appState.sessionsSelectedServerFilterId let showOnlyForks = appState.sessionsShowOnlyForks @@ -82,6 +84,14 @@ final class SessionsModel { hasTurnActive: entry.value.hasTurnActive, updatedAt: entry.value.updatedAt ) + // Collect cache updates — applied after withObservationTracking to avoid + // SessionsModel inadvertently observing localLastMessages.messages + if !entry.value.hasTurnActive, + let lastText = entry.value.items + .last(where: { $0.isAssistantItem && !($0.assistantText ?? "").isEmpty })? + .assistantText { + pendingCacheUpdates.append((entry.key, lastText)) + } } let nextFrozenMostRecentThreadOrder = resolvedFrozenMostRecentThreadOrder( @@ -116,6 +126,13 @@ final class SessionsModel { connectedServerOptions = snapshot.connectedServerOptions ephemeralStateByThreadKey = snapshot.ephemeralStateByThreadKey derivedData = snapshot.derivedData + + // Apply last-message cache updates outside withObservationTracking so + // SessionsModel does not observe localLastMessages and trigger a spurious + // extra refreshState() on every write + for (key, text) in pendingCacheUpdates { + localLastMessages.update(text, for: key) + } } private func resolvedFrozenMostRecentThreadOrder( diff --git a/apps/ios/Sources/Litter/Views/SessionsScreen.swift b/apps/ios/Sources/Litter/Views/SessionsScreen.swift index 7b601ec5..a8706078 100644 --- a/apps/ios/Sources/Litter/Views/SessionsScreen.swift +++ b/apps/ios/Sources/Litter/Views/SessionsScreen.swift @@ -665,7 +665,7 @@ struct SessionsScreen: View { Button { renamingThreadKey = thread.key renameCurrentTitle = thread.sessionTitle - renameDraft = "" + renameDraft = thread.sessionTitle } label: { Label("Rename", systemImage: "pencil") } @@ -723,7 +723,7 @@ struct SessionsScreen: View { } else if thread.isSubagent { subagentStatusIndicator(thread.agentStatus).padding(.top, 3) } else { - Circle().fill(Color.clear).frame(width: 8, height: 8).padding(.top, 3) + Circle().fill(LitterTheme.textMuted.opacity(0.4)).frame(width: 8, height: 8).padding(.top, 3) } VStack(alignment: .leading, spacing: 3) { @@ -766,6 +766,13 @@ struct SessionsScreen: View { } } + if !hasTurnActive, let lastMessage = sessionsModel.localLastMessages.lastMessage(for: thread.key) { + Text(lastMessage) + .litterFont(.caption2) + .foregroundColor(LitterTheme.textMuted) + .lineLimit(1) + } + HStack(spacing: 4) { Text(relativeDate(updatedAt)) .foregroundColor(LitterTheme.textSecondary) @@ -1075,7 +1082,12 @@ struct SessionsScreen: View { private func submitRename() async { guard let key = renamingThreadKey else { return } let nextTitle = renameDraft.trimmingCharacters(in: .whitespacesAndNewlines) - guard !nextTitle.isEmpty else { return } + guard !nextTitle.isEmpty else { + renamingThreadKey = nil + renameCurrentTitle = "" + renameDraft = "" + return + } do { try await serverManager.renameThread(key, to: nextTitle) } catch {