@@ -45,6 +45,12 @@ import kotlinx.coroutines.sync.withPermit
45/** Sort order for the session list. */
45/** Sort order for the session list. */
46enum class SessionSortMode { RECENT, TITLE }
46enum class SessionSortMode { RECENT, TITLE }
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 }
52+data class SessionActionEvent(val sessionId: String, val kind: SessionActionKind)
48/** Directory options for the new-session picker: worktree paths the server knows about
54/** Directory options for the new-session picker: worktree paths the server knows about
49 * ([projects]) and the server's default working directory ([serverDefault], its cwd).
55 * ([projects]) and the server's default working directory ([serverDefault], its cwd).
50 * [loaded] distinguishes "not fetched yet" from "fetched, none found". */
56 * [loaded] distinguishes "not fetched yet" from "fetched, none found". */
@@ -64,6 +70,10 @@ data class SessionListState(
64 val loading: Boolean = true,
70 val loading: Boolean = true,
65 val error: String? = null,
71 val error: String? = null,
66 val sortMode: SessionSortMode = SessionSortMode.RECENT,
72 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,
67 val pinnedIds: Set<String> = emptySet(),
77 val pinnedIds: Set<String> = emptySet(),
68 val archivedIds: Set<String> = emptySet(),
78 val archivedIds: Set<String> = emptySet(),
69 val showArchived: Boolean = false,
79 val showArchived: Boolean = false,
@@ -94,18 +104,24 @@ data class SessionListState(
94 get() = if (showArchived) 0 else sessions.count { it.id in archivedIds }
104 get() = if (showArchived) 0 else sessions.count { it.id in archivedIds }
107+/** Sort sessions by the given mode and direction. In each mode's *natural* direction,
97−/** Sort sessions by the given mode. RECENT sorts by last activity time desc; TITLE by
108+ * RECENT sorts by last activity time desc; TITLE by display title asc (case-insensitive),
98− * display title asc (case-insensitive), falling back to recency for ties. */
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. */
99−private fun Iterable<Session>.sortedByMode(mode: SessionSortMode): List<Session> = when (mode) {
111+private fun Iterable<Session>.sortedByMode(mode: SessionSortMode, descending: Boolean): List<Session> {
112+ val natural = when (mode) {
100− SessionSortMode.RECENT ->
113+ SessionSortMode.RECENT ->
101− sortedByDescending { it.time?.updated ?: it.time?.created ?: 0L }
114+ sortedByDescending { it.time?.updated ?: it.time?.created ?: 0L }
102− SessionSortMode.TITLE ->
115+ SessionSortMode.TITLE ->
103− // CASE_INSENSITIVE_ORDER avoids allocating a lowercased title per comparison; this
116+ // CASE_INSENSITIVE_ORDER avoids allocating a lowercased title per comparison; this
104− // sort re-runs on every debounced list flush during streaming.
117+ // sort re-runs on every debounced list flush during streaming.
105− sortedWith(
118+ sortedWith(
106− compareBy<Session, String>(String.CASE_INSENSITIVE_ORDER) { it.displayTitle }
119+ compareBy<Session, String>(String.CASE_INSENSITIVE_ORDER) { it.displayTitle }
107− .thenByDescending { it.time?.updated ?: it.time?.created ?: 0L },
120+ .thenByDescending { it.time?.updated ?: it.time?.created ?: 0L },
123+ val naturalDescending = mode == SessionSortMode.RECENT
124+ return if (descending == naturalDescending) natural else natural.reversed()
111// Feature-rich session-list VM: many small, cohesive actions (create/delete/undo/rename/
127// Feature-rich session-list VM: many small, cohesive actions (create/delete/undo/rename/
@@ -175,6 +191,15 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
176 val undoEvents: SharedFlow<String> = _undoEvents.asSharedFlow()
192 val undoEvents: SharedFlow<String> = _undoEvents.asSharedFlow()
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,
201+ val sessionActionEvents: SharedFlow<SessionActionEvent> = _sessionActionEvents.asSharedFlow()
178 /** Session awaiting the undo window to expire before its REST delete fires. Holds the
203 /** Session awaiting the undo window to expire before its REST delete fires. Holds the
179 * Session so Undo can restore it. */
204 * Session so Undo can restore it. */
180 // Keyed by session id, not a single field: the undo window lets several deletes be
205 // 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() {
233 /** Pin or unpin [session] (pinned sessions sort to the top of the list). */
258 /** Pin or unpin [session] (pinned sessions sort to the top of the list). */
234 fun togglePin(session: Session) {
259 fun togglePin(session: Session) {
235 val pin = session.id !in _state.value.pinnedIds
260 val pin = session.id !in _state.value.pinnedIds
236− // Optimistically update the displayed state so rapid taps toggle correctly instead
261+ applyPin(session.id, pin)
237− // of both reading the same stale set (pre-observer-round-trip) and double-pinning.
262+ _sessionActionEvents.tryEmit(
238− _state.update { it.copy(pinnedIds = if (pin) it.pinnedIds + session.id else it.pinnedIds - session.id) }
263+ SessionActionEvent(session.id, if (pin) SessionActionKind.PINNED else SessionActionKind.UNPINNED),
239− viewModelScope.launch {
264+ )
240− runCatchingCancellable { container.sessionPrefsStore.setPinned(session.id, pin) }
241− .onFailure { _transientErrors.tryEmit(container.friendlyError(it)) }
245 /** Archive or unarchive [session] (archived sessions are hidden unless "show archived"). */
267 /** Archive or unarchive [session] (archived sessions are hidden unless "show archived"). */
246 fun toggleArchive(session: Session) {
268 fun toggleArchive(session: Session) {
247 val archive = session.id !in _state.value.archivedIds
269 val archive = session.id !in _state.value.archivedIds
270+ applyArchive(session.id, archive)
271+ _sessionActionEvents.tryEmit(
272+ SessionActionEvent(session.id, if (archive) SessionActionKind.ARCHIVED else SessionActionKind.UNARCHIVED),
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)) }
287+ private fun applyArchive(sessionId: String, archive: Boolean) {
248− _state.update { it.copy(archivedIds = if (archive) it.archivedIds + session.id else it.archivedIds - session.id) }
288+ _state.update { it.copy(archivedIds = if (archive) it.archivedIds + sessionId else it.archivedIds - sessionId) }
249 viewModelScope.launch {
289 viewModelScope.launch {
250− runCatchingCancellable { container.sessionPrefsStore.setArchived(session.id, archive) }
290+ runCatchingCancellable { container.sessionPrefsStore.setArchived(sessionId, archive) }
251 .onFailure { _transientErrors.tryEmit(container.friendlyError(it)) }
291 .onFailure { _transientErrors.tryEmit(container.friendlyError(it)) }
295+ /** Reverse a pin/archive action surfaced via [sessionActionEvents] (Undo snackbar). */
296+ fun undoSessionAction(event: SessionActionEvent) {
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)
255 /** Toggle whether archived sessions are shown in the list. */
305 /** Toggle whether archived sessions are shown in the list. */
256 fun setShowArchived(show: Boolean) {
306 fun setShowArchived(show: Boolean) {
257 _state.update { it.copy(showArchived = show) }
307 _state.update { it.copy(showArchived = show) }
@@ -391,7 +441,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
391 // new state (and trigger recomposition) when the batch held no real changes
441 // new state (and trigger recomposition) when the batch held no real changes
392 // (e.g. a duplicate SessionUpdated for an unchanged session).
442 // (e.g. a duplicate SessionUpdated for an unchanged session).
393 if (changedIds.isEmpty()) return@update s
443 if (changedIds.isEmpty()) return@update s
394− val sorted = byId.values.sortedByMode(s.sortMode)
444+ val sorted = byId.values.sortedByMode(s.sortMode, s.sortDescending)
395 // If the new ordering matches the existing one (e.g. only the already-newest
445 // If the new ordering matches the existing one (e.g. only the already-newest
396 // session updated its timestamp), reuse the same list instance so downstream
446 // session updated its timestamp), reuse the same list instance so downstream
397 // skips a recomposition for an identical reference.
447 // skips a recomposition for an identical reference.
@@ -453,7 +503,8 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
453 // REST delete hasn't fired yet — the server still returns them, and
503 // REST delete hasn't fired yet — the server still returns them, and
454 // re-adding them here would resurrect a just-hidden row (and later
504 // re-adding them here would resurrect a just-hidden row (and later
455 // collide on the LazyColumn id key when undo/restore re-appends it).
505 // 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)
457 _state.update { it.copy(sessions = sorted, loading = false, error = null) }
508 _state.update { it.copy(sessions = sorted, loading = false, error = null) }
458 loadPreviews(sorted)
509 loadPreviews(sorted)
@@ -640,7 +691,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
640 // we never produce two rows with the same id.
691 // we never produce two rows with the same id.
641 pendingDeletes.remove(session.id)
692 pendingDeletes.remove(session.id)
642 _state.update { s ->
693 _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))
645 _transientErrors.tryEmit(container.friendlyError(err))
696 _transientErrors.tryEmit(container.friendlyError(err))
@@ -660,13 +711,26 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
660 val pending = pendingDeletes.remove(sessionId) ?: return
711 val pending = pendingDeletes.remove(sessionId) ?: return
661 _state.update { s ->
712 _state.update { s ->
662 // Dedup by id in case a refresh during the undo window already re-added it.
713 // 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))
667 fun setSortMode(mode: SessionSortMode) {
718 fun setSortMode(mode: SessionSortMode) {
668 if (mode == _state.value.sortMode) return
719 if (mode == _state.value.sortMode) return
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 ->
669− _state.update { s -> s.copy(sortMode = mode, sessions = s.sessions.sortedByMode(mode)) }
724+ s.copy(sortMode = mode, sortDescending = descending, sessions = s.sessions.sortedByMode(mode, descending))
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))
672 fun renameSession(session: Session, newTitle: String) {
736 fun renameSession(session: Session, newTitle: String) {