Skip to content

feat: add sidebar-based workspace navigation#1

Merged
ratulsarna merged 27 commits intomainfrom
feature/sidebar-navigation-rework
Mar 16, 2026
Merged

feat: add sidebar-based workspace navigation#1
ratulsarna merged 27 commits intomainfrom
feature/sidebar-navigation-rework

Conversation

@ratulsarna
Copy link
Owner

@ratulsarna ratulsarna commented Mar 15, 2026

Summary

  • replace the old sessions/workspaces flows with a sidebar-driven workspace and session browser wired through a new shared SidebarViewModel
  • update the chat chrome to use the sidebar entry point, show the active session title and workspace path, and fix toolbar/sidebar interactions after the navigation refactor
  • fix workspace-specific session loading, workspace-switch session persistence, add-workspace failure handling, and restore dynamic TEAM_ID usage in the Xcode project

Testing

  • ./gradlew :composeApp:jvmTest
  • ./gradlew :composeApp:jvmTest --tests 'com.ratulsarna.ocmobile.ui.screen.sidebar.SidebarViewModelTest'
  • xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp -destination 'generic/platform=iOS Simulator' build

Summary by CodeRabbit

  • New Features

    • Unified sidebar for managing workspaces and sessions with in-sidebar session lists and workspace cards
    • Directory-based session filtering
    • Session title and workspace path shown in chat header; sidebar toggle button added
  • Removed

    • Dedicated Sessions and Workspaces screens replaced by the new sidebar flow

ratulsarna and others added 23 commits March 15, 2026 13:48
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Correct file paths, API method names, test source set,
add session-workspace association and cross-reset persistence details.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
8 tasks across 4 chunks: SidebarViewModel, sidebar UI,
navigation/toolbar rewiring, and cleanup/deletion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
On re-expand, previously-loaded sessions display instantly while
a background fetch updates them. Loading indicator only shows on
first fetch when there are no cached sessions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…kspace path to toolbar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ring

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
The GlassEffect type cannot be referenced directly as a variable type.
Use inline .glassEffect() calls matching the existing codebase pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove glass card wrapping, use plain rows with subtle backgrounds
- Remove chevron from workspace headers
- Add relative timestamps to session rows (now, 1h, 2d)
- Active session gets subtle highlight background + accent dot
- Workspaces stay in natural order (no sorting active to top)
- Remove isActive param — cards are visually uniform
- Cleaner spacing and lighter visual weight throughout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
On iPhone, NavigationSplitView collapses into a stack — setting
columnVisibility programmatically doesn't pop the sidebar.
Use @Environment(\.dismiss) to properly navigate back.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
NavigationSplitView on iPhone doesn't support programmatic dismissal —
columnVisibility changes are ignored on compact widths. Switch to
pushing the sidebar as a .sidebar route on the existing NavigationStack.
Tapping a session pops the route to return to chat.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Updated `getSessions` method across multiple interfaces and implementations to include an optional `directory` parameter.
- Adjusted the logic in `MockState` and `MockOpenCodeApi` to filter sessions based on the provided directory.
- Modified `SidebarViewModel` to utilize the new directory parameter when loading sessions for a workspace.
- Updated related tests to ensure proper functionality with the new directory filtering feature.
…specs

- Deleted the implementation plan and design specification documents for the sidebar navigation rework.
- This change is part of the transition to a unified sidebar that integrates workspaces and sessions, streamlining the user experience.
- Updated the `addWorkspace` method to expose workspace creation errors in the UI state.
- Introduced `workspaceCreationError` property in `SidebarUiState` to store error messages.
- Modified the `WorkspacesSidebarView` to display an alert for workspace creation errors.
- Added tests to verify error handling during workspace addition failures.
- Replaced hardcoded DEVELOPMENT_TEAM identifier with a dynamic variable "$(TEAM_ID)" in multiple project settings.
- This change enhances flexibility for team configurations across different environments.
@coderabbitai
Copy link

coderabbitai bot commented Mar 15, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 410d34fe-ed1c-4520-a77e-e388689e726c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Consolidates Sessions and Workspaces view models into a new SidebarViewModel, adds optional directory filtering to session queries across API/repository/mock layers, removes legacy Sessions/Workspaces UI and tests, and introduces an iOS sidebar UI with new components and toolbar changes. Also updates .gitignore for Git worktrees.

Changes

