Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 30 additions & 1 deletion CodegiOS/Features/Search/SearchView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
}
Expand All @@ -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,
Expand Down
44 changes: 41 additions & 3 deletions CodegiOS/Features/Sessions/SessionListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ struct SessionListView: View {
serverTitleMenu(serverSwitcher)
}
}
ToolbarItem(placement: .topBarTrailing) {
filterMenu
}
if let onNewSession {
ToolbarItem(placement: .topBarTrailing) {
Button(action: onNewSession) {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
39 changes: 35 additions & 4 deletions CodegiOS/Features/Sessions/SessionListViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
}
Expand Down
38 changes: 38 additions & 0 deletions CodegiOS/Models/SessionListVisibility.swift
Original file line number Diff line number Diff line change
@@ -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) }
}
}
60 changes: 60 additions & 0 deletions CodegiOS/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {

},
Expand Down
64 changes: 64 additions & 0 deletions CodegiOSTests/SessionListVisibilityTests.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
Loading