diff --git a/.gitignore b/.gitignore index 62721c2..3dbd9e1 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,6 @@ companion/oc-pocket/bin/ # oc-pocket release/build artifacts companion/oc-pocket/dist/ + +# Git worktrees +.worktrees/ diff --git a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/IosViewModelOwners.kt b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/IosViewModelOwners.kt index e7fa87c..357c81b 100644 --- a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/IosViewModelOwners.kt +++ b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/IosViewModelOwners.kt @@ -12,9 +12,8 @@ import com.ratulsarna.ocmobile.ui.screen.chat.ChatViewModel import com.ratulsarna.ocmobile.ui.screen.connect.ConnectViewModel import com.ratulsarna.ocmobile.ui.screen.docs.MarkdownFileViewerViewModel import com.ratulsarna.ocmobile.ui.screen.filebrowser.FileBrowserViewModel -import com.ratulsarna.ocmobile.ui.screen.sessions.SessionsViewModel import com.ratulsarna.ocmobile.ui.screen.settings.SettingsViewModel -import com.ratulsarna.ocmobile.ui.screen.workspaces.WorkspacesViewModel +import com.ratulsarna.ocmobile.ui.screen.sidebar.SidebarViewModel /** * iOS-facing ViewModel owners for SwiftUI. @@ -44,8 +43,8 @@ class IosAppViewModelOwner : ViewModelStoreOwner { /** Matches Compose `viewModel(key = "settings") { AppModule.createSettingsViewModel() }` */ fun settingsViewModel(): SettingsViewModel = get(key = "settings") { AppModule.createSettingsViewModel() } - /** Matches Compose `viewModel(key = "workspaces") { AppModule.createWorkspacesViewModel() }` */ - fun workspacesViewModel(): WorkspacesViewModel = get(key = "workspaces") { AppModule.createWorkspacesViewModel() } + /** App-scoped sidebar combining workspaces + sessions. */ + fun sidebarViewModel(): SidebarViewModel = get(key = "sidebar") { AppModule.createSidebarViewModel() } /** App-scoped: used for onboarding/pairing flow. */ fun connectViewModel(): ConnectViewModel = get(key = "connect") { AppModule.createConnectViewModel() } @@ -86,8 +85,6 @@ class IosScreenViewModelOwner : ViewModelStoreOwner { viewModelStore.clear() } - fun sessionsViewModel(): SessionsViewModel = get(key = "sessions") { AppModule.createSessionsViewModel() } - /** Matches Compose `viewModel(key = "markdown-$path-$openId") { AppModule.createMarkdownFileViewerViewModel(path) }` */ fun markdownFileViewerViewModel(path: String, openId: Long): MarkdownFileViewerViewModel = get(key = "markdown-$path-$openId") { AppModule.createMarkdownFileViewerViewModel(path) } diff --git a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/data/api/OpenCodeApi.kt b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/data/api/OpenCodeApi.kt index b408024..f98e47c 100644 --- a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/data/api/OpenCodeApi.kt +++ b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/data/api/OpenCodeApi.kt @@ -37,7 +37,8 @@ interface OpenCodeApi { suspend fun getSessions( search: String? = null, limit: Int? = null, - start: Long? = null + start: Long? = null, + directory: String? = null ): List /** diff --git a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/data/api/OpenCodeApiImpl.kt b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/data/api/OpenCodeApiImpl.kt index 448e59e..5d23129 100644 --- a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/data/api/OpenCodeApiImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/data/api/OpenCodeApiImpl.kt @@ -96,8 +96,13 @@ class OpenCodeApiImpl( return httpClient.get("$baseUrl/session/$sessionId").body() } - override suspend fun getSessions(search: String?, limit: Int?, start: Long?): List { + override suspend fun getSessions(search: String?, limit: Int?, start: Long?, directory: String?): List { return httpClient.get("$baseUrl/session") { + val dir = directory?.trim()?.takeIf { it.isNotBlank() } + if (dir != null) { + headers.remove("x-opencode-directory") + header("x-opencode-directory", dir) + } if (start != null) parameter("start", start) if (!search.isNullOrBlank()) parameter("search", search) if (limit != null) parameter("limit", limit) diff --git a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/data/mock/MockOpenCodeApi.kt b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/data/mock/MockOpenCodeApi.kt index ce6a520..c642c60 100644 --- a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/data/mock/MockOpenCodeApi.kt +++ b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/data/mock/MockOpenCodeApi.kt @@ -137,8 +137,8 @@ class MockOpenCodeApi( override suspend fun getSession(sessionId: String): SessionDto = state.getSession(sessionId) ?: throw RuntimeException("Session not found: $sessionId") - override suspend fun getSessions(search: String?, limit: Int?, start: Long?): List = - state.getSessions(search = search, limit = limit, start = start) + override suspend fun getSessions(search: String?, limit: Int?, start: Long?, directory: String?): List = + state.getSessions(search = search, limit = limit, start = start, directory = directory) override suspend fun createSession(request: CreateSessionRequest): SessionDto { return state.createSession( diff --git a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/data/mock/MockState.kt b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/data/mock/MockState.kt index f24b6ad..b6fd06c 100644 --- a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/data/mock/MockState.kt +++ b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/data/mock/MockState.kt @@ -329,8 +329,12 @@ class MockState { sessions[id] } - suspend fun getSessions(search: String?, limit: Int?, start: Long?): List = mutex.withLock { + suspend fun getSessions(search: String?, limit: Int?, start: Long?, directory: String?): List = mutex.withLock { val filtered = sessions.values.asSequence() + .filter { dto -> + if (directory.isNullOrBlank()) return@filter true + dto.directory == directory + } .filter { dto -> if (start == null) return@filter true dto.time.updated >= start diff --git a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/data/repository/SessionRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/data/repository/SessionRepositoryImpl.kt index b79dcb7..9eb359b 100644 --- a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/data/repository/SessionRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/data/repository/SessionRepositoryImpl.kt @@ -81,9 +81,9 @@ class SessionRepositoryImpl( } } - override suspend fun getSessions(search: String?, limit: Int?, start: Long?): Result> { + override suspend fun getSessions(search: String?, limit: Int?, start: Long?, directory: String?): Result> { return runCatching { - api.getSessions(search = search, limit = limit, start = start).map { it.toDomain() } + api.getSessions(search = search, limit = limit, start = start, directory = directory).map { it.toDomain() } }.recoverCatching { e -> when (e) { is ClientRequestException -> { diff --git a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/di/AppModule.kt b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/di/AppModule.kt index bfdc42d..d81d5b6 100644 --- a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/di/AppModule.kt +++ b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/di/AppModule.kt @@ -35,9 +35,8 @@ import com.ratulsarna.ocmobile.ui.screen.chat.ChatViewModel import com.ratulsarna.ocmobile.ui.screen.docs.MarkdownFileViewerViewModel import com.ratulsarna.ocmobile.ui.screen.filebrowser.FileBrowserViewModel import com.ratulsarna.ocmobile.ui.screen.connect.ConnectViewModel -import com.ratulsarna.ocmobile.ui.screen.sessions.SessionsViewModel import com.ratulsarna.ocmobile.ui.screen.settings.SettingsViewModel -import com.ratulsarna.ocmobile.ui.screen.workspaces.WorkspacesViewModel +import com.ratulsarna.ocmobile.ui.screen.sidebar.SidebarViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -260,8 +259,9 @@ object AppModule { ) } - fun createSessionsViewModel(): SessionsViewModel { - return SessionsViewModel( + fun createSidebarViewModel(): SidebarViewModel { + return SidebarViewModel( + workspaceRepository = graphWorkspaceRepository(), sessionRepository = graphSessionRepository(), appSettings = appSettings ) @@ -286,10 +286,6 @@ object AppModule { ) } - fun createWorkspacesViewModel(): WorkspacesViewModel { - return WorkspacesViewModel(workspaceRepository = graphWorkspaceRepository()) - } - fun createMarkdownFileViewerViewModel(path: String): MarkdownFileViewerViewModel { return MarkdownFileViewerViewModel(path, graphVaultRepository()) } diff --git a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/domain/repository/SessionRepository.kt b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/domain/repository/SessionRepository.kt index e03da99..e7cba58 100644 --- a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/domain/repository/SessionRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/domain/repository/SessionRepository.kt @@ -23,7 +23,8 @@ interface SessionRepository { suspend fun getSessions( search: String? = null, limit: Int? = null, - start: Long? = null + start: Long? = null, + directory: String? = null ): Result> /** diff --git a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/chat/ChatState.kt b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/chat/ChatState.kt index c29432b..f2d42e0 100644 --- a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/chat/ChatState.kt +++ b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/chat/ChatState.kt @@ -14,6 +14,7 @@ import com.ratulsarna.ocmobile.domain.model.VaultEntry */ data class ChatUiState( val currentSessionId: String? = null, + val currentSessionTitle: String? = null, /** If set, the session is in "reverted" state and messages after this point should be hidden. */ val revertMessageId: String? = null, val messages: List = emptyList(), diff --git a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/chat/ChatViewModel.kt b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/chat/ChatViewModel.kt index 7b9363c..11542cf 100644 --- a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/chat/ChatViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/chat/ChatViewModel.kt @@ -156,6 +156,8 @@ class ChatViewModel( ) } + private fun normalizedSessionTitle(title: String?): String? = title?.takeIf(String::isNotBlank) + /** Current selected agent (from persistent storage) */ private var currentAgent: String? = null @@ -1156,6 +1158,7 @@ class ChatViewModel( _uiState.update { it.copy( currentSessionId = null, + currentSessionTitle = null, revertMessageId = null, messages = emptyList(), lastGoodMessageId = null, @@ -1176,6 +1179,7 @@ class ChatViewModel( _uiState.update { it.copy( currentSessionId = newSessionId, + currentSessionTitle = null, revertMessageId = null, messages = emptyList(), lastGoodMessageId = null, @@ -1190,7 +1194,11 @@ class ChatViewModel( val session = sessionRepository.getSession(newSessionId).getOrNull() if (newSessionId == _uiState.value.currentSessionId) { val revertMessageId = session?.revert?.messageId - _uiState.update { state -> applyRevertPointer(state, revertMessageId) } + _uiState.update { state -> + applyRevertPointer(state, revertMessageId).copy( + currentSessionTitle = normalizedSessionTitle(session?.title) + ) + } viewModelScope.launch { contextUsageRepository.updateUsage(_uiState.value.messages) } @@ -1233,7 +1241,13 @@ class ChatViewModel( } OcMobileLog.d(TAG, "loadCurrentSession: REST returned sessionId=$sessionId") - _uiState.update { it.copy(currentSessionId = sessionId, revertMessageId = null) } + _uiState.update { + it.copy( + currentSessionId = sessionId, + currentSessionTitle = null, + revertMessageId = null + ) + } refreshPendingPermissions() // Resolve revert pointer before loading messages to avoid transiently rendering hidden messages, @@ -1241,7 +1255,11 @@ class ChatViewModel( val session = sessionRepository.getSession(sessionId).getOrNull() if (sessionId == _uiState.value.currentSessionId) { val revertMessageId = session?.revert?.messageId - _uiState.update { state -> applyRevertPointer(state, revertMessageId) } + _uiState.update { state -> + applyRevertPointer(state, revertMessageId).copy( + currentSessionTitle = normalizedSessionTitle(session?.title) + ) + } viewModelScope.launch { contextUsageRepository.updateUsage(_uiState.value.messages) } @@ -1767,7 +1785,11 @@ class ChatViewModel( val revertMessageId = event.session.revert?.messageId val previousRevertMessageId = _uiState.value.revertMessageId - _uiState.update { state -> applyRevertPointer(state, revertMessageId) } + _uiState.update { state -> + applyRevertPointer(state, revertMessageId).copy( + currentSessionTitle = normalizedSessionTitle(event.session.title) + ) + } viewModelScope.launch { contextUsageRepository.updateUsage(_uiState.value.messages) } @@ -2102,6 +2124,7 @@ class ChatViewModel( lastGoodMessageId = if (response.error == null) response.id else it.lastGoodMessageId, // Update local state to actual session (handles session cycling) currentSessionId = response.sessionId, + currentSessionTitle = if (response.sessionId == it.currentSessionId) it.currentSessionTitle else null, // After the user resumes, OpenCode cleans up reverted messages and clears the pointer. revertMessageId = null ) @@ -2399,6 +2422,7 @@ class ChatViewModel( _uiState.update { it.copy( currentSessionId = newSession.id, + currentSessionTitle = normalizedSessionTitle(newSession.title), revertMessageId = null, messages = emptyList(), lastGoodMessageId = null, @@ -2576,6 +2600,7 @@ class ChatViewModel( _uiState.update { it.copy( currentSessionId = sessionId, + currentSessionTitle = null, revertMessageId = null, messages = emptyList(), // Clear while loading isLoading = true, @@ -2589,7 +2614,11 @@ class ChatViewModel( val session = sessionRepository.getSession(sessionId).getOrNull() if (sessionId == _uiState.value.currentSessionId) { val revertMessageId = session?.revert?.messageId - _uiState.update { state -> applyRevertPointer(state, revertMessageId) } + _uiState.update { state -> + applyRevertPointer(state, revertMessageId).copy( + currentSessionTitle = normalizedSessionTitle(session?.title) + ) + } viewModelScope.launch { contextUsageRepository.updateUsage(_uiState.value.messages) } diff --git a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModel.kt b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModel.kt deleted file mode 100644 index 33da229..0000000 --- a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModel.kt +++ /dev/null @@ -1,270 +0,0 @@ -package com.ratulsarna.ocmobile.ui.screen.sessions - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.ratulsarna.ocmobile.data.settings.AppSettings -import com.ratulsarna.ocmobile.domain.model.Session -import com.ratulsarna.ocmobile.domain.repository.SessionRepository -import kotlin.time.Clock -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -class SessionsViewModel( - private val sessionRepository: SessionRepository, - private val appSettings: AppSettings, - private val debounceMs: Long = DEFAULT_DEBOUNCE_MS, - private val searchLimit: Int = DEFAULT_SEARCH_LIMIT -) : ViewModel() { - - private val _uiState = MutableStateFlow(SessionsUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - private var searchJob: Job? = null - private var loadJob: Job? = null - private var loadRequestId: Long = 0L - - init { - observeActiveSessionId() - loadSessions(search = null) - } - - /** - * Called when Sessions screen becomes visible. - * Refreshes data if not currently loading (avoids double-fetch when init load is in progress). - */ - fun onScreenVisible() { - val state = _uiState.value - if (!state.isLoading && !state.isSearching) { - refresh() - } - } - - fun refresh() { - val query = _uiState.value.searchQuery.trim().ifBlank { null } - // Cancel any pending debounced search; this refresh is explicit and should run immediately. - searchJob?.cancel() - searchJob = null - loadSessions(search = query) - } - - fun onSearchQueryChanged(query: String) { - // SwiftUI's searchable field may call setters even when the value is unchanged - // (e.g. focus/clear interactions). Avoid triggering a reload in that case. - if (query == _uiState.value.searchQuery) return - - _uiState.update { it.copy(searchQuery = query) } - - searchJob?.cancel() - searchJob = viewModelScope.launch { - delay(debounceMs) - val normalized = _uiState.value.searchQuery.trim() - val effective = normalized.ifBlank { null } - loadSessions(search = effective) - } - } - - fun createNewSession() { - var shouldStart = false - _uiState.update { state -> - if (state.isCreatingSession) { - state - } else { - shouldStart = true - state.copy(isCreatingSession = true, error = null) - } - } - if (!shouldStart) return - - viewModelScope.launch { - sessionRepository.createSession(parentId = null) - .onSuccess { session -> - _uiState.update { - it.copy( - isCreatingSession = false, - newSessionId = session.id - ) - } - } - .onFailure { error -> - _uiState.update { - it.copy( - isCreatingSession = false, - error = error.message ?: "Failed to create session" - ) - } - } - } - } - - fun activateSession(sessionId: String) { - var shouldStart = false - _uiState.update { state -> - if (state.isActivating) { - state - } else { - shouldStart = true - state.copy( - isActivating = true, - activatingSessionId = sessionId, - activationError = null - ) - } - } - if (!shouldStart) return - - viewModelScope.launch { - sessionRepository.updateCurrentSessionId(sessionId) - .onSuccess { - _uiState.update { - it.copy( - isActivating = false, - activatingSessionId = null, - activatedSessionId = sessionId - ) - } - } - .onFailure { error -> - _uiState.update { - it.copy( - isActivating = false, - activatingSessionId = null, - activationError = error.message ?: "Failed to activate session" - ) - } - } - } - } - - fun clearActivation() { - _uiState.update { - it.copy( - isActivating = false, - activatingSessionId = null, - activatedSessionId = null, - activationError = null - ) - } - } - - fun clearNewSession() { - _uiState.update { it.copy(newSessionId = null) } - } - - private fun loadSessions(search: String?) { - val requestId = ++loadRequestId - - // Cancel any previous load so (a) we don't waste network calls and (b) late responses don't - // overwrite results for the latest query. - loadJob?.cancel() - loadJob = viewModelScope.launch { - val isSearch = !search.isNullOrBlank() - _uiState.update { state -> - state.copy( - isLoading = !isSearch, - isSearching = isSearch, - error = null - ) - } - - val start = if (isSearch) null else (Clock.System.now().toEpochMilliseconds() - DEFAULT_RECENT_WINDOW_MS) - val limit = if (isSearch) searchLimit else null - - val result = if (isSearch && !search.isNullOrBlank()) { - // We only display root sessions (parentId == null). When server-side search is - // limited, we may receive many child sessions first, leaving 0 root sessions to show. - // Retry with a larger limit so root sessions remain reachable. - sessionRepository - .getSessions(search = search, limit = searchLimit, start = null) - .mapCatching { sessions -> - val visible = visibleSessions(sessions) - if (visible.size >= searchLimit || sessions.size < searchLimit) { - visible.take(searchLimit) - } else { - val expanded = sessionRepository - .getSessions(search = search, limit = DEFAULT_SEARCH_EXPANDED_LIMIT, start = null) - .getOrThrow() - visibleSessions(expanded).take(searchLimit) - } - } - } else { - sessionRepository.getSessions(search = search, limit = limit, start = start) - .map { sessions -> visibleSessions(sessions) } - } - - result - .onSuccess { sessions -> - if (requestId == loadRequestId) { - _uiState.update { - it.copy( - sessions = sessions, - isLoading = false, - isSearching = false, - error = null - ) - } - } - } - .onFailure { error -> - if (requestId == loadRequestId) { - val message = if (isSearch) { - error.message ?: "Failed to search sessions" - } else { - error.message ?: "Failed to load sessions" - } - _uiState.update { state -> - // Preserve already-loaded sessions so transient failures don't blank the list. - val nextSessions = if (state.sessions.isEmpty()) emptyList() else state.sessions - state.copy( - sessions = nextSessions, - isLoading = false, - isSearching = false, - error = message - ) - } - } - } - } - } - - private fun visibleSessions(sessions: List): List = - sessions - .asSequence() - .filter { it.parentId == null } - .sortedByDescending { it.updatedAt } - .toList() - - private fun observeActiveSessionId() { - viewModelScope.launch { - appSettings.getCurrentSessionId().collect { id -> - _uiState.update { it.copy(activeSessionId = id) } - } - } - } - - companion object { - private const val DEFAULT_DEBOUNCE_MS = 150L - private const val DEFAULT_SEARCH_LIMIT = 30 - private const val DEFAULT_SEARCH_EXPANDED_LIMIT = 200 - private const val DEFAULT_RECENT_WINDOW_MS = 30L * 24L * 60L * 60L * 1000L - } -} - -data class SessionsUiState( - val sessions: List = emptyList(), - val searchQuery: String = "", - val activeSessionId: String? = null, - val isLoading: Boolean = true, - val isSearching: Boolean = false, - val isCreatingSession: Boolean = false, - val isActivating: Boolean = false, - val activatingSessionId: String? = null, - val activatedSessionId: String? = null, - val newSessionId: String? = null, - val error: String? = null, - val activationError: String? = null -) diff --git a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModel.kt b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModel.kt new file mode 100644 index 0000000..c6563a0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModel.kt @@ -0,0 +1,353 @@ +package com.ratulsarna.ocmobile.ui.screen.sidebar + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ratulsarna.ocmobile.data.settings.AppSettings +import com.ratulsarna.ocmobile.domain.model.Session +import com.ratulsarna.ocmobile.domain.model.Workspace +import com.ratulsarna.ocmobile.domain.repository.SessionRepository +import com.ratulsarna.ocmobile.domain.repository.WorkspaceRepository +import com.ratulsarna.ocmobile.util.OcMobileLog +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlin.time.Clock + +private const val TAG = "SidebarVM" +private const val DEFAULT_RECENT_WINDOW_MS = 30L * 24L * 60L * 60L * 1000L + +class SidebarViewModel( + private val workspaceRepository: WorkspaceRepository, + private val sessionRepository: SessionRepository, + private val appSettings: AppSettings +) : ViewModel() { + + private val _uiState = MutableStateFlow(SidebarUiState()) + private val loadingWorkspaceIds = mutableSetOf() + val uiState: StateFlow = _uiState.asStateFlow() + + init { + ensureInitialized() + observeWorkspaces() + observeActiveSessionId() + } + + private fun ensureInitialized() { + viewModelScope.launch { + workspaceRepository.ensureInitialized() + .onSuccess { + workspaceRepository.refresh() + .onFailure { error -> + OcMobileLog.w(TAG, "Failed to refresh workspaces after initialization: ${error.message}") + } + } + .onFailure { error -> + OcMobileLog.w(TAG, "Failed to initialize workspaces: ${error.message}") + } + } + } + + private fun observeWorkspaces() { + viewModelScope.launch { + combine( + workspaceRepository.getWorkspaces(), + workspaceRepository.getActiveWorkspace() + ) { workspaces, active -> + workspaces to active + }.collect { (workspaces, active) -> + _uiState.update { current -> + current.copy( + activeWorkspaceId = active?.projectId, + workspaces = workspaces.map { incoming -> + val existing = current.workspaces.find { w -> w.workspace.projectId == incoming.projectId } + existing?.copy(workspace = incoming) ?: WorkspaceWithSessions(workspace = incoming) + } + ) + } + + active?.projectId?.let(::loadSessionsForWorkspace) + } + } + } + + private fun observeActiveSessionId() { + viewModelScope.launch { + appSettings.getCurrentSessionId().collect { id -> + _uiState.update { + it.copy( + activeSessionId = id, + activeSessionTitle = null + ) + } + + loadActiveSessionTitle(sessionId = id) + } + } + } + + private fun loadActiveSessionTitle(sessionId: String?) { + if (sessionId.isNullOrBlank()) { + _uiState.update { it.copy(activeSessionTitle = null) } + return + } + + viewModelScope.launch { + sessionRepository.getSession(sessionId) + .onSuccess { session -> + if (sessionId != _uiState.value.activeSessionId) return@onSuccess + + _uiState.update { + it.copy(activeSessionTitle = session.title?.takeIf(String::isNotBlank)) + } + } + .onFailure { error -> + OcMobileLog.w(TAG, "Failed to load active session title for $sessionId: ${error.message}") + } + } + } + + fun loadSessionsForWorkspace(projectId: String) { + val workspace = _uiState.value.workspaces.find { it.workspace.projectId == projectId } ?: return + if (!loadingWorkspaceIds.add(projectId)) return + + // If sessions are already cached, show them immediately and refresh in background. + // Only show loading indicator on first fetch (no cached sessions). + val hasCachedSessions = workspace.sessions.isNotEmpty() + if (!hasCachedSessions) { + _uiState.update { state -> + state.copy(workspaces = state.workspaces.map { + if (it.workspace.projectId == projectId) { + it.copy(isLoading = true, error = null) + } else { + it + } + }) + } + } + + viewModelScope.launch { + try { + val start = Clock.System.now().toEpochMilliseconds() - DEFAULT_RECENT_WINDOW_MS + sessionRepository.getSessions( + search = null, + limit = null, + start = start, + directory = workspace.workspace.worktree + ) + .onSuccess { allSessions -> + val filtered = allSessions + .filter { it.parentId == null && it.directory == workspace.workspace.worktree } + .sortedByDescending { it.updatedAt } + _uiState.update { state -> + state.copy(workspaces = state.workspaces.map { + if (it.workspace.projectId == projectId) { + it.copy(sessions = filtered, isLoading = false, error = null) + } else it + }) + } + } + .onFailure { error -> + OcMobileLog.w(TAG, "Failed to load sessions for $projectId: ${error.message}") + _uiState.update { state -> + state.copy(workspaces = state.workspaces.map { + if (it.workspace.projectId == projectId) { + it.copy( + isLoading = false, + error = error.message ?: "Failed to load sessions" + ) + } else it + }) + } + } + } finally { + loadingWorkspaceIds.remove(projectId) + } + } + } + + fun switchSession(sessionId: String) { + if (_uiState.value.isSwitchingSession) return + + _uiState.update { + it.copy( + isSwitchingSession = true, + operationErrorMessage = null, + switchedSessionId = null + ) + } + + viewModelScope.launch { + sessionRepository.updateCurrentSessionId(sessionId) + .onSuccess { + _uiState.update { + it.copy( + isSwitchingSession = false, + switchedSessionId = sessionId + ) + } + } + .onFailure { error -> + OcMobileLog.w(TAG, "Failed to switch session: ${error.message}") + _uiState.update { + it.copy( + isSwitchingSession = false, + operationErrorMessage = error.message ?: "Failed to switch session" + ) + } + } + } + } + + fun switchWorkspace(projectId: String, sessionId: String?) { + if (_uiState.value.isSwitchingWorkspace) return + + _uiState.update { it.copy(isSwitchingWorkspace = true, operationErrorMessage = null) } + + viewModelScope.launch { + workspaceRepository.activateWorkspace(projectId) + .onSuccess { + if (sessionId != null) { + sessionRepository.updateCurrentSessionId(sessionId) + .onSuccess { + _uiState.update { + it.copy(isSwitchingWorkspace = false, switchedWorkspaceId = projectId) + } + } + .onFailure { error -> + OcMobileLog.w(TAG, "Failed to persist session $sessionId for workspace $projectId: ${error.message}") + _uiState.update { + it.copy( + isSwitchingWorkspace = false, + operationErrorMessage = error.message ?: "Failed to open session" + ) + } + } + } else { + _uiState.update { it.copy(isSwitchingWorkspace = false, switchedWorkspaceId = projectId) } + } + } + .onFailure { error -> + OcMobileLog.w(TAG, "Failed to switch workspace: ${error.message}") + _uiState.update { + it.copy( + isSwitchingWorkspace = false, + operationErrorMessage = error.message ?: "Failed to switch workspace" + ) + } + } + } + } + + fun clearWorkspaceSwitch() { + _uiState.update { it.copy(switchedWorkspaceId = null) } + } + + fun clearSwitchedSession() { + _uiState.update { it.copy(switchedSessionId = null) } + } + + fun clearOperationError() { + _uiState.update { it.copy(operationErrorMessage = null) } + } + + fun createSession(workspaceProjectId: String) { + if (_uiState.value.isCreatingSession) return + _uiState.update { it.copy(isCreatingSession = true, operationErrorMessage = null) } + + viewModelScope.launch { + val isActiveWorkspace = workspaceProjectId == _uiState.value.activeWorkspaceId + + if (!isActiveWorkspace) { + workspaceRepository.activateWorkspace(workspaceProjectId) + .onFailure { error -> + OcMobileLog.w(TAG, "Failed to switch workspace for new session: ${error.message}") + _uiState.update { + it.copy( + isCreatingSession = false, + operationErrorMessage = error.message ?: "Failed to switch workspace" + ) + } + return@launch + } + } + + sessionRepository.createSession(parentId = null) + .onSuccess { session -> + _uiState.update { it.copy( + isCreatingSession = false, + createdSessionId = session.id, + switchedWorkspaceId = if (!isActiveWorkspace) workspaceProjectId else null + ) } + } + .onFailure { error -> + OcMobileLog.w(TAG, "Failed to create session: ${error.message}") + _uiState.update { + it.copy( + isCreatingSession = false, + operationErrorMessage = error.message ?: "Failed to create session" + ) + } + } + } + } + + fun clearCreatedSession() { + _uiState.update { it.copy(createdSessionId = null) } + } + + fun addWorkspace(directoryInput: String) { + if (_uiState.value.isCreatingWorkspace) return + _uiState.update { it.copy(isCreatingWorkspace = true, workspaceCreationError = null) } + + viewModelScope.launch { + workspaceRepository.addWorkspace(directoryInput) + .onSuccess { + _uiState.update { it.copy(isCreatingWorkspace = false, workspaceCreationError = null) } + } + .onFailure { error -> + OcMobileLog.w(TAG, "Failed to add workspace: ${error.message}") + _uiState.update { + it.copy( + isCreatingWorkspace = false, + workspaceCreationError = error.message ?: "Failed to add workspace" + ) + } + } + } + } + + fun refresh() { + viewModelScope.launch { + workspaceRepository.refresh() + .onFailure { error -> + OcMobileLog.w(TAG, "Failed to refresh workspaces: ${error.message}") + } + } + } +} + +data class SidebarUiState( + val workspaces: List = emptyList(), + val activeWorkspaceId: String? = null, + val activeSessionId: String? = null, + val activeSessionTitle: String? = null, + val isCreatingSession: Boolean = false, + val isCreatingWorkspace: Boolean = false, + val workspaceCreationError: String? = null, + val operationErrorMessage: String? = null, + val isSwitchingSession: Boolean = false, + val isSwitchingWorkspace: Boolean = false, + val switchedSessionId: String? = null, + val switchedWorkspaceId: String? = null, + val createdSessionId: String? = null +) + +data class WorkspaceWithSessions( + val workspace: Workspace, + val sessions: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null +) diff --git a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/workspaces/WorkspacesViewModel.kt b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/workspaces/WorkspacesViewModel.kt deleted file mode 100644 index 745ec33..0000000 --- a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/workspaces/WorkspacesViewModel.kt +++ /dev/null @@ -1,155 +0,0 @@ -package com.ratulsarna.ocmobile.ui.screen.workspaces - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.ratulsarna.ocmobile.domain.model.Workspace -import com.ratulsarna.ocmobile.domain.repository.WorkspaceRepository -import com.ratulsarna.ocmobile.util.OcMobileLog -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -private const val TAG = "WorkspacesVM" - -class WorkspacesViewModel( - private val workspaceRepository: WorkspaceRepository -) : ViewModel() { - - private val _uiState = MutableStateFlow(WorkspacesUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - init { - ensureInitialized() - observeWorkspaces() - } - - private fun ensureInitialized() { - viewModelScope.launch { - workspaceRepository.ensureInitialized() - .onFailure { error -> - OcMobileLog.w(TAG, "Failed to initialize workspaces: ${error.message}") - _uiState.update { it.copy(error = error.message ?: "Failed to initialize workspaces") } - } - } - } - - private fun observeWorkspaces() { - viewModelScope.launch { - combine( - workspaceRepository.getWorkspaces(), - workspaceRepository.getActiveWorkspace() - ) { workspaces, active -> - workspaces to active - }.collect { (workspaces, active) -> - _uiState.update { - it.copy( - workspaces = workspaces, - activeProjectId = active?.projectId, - error = null - ) - } - } - } - } - - fun refresh() { - if (_uiState.value.isRefreshing) return - _uiState.update { it.copy(isRefreshing = true, error = null) } - viewModelScope.launch { - workspaceRepository.refresh() - .onSuccess { - _uiState.update { it.copy(isRefreshing = false, error = null) } - } - .onFailure { error -> - OcMobileLog.w(TAG, "Failed to refresh workspaces: ${error.message}") - _uiState.update { it.copy(isRefreshing = false, error = error.message ?: "Failed to refresh workspaces") } - } - } - } - - fun addWorkspace(directoryInput: String) { - var shouldStart = false - _uiState.update { state -> - if (state.isSaving) { - state - } else { - shouldStart = true - state.copy(isSaving = true, error = null) - } - } - if (!shouldStart) return - - viewModelScope.launch { - workspaceRepository.addWorkspace(directoryInput) - .onSuccess { - _uiState.update { it.copy(isSaving = false, error = null) } - } - .onFailure { error -> - OcMobileLog.w(TAG, "Failed to add workspace: ${error.message}") - _uiState.update { it.copy(isSaving = false, error = error.message ?: "Failed to add workspace") } - } - } - } - - fun activateWorkspace(projectId: String) { - var shouldStart = false - _uiState.update { state -> - if (state.isActivating) { - state - } else { - shouldStart = true - state.copy(isActivating = true, activatingProjectId = projectId, activationError = null) - } - } - if (!shouldStart) return - - viewModelScope.launch { - workspaceRepository.activateWorkspace(projectId) - .onSuccess { - _uiState.update { - it.copy( - isActivating = false, - activatingProjectId = null, - activatedProjectId = projectId - ) - } - } - .onFailure { error -> - OcMobileLog.w(TAG, "Failed to activate workspace: ${error.message}") - _uiState.update { - it.copy( - isActivating = false, - activatingProjectId = null, - activationError = error.message ?: "Failed to switch workspace" - ) - } - } - } - } - - fun clearActivation() { - _uiState.update { - it.copy( - isActivating = false, - activatingProjectId = null, - activatedProjectId = null, - activationError = null - ) - } - } -} - -data class WorkspacesUiState( - val workspaces: List = emptyList(), - val activeProjectId: String? = null, - val isRefreshing: Boolean = false, - val isSaving: Boolean = false, - val isActivating: Boolean = false, - val activatingProjectId: String? = null, - val activatedProjectId: String? = null, - val error: String? = null, - val activationError: String? = null -) diff --git a/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/data/repository/AgentRepositoryDefaultSelectionTest.kt b/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/data/repository/AgentRepositoryDefaultSelectionTest.kt index 73e21ea..8cbe223 100644 --- a/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/data/repository/AgentRepositoryDefaultSelectionTest.kt +++ b/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/data/repository/AgentRepositoryDefaultSelectionTest.kt @@ -57,7 +57,7 @@ class AgentRepositoryDefaultSelectionTest { override suspend fun sendMessage(sessionId: String, request: SendMessageRequest): SendMessageResponse = TODO() override suspend fun getMessages(sessionId: String, limit: Int?, reverse: Boolean?): List = TODO() override suspend fun getSession(sessionId: String): SessionDto = TODO() - override suspend fun getSessions(search: String?, limit: Int?, start: Long?): List = TODO() + override suspend fun getSessions(search: String?, limit: Int?, start: Long?, directory: String?): List = TODO() override suspend fun createSession(request: CreateSessionRequest): SessionDto = TODO() override suspend fun forkSession(sessionId: String, request: ForkSessionRequest): SessionDto = TODO() override suspend fun revertSession(sessionId: String, request: RevertSessionRequest): SessionDto = TODO() @@ -77,4 +77,3 @@ class AgentRepositoryDefaultSelectionTest { override suspend fun sendCommand(sessionId: String, request: SendCommandRequest): SendMessageResponse = TODO() } } - diff --git a/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/data/repository/ModelRepositoryDefaultSelectionTest.kt b/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/data/repository/ModelRepositoryDefaultSelectionTest.kt index 3ff2379..a52aff5 100644 --- a/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/data/repository/ModelRepositoryDefaultSelectionTest.kt +++ b/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/data/repository/ModelRepositoryDefaultSelectionTest.kt @@ -98,7 +98,7 @@ class ModelRepositoryDefaultSelectionTest { override suspend fun sendMessage(sessionId: String, request: SendMessageRequest): SendMessageResponse = TODO() override suspend fun getMessages(sessionId: String, limit: Int?, reverse: Boolean?): List = TODO() override suspend fun getSession(sessionId: String): SessionDto = TODO() - override suspend fun getSessions(search: String?, limit: Int?, start: Long?): List = TODO() + override suspend fun getSessions(search: String?, limit: Int?, start: Long?, directory: String?): List = TODO() override suspend fun createSession(request: CreateSessionRequest): SessionDto = TODO() override suspend fun forkSession(sessionId: String, request: ForkSessionRequest): SessionDto = TODO() override suspend fun revertSession(sessionId: String, request: RevertSessionRequest): SessionDto = TODO() @@ -117,4 +117,3 @@ class ModelRepositoryDefaultSelectionTest { override suspend fun sendCommand(sessionId: String, request: SendCommandRequest): SendMessageResponse = TODO() } } - diff --git a/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/domain/repository/WorkspaceRepositoryTest.kt b/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/domain/repository/WorkspaceRepositoryTest.kt index 96a60b3..b28a43f 100644 --- a/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/domain/repository/WorkspaceRepositoryTest.kt +++ b/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/domain/repository/WorkspaceRepositoryTest.kt @@ -86,7 +86,7 @@ class WorkspaceRepositoryTest { override suspend fun sendMessage(sessionId: String, request: SendMessageRequest): SendMessageResponse = TODO() override suspend fun getMessages(sessionId: String, limit: Int?, reverse: Boolean?): List = TODO() override suspend fun getSession(sessionId: String): SessionDto = TODO() - override suspend fun getSessions(search: String?, limit: Int?, start: Long?): List = TODO() + override suspend fun getSessions(search: String?, limit: Int?, start: Long?, directory: String?): List = TODO() override suspend fun createSession(request: CreateSessionRequest): SessionDto = TODO() override suspend fun forkSession(sessionId: String, request: ForkSessionRequest): SessionDto = TODO() override suspend fun revertSession(sessionId: String, request: RevertSessionRequest): SessionDto = TODO() @@ -111,7 +111,7 @@ class WorkspaceRepositoryTest { override suspend fun sendMessage(sessionId: String, request: SendMessageRequest): SendMessageResponse = TODO() override suspend fun getMessages(sessionId: String, limit: Int?, reverse: Boolean?): List = TODO() override suspend fun getSession(sessionId: String): SessionDto = TODO() - override suspend fun getSessions(search: String?, limit: Int?, start: Long?): List = TODO() + override suspend fun getSessions(search: String?, limit: Int?, start: Long?, directory: String?): List = TODO() override suspend fun createSession(request: CreateSessionRequest): SessionDto = TODO() override suspend fun forkSession(sessionId: String, request: ForkSessionRequest): SessionDto = TODO() override suspend fun revertSession(sessionId: String, request: RevertSessionRequest): SessionDto = TODO() diff --git a/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/chat/ChatViewModelBootstrapTest.kt b/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/chat/ChatViewModelBootstrapTest.kt index 1df4afe..7a8d197 100644 --- a/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/chat/ChatViewModelBootstrapTest.kt +++ b/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/chat/ChatViewModelBootstrapTest.kt @@ -101,7 +101,7 @@ private class FakeSessionRepository : SessionRepository { ) ) - override suspend fun getSessions(search: String?, limit: Int?, start: Long?): Result> = + override suspend fun getSessions(search: String?, limit: Int?, start: Long?, directory: String?): Result> = Result.success(emptyList()) override suspend fun createSession(title: String?, parentId: String?): Result = diff --git a/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/chat/ChatViewModelSendDefaultsTest.kt b/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/chat/ChatViewModelSendDefaultsTest.kt index 7c6b736..92de8cb 100644 --- a/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/chat/ChatViewModelSendDefaultsTest.kt +++ b/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/chat/ChatViewModelSendDefaultsTest.kt @@ -103,7 +103,7 @@ private class FixedSessionRepository : SessionRepository { ) ) - override suspend fun getSessions(search: String?, limit: Int?, start: Long?): Result> = + override suspend fun getSessions(search: String?, limit: Int?, start: Long?, directory: String?): Result> = Result.success(emptyList()) override suspend fun createSession(title: String?, parentId: String?): Result = diff --git a/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModelTest.kt b/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModelTest.kt deleted file mode 100644 index 14708b9..0000000 --- a/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModelTest.kt +++ /dev/null @@ -1,296 +0,0 @@ -package com.ratulsarna.ocmobile.ui.screen.sessions - -import com.ratulsarna.ocmobile.data.mock.MockAppSettings -import com.ratulsarna.ocmobile.domain.model.Session -import com.ratulsarna.ocmobile.domain.repository.SessionRepository -import com.ratulsarna.ocmobile.testing.MainDispatcherRule -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import kotlinx.datetime.Instant -import org.junit.Rule - -@OptIn(ExperimentalCoroutinesApi::class) -class SessionsViewModelTest { - - private val dispatcher = StandardTestDispatcher() - - @get:Rule - val mainDispatcherRule = MainDispatcherRule(dispatcher) - - @Test - fun SessionsViewModel_filtersRootSessionsAndSortsByUpdatedDesc() = runTest(dispatcher) { - val appSettings = MockAppSettings() - - val sessions = listOf( - session(id = "ses-root-old", parentId = null, updatedAtMs = 100), - session(id = "ses-child-new", parentId = "ses-root-old", updatedAtMs = 400), - session(id = "ses-root-new", parentId = null, updatedAtMs = 300) - ) - - val repo = FakeSessionRepository( - getSessionsHandler = { _, _, _ -> Result.success(sessions) } - ) - - val vm = SessionsViewModel( - sessionRepository = repo, - appSettings = appSettings, - debounceMs = 150L, - searchLimit = 30 - ) - - advanceUntilIdle() - - val visible = vm.uiState.value.sessions - assertEquals(listOf("ses-root-new", "ses-root-old"), visible.map { it.id }) - assertTrue(visible.all { it.parentId == null }) - } - - @Test - fun SessionsViewModel_searchDebouncesAndUsesServerSearchLimit() = runTest(dispatcher) { - val appSettings = MockAppSettings() - val calls = mutableListOf>() - - val repo = FakeSessionRepository( - getSessionsHandler = { search, limit, start -> - calls.add(Triple(search, limit, start)) - Result.success(emptyList()) - } - ) - - val vm = SessionsViewModel( - sessionRepository = repo, - appSettings = appSettings, - debounceMs = 150L, - searchLimit = 30 - ) - advanceUntilIdle() - calls.clear() // Ignore init load for this test - - vm.onSearchQueryChanged("foo") - advanceTimeBy(149) - assertEquals(0, calls.size) - - advanceTimeBy(1) - advanceUntilIdle() - - assertEquals(1, calls.size) - assertEquals("foo", calls.single().first) - assertEquals(30, calls.single().second) - - vm.onSearchQueryChanged("") - advanceTimeBy(150) - advanceUntilIdle() - - assertEquals(2, calls.size) - assertEquals(null, calls.last().first) - } - - @Test - fun SessionsViewModel_activateSessionUpdatesActiveSessionIdAndEmitsSuccess() = runTest(dispatcher) { - val appSettings = MockAppSettings() - - val repo = FakeSessionRepository( - getSessionsHandler = { _, _, _ -> Result.success(emptyList()) }, - updateCurrentSessionIdHandler = { Result.success(Unit) } - ) - - val vm = SessionsViewModel( - sessionRepository = repo, - appSettings = appSettings, - debounceMs = 150L, - searchLimit = 30 - ) - advanceUntilIdle() - - vm.activateSession("ses-123") - assertEquals("ses-123", vm.uiState.value.activatingSessionId) - - advanceUntilIdle() - - assertEquals("ses-123", vm.uiState.value.activatedSessionId) - assertEquals(null, vm.uiState.value.activatingSessionId) - } - - @Test - fun SessionsViewModel_createNewSessionPersistsAndEmitsNewSessionId() = runTest(dispatcher) { - val appSettings = MockAppSettings() - - val repo = FakeSessionRepository( - getSessionsHandler = { _, _, _ -> Result.success(emptyList()) }, - createSessionHandler = { Result.success(session(id = "ses-new", parentId = null, updatedAtMs = 1)) } - ) - - val vm = SessionsViewModel( - sessionRepository = repo, - appSettings = appSettings, - debounceMs = 150L, - searchLimit = 30 - ) - advanceUntilIdle() - - vm.createNewSession() - advanceUntilIdle() - - assertEquals("ses-new", vm.uiState.value.newSessionId) - } - - @Test - fun SessionsViewModel_preservesSessionsOnLoadFailure() = runTest(dispatcher) { - val appSettings = MockAppSettings() - - val sessions = listOf( - session(id = "ses-a", parentId = null, updatedAtMs = 100), - session(id = "ses-b", parentId = null, updatedAtMs = 200) - ) - - var callCount = 0 - val repo = FakeSessionRepository( - getSessionsHandler = { _, _, _ -> - callCount += 1 - if (callCount == 1) { - Result.success(sessions) - } else { - Result.failure(RuntimeException("boom")) - } - } - ) - - val vm = SessionsViewModel( - sessionRepository = repo, - appSettings = appSettings, - debounceMs = 150L, - searchLimit = 30 - ) - advanceUntilIdle() - - assertEquals(2, vm.uiState.value.sessions.size) - - vm.refresh() - advanceUntilIdle() - - assertEquals(2, vm.uiState.value.sessions.size) - assertEquals("boom", vm.uiState.value.error) - } - - @Test - fun SessionsViewModel_searchRetriesWithLargerLimitWhenOnlyChildSessionsReturned() = runTest(dispatcher) { - val appSettings = MockAppSettings() - val calls = mutableListOf>() - - val children = (1..30).map { idx -> - session(id = "ses-child-$idx", parentId = "ses-root", updatedAtMs = idx.toLong()) - } - val roots = listOf(session(id = "ses-root-match", parentId = null, updatedAtMs = 999)) - - val repo = FakeSessionRepository( - getSessionsHandler = { search, limit, start -> - calls.add(Triple(search, limit, start)) - if (search.isNullOrBlank()) return@FakeSessionRepository Result.success(emptyList()) - if (limit == 30) return@FakeSessionRepository Result.success(children) - if (limit == 200) return@FakeSessionRepository Result.success(children + roots) - Result.success(emptyList()) - } - ) - - val vm = SessionsViewModel( - sessionRepository = repo, - appSettings = appSettings, - debounceMs = 150L, - searchLimit = 30 - ) - advanceUntilIdle() - calls.clear() - - vm.onSearchQueryChanged("match") - advanceTimeBy(150) - advanceUntilIdle() - - assertEquals(listOf(30, 200), calls.map { it.second }) - assertEquals(listOf("ses-root-match"), vm.uiState.value.sessions.map { it.id }) - } - - @Test - fun SessionsViewModel_searchExpandsWhenRootsAreTruncatedByChildSessions() = runTest(dispatcher) { - val appSettings = MockAppSettings() - val calls = mutableListOf>() - - val rootsFirstPage = (1..5).map { idx -> - session(id = "ses-root-$idx", parentId = null, updatedAtMs = (500 + idx).toLong()) - } - val children = (1..25).map { idx -> - session(id = "ses-child-$idx", parentId = "ses-root-1", updatedAtMs = idx.toLong()) - } - val extraRoots = (6..12).map { idx -> - session(id = "ses-root-$idx", parentId = null, updatedAtMs = (600 + idx).toLong()) - } - - val repo = FakeSessionRepository( - getSessionsHandler = { search, limit, start -> - calls.add(Triple(search, limit, start)) - if (search.isNullOrBlank()) return@FakeSessionRepository Result.success(emptyList()) - if (limit == 30) return@FakeSessionRepository Result.success(children + rootsFirstPage) - if (limit == 200) return@FakeSessionRepository Result.success(children + rootsFirstPage + extraRoots) - Result.success(emptyList()) - } - ) - - val vm = SessionsViewModel( - sessionRepository = repo, - appSettings = appSettings, - debounceMs = 150L, - searchLimit = 30 - ) - advanceUntilIdle() - calls.clear() - - vm.onSearchQueryChanged("root") - advanceTimeBy(150) - advanceUntilIdle() - - assertEquals(listOf(30, 200), calls.map { it.second }) - val ids = vm.uiState.value.sessions.map { it.id } - // We should see more than the 5 root sessions available in the first page after expansion. - assertTrue(ids.size > 5) - assertTrue(ids.all { it.startsWith("ses-root-") }) - } - - private fun session(id: String, parentId: String?, updatedAtMs: Long): Session { - val instant = Instant.fromEpochMilliseconds(updatedAtMs) - return Session( - id = id, - directory = "/mock", - title = id, - createdAt = instant, - updatedAt = instant, - parentId = parentId - ) - } - - private class FakeSessionRepository( - private val getSessionsHandler: suspend (search: String?, limit: Int?, start: Long?) -> Result>, - private val createSessionHandler: suspend () -> Result = { error("createSession not configured") }, - private val updateCurrentSessionIdHandler: suspend (String) -> Result = { error("updateCurrentSessionId not configured") } - ) : SessionRepository { - override suspend fun getCurrentSessionId(): Result = error("not used") - override suspend fun getSession(sessionId: String): Result = error("not used") - - override suspend fun getSessions(search: String?, limit: Int?, start: Long?): Result> = - getSessionsHandler(search, limit, start) - - override suspend fun createSession(title: String?, parentId: String?): Result { - return createSessionHandler() - } - - override suspend fun forkSession(sessionId: String, messageId: String?): Result = error("not used") - override suspend fun revertSession(sessionId: String, messageId: String): Result = error("not used") - override suspend fun updateCurrentSessionId(sessionId: String): Result = updateCurrentSessionIdHandler(sessionId) - override suspend fun abortSession(sessionId: String): Result = error("not used") - } -} diff --git a/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModelTest.kt b/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModelTest.kt new file mode 100644 index 0000000..858d3ef --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModelTest.kt @@ -0,0 +1,603 @@ +package com.ratulsarna.ocmobile.ui.screen.sidebar + +import com.ratulsarna.ocmobile.data.mock.MockAppSettings +import com.ratulsarna.ocmobile.domain.model.Session +import com.ratulsarna.ocmobile.domain.model.Workspace +import com.ratulsarna.ocmobile.domain.repository.SessionRepository +import com.ratulsarna.ocmobile.domain.repository.WorkspaceRepository +import com.ratulsarna.ocmobile.testing.MainDispatcherRule +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant +import org.junit.Rule + +@OptIn(ExperimentalCoroutinesApi::class) +class SidebarViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule(dispatcher) + + @Test + fun SidebarViewModel_observesWorkspacesAndActiveWorkspace() = runTest(dispatcher) { + val workspace1 = workspace("proj-1", "/path/to/project-a") + val workspace2 = workspace("proj-2", "/path/to/project-b") + val repo = FakeWorkspaceRepository( + workspaces = listOf(workspace1, workspace2), + activeWorkspace = workspace1 + ) + val sessionRepo = FakeSessionRepository() + val appSettings = MockAppSettings() + + val vm = SidebarViewModel( + workspaceRepository = repo, + sessionRepository = sessionRepo, + appSettings = appSettings + ) + advanceUntilIdle() + + val state = vm.uiState.value + assertEquals(2, state.workspaces.size) + assertEquals("proj-1", state.activeWorkspaceId) + assertEquals("proj-1", state.workspaces[0].workspace.projectId) + assertEquals("proj-2", state.workspaces[1].workspace.projectId) + } + + @Test + fun SidebarViewModel_loadSessionsForWorkspaceFiltersAndSortsByUpdatedDesc() = runTest(dispatcher) { + val workspace1 = workspace("proj-1", "/path/to/project-a") + val repo = FakeWorkspaceRepository( + workspaces = listOf(workspace1), + activeWorkspace = workspace1 + ) + val sessions = listOf( + session("ses-1", "/path/to/project-a", updatedAtMs = 100), + session("ses-2", "/path/to/project-a", updatedAtMs = 300), + session("ses-3", "/path/to/project-b", updatedAtMs = 200), + session("ses-child", "/path/to/project-a", updatedAtMs = 400, parentId = "ses-1") + ) + val sessionRepo = FakeSessionRepository(sessions = sessions) + val appSettings = MockAppSettings() + + val vm = SidebarViewModel( + workspaceRepository = repo, + sessionRepository = sessionRepo, + appSettings = appSettings + ) + advanceUntilIdle() + + vm.loadSessionsForWorkspace("proj-1") + advanceUntilIdle() + + val loaded = vm.uiState.value.workspaces.first().sessions + assertEquals(listOf("ses-2", "ses-1"), loaded.map { it.id }) + } + + @Test + fun SidebarViewModel_loadSessionsForWorkspaceUsesWorkspaceDirectory() = runTest(dispatcher) { + val workspace1 = workspace("proj-1", "/path/to/project-a") + val workspace2 = workspace("proj-2", "/path/to/project-b") + val requestedDirectories = mutableListOf() + val repo = FakeWorkspaceRepository( + workspaces = listOf(workspace1, workspace2), + activeWorkspace = workspace1 + ) + val sessionRepo = FakeSessionRepository( + getSessionsHandler = { _, _, _, directory -> + requestedDirectories.add(directory) + Result.success( + listOf( + session("ses-b", "/path/to/project-b", updatedAtMs = 200), + session("ses-a", "/path/to/project-a", updatedAtMs = 100) + ) + ) + } + ) + val appSettings = MockAppSettings() + + val vm = SidebarViewModel( + workspaceRepository = repo, + sessionRepository = sessionRepo, + appSettings = appSettings + ) + advanceUntilIdle() + + vm.loadSessionsForWorkspace("proj-2") + advanceUntilIdle() + + val loaded = vm.uiState.value.workspaces.first { it.workspace.projectId == "proj-2" } + assertEquals(listOf("/path/to/project-a", "/path/to/project-b"), requestedDirectories) + assertEquals(listOf("ses-b"), loaded.sessions.map { it.id }) + } + + @Test + fun SidebarViewModel_loadSessionsForWorkspaceClearsPreviousErrorAfterSuccess() = runTest(dispatcher) { + val workspace1 = workspace("proj-1", "/path/to/project-a") + var attempt = 0 + val repo = FakeWorkspaceRepository( + workspaces = listOf(workspace1), + activeWorkspace = workspace1 + ) + val sessionRepo = FakeSessionRepository( + getSessionsHandler = { _, _, _, _ -> + attempt += 1 + if (attempt == 1) { + Result.failure(IllegalStateException("load failed")) + } else { + Result.success(listOf(session("ses-1", "/path/to/project-a", updatedAtMs = 100))) + } + } + ) + + val vm = SidebarViewModel( + workspaceRepository = repo, + sessionRepository = sessionRepo, + appSettings = MockAppSettings() + ) + advanceUntilIdle() + + assertEquals("load failed", vm.uiState.value.workspaces.first().error) + + vm.loadSessionsForWorkspace("proj-1") + advanceUntilIdle() + + val workspace = vm.uiState.value.workspaces.first() + assertEquals(null, workspace.error) + assertEquals(listOf("ses-1"), workspace.sessions.map { it.id }) + } + + @Test + fun SidebarViewModel_loadSessionsForWorkspaceSkipsOverlappingRefreshes() = runTest(dispatcher) { + val workspace1 = workspace("proj-1", "/path/to/project-a") + var requestCount = 0 + val repo = FakeWorkspaceRepository( + workspaces = listOf(workspace1), + activeWorkspace = workspace1 + ) + val sessionRepo = FakeSessionRepository( + getSessionsHandler = { _, _, _, _ -> + requestCount += 1 + Result.success(listOf(session("ses-1", "/path/to/project-a", updatedAtMs = 100))) + } + ) + + val vm = SidebarViewModel( + workspaceRepository = repo, + sessionRepository = sessionRepo, + appSettings = MockAppSettings() + ) + advanceUntilIdle() + + vm.loadSessionsForWorkspace("proj-1") + vm.loadSessionsForWorkspace("proj-1") + advanceUntilIdle() + + assertEquals(2, requestCount) + } + + @Test + fun SidebarViewModel_switchSessionCallsRepository() = runTest(dispatcher) { + val repo = FakeWorkspaceRepository( + workspaces = listOf(workspace("proj-1", "/p")), + activeWorkspace = workspace("proj-1", "/p") + ) + val updatedIds = mutableListOf() + val sessionRepo = FakeSessionRepository( + updateCurrentSessionIdHandler = { id -> + updatedIds.add(id) + Result.success(Unit) + } + ) + val appSettings = MockAppSettings() + + val vm = SidebarViewModel( + workspaceRepository = repo, + sessionRepository = sessionRepo, + appSettings = appSettings + ) + advanceUntilIdle() + + vm.switchSession("ses-target") + advanceUntilIdle() + + assertEquals(listOf("ses-target"), updatedIds) + assertEquals(false, vm.uiState.value.isSwitchingSession) + assertEquals("ses-target", vm.uiState.value.switchedSessionId) + } + + @Test + fun SidebarViewModel_switchSessionExposesFailure() = runTest(dispatcher) { + val vm = SidebarViewModel( + workspaceRepository = FakeWorkspaceRepository( + workspaces = listOf(workspace("proj-1", "/p")), + activeWorkspace = workspace("proj-1", "/p") + ), + sessionRepository = FakeSessionRepository( + updateCurrentSessionIdHandler = { + Result.failure(IllegalStateException("switch failed")) + } + ), + appSettings = MockAppSettings() + ) + advanceUntilIdle() + + vm.switchSession("ses-target") + advanceUntilIdle() + + assertEquals(false, vm.uiState.value.isSwitchingSession) + assertEquals(null, vm.uiState.value.switchedSessionId) + assertEquals("switch failed", vm.uiState.value.operationErrorMessage) + } + + @Test + fun SidebarViewModel_clearSwitchedSessionRemovesSignal() = runTest(dispatcher) { + val updatedIds = mutableListOf() + val vm = SidebarViewModel( + workspaceRepository = FakeWorkspaceRepository( + workspaces = listOf(workspace("proj-1", "/p")), + activeWorkspace = workspace("proj-1", "/p") + ), + sessionRepository = FakeSessionRepository( + updateCurrentSessionIdHandler = { id -> + updatedIds.add(id) + Result.success(Unit) + } + ), + appSettings = MockAppSettings() + ) + advanceUntilIdle() + + vm.switchSession("ses-target") + advanceUntilIdle() + assertEquals("ses-target", vm.uiState.value.switchedSessionId) + + vm.clearSwitchedSession() + + assertEquals(listOf("ses-target"), updatedIds) + assertEquals(null, vm.uiState.value.switchedSessionId) + } + + @Test + fun SidebarViewModel_loadsActiveSessionTitleFromRepository() = runTest(dispatcher) { + val repo = FakeWorkspaceRepository( + workspaces = listOf(workspace("proj-1", "/p1")), + activeWorkspace = workspace("proj-1", "/p1") + ) + val appSettings = MockAppSettings() + appSettings.setCurrentSessionId("ses-target") + val sessionRepo = FakeSessionRepository( + getSessionHandler = { sessionId -> + Result.success(session(sessionId, "/p1", updatedAtMs = 1).copy(title = "My session title")) + } + ) + + val vm = SidebarViewModel( + workspaceRepository = repo, + sessionRepository = sessionRepo, + appSettings = appSettings + ) + advanceUntilIdle() + + assertEquals("My session title", vm.uiState.value.activeSessionTitle) + } + + @Test + fun SidebarViewModel_switchWorkspaceActivatesBeforePersistingSessionId() = runTest(dispatcher) { + val operations = mutableListOf() + val appSettings = MockAppSettings() + appSettings.setActiveServerId("server-1") + appSettings.setInstallationIdForServer("server-1", "inst-1") + + val workspace1 = workspace("proj-1", "/p1") + val workspace2 = workspace("proj-2", "/p2") + appSettings.setWorkspacesForInstallation("inst-1", listOf(workspace1, workspace2)) + appSettings.setActiveWorkspace("inst-1", workspace1) + + val repo = FakeWorkspaceRepository( + workspaces = listOf(workspace1, workspace2), + activeWorkspace = workspace1, + appSettings = appSettings, + activateHandler = { id -> + operations.add("activate:$id") + Result.success(Unit) + } + ) + val sessionRepo = FakeSessionRepository( + updateCurrentSessionIdHandler = { sessionId -> + operations.add("persist:$sessionId") + appSettings.setCurrentSessionId(sessionId) + Result.success(Unit) + } + ) + + val vm = SidebarViewModel( + workspaceRepository = repo, + sessionRepository = sessionRepo, + appSettings = appSettings + ) + advanceUntilIdle() + + vm.switchWorkspace("proj-2", "ses-target") + advanceUntilIdle() + + assertEquals("proj-2", appSettings.getActiveWorkspaceSnapshot()?.projectId) + assertEquals("ses-target", appSettings.getCurrentSessionIdSnapshot()) + assertEquals(listOf("activate:proj-2", "persist:ses-target"), operations) + assertEquals("proj-2", vm.uiState.value.switchedWorkspaceId) + } + + @Test + fun SidebarViewModel_switchWorkspaceExposesSessionPersistenceFailure() = runTest(dispatcher) { + val workspace1 = workspace("proj-1", "/p1") + val workspace2 = workspace("proj-2", "/p2") + val appSettings = MockAppSettings() + appSettings.setActiveServerId("server-1") + appSettings.setInstallationIdForServer("server-1", "inst-1") + appSettings.setWorkspacesForInstallation("inst-1", listOf(workspace1, workspace2)) + appSettings.setActiveWorkspace("inst-1", workspace1) + + val vm = SidebarViewModel( + workspaceRepository = FakeWorkspaceRepository( + workspaces = listOf(workspace1, workspace2), + activeWorkspace = workspace1, + appSettings = appSettings + ), + sessionRepository = FakeSessionRepository( + updateCurrentSessionIdHandler = { + Result.failure(IllegalStateException("persist failed")) + } + ), + appSettings = appSettings + ) + advanceUntilIdle() + + vm.switchWorkspace("proj-2", "ses-target") + advanceUntilIdle() + + assertEquals(false, vm.uiState.value.isSwitchingWorkspace) + assertEquals(null, vm.uiState.value.switchedWorkspaceId) + assertEquals("persist failed", vm.uiState.value.operationErrorMessage) + } + + @Test + fun SidebarViewModel_createSessionFailureInInactiveWorkspaceStillSignalsWorkspaceSwitch() = runTest(dispatcher) { + val workspace1 = workspace("proj-1", "/p1") + val workspace2 = workspace("proj-2", "/p2") + val appSettings = MockAppSettings() + appSettings.setActiveServerId("server-1") + appSettings.setInstallationIdForServer("server-1", "inst-1") + appSettings.setWorkspacesForInstallation("inst-1", listOf(workspace1, workspace2)) + appSettings.setActiveWorkspace("inst-1", workspace1) + + val repo = FakeWorkspaceRepository( + workspaces = listOf(workspace1, workspace2), + activeWorkspace = workspace1, + appSettings = appSettings + ) + val sessionRepo = FakeSessionRepository( + createSessionHandler = { Result.failure(IllegalStateException("create failed")) } + ) + + val vm = SidebarViewModel( + workspaceRepository = repo, + sessionRepository = sessionRepo, + appSettings = appSettings + ) + advanceUntilIdle() + + vm.createSession("proj-2") + advanceUntilIdle() + + assertEquals(false, vm.uiState.value.isCreatingSession) + assertEquals(null, vm.uiState.value.switchedWorkspaceId) + assertEquals("create failed", vm.uiState.value.operationErrorMessage) + assertEquals("proj-2", appSettings.getActiveWorkspaceSnapshot()?.projectId) + } + + @Test + fun SidebarViewModel_clearOperationErrorRemovesMessage() = runTest(dispatcher) { + val workspace1 = workspace("proj-1", "/p1") + val vm = SidebarViewModel( + workspaceRepository = FakeWorkspaceRepository( + workspaces = listOf(workspace1), + activeWorkspace = workspace1 + ), + sessionRepository = FakeSessionRepository( + createSessionHandler = { Result.failure(IllegalStateException("create failed")) } + ), + appSettings = MockAppSettings() + ) + advanceUntilIdle() + + vm.createSession("proj-1") + advanceUntilIdle() + assertEquals("create failed", vm.uiState.value.operationErrorMessage) + + vm.clearOperationError() + + assertEquals(null, vm.uiState.value.operationErrorMessage) + } + + @Test + fun SidebarViewModel_createSessionInActiveWorkspace() = runTest(dispatcher) { + val repo = FakeWorkspaceRepository( + workspaces = listOf(workspace("proj-1", "/p1")), + activeWorkspace = workspace("proj-1", "/p1") + ) + val sessionRepo = FakeSessionRepository( + createSessionHandler = { Result.success(session("ses-new", "/p1", updatedAtMs = 1)) } + ) + val appSettings = MockAppSettings() + + val vm = SidebarViewModel( + workspaceRepository = repo, + sessionRepository = sessionRepo, + appSettings = appSettings + ) + advanceUntilIdle() + + vm.createSession("proj-1") + advanceUntilIdle() + + assertEquals("ses-new", vm.uiState.value.createdSessionId) + assertEquals(null, vm.uiState.value.switchedWorkspaceId) + } + + @Test + fun SidebarViewModel_addWorkspaceCallsRepository() = runTest(dispatcher) { + val addedDirs = mutableListOf() + val repo = FakeWorkspaceRepository( + workspaces = emptyList(), + activeWorkspace = null, + addHandler = { dir -> + addedDirs.add(dir) + Result.success(workspace("proj-new", dir)) + } + ) + val sessionRepo = FakeSessionRepository() + val appSettings = MockAppSettings() + + val vm = SidebarViewModel( + workspaceRepository = repo, + sessionRepository = sessionRepo, + appSettings = appSettings + ) + advanceUntilIdle() + + vm.addWorkspace("/new/path") + advanceUntilIdle() + + assertEquals(listOf("/new/path"), addedDirs) + assertEquals(false, vm.uiState.value.isCreatingWorkspace) + assertEquals(null, vm.uiState.value.workspaceCreationError) + } + + @Test + fun SidebarViewModel_addWorkspaceExposesFailure() = runTest(dispatcher) { + val repo = FakeWorkspaceRepository( + workspaces = emptyList(), + activeWorkspace = null, + addHandler = { Result.failure(IllegalStateException("Workspace already exists")) } + ) + val sessionRepo = FakeSessionRepository() + val appSettings = MockAppSettings() + + val vm = SidebarViewModel( + workspaceRepository = repo, + sessionRepository = sessionRepo, + appSettings = appSettings + ) + advanceUntilIdle() + + vm.addWorkspace("/existing/path") + advanceUntilIdle() + + assertEquals(false, vm.uiState.value.isCreatingWorkspace) + assertEquals("Workspace already exists", vm.uiState.value.workspaceCreationError) + } + + @Test + fun SidebarViewModel_refreshesWorkspacesAfterInitialization() = runTest(dispatcher) { + var refreshCount = 0 + val workspace1 = workspace("proj-1", "/p1") + val repo = FakeWorkspaceRepository( + workspaces = listOf(workspace1), + activeWorkspace = workspace1, + refreshHandler = { + refreshCount += 1 + Result.success(Unit) + } + ) + + SidebarViewModel( + workspaceRepository = repo, + sessionRepository = FakeSessionRepository(), + appSettings = MockAppSettings() + ) + advanceUntilIdle() + + assertEquals(1, refreshCount) + } + + private fun workspace(projectId: String, worktree: String, name: String? = null): Workspace = + Workspace(projectId = projectId, worktree = worktree, name = name) + + private fun session( + id: String, + directory: String, + updatedAtMs: Long, + parentId: String? = null + ): Session { + val instant = Instant.fromEpochMilliseconds(updatedAtMs) + return Session(id = id, directory = directory, title = id, createdAt = instant, updatedAt = instant, parentId = parentId) + } + + private class FakeWorkspaceRepository( + private val workspaces: List = emptyList(), + private val activeWorkspace: Workspace? = null, + private val appSettings: MockAppSettings? = null, + private val refreshHandler: suspend () -> Result = { Result.success(Unit) }, + private val activateHandler: suspend (String) -> Result = { Result.success(Unit) }, + private val addHandler: suspend (String) -> Result = { error("addWorkspace not configured") } + ) : WorkspaceRepository { + private val _workspaces = MutableStateFlow(workspaces) + private val _active = MutableStateFlow(activeWorkspace) + + override fun getWorkspaces(): Flow> = _workspaces + override fun getActiveWorkspace(): Flow = _active + override fun getActiveWorkspaceSnapshot(): Workspace? = _active.value + override suspend fun ensureInitialized(): Result = + _active.value?.let { Result.success(it) } ?: Result.failure(RuntimeException("no active")) + override suspend fun refresh(): Result = refreshHandler() + override suspend fun addWorkspace(directoryInput: String): Result = addHandler(directoryInput) + override suspend fun activateWorkspace(projectId: String): Result { + val result = activateHandler(projectId) + if (result.isSuccess) { + val workspace = _workspaces.value.firstOrNull { it.projectId == projectId } + ?: return Result.failure(IllegalArgumentException("Workspace not found: $projectId")) + _active.value = workspace + appSettings?.setActiveWorkspace("inst-1", workspace) + } + return result + } + } + + private class FakeSessionRepository( + private val sessions: List = emptyList(), + private val getSessionHandler: suspend (String) -> Result = { sessionId -> + val instant = Instant.fromEpochMilliseconds(0) + Result.success( + Session( + id = sessionId, + directory = "/unused", + title = sessionId, + createdAt = instant, + updatedAt = instant, + parentId = null + ) + ) + }, + private val getSessionsHandler: suspend (String?, Int?, Long?, String?) -> Result> = { _, _, _, _ -> + Result.success(sessions) + }, + private val createSessionHandler: suspend () -> Result = { error("createSession not configured") }, + private val updateCurrentSessionIdHandler: suspend (String) -> Result = { Result.success(Unit) } + ) : SessionRepository { + override suspend fun getCurrentSessionId(): Result = Result.success("ses-current") + override suspend fun getSession(sessionId: String): Result = getSessionHandler(sessionId) + override suspend fun getSessions(search: String?, limit: Int?, start: Long?, directory: String?): Result> = + getSessionsHandler(search, limit, start, directory) + override suspend fun createSession(title: String?, parentId: String?): Result = createSessionHandler() + override suspend fun forkSession(sessionId: String, messageId: String?): Result = error("not used") + override suspend fun revertSession(sessionId: String, messageId: String): Result = error("not used") + override suspend fun updateCurrentSessionId(sessionId: String): Result = updateCurrentSessionIdHandler(sessionId) + override suspend fun abortSession(sessionId: String): Result = error("not used") + } +} diff --git a/iosApp/iosApp/ChatUIKit/ChatScreenChromeView.swift b/iosApp/iosApp/ChatUIKit/ChatScreenChromeView.swift index 8b350e2..64beaf7 100644 --- a/iosApp/iosApp/ChatUIKit/ChatScreenChromeView.swift +++ b/iosApp/iosApp/ChatUIKit/ChatScreenChromeView.swift @@ -7,10 +7,12 @@ struct ChatToolbarGlassView: View { let state: ChatUiState let isRefreshing: Bool let onRetry: () -> Void - let onOpenSessions: () -> Void + let onToggleSidebar: () -> Void let onOpenSettings: () -> Void let onDismissError: () -> Void let onRevert: () -> Void + let sessionTitle: String? + let workspacePath: String? private var showTypingIndicator: Bool { TypingIndicatorKt.shouldShowTypingIndicator(state: state) @@ -25,10 +27,11 @@ struct ChatToolbarGlassView: View { } private var subtitle: String { - guard let sessionId = state.currentSessionId, !sessionId.isEmpty else { + guard let path = workspacePath, !path.isEmpty else { return "Pocket chat" } - return "Session \(sessionId.prefix(8))" + let lastComponent = (path as NSString).lastPathComponent + return lastComponent.isEmpty ? path : "…/\(lastComponent)" } private var shouldShowRevert: Bool { @@ -37,68 +40,65 @@ struct ChatToolbarGlassView: View { } var body: some View { - chatGlassGrouping(spacing: 16) { - VStack(spacing: 10) { - VStack(spacing: 0) { - HStack(alignment: .center, spacing: 12) { - VStack(alignment: .leading, spacing: 3) { - Text("OpenCode") - .font(.system(.title3, design: .rounded).weight(.semibold)) - .foregroundStyle(.primary) - .lineLimit(1) - - Text(subtitle) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .lineLimit(1) - } + VStack(spacing: 10) { + VStack(spacing: 0) { + HStack(alignment: .center, spacing: 12) { + ChatToolbarIconButton(action: onToggleSidebar) { + Image(systemName: "line.3.horizontal") + } - Spacer(minLength: 0) + VStack(alignment: .leading, spacing: 3) { + Text(sessionTitle ?? "OpenCode") + .font(.system(.title3, design: .rounded).weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) - HStack(spacing: 8) { - ChatToolbarIconButton(action: onRetry) { - if isRefreshing { - ProgressView() - .controlSize(.small) - } else { - Image(systemName: "arrow.clockwise") - } - } - .disabled(isRefreshing) - - ChatToolbarIconButton(action: onOpenSessions) { - Image(systemName: "rectangle.stack") + Text(subtitle) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 10) { + ChatToolbarIconButton(action: onRetry) { + if isRefreshing { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "arrow.clockwise") } + } + .disabled(isRefreshing) - ChatToolbarIconButton(action: onOpenSettings) { - Image(systemName: "gearshape") - } + ChatToolbarIconButton(action: onOpenSettings) { + Image(systemName: "gearshape") } } - .padding(.horizontal, 14) - .padding(.top, 9) - .padding(.bottom, showProcessingBar ? 7 : 9) - - if showProcessingBar { - ChatToolbarProcessingBar() - .padding(.horizontal, 14) - .padding(.bottom, 9) - } } + .padding(.horizontal, 14) + .padding(.top, 9) + .padding(.bottom, showProcessingBar ? 7 : 9) - if isReconnecting { - ChatStatusBanner(title: "Reconnecting", message: "Trying to restore the stream.") + if showProcessingBar { + ChatToolbarProcessingBar() + .padding(.horizontal, 14) + .padding(.bottom, 9) } + } - if let error = state.error { - ChatErrorBanner( - message: error.message ?? "An error occurred.", - showRevert: shouldShowRevert, - onDismiss: onDismissError, - onRetry: onRetry, - onRevert: onRevert - ) - } + if isReconnecting { + ChatStatusBanner(title: "Reconnecting", message: "Trying to restore the stream.") + } + + if let error = state.error { + ChatErrorBanner( + message: error.message ?? "An error occurred.", + showRevert: shouldShowRevert, + onDismiss: onDismissError, + onRetry: onRetry, + onRevert: onRevert + ) } } } @@ -333,6 +333,7 @@ private struct ChatToolbarIconButton: View { .font(.system(size: 14, weight: .medium)) .foregroundStyle(.primary) .frame(width: 34, height: 34) + .contentShape(Circle()) .chatGlassCircle(tint: Color.white.opacity(0.01)) } .buttonStyle(.plain) diff --git a/iosApp/iosApp/ChatUIKit/SwiftUIChatUIKitView.swift b/iosApp/iosApp/ChatUIKit/SwiftUIChatUIKitView.swift index 0506fda..33d389a 100644 --- a/iosApp/iosApp/ChatUIKit/SwiftUIChatUIKitView.swift +++ b/iosApp/iosApp/ChatUIKit/SwiftUIChatUIKitView.swift @@ -7,8 +7,10 @@ struct SwiftUIChatUIKitView: View { let viewModel: ChatViewModel let onOpenSettings: () -> Void - let onOpenSessions: () -> Void + let onToggleSidebar: () -> Void let onOpenFile: (String) -> Void + let sessionTitle: String? + let workspacePath: String? @StateObject private var store: ChatViewControllerStore @StateObject private var uiStateEvents = KmpUiEventBridge() @@ -19,13 +21,17 @@ struct SwiftUIChatUIKitView: View { init( viewModel: ChatViewModel, onOpenSettings: @escaping () -> Void, - onOpenSessions: @escaping () -> Void, - onOpenFile: @escaping (String) -> Void + onToggleSidebar: @escaping () -> Void, + onOpenFile: @escaping (String) -> Void, + sessionTitle: String?, + workspacePath: String? ) { self.viewModel = viewModel self.onOpenSettings = onOpenSettings - self.onOpenSessions = onOpenSessions + self.onToggleSidebar = onToggleSidebar self.onOpenFile = onOpenFile + self.sessionTitle = sessionTitle + self.workspacePath = workspacePath _store = StateObject(wrappedValue: ChatViewControllerStore(viewModel: viewModel)) } @@ -53,6 +59,7 @@ struct SwiftUIChatUIKitView: View { Spacer(minLength: 0) composerOverlay(state: state, safeBottomInset: keyboardAwareBottomInset(proxy.safeAreaInsets.bottom)) } + .zIndex(1) } } .ignoresSafeArea(.container) @@ -106,10 +113,12 @@ struct SwiftUIChatUIKitView: View { state: state, isRefreshing: state.isRefreshing, onRetry: viewModel.retry, - onOpenSessions: onOpenSessions, + onToggleSidebar: onToggleSidebar, onOpenSettings: onOpenSettings, onDismissError: viewModel.dismissError, - onRevert: viewModel.revertToLastGood + onRevert: viewModel.revertToLastGood, + sessionTitle: state.currentSessionTitle ?? sessionTitle, + workspacePath: workspacePath ) .padding(.horizontal, 12) .padding(.top, safeTopInset + 2) diff --git a/iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift b/iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift index 4271808..48aa801 100644 --- a/iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift +++ b/iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift @@ -6,7 +6,6 @@ private enum SwiftUIAppRoute: Hashable { case connect case settings case modelSelection - case workspaces case markdownFile(path: String, openId: Int64) } @@ -18,13 +17,20 @@ struct SwiftUIAppRootView: View { @State private var markdownManager = MarkdownFlowStoreManager() @State private var path: [SwiftUIAppRoute] = [] @Environment(\.scenePhase) private var scenePhase - @State private var isShowingSessions: Bool = false + @State private var settingsUiState: SettingsUiState? + @StateObject private var sidebarUiStateEvents = KmpUiEventBridge() + @State private var sidebarUiState: SidebarUiState? @StateObject private var shareEvents = KmpUiEventBridge() @StateObject private var settingsUiStateEvents = KmpUiEventBridge() @State private var pendingThemeVerification: Task? @State private var desiredInterfaceStyle: UIUserInterfaceStyle = IosThemeApplier.readStoredStyle() @State private var showThemeRestartNotice: Bool = false + @State private var isSidebarPresented: Bool = false + + private var activeSessionTitle: String? { + sidebarUiState?.activeSessionTitle + } var body: some View { let connectViewModel = kmp.owner.connectViewModel() @@ -54,70 +60,89 @@ struct SwiftUIAppRootView: View { private func pairedAppView(connectViewModel: ConnectViewModel) -> some View { let chatViewModel = kmp.owner.chatViewModel() let settingsViewModel = kmp.owner.settingsViewModel() - let workspacesViewModel = kmp.owner.workspacesViewModel() + let sidebarViewModel = kmp.owner.sidebarViewModel() - NavigationStack(path: $path) { - Group { - SwiftUIChatUIKitView( - viewModel: chatViewModel, - onOpenSettings: { path.append(.settings) }, - onOpenSessions: { isShowingSessions = true }, - onOpenFile: { openMarkdownFile($0) } - ) - } - .navigationDestination(for: SwiftUIAppRoute.self) { route in - switch route { - case .connect: - SwiftUIConnectToOpenCodeView( - viewModel: connectViewModel, - onConnected: { onRequestAppReset() }, - onDisconnected: { onRequestAppReset() } + ZStack(alignment: .leading) { + NavigationStack(path: $path) { + Group { + SwiftUIChatUIKitView( + viewModel: chatViewModel, + onOpenSettings: { path.append(.settings) }, + onToggleSidebar: { + withAnimation(.snappy(duration: 0.28)) { + isSidebarPresented = true + } + }, + onOpenFile: { openMarkdownFile($0) }, + sessionTitle: activeSessionTitle, + workspacePath: settingsUiState?.activeWorkspaceWorktree ) + } + .navigationDestination(for: SwiftUIAppRoute.self) { route in + switch route { + case .connect: + SwiftUIConnectToOpenCodeView( + viewModel: connectViewModel, + onConnected: { onRequestAppReset() }, + onDisconnected: { onRequestAppReset() } + ) - case .settings: - SwiftUISettingsView( - viewModel: settingsViewModel, - onOpenConnect: { path.append(.connect) }, - onOpenModelSelection: { path.append(.modelSelection) }, - onOpenWorkspaces: { path.append(.workspaces) }, - onOpenSessions: { isShowingSessions = true }, - themeRestartNotice: $showThemeRestartNotice - ) + case .settings: + SwiftUISettingsView( + viewModel: settingsViewModel, + onOpenConnect: { path.append(.connect) }, + onOpenModelSelection: { path.append(.modelSelection) }, + themeRestartNotice: $showThemeRestartNotice + ) - case .modelSelection: - SwiftUIModelSelectionView(viewModel: settingsViewModel) + case .modelSelection: + SwiftUIModelSelectionView(viewModel: settingsViewModel) - case .workspaces: - SwiftUIWorkspacesView( - viewModel: workspacesViewModel, - onDidSwitchWorkspace: { onRequestAppReset() } - ) + case .markdownFile(let filePath, let openId): + let key = MarkdownRouteKey(path: filePath, openId: openId) + if let store = markdownManager.stores[key] { + SwiftUIMarkdownFileViewerView( + viewModel: store.owner.markdownFileViewerViewModel(path: filePath, openId: openId), + onOpenFile: { openMarkdownFile($0) } + ) + } else { + SamFullScreenLoadingView(title: "Opening file…") + .task { + markdownManager.ensureStore(for: key) + } + } + } + } + } - case .markdownFile(let filePath, let openId): - let key = MarkdownRouteKey(path: filePath, openId: openId) - if let store = markdownManager.stores[key] { - SwiftUIMarkdownFileViewerView( - viewModel: store.owner.markdownFileViewerViewModel(path: filePath, openId: openId), - onOpenFile: { openMarkdownFile($0) } - ) - } else { - SamFullScreenLoadingView(title: "Opening file…") - .task { - markdownManager.ensureStore(for: key) + if isSidebarPresented { + NavigationStack { + WorkspacesSidebarView( + viewModel: sidebarViewModel, + onClose: { + withAnimation(.snappy(duration: 0.28)) { + isSidebarPresented = false } - } + }, + onSelectSession: { + withAnimation(.snappy(duration: 0.28)) { + isSidebarPresented = false + } + } + ) } + .background(Color(.systemBackground)) + .transition(.move(edge: .leading)) + .zIndex(1) + .ignoresSafeArea() } } - .sheet(isPresented: $isShowingSessions) { - SwiftUISessionsSheetView() - } .onAppear { - // Apply a best-effort theme override on first appearance. Launch-time application happens in `iOSApp`, - // but we re-apply here in case the window did not exist yet. IosThemeApplier.apply(style: desiredInterfaceStyle) settingsUiStateEvents.start(flow: settingsViewModel.uiState) { uiState in + settingsUiState = uiState + let newStyle = IosThemeApplier.style(fromStoredString: uiState.selectedThemeMode.name) if newStyle == desiredInterfaceStyle { return } @@ -142,10 +167,15 @@ struct SwiftUIAppRootView: View { shareEvents.start(flow: ShareExtensionBridge.shared.pendingPayload) { payload in handleSharePayload(payload, chatViewModel: chatViewModel) } + + sidebarUiStateEvents.start(flow: sidebarViewModel.uiState) { state in + sidebarUiState = state + } } .onDisappear { settingsUiStateEvents.stop() shareEvents.stop() + sidebarUiStateEvents.stop() pendingThemeVerification?.cancel() pendingThemeVerification = nil } @@ -158,6 +188,29 @@ struct SwiftUIAppRootView: View { }) markdownManager.prune(activeKeys: activeMarkdownKeys) } + .onChange(of: sidebarUiState?.switchedWorkspaceId) { switchedWorkspaceId in + guard let switchedWorkspaceId, !switchedWorkspaceId.isEmpty else { return } + sidebarViewModel.clearCreatedSession() + sidebarViewModel.clearSwitchedSession() + sidebarViewModel.clearWorkspaceSwitch() + isSidebarPresented = false + onRequestAppReset() + } + .onChange(of: sidebarUiState?.switchedSessionId) { switchedSessionId in + guard let switchedSessionId, !switchedSessionId.isEmpty else { return } + sidebarViewModel.clearSwitchedSession() + withAnimation(.snappy(duration: 0.28)) { + isSidebarPresented = false + } + } + .onChange(of: sidebarUiState?.createdSessionId) { createdSessionId in + guard let createdSessionId, !createdSessionId.isEmpty else { return } + guard sidebarUiState?.switchedWorkspaceId == nil else { return } + sidebarViewModel.clearCreatedSession() + withAnimation(.snappy(duration: 0.28)) { + isSidebarPresented = false + } + } .onChange(of: scenePhase) { newPhase in switch newPhase { case .active: @@ -187,6 +240,7 @@ struct SwiftUIAppRootView: View { guard let payload else { return } path = [] + isSidebarPresented = false payload.attachments.forEach { attachment in chatViewModel.addAttachment(attachment: attachment) diff --git a/iosApp/iosApp/SwiftUIInterop/SwiftUISessionsViews.swift b/iosApp/iosApp/SwiftUIInterop/SwiftUISessionsViews.swift deleted file mode 100644 index 217931e..0000000 --- a/iosApp/iosApp/SwiftUIInterop/SwiftUISessionsViews.swift +++ /dev/null @@ -1,146 +0,0 @@ -import Foundation -import SwiftUI -import ComposeApp - -@MainActor -struct SwiftUISessionsView: View { - let kmp: KmpScreenOwnerStore - - @Environment(\.dismiss) private var dismiss - - var body: some View { - let viewModel = kmp.owner.sessionsViewModel() - - Observing(viewModel.uiState) { - SamFullScreenLoadingView(title: "Loading sessions…") - } content: { uiState in - content(uiState: uiState, viewModel: viewModel) - .navigationTitle("Sessions") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button("Close") { dismiss() } - } - - ToolbarItemGroup(placement: .topBarTrailing) { - if uiState.isSearching { - ProgressView() - .controlSize(.small) - } - - Button { - viewModel.createNewSession() - } label: { - if uiState.isCreatingSession { - ProgressView() - } else { - Image(systemName: "plus") - } - } - .disabled(uiState.isLoading || uiState.isCreatingSession || uiState.isActivating) - } - } - .searchable( - text: Binding( - get: { uiState.searchQuery }, - set: { viewModel.onSearchQueryChanged(query: $0) } - ), - prompt: "Search sessions" - ) - .onAppear { viewModel.onScreenVisible() } - .task(id: uiState.activatedSessionId ?? "nil") { - guard uiState.activatedSessionId != nil else { return } - viewModel.clearActivation() - dismiss() - } - .task(id: uiState.newSessionId ?? "nil") { - guard uiState.newSessionId != nil else { return } - viewModel.clearNewSession() - dismiss() - } - } - } - - @ViewBuilder - private func content(uiState: SessionsUiState, viewModel: SessionsViewModel) -> some View { - if uiState.isLoading { - SamFullScreenLoadingView(title: "Loading sessions…") - } else if uiState.sessions.isEmpty { - if let error = uiState.error, !error.isEmpty { - VStack(spacing: 12) { - Text(error) - .foregroundStyle(.red) - .multilineTextAlignment(.center) - Button("Retry") { viewModel.refresh() } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - VStack(spacing: 8) { - Text(uiState.searchQuery.isEmpty ? "No recent sessions found" : "No results") - .foregroundStyle(.secondary) - Button("Refresh") { viewModel.refresh() } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } else { - List { - if let error = uiState.error, !error.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text(error) - .foregroundStyle(.red) - Button("Retry") { viewModel.refresh() } - } - } - - if let activationError = uiState.activationError, !activationError.isEmpty { - Text(activationError) - .foregroundStyle(.red) - } - - ForEach(uiState.sessions, id: \.id) { session in - Button { - viewModel.activateSession(sessionId: session.id) - } label: { - HStack(alignment: .firstTextBaseline, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text(session.title ?? "Untitled Session") - .font(.headline) - .lineLimit(1) - - Text(KmpDateFormat.mediumDateTime(session.updatedAt)) - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer(minLength: 0) - - if uiState.activeSessionId == session.id { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - } else if uiState.activatingSessionId == session.id { - ProgressView() - .controlSize(.small) - } - } - .padding(.vertical, 4) - } - .buttonStyle(.plain) - .disabled(uiState.isCreatingSession || uiState.isLoading || uiState.isActivating) - } - } - .listStyle(.plain) - .refreshable { viewModel.refresh() } - } - } -} - -@MainActor -struct SwiftUISessionsSheetView: View { - @StateObject private var kmp = KmpScreenOwnerStore() - - var body: some View { - NavigationStack { - SwiftUISessionsView(kmp: kmp) - } - } -} diff --git a/iosApp/iosApp/SwiftUIInterop/SwiftUISettingsViews.swift b/iosApp/iosApp/SwiftUIInterop/SwiftUISettingsViews.swift index dab0c36..0288f4b 100644 --- a/iosApp/iosApp/SwiftUIInterop/SwiftUISettingsViews.swift +++ b/iosApp/iosApp/SwiftUIInterop/SwiftUISettingsViews.swift @@ -6,8 +6,6 @@ struct SwiftUISettingsView: View { let viewModel: SettingsViewModel let onOpenConnect: () -> Void let onOpenModelSelection: () -> Void - let onOpenWorkspaces: () -> Void - let onOpenSessions: () -> Void @Binding var themeRestartNotice: Bool @State private var isShowingAgentSheet = false @@ -61,23 +59,6 @@ struct SwiftUISettingsView: View { } .buttonStyle(.plain) - Button(action: onOpenWorkspaces) { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Workspace") - .foregroundColor(.primary) - Text(workspaceText(name: uiState.activeWorkspaceName, worktree: uiState.activeWorkspaceWorktree)) - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - .font(.caption) - } - } - .buttonStyle(.plain) - Button(action: onOpenModelSelection) { HStack { VStack(alignment: .leading, spacing: 4) { @@ -164,12 +145,6 @@ struct SwiftUISettingsView: View { } } - Section("Navigation") { - Button(action: onOpenSessions) { - NavigationRowLabel(title: "Sessions", systemImage: "rectangle.stack") - } - } - Section("Advanced") { Toggle( "Always expand assistant details", @@ -333,20 +308,6 @@ struct SwiftUISettingsView: View { return "\(serverName) (\(endpoint))" } - private func workspaceText(name: String?, worktree: String?) -> String { - let workspaceName = (name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let path = (worktree ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - - if path.isEmpty { - return "Server default (cwd)" - } - - if workspaceName.isEmpty { - return path - } - - return "\(workspaceName) · \(path)" - } } private struct NavigationRowLabel: View { diff --git a/iosApp/iosApp/SwiftUIInterop/SwiftUIWorkspacesViews.swift b/iosApp/iosApp/SwiftUIInterop/SwiftUIWorkspacesViews.swift deleted file mode 100644 index c19d021..0000000 --- a/iosApp/iosApp/SwiftUIInterop/SwiftUIWorkspacesViews.swift +++ /dev/null @@ -1,156 +0,0 @@ -import SwiftUI -import ComposeApp - -@MainActor -struct SwiftUIWorkspacesView: View { - let viewModel: WorkspacesViewModel - let onDidSwitchWorkspace: () -> Void - - @Environment(\.dismiss) private var dismiss - - @State private var isShowingAddWorkspace = false - @State private var draftDirectory: String = "" - @State private var didAttemptAdd = false - - var body: some View { - Observing(viewModel.uiState) { - SamFullScreenLoadingView(title: "Loading workspaces…") - } content: { uiState in - List { - if let activationError = uiState.activationError, !activationError.isEmpty { - Text(activationError) - .foregroundStyle(.red) - } - - if let error = uiState.error, !error.isEmpty { - Text(error) - .foregroundStyle(.red) - } - - ForEach(uiState.workspaces, id: \.projectId) { workspace in - Button { - if uiState.activeProjectId == workspace.projectId { return } - viewModel.activateWorkspace(projectId: workspace.projectId) - } label: { - HStack(alignment: .firstTextBaseline, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text(workspaceTitle(workspace)) - .font(.headline) - .lineLimit(1) - - Text(workspace.worktree) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer(minLength: 0) - - if uiState.activeProjectId == workspace.projectId { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - } else if uiState.activatingProjectId == workspace.projectId { - ProgressView() - .controlSize(.small) - } - } - .padding(.vertical, 4) - } - .buttonStyle(.plain) - .disabled(uiState.isSaving || uiState.isActivating) - } - } - .navigationTitle("Workspaces") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - isShowingAddWorkspace = true - } label: { - Image(systemName: "plus") - } - .disabled(uiState.isSaving || uiState.isActivating) - } - } - .sheet(isPresented: $isShowingAddWorkspace) { - NavigationStack { - Form { - Section("Directory") { - TextField("/path/to/project", text: $draftDirectory) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - } - - Section { - Text("Enter a directory path on the server machine. We'll resolve it to the project root (worktree).") - .font(.caption) - .foregroundStyle(.secondary) - } - - if let error = uiState.error, !error.isEmpty { - Section { - Text(error) - .foregroundStyle(.red) - } - } - } - .navigationTitle("Add Workspace") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button("Cancel") { - isShowingAddWorkspace = false - didAttemptAdd = false - } - } - - ToolbarItem(placement: .topBarTrailing) { - Button("Save") { - didAttemptAdd = true - viewModel.addWorkspace(directoryInput: draftDirectory) - } - .disabled(uiState.isSaving || draftDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - } - } - .refreshable { - viewModel.refresh() - } - .task { - viewModel.refresh() - } - .task(id: uiState.isSaving) { - guard isShowingAddWorkspace else { return } - guard didAttemptAdd else { return } - guard uiState.isSaving == false else { return } - guard (uiState.error ?? "").isEmpty else { return } - - didAttemptAdd = false - draftDirectory = "" - isShowingAddWorkspace = false - } - .task(id: uiState.activatedProjectId ?? "nil") { - guard uiState.activatedProjectId != nil else { return } - viewModel.clearActivation() - onDidSwitchWorkspace() - dismiss() - } - } - } - - private func workspaceTitle(_ workspace: Workspace) -> String { - if let name = workspace.name?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty { - return name - } - - let path = workspace.worktree.trimmingCharacters(in: .whitespacesAndNewlines) - if path.isEmpty { - return workspace.projectId - } - - let component = URL(fileURLWithPath: path).lastPathComponent - return component.isEmpty ? path : component - } -} - diff --git a/iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift b/iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift new file mode 100644 index 0000000..266f342 --- /dev/null +++ b/iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift @@ -0,0 +1,173 @@ +import SwiftUI +import ComposeApp + +@MainActor +struct WorkspaceCardView: View { + let workspaceWithSessions: WorkspaceWithSessions + let activeSessionId: String? + let isExpanded: Bool + let isFullyExpanded: Bool + let isCreatingSession: Bool + let onToggleExpand: () -> Void + let onToggleFullExpand: () -> Void + let onSelectSession: (String) -> Void + let onCreateSession: () -> Void + + private var displayTitle: String { + if let name = workspaceWithSessions.workspace.name, !name.isEmpty { + return name + } + let worktree = workspaceWithSessions.workspace.worktree + let last = (worktree as NSString).lastPathComponent + return last.isEmpty ? workspaceWithSessions.workspace.projectId : last + } + + private var sessions: [Session] { + let all = workspaceWithSessions.sessions + if isFullyExpanded { return Array(all) } + return Array(all.prefix(3)) + } + + private var hiddenCount: Int { + max(0, Int(workspaceWithSessions.sessions.count) - 3) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Workspace header + HStack(spacing: 10) { + Image(systemName: "folder") + .foregroundStyle(.secondary) + .font(.body) + + Text(displayTitle) + .font(.system(.body, design: .default).weight(.medium)) + .foregroundStyle(.primary) + .lineLimit(1) + + Spacer(minLength: 0) + + if isCreatingSession { + ProgressView() + .controlSize(.small) + } else { + Button(action: onCreateSession) { + Image(systemName: "plus") + .font(.system(.subheadline, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 28, height: 28) + .background(Color(.tertiarySystemFill), in: Circle()) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .contentShape(Rectangle()) + .onTapGesture(perform: onToggleExpand) + + // Expanded session list + if isExpanded { + if workspaceWithSessions.isLoading { + HStack { + Spacer() + ProgressView() + .controlSize(.small) + Spacer() + } + .padding(.vertical, 10) + } else if let error = workspaceWithSessions.error { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .padding(.horizontal, 16) + .padding(.bottom, 10) + } else if sessions.isEmpty { + Text("No sessions") + .font(.subheadline) + .foregroundStyle(.tertiary) + .padding(.horizontal, 16) + .padding(.bottom, 10) + } else { + VStack(alignment: .leading, spacing: 2) { + ForEach(sessions, id: \.id) { session in + sessionRow(session) + .transition(.opacity.combined(with: .move(edge: .top))) + } + + if hiddenCount > 0 && !isFullyExpanded { + Button(action: onToggleFullExpand) { + Text("View \(hiddenCount) more") + .font(.caption) + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + .padding(.horizontal, 16) + .padding(.vertical, 8) + } else if isFullyExpanded && hiddenCount > 0 { + Button(action: onToggleFullExpand) { + Text("Show less") + .font(.caption) + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + } + .padding(.bottom, 4) + } + } + } + } + + @ViewBuilder + private func sessionRow(_ session: Session) -> some View { + let isActiveSession = session.id == activeSessionId + + Button { + onSelectSession(session.id) + } label: { + HStack(spacing: 8) { + if isActiveSession { + Circle() + .fill(Color.accentColor) + .frame(width: 6, height: 6) + } + + Text(session.title ?? String(session.id.prefix(8))) + .font(.subheadline) + .foregroundStyle(isActiveSession ? .primary : .secondary) + .lineLimit(1) + + Spacer(minLength: 0) + + Text(relativeTime(session.updatedAt)) + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + isActiveSession + ? Color(.tertiarySystemFill) + : Color.clear, + in: RoundedRectangle(cornerRadius: 8) + ) + .padding(.horizontal, 8) + } + .buttonStyle(.plain) + } + + private func relativeTime(_ instant: KotlinInstant) -> String { + let epochMs = instant.toEpochMilliseconds() + let date = Date(timeIntervalSince1970: TimeInterval(epochMs) / 1000.0) + let interval = Date().timeIntervalSince(date) + + if interval < 60 { return "now" } + if interval < 3600 { return "\(Int(interval / 60))m" } + if interval < 86400 { return "\(Int(interval / 3600))h" } + if interval < 604800 { return "\(Int(interval / 86400))d" } + return "\(Int(interval / 604800))w" + } +} diff --git a/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift b/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift new file mode 100644 index 0000000..164d82f --- /dev/null +++ b/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift @@ -0,0 +1,201 @@ +import SwiftUI +import ComposeApp + +@MainActor +struct WorkspacesSidebarView: View { + let viewModel: SidebarViewModel + let onClose: () -> Void + let onSelectSession: () -> Void + + @StateObject private var uiStateEvents = KmpUiEventBridge() + @State private var latestUiState: SidebarUiState? + @State private var expanded: Set = [] + @State private var fullyExpanded: Set = [] + @State private var isShowingAddWorkspace = false + @State private var draftDirectory = "" + @State private var pendingAddWorkspace = false + @State private var addWorkspaceErrorMessage: String? + @State private var isShowingAddWorkspaceError = false + @State private var operationErrorMessage: String? + @State private var isShowingOperationError = false + @State private var hasSeededInitialExpansion = false + + var body: some View { + Group { + if let state = latestUiState { + sidebarContent(state: state) + } else { + ProgressView() + } + } + .navigationTitle("Workspaces") + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button(action: onClose) { + Image(systemName: "chevron.left") + } + } + + ToolbarItem(placement: .topBarTrailing) { + if let state = latestUiState, state.isCreatingWorkspace { + ProgressView() + .controlSize(.small) + } else { + Button(action: { isShowingAddWorkspace = true }) { + Image(systemName: "plus") + } + } + } + } + .sheet(isPresented: $isShowingAddWorkspace) { + addWorkspaceSheet + } + .onAppear { + uiStateEvents.start(flow: viewModel.uiState) { state in + let previousState = latestUiState + latestUiState = state + + if !hasSeededInitialExpansion, let activeId = state.activeWorkspaceId { + hasSeededInitialExpansion = true + expanded.insert(activeId) + viewModel.loadSessionsForWorkspace(projectId: activeId) + } + + if pendingAddWorkspace, + !state.isCreatingWorkspace, + previousState?.isCreatingWorkspace == true { + pendingAddWorkspace = false + + if let error = state.workspaceCreationError, !error.isEmpty { + addWorkspaceErrorMessage = error + isShowingAddWorkspaceError = true + } else { + draftDirectory = "" + isShowingAddWorkspace = false + } + } + + if let error = state.operationErrorMessage, + !error.isEmpty, + previousState?.operationErrorMessage != error { + operationErrorMessage = error + isShowingOperationError = true + } + } + } + .onDisappear { + uiStateEvents.stop() + } + } + + @ViewBuilder + private func sidebarContent(state: SidebarUiState) -> some View { + ScrollView { + LazyVStack(spacing: 4) { + ForEach(state.workspaces, id: \.workspace.projectId) { workspaceWithSessions in + let projectId = workspaceWithSessions.workspace.projectId + let isExp = expanded.contains(projectId) + let isFull = fullyExpanded.contains(projectId) + + WorkspaceCardView( + workspaceWithSessions: workspaceWithSessions, + activeSessionId: state.activeSessionId, + isExpanded: isExp, + isFullyExpanded: isFull, + isCreatingSession: state.isCreatingSession, + onToggleExpand: { + withAnimation(.easeInOut(duration: 0.25)) { + if expanded.contains(projectId) { + expanded.remove(projectId) + } else { + expanded.insert(projectId) + viewModel.loadSessionsForWorkspace(projectId: projectId) + } + } + }, + onToggleFullExpand: { + withAnimation(.easeInOut(duration: 0.25)) { + if fullyExpanded.contains(projectId) { + fullyExpanded.remove(projectId) + } else { + fullyExpanded.insert(projectId) + } + } + }, + onSelectSession: { sessionId in + let isActiveWorkspace = projectId == state.activeWorkspaceId + if isActiveWorkspace { + viewModel.switchSession(sessionId: sessionId) + } else { + viewModel.switchWorkspace(projectId: projectId, sessionId: sessionId) + } + }, + onCreateSession: { + viewModel.createSession(workspaceProjectId: projectId) + } + ) + .disabled(state.isSwitchingWorkspace || state.isSwitchingSession || state.isCreatingSession) + } + } + .padding(.horizontal, 12) + .padding(.top, 8) + .alert("Action Failed", isPresented: $isShowingOperationError) { + Button("OK", role: .cancel) { + operationErrorMessage = nil + viewModel.clearOperationError() + } + } message: { + Text(operationErrorMessage ?? "Something went wrong") + } + } + } + + @ViewBuilder + private var addWorkspaceSheet: some View { + NavigationStack { + Form { + Section { + TextField("Directory path", text: $draftDirectory) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .disabled(latestUiState?.isCreatingWorkspace == true) + } footer: { + Text("Enter the full directory path on the server machine.") + } + } + .navigationTitle("Add Workspace") + .navigationBarTitleDisplayMode(.inline) + .interactiveDismissDisabled(latestUiState?.isCreatingWorkspace == true) + .alert("Couldn’t Add Workspace", isPresented: $isShowingAddWorkspaceError) { + Button("OK", role: .cancel) { + addWorkspaceErrorMessage = nil + } + } message: { + Text(addWorkspaceErrorMessage ?? "Failed to add workspace") + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + draftDirectory = "" + pendingAddWorkspace = false + addWorkspaceErrorMessage = nil + isShowingAddWorkspace = false + } + .disabled(latestUiState?.isCreatingWorkspace == true) + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + let trimmed = draftDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + pendingAddWorkspace = true + viewModel.addWorkspace(directoryInput: trimmed) + } + .disabled( + draftDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || + latestUiState?.isCreatingWorkspace == true + ) + } + } + } + } +}