Cohort / File(s) Summary
ViewModel Owners & DI
composeApp/src/.../IosViewModelOwners.kt, composeApp/src/.../di/AppModule.kt
Removed Sessions/Workspaces view-model exposure; added SidebarViewModel factory and iOS accessor.
New Sidebar ViewModel
composeApp/src/.../ui/screen/sidebar/SidebarViewModel.kt
Added SidebarViewModel and related UI state types managing workspaces, per-workspace session caching, switching, creation, and error handling.
API: directory filter
composeApp/src/.../data/api/OpenCodeApi.kt, composeApp/src/.../data/api/OpenCodeApiImpl.kt
Added optional directory: String? parameter to getSessions; implementation sets/overrides header when provided.
Repository + Domain
composeApp/src/.../data/repository/SessionRepositoryImpl.kt, composeApp/src/.../domain/repository/SessionRepository.kt
Propagated directory: String? parameter through repository API calls and domain interface.
Mocks & State
composeApp/src/.../data/mock/MockOpenCodeApi.kt, composeApp/src/.../data/mock/MockState.kt
Updated mock signatures and added directory-based filtering in mock state lookup.
Removed ViewModels & Tests
composeApp/src/.../ui/screen/sessions/SessionsViewModel.kt, composeApp/src/.../ui/screen/workspaces/WorkspacesViewModel.kt, composeApp/src/jvmTest/.../ui/screen/sessions/SessionsViewModelTest.kt
Deleted SessionsViewModel, WorkspacesViewModel, and sessions test file (large removals).
New Sidebar Tests
composeApp/src/jvmTest/.../ui/screen/sidebar/SidebarViewModelTest.kt
Added comprehensive unit tests for SidebarViewModel (workspace/session loading, directory filtering, switching, creation, errors).
Test adjustments for API change
composeApp/src/jvmTest/.../AgentRepositoryDefaultSelectionTest.kt, .../ModelRepositoryDefaultSelectionTest.kt, .../WorkspaceRepositoryTest.kt, .../ChatViewModel*Test.kt
Updated test doubles/overrides to include new directory parameter in getSessions signatures.
iOS Chat Toolbar & Init
iosApp/iosApp/ChatUIKit/ChatScreenChromeView.swift, iosApp/iosApp/ChatUIKit/SwiftUIChatUIKitView.swift
Replaced onOpenSessions with onToggleSidebar; added sessionTitle and workspacePath props and adjusted toolbar/layout.
iOS Root & Routing
iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift
Removed workspaces route; integrated sidebar state, activeSessionTitle, and sidebar presentation/handlers; wired sidebar lifecycle and reactions.
New iOS Sidebar UI
iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift, iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift
Added WorkspaceCardView and WorkspacesSidebarView for collapsible workspace/session lists, add-workspace flow, and selection callbacks.
Removed iOS Session/Workspace Views & Settings hooks
iosApp/iosApp/SwiftUIInterop/SwiftUISessionsViews.swift, .../SwiftUIWorkspacesViews.swift, .../SwiftUISettingsViews.swift
Removed separate SwiftUI sessions and workspaces views and removed workspace/session navigation from Settings view.
Git config
.gitignore
Added .worktrees/ entry to ignore Git worktrees directory.

Sequence Diagram

sequenceDiagram
    participant User
    participant SidebarUI as "WorkspacesSidebarView / iOS UI"
    participant SVM as "SidebarViewModel"
    participant WSRepo as "WorkspaceRepository"
    participant SRepo as "SessionRepository"
    participant Settings as "AppSettings"

    User->>SidebarUI: open sidebar
    SidebarUI->>SVM: init / request workspaces
    SVM->>WSRepo: getWorkspaces()
    WSRepo-->>SVM: workspaces list
    SVM->>Settings: get activeWorkspaceId
    Settings-->>SVM: activeWorkspaceId
    SVM->>SRepo: getSessions(directory=workspaceDirectory)
    SRepo-->>SVM: sessions (filtered by directory)
    SVM-->>SidebarUI: emit SidebarUiState (workspaces + sessions)
    User->>SidebarUI: select workspace / session
    SidebarUI->>SVM: switchWorkspace(projectId, sessionId?)
    SVM->>Settings: persist current session id
    SVM->>WSRepo: activateWorkspace(projectId)
    WSRepo-->>SVM: activation result
    SVM->>SRepo: getSessions(directory=newWorkspaceDirectory)
    SRepo-->>SVM: sessions for new workspace
    SVM-->>SidebarUI: update UI state (active workspace, sessions)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I thumped through folders, found sessions and trees,

I stitched them together with elegant ease.
A sidebar now hums where two views used to be,
Sessions and workspaces, united and free.
Hop, click, switch — the warren sings with glee!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: adding sidebar-based workspace navigation, which is the primary architectural shift evident across all modified files.
Description check ✅ Passed The description covers the main objectives (sidebar implementation, chat chrome updates, workspace-specific fixes) and includes comprehensive testing commands, though it lacks a dedicated Testing section header as specified in the template.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/sidebar-navigation-rework
📝 Coding Plan
  • Generate coding plan for human review comments

Comment @coderabbitai help to get the list of available commands and usage tips.

@ratulsarna
Copy link
Owner Author

@coderabbitai review

@ratulsarna
Copy link
Owner Author

@codex review

@coderabbitai
Copy link

coderabbitai bot commented Mar 15, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (5)
composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModel.kt (2)

196-225: Consider providing user feedback on session creation failure.

Similar to switchWorkspace, the createSession function logs failures but doesn't update UI state with an error message for the user.

♻️ Add error feedback to UI state
 fun createSession(workspaceProjectId: String) {
     if (_uiState.value.isCreatingSession) return
-    _uiState.update { it.copy(isCreatingSession = true) }
+    _uiState.update { it.copy(isCreatingSession = true, sessionCreationError = null) }

     viewModelScope.launch {
         // ... existing logic ...
         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) }
+                _uiState.update { it.copy(
+                    isCreatingSession = false,
+                    sessionCreationError = error.message ?: "Failed to create session"
+                ) }
             }
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModel.kt`
around lines 196 - 225, The createSession flow logs failures but never surfaces
an error to the UI; update the _uiState in both failure branches so the UI
receives an error message and resets isCreatingSession. Specifically, modify
createSession (and the failure lambda after
workspaceRepository.activateWorkspace and the onFailure after
sessionRepository.createSession) to set a new/appropriate error field on
_uiState (e.g., sessionCreationError or errorMessage) and clear/reset
createdSessionId/switchedWorkspaceId as needed, while still setting
isCreatingSession = false so the UI can display the error to the user.

169-190: Consider providing user feedback on workspace switch failure.

The switchWorkspace function logs failures but doesn't surface errors to the UI state. Users won't know if the workspace switch failed.

♻️ Add error feedback to UI state
 fun switchWorkspace(projectId: String, sessionId: String?) {
     if (_uiState.value.isSwitchingWorkspace) return

-    _uiState.update { it.copy(isSwitchingWorkspace = true) }
+    _uiState.update { it.copy(isSwitchingWorkspace = true, workspaceSwitchError = null) }

     viewModelScope.launch {
         workspaceRepository.activateWorkspace(projectId)
             .onSuccess {
                 if (sessionId != null) {
                     sessionRepository.updateCurrentSessionId(sessionId)
                         .onFailure { error ->
                             OcMobileLog.w(TAG, "Failed to persist session $sessionId for workspace $projectId: ${error.message}")
                         }
                 }
                 _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) }
+                _uiState.update { it.copy(
+                    isSwitchingWorkspace = false,
+                    workspaceSwitchError = error.message ?: "Failed to switch workspace"
+                ) }
             }
     }
 }

And add to SidebarUiState:

data class SidebarUiState(
    // ... existing fields ...
    val workspaceSwitchError: String? = null
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModel.kt`
around lines 169 - 190, The switchWorkspace function logs failures but never
surfaces them to the UI; add a nullable workspaceSwitchError:String? property to
SidebarUiState and update _uiState inside switchWorkspace: clear
workspaceSwitchError (set null) when activation succeeds (alongside setting
isSwitchingWorkspace = false and switchedWorkspaceId = projectId), and set
workspaceSwitchError to a user-friendly message (e.g., error.message) when
workspaceRepository.activateWorkspace or
sessionRepository.updateCurrentSessionId fail; update the onSuccess and
onFailure handlers in switchWorkspace (referencing switchWorkspace, _uiState,
workspaceRepository.activateWorkspace, sessionRepository.updateCurrentSessionId,
isSwitchingWorkspace, switchedWorkspaceId) so the UI can display the error.
iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift (2)

162-172: Consider using RelativeDateTimeFormatter for localization support.

The manual time formatting works but won't adapt to user locale settings. For production, consider using RelativeDateTimeFormatter for proper localization.

