Skip to content

iOS: Wire chat-history sheet — row taps, new chat, search#5204

Open
SabrinaTardio wants to merge 3 commits into
sabrina/ios-ai-chat-history-full-statefrom
sabrina/ios-ai-chat-history-actions
Open

iOS: Wire chat-history sheet — row taps, new chat, search#5204
SabrinaTardio wants to merge 3 commits into
sabrina/ios-ai-chat-history-full-statefrom
sabrina/ios-ai-chat-history-actions

Conversation

@SabrinaTardio
Copy link
Copy Markdown
Contributor

@SabrinaTardio SabrinaTardio commented Jun 1, 2026

Task/Issue URL: https://app.asana.com/1/137249556945/project/414235014887631/task/1215232204008019?focus=true
Tech Design URL:
CC:

Description

Implements Row tap, new tab button and search

  • Row tap → opens the selected chat in Duck.ai (URL with chatID=…). Routes through the existing onChatHistorySelected(url:) path used by autocomplete history taps, so navigation behaviour (boundary handling, NTP transform, etc.) is identical.
  • Compose toolbar button and empty-state "Open Duck.ai" button → open a fresh chat. Both call the same delegate intent (viewModelDidRequestOpenNewChat).
  • Search bar → filters the chat list by title (case-insensitive substring) as the user types. Pinned / recent split is preserved during search; live updates from the storage publisher still flow through. When the search returns no results, the existing empty state is shown — matches Android (ChatHistoryViewModel.reduce).

Testing Steps

  1. Settings → Debug → Feature Flags Overrides → toggle aiChatNativeChatHistory ON.
  2. Use Duck.ai to create a handful of chats — at least one pinned, several recent.
  3. Open the browser menu → Chats.
  4. Row tap: tap a chat row → sheet dismisses, Duck.ai opens with that chat (URL has chatID=…).
  5. New chat (toolbar): reopen Chats → tap the compose icon in the bottom toolbar → sheet dismisses, Duck.ai opens fresh.
  6. New chat (empty state): clear all chats, reopen Chats → tap "Open Duck.ai" in the empty state → same fresh-chat behaviour.
  7. Search: with a populated list, type part of a chat title in the search bar. The list filters live (case-insensitive). Both pinned and recent sections shrink to matching rows.
  8. Search no matches: type a string that matches nothing → the empty state appears (note: copy reads "No recent chats" — Android parity, design follow-up to refine).
  9. Search live update: with a query active that matches at least one chat, edit/delete a matching chat in Duck.ai in another tab. The filtered list updates automatically without dismissing.
  10. Clear search: tap the X / clear the field → full list returns.

Impact and Risks

Low. All changes are inside the iOS app target — no shared-package changes. Behaviour is gated behind aiChatNativeChatHistory (default internalOnly). The new code is purely additive on top of the publisher pipeline landed in #5170.

What could go wrong?

  • Row tap goes to wrong chat: would mean the view model's index-path → chatId mapping is off. Covered by testChatTapped_validIndexPath_notifiesDelegateWithChatId and the matching invalid-index test.
  • Search filter desyncs from live updates: would mean CombineLatest is misbehaving with the throttled query stream. Verified manually (live update while filtered, step 9) and covered by tests for empty / whitespace / no-match queries.
  • Memory / Null storage paths (no observer) — the publisher path still emits .storageUnavailable; the error alert from iOS: Wire Duck.ai chat-history view model to local data source #5170 fires regardless of whether search is active.

Quality Considerations

  • Performance: filter runs in-memory on each CombineLatest emission; sub-millisecond at expected chat counts. Throttle (150ms, latest: true) caps the reload rate during fast typing.
  • No pixels in this PR — they'll land in a focused follow-up alongside the other history surface pixels so they're easy to reason about together.
  • Localization: no new strings; uses existing UserText from iOS: Wire Duck.ai chat-history view model to local data source #5170 (still NotLocalizedString; final copy follow-up before public rollout).

