@@ -45,6 +45,12 @@ import kotlinx.coroutines.sync.withPermit
4545/** Sort order for the session list. */
4646enum class SessionSortMode { RECENT, TITLE }
4747
48+/** A pin/archive action the user just took, surfaced so the UI can confirm it with an
49+ * Undo snackbar. [undoable] via [SessionListViewModel.undoSessionAction]. */
50+enum class SessionActionKind { PINNED, UNPINNED, ARCHIVED, UNARCHIVED }
51+
52+data class SessionActionEvent(val sessionId: String, val kind: SessionActionKind)
53+
4854/** Directory options for the new-session picker: worktree paths the server knows about
4955 * ([projects]) and the server's default working directory ([serverDefault], its cwd).
5056 * [loaded] distinguishes "not fetched yet" from "fetched, none found". */
@@ -64,6 +70,10 @@ data class SessionListState(
6470 val loading: Boolean = true,
6571 val error: String? = null,
6672 val sortMode: SessionSortMode = SessionSortMode.RECENT,
73+ // Primary-key sort direction. RECENT's natural order is descending (newest first),
74+ // TITLE's is ascending (A→Z); this defaults to RECENT's and is reset to each mode's
75+ // natural direction on a mode switch, then flipped by the direction toggle.
76+ val sortDescending: Boolean = true,
6777 val pinnedIds: Set<String> = emptySet(),
6878 val archivedIds: Set<String> = emptySet(),
6979 val showArchived: Boolean = false,
@@ -94,18 +104,24 @@ data class SessionListState(
94104 get() = if (showArchived) 0 else sessions.count { it.id in archivedIds }
95105}
96106
97−/** Sort sessions by the given mode. RECENT sorts by last activity time desc; TITLE by
98− * display title asc (case-insensitive), falling back to recency for ties. */
99−private fun Iterable<Session>.sortedByMode(mode: SessionSortMode): List<Session> = when (mode) {
100− SessionSortMode.RECENT ->
101− sortedByDescending { it.time?.updated ?: it.time?.created ?: 0L }
102− SessionSortMode.TITLE ->
103− // CASE_INSENSITIVE_ORDER avoids allocating a lowercased title per comparison; this
104− // sort re-runs on every debounced list flush during streaming.
105− sortedWith(
106− compareBy<Session, String>(String.CASE_INSENSITIVE_ORDER) { it.displayTitle }
107− .thenByDescending { it.time?.updated ?: it.time?.created ?: 0L },
108− )
107+/** Sort sessions by the given mode and direction. In each mode's *natural* direction,
108+ * RECENT sorts by last activity time desc; TITLE by display title asc (case-insensitive),
109+ * falling back to recency for ties. When [descending] differs from the mode's natural
110+ * direction (RECENT natural = desc, TITLE natural = asc) the whole order is reversed. */
111+private fun Iterable<Session>.sortedByMode(mode: SessionSortMode, descending: Boolean): List<Session> {
112+ val natural = when (mode) {
113+ SessionSortMode.RECENT ->
114+ sortedByDescending { it.time?.updated ?: it.time?.created ?: 0L }
115+ SessionSortMode.TITLE ->
116+ // CASE_INSENSITIVE_ORDER avoids allocating a lowercased title per comparison; this
117+ // sort re-runs on every debounced list flush during streaming.
118+ sortedWith(
119+ compareBy<Session, String>(String.CASE_INSENSITIVE_ORDER) { it.displayTitle }
120+ .thenByDescending { it.time?.updated ?: it.time?.created ?: 0L },
121+ )
122+ }
123+ val naturalDescending = mode == SessionSortMode.RECENT
124+ return if (descending == naturalDescending) natural else natural.reversed()
109125}
110126
111127// Feature-rich session-list VM: many small, cohesive actions (create/delete/undo/rename/
@@ -175,6 +191,15 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
175191 )
176192 val undoEvents: SharedFlow<String> = _undoEvents.asSharedFlow()
177193
194+ /** One-shot events carrying a pin/archive action the user just took, so the UI can show
195+ * a confirmation + Undo. Archiving hides the row from the default view, so undo is the
196+ * only feedback that the action can be reversed. */
197+ private val _sessionActionEvents = MutableSharedFlow<SessionActionEvent>(
198+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
199+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
200+ )
201+ val sessionActionEvents: SharedFlow<SessionActionEvent> = _sessionActionEvents.asSharedFlow()
202+
178203 /** Session awaiting the undo window to expire before its REST delete fires. Holds the
179204 * Session so Undo can restore it. */
180205 // Keyed by session id, not a single field: the undo window lets several deletes be
@@ -233,25 +258,50 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
233258 /** Pin or unpin [session] (pinned sessions sort to the top of the list). */
234259 fun togglePin(session: Session) {
235260 val pin = session.id !in _state.value.pinnedIds
236− // Optimistically update the displayed state so rapid taps toggle correctly instead
237− // of both reading the same stale set (pre-observer-round-trip) and double-pinning.
238− _state.update { it.copy(pinnedIds = if (pin) it.pinnedIds + session.id else it.pinnedIds - session.id) }
239− viewModelScope.launch {
240− runCatchingCancellable { container.sessionPrefsStore.setPinned(session.id, pin) }
241− .onFailure { _transientErrors.tryEmit(container.friendlyError(it)) }
242− }
261+ applyPin(session.id, pin)
262+ _sessionActionEvents.tryEmit(
263+ SessionActionEvent(session.id, if (pin) SessionActionKind.PINNED else SessionActionKind.UNPINNED),
264+ )
243265 }
244266
245267 /** Archive or unarchive [session] (archived sessions are hidden unless "show archived"). */
246268 fun toggleArchive(session: Session) {
247269 val archive = session.id !in _state.value.archivedIds
248− _state.update { it.copy(archivedIds = if (archive) it.archivedIds + session.id else it.archivedIds - session.id) }
270+ applyArchive(session.id, archive)
271+ _sessionActionEvents.tryEmit(
272+ SessionActionEvent(session.id, if (archive) SessionActionKind.ARCHIVED else SessionActionKind.UNARCHIVED),
273+ )
274+ }
275+
276+ // Optimistically update the displayed state so rapid taps toggle correctly instead of
277+ // both reading the same stale set (pre-observer-round-trip) and double-toggling, then
278+ // persist. Shared by the toggle actions and their undo.
279+ private fun applyPin(sessionId: String, pin: Boolean) {
280+ _state.update { it.copy(pinnedIds = if (pin) it.pinnedIds + sessionId else it.pinnedIds - sessionId) }
281+ viewModelScope.launch {
282+ runCatchingCancellable { container.sessionPrefsStore.setPinned(sessionId, pin) }
283+ .onFailure { _transientErrors.tryEmit(container.friendlyError(it)) }
284+ }
285+ }
286+
287+ private fun applyArchive(sessionId: String, archive: Boolean) {
288+ _state.update { it.copy(archivedIds = if (archive) it.archivedIds + sessionId else it.archivedIds - sessionId) }
249289 viewModelScope.launch {
250− runCatchingCancellable { container.sessionPrefsStore.setArchived(session.id, archive) }
290+ runCatchingCancellable { container.sessionPrefsStore.setArchived(sessionId, archive) }
251291 .onFailure { _transientErrors.tryEmit(container.friendlyError(it)) }
252292 }
253293 }
254294
295+ /** Reverse a pin/archive action surfaced via [sessionActionEvents] (Undo snackbar). */
296+ fun undoSessionAction(event: SessionActionEvent) {
297+ when (event.kind) {
298+ SessionActionKind.PINNED -> applyPin(event.sessionId, false)
299+ SessionActionKind.UNPINNED -> applyPin(event.sessionId, true)
300+ SessionActionKind.ARCHIVED -> applyArchive(event.sessionId, false)
301+ SessionActionKind.UNARCHIVED -> applyArchive(event.sessionId, true)
302+ }
303+ }
304+
255305 /** Toggle whether archived sessions are shown in the list. */
256306 fun setShowArchived(show: Boolean) {
257307 _state.update { it.copy(showArchived = show) }
@@ -391,7 +441,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
391441 // new state (and trigger recomposition) when the batch held no real changes
392442 // (e.g. a duplicate SessionUpdated for an unchanged session).
393443 if (changedIds.isEmpty()) return@update s
394− val sorted = byId.values.sortedByMode(s.sortMode)
444+ val sorted = byId.values.sortedByMode(s.sortMode, s.sortDescending)
395445 // If the new ordering matches the existing one (e.g. only the already-newest
396446 // session updated its timestamp), reuse the same list instance so downstream
397447 // skips a recomposition for an identical reference.
@@ -453,7 +503,8 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
453503 // REST delete hasn't fired yet — the server still returns them, and
454504 // re-adding them here would resurrect a just-hidden row (and later
455505 // collide on the LazyColumn id key when undo/restore re-appends it).
456− val sorted = list.filterNot { pendingDeletes.containsKey(it.id) }.sortedByMode(_state.value.sortMode)
506+ val sorted = list.filterNot { pendingDeletes.containsKey(it.id) }
507+ .sortedByMode(_state.value.sortMode, _state.value.sortDescending)
457508 _state.update { it.copy(sessions = sorted, loading = false, error = null) }
458509 loadPreviews(sorted)
459510 }
@@ -640,7 +691,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
640691 // we never produce two rows with the same id.
641692 pendingDeletes.remove(session.id)
642693 _state.update { s ->
643− s.copy(sessions = (s.sessions.filterNot { it.id == session.id } + session).sortedByMode(s.sortMode))
694+ s.copy(sessions = (s.sessions.filterNot { it.id == session.id } + session).sortedByMode(s.sortMode, s.sortDescending))
644695 }
645696 _transientErrors.tryEmit(container.friendlyError(err))
646697 }
@@ -660,13 +711,26 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
660711 val pending = pendingDeletes.remove(sessionId) ?: return
661712 _state.update { s ->
662713 // Dedup by id in case a refresh during the undo window already re-added it.
663− s.copy(sessions = (s.sessions.filterNot { it.id == pending.id } + pending).sortedByMode(s.sortMode))
714+ s.copy(sessions = (s.sessions.filterNot { it.id == pending.id } + pending).sortedByMode(s.sortMode, s.sortDescending))
664715 }
665716 }
666717
667718 fun setSortMode(mode: SessionSortMode) {
668719 if (mode == _state.value.sortMode) return
669− _state.update { s -> s.copy(sortMode = mode, sessions = s.sessions.sortedByMode(mode)) }
720+ // Reset to the new mode's natural direction (RECENT=desc, TITLE=asc) so switching
721+ // modes doesn't inherit a surprising reversed order from the previous mode.
722+ val descending = mode == SessionSortMode.RECENT
723+ _state.update { s ->
724+ s.copy(sortMode = mode, sortDescending = descending, sessions = s.sessions.sortedByMode(mode, descending))
725+ }
726+ }
727+
728+ /** Flip the ascending/descending direction of the current sort mode. */
729+ fun toggleSortDirection() {
730+ _state.update { s ->
731+ val descending = !s.sortDescending
732+ s.copy(sortDescending = descending, sessions = s.sessions.sortedByMode(s.sortMode, descending))
733+ }
670734 }
671735
672736 fun renameSession(session: Session, newTitle: String) {