♻️ Optional: Use RelativeDateTimeFormatter
private func relativeTime(_ instant: KotlinInstant) -> String {
    let epochMs = instant.toEpochMilliseconds()
    let date = Date(timeIntervalSince1970: TimeInterval(epochMs) / 1000.0)
    let formatter = RelativeDateTimeFormatter()
    formatter.unitsStyle = .abbreviated
    return formatter.localizedString(for: date, relativeTo: Date())
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift` around lines 162 - 172,
The relativeTime(_ instant: KotlinInstant) function builds durations manually
and doesn't localize; replace the manual formatting with
RelativeDateTimeFormatter to respect user locales: convert the KotlinInstant to
a Date as you do now, create a RelativeDateTimeFormatter (set unitsStyle =
.abbreviated if you want compact output), and return
formatter.localizedString(for: date, relativeTo: Date()); update the
relativeTime function to use this formatter so outputs are localized and
consistent across locales.

31-33: Remove unnecessary Int() cast.

The sessions property is a Kotlin List<Session>, which bridges to a Swift Array<Session> in KMM. The .count property on Swift arrays is already an Int, so the explicit Int() conversion on line 32 is redundant. Simplify to:

Suggested change
private var hiddenCount: Int {
    max(0, workspaceWithSessions.sessions.count - 3)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift` around lines 31 - 33,
In WorkspaceCardView, simplify the hiddenCount computed property by removing the
unnecessary Int(...) cast around workspaceWithSessions.sessions.count —
sessions.count is already an Int in Swift (bridged from KMM), so update
hiddenCount to compute max(0, workspaceWithSessions.sessions.count - 3) using
the existing hiddenCount property and workspaceWithSessions.sessions references.
composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModelTest.kt (1)

105-117: Decouple this assertion from init-side prefetching.

The exact requestedDirectories list now depends on the active workspace being auto-loaded once during startup. Clearing the list after the initial advanceUntilIdle() (or asserting only the last call) would make this test validate the proj-2 directory propagation directly.

Proposed tightening
         val vm = SidebarViewModel(
             workspaceRepository = repo,
             sessionRepository = sessionRepo,
             appSettings = appSettings
         )
         advanceUntilIdle()
+        requestedDirectories.clear()
 
         vm.loadSessionsForWorkspace("proj-2")
         advanceUntilIdle()
 
         val loaded = vm.uiState.value.workspaces.first { it.workspace.projectId == "proj-2" }
-        assertEquals(listOf<String?>("/path/to/project-a", "/path/to/project-b"), requestedDirectories)
+        assertEquals(listOf<String?>("/path/to/project-b"), requestedDirectories)
         assertEquals(listOf("ses-b"), loaded.sessions.map { it.id })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModelTest.kt`
around lines 105 - 117, The test's assertion on requestedDirectories is polluted
by the ViewModel's init prefetch; after creating SidebarViewModel and calling
advanceUntilIdle(), clear or reset the requestedDirectories collection (or
capture/replace it) before calling vm.loadSessionsForWorkspace("proj-2") and the
second advanceUntilIdle(), then assert only the subsequent entries (or assert
the last call) to verify proj-2 propagation; update the SidebarViewModelTest to
reset requestedDirectories (or assert requestedDirectories.last() / the
post-load snapshot) and keep the existing vm.loadSessionsForWorkspace("proj-2")
and loaded.sessions assertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModelTest.kt`:
- Around line 319-321: The fake's getActiveWorkspaceSnapshot() and
ensureInitialized() are returning the initial constructor value instead of the
mutable state updated by activateWorkspace(), causing stale reads; modify the
fake so both getActiveWorkspaceSnapshot() and ensureInitialized() read from the
mutable activeWorkspace backing field (the one that activateWorkspace() updates)
rather than a captured constructor parameter, and ensure any other similar
methods around the other block (lines referencing the same fake between 324-330)
also reference that mutable property so workspace switches are reflected
immediately.
- Around line 173-214: The test currently only checks final state; modify
SidebarViewModel_switchWorkspacePersistsSessionIdBeforeActivating to record the
call order and assert the session update occurs before workspace activation: add
a shared mutable list (e.g., callOrder) captured by FakeSessionRepository's
updateCurrentSessionIdHandler (push "updateSession" or include sessionId) and by
FakeWorkspaceRepository's activateHandler (push "activate:<id>"), then after
vm.switchWorkspace(...) assert that callOrder shows the update entry appears
before the activate entry (e.g., ["updateSession","activate:proj-2"]); keep
existing final-state assertions but add this ordering assertion to prove
updateCurrentSessionId() runs before activateWorkspace().

In `@iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift`:
- Around line 80-92: The .task(id:) handlers in WorkspacesSidebarView are
performing one-shot side effects (consuming latestUiState?.switchedWorkspaceId
and latestUiState?.createdSessionId, calling viewModel.clearWorkspaceSwitch(),
viewModel.clearCreatedSession(), onRequestAppReset(), and onSelectSession())
inside a transient sidebar view; move that logic out of WorkspacesSidebarView
and into the always-on collector in SwiftUIAppRootView (which already observes
the shared SidebarViewModel/uiState), so the app-scoped SidebarViewModel flags
are consumed and cleared there to avoid stale latched behavior when the sidebar
is dismissed; update WorkspacesSidebarView to remove these .task handlers and
ensure SwiftUIAppRootView performs the checks for switchedWorkspaceId and
createdSessionId, calls viewModel.clearWorkspaceSwitch()/clearCreatedSession(),
and then invokes onRequestAppReset()/onSelectSession() as appropriate.
- Around line 13-14: The code uses expanded.isEmpty to detect "first load" which
breaks once the user collapses everything; introduce a dedicated one-time flag
(e.g. `@State` private var didAutoExpandInitially: Bool = false) in
WorkspacesSidebarView and replace checks that rely on expanded.isEmpty (and the
analogous fullyExpanded usage) with !didAutoExpandInitially && /* other
conditions */; after performing the initial auto-expand and calling
loadSessionsForWorkspace, set didAutoExpandInitially = true so subsequent
uiState emissions don't re-expand workspaces when the user has manually
collapsed them.
- Around line 48-50: The sheet bound to isShowingAddWorkspace should be
prevented from interactive swipe-to-dismiss while workspace creation is in
progress: add an interactiveDismissDisabled(...) modifier to the sheet (on
addWorkspaceSheet or the sheet content) and bind it to the creation-running flag
(create a `@State/`@Published isCreatingWorkspace if none exists), update that
flag to true when creation starts and false when creation finishes, and only set
isShowingAddWorkspace = false after success/failure handling completes so the
sheet cannot be dismissed mid-operation.

---

Nitpick comments:
In
`@composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModel.kt`:
- Around line 196-225: The createSession flow logs failures but never surfaces
an error to the UI; update the _uiState in both failure branches so the UI
receives an error message and resets isCreatingSession. Specifically, modify
createSession (and the failure lambda after
workspaceRepository.activateWorkspace and the onFailure after
sessionRepository.createSession) to set a new/appropriate error field on
_uiState (e.g., sessionCreationError or errorMessage) and clear/reset
createdSessionId/switchedWorkspaceId as needed, while still setting
isCreatingSession = false so the UI can display the error to the user.
- Around line 169-190: The switchWorkspace function logs failures but never
surfaces them to the UI; add a nullable workspaceSwitchError:String? property to
SidebarUiState and update _uiState inside switchWorkspace: clear
workspaceSwitchError (set null) when activation succeeds (alongside setting
isSwitchingWorkspace = false and switchedWorkspaceId = projectId), and set
workspaceSwitchError to a user-friendly message (e.g., error.message) when
workspaceRepository.activateWorkspace or
sessionRepository.updateCurrentSessionId fail; update the onSuccess and
onFailure handlers in switchWorkspace (referencing switchWorkspace, _uiState,
workspaceRepository.activateWorkspace, sessionRepository.updateCurrentSessionId,
isSwitchingWorkspace, switchedWorkspaceId) so the UI can display the error.

In
`@composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModelTest.kt`:
- Around line 105-117: The test's assertion on requestedDirectories is polluted
by the ViewModel's init prefetch; after creating SidebarViewModel and calling
advanceUntilIdle(), clear or reset the requestedDirectories collection (or
capture/replace it) before calling vm.loadSessionsForWorkspace("proj-2") and the
second advanceUntilIdle(), then assert only the subsequent entries (or assert
the last call) to verify proj-2 propagation; update the SidebarViewModelTest to
reset requestedDirectories (or assert requestedDirectories.last() / the
post-load snapshot) and keep the existing vm.loadSessionsForWorkspace("proj-2")
and loaded.sessions assertion.

In `@iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift`:
- Around line 162-172: The relativeTime(_ instant: KotlinInstant) function
builds durations manually and doesn't localize; replace the manual formatting
with RelativeDateTimeFormatter to respect user locales: convert the
KotlinInstant to a Date as you do now, create a RelativeDateTimeFormatter (set
unitsStyle = .abbreviated if you want compact output), and return
formatter.localizedString(for: date, relativeTo: Date()); update the
relativeTime function to use this formatter so outputs are localized and
consistent across locales.
- Around line 31-33: In WorkspaceCardView, simplify the hiddenCount computed
property by removing the unnecessary Int(...) cast around
workspaceWithSessions.sessions.count — sessions.count is already an Int in Swift
(bridged from KMM), so update hiddenCount to compute max(0,
workspaceWithSessions.sessions.count - 3) using the existing hiddenCount
property and workspaceWithSessions.sessions references.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a8b68600-93c9-4b4e-a184-b04628724916

📥 Commits

Reviewing files that changed from the base of the PR and between 0392249 and 37b865d.

📒 Files selected for processing (27)
  • .gitignore
  • composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/IosViewModelOwners.kt
  • composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/data/api/OpenCodeApi.kt
  • composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/data/api/OpenCodeApiImpl.kt
  • composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/data/mock/MockOpenCodeApi.kt
  • composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/data/mock/MockState.kt
  • composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/data/repository/SessionRepositoryImpl.kt
  • composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/di/AppModule.kt
  • composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/domain/repository/SessionRepository.kt
  • composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModel.kt
  • composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModel.kt
  • composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/workspaces/WorkspacesViewModel.kt
  • composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/data/repository/AgentRepositoryDefaultSelectionTest.kt
  • composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/data/repository/ModelRepositoryDefaultSelectionTest.kt
  • composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/domain/repository/WorkspaceRepositoryTest.kt
  • composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/chat/ChatViewModelBootstrapTest.kt
  • composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/chat/ChatViewModelSendDefaultsTest.kt
  • composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModelTest.kt
  • composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModelTest.kt
  • iosApp/iosApp/ChatUIKit/ChatScreenChromeView.swift
  • iosApp/iosApp/ChatUIKit/SwiftUIChatUIKitView.swift
  • iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift
  • iosApp/iosApp/SwiftUIInterop/SwiftUISessionsViews.swift
  • iosApp/iosApp/SwiftUIInterop/SwiftUISettingsViews.swift
  • iosApp/iosApp/SwiftUIInterop/SwiftUIWorkspacesViews.swift
  • iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift
  • iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift
💤 Files with no reviewable changes (6)
  • composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModelTest.kt
  • composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModel.kt
  • composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/workspaces/WorkspacesViewModel.kt
  • iosApp/iosApp/SwiftUIInterop/SwiftUIWorkspacesViews.swift
  • iosApp/iosApp/SwiftUIInterop/SwiftUISessionsViews.swift
  • iosApp/iosApp/SwiftUIInterop/SwiftUISettingsViews.swift

Comment on lines +173 to +214
fun SidebarViewModel_switchWorkspacePersistsSessionIdBeforeActivating() = runTest(dispatcher) {
val activatedIds = mutableListOf<String>()
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 ->
activatedIds.add(id)
Result.success(Unit)
}
)
val sessionRepo = FakeSessionRepository(
updateCurrentSessionIdHandler = { 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("proj-2"), activatedIds)
assertEquals("proj-2", vm.uiState.value.switchedWorkspaceId)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

This test doesn't prove “before activating” yet.

The current assertions only validate final state, so activateWorkspace() could happen before updateCurrentSessionId() and the test would still pass. Capture the call sequence and assert it explicitly.

Proposed change
-        val activatedIds = mutableListOf<String>()
+        val operations = mutableListOf<String>()
         val appSettings = MockAppSettings()
         appSettings.setActiveServerId("server-1")
         appSettings.setInstallationIdForServer("server-1", "inst-1")
@@
             workspaces = listOf(workspace1, workspace2),
             activeWorkspace = workspace1,
             appSettings = appSettings,
             activateHandler = { id ->
-                activatedIds.add(id)
+                operations.add("activate:$id")
                 Result.success(Unit)
             }
         )
         val sessionRepo = FakeSessionRepository(
             updateCurrentSessionIdHandler = { sessionId ->
+                operations.add("session:$sessionId")
                 appSettings.setCurrentSessionId(sessionId)
                 Result.success(Unit)
             }
         )
@@
         assertEquals("proj-2", appSettings.getActiveWorkspaceSnapshot()?.projectId)
         assertEquals("ses-target", appSettings.getCurrentSessionIdSnapshot())
-        assertEquals(listOf("proj-2"), activatedIds)
+        assertEquals(listOf("session:ses-target", "activate:proj-2"), operations)
         assertEquals("proj-2", vm.uiState.value.switchedWorkspaceId)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModelTest.kt`
around lines 173 - 214, The test currently only checks final state; modify
SidebarViewModel_switchWorkspacePersistsSessionIdBeforeActivating to record the
call order and assert the session update occurs before workspace activation: add
a shared mutable list (e.g., callOrder) captured by FakeSessionRepository's
updateCurrentSessionIdHandler (push "updateSession" or include sessionId) and by
FakeWorkspaceRepository's activateHandler (push "activate:<id>"), then after
vm.switchWorkspace(...) assert that callOrder shows the update entry appears
before the activate entry (e.g., ["updateSession","activate:proj-2"]); keep
existing final-state assertions but add this ordering assertion to prove
updateCurrentSessionId() runs before activateWorkspace().

Comment on lines +48 to +50
.sheet(isPresented: $isShowingAddWorkspace) {
addWorkspaceSheet
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Disable swipe-to-dismiss while workspace creation is running.

The buttons are disabled during creation, but the sheet can still be dismissed interactively. That lets the success/error path complete off-screen and undermines the new add-workspace failure handling.

Suggested fix
         .sheet(isPresented: $isShowingAddWorkspace) {
             addWorkspaceSheet
+                .interactiveDismissDisabled(latestUiState?.isCreatingWorkspace == true)
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift` around lines 48 -
50, The sheet bound to isShowingAddWorkspace should be prevented from
interactive swipe-to-dismiss while workspace creation is in progress: add an
interactiveDismissDisabled(...) modifier to the sheet (on addWorkspaceSheet or
the sheet content) and bind it to the creation-running flag (create a
`@State/`@Published isCreatingWorkspace if none exists), update that flag to true
when creation starts and false when creation finishes, and only set
isShowingAddWorkspace = false after success/failure handling completes so the
sheet cannot be dismissed mid-operation.