Notes to Reviewer

  • Behavior on the empty-results during-search state is intentional parity with Android — reuses the existing empty state ("No recent chats" + "Open Duck.ai" button). If product wants a distinct "no matches for X" message that's a small UI variant for a follow-up.
  • Image-Pinned-24 glyph is still a future ask (carried over from iOS: Wire Duck.ai chat-history view model to local data source #5170) — pinned image chats fall back to the unpinned glyph.

Internal references:

Definition of Done | Engineering Expectations | Tech Design Template


Note

Low Risk
iOS-only, feature-flagged (aiChatNativeChatHistory), additive wiring on an existing publisher; navigation reuses established Duck.ai URL paths.

Overview
Wires the native Chats history sheet so users can open a specific chat, start a new one, and filter the list by title.

Row selection dismisses the sheet and navigates to Duck.ai with a chatID URL via the existing onChatHistorySelected path (same as autocomplete history). Compose and the empty-state Open Duck.ai button both request a fresh chat through a renamed delegate (viewModelDidRequestOpenNewChat).

Search drives a throttled (150ms) case-insensitive title filter while keeping pinned/recent sections; chat updates from storage still merge via CombineLatest, with load failures caught so the stream does not terminate. The illustrated empty state appears only when there are no chats and the search field is empty—zero search matches keep the table and search bar visible.

Reviewed by Cursor Bugbot for commit f6c9e10. Bugbot is set up for automated code reviews on this repo. Configure here.

SabrinaTardio and others added 2 commits June 1, 2026 12:31
- AIChatHistoryViewModel: rename `openDuckAiTapped` → `newChatTapped`
  (used by both the empty-state CTA and the toolbar compose button —
  they produce the same outcome, opening Duck.ai on a fresh chat). Add
  `chatTapped(at:)` for row taps, which resolves the chat at the given
  index path and emits the chatId to the delegate.
- Delegate is renamed accordingly: `viewModelDidRequestOpenNewChat`
  replaces the old `viewModelDidRequestOpenDuckAi`, and a new
  `viewModelDidRequestOpenChat(chatId:)` carries the row-tap intent.
- AIChatHistoryViewController: forward `didSelectRowAt` to
  `chatTapped(at:)`. Wire the compose `UIBarButtonItem` to
  `newChatTapped`.
- AIChatHistoryEmptyStateView: button calls `newChatTapped`.
- MainViewController: implement both delegate methods. New chat
  dismisses + `openAIChat()`. Row tap builds the URL via
  `aiChatSettings.aiChatURL.withChatID(chatId)` then routes through the
  existing `onChatHistorySelected(url:)` path — same behaviour as the
  autocomplete chat-history tap.
- Add view-model tests for both new intents (valid + invalid index
  paths) and update the mock delegate.

Pixels deferred to the end of the feature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Filter the chat list by title as the user types in the search bar at
the top of the sheet. Implementation lives in the view model — the
view controller just forwards `UISearchBarDelegate` text changes.

- AIChatHistoryViewModel: add `@Published query: String` + an
  `updateQuery(_:)` intent. Restructure the subscription to
  `Publishers.CombineLatest(chats, $query.throttle(latest: true))` and
  apply a case-insensitive substring filter on title before splitting
  into pinned / recent. `throttle(latest: true)` emits the first value
  immediately (no initial wait) and forwards the latest value at most
  once per 150ms while the user types.
- The chats publisher is materialised into `Result<[DuckAiChat], Error>`
  so a storage failure becomes a sentinel value the combined publisher
  can react to (sets `loadFailed`, clears the lists) without tearing
  down the stream.
- AIChatHistoryViewController conforms to `UISearchBarDelegate` and
  routes `textDidChange` to `viewModel.updateQuery(_:)`.
- Behaviour during search mirrors Android: pinned / recent split is
  preserved (each side is filtered against the query); when no results
  match, the existing empty state shows. Reusing the empty-state copy
  ("No recent chats") during search is intentional parity — design
  follow-up to refine the copy.
- Live updates compose for free: storage writes re-fire the chats
  publisher, CombineLatest fires with the latest chats + the current
  query, and the filtered list updates automatically. Verified with the
  reactive pipeline established in #5170.
- Tests cover filter-by-title (case-insensitive), empty query restores
  full list, whitespace-only query restores full list, and the
  no-matches → empty state path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit fd54d45. Configure here.

Comment thread iOS/DuckDuckGo/AIChat/AIChatHistoryViewController.swift
Only show the illustrated empty state when the user has no chats AND
isn't searching. When a search returns no matches, leave the table
view visible — its search-bar header stays put so the user can
backspace to clear the query without dismissing the sheet.

Behaviour:
- No chats, no query → illustrated empty state (welcome).
- No chats, query active → empty table with search bar header.
- Chats present, no matches → empty table with search bar header.
- Chats present, with matches → filtered list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@SabrinaTardio SabrinaTardio requested a review from Bunn June 1, 2026 14:59
// `throttle(latest: true)` emits the first value immediately (no initial wait on subscribe)
// and then forwards the latest value at most once per interval while the user types.
let queryStream = $query
.throttle(for: .milliseconds(150), scheduler: DispatchQueue.main, latest: true)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe a debounce would be better here?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants