From bb2d4ff3da1e5af5874eb50d1bed05a3df0623d4 Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:17:03 -0700 Subject: [PATCH] feat(chats): hide completed conversations by default Match desktop sidebar Show completed. iPhone and iPad Chats plus Search default to unfinished only. A filter menu reveals completed. Pinned rows stay visible. Empty state explains the hidden finished list. --- CHANGELOG.md | 3 + CodegiOS/Features/Search/SearchView.swift | 31 ++++++++- .../Features/Sessions/SessionListView.swift | 44 ++++++++++++- .../Sessions/SessionListViewModel.swift | 39 +++++++++-- CodegiOS/Models/SessionListVisibility.swift | 38 +++++++++++ CodegiOS/Resources/Localizable.xcstrings | 60 +++++++++++++++++ .../SessionListVisibilityTests.swift | 64 +++++++++++++++++++ project.yml | 15 +++++ 8 files changed, 286 insertions(+), 8 deletions(-) create mode 100644 CodegiOS/Models/SessionListVisibility.swift create mode 100644 CodegiOSTests/SessionListVisibilityTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index e5500e3..7a09c61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ the text as the git tag message and the GitHub Release notes. ### Added +- Chats and Search can hide completed conversations, matching desktop. Off + by default; a filter menu turns them back on. Pinned rows stay visible. + ### Changed - Apple signing now uses an ignored local configuration instead of a committed diff --git a/CodegiOS/Features/Search/SearchView.swift b/CodegiOS/Features/Search/SearchView.swift index 52f910b..0846250 100644 --- a/CodegiOS/Features/Search/SearchView.swift +++ b/CodegiOS/Features/Search/SearchView.swift @@ -16,6 +16,8 @@ struct SearchView: View { @State private var hasSearched = false @State private var error: String? @State private var recents: [String] = RecentSearches.load() + /// Shared with the Chats list so flipping the filter in either place matches. + @State private var showCompleted = SessionListPrefs.showCompleted @Environment(\.horizontalSizeClass) private var horizontalSizeClass @@ -26,11 +28,32 @@ struct SearchView: View { } .screenTitle("Search", compact: horizontalSizeClass == .compact) .searchable(text: $query, prompt: "Search sessions") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Toggle("Show completed", isOn: $showCompleted) + } label: { + Image(systemName: showCompleted + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle") + } + .tint(Theme.accent) + .accessibilityLabel("View options") + } + } + .onChange(of: showCompleted) { _, on in + SessionListPrefs.showCompleted = on + } + .onAppear { showCompleted = SessionListPrefs.showCompleted } .task(id: query) { await runSearch() } } + private var displayedResults: [ConversationSummary] { + SessionListVisibility.filter(results, showCompleted: showCompleted) + } + // MARK: - Content states @ViewBuilder @@ -55,6 +78,12 @@ struct SearchView: View { title: "No Matches", message: "No sessions match \"\(trimmedQuery)\"." ) + } else if displayedResults.isEmpty, hasSearched { + EmptyStateView( + icon: "checkmark.circle", + title: "No Unfinished Tasks", + message: "Completed conversations are hidden. Turn on Show completed to see them." + ) } else { resultsList } @@ -63,7 +92,7 @@ struct SearchView: View { private var resultsList: some View { ScrollView { LazyVStack(spacing: 10) { - ForEach(results) { conversation in + ForEach(displayedResults) { conversation in SessionRow( conversation: conversation, isSelected: false, diff --git a/CodegiOS/Features/Sessions/SessionListView.swift b/CodegiOS/Features/Sessions/SessionListView.swift index 7726954..e51bf4b 100644 --- a/CodegiOS/Features/Sessions/SessionListView.swift +++ b/CodegiOS/Features/Sessions/SessionListView.swift @@ -98,6 +98,9 @@ struct SessionListView: View { serverTitleMenu(serverSwitcher) } } + ToolbarItem(placement: .topBarTrailing) { + filterMenu + } if let onNewSession { ToolbarItem(placement: .topBarTrailing) { Button(action: onNewSession) { @@ -116,6 +119,7 @@ struct SessionListView: View { // (same UUID, new URL/token → fresh `client`) rebinds the list to the // new endpoint and refetches, rather than serving the old endpoint's // sessions. + .onAppear { viewModel.showCompleted = SessionListPrefs.showCompleted } .task(id: serverRevision) { await viewModel.reload(client: client) } @@ -327,10 +331,24 @@ struct SessionListView: View { Task { await viewModel.refresh() } } } else if searching { + if viewModel.hasHiddenCompletedMatches(searchText: searchText) { + EmptyStateView( + icon: "checkmark.circle", + title: "No Unfinished Tasks", + message: "Completed conversations are hidden. Turn on Show completed to see them." + ) + } else { + EmptyStateView( + icon: "magnifyingglass", + title: "No Matches", + message: "No sessions match \"\(searchText)\"." + ) + } + } else if viewModel.hiddenCompletedCount > 0 { EmptyStateView( - icon: "magnifyingglass", - title: "No Matches", - message: "No sessions match \"\(searchText)\"." + icon: "checkmark.circle", + title: "No Unfinished Tasks", + message: "Completed conversations are hidden. Turn on Show completed to see them." ) } else if let onNewSession { EmptyStateView( @@ -368,6 +386,26 @@ struct SessionListView: View { Task { await viewModel.setPinned(conversation, pinned: !conversation.isPinned) } } + /// Same control as desktop sidebar "Show completed conversations". + /// Filled icon when the filter is off the default (completed visible). + private var filterMenu: some View { + Menu { + Toggle("Show completed", isOn: Binding( + get: { viewModel.showCompleted }, + set: { viewModel.showCompleted = $0 } + )) + } label: { + Image(systemName: viewModel.showCompleted + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle") + } + .tint(Theme.accent) + .accessibilityLabel("View options") + .accessibilityValue(viewModel.showCompleted + ? "Showing completed" + : "Hiding completed") + } + /// The big, left-aligned server name rendered as a `Menu` so it's tappable in /// place (not only when collapsed to the centered toolbar title). A chevron /// signals the affordance; the picker carries a checkmark on the current diff --git a/CodegiOS/Features/Sessions/SessionListViewModel.swift b/CodegiOS/Features/Sessions/SessionListViewModel.swift index 66237ec..a2f6fab 100644 --- a/CodegiOS/Features/Sessions/SessionListViewModel.swift +++ b/CodegiOS/Features/Sessions/SessionListViewModel.swift @@ -38,6 +38,15 @@ final class SessionListViewModel { /// User-facing error message for the most recent load/refresh/pin, if it failed. private(set) var error: String? + /// Desktop sidebar "Show completed conversations". Default off. Persisted + /// so iPhone and iPad keep the same choice. Pinned rows ignore this. + var showCompleted: Bool = SessionListPrefs.showCompleted { + didSet { + guard showCompleted != oldValue else { return } + SessionListPrefs.showCompleted = showCompleted + } + } + /// Monotonic token so a slow fetch can't clobber a newer one's results. private var fetchGeneration = 0 @@ -102,12 +111,26 @@ final class SessionListViewModel { } /// Pinned conversations across all folders, most-recently-pinned first, - /// after applying the search filter. + /// after applying the search filter. Completed pins stay visible (desktop). func pinned(searchText: String) -> [ConversationSummary] { matching(conversations.filter(\.isPinned), searchText) .sorted { ($0.pinnedAt ?? .distantPast) > ($1.pinnedAt ?? .distantPast) } } + /// Completed conversations currently hidden by the default filter. Used + /// to tell "no sessions at all" from "only finished ones, flip the toggle". + var hiddenCompletedCount: Int { + guard !showCompleted else { return 0 } + return conversations.filter { $0.status == .completed && !$0.isPinned }.count + } + + /// Search hits that exist only among hidden completed rows. + func hasHiddenCompletedMatches(searchText: String) -> Bool { + guard !showCompleted else { return false } + let hidden = conversations.filter { $0.status == .completed && !$0.isPinned } + return !matching(hidden, searchText).isEmpty + } + /// One group per folder (in `sortedFolders` order) holding that folder's /// non-pinned conversations, newest-first. With no search every folder is /// included — even empty ones, so their collapsible header still shows; when @@ -117,7 +140,7 @@ final class SessionListViewModel { // Group by the merge target so a worktree's conversations land under its // root folder's header (matching the web), not a hidden worktree row. let map = childToParent - let unpinnedByGroup = Dictionary(grouping: conversations.filter { !$0.isPinned }) { + let unpinnedByGroup = Dictionary(grouping: visibleUnpinned) { FolderVisibility.mergedFolderId($0.folderId, childToParent: map) } return sortedFolders.compactMap { folder in @@ -135,12 +158,20 @@ final class SessionListViewModel { func ungrouped(searchText: String) -> [ConversationSummary] { let map = childToParent let known = Set(sortedFolders.map(\.id)) - return matching(conversations.filter { - !$0.isPinned && !known.contains(FolderVisibility.mergedFolderId($0.folderId, childToParent: map)) + return matching(visibleUnpinned.filter { + !known.contains(FolderVisibility.mergedFolderId($0.folderId, childToParent: map)) }, searchText) .sorted { $0.updatedAt > $1.updatedAt } } + /// Non-pinned conversations after the completed filter. + private var visibleUnpinned: [ConversationSummary] { + SessionListVisibility.filter( + conversations.filter { !$0.isPinned }, + showCompleted: showCompleted + ) + } + private func trimmed(_ s: String) -> String { s.trimmingCharacters(in: .whitespacesAndNewlines) } diff --git a/CodegiOS/Models/SessionListVisibility.swift b/CodegiOS/Models/SessionListVisibility.swift new file mode 100644 index 0000000..e3b8a7c --- /dev/null +++ b/CodegiOS/Models/SessionListVisibility.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Whether completed conversations appear in the Chats / Search lists. +/// +/// Matches desktop sidebar `workspace:sidebar-show-completed`: default OFF so +/// finished sessions stay out of the way; only an explicit on reveals them. +/// Pinned rows stay visible either way, same as desktop's Pinned section. +enum SessionListPrefs { + private static let key = "codeg.sessions.showCompleted" + + /// `false` when unset — same as desktop `loadShowCompleted()`. + static var showCompleted: Bool { + get { + if UserDefaults.standard.object(forKey: key) == nil { return false } + return UserDefaults.standard.bool(forKey: key) + } + set { UserDefaults.standard.set(newValue, forKey: key) } + } +} + +/// Pure visibility rules for a conversation row. No I/O so the list view +/// model, Search, and unit tests share one source of truth. +enum SessionListVisibility { + /// Desktop hides only `status == completed`. Cancelled / review stay. + /// A pinned conversation is never hidden by this filter. + static func isVisible(_ conv: ConversationSummary, showCompleted: Bool) -> Bool { + if conv.isPinned { return true } + if showCompleted { return true } + return conv.status != .completed + } + + static func filter( + _ convs: [ConversationSummary], + showCompleted: Bool + ) -> [ConversationSummary] { + convs.filter { isVisible($0, showCompleted: showCompleted) } + } +} diff --git a/CodegiOS/Resources/Localizable.xcstrings b/CodegiOS/Resources/Localizable.xcstrings index 28c5f3a..d0bb34f 100644 --- a/CodegiOS/Resources/Localizable.xcstrings +++ b/CodegiOS/Resources/Localizable.xcstrings @@ -7939,6 +7939,66 @@ } } }, + "Show completed" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示已完成" + } + } + } + }, + "Showing completed" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在显示已完成" + } + } + } + }, + "Hiding completed" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已隐藏已完成" + } + } + } + }, + "View options" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "查看选项" + } + } + } + }, + "No Unfinished Tasks" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有未完成的任务" + } + } + } + }, + "Completed conversations are hidden. Turn on Show completed to see them." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已完成的会话已隐藏。打开“显示已完成”即可查看。" + } + } + } + }, "Showing the latest %@ commits" : { }, diff --git a/CodegiOSTests/SessionListVisibilityTests.swift b/CodegiOSTests/SessionListVisibilityTests.swift new file mode 100644 index 0000000..8b658ef --- /dev/null +++ b/CodegiOSTests/SessionListVisibilityTests.swift @@ -0,0 +1,64 @@ +import XCTest +@testable import Codeg + +final class SessionListVisibilityTests: XCTestCase { + func testDefaultHidesCompletedOnly() { + XCTAssertTrue(SessionListVisibility.isVisible(conv(.inProgress), showCompleted: false)) + XCTAssertTrue(SessionListVisibility.isVisible(conv(.pendingReview), showCompleted: false)) + XCTAssertTrue(SessionListVisibility.isVisible(conv(.cancelled), showCompleted: false)) + XCTAssertFalse(SessionListVisibility.isVisible(conv(.completed), showCompleted: false)) + } + + func testShowCompletedRevealsFinishedRows() { + XCTAssertTrue(SessionListVisibility.isVisible(conv(.completed), showCompleted: true)) + } + + func testPinnedCompletedStaysVisibleWhenFilterIsOff() { + XCTAssertTrue( + SessionListVisibility.isVisible(conv(.completed, pinned: true), showCompleted: false) + ) + } + + func testFilterKeepsOrderAndDropsHidden() { + let rows = [ + conv(.inProgress, id: 1), + conv(.completed, id: 2), + conv(.completed, pinned: true, id: 3), + conv(.cancelled, id: 4), + ] + let visible = SessionListVisibility.filter(rows, showCompleted: false) + XCTAssertEqual(visible.map(\.id), [1, 3, 4]) + } + + func testPrefsDefaultOff() { + let key = "codeg.sessions.showCompleted" + UserDefaults.standard.removeObject(forKey: key) + XCTAssertFalse(SessionListPrefs.showCompleted) + SessionListPrefs.showCompleted = true + XCTAssertTrue(SessionListPrefs.showCompleted) + SessionListPrefs.showCompleted = false + XCTAssertFalse(SessionListPrefs.showCompleted) + UserDefaults.standard.removeObject(forKey: key) + } + + private func conv( + _ status: ConversationStatus, + pinned: Bool = false, + id: Int = 1 + ) -> ConversationSummary { + ConversationSummary( + id: id, + folderId: 1, + title: "t\(id)", + agentType: .claudeCode, + status: status, + model: nil, + gitBranch: nil, + externalId: nil, + messageCount: 0, + createdAt: Date(timeIntervalSince1970: 0), + updatedAt: Date(timeIntervalSince1970: 0), + pinnedAt: pinned ? Date(timeIntervalSince1970: 1) : nil + ) + } +} diff --git a/project.yml b/project.yml index 7a63c42..318b031 100644 --- a/project.yml +++ b/project.yml @@ -92,3 +92,18 @@ targets: - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight + + CodegiOSTests: + type: bundle.unit-test + platform: iOS + deploymentTarget: "26.0" + sources: + - path: CodegiOSTests + dependencies: + - target: CodegiOS + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: app.codeg.ios.tests + GENERATE_INFOPLIST_FILE: YES + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Codeg.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Codeg" + BUNDLE_LOADER: "$(TEST_HOST)"