Comment on lines +80 to +92
.task(id: latestUiState?.switchedWorkspaceId ?? "") {
guard let switchedId = latestUiState?.switchedWorkspaceId, !switchedId.isEmpty else { return }
viewModel.clearWorkspaceSwitch()
onRequestAppReset()
}
.task(id: latestUiState?.createdSessionId ?? "") {
guard let sessionId = latestUiState?.createdSessionId, !sessionId.isEmpty else { return }
viewModel.clearCreatedSession()
if latestUiState?.switchedWorkspaceId != nil {
return
}
onSelectSession()
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Move these one-shot side effects out of the transient sidebar view.

SidebarViewModel is app-scoped, but these .task(id:) handlers only exist while the sidebar is presented. If the user closes the sidebar before createSession or switchWorkspace completes, createdSessionId / switchedWorkspaceId stays latched in the shared view model, and the next sidebar open can immediately close itself or fire onRequestAppReset(). SwiftUIAppRootView already has an always-on collector for this uiState, so that is the safer place to consume and clear these flags.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift` around lines 80 -
92, The .task(id:) handlers in WorkspacesSidebarView are performing one-shot
side effects (consuming latestUiState?.switchedWorkspaceId and
latestUiState?.createdSessionId, calling viewModel.clearWorkspaceSwitch(),
viewModel.clearCreatedSession(), onRequestAppReset(), and onSelectSession())
inside a transient sidebar view; move that logic out of WorkspacesSidebarView
and into the always-on collector in SwiftUIAppRootView (which already observes
the shared SidebarViewModel/uiState), so the app-scoped SidebarViewModel flags
are consumed and cleared there to avoid stale latched behavior when the sidebar
is dismissed; update WorkspacesSidebarView to remove these .task handlers and
ensure SwiftUIAppRootView performs the checks for switchedWorkspaceId and
createdSessionId, calls viewModel.clearWorkspaceSwitch()/clearCreatedSession(),
and then invokes onRequestAppReset()/onSelectSession() as appropriate.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 37b865da34

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +31 to +34
init {
ensureInitialized()
observeWorkspaces()
observeActiveSessionId()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Refresh workspaces when sidebar initializes

The sidebar startup path only calls ensureInitialized() and observers, but never triggers workspaceRepository.refresh(). Since ensureInitialized() seeds just the current project when local state is empty, users on a fresh install/pairing won’t see other server-known workspaces in the new sidebar unless they manually add each path, which regresses workspace switching compared with the previous workspaces flow that refreshed on first appearance.

Useful? React with 👍 / 👎.

Comment on lines +220 to +223
.onFailure { error ->
OcMobileLog.w(TAG, "Failed to create session: ${error.message}")
_uiState.update { it.copy(isCreatingSession = false) }
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle create-session failure after workspace activation

When creating a session in a non-active workspace, the code activates that workspace first, but on createSession failure it only clears isCreatingSession and emits no switch/reset signal. Because the UI reset is keyed off switchedWorkspaceId, this leaves global active workspace changed while the chat screen remains bound to the previous session, so subsequent chat calls can run under the wrong directory context after a transient create failure.

Useful? React with 👍 / 👎.

@ratulsarna
Copy link
Owner Author

@codex review

@ratulsarna
Copy link
Owner Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Mar 15, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0c54e0dd36

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +228 to +232
_uiState.update {
it.copy(
isCreatingSession = false,
switchedWorkspaceId = if (!isActiveWorkspace) workspaceProjectId else null
)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Surface session creation failures in sidebar state

When createSession fails, the state update only clears isCreatingSession (and may set switchedWorkspaceId) but does not store any error that the SwiftUI sidebar can render, so users see a silent no-op whenever the API rejects session creation or networking fails. The previous sessions flow exposed these failures, and this regression makes troubleshooting and recovery much harder for normal runtime errors.

Useful? React with 👍 / 👎.

Comment on lines +185 to +189
.onFailure { error ->
OcMobileLog.w(TAG, "Failed to persist session $sessionId for workspace $projectId: ${error.message}")
}
}
_uiState.update { it.copy(isSwitchingWorkspace = false, switchedWorkspaceId = projectId) }

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate workspace-switch completion on session ID persistence

This path marks the workspace switch as complete even if updateCurrentSessionId fails. In this commit, SwiftUIAppRootView treats switchedWorkspaceId as a successful switch and resets the app immediately, so any persistence failure leaves the app reset into the new workspace with a stale session selection. The impact appears when settings writes fail or are interrupted, but in that case users can be routed to the wrong/invalid session context.

Useful? React with 👍 / 👎.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModel.kt`:
- Around line 138-149: In the onSuccess block where you compute filtered and
call _uiState.update to set sessions and isLoading, also clear any previous
error for that workspace by setting error = null (or empty) on the matching
workspace copy; i.e., in the lambda that returns it.copy(sessions = filtered,
isLoading = false) change it to it.copy(sessions = filtered, isLoading = false,
error = null) so successful loads remove stale errors.
- Around line 181-189: The code currently sets _uiState.switchedWorkspaceId
immediately after workspaceRepository.activateWorkspace succeeds, even if the
subsequent sessionRepository.updateCurrentSessionId(sessionId) call fails;
change the flow so switchedWorkspaceId is only set when both
workspaceRepository.activateWorkspace(projectId) succeeds and, if sessionId !=
null, sessionRepository.updateCurrentSessionId(sessionId) also succeeds — i.e.,
move the _uiState.update { it.copy(isSwitchingWorkspace = false,
switchedWorkspaceId = projectId) } into the success branch of
updateCurrentSessionId (or set switchedWorkspaceId only in the onSuccess of
updateCurrentSessionId, and in the onFailure set isSwitchingWorkspace = false
without toggling switchedWorkspaceId) while keeping OcMobileLog.w(...) on update
failure.

In `@iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift`:
- Around line 84-133: The workspace cards remain tappable during a running
workspace switch; update the UI to prevent interactions while
state.isSwitchingWorkspace is true by disabling the card actions: add a
.disabled(state.isSwitchingWorkspace) modifier to the WorkspaceCardView (or its
parent view) so taps are ignored, and also guard the action closures (the
onSelectSession and onCreateSession blocks) to early-return when
state.isSwitchingWorkspace is true before calling viewModel.switchWorkspace,
viewModel.switchSession, viewModel.createSession or invoking onSelectSession().

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9bac496d-6f8e-49b8-938f-25127f740234

📥 Commits

Reviewing files that changed from the base of the PR and between 37b865d and 0c54e0d.

📒 Files selected for processing (4)
  • composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModel.kt
  • composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModelTest.kt
  • iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift
  • iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift

Comment on lines +138 to +149
.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)
} else it
})
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clear stale load errors on success.

