@@ -67,6 +67,9 @@ class ConversationService: ObservableObject {
6767
6868 print ( " [ConversationService] Loaded \( loaded. count) conversations from Core Data " )
6969 conversations = loaded
70+
71+ // Startup catch-up for legacy placeholder/first-message titles.
72+ ConversationTitleMaintenanceService . shared. scheduleStartupCatchUp ( conversations: loaded)
7073 }
7174
7275 /// Clean up deletion records older than retention period
@@ -435,6 +438,10 @@ class ConversationService: ObservableObject {
435438 messages = [ ]
436439 }
437440
441+ // Remove local title overrides for deleted conversations.
442+ SettingsStorage . shared. setDisplayName ( nil , for: id)
443+ SettingsStorage . shared. setGeneratedTitle ( nil , for: id)
444+
438445 // For local-only conversations OR when no backend configured, hard delete immediately
439446 if id. hasPrefix ( " local_ " ) || !apiClient. isBackendConfigured {
440447 try localStore. deleteLocalConversation ( id: id)
@@ -754,16 +761,12 @@ class ConversationService: ObservableObject {
754761 // Save both messages to Core Data immediately
755762 try await syncManager. saveMessagesToCoreData ( [ userMessage, assistantMessage] , conversationId: conversationId)
756763
757- // After the assistant completes its response, attempt to generate a concise chat title
758- // using Apple Intelligence (FoundationModels). This does NOT replace the existing
759- // initial-title method; it just upgrades the title post-response.
760- Task { @MainActor in
761- await self . tryAutoRenameConversationAfterFirstReply (
762- conversationId: conversationId,
763- userMessage: userMessage,
764- assistantMessage: assistantMessage
765- )
766- }
764+ // Shared post-reply titling entrypoint (used by both non-streaming and streaming).
765+ schedulePostReplyTitling (
766+ conversationId: conversationId,
767+ userMessage: userMessage,
768+ assistantMessage: assistantMessage
769+ )
767770
768771 // Process and save memories if any were created
769772 if let memories = memories, !memories. isEmpty {
@@ -912,66 +915,18 @@ class ConversationService: ObservableObject {
912915
913916 // MARK: - Auto Title
914917
915- /// Attempts to auto-rename a conversation after the assistant completes a reply.
916- ///
917- /// - Important: Will not override user manual renames (displayNameOverride).
918- /// - Important: Uses only the last (user, assistant) messages to keep latency predictable.
918+ /// Shared post-reply title hook used by both non-streaming and streaming completion paths.
919919 @MainActor
920- private func tryAutoRenameConversationAfterFirstReply (
920+ func schedulePostReplyTitling (
921921 conversationId: String ,
922922 userMessage: Message ,
923923 assistantMessage: Message
924- ) async {
925- // Do not override a manual rename.
926- if SettingsStorage . shared. displayName ( for: conversationId) != nil {
927- print ( " [ConversationService] ℹ️ Auto-title skipped: manual rename exists " )
928- return
929- }
930-
931- // Only attempt when we have a conversation loaded.
932- guard let conv = conversations. first ( where: { $0. id == conversationId } ) else {
933- print ( " [ConversationService] ⚠️ Auto-title skipped: conversation not in memory " )
934- return
935- }
936-
937- // Heuristic: only auto-rename if the current title looks like the placeholder
938- // (the app currently uses the first user message prefix).
939- let placeholderTitle = String ( userMessage. content. prefix ( 50 ) ) . trimmingCharacters ( in: . whitespacesAndNewlines)
940- let isPlaceholder = conv. title == " New Chat " || conv. title == placeholderTitle
941- guard isPlaceholder else {
942- print ( " [ConversationService] ℹ️ Auto-title skipped: title already non-placeholder (' \( conv. title) ') " )
943- return
944- }
945-
946- do {
947- let generated = try await ConversationTitleOrchestrator . shared. generateTitle (
948- userMessage: userMessage,
949- assistantMessage: assistantMessage
950- )
951-
952- let trimmed = generated. trimmingCharacters ( in: . whitespacesAndNewlines)
953- guard !trimmed. isEmpty else {
954- print ( " [ConversationService] ⚠️ Auto-title generation produced empty title; skipping " )
955- return
956- }
957-
958- // 1) Update UI immediately via local display name override.
959- ConversationTitleOrchestrator . shared. applyTitle ( trimmed, to: conversationId)
960-
961- // 2) Persist/sync as the official conversation title (if possible).
962- do {
963- _ = try await updateConversation ( id: conversationId, title: trimmed)
964- } catch {
965- // In local-only / offline mode this can still succeed (local store), but if it
966- // fails we keep the displayNameOverride so the UI still shows the new title.
967- print ( " [ConversationService] ⚠️ Auto-title could not persist server-side: \( error. localizedDescription) " )
968- }
969-
970- print ( " [ConversationService] ✅ Auto-renamed conversation \( conversationId) -> ' \( trimmed) ' " )
971- } catch {
972- // Leave the existing title as-is.
973- print ( " [ConversationService] ⚠️ Auto-title generation failed: \( error. localizedDescription) " )
974- }
924+ ) {
925+ ConversationTitleSubAgentService . shared. schedulePostReplyTitling (
926+ conversationId: conversationId,
927+ userMessage: userMessage,
928+ assistantMessage: assistantMessage
929+ )
975930 }
976931
977932 // MARK: - Message Editing & Deletion
0 commit comments