The success path updates sessions and isLoading, but it leaves error untouched. After one failed refresh, later successful loads can still render the old error.

Suggested fix
                     _uiState.update { state ->
                         state.copy(workspaces = state.workspaces.map {
                             if (it.workspace.projectId == projectId) {
-                                it.copy(sessions = filtered, isLoading = false)
+                                it.copy(sessions = filtered, isLoading = false, error = null)
                             } else it
                         })
                     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModel.kt`
around lines 138 - 149, In the onSuccess block where you compute filtered and
call _uiState.update to set sessions and isLoading, also clear any previous
error for that workspace by setting error = null (or empty) on the matching
workspace copy; i.e., in the lambda that returns it.copy(sessions = filtered,
isLoading = false) change it to it.copy(sessions = filtered, isLoading = false,
error = null) so successful loads remove stale errors.

Comment on lines +181 to +189
workspaceRepository.activateWorkspace(projectId)
.onSuccess {
if (sessionId != null) {
sessionRepository.updateCurrentSessionId(sessionId)
.onFailure { error ->
OcMobileLog.w(TAG, "Failed to persist session $sessionId for workspace $projectId: ${error.message}")
}
}
_uiState.update { it.copy(isSwitchingWorkspace = false, switchedWorkspaceId = projectId) }
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t signal a completed workspace switch on partial failure.

updateCurrentSessionId(sessionId) is best-effort here, but switchedWorkspaceId is still published even when that write fails. SwiftUIAppRootView resets the app as soon as this flag is set, so this can reopen the new workspace with the previous session ID still stored.

Suggested fix
         viewModelScope.launch {
             workspaceRepository.activateWorkspace(projectId)
                 .onSuccess {
                     if (sessionId != null) {
-                        sessionRepository.updateCurrentSessionId(sessionId)
-                            .onFailure { error ->
-                                OcMobileLog.w(TAG, "Failed to persist session $sessionId for workspace $projectId: ${error.message}")
-                            }
+                        val persistResult = sessionRepository.updateCurrentSessionId(sessionId)
+                        if (persistResult.isFailure) {
+                            OcMobileLog.w(
+                                TAG,
+                                "Failed to persist session $sessionId for workspace $projectId: ${persistResult.exceptionOrNull()?.message}"
+                            )
+                            _uiState.update { it.copy(isSwitchingWorkspace = false) }
+                            return@onSuccess
+                        }
                     }
                     _uiState.update { it.copy(isSwitchingWorkspace = false, switchedWorkspaceId = projectId) }
                 }
                 .onFailure { error ->
                     OcMobileLog.w(TAG, "Failed to switch workspace: ${error.message}")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
workspaceRepository.activateWorkspace(projectId)
.onSuccess {
if (sessionId != null) {
sessionRepository.updateCurrentSessionId(sessionId)
.onFailure { error ->
OcMobileLog.w(TAG, "Failed to persist session $sessionId for workspace $projectId: ${error.message}")
}
}
_uiState.update { it.copy(isSwitchingWorkspace = false, switchedWorkspaceId = projectId) }
viewModelScope.launch {
workspaceRepository.activateWorkspace(projectId)
.onSuccess {
if (sessionId != null) {
val persistResult = sessionRepository.updateCurrentSessionId(sessionId)
if (persistResult.isFailure) {
OcMobileLog.w(
TAG,
"Failed to persist session $sessionId for workspace $projectId: ${persistResult.exceptionOrNull()?.message}"
)
_uiState.update { it.copy(isSwitchingWorkspace = false) }
return@onSuccess
}
}
_uiState.update { it.copy(isSwitchingWorkspace = false, switchedWorkspaceId = projectId) }
}
.onFailure { error ->
OcMobileLog.w(TAG, "Failed to switch workspace: ${error.message}")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModel.kt`
around lines 181 - 189, The code currently sets _uiState.switchedWorkspaceId
immediately after workspaceRepository.activateWorkspace succeeds, even if the
subsequent sessionRepository.updateCurrentSessionId(sessionId) call fails;
change the flow so switchedWorkspaceId is only set when both
workspaceRepository.activateWorkspace(projectId) succeeds and, if sessionId !=
null, sessionRepository.updateCurrentSessionId(sessionId) also succeeds — i.e.,
move the _uiState.update { it.copy(isSwitchingWorkspace = false,
switchedWorkspaceId = projectId) } into the success branch of
updateCurrentSessionId (or set switchedWorkspaceId only in the onSuccess of
updateCurrentSessionId, and in the onFailure set isSwitchingWorkspace = false
without toggling switchedWorkspaceId) while keeping OcMobileLog.w(...) on update
failure.

Comment on lines +84 to +133
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)
onSelectSession()
} else {
viewModel.switchWorkspace(projectId: projectId, sessionId: sessionId)
}
},
onCreateSession: {
viewModel.createSession(workspaceProjectId: projectId)
}
)
}
}
.padding(.horizontal, 12)
.padding(.top, 8)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Disable the workspace cards while a switch is running.

state.isSwitchingWorkspace is never applied here, so the list stays tappable during switchWorkspace(). A second tap can still fire createSession() or another selection against a half-switched state.

Suggested fix
     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)
                                 onSelectSession()
                             } else {
                                 viewModel.switchWorkspace(projectId: projectId, sessionId: sessionId)
                             }
                         },
                         onCreateSession: {
                             viewModel.createSession(workspaceProjectId: projectId)
                         }
                     )
                 }
             }
             .padding(.horizontal, 12)
             .padding(.top, 8)
         }
+        .disabled(state.isSwitchingWorkspace)
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)
onSelectSession()
} else {
viewModel.switchWorkspace(projectId: projectId, sessionId: sessionId)
}
},
onCreateSession: {
viewModel.createSession(workspaceProjectId: projectId)
}
)
}
}
.padding(.horizontal, 12)
.padding(.top, 8)
}
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)
onSelectSession()
} else {
viewModel.switchWorkspace(projectId: projectId, sessionId: sessionId)
}
},
onCreateSession: {
viewModel.createSession(workspaceProjectId: projectId)
}
)
}
}
.padding(.horizontal, 12)
.padding(.top, 8)
}
.disabled(state.isSwitchingWorkspace)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift` around lines 84 -
133, The workspace cards remain tappable during a running workspace switch;
update the UI to prevent interactions while state.isSwitchingWorkspace is true
by disabling the card actions: add a .disabled(state.isSwitchingWorkspace)
modifier to the WorkspaceCardView (or its parent view) so taps are ignored, and
also guard the action closures (the onSelectSession and onCreateSession blocks)
to early-return when state.isSwitchingWorkspace is true before calling
viewModel.switchWorkspace, viewModel.switchSession, viewModel.createSession or
invoking onSelectSession().

@ratulsarna
Copy link
Owner Author

@codex review

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 24fa7c9f58

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +127 to +129
if isActiveWorkspace {
viewModel.switchSession(sessionId: sessionId)
onSelectSession()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Surface session switch failures before closing sidebar

When a user taps a session in the active workspace, the sidebar is dismissed immediately, but the underlying switch call is fire-and-forget and only logs failures. If persisting the new session ID fails (for example due to a local settings write error), the UI closes as if the switch succeeded while chat remains on the previous session, with no error or retry path.

Useful? React with 👍 / 👎.

Comment on lines +113 to +117
if (workspace.isLoading) 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()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize cached session refreshes to avoid stale overwrites

This method only marks a workspace as loading when there is no cached data, so once sessions are cached the isLoading guard no longer prevents concurrent fetches. Because refreshes can be triggered repeatedly (e.g., workspace flow re-emits or users re-expand quickly) and there is no request cancellation/version check, a slower older response can overwrite a newer one and regress the sidebar list to stale sessions.

Useful? React with 👍 / 👎.

…iewModel

- Added logic to prevent overlapping session loading requests for the same workspace.
- Introduced a new method `clearSwitchedSession` to reset the switched session state.
- Enhanced error handling during session switching, updating the UI state accordingly.
- Updated tests to verify the new behavior for session loading and switching operations.
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8d185edec9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

)
}

loadActiveSessionTitle(sessionId = id)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh active session title after session metadata changes

activeSessionTitle is only fetched when currentSessionId changes, so title updates for the same session are never propagated to the toolbar. This shows up when a session starts untitled and the backend later assigns a title: the header can remain stale ("OpenCode") until the user switches sessions or resets the app, even though session metadata has changed.

Useful? React with 👍 / 👎.

viewModel.createSession(workspaceProjectId: projectId)
}
)
.disabled(state.isSwitchingWorkspace || state.isSwitchingSession)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Block session selection while session creation is in flight

The workspace cards stay interactive during isCreatingSession, because only switching flags are used in .disabled(...). If a user taps another session while creation is still running (slow network), the later createSession completion can persist its new session ID and override the user’s second selection, causing an unexpected context jump.

Useful? React with 👍 / 👎.

@ratulsarna ratulsarna merged commit d2a0821 into main Mar 16, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant