From f235fb829e87bf78b3a76dc51b89d9ddf172049f Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 15 Mar 2026 13:48:16 +0530 Subject: [PATCH 01/27] docs: add sidebar navigation rework design spec Co-Authored-By: Claude Opus 4.6 (1M context) --- ...-03-15-sidebar-navigation-rework-design.md | 253 ++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-15-sidebar-navigation-rework-design.md diff --git a/docs/superpowers/specs/2026-03-15-sidebar-navigation-rework-design.md b/docs/superpowers/specs/2026-03-15-sidebar-navigation-rework-design.md new file mode 100644 index 0000000..4a81221 --- /dev/null +++ b/docs/superpowers/specs/2026-03-15-sidebar-navigation-rework-design.md @@ -0,0 +1,253 @@ +# Sidebar Navigation Rework — Design Spec + +## Problem + +Workspaces and sessions are currently separate screens accessed through different paths — the Sessions sheet (modal from chat toolbar) and the Workspaces screen (pushed from Settings). This creates a disjointed experience where the two most closely related concepts (which project am I in, which conversation am I having) are managed in completely different places. + +## Solution + +Replace both with a unified sidebar that shows workspaces with nested sessions, accessed via a hamburger button on the chat toolbar. Use `NavigationSplitView` so the sidebar automatically adapts between iPhone (full push) and iPad (persistent sidebar) without code changes. + +## Design Decisions + +| Decision | Choice | Reasoning | +|----------|--------|-----------| +| Sidebar presentation | `NavigationSplitView` (full push on iPhone) | Native iPhone/iPad adaptation for free; forward-compatible | +| Sidebar content | Custom `ScrollView` + `LazyVStack` inside `NavigationSplitView` sidebar column | Avoids `List` styling conflicts with Liquid Glass | +| Workspace tap behavior | Expand/collapse session list | No separate "activate workspace" action; selecting a session implicitly activates its workspace | +| Default sessions shown | Top 3 per workspace (most recent) | Keeps sidebar compact; "View N more" CTA expands | +| "Add Workspace" location | Sidebar toolbar top-right `+` button | Always visible regardless of scroll position | +| "New Session" location | Per-workspace `+` button on each workspace row | Contextual to the workspace | +| Chat toolbar changes | Hamburger left, session title + workspace subtitle center, refresh + gear right | Simplified; sessions button removed; workspace/session context visible at a glance | +| Workspace path display | Leading ellipsis (`…/opencode-pocket`) | Shows the meaningful final folder name within limited toolbar space | +| Glass treatment | Glass on workspace cards; sessions are plain rows inside the card | Avoids cluttered glass-on-glass nesting; clear visual hierarchy | +| State management | New `SidebarViewModel` (Kotlin, app-scoped) absorbs `WorkspacesViewModel` + `SessionsViewModel` | Single source of truth for workspace + session state | + +## Navigation Architecture + +### Root View (`SwiftUIAppRootView`) + +``` +SwiftUIAppRootView +├── [NOT PAIRED] NavigationStack +│ └── SwiftUIConnectToOpenCodeView (full-screen, no hamburger, no sidebar) +│ +└── [PAIRED] NavigationSplitView(columnVisibility: $sidebarVisibility) + ├── sidebar: + │ └── WorkspacesSidebarView (custom ScrollView + LazyVStack) + │ + └── detail: + └── NavigationStack + ├── SwiftUIChatUIKitView (root) + └── .navigationDestination: + ├── .settings → SwiftUISettingsView + ├── .connect → SwiftUIConnectToOpenCodeView + ├── .modelSelection → SwiftUIModelSelectionView + └── .markdownFile → SwiftUIMarkdownFileViewerView +``` + +### Key Behaviors + +- `columnVisibility` is a `@State var sidebarVisibility: NavigationSplitViewVisibility`. +- Hamburger button toggles between `.detailOnly` (hidden) and `.doubleColumn` (shown). +- On iPhone, `.doubleColumn` presents the sidebar as a full-screen push. Tapping a session sets visibility back to `.detailOnly`. +- On iPad (future), the sidebar remains persistent in `.doubleColumn` — no code changes needed. +- The detail column retains its own `NavigationStack` for pushing Settings, Markdown viewer, etc. + +### First Run / Unpaired State + +- `NavigationSplitView` does not exist. The `[NOT PAIRED]` branch uses a plain `NavigationStack` with `SwiftUIConnectToOpenCodeView` as a full-screen experience. +- No hamburger, no sidebar, no toolbar. Identical to current behavior. +- Once pairing succeeds, app resets (new DI graph), root switches to the `[PAIRED]` branch. + +### Paired but Disconnected + +- Hamburger is present and functional. +- Sidebar shows last-known workspaces and sessions from cached state. +- Chat screen shows reconnecting banner as today. + +## Sidebar Content & Interaction + +### Layout + +``` +┌─────────────────────────────────────┐ +│ Workspaces [+] │ ← Sidebar toolbar: title + Add Workspace +├─────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────┐│ +│ │ 📁 opencode-pocket [+] ││ ← Workspace row (+ = new session) +│ ├─────────────────────────────────┤│ +│ │ ● Fix auth token refresh ││ ← Active session (accent dot) +│ │ Add image paste support ││ +│ │ Refactor chat toolbar ││ ← 3rd session +│ │ View 12 more sessions ▾ ││ ← "See more" CTA +│ └─────────────────────────────────┘│ +│ │ +│ ┌─────────────────────────────────┐│ +│ │ 📁 backend-api [+] ││ ← Collapsed workspace +│ └─────────────────────────────────┘│ +│ │ +└─────────────────────────────────────┘ +``` + +### Interaction Model + +| Action | Behavior | +|--------|----------| +| Tap workspace row | Toggle expand/collapse of that workspace's session list (animated) | +| Tap `[+]` on workspace row | Create new session in that workspace. If different workspace than active, triggers workspace switch (app reset) + new session | +| Tap session row (same workspace) | Switch to that session, dismiss sidebar | +| Tap session row (different workspace) | Trigger workspace switch (app reset), then activate that session | +| Tap "View N more sessions" | Expand to show all sessions for that workspace (animated). CTA changes to "Show less" | +| Tap toolbar `[+]` (Add Workspace) | Present inline text field or sheet for directory path entry | + +### Sidebar State + +- `expanded: Set` — which workspaces show sessions. **Active workspace expanded by default** when sidebar opens. Others collapsed. +- `fullyExpanded: Set` — which workspaces show all sessions (past initial 3). Default: none. +- Both are SwiftUI `@State` — UI-only, not business logic. + +### Session Ordering + +Sessions within each workspace are sorted by `updatedAt` descending. The top 3 shown by default are the most recent. + +## Chat Toolbar + +### Layout + +``` +[☰] "Fix auth token refresh" / "…/opencode-pocket" [↻] [⚙] +``` + +| Element | Description | +|---------|-------------| +| Left: Hamburger (`line.3.horizontal`) | Toggles sidebar visibility | +| Center line 1: Session title | Active session's title string | +| Center line 2: Workspace path | Last path component with leading ellipsis (`…/opencode-pocket`). If path is short enough, show without ellipsis | +| Right: Refresh button | Existing refresh action | +| Right: Settings gear | Pushes to Settings screen | + +### Overlay Elements (Unchanged) + +- Indeterminate processing bar (agent working) +- Reconnecting banner +- Error banner with Dismiss / Retry / Revert actions + +## Liquid Glass Treatment + +### Sidebar + +| Element | Treatment | +|---------|-----------| +| Workspace card (collapsed) | `.glassEffect(.regular.interactive(), in: .rect(cornerRadius: 12))` | +| Workspace card (expanded) | Same glass wrapping the entire card including session rows | +| Active workspace card | `.glassEffect(.regular.tint(.accentColor).interactive(), in: .rect(cornerRadius: 12))` | +| Session rows | No individual glass — content within the workspace card's glass surface, separated by subtle spacing | +| "View N more" CTA | No glass — plain text button in accent color | +| Per-workspace `[+]` button | `.buttonStyle(.glass)` | +| Toolbar `[+]` button | `.buttonStyle(.glass)` | + +### Design Rationale + +Sessions don't get their own glass to avoid glass-on-glass nesting, which looks cluttered. The workspace card is the glass container; sessions are content within it. This creates a clear visual hierarchy: glass cards = workspaces, plain rows inside = sessions. + +### Animations + +- Sidebar wrapped in `GlassEffectContainer`. +- Each workspace card uses `glassEffectID` with `@Namespace` for smooth glass morphing on expand/collapse. +- Session rows animate with `.transition(.opacity.combined(with: .move(edge: .top)))` inside `withAnimation`. + +### Chat Toolbar + +- Existing `ChatToolbarGlassView` retains its glass treatment. +- Hamburger button gets `.buttonStyle(.glass)`. + +### iOS Version Gating + +All `glassEffect` calls gated behind `#available(iOS 26, *)` with fallback to `.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))`. + +## Data Flow + +### New ViewModel: `SidebarViewModel` (Kotlin, app-scoped) + +```kotlin +data class SidebarUiState( + val workspaces: List, + val activeWorkspaceId: String?, + val activeSessionId: String?, + val isCreatingSession: Boolean, + val isCreatingWorkspace: Boolean, + val isSwitchingWorkspace: Boolean +) + +data class WorkspaceWithSessions( + val workspace: Workspace, + val sessions: List, // sorted by updatedAt desc + val isLoading: Boolean // loading sessions for this workspace +) +``` + +### Key Behaviors + +- On init, fetches workspaces. Sessions for the **active workspace** are fetched eagerly. Sessions for other workspaces are fetched **lazily** — only when that workspace is first expanded. +- `createSession(workspaceId)` — creates session, handles workspace switch if needed. +- `addWorkspace(path)` — adds workspace to the list. +- `switchSession(sessionId)` — for same-workspace session switches. +- `switchWorkspace(workspaceId, sessionId)` — triggers app reset + session activation. + +### Swift Side + +- `SidebarViewModel` held in `IosAppViewModelOwner` (app-scoped). +- `WorkspacesSidebarView` observes via `Observing(viewModel.uiState)` SKIE pattern. +- Expand/collapse state is SwiftUI `@State` only. + +## Deletions + +### Files to Delete + +| File | Reason | +|------|--------| +| `SwiftUISessionsSheetView.swift` | Replaced by sidebar | +| `SwiftUISessionsView.swift` | Replaced by sidebar | +| `SwiftUIWorkspacesView.swift` | Replaced by sidebar | +| `SessionsViewModel.kt` | Logic absorbed into `SidebarViewModel` | +| `WorkspacesViewModel.kt` | Logic absorbed into `SidebarViewModel` | + +### Settings Cleanup (`SwiftUISettingsView`) + +Remove: +- "Workspace" row +- "Sessions" row + +Keep: +- Status pills (Connected/Disconnected, Context usage) +- Connect to OpenCode row +- Model selection row +- Agent selection row/sheet +- Theme picker +- Advanced section + +### Navigation Routes (`SwiftUIAppRoute`) + +Remove: +- `.workspaces` + +Keep: +- `.connect` +- `.settings` +- `.modelSelection` +- `.markdownFile(path:openId:)` + +### DI Cleanup + +- Remove `SessionsViewModel` and `WorkspacesViewModel` from `IosAppViewModelOwner` / `IosScreenViewModelOwner`. +- Add `SidebarViewModel` to `IosAppViewModelOwner` (app-scoped). + +## New Files + +| File | Location | Purpose | +|------|----------|---------| +| `WorkspacesSidebarView.swift` | `iosApp/iosApp/SwiftUIInterop/` | Sidebar root view with toolbar and workspace list | +| `WorkspaceCardView.swift` | `iosApp/iosApp/SwiftUIInterop/` | Individual workspace card with expand/collapse, session rows, glass treatment | +| `SidebarViewModel.kt` | `composeApp/src/commonMain/kotlin/ui/screen/` | Combined workspace + session state management | From 7fd693228c174c90f449a975d318b4ff3541864d Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 15 Mar 2026 13:55:43 +0530 Subject: [PATCH 02/27] docs: fix spec review issues in sidebar navigation design 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) --- ...-03-15-sidebar-navigation-rework-design.md | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/specs/2026-03-15-sidebar-navigation-rework-design.md b/docs/superpowers/specs/2026-03-15-sidebar-navigation-rework-design.md index 4a81221..9e9c928 100644 --- a/docs/superpowers/specs/2026-03-15-sidebar-navigation-rework-design.md +++ b/docs/superpowers/specs/2026-03-15-sidebar-navigation-rework-design.md @@ -184,17 +184,36 @@ data class SidebarUiState( data class WorkspaceWithSessions( val workspace: Workspace, val sessions: List, // sorted by updatedAt desc - val isLoading: Boolean // loading sessions for this workspace + val isLoading: Boolean, // loading sessions for this workspace + val error: String? // per-workspace loading error ) ``` +### Session-Workspace Association + +Sessions are grouped into workspaces by matching `Session.directory` against `Workspace.worktree`. A session belongs to the workspace whose `worktree` path matches the session's `directory` field. + +### Session Fetching Strategy + +The current `SessionRepository.getSessions()` API fetches all sessions globally (no workspace/directory filter). The `SidebarViewModel` fetches all sessions once, then groups and filters client-side by matching `Session.directory` to each `Workspace.worktree`. This avoids API changes and is efficient given the expected session count. Lazy loading per-workspace means: the global fetch is triggered on first sidebar open, but the client-side grouping for a collapsed workspace is deferred until expansion. + +### Cross-Reset Session Persistence + +When `switchWorkspace(workspaceId, sessionId)` is called for a different workspace, the target session ID is written to `AppSettings` via `setCurrentSessionId(sessionId)` **before** triggering the app reset. After reset, the new DI graph reads `getCurrentSessionId()` during initialization and activates that session. This matches the existing pattern where `AppSettings` persists state across resets. + +Note: `SidebarUiState.activeSessionId` maps to `AppSettings.currentSessionId` — these refer to the same value, no new persistence key is needed. + ### Key Behaviors -- On init, fetches workspaces. Sessions for the **active workspace** are fetched eagerly. Sessions for other workspaces are fetched **lazily** — only when that workspace is first expanded. +- On init, fetches workspaces. Sessions are fetched globally and grouped client-side by matching `Session.directory` to `Workspace.worktree`. - `createSession(workspaceId)` — creates session, handles workspace switch if needed. - `addWorkspace(path)` — adds workspace to the list. - `switchSession(sessionId)` — for same-workspace session switches. -- `switchWorkspace(workspaceId, sessionId)` — triggers app reset + session activation. +- `switchWorkspace(workspaceId, sessionId)` — writes target session ID to `AppSettings`, then triggers app reset. + +### Dropped Feature: Session Search + +The current `SessionsView` has `.searchable` for filtering sessions. This is intentionally dropped from the initial sidebar implementation to keep scope focused. Session search can be added later as a filter field within the sidebar if needed. ### Swift Side @@ -208,11 +227,11 @@ data class WorkspaceWithSessions( | File | Reason | |------|--------| -| `SwiftUISessionsSheetView.swift` | Replaced by sidebar | -| `SwiftUISessionsView.swift` | Replaced by sidebar | -| `SwiftUIWorkspacesView.swift` | Replaced by sidebar | -| `SessionsViewModel.kt` | Logic absorbed into `SidebarViewModel` | -| `WorkspacesViewModel.kt` | Logic absorbed into `SidebarViewModel` | +| `iosApp/iosApp/SwiftUIInterop/SwiftUISessionsViews.swift` | Contains both `SwiftUISessionsSheetView` and `SwiftUISessionsView`. Replaced by sidebar | +| `iosApp/iosApp/SwiftUIInterop/SwiftUIWorkspacesViews.swift` | Contains `SwiftUIWorkspacesView`. Replaced by sidebar | +| `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModel.kt` | Logic absorbed into `SidebarViewModel` | +| `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/workspaces/WorkspacesViewModel.kt` | Logic absorbed into `SidebarViewModel` | +| `composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModelTest.kt` | Tests for deleted `SessionsViewModel` — migrate relevant cases to `SidebarViewModelTest` | ### Settings Cleanup (`SwiftUISettingsView`) @@ -241,8 +260,10 @@ Keep: ### DI Cleanup -- Remove `SessionsViewModel` and `WorkspacesViewModel` from `IosAppViewModelOwner` / `IosScreenViewModelOwner`. +- Remove `SessionsViewModel` from `IosScreenViewModelOwner`. +- Remove `WorkspacesViewModel` from `IosAppViewModelOwner`. - Add `SidebarViewModel` to `IosAppViewModelOwner` (app-scoped). +- `IosScreenViewModelOwner` retains only `markdownFileViewerViewModel()` after this change. ## New Files @@ -250,4 +271,4 @@ Keep: |------|----------|---------| | `WorkspacesSidebarView.swift` | `iosApp/iosApp/SwiftUIInterop/` | Sidebar root view with toolbar and workspace list | | `WorkspaceCardView.swift` | `iosApp/iosApp/SwiftUIInterop/` | Individual workspace card with expand/collapse, session rows, glass treatment | -| `SidebarViewModel.kt` | `composeApp/src/commonMain/kotlin/ui/screen/` | Combined workspace + session state management | +| `SidebarViewModel.kt` | `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/` | Combined workspace + session state management | From bcc8a4f6ecd0dcd64ae6d5921605c0ada76ccb3b Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 15 Mar 2026 14:09:30 +0530 Subject: [PATCH 03/27] docs: add sidebar navigation rework implementation plan 8 tasks across 4 chunks: SidebarViewModel, sidebar UI, navigation/toolbar rewiring, and cleanup/deletion. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-15-sidebar-navigation-rework.md | 1456 +++++++++++++++++ 1 file changed, 1456 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-15-sidebar-navigation-rework.md diff --git a/docs/superpowers/plans/2026-03-15-sidebar-navigation-rework.md b/docs/superpowers/plans/2026-03-15-sidebar-navigation-rework.md new file mode 100644 index 0000000..179d795 --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-sidebar-navigation-rework.md @@ -0,0 +1,1456 @@ +# Sidebar Navigation Rework — Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace separate Workspace/Sessions screens with a unified sidebar using `NavigationSplitView`, featuring workspaces with nested sessions and Liquid Glass styling. + +**Architecture:** `NavigationSplitView` at the root (when paired) with a custom sidebar containing workspace cards with expandable session lists. A new `SidebarViewModel` (Kotlin) absorbs logic from the deleted `SessionsViewModel` and `WorkspacesViewModel`. The detail column retains the existing chat + settings navigation stack. + +**Tech Stack:** Kotlin Multiplatform (shared ViewModel), SwiftUI (`NavigationSplitView`, Liquid Glass), SKIE (Kotlin-Swift bridging), UIKit (chat message list — unchanged) + +--- + +## File Structure + +### New Files + +| File | Responsibility | +|------|----------------| +| `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModel.kt` | Combined workspace + session state, fetching, switching, creation | +| `composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModelTest.kt` | Unit tests for SidebarViewModel | +| `iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift` | Sidebar root view: toolbar, ScrollView + LazyVStack of workspace cards | +| `iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift` | Single workspace card: glass surface, expand/collapse, session rows, + button | + +### Modified Files + +| File | Changes | +|------|---------| +| `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/di/AppModule.kt` | Add `createSidebarViewModel()` factory, keep old factories until deletion step | +| `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/IosViewModelOwners.kt` | Add `sidebarViewModel()` to `IosAppViewModelOwner`, remove `workspacesViewModel()` and `sessionsViewModel()` | +| `iosApp/iosApp/SwiftUIInterop/KmpOwners.swift` | No changes needed (accesses `IosAppViewModelOwner` which is KMP-bridged) | +| `iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift` | Replace `NavigationStack` with `NavigationSplitView`, remove sessions sheet, wire sidebar, remove `.workspaces` route | +| `iosApp/iosApp/ChatUIKit/ChatScreenChromeView.swift` | Replace sessions button with hamburger, update title/subtitle to session name + workspace path | +| `iosApp/iosApp/ChatUIKit/SwiftUIChatUIKitView.swift` | Replace `onOpenSessions` with `onToggleSidebar`, pass new toolbar data | +| `iosApp/iosApp/SwiftUIInterop/SwiftUISettingsViews.swift` | Remove Workspace row, Sessions section, and their callback parameters | + +### Deleted Files + +| File | Reason | +|------|--------| +| `iosApp/iosApp/SwiftUIInterop/SwiftUISessionsViews.swift` | Replaced by sidebar | +| `iosApp/iosApp/SwiftUIInterop/SwiftUIWorkspacesViews.swift` | Replaced by sidebar | +| `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModel.kt` | Logic absorbed into SidebarViewModel | +| `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/workspaces/WorkspacesViewModel.kt` | Logic absorbed into SidebarViewModel | +| `composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModelTest.kt` | Replaced by SidebarViewModelTest | + +--- + +## Chunk 1: SidebarViewModel (Kotlin) + +### Task 1: Create SidebarViewModel with UiState + +**Files:** +- Create: `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModel.kt` +- Create: `composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModelTest.kt` + +- [ ] **Step 1: Write the data classes and empty ViewModel** + +Create the file with the UiState data classes and an empty ViewModel shell: + +```kotlin +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 + +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()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + ensureInitialized() + observeWorkspaces() + observeActiveSessionId() + } + + private fun ensureInitialized() { + viewModelScope.launch { + workspaceRepository.ensureInitialized() + .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 { + it.copy( + activeWorkspaceId = active?.projectId, + workspaces = workspaces.map { workspace -> + val existing = it.workspaces.find { w -> w.workspace.projectId == workspace.projectId } + existing?.copy(workspace = workspace) ?: WorkspaceWithSessions(workspace = workspace) + } + ) + } + } + } + } + + private fun observeActiveSessionId() { + viewModelScope.launch { + appSettings.getCurrentSessionId().collect { id -> + _uiState.update { it.copy(activeSessionId = id) } + } + } + } + + /** + * Fetch sessions for a specific workspace. Called when a workspace is first expanded. + * Sessions are fetched globally and filtered client-side by matching + * Session.directory to Workspace.worktree. + */ + fun loadSessionsForWorkspace(projectId: String) { + val workspace = _uiState.value.workspaces.find { it.workspace.projectId == projectId } ?: return + if (workspace.isLoading) return + + _uiState.update { state -> + state.copy(workspaces = state.workspaces.map { + if (it.workspace.projectId == projectId) it.copy(isLoading = true, error = null) else it + }) + } + + viewModelScope.launch { + val start = kotlin.time.Clock.System.now().toEpochMilliseconds() - DEFAULT_RECENT_WINDOW_MS + sessionRepository.getSessions(search = null, limit = null, start = start) + .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 + }) + } + } + .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 + }) + } + } + } + } + + /** + * Switch to a session in the same workspace. + */ + fun switchSession(sessionId: String) { + viewModelScope.launch { + sessionRepository.updateCurrentSessionId(sessionId) + .onFailure { error -> + OcMobileLog.w(TAG, "Failed to switch session: ${error.message}") + } + } + } + + /** + * Switch to a different workspace and activate a specific session. + * Writes the target session ID to AppSettings before triggering the workspace switch, + * so it survives the app reset. + */ + fun switchWorkspace(projectId: String, sessionId: String?) { + if (_uiState.value.isSwitchingWorkspace) return + + _uiState.update { it.copy(isSwitchingWorkspace = true) } + + viewModelScope.launch { + // Persist the target session ID so it survives the app reset + if (sessionId != null) { + appSettings.setCurrentSessionId(sessionId) + } + workspaceRepository.activateWorkspace(projectId) + .onSuccess { + _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) } + } + } + } + + fun clearWorkspaceSwitch() { + _uiState.update { it.copy(switchedWorkspaceId = null) } + } + + /** + * Create a new session in a workspace. + * If the workspace differs from active, triggers a workspace switch first. + */ + fun createSession(workspaceProjectId: String) { + if (_uiState.value.isCreatingSession) return + _uiState.update { it.copy(isCreatingSession = true) } + + viewModelScope.launch { + val isActiveWorkspace = workspaceProjectId == _uiState.value.activeWorkspaceId + + if (!isActiveWorkspace) { + // Switch workspace first, then create session + workspaceRepository.activateWorkspace(workspaceProjectId) + .onFailure { error -> + OcMobileLog.w(TAG, "Failed to switch workspace for new session: ${error.message}") + _uiState.update { it.copy(isCreatingSession = false) } + return@launch + } + } + + sessionRepository.createSession(parentId = null) + .onSuccess { session -> + _uiState.update { it.copy( + isCreatingSession = false, + createdSessionId = session.id, + // If workspace changed, signal for app reset + switchedWorkspaceId = if (!isActiveWorkspace) workspaceProjectId else null + ) } + } + .onFailure { error -> + OcMobileLog.w(TAG, "Failed to create session: ${error.message}") + _uiState.update { it.copy(isCreatingSession = false) } + } + } + } + + fun clearCreatedSession() { + _uiState.update { it.copy(createdSessionId = null) } + } + + fun addWorkspace(directoryInput: String) { + if (_uiState.value.isCreatingWorkspace) return + _uiState.update { it.copy(isCreatingWorkspace = true) } + + viewModelScope.launch { + workspaceRepository.addWorkspace(directoryInput) + .onSuccess { + _uiState.update { it.copy(isCreatingWorkspace = false) } + } + .onFailure { error -> + OcMobileLog.w(TAG, "Failed to add workspace: ${error.message}") + _uiState.update { it.copy(isCreatingWorkspace = false) } + } + } + } + + fun refresh() { + viewModelScope.launch { + workspaceRepository.refresh() + } + } +} + +data class SidebarUiState( + val workspaces: List = emptyList(), + val activeWorkspaceId: String? = null, + val activeSessionId: String? = null, + val isCreatingSession: Boolean = false, + val isCreatingWorkspace: Boolean = false, + val isSwitchingWorkspace: Boolean = false, + 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 +) +``` + +- [ ] **Step 2: Verify the file compiles** + +Run: `cd /Users/ratulsarna/Developer/Projects/opencode-pocket && ./gradlew composeApp:compileKotlinJvm` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 3: Write failing test — observes workspaces and groups sessions** + +Create `composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModelTest.kt`: + +```kotlin +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 kotlin.test.assertTrue +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), // different workspace + session("ses-child", "/path/to/project-a", updatedAtMs = 400, parentId = "ses-1") // child + ) + 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_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) + } + + @Test + fun SidebarViewModel_switchWorkspacePersistsSessionIdBeforeActivating() = runTest(dispatcher) { + val activatedIds = mutableListOf() + val repo = FakeWorkspaceRepository( + workspaces = listOf(workspace("proj-1", "/p1"), workspace("proj-2", "/p2")), + activeWorkspace = workspace("proj-1", "/p1"), + activateHandler = { id -> + activatedIds.add(id) + Result.success(Unit) + } + ) + val sessionRepo = FakeSessionRepository() + val appSettings = MockAppSettings() + + val vm = SidebarViewModel( + workspaceRepository = repo, + sessionRepository = sessionRepo, + appSettings = appSettings + ) + advanceUntilIdle() + + vm.switchWorkspace("proj-2", "ses-target") + advanceUntilIdle() + + assertEquals("ses-target", appSettings.getCurrentSessionIdSnapshot()) + assertEquals(listOf("proj-2"), activatedIds) + assertEquals("proj-2", vm.uiState.value.switchedWorkspaceId) + } + + @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) // no workspace switch + } + + @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) + } + + // --- Helpers --- + + 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 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? = activeWorkspace + override suspend fun ensureInitialized(): Result = + activeWorkspace?.let { Result.success(it) } ?: Result.failure(RuntimeException("no active")) + override suspend fun refresh(): Result = Result.success(Unit) + override suspend fun addWorkspace(directoryInput: String): Result = addHandler(directoryInput) + override suspend fun activateWorkspace(projectId: String): Result = activateHandler(projectId) + } + + private class FakeSessionRepository( + private val sessions: List = emptyList(), + 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 = error("not used") + override suspend fun getSessions(search: String?, limit: Int?, start: Long?): Result> = + Result.success(sessions) + 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") + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /Users/ratulsarna/Developer/Projects/opencode-pocket && ./gradlew composeApp:jvmTest --tests "com.ratulsarna.ocmobile.ui.screen.sidebar.SidebarViewModelTest"` +Expected: All 6 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModel.kt \ + composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModelTest.kt +git commit -m "feat: add SidebarViewModel with workspace + session management" +``` + +--- + +### Task 2: Wire SidebarViewModel into DI and iOS bridge + +**Files:** +- Modify: `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/di/AppModule.kt:263-291` +- Modify: `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/IosViewModelOwners.kt:33-76` + +- [ ] **Step 1: Add `createSidebarViewModel()` factory to AppModule** + +In `AppModule.kt`, add after line 261 (after `createChatViewModel`): + +```kotlin +fun createSidebarViewModel(): SidebarViewModel { + return SidebarViewModel( + workspaceRepository = graphWorkspaceRepository(), + sessionRepository = graphSessionRepository(), + appSettings = appSettings + ) +} +``` + +Add the import at the top of the file: + +```kotlin +import com.ratulsarna.ocmobile.ui.screen.sidebar.SidebarViewModel +``` + +- [ ] **Step 2: Add `sidebarViewModel()` to `IosAppViewModelOwner`** + +In `IosViewModelOwners.kt`, add after the `workspacesViewModel()` method (line 48): + +```kotlin +/** App-scoped sidebar combining workspaces + sessions. */ +fun sidebarViewModel(): SidebarViewModel = get(key = "sidebar") { AppModule.createSidebarViewModel() } +``` + +Add the import: + +```kotlin +import com.ratulsarna.ocmobile.ui.screen.sidebar.SidebarViewModel +``` + +- [ ] **Step 3: Verify compilation** + +Run: `cd /Users/ratulsarna/Developer/Projects/opencode-pocket && ./gradlew composeApp:compileKotlinJvm` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 4: Commit** + +```bash +git add composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/di/AppModule.kt \ + composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/IosViewModelOwners.kt +git commit -m "feat: wire SidebarViewModel into DI and iOS bridge" +``` + +--- + +## Chunk 2: Sidebar UI (Swift) + +### Task 3: Create WorkspaceCardView + +**Files:** +- Create: `iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift` + +- [ ] **Step 1: Create the workspace card component** + +This is a self-contained SwiftUI view for a single workspace. It handles: +- Workspace header row with folder icon, name, and `+` (new session) button +- Tap on header toggles expand/collapse +- When expanded: shows session rows (up to 3 or all if fully expanded) +- Active session indicator (accent dot) +- "View N more sessions" CTA when there are more than 3 + +```swift +import SwiftUI +import ComposeApp + +@MainActor +struct WorkspaceCardView: View { + let workspaceWithSessions: WorkspaceWithSessions + let isActive: Bool + 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 + return (worktree as NSString).lastPathComponent.isEmpty + ? workspaceWithSessions.workspace.projectId + : (worktree as NSString).lastPathComponent + } + + 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) + } + + @Namespace private var glassNamespace + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header row + HStack(spacing: 10) { + Image(systemName: "folder.fill") + .foregroundStyle(.secondary) + .font(.body) + + Text(displayTitle) + .font(.system(.subheadline, design: .rounded).weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + + Spacer(minLength: 0) + + if isCreatingSession { + ProgressView() + .controlSize(.small) + } else { + if #available(iOS 26, *) { + Button(action: onCreateSession) { + Image(systemName: "plus") + .font(.system(.caption, weight: .semibold)) + } + .buttonStyle(.glass) + } else { + Button(action: onCreateSession) { + Image(systemName: "plus") + .font(.system(.caption, weight: .semibold)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .contentShape(Rectangle()) + .onTapGesture(perform: onToggleExpand) + + // Expanded session list + if isExpanded { + if workspaceWithSessions.isLoading { + HStack { + Spacer() + ProgressView() + .controlSize(.small) + Spacer() + } + .padding(.vertical, 8) + } else if let error = workspaceWithSessions.error { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .padding(.horizontal, 14) + .padding(.vertical, 8) + } else if sessions.isEmpty { + Text("No sessions") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 14) + .padding(.vertical, 8) + } else { + VStack(alignment: .leading, spacing: 0) { + 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 sessions") + .font(.caption) + .foregroundStyle(.accent) + } + .buttonStyle(.plain) + .padding(.horizontal, 14) + .padding(.vertical, 8) + } else if isFullyExpanded && hiddenCount > 0 { + Button(action: onToggleFullExpand) { + Text("Show less") + .font(.caption) + .foregroundStyle(.accent) + } + .buttonStyle(.plain) + .padding(.horizontal, 14) + .padding(.vertical, 8) + } + } + } + } + } + .workspaceCardGlass(isActive: isActive) + .workspaceCardGlassID(workspaceWithSessions.workspace.projectId, namespace: glassNamespace) + } + + @ViewBuilder + private func sessionRow(_ session: Session) -> some View { + Button { + onSelectSession(session.id) + } label: { + HStack(spacing: 8) { + if session.id == activeSessionId { + Circle() + .fill(Color.accentColor) + .frame(width: 6, height: 6) + } else { + Circle() + .fill(Color.clear) + .frame(width: 6, height: 6) + } + + Text(session.title ?? session.id.prefix(8).description) + .font(.subheadline) + .foregroundStyle(session.id == activeSessionId ? .primary : .secondary) + .lineLimit(1) + + Spacer() + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + +// MARK: - Glass modifiers + +private extension View { + @ViewBuilder + func workspaceCardGlass(isActive: Bool) -> some View { + if #available(iOS 26, *) { + let glass: GlassEffect = isActive + ? .regular.tint(.accentColor).interactive() + : .regular.interactive() + self.glassEffect(glass, in: .rect(cornerRadius: 12)) + } else { + self.background( + isActive ? Color.accentColor.opacity(0.08) : Color(.secondarySystemGroupedBackground), + in: RoundedRectangle(cornerRadius: 12) + ) + } + } + + @ViewBuilder + func workspaceCardGlassID(_ id: String, namespace: Namespace.ID) -> some View { + if #available(iOS 26, *) { + self.glassEffectID(id, in: namespace) + } else { + self + } + } +} +``` + +- [ ] **Step 2: Verify it compiles by building the iOS target** + +Run: `cd /Users/ratulsarna/Developer/Projects/opencode-pocket && xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 16 Pro' build 2>&1 | tail -5` +Expected: BUILD SUCCEEDED (or verify via Xcode) + +- [ ] **Step 3: Commit** + +```bash +git add iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift +git commit -m "feat: add WorkspaceCardView with glass styling and expand/collapse" +``` + +--- + +### Task 4: Create WorkspacesSidebarView + +**Files:** +- Create: `iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift` + +- [ ] **Step 1: Create the sidebar root view** + +This wraps the workspace cards in a `ScrollView` + `LazyVStack` with a toolbar for the "Add Workspace" button. + +```swift +import SwiftUI +import ComposeApp + +@MainActor +struct WorkspacesSidebarView: View { + let viewModel: SidebarViewModel + let onSelectSession: () -> Void + let onRequestAppReset: () -> 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 = "" + + var body: some View { + Group { + if let state = latestUiState { + sidebarContent(state: state) + } else { + ProgressView() + } + } + .navigationTitle("Workspaces") + .toolbar { + 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 + latestUiState = state + + // Auto-expand active workspace on first load + if expanded.isEmpty, let activeId = state.activeWorkspaceId { + expanded.insert(activeId) + viewModel.loadSessionsForWorkspace(projectId: activeId) + } + } + } + .onDisappear { + uiStateEvents.stop() + } + .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 a workspace switch is also pending, let that handle the reset + if latestUiState?.switchedWorkspaceId != nil { + return + } + onSelectSession() + } + } + + @ViewBuilder + private func sidebarContent(state: SidebarUiState) -> some View { + ScrollView { + glassContainerWrapper { + LazyVStack(spacing: 12) { + ForEach(state.workspaces, id: \.workspace.projectId) { workspaceWithSessions in + let projectId = workspaceWithSessions.workspace.projectId + let isActive = projectId == state.activeWorkspaceId + let isExp = expanded.contains(projectId) + let isFull = fullyExpanded.contains(projectId) + + WorkspaceCardView( + workspaceWithSessions: workspaceWithSessions, + isActive: isActive, + 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) + // Load sessions on first expand + if workspaceWithSessions.sessions.isEmpty && !workspaceWithSessions.isLoading { + viewModel.loadSessionsForWorkspace(projectId: projectId) + } + } + } + }, + onToggleFullExpand: { + withAnimation(.easeInOut(duration: 0.25)) { + if fullyExpanded.contains(projectId) { + fullyExpanded.remove(projectId) + } else { + fullyExpanded.insert(projectId) + } + } + }, + onSelectSession: { sessionId in + if isActive { + viewModel.switchSession(sessionId: sessionId) + onSelectSession() + } else { + viewModel.switchWorkspace(projectId: projectId, sessionId: sessionId) + } + }, + onCreateSession: { + viewModel.createSession(workspaceProjectId: projectId) + } + ) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + } + } + } + + @ViewBuilder + private func glassContainerWrapper(@ViewBuilder content: () -> Content) -> some View { + if #available(iOS 26, *) { + GlassEffectContainer(spacing: 12) { + content() + } + } else { + content() + } + } + + @ViewBuilder + private var addWorkspaceSheet: some View { + NavigationStack { + Form { + Section { + TextField("Directory path", text: $draftDirectory) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } footer: { + Text("Enter the full directory path on the server machine.") + } + } + .navigationTitle("Add Workspace") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + draftDirectory = "" + isShowingAddWorkspace = false + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + let trimmed = draftDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + viewModel.addWorkspace(directoryInput: trimmed) + draftDirectory = "" + isShowingAddWorkspace = false + } + .disabled(draftDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + } +} +``` + +- [ ] **Step 2: Verify iOS build compiles** + +Run: `cd /Users/ratulsarna/Developer/Projects/opencode-pocket && xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 16 Pro' build 2>&1 | tail -5` +Expected: BUILD SUCCEEDED + +- [ ] **Step 3: Commit** + +```bash +git add iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift +git commit -m "feat: add WorkspacesSidebarView with expandable workspace cards" +``` + +--- + +## Chunk 3: Navigation & Toolbar Rewiring (Swift) + +### Task 5: Modify ChatToolbarGlassView — hamburger + new title/subtitle + +**Files:** +- Modify: `iosApp/iosApp/ChatUIKit/ChatScreenChromeView.swift:6-105` +- Modify: `iosApp/iosApp/ChatUIKit/SwiftUIChatUIKitView.swift:6-119` + +- [ ] **Step 1: Update `ChatToolbarGlassView` parameters and layout** + +In `ChatScreenChromeView.swift`, replace the `ChatToolbarGlassView` struct (lines 6-105): + +1. Replace `onOpenSessions` parameter with `onToggleSidebar`: + - Change line 10 from `let onOpenSessions: () -> Void` to `let onToggleSidebar: () -> Void` + +2. Add new parameters after `onRevert` (line 13): + - `let sessionTitle: String?` + - `let workspacePath: String?` + +3. Replace the `subtitle` computed property (lines 27-32) with: + ```swift + private var subtitle: String { + guard let path = workspacePath, !path.isEmpty else { + return "Pocket chat" + } + let lastComponent = (path as NSString).lastPathComponent + return lastComponent.isEmpty ? path : "…/\(lastComponent)" + } + ``` + +4. Replace the title `Text("OpenCode")` (lines 45-48) with: + ```swift + Text(sessionTitle ?? "OpenCode") + .font(.system(.title3, design: .rounded).weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + ``` + +5. Restructure the `HStack` at lines 43-76 so the hamburger is on the left and the sessions button is removed: + ```swift + HStack(alignment: .center, spacing: 12) { + ChatToolbarIconButton(action: onToggleSidebar) { + Image(systemName: "line.3.horizontal") + } + + VStack(alignment: .leading, spacing: 3) { + Text(sessionTitle ?? "OpenCode") + .font(.system(.title3, design: .rounded).weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + + Text(subtitle) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer(minLength: 0) + + HStack(spacing: 8) { + ChatToolbarIconButton(action: onRetry) { + if isRefreshing { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "arrow.clockwise") + } + } + .disabled(isRefreshing) + + ChatToolbarIconButton(action: onOpenSettings) { + Image(systemName: "gearshape") + } + } + } + ``` + +- [ ] **Step 2: Update `SwiftUIChatUIKitView` to pass new parameters** + +In `SwiftUIChatUIKitView.swift`: + +1. Replace `onOpenSessions` parameter (line 10) with `onToggleSidebar`: + - `let onToggleSidebar: () -> Void` + +2. Add new parameters: + - `let sessionTitle: String?` + - `let workspacePath: String?` + +3. Update the `init` (lines 19-31) to accept the new parameters. + +4. Update `toolbarOverlay` (lines 104-119) to pass the new parameters to `ChatToolbarGlassView`: + ```swift + ChatToolbarGlassView( + state: state, + isRefreshing: state.isRefreshing, + onRetry: viewModel.retry, + onToggleSidebar: onToggleSidebar, + onOpenSettings: onOpenSettings, + onDismissError: viewModel.dismissError, + onRevert: viewModel.revertToLastGood, + sessionTitle: sessionTitle, + workspacePath: workspacePath + ) + ``` + +- [ ] **Step 3: Verify iOS build compiles (will fail until Task 6 wires the call site)** + +This is expected — the call site in `SwiftUIAppRootView` still passes the old parameters. We fix that in Task 6. + +- [ ] **Step 4: Commit** + +```bash +git add iosApp/iosApp/ChatUIKit/ChatScreenChromeView.swift \ + iosApp/iosApp/ChatUIKit/SwiftUIChatUIKitView.swift +git commit -m "feat: replace sessions button with hamburger, add session title + workspace path to toolbar" +``` + +--- + +### Task 6: Rewire SwiftUIAppRootView — NavigationSplitView + sidebar + +**Files:** +- Modify: `iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift` + +- [ ] **Step 1: Update the route enum** + +Remove `.workspaces` case from `SwiftUIAppRoute` (line 9). The enum becomes: + +```swift +private enum SwiftUIAppRoute: Hashable { + case connect + case settings + case modelSelection + case markdownFile(path: String, openId: Int64) +} +``` + +- [ ] **Step 2: Replace `pairedAppView` with `NavigationSplitView`** + +Replace the `pairedAppView` function. Key changes: +- Remove `@State private var isShowingSessions: Bool = false` (line 21) +- Add `@State private var sidebarVisibility: NavigationSplitViewVisibility = .detailOnly` +- Replace `NavigationStack(path: $path)` with `NavigationSplitView(columnVisibility: $sidebarVisibility)` +- Add sidebar column with `WorkspacesSidebarView` +- Detail column contains the existing `NavigationStack` +- Remove `.sheet(isPresented: $isShowingSessions)` (lines 112-114) +- Remove `.workspaces` case from `navigationDestination` (lines 90-94) +- Remove `workspacesViewModel` instantiation (line 57) +- Add `sidebarViewModel` instantiation +- Remove `onOpenSessions` from `SwiftUISettingsView` (line 83) and `SwiftUIChatUIKitView` (line 64) +- Pass `onToggleSidebar`, `sessionTitle`, `workspacePath` to `SwiftUIChatUIKitView` +- Remove `onOpenWorkspaces` and `onOpenSessions` from `SwiftUISettingsView` + +The updated `pairedAppView` requires two new `@State` properties at the top of `SwiftUIAppRootView`: + +```swift +@State private var sidebarVisibility: NavigationSplitViewVisibility = .detailOnly +@State private var settingsUiState: SettingsUiState? +@StateObject private var sidebarUiStateEvents = KmpUiEventBridge() +@State private var sidebarUiState: SidebarUiState? +``` + +Remove the existing `@State private var isShowingSessions: Bool = false` (line 21). + +**Sub-step 2a: Store `settingsUiState` from the existing callback.** +In the existing `settingsUiStateEvents.start(flow:)` callback at line 120, add `self.settingsUiState = uiState` as the first line of the closure, before the theme logic. + +**Sub-step 2b: Add a `sidebarUiStateEvents` bridge.** +In the `.onAppear` block, add: +```swift +sidebarUiStateEvents.start(flow: sidebarViewModel.uiState) { state in + sidebarUiState = state +} +``` +In `.onDisappear`, add `sidebarUiStateEvents.stop()`. + +**Sub-step 2c: Derive session title from `SidebarUiState`.** +`SettingsUiState` does NOT have a session title field. Instead, derive it from the sidebar state: +- Find the active workspace in `sidebarUiState?.workspaces` using `sidebarUiState?.activeWorkspaceId` +- Find the active session in that workspace's sessions using `sidebarUiState?.activeSessionId` +- Use `session.title` as the toolbar title, falling back to the session ID prefix + +Add a computed helper in `SwiftUIAppRootView`: +```swift +private var activeSessionTitle: String? { + guard let state = sidebarUiState, + let activeWorkspaceId = state.activeWorkspaceId, + let activeSessionId = state.activeSessionId, + let workspace = state.workspaces.first(where: { $0.workspace.projectId == activeWorkspaceId }), + let session = workspace.sessions.first(where: { $0.id == activeSessionId }) + else { return nil } + return session.title +} +``` + +The new `pairedAppView`: + +```swift +@ViewBuilder +private func pairedAppView(connectViewModel: ConnectViewModel) -> some View { + let chatViewModel = kmp.owner.chatViewModel() + let settingsViewModel = kmp.owner.settingsViewModel() + let sidebarViewModel = kmp.owner.sidebarViewModel() + + NavigationSplitView(columnVisibility: $sidebarVisibility) { + WorkspacesSidebarView( + viewModel: sidebarViewModel, + onSelectSession: { + sidebarVisibility = .detailOnly + }, + onRequestAppReset: { onRequestAppReset() } + ) + } detail: { + NavigationStack(path: $path) { + Group { + SwiftUIChatUIKitView( + viewModel: chatViewModel, + onOpenSettings: { path.append(.settings) }, + onToggleSidebar: { + withAnimation { + sidebarVisibility = sidebarVisibility == .detailOnly + ? .doubleColumn + : .detailOnly + } + }, + 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) }, + themeRestartNotice: $showThemeRestartNotice + ) + + case .modelSelection: + SwiftUIModelSelectionView(viewModel: settingsViewModel) + + 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) + } + } + } + } + } + } + .navigationSplitViewStyle(.balanced) + .onAppear { + // Retain ALL existing onAppear logic from lines 115-145 of the original file: + // - IosThemeApplier.apply (line 118) + // - settingsUiStateEvents.start with theme logic (lines 120-140) — add settingsUiState = uiState as first line + // - shareEvents.start (lines 142-144) + // PLUS add: + sidebarUiStateEvents.start(flow: sidebarViewModel.uiState) { state in + sidebarUiState = state + } + } + .onDisappear { + // Retain existing: settingsUiStateEvents.stop(), shareEvents.stop(), pendingThemeVerification cancel + sidebarUiStateEvents.stop() + } + // Retain existing modifiers from lines 152-172: + // - .onChange(of: path) — markdown store pruning + // - .onChange(of: scenePhase) — foreground/background handling +} +``` + +- [ ] **Step 3: Verify iOS build compiles** + +Run: `cd /Users/ratulsarna/Developer/Projects/opencode-pocket && xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 16 Pro' build 2>&1 | tail -5` +Expected: BUILD SUCCEEDED + +- [ ] **Step 4: Commit** + +```bash +git add iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift +git commit -m "feat: replace NavigationStack with NavigationSplitView, wire sidebar" +``` + +--- + +### Task 7: Clean up SwiftUISettingsView + +**Files:** +- Modify: `iosApp/iosApp/SwiftUIInterop/SwiftUISettingsViews.swift:4-171` + +- [ ] **Step 1: Remove workspace and sessions parameters and rows** + +1. Remove `let onOpenWorkspaces: () -> Void` (line 9) +2. Remove `let onOpenSessions: () -> Void` (line 10) +3. Remove the Workspace button (lines 64-79 inside `Section("App")`) +4. Remove the entire `Section("Navigation")` block (lines 167-171) +5. Remove the now-unused `workspaceText(name:worktree:)` private function (around lines 336-349) which becomes dead code after the Workspace row is removed + +- [ ] **Step 2: Verify iOS build compiles** + +Run: `cd /Users/ratulsarna/Developer/Projects/opencode-pocket && xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 16 Pro' build 2>&1 | tail -5` +Expected: BUILD SUCCEEDED + +- [ ] **Step 3: Commit** + +```bash +git add iosApp/iosApp/SwiftUIInterop/SwiftUISettingsViews.swift +git commit -m "refactor: remove workspace and sessions navigation from Settings" +``` + +--- + +## Chunk 4: Cleanup & Deletion + +### Task 8: Delete old files and clean up DI + +**Files:** +- Delete: `iosApp/iosApp/SwiftUIInterop/SwiftUISessionsViews.swift` +- Delete: `iosApp/iosApp/SwiftUIInterop/SwiftUIWorkspacesViews.swift` +- Delete: `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModel.kt` +- Delete: `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/workspaces/WorkspacesViewModel.kt` +- Delete: `composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModelTest.kt` +- Modify: `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/di/AppModule.kt` +- Modify: `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/IosViewModelOwners.kt` + +- [ ] **Step 1: Delete the Swift view files** + +```bash +rm iosApp/iosApp/SwiftUIInterop/SwiftUISessionsViews.swift +rm iosApp/iosApp/SwiftUIInterop/SwiftUIWorkspacesViews.swift +``` + +If the Xcode project uses explicit file references in `.pbxproj` for these files, remove the corresponding entries. If using folder references (most likely), no `.pbxproj` changes are needed. + +- [ ] **Step 2: Delete the Kotlin ViewModel files and test** + +```bash +rm composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModel.kt +rm composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/workspaces/WorkspacesViewModel.kt +rm composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModelTest.kt +``` + +- [ ] **Step 3: Remove old factory methods from AppModule** + +In `AppModule.kt`: +- Remove `createSessionsViewModel()` (lines 263-268) +- Remove `createWorkspacesViewModel()` (lines 289-291) +- Remove the imports for `SessionsViewModel` and `WorkspacesViewModel` (lines 38-40) + +- [ ] **Step 4: Remove old accessors from IosViewModelOwners** + +In `IosViewModelOwners.kt`: +- Remove `workspacesViewModel()` from `IosAppViewModelOwner` (line 48) +- Remove `sessionsViewModel()` from `IosScreenViewModelOwner` (line 89) +- Remove the imports for `SessionsViewModel` and `WorkspacesViewModel` (lines 15, 17) + +- [ ] **Step 5: Verify both Kotlin and iOS builds compile** + +Run (in parallel): +- `cd /Users/ratulsarna/Developer/Projects/opencode-pocket && ./gradlew composeApp:compileKotlinJvm` +- `cd /Users/ratulsarna/Developer/Projects/opencode-pocket && xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 16 Pro' build 2>&1 | tail -5` + +Expected: Both BUILD SUCCESSFUL + +- [ ] **Step 6: Run all remaining tests** + +Run: `cd /Users/ratulsarna/Developer/Projects/opencode-pocket && ./gradlew composeApp:jvmTest` +Expected: All tests PASS (including the new SidebarViewModelTest) + +- [ ] **Step 7: Commit** + +```bash +git add composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/di/AppModule.kt \ + composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/IosViewModelOwners.kt +git rm iosApp/iosApp/SwiftUIInterop/SwiftUISessionsViews.swift \ + iosApp/iosApp/SwiftUIInterop/SwiftUIWorkspacesViews.swift \ + composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModel.kt \ + composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/workspaces/WorkspacesViewModel.kt \ + composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModelTest.kt +git commit -m "refactor: delete old Sessions/Workspaces views, ViewModels, and DI wiring" +``` From 5243dfd9f02070ed8ab658084411f438b309b3a6 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 15 Mar 2026 14:17:53 +0530 Subject: [PATCH 04/27] chore: add .worktrees/ to .gitignore Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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/ From 9ab63d687a94fcce0cbc7e15df142c3eb6f81faf Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 15 Mar 2026 14:22:20 +0530 Subject: [PATCH 05/27] feat: add SidebarViewModel with workspace + session management Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../ui/screen/sidebar/SidebarViewModel.kt | 220 ++++++++++++++++ .../ui/screen/sidebar/SidebarViewModelTest.kt | 241 ++++++++++++++++++ 2 files changed, 461 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModel.kt create mode 100644 composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModelTest.kt 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..6569b2a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModel.kt @@ -0,0 +1,220 @@ +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()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + ensureInitialized() + observeWorkspaces() + observeActiveSessionId() + } + + private fun ensureInitialized() { + viewModelScope.launch { + workspaceRepository.ensureInitialized() + .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 { + it.copy( + activeWorkspaceId = active?.projectId, + workspaces = workspaces.map { workspace -> + val existing = it.workspaces.find { w -> w.workspace.projectId == workspace.projectId } + existing?.copy(workspace = workspace) ?: WorkspaceWithSessions(workspace = workspace) + } + ) + } + } + } + } + + private fun observeActiveSessionId() { + viewModelScope.launch { + appSettings.getCurrentSessionId().collect { id -> + _uiState.update { it.copy(activeSessionId = id) } + } + } + } + + fun loadSessionsForWorkspace(projectId: String) { + val workspace = _uiState.value.workspaces.find { it.workspace.projectId == projectId } ?: return + if (workspace.isLoading) return + + _uiState.update { state -> + state.copy(workspaces = state.workspaces.map { + if (it.workspace.projectId == projectId) it.copy(isLoading = true, error = null) else it + }) + } + + viewModelScope.launch { + val start = Clock.System.now().toEpochMilliseconds() - DEFAULT_RECENT_WINDOW_MS + sessionRepository.getSessions(search = null, limit = null, start = start) + .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 + }) + } + } + .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 + }) + } + } + } + } + + fun switchSession(sessionId: String) { + viewModelScope.launch { + sessionRepository.updateCurrentSessionId(sessionId) + .onFailure { error -> + OcMobileLog.w(TAG, "Failed to switch session: ${error.message}") + } + } + } + + fun switchWorkspace(projectId: String, sessionId: String?) { + if (_uiState.value.isSwitchingWorkspace) return + + _uiState.update { it.copy(isSwitchingWorkspace = true) } + + viewModelScope.launch { + if (sessionId != null) { + appSettings.setCurrentSessionId(sessionId) + } + workspaceRepository.activateWorkspace(projectId) + .onSuccess { + _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) } + } + } + } + + fun clearWorkspaceSwitch() { + _uiState.update { it.copy(switchedWorkspaceId = null) } + } + + fun createSession(workspaceProjectId: String) { + if (_uiState.value.isCreatingSession) return + _uiState.update { it.copy(isCreatingSession = true) } + + 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) } + 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) } + } + } + } + + fun clearCreatedSession() { + _uiState.update { it.copy(createdSessionId = null) } + } + + fun addWorkspace(directoryInput: String) { + if (_uiState.value.isCreatingWorkspace) return + _uiState.update { it.copy(isCreatingWorkspace = true) } + + viewModelScope.launch { + workspaceRepository.addWorkspace(directoryInput) + .onSuccess { + _uiState.update { it.copy(isCreatingWorkspace = false) } + } + .onFailure { error -> + OcMobileLog.w(TAG, "Failed to add workspace: ${error.message}") + _uiState.update { it.copy(isCreatingWorkspace = false) } + } + } + } + + fun refresh() { + viewModelScope.launch { + workspaceRepository.refresh() + } + } +} + +data class SidebarUiState( + val workspaces: List = emptyList(), + val activeWorkspaceId: String? = null, + val activeSessionId: String? = null, + val isCreatingSession: Boolean = false, + val isCreatingWorkspace: Boolean = false, + val isSwitchingWorkspace: Boolean = false, + 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/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..b593c2f --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModelTest.kt @@ -0,0 +1,241 @@ +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 kotlin.test.assertTrue +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_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) + } + + @Test + fun SidebarViewModel_switchWorkspacePersistsSessionIdBeforeActivating() = runTest(dispatcher) { + val activatedIds = mutableListOf() + val repo = FakeWorkspaceRepository( + workspaces = listOf(workspace("proj-1", "/p1"), workspace("proj-2", "/p2")), + activeWorkspace = workspace("proj-1", "/p1"), + activateHandler = { id -> + activatedIds.add(id) + Result.success(Unit) + } + ) + val sessionRepo = FakeSessionRepository() + val appSettings = MockAppSettings() + + val vm = SidebarViewModel( + workspaceRepository = repo, + sessionRepository = sessionRepo, + appSettings = appSettings + ) + advanceUntilIdle() + + vm.switchWorkspace("proj-2", "ses-target") + advanceUntilIdle() + + assertEquals("ses-target", appSettings.getCurrentSessionIdSnapshot()) + assertEquals(listOf("proj-2"), activatedIds) + assertEquals("proj-2", vm.uiState.value.switchedWorkspaceId) + } + + @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) + } + + 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 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? = activeWorkspace + override suspend fun ensureInitialized(): Result = + activeWorkspace?.let { Result.success(it) } ?: Result.failure(RuntimeException("no active")) + override suspend fun refresh(): Result = Result.success(Unit) + override suspend fun addWorkspace(directoryInput: String): Result = addHandler(directoryInput) + override suspend fun activateWorkspace(projectId: String): Result = activateHandler(projectId) + } + + private class FakeSessionRepository( + private val sessions: List = emptyList(), + 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 = error("not used") + override suspend fun getSessions(search: String?, limit: Int?, start: Long?): Result> = + Result.success(sessions) + 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") + } +} From 37d203c1b1ee138d819e22db89ccb1e83fa009ec Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 15 Mar 2026 14:22:52 +0530 Subject: [PATCH 06/27] feat: wire SidebarViewModel into DI and iOS bridge Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../kotlin/com/ratulsarna/ocmobile/IosViewModelOwners.kt | 4 ++++ .../kotlin/com/ratulsarna/ocmobile/di/AppModule.kt | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/IosViewModelOwners.kt b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/IosViewModelOwners.kt index e7fa87c..176f5ce 100644 --- a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/IosViewModelOwners.kt +++ b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/IosViewModelOwners.kt @@ -14,6 +14,7 @@ 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.sidebar.SidebarViewModel import com.ratulsarna.ocmobile.ui.screen.workspaces.WorkspacesViewModel /** @@ -47,6 +48,9 @@ class IosAppViewModelOwner : ViewModelStoreOwner { /** 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() } 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..b903e2d 100644 --- a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/di/AppModule.kt +++ b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/di/AppModule.kt @@ -37,6 +37,7 @@ 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.sidebar.SidebarViewModel import com.ratulsarna.ocmobile.ui.screen.workspaces.WorkspacesViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -260,6 +261,14 @@ object AppModule { ) } + fun createSidebarViewModel(): SidebarViewModel { + return SidebarViewModel( + workspaceRepository = graphWorkspaceRepository(), + sessionRepository = graphSessionRepository(), + appSettings = appSettings + ) + } + fun createSessionsViewModel(): SessionsViewModel { return SessionsViewModel( sessionRepository = graphSessionRepository(), From 40f8d3af8bc83f82131c188c355342c5f4cc7f64 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 15 Mar 2026 14:26:41 +0530 Subject: [PATCH 07/27] fix: address code quality issues in SidebarViewModel Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../ocmobile/ui/screen/sidebar/SidebarViewModel.kt | 13 ++++++++----- .../ui/screen/sidebar/SidebarViewModelTest.kt | 1 - 2 files changed, 8 insertions(+), 6 deletions(-) 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 index 6569b2a..08564c9 100644 --- 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 @@ -51,12 +51,12 @@ class SidebarViewModel( ) { workspaces, active -> workspaces to active }.collect { (workspaces, active) -> - _uiState.update { - it.copy( + _uiState.update { current -> + current.copy( activeWorkspaceId = active?.projectId, - workspaces = workspaces.map { workspace -> - val existing = it.workspaces.find { w -> w.workspace.projectId == workspace.projectId } - existing?.copy(workspace = workspace) ?: WorkspaceWithSessions(workspace = workspace) + workspaces = workspaces.map { incoming -> + val existing = current.workspaces.find { w -> w.workspace.projectId == incoming.projectId } + existing?.copy(workspace = incoming) ?: WorkspaceWithSessions(workspace = incoming) } ) } @@ -197,6 +197,9 @@ class SidebarViewModel( fun refresh() { viewModelScope.launch { workspaceRepository.refresh() + .onFailure { error -> + OcMobileLog.w(TAG, "Failed to refresh workspaces: ${error.message}") + } } } } 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 index b593c2f..1200221 100644 --- 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 @@ -8,7 +8,6 @@ import com.ratulsarna.ocmobile.domain.repository.WorkspaceRepository 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.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow From 234bc284fe64bd6edf978297e6514587710c3b86 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 15 Mar 2026 14:27:27 +0530 Subject: [PATCH 08/27] fix: show cached sessions immediately, refresh in background 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) --- .../ocmobile/ui/screen/sidebar/SidebarViewModel.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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 index 08564c9..cfb7407 100644 --- 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 @@ -76,10 +76,15 @@ class SidebarViewModel( val workspace = _uiState.value.workspaces.find { it.workspace.projectId == projectId } ?: return if (workspace.isLoading) return - _uiState.update { state -> - state.copy(workspaces = state.workspaces.map { - if (it.workspace.projectId == projectId) it.copy(isLoading = true, error = null) else it - }) + // 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 { From c090008480d604906dc2a130098206fd0d39041e Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 15 Mar 2026 14:29:22 +0530 Subject: [PATCH 09/27] feat: add WorkspaceCardView with glass styling and expand/collapse --- .../SwiftUIInterop/WorkspaceCardView.swift | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift diff --git a/iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift b/iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift new file mode 100644 index 0000000..9a1bc82 --- /dev/null +++ b/iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift @@ -0,0 +1,196 @@ +import SwiftUI +import ComposeApp + +@MainActor +struct WorkspaceCardView: View { + let workspaceWithSessions: WorkspaceWithSessions + let isActive: Bool + 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 + return (worktree as NSString).lastPathComponent.isEmpty + ? workspaceWithSessions.workspace.projectId + : (worktree as NSString).lastPathComponent + } + + 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) + } + + @Namespace private var glassNamespace + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header row + HStack(spacing: 10) { + Image(systemName: "folder.fill") + .foregroundStyle(.secondary) + .font(.body) + + Text(displayTitle) + .font(.system(.subheadline, design: .rounded).weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + + Spacer(minLength: 0) + + if isCreatingSession { + ProgressView() + .controlSize(.small) + } else { + if #available(iOS 26, *) { + Button(action: onCreateSession) { + Image(systemName: "plus") + .font(.system(.caption, weight: .semibold)) + } + .buttonStyle(.glass) + } else { + Button(action: onCreateSession) { + Image(systemName: "plus") + .font(.system(.caption, weight: .semibold)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .contentShape(Rectangle()) + .onTapGesture(perform: onToggleExpand) + + // Expanded session list + if isExpanded { + if workspaceWithSessions.isLoading { + HStack { + Spacer() + ProgressView() + .controlSize(.small) + Spacer() + } + .padding(.vertical, 8) + } else if let error = workspaceWithSessions.error { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .padding(.horizontal, 14) + .padding(.vertical, 8) + } else if sessions.isEmpty { + Text("No sessions") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 14) + .padding(.vertical, 8) + } else { + VStack(alignment: .leading, spacing: 0) { + 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 sessions") + .font(.caption) + .foregroundStyle(.accent) + } + .buttonStyle(.plain) + .padding(.horizontal, 14) + .padding(.vertical, 8) + } else if isFullyExpanded && hiddenCount > 0 { + Button(action: onToggleFullExpand) { + Text("Show less") + .font(.caption) + .foregroundStyle(.accent) + } + .buttonStyle(.plain) + .padding(.horizontal, 14) + .padding(.vertical, 8) + } + } + } + } + } + .workspaceCardGlass(isActive: isActive) + .workspaceCardGlassID(workspaceWithSessions.workspace.projectId, namespace: glassNamespace) + } + + @ViewBuilder + private func sessionRow(_ session: Session) -> some View { + Button { + onSelectSession(session.id) + } label: { + HStack(spacing: 8) { + if session.id == activeSessionId { + Circle() + .fill(Color.accentColor) + .frame(width: 6, height: 6) + } else { + Circle() + .fill(Color.clear) + .frame(width: 6, height: 6) + } + + Text(session.title ?? session.id.prefix(8).description) + .font(.subheadline) + .foregroundStyle(session.id == activeSessionId ? .primary : .secondary) + .lineLimit(1) + + Spacer() + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + +// MARK: - Glass modifiers + +private extension View { + @ViewBuilder + func workspaceCardGlass(isActive: Bool) -> some View { + if #available(iOS 26, *) { + let glass: GlassEffect = isActive + ? .regular.tint(.accentColor).interactive() + : .regular.interactive() + self.glassEffect(glass, in: .rect(cornerRadius: 12)) + } else { + self.background( + isActive ? Color.accentColor.opacity(0.08) : Color(.secondarySystemGroupedBackground), + in: RoundedRectangle(cornerRadius: 12) + ) + } + } + + @ViewBuilder + func workspaceCardGlassID(_ id: String, namespace: Namespace.ID) -> some View { + if #available(iOS 26, *) { + self.glassEffectID(id, in: namespace) + } else { + self + } + } +} From 75cd4eb1f33c83162f0b802fb000bd6b3097243f Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 15 Mar 2026 14:29:25 +0530 Subject: [PATCH 10/27] feat: add WorkspacesSidebarView with expandable workspace cards --- .../WorkspacesSidebarView.swift | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift diff --git a/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift b/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift new file mode 100644 index 0000000..9b8b2d0 --- /dev/null +++ b/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift @@ -0,0 +1,174 @@ +import SwiftUI +import ComposeApp + +@MainActor +struct WorkspacesSidebarView: View { + let viewModel: SidebarViewModel + let onSelectSession: () -> Void + let onRequestAppReset: () -> 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 = "" + + var body: some View { + Group { + if let state = latestUiState { + sidebarContent(state: state) + } else { + ProgressView() + } + } + .navigationTitle("Workspaces") + .toolbar { + 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 + latestUiState = state + + // Auto-expand active workspace on first load + if expanded.isEmpty, let activeId = state.activeWorkspaceId { + expanded.insert(activeId) + viewModel.loadSessionsForWorkspace(projectId: activeId) + } + } + } + .onDisappear { + uiStateEvents.stop() + } + .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 a workspace switch is also pending, let that handle the reset + if latestUiState?.switchedWorkspaceId != nil { + return + } + onSelectSession() + } + } + + @ViewBuilder + private func sidebarContent(state: SidebarUiState) -> some View { + ScrollView { + glassContainerWrapper { + LazyVStack(spacing: 12) { + ForEach(state.workspaces, id: \.workspace.projectId) { workspaceWithSessions in + let projectId = workspaceWithSessions.workspace.projectId + let isActive = projectId == state.activeWorkspaceId + let isExp = expanded.contains(projectId) + let isFull = fullyExpanded.contains(projectId) + + WorkspaceCardView( + workspaceWithSessions: workspaceWithSessions, + isActive: isActive, + 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) + // Load sessions on first expand (or background refresh if cached) + viewModel.loadSessionsForWorkspace(projectId: projectId) + } + } + }, + onToggleFullExpand: { + withAnimation(.easeInOut(duration: 0.25)) { + if fullyExpanded.contains(projectId) { + fullyExpanded.remove(projectId) + } else { + fullyExpanded.insert(projectId) + } + } + }, + onSelectSession: { sessionId in + if isActive { + viewModel.switchSession(sessionId: sessionId) + onSelectSession() + } else { + viewModel.switchWorkspace(projectId: projectId, sessionId: sessionId) + } + }, + onCreateSession: { + viewModel.createSession(workspaceProjectId: projectId) + } + ) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + } + } + } + + @ViewBuilder + private func glassContainerWrapper(@ViewBuilder content: () -> Content) -> some View { + if #available(iOS 26, *) { + GlassEffectContainer(spacing: 12) { + content() + } + } else { + content() + } + } + + @ViewBuilder + private var addWorkspaceSheet: some View { + NavigationStack { + Form { + Section { + TextField("Directory path", text: $draftDirectory) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } footer: { + Text("Enter the full directory path on the server machine.") + } + } + .navigationTitle("Add Workspace") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + draftDirectory = "" + isShowingAddWorkspace = false + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + let trimmed = draftDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + viewModel.addWorkspace(directoryInput: trimmed) + draftDirectory = "" + isShowingAddWorkspace = false + } + .disabled(draftDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + } +} From 02c745cbd7c535c6e8dd438422ff3333daa5f5d2 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 15 Mar 2026 14:31:44 +0530 Subject: [PATCH 11/27] feat: replace sessions button with hamburger, add session title + workspace path to toolbar Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ChatUIKit/ChatScreenChromeView.swift | 19 ++++++++++-------- .../ChatUIKit/SwiftUIChatUIKitView.swift | 20 +++++++++++++------ 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/iosApp/iosApp/ChatUIKit/ChatScreenChromeView.swift b/iosApp/iosApp/ChatUIKit/ChatScreenChromeView.swift index 8b350e2..b3a4132 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 { @@ -41,8 +44,12 @@ struct ChatToolbarGlassView: View { VStack(spacing: 10) { VStack(spacing: 0) { HStack(alignment: .center, spacing: 12) { + ChatToolbarIconButton(action: onToggleSidebar) { + Image(systemName: "line.3.horizontal") + } + VStack(alignment: .leading, spacing: 3) { - Text("OpenCode") + Text(sessionTitle ?? "OpenCode") .font(.system(.title3, design: .rounded).weight(.semibold)) .foregroundStyle(.primary) .lineLimit(1) @@ -66,10 +73,6 @@ struct ChatToolbarGlassView: View { } .disabled(isRefreshing) - ChatToolbarIconButton(action: onOpenSessions) { - Image(systemName: "rectangle.stack") - } - ChatToolbarIconButton(action: onOpenSettings) { Image(systemName: "gearshape") } diff --git a/iosApp/iosApp/ChatUIKit/SwiftUIChatUIKitView.swift b/iosApp/iosApp/ChatUIKit/SwiftUIChatUIKitView.swift index 0506fda..23259f9 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)) } @@ -106,10 +112,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: sessionTitle, + workspacePath: workspacePath ) .padding(.horizontal, 12) .padding(.top, safeTopInset + 2) From 7dcaa41a423ef8e02cf5180013e4d31e1fb2e944 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 15 Mar 2026 14:33:12 +0530 Subject: [PATCH 12/27] feat: replace NavigationStack with NavigationSplitView, wire sidebar Co-Authored-By: Claude Opus 4.6 (1M context) --- .../SwiftUIInterop/SwiftUIAppRootView.swift | 131 +++++++++++------- 1 file changed, 78 insertions(+), 53 deletions(-) diff --git a/iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift b/iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift index 4271808..99a02fb 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,7 +17,10 @@ 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 sidebarVisibility: NavigationSplitViewVisibility = .detailOnly + @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() @@ -26,6 +28,16 @@ struct SwiftUIAppRootView: View { @State private var desiredInterfaceStyle: UIUserInterfaceStyle = IosThemeApplier.readStoredStyle() @State private var showThemeRestartNotice: Bool = false + private var activeSessionTitle: String? { + guard let state = sidebarUiState, + let activeWorkspaceId = state.activeWorkspaceId, + let activeSessionId = state.activeSessionId, + let workspace = state.workspaces.first(where: { $0.workspace.projectId == activeWorkspaceId }), + let session = workspace.sessions.first(where: { $0.id == activeSessionId }) + else { return nil } + return session.title + } + var body: some View { let connectViewModel = kmp.owner.connectViewModel() @@ -54,70 +66,78 @@ 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() - - 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() } - ) + let sidebarViewModel = kmp.owner.sidebarViewModel() - case .settings: - SwiftUISettingsView( - viewModel: settingsViewModel, - onOpenConnect: { path.append(.connect) }, - onOpenModelSelection: { path.append(.modelSelection) }, - onOpenWorkspaces: { path.append(.workspaces) }, - onOpenSessions: { isShowingSessions = true }, - themeRestartNotice: $showThemeRestartNotice + NavigationSplitView(columnVisibility: $sidebarVisibility) { + WorkspacesSidebarView( + viewModel: sidebarViewModel, + onSelectSession: { + sidebarVisibility = .detailOnly + }, + onRequestAppReset: { onRequestAppReset() } + ) + } detail: { + NavigationStack(path: $path) { + Group { + SwiftUIChatUIKitView( + viewModel: chatViewModel, + onOpenSettings: { path.append(.settings) }, + onToggleSidebar: { + withAnimation { + sidebarVisibility = sidebarVisibility == .detailOnly + ? .doubleColumn + : .detailOnly + } + }, + 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 .modelSelection: - SwiftUIModelSelectionView(viewModel: settingsViewModel) + case .settings: + SwiftUISettingsView( + viewModel: settingsViewModel, + onOpenConnect: { path.append(.connect) }, + onOpenModelSelection: { path.append(.modelSelection) }, + themeRestartNotice: $showThemeRestartNotice + ) - case .workspaces: - SwiftUIWorkspacesView( - viewModel: workspacesViewModel, - onDidSwitchWorkspace: { onRequestAppReset() } - ) + case .modelSelection: + SwiftUIModelSelectionView(viewModel: settingsViewModel) - 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) + } + } } } } } - .sheet(isPresented: $isShowingSessions) { - SwiftUISessionsSheetView() - } + .navigationSplitViewStyle(.balanced) .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 +162,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 } From 0e39642a516ccd08c219e623977751d47db4eade Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 15 Mar 2026 14:33:47 +0530 Subject: [PATCH 13/27] refactor: remove workspace and sessions navigation from Settings Co-Authored-By: Claude Opus 4.6 (1M context) --- .../SwiftUIInterop/SwiftUISettingsViews.swift | 39 ------------------- 1 file changed, 39 deletions(-) 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 { From c2a78222dacf31d4e1c3435296fc51a3ae923d1b Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 15 Mar 2026 14:35:46 +0530 Subject: [PATCH 14/27] refactor: delete old Sessions/Workspaces views, ViewModels, and DI wiring Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../ratulsarna/ocmobile/IosViewModelOwners.kt | 7 - .../com/ratulsarna/ocmobile/di/AppModule.kt | 13 - .../ui/screen/sessions/SessionsViewModel.kt | 270 ---------------- .../screen/workspaces/WorkspacesViewModel.kt | 155 --------- .../screen/sessions/SessionsViewModelTest.kt | 296 ------------------ .../SwiftUIInterop/SwiftUISessionsViews.swift | 146 --------- .../SwiftUIWorkspacesViews.swift | 156 --------- 7 files changed, 1043 deletions(-) delete mode 100644 composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModel.kt delete mode 100644 composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/workspaces/WorkspacesViewModel.kt delete mode 100644 composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModelTest.kt delete mode 100644 iosApp/iosApp/SwiftUIInterop/SwiftUISessionsViews.swift delete mode 100644 iosApp/iosApp/SwiftUIInterop/SwiftUIWorkspacesViews.swift diff --git a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/IosViewModelOwners.kt b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/IosViewModelOwners.kt index 176f5ce..357c81b 100644 --- a/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/IosViewModelOwners.kt +++ b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/IosViewModelOwners.kt @@ -12,10 +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.sidebar.SidebarViewModel -import com.ratulsarna.ocmobile.ui.screen.workspaces.WorkspacesViewModel /** * iOS-facing ViewModel owners for SwiftUI. @@ -45,9 +43,6 @@ 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() } @@ -90,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/di/AppModule.kt b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/di/AppModule.kt index b903e2d..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,10 +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.sidebar.SidebarViewModel -import com.ratulsarna.ocmobile.ui.screen.workspaces.WorkspacesViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -269,13 +267,6 @@ object AppModule { ) } - fun createSessionsViewModel(): SessionsViewModel { - return SessionsViewModel( - sessionRepository = graphSessionRepository(), - appSettings = appSettings - ) - } - fun createConnectViewModel(): ConnectViewModel { return ConnectViewModel( serverRepository = serverRepository, @@ -295,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/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/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/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/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/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 - } -} - From fc7909157b50a027f055fb3b516cc64834d8bbed Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 15 Mar 2026 14:47:50 +0530 Subject: [PATCH 15/27] fix: use inline glassEffect calls instead of GlassEffect type variable 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) --- iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift b/iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift index 9a1bc82..589ac00 100644 --- a/iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift +++ b/iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift @@ -173,10 +173,11 @@ private extension View { @ViewBuilder func workspaceCardGlass(isActive: Bool) -> some View { if #available(iOS 26, *) { - let glass: GlassEffect = isActive - ? .regular.tint(.accentColor).interactive() - : .regular.interactive() - self.glassEffect(glass, in: .rect(cornerRadius: 12)) + if isActive { + self.glassEffect(.regular.tint(.accentColor).interactive(), in: .rect(cornerRadius: 12)) + } else { + self.glassEffect(.regular.interactive(), in: .rect(cornerRadius: 12)) + } } else { self.background( isActive ? Color.accentColor.opacity(0.08) : Color(.secondarySystemGroupedBackground), From b3bf8b11e23ad83e92693c2f53cfdfa62bc53fca Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 15 Mar 2026 14:52:11 +0530 Subject: [PATCH 16/27] fix: redesign sidebar UI to match reference screenshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../SwiftUIInterop/WorkspaceCardView.swift | 132 +++++++----------- .../WorkspacesSidebarView.swift | 98 ++++++------- 2 files changed, 95 insertions(+), 135 deletions(-) diff --git a/iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift b/iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift index 589ac00..a1c921b 100644 --- a/iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift +++ b/iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift @@ -4,7 +4,6 @@ import ComposeApp @MainActor struct WorkspaceCardView: View { let workspaceWithSessions: WorkspaceWithSessions - let isActive: Bool let activeSessionId: String? let isExpanded: Bool let isFullyExpanded: Bool @@ -19,9 +18,8 @@ struct WorkspaceCardView: View { return name } let worktree = workspaceWithSessions.workspace.worktree - return (worktree as NSString).lastPathComponent.isEmpty - ? workspaceWithSessions.workspace.projectId - : (worktree as NSString).lastPathComponent + let last = (worktree as NSString).lastPathComponent + return last.isEmpty ? workspaceWithSessions.workspace.projectId : last } private var sessions: [Session] { @@ -34,18 +32,16 @@ struct WorkspaceCardView: View { max(0, Int(workspaceWithSessions.sessions.count) - 3) } - @Namespace private var glassNamespace - var body: some View { VStack(alignment: .leading, spacing: 0) { - // Header row + // Workspace header HStack(spacing: 10) { - Image(systemName: "folder.fill") + Image(systemName: "folder") .foregroundStyle(.secondary) .font(.body) Text(displayTitle) - .font(.system(.subheadline, design: .rounded).weight(.semibold)) + .font(.system(.body, design: .default).weight(.medium)) .foregroundStyle(.primary) .lineLimit(1) @@ -55,27 +51,17 @@ struct WorkspaceCardView: View { ProgressView() .controlSize(.small) } else { - if #available(iOS 26, *) { - Button(action: onCreateSession) { - Image(systemName: "plus") - .font(.system(.caption, weight: .semibold)) - } - .buttonStyle(.glass) - } else { - Button(action: onCreateSession) { - Image(systemName: "plus") - .font(.system(.caption, weight: .semibold)) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) + 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) } - - Image(systemName: isExpanded ? "chevron.up" : "chevron.down") - .font(.caption2) - .foregroundStyle(.tertiary) } - .padding(.horizontal, 14) + .padding(.horizontal, 16) .padding(.vertical, 12) .contentShape(Rectangle()) .onTapGesture(perform: onToggleExpand) @@ -89,21 +75,21 @@ struct WorkspaceCardView: View { .controlSize(.small) Spacer() } - .padding(.vertical, 8) + .padding(.vertical, 10) } else if let error = workspaceWithSessions.error { Text(error) .font(.caption) .foregroundStyle(.red) - .padding(.horizontal, 14) - .padding(.vertical, 8) + .padding(.horizontal, 16) + .padding(.bottom, 10) } else if sessions.isEmpty { Text("No sessions") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.horizontal, 14) - .padding(.vertical, 8) + .font(.subheadline) + .foregroundStyle(.tertiary) + .padding(.horizontal, 16) + .padding(.bottom, 10) } else { - VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 2) { ForEach(sessions, id: \.id) { session in sessionRow(session) .transition(.opacity.combined(with: .move(edge: .top))) @@ -111,87 +97,77 @@ struct WorkspaceCardView: View { if hiddenCount > 0 && !isFullyExpanded { Button(action: onToggleFullExpand) { - Text("View \(hiddenCount) more sessions") + Text("View \(hiddenCount) more") .font(.caption) - .foregroundStyle(.accent) + .foregroundStyle(.accentColor) } .buttonStyle(.plain) - .padding(.horizontal, 14) + .padding(.horizontal, 16) .padding(.vertical, 8) } else if isFullyExpanded && hiddenCount > 0 { Button(action: onToggleFullExpand) { Text("Show less") .font(.caption) - .foregroundStyle(.accent) + .foregroundStyle(.accentColor) } .buttonStyle(.plain) - .padding(.horizontal, 14) + .padding(.horizontal, 16) .padding(.vertical, 8) } } + .padding(.bottom, 4) } } } - .workspaceCardGlass(isActive: isActive) - .workspaceCardGlassID(workspaceWithSessions.workspace.projectId, namespace: glassNamespace) } @ViewBuilder private func sessionRow(_ session: Session) -> some View { + let isActiveSession = session.id == activeSessionId + Button { onSelectSession(session.id) } label: { HStack(spacing: 8) { - if session.id == activeSessionId { + if isActiveSession { Circle() .fill(Color.accentColor) .frame(width: 6, height: 6) - } else { - Circle() - .fill(Color.clear) - .frame(width: 6, height: 6) } - Text(session.title ?? session.id.prefix(8).description) + Text(session.title ?? String(session.id.prefix(8))) .font(.subheadline) - .foregroundStyle(session.id == activeSessionId ? .primary : .secondary) + .foregroundStyle(isActiveSession ? .primary : .secondary) .lineLimit(1) - Spacer() + Spacer(minLength: 0) + + Text(relativeTime(session.updatedAt)) + .font(.caption) + .foregroundStyle(.tertiary) } - .padding(.horizontal, 14) + .padding(.horizontal, 16) .padding(.vertical, 8) - .contentShape(Rectangle()) + .background( + isActiveSession + ? Color(.tertiarySystemFill) + : Color.clear, + in: RoundedRectangle(cornerRadius: 8) + ) + .padding(.horizontal, 8) } .buttonStyle(.plain) } -} - -// MARK: - Glass modifiers -private extension View { - @ViewBuilder - func workspaceCardGlass(isActive: Bool) -> some View { - if #available(iOS 26, *) { - if isActive { - self.glassEffect(.regular.tint(.accentColor).interactive(), in: .rect(cornerRadius: 12)) - } else { - self.glassEffect(.regular.interactive(), in: .rect(cornerRadius: 12)) - } - } else { - self.background( - isActive ? Color.accentColor.opacity(0.08) : Color(.secondarySystemGroupedBackground), - in: RoundedRectangle(cornerRadius: 12) - ) - } - } + private func relativeTime(_ instant: KotlinInstant) -> String { + let epochMs = instant.toEpochMilliseconds() + let date = Date(timeIntervalSince1970: TimeInterval(epochMs) / 1000.0) + let interval = Date().timeIntervalSince(date) - @ViewBuilder - func workspaceCardGlassID(_ id: String, namespace: Namespace.ID) -> some View { - if #available(iOS 26, *) { - self.glassEffectID(id, in: namespace) - } else { - self - } + 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 index 9b8b2d0..8f04754 100644 --- a/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift +++ b/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift @@ -60,7 +60,6 @@ struct WorkspacesSidebarView: View { .task(id: latestUiState?.createdSessionId ?? "") { guard let sessionId = latestUiState?.createdSessionId, !sessionId.isEmpty else { return } viewModel.clearCreatedSession() - // If a workspace switch is also pending, let that handle the reset if latestUiState?.switchedWorkspaceId != nil { return } @@ -71,69 +70,54 @@ struct WorkspacesSidebarView: View { @ViewBuilder private func sidebarContent(state: SidebarUiState) -> some View { ScrollView { - glassContainerWrapper { - LazyVStack(spacing: 12) { - ForEach(state.workspaces, id: \.workspace.projectId) { workspaceWithSessions in - let projectId = workspaceWithSessions.workspace.projectId - let isActive = projectId == state.activeWorkspaceId - let isExp = expanded.contains(projectId) - let isFull = fullyExpanded.contains(projectId) + 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, - isActive: isActive, - 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) - // Load sessions on first expand (or background refresh if cached) - viewModel.loadSessionsForWorkspace(projectId: projectId) - } - } - }, - onToggleFullExpand: { - withAnimation(.easeInOut(duration: 0.25)) { - if fullyExpanded.contains(projectId) { - fullyExpanded.remove(projectId) - } else { - fullyExpanded.insert(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) } - }, - onSelectSession: { sessionId in - if isActive { - viewModel.switchSession(sessionId: sessionId) - onSelectSession() + } + }, + onToggleFullExpand: { + withAnimation(.easeInOut(duration: 0.25)) { + if fullyExpanded.contains(projectId) { + fullyExpanded.remove(projectId) } else { - viewModel.switchWorkspace(projectId: projectId, sessionId: sessionId) + fullyExpanded.insert(projectId) } - }, - onCreateSession: { - viewModel.createSession(workspaceProjectId: 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, 16) - .padding(.top, 8) - } - } - } - - @ViewBuilder - private func glassContainerWrapper(@ViewBuilder content: () -> Content) -> some View { - if #available(iOS 26, *) { - GlassEffectContainer(spacing: 12) { - content() } - } else { - content() + .padding(.horizontal, 12) + .padding(.top, 8) } } From 94f8d38af23f32ac800b0653d7d5b36d8766e976 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 15 Mar 2026 14:53:03 +0530 Subject: [PATCH 17/27] fix: use Color.accentColor instead of .accentColor for ShapeStyle Co-Authored-By: Claude Opus 4.6 (1M context) --- iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift b/iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift index a1c921b..266f342 100644 --- a/iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift +++ b/iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift @@ -99,7 +99,7 @@ struct WorkspaceCardView: View { Button(action: onToggleFullExpand) { Text("View \(hiddenCount) more") .font(.caption) - .foregroundStyle(.accentColor) + .foregroundStyle(Color.accentColor) } .buttonStyle(.plain) .padding(.horizontal, 16) @@ -108,7 +108,7 @@ struct WorkspaceCardView: View { Button(action: onToggleFullExpand) { Text("Show less") .font(.caption) - .foregroundStyle(.accentColor) + .foregroundStyle(Color.accentColor) } .buttonStyle(.plain) .padding(.horizontal, 16) From 173220bd2640af0097258a4614c97501147e7bc9 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 15 Mar 2026 14:56:26 +0530 Subject: [PATCH 18/27] fix: use dismiss() to navigate back to chat on session tap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift b/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift index 8f04754..d8d1bc2 100644 --- a/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift +++ b/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift @@ -7,6 +7,7 @@ struct WorkspacesSidebarView: View { let onSelectSession: () -> Void let onRequestAppReset: () -> Void + @Environment(\.dismiss) private var dismiss @StateObject private var uiStateEvents = KmpUiEventBridge() @State private var latestUiState: SidebarUiState? @State private var expanded: Set = [] @@ -105,6 +106,7 @@ struct WorkspacesSidebarView: View { let isActiveWorkspace = projectId == state.activeWorkspaceId if isActiveWorkspace { viewModel.switchSession(sessionId: sessionId) + dismiss() onSelectSession() } else { viewModel.switchWorkspace(projectId: projectId, sessionId: sessionId) From 4ca5bbc25ca508ffd03a1bd8bdeca0c710af8bf0 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 15 Mar 2026 15:00:29 +0530 Subject: [PATCH 19/27] fix: replace NavigationSplitView with NavigationStack push for sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../SwiftUIInterop/SwiftUIAppRootView.swift | 105 ++++++++---------- .../WorkspacesSidebarView.swift | 2 - 2 files changed, 49 insertions(+), 58 deletions(-) diff --git a/iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift b/iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift index 99a02fb..7be6ca1 100644 --- a/iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift +++ b/iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift @@ -3,6 +3,7 @@ import ComposeApp import UIKit private enum SwiftUIAppRoute: Hashable { + case sidebar case connect case settings case modelSelection @@ -17,7 +18,6 @@ struct SwiftUIAppRootView: View { @State private var markdownManager = MarkdownFlowStoreManager() @State private var path: [SwiftUIAppRoute] = [] @Environment(\.scenePhase) private var scenePhase - @State private var sidebarVisibility: NavigationSplitViewVisibility = .detailOnly @State private var settingsUiState: SettingsUiState? @StateObject private var sidebarUiStateEvents = KmpUiEventBridge() @State private var sidebarUiState: SidebarUiState? @@ -68,70 +68,63 @@ struct SwiftUIAppRootView: View { let settingsViewModel = kmp.owner.settingsViewModel() let sidebarViewModel = kmp.owner.sidebarViewModel() - NavigationSplitView(columnVisibility: $sidebarVisibility) { - WorkspacesSidebarView( - viewModel: sidebarViewModel, - onSelectSession: { - sidebarVisibility = .detailOnly - }, - onRequestAppReset: { onRequestAppReset() } - ) - } detail: { - NavigationStack(path: $path) { - Group { - SwiftUIChatUIKitView( - viewModel: chatViewModel, - onOpenSettings: { path.append(.settings) }, - onToggleSidebar: { - withAnimation { - sidebarVisibility = sidebarVisibility == .detailOnly - ? .doubleColumn - : .detailOnly - } + NavigationStack(path: $path) { + Group { + SwiftUIChatUIKitView( + viewModel: chatViewModel, + onOpenSettings: { path.append(.settings) }, + onToggleSidebar: { path.append(.sidebar) }, + onOpenFile: { openMarkdownFile($0) }, + sessionTitle: activeSessionTitle, + workspacePath: settingsUiState?.activeWorkspaceWorktree + ) + } + .navigationDestination(for: SwiftUIAppRoute.self) { route in + switch route { + case .sidebar: + WorkspacesSidebarView( + viewModel: sidebarViewModel, + onSelectSession: { + // Pop back to chat + path.removeAll(where: { $0 == .sidebar }) }, - onOpenFile: { openMarkdownFile($0) }, - sessionTitle: activeSessionTitle, - workspacePath: settingsUiState?.activeWorkspaceWorktree + onRequestAppReset: { onRequestAppReset() } ) - } - .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) }, - themeRestartNotice: $showThemeRestartNotice - ) + case .connect: + SwiftUIConnectToOpenCodeView( + viewModel: connectViewModel, + onConnected: { onRequestAppReset() }, + onDisconnected: { onRequestAppReset() } + ) - case .modelSelection: - SwiftUIModelSelectionView(viewModel: settingsViewModel) + case .settings: + SwiftUISettingsView( + viewModel: settingsViewModel, + onOpenConnect: { path.append(.connect) }, + onOpenModelSelection: { path.append(.modelSelection) }, + themeRestartNotice: $showThemeRestartNotice + ) + + case .modelSelection: + SwiftUIModelSelectionView(viewModel: settingsViewModel) - 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) + } } } } } - .navigationSplitViewStyle(.balanced) .onAppear { IosThemeApplier.apply(style: desiredInterfaceStyle) diff --git a/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift b/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift index d8d1bc2..8f04754 100644 --- a/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift +++ b/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift @@ -7,7 +7,6 @@ struct WorkspacesSidebarView: View { let onSelectSession: () -> Void let onRequestAppReset: () -> Void - @Environment(\.dismiss) private var dismiss @StateObject private var uiStateEvents = KmpUiEventBridge() @State private var latestUiState: SidebarUiState? @State private var expanded: Set = [] @@ -106,7 +105,6 @@ struct WorkspacesSidebarView: View { let isActiveWorkspace = projectId == state.activeWorkspaceId if isActiveWorkspace { viewModel.switchSession(sessionId: sessionId) - dismiss() onSelectSession() } else { viewModel.switchWorkspace(projectId: projectId, sessionId: sessionId) From e2cd99495919f9a4f802ddec721e9438ca5c5658 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 15 Mar 2026 16:18:20 +0530 Subject: [PATCH 20/27] feat: enhance session retrieval with directory filtering - 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. --- .../ocmobile/data/api/OpenCodeApi.kt | 3 +- .../ocmobile/data/api/OpenCodeApiImpl.kt | 7 +- .../ocmobile/data/mock/MockOpenCodeApi.kt | 4 +- .../ocmobile/data/mock/MockState.kt | 6 +- .../data/repository/SessionRepositoryImpl.kt | 4 +- .../domain/repository/SessionRepository.kt | 3 +- .../ui/screen/sidebar/SidebarViewModel.kt | 60 ++++++++- .../AgentRepositoryDefaultSelectionTest.kt | 3 +- .../ModelRepositoryDefaultSelectionTest.kt | 3 +- .../repository/WorkspaceRepositoryTest.kt | 4 +- .../screen/chat/ChatViewModelBootstrapTest.kt | 2 +- .../chat/ChatViewModelSendDefaultsTest.kt | 2 +- .../ui/screen/sidebar/SidebarViewModelTest.kt | 118 ++++++++++++++-- iosApp/iosApp.xcodeproj/project.pbxproj | 8 +- .../ChatUIKit/ChatScreenChromeView.swift | 102 +++++++------- .../ChatUIKit/SwiftUIChatUIKitView.swift | 1 + .../SwiftUIInterop/SwiftUIAppRootView.swift | 127 ++++++++++-------- .../WorkspacesSidebarView.swift | 7 + 18 files changed, 322 insertions(+), 142 deletions(-) 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/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/sidebar/SidebarViewModel.kt b/composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModel.kt index cfb7407..1cfca64 100644 --- 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 @@ -60,6 +60,8 @@ class SidebarViewModel( } ) } + + active?.projectId?.let(::loadSessionsForWorkspace) } } } @@ -67,11 +69,39 @@ class SidebarViewModel( private fun observeActiveSessionId() { viewModelScope.launch { appSettings.getCurrentSessionId().collect { id -> - _uiState.update { it.copy(activeSessionId = 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 (workspace.isLoading) return @@ -82,14 +112,23 @@ class SidebarViewModel( if (!hasCachedSessions) { _uiState.update { state -> state.copy(workspaces = state.workspaces.map { - if (it.workspace.projectId == projectId) it.copy(isLoading = true, error = null) else it + if (it.workspace.projectId == projectId) { + it.copy(isLoading = true, error = null) + } else { + it + } }) } } viewModelScope.launch { val start = Clock.System.now().toEpochMilliseconds() - DEFAULT_RECENT_WINDOW_MS - sessionRepository.getSessions(search = null, limit = null, start = start) + 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 } @@ -107,7 +146,10 @@ class SidebarViewModel( _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") + it.copy( + isLoading = false, + error = error.message ?: "Failed to load sessions" + ) } else it }) } @@ -130,11 +172,14 @@ class SidebarViewModel( _uiState.update { it.copy(isSwitchingWorkspace = true) } viewModelScope.launch { - if (sessionId != null) { - appSettings.setCurrentSessionId(sessionId) - } 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 -> @@ -213,6 +258,7 @@ 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 isSwitchingWorkspace: Boolean = false, 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/sidebar/SidebarViewModelTest.kt b/composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModelTest.kt index 1200221..72d914b 100644 --- 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 @@ -80,6 +80,43 @@ class SidebarViewModelTest { 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_switchSessionCallsRepository() = runTest(dispatcher) { val repo = FakeWorkspaceRepository( @@ -108,19 +145,57 @@ class SidebarViewModelTest { assertEquals(listOf("ses-target"), updatedIds) } + @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_switchWorkspacePersistsSessionIdBeforeActivating() = runTest(dispatcher) { val activatedIds = 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(workspace("proj-1", "/p1"), workspace("proj-2", "/p2")), - activeWorkspace = workspace("proj-1", "/p1"), + workspaces = listOf(workspace1, workspace2), + activeWorkspace = workspace1, + appSettings = appSettings, activateHandler = { id -> activatedIds.add(id) Result.success(Unit) } ) - val sessionRepo = FakeSessionRepository() - val appSettings = MockAppSettings() + val sessionRepo = FakeSessionRepository( + updateCurrentSessionIdHandler = { sessionId -> + appSettings.setCurrentSessionId(sessionId) + Result.success(Unit) + } + ) val vm = SidebarViewModel( workspaceRepository = repo, @@ -132,6 +207,7 @@ class SidebarViewModelTest { 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) @@ -206,6 +282,7 @@ class SidebarViewModelTest { private class FakeWorkspaceRepository( private val workspaces: List = emptyList(), private val activeWorkspace: Workspace? = null, + private val appSettings: MockAppSettings? = null, private val activateHandler: suspend (String) -> Result = { Result.success(Unit) }, private val addHandler: suspend (String) -> Result = { error("addWorkspace not configured") } ) : WorkspaceRepository { @@ -219,18 +296,43 @@ class SidebarViewModelTest { activeWorkspace?.let { Result.success(it) } ?: Result.failure(RuntimeException("no active")) override suspend fun refresh(): Result = Result.success(Unit) override suspend fun addWorkspace(directoryInput: String): Result = addHandler(directoryInput) - override suspend fun activateWorkspace(projectId: String): Result = activateHandler(projectId) + 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 = error("not used") - override suspend fun getSessions(search: String?, limit: Int?, start: Long?): Result> = - Result.success(sessions) + 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") diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 29310e8..cbf1a45 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -437,7 +437,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(TEAM_ID)"; + DEVELOPMENT_TEAM = CDY7Z973ZG; ENABLE_USER_SCRIPT_SANDBOXING = NO; "FRAMEWORK_SEARCH_PATHS[arch=*]" = "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(PLATFORM_NAME)$(SDK_VERSION)"; GENERATE_INFOPLIST_FILE = YES; @@ -477,7 +477,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(TEAM_ID)"; + DEVELOPMENT_TEAM = CDY7Z973ZG; ENABLE_USER_SCRIPT_SANDBOXING = NO; "FRAMEWORK_SEARCH_PATHS[arch=*]" = "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(PLATFORM_NAME)$(SDK_VERSION)"; GENERATE_INFOPLIST_FILE = YES; @@ -645,7 +645,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "$(TEAM_ID)"; + DEVELOPMENT_TEAM = CDY7Z973ZG; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; @@ -675,7 +675,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "$(TEAM_ID)"; + DEVELOPMENT_TEAM = CDY7Z973ZG; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "iosApp/Info-Debug.plist"; diff --git a/iosApp/iosApp/ChatUIKit/ChatScreenChromeView.swift b/iosApp/iosApp/ChatUIKit/ChatScreenChromeView.swift index b3a4132..64beaf7 100644 --- a/iosApp/iosApp/ChatUIKit/ChatScreenChromeView.swift +++ b/iosApp/iosApp/ChatUIKit/ChatScreenChromeView.swift @@ -40,68 +40,65 @@ struct ChatToolbarGlassView: View { } var body: some View { - chatGlassGrouping(spacing: 16) { - VStack(spacing: 10) { - VStack(spacing: 0) { - HStack(alignment: .center, spacing: 12) { - ChatToolbarIconButton(action: onToggleSidebar) { - Image(systemName: "line.3.horizontal") - } - - VStack(alignment: .leading, spacing: 3) { - Text(sessionTitle ?? "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") - } + 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) + } + .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 + ) } } } @@ -336,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 23259f9..20acde4 100644 --- a/iosApp/iosApp/ChatUIKit/SwiftUIChatUIKitView.swift +++ b/iosApp/iosApp/ChatUIKit/SwiftUIChatUIKitView.swift @@ -59,6 +59,7 @@ struct SwiftUIChatUIKitView: View { Spacer(minLength: 0) composerOverlay(state: state, safeBottomInset: keyboardAwareBottomInset(proxy.safeAreaInsets.bottom)) } + .zIndex(1) } } .ignoresSafeArea(.container) diff --git a/iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift b/iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift index 7be6ca1..8d99619 100644 --- a/iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift +++ b/iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift @@ -3,7 +3,6 @@ import ComposeApp import UIKit private enum SwiftUIAppRoute: Hashable { - case sidebar case connect case settings case modelSelection @@ -27,15 +26,10 @@ struct SwiftUIAppRootView: View { @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? { - guard let state = sidebarUiState, - let activeWorkspaceId = state.activeWorkspaceId, - let activeSessionId = state.activeSessionId, - let workspace = state.workspaces.first(where: { $0.workspace.projectId == activeWorkspaceId }), - let session = workspace.sessions.first(where: { $0.id == activeSessionId }) - else { return nil } - return session.title + sidebarUiState?.activeSessionTitle } var body: some View { @@ -68,61 +62,83 @@ struct SwiftUIAppRootView: View { let settingsViewModel = kmp.owner.settingsViewModel() let sidebarViewModel = kmp.owner.sidebarViewModel() - NavigationStack(path: $path) { - Group { - SwiftUIChatUIKitView( - viewModel: chatViewModel, - onOpenSettings: { path.append(.settings) }, - onToggleSidebar: { path.append(.sidebar) }, - onOpenFile: { openMarkdownFile($0) }, - sessionTitle: activeSessionTitle, - workspacePath: settingsUiState?.activeWorkspaceWorktree - ) - } - .navigationDestination(for: SwiftUIAppRoute.self) { route in - switch route { - case .sidebar: - WorkspacesSidebarView( - viewModel: sidebarViewModel, - onSelectSession: { - // Pop back to chat - path.removeAll(where: { $0 == .sidebar }) + ZStack(alignment: .leading) { + NavigationStack(path: $path) { + Group { + SwiftUIChatUIKitView( + viewModel: chatViewModel, + onOpenSettings: { path.append(.settings) }, + onToggleSidebar: { + withAnimation(.snappy(duration: 0.28)) { + isSidebarPresented = true + } }, - onRequestAppReset: { onRequestAppReset() } + 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 .connect: - SwiftUIConnectToOpenCodeView( - viewModel: connectViewModel, - onConnected: { onRequestAppReset() }, - onDisconnected: { onRequestAppReset() } - ) + case .settings: + SwiftUISettingsView( + viewModel: settingsViewModel, + onOpenConnect: { path.append(.connect) }, + onOpenModelSelection: { path.append(.modelSelection) }, + 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 .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 + } + }, + onRequestAppReset: { + isSidebarPresented = false + onRequestAppReset() + } + ) } + .background(Color(.systemBackground)) + .transition(.move(edge: .leading)) + .zIndex(1) + .ignoresSafeArea() } } .onAppear { @@ -205,6 +221,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/WorkspacesSidebarView.swift b/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift index 8f04754..e4e36fd 100644 --- a/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift +++ b/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift @@ -4,6 +4,7 @@ import ComposeApp @MainActor struct WorkspacesSidebarView: View { let viewModel: SidebarViewModel + let onClose: () -> Void let onSelectSession: () -> Void let onRequestAppReset: () -> Void @@ -24,6 +25,12 @@ struct WorkspacesSidebarView: View { } .navigationTitle("Workspaces") .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button(action: onClose) { + Image(systemName: "chevron.left") + } + } + ToolbarItem(placement: .topBarTrailing) { if let state = latestUiState, state.isCreatingWorkspace { ProgressView() From 13e88f492b22069e303695bdf666759e5d2d427d Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 15 Mar 2026 16:18:59 +0530 Subject: [PATCH 21/27] refactor: remove sidebar navigation rework implementation and design 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. --- .../2026-03-15-sidebar-navigation-rework.md | 1456 ----------------- ...-03-15-sidebar-navigation-rework-design.md | 274 ---- 2 files changed, 1730 deletions(-) delete mode 100644 docs/superpowers/plans/2026-03-15-sidebar-navigation-rework.md delete mode 100644 docs/superpowers/specs/2026-03-15-sidebar-navigation-rework-design.md diff --git a/docs/superpowers/plans/2026-03-15-sidebar-navigation-rework.md b/docs/superpowers/plans/2026-03-15-sidebar-navigation-rework.md deleted file mode 100644 index 179d795..0000000 --- a/docs/superpowers/plans/2026-03-15-sidebar-navigation-rework.md +++ /dev/null @@ -1,1456 +0,0 @@ -# Sidebar Navigation Rework — Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace separate Workspace/Sessions screens with a unified sidebar using `NavigationSplitView`, featuring workspaces with nested sessions and Liquid Glass styling. - -**Architecture:** `NavigationSplitView` at the root (when paired) with a custom sidebar containing workspace cards with expandable session lists. A new `SidebarViewModel` (Kotlin) absorbs logic from the deleted `SessionsViewModel` and `WorkspacesViewModel`. The detail column retains the existing chat + settings navigation stack. - -**Tech Stack:** Kotlin Multiplatform (shared ViewModel), SwiftUI (`NavigationSplitView`, Liquid Glass), SKIE (Kotlin-Swift bridging), UIKit (chat message list — unchanged) - ---- - -## File Structure - -### New Files - -| File | Responsibility | -|------|----------------| -| `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModel.kt` | Combined workspace + session state, fetching, switching, creation | -| `composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModelTest.kt` | Unit tests for SidebarViewModel | -| `iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift` | Sidebar root view: toolbar, ScrollView + LazyVStack of workspace cards | -| `iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift` | Single workspace card: glass surface, expand/collapse, session rows, + button | - -### Modified Files - -| File | Changes | -|------|---------| -| `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/di/AppModule.kt` | Add `createSidebarViewModel()` factory, keep old factories until deletion step | -| `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/IosViewModelOwners.kt` | Add `sidebarViewModel()` to `IosAppViewModelOwner`, remove `workspacesViewModel()` and `sessionsViewModel()` | -| `iosApp/iosApp/SwiftUIInterop/KmpOwners.swift` | No changes needed (accesses `IosAppViewModelOwner` which is KMP-bridged) | -| `iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift` | Replace `NavigationStack` with `NavigationSplitView`, remove sessions sheet, wire sidebar, remove `.workspaces` route | -| `iosApp/iosApp/ChatUIKit/ChatScreenChromeView.swift` | Replace sessions button with hamburger, update title/subtitle to session name + workspace path | -| `iosApp/iosApp/ChatUIKit/SwiftUIChatUIKitView.swift` | Replace `onOpenSessions` with `onToggleSidebar`, pass new toolbar data | -| `iosApp/iosApp/SwiftUIInterop/SwiftUISettingsViews.swift` | Remove Workspace row, Sessions section, and their callback parameters | - -### Deleted Files - -| File | Reason | -|------|--------| -| `iosApp/iosApp/SwiftUIInterop/SwiftUISessionsViews.swift` | Replaced by sidebar | -| `iosApp/iosApp/SwiftUIInterop/SwiftUIWorkspacesViews.swift` | Replaced by sidebar | -| `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModel.kt` | Logic absorbed into SidebarViewModel | -| `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/workspaces/WorkspacesViewModel.kt` | Logic absorbed into SidebarViewModel | -| `composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModelTest.kt` | Replaced by SidebarViewModelTest | - ---- - -## Chunk 1: SidebarViewModel (Kotlin) - -### Task 1: Create SidebarViewModel with UiState - -**Files:** -- Create: `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModel.kt` -- Create: `composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModelTest.kt` - -- [ ] **Step 1: Write the data classes and empty ViewModel** - -Create the file with the UiState data classes and an empty ViewModel shell: - -```kotlin -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 - -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()) - val uiState: StateFlow = _uiState.asStateFlow() - - init { - ensureInitialized() - observeWorkspaces() - observeActiveSessionId() - } - - private fun ensureInitialized() { - viewModelScope.launch { - workspaceRepository.ensureInitialized() - .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 { - it.copy( - activeWorkspaceId = active?.projectId, - workspaces = workspaces.map { workspace -> - val existing = it.workspaces.find { w -> w.workspace.projectId == workspace.projectId } - existing?.copy(workspace = workspace) ?: WorkspaceWithSessions(workspace = workspace) - } - ) - } - } - } - } - - private fun observeActiveSessionId() { - viewModelScope.launch { - appSettings.getCurrentSessionId().collect { id -> - _uiState.update { it.copy(activeSessionId = id) } - } - } - } - - /** - * Fetch sessions for a specific workspace. Called when a workspace is first expanded. - * Sessions are fetched globally and filtered client-side by matching - * Session.directory to Workspace.worktree. - */ - fun loadSessionsForWorkspace(projectId: String) { - val workspace = _uiState.value.workspaces.find { it.workspace.projectId == projectId } ?: return - if (workspace.isLoading) return - - _uiState.update { state -> - state.copy(workspaces = state.workspaces.map { - if (it.workspace.projectId == projectId) it.copy(isLoading = true, error = null) else it - }) - } - - viewModelScope.launch { - val start = kotlin.time.Clock.System.now().toEpochMilliseconds() - DEFAULT_RECENT_WINDOW_MS - sessionRepository.getSessions(search = null, limit = null, start = start) - .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 - }) - } - } - .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 - }) - } - } - } - } - - /** - * Switch to a session in the same workspace. - */ - fun switchSession(sessionId: String) { - viewModelScope.launch { - sessionRepository.updateCurrentSessionId(sessionId) - .onFailure { error -> - OcMobileLog.w(TAG, "Failed to switch session: ${error.message}") - } - } - } - - /** - * Switch to a different workspace and activate a specific session. - * Writes the target session ID to AppSettings before triggering the workspace switch, - * so it survives the app reset. - */ - fun switchWorkspace(projectId: String, sessionId: String?) { - if (_uiState.value.isSwitchingWorkspace) return - - _uiState.update { it.copy(isSwitchingWorkspace = true) } - - viewModelScope.launch { - // Persist the target session ID so it survives the app reset - if (sessionId != null) { - appSettings.setCurrentSessionId(sessionId) - } - workspaceRepository.activateWorkspace(projectId) - .onSuccess { - _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) } - } - } - } - - fun clearWorkspaceSwitch() { - _uiState.update { it.copy(switchedWorkspaceId = null) } - } - - /** - * Create a new session in a workspace. - * If the workspace differs from active, triggers a workspace switch first. - */ - fun createSession(workspaceProjectId: String) { - if (_uiState.value.isCreatingSession) return - _uiState.update { it.copy(isCreatingSession = true) } - - viewModelScope.launch { - val isActiveWorkspace = workspaceProjectId == _uiState.value.activeWorkspaceId - - if (!isActiveWorkspace) { - // Switch workspace first, then create session - workspaceRepository.activateWorkspace(workspaceProjectId) - .onFailure { error -> - OcMobileLog.w(TAG, "Failed to switch workspace for new session: ${error.message}") - _uiState.update { it.copy(isCreatingSession = false) } - return@launch - } - } - - sessionRepository.createSession(parentId = null) - .onSuccess { session -> - _uiState.update { it.copy( - isCreatingSession = false, - createdSessionId = session.id, - // If workspace changed, signal for app reset - switchedWorkspaceId = if (!isActiveWorkspace) workspaceProjectId else null - ) } - } - .onFailure { error -> - OcMobileLog.w(TAG, "Failed to create session: ${error.message}") - _uiState.update { it.copy(isCreatingSession = false) } - } - } - } - - fun clearCreatedSession() { - _uiState.update { it.copy(createdSessionId = null) } - } - - fun addWorkspace(directoryInput: String) { - if (_uiState.value.isCreatingWorkspace) return - _uiState.update { it.copy(isCreatingWorkspace = true) } - - viewModelScope.launch { - workspaceRepository.addWorkspace(directoryInput) - .onSuccess { - _uiState.update { it.copy(isCreatingWorkspace = false) } - } - .onFailure { error -> - OcMobileLog.w(TAG, "Failed to add workspace: ${error.message}") - _uiState.update { it.copy(isCreatingWorkspace = false) } - } - } - } - - fun refresh() { - viewModelScope.launch { - workspaceRepository.refresh() - } - } -} - -data class SidebarUiState( - val workspaces: List = emptyList(), - val activeWorkspaceId: String? = null, - val activeSessionId: String? = null, - val isCreatingSession: Boolean = false, - val isCreatingWorkspace: Boolean = false, - val isSwitchingWorkspace: Boolean = false, - 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 -) -``` - -- [ ] **Step 2: Verify the file compiles** - -Run: `cd /Users/ratulsarna/Developer/Projects/opencode-pocket && ./gradlew composeApp:compileKotlinJvm` -Expected: BUILD SUCCESSFUL - -- [ ] **Step 3: Write failing test — observes workspaces and groups sessions** - -Create `composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModelTest.kt`: - -```kotlin -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 kotlin.test.assertTrue -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), // different workspace - session("ses-child", "/path/to/project-a", updatedAtMs = 400, parentId = "ses-1") // child - ) - 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_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) - } - - @Test - fun SidebarViewModel_switchWorkspacePersistsSessionIdBeforeActivating() = runTest(dispatcher) { - val activatedIds = mutableListOf() - val repo = FakeWorkspaceRepository( - workspaces = listOf(workspace("proj-1", "/p1"), workspace("proj-2", "/p2")), - activeWorkspace = workspace("proj-1", "/p1"), - activateHandler = { id -> - activatedIds.add(id) - Result.success(Unit) - } - ) - val sessionRepo = FakeSessionRepository() - val appSettings = MockAppSettings() - - val vm = SidebarViewModel( - workspaceRepository = repo, - sessionRepository = sessionRepo, - appSettings = appSettings - ) - advanceUntilIdle() - - vm.switchWorkspace("proj-2", "ses-target") - advanceUntilIdle() - - assertEquals("ses-target", appSettings.getCurrentSessionIdSnapshot()) - assertEquals(listOf("proj-2"), activatedIds) - assertEquals("proj-2", vm.uiState.value.switchedWorkspaceId) - } - - @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) // no workspace switch - } - - @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) - } - - // --- Helpers --- - - 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 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? = activeWorkspace - override suspend fun ensureInitialized(): Result = - activeWorkspace?.let { Result.success(it) } ?: Result.failure(RuntimeException("no active")) - override suspend fun refresh(): Result = Result.success(Unit) - override suspend fun addWorkspace(directoryInput: String): Result = addHandler(directoryInput) - override suspend fun activateWorkspace(projectId: String): Result = activateHandler(projectId) - } - - private class FakeSessionRepository( - private val sessions: List = emptyList(), - 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 = error("not used") - override suspend fun getSessions(search: String?, limit: Int?, start: Long?): Result> = - Result.success(sessions) - 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") - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /Users/ratulsarna/Developer/Projects/opencode-pocket && ./gradlew composeApp:jvmTest --tests "com.ratulsarna.ocmobile.ui.screen.sidebar.SidebarViewModelTest"` -Expected: All 6 tests PASS - -- [ ] **Step 5: Commit** - -```bash -git add composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModel.kt \ - composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/SidebarViewModelTest.kt -git commit -m "feat: add SidebarViewModel with workspace + session management" -``` - ---- - -### Task 2: Wire SidebarViewModel into DI and iOS bridge - -**Files:** -- Modify: `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/di/AppModule.kt:263-291` -- Modify: `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/IosViewModelOwners.kt:33-76` - -- [ ] **Step 1: Add `createSidebarViewModel()` factory to AppModule** - -In `AppModule.kt`, add after line 261 (after `createChatViewModel`): - -```kotlin -fun createSidebarViewModel(): SidebarViewModel { - return SidebarViewModel( - workspaceRepository = graphWorkspaceRepository(), - sessionRepository = graphSessionRepository(), - appSettings = appSettings - ) -} -``` - -Add the import at the top of the file: - -```kotlin -import com.ratulsarna.ocmobile.ui.screen.sidebar.SidebarViewModel -``` - -- [ ] **Step 2: Add `sidebarViewModel()` to `IosAppViewModelOwner`** - -In `IosViewModelOwners.kt`, add after the `workspacesViewModel()` method (line 48): - -```kotlin -/** App-scoped sidebar combining workspaces + sessions. */ -fun sidebarViewModel(): SidebarViewModel = get(key = "sidebar") { AppModule.createSidebarViewModel() } -``` - -Add the import: - -```kotlin -import com.ratulsarna.ocmobile.ui.screen.sidebar.SidebarViewModel -``` - -- [ ] **Step 3: Verify compilation** - -Run: `cd /Users/ratulsarna/Developer/Projects/opencode-pocket && ./gradlew composeApp:compileKotlinJvm` -Expected: BUILD SUCCESSFUL - -- [ ] **Step 4: Commit** - -```bash -git add composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/di/AppModule.kt \ - composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/IosViewModelOwners.kt -git commit -m "feat: wire SidebarViewModel into DI and iOS bridge" -``` - ---- - -## Chunk 2: Sidebar UI (Swift) - -### Task 3: Create WorkspaceCardView - -**Files:** -- Create: `iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift` - -- [ ] **Step 1: Create the workspace card component** - -This is a self-contained SwiftUI view for a single workspace. It handles: -- Workspace header row with folder icon, name, and `+` (new session) button -- Tap on header toggles expand/collapse -- When expanded: shows session rows (up to 3 or all if fully expanded) -- Active session indicator (accent dot) -- "View N more sessions" CTA when there are more than 3 - -```swift -import SwiftUI -import ComposeApp - -@MainActor -struct WorkspaceCardView: View { - let workspaceWithSessions: WorkspaceWithSessions - let isActive: Bool - 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 - return (worktree as NSString).lastPathComponent.isEmpty - ? workspaceWithSessions.workspace.projectId - : (worktree as NSString).lastPathComponent - } - - 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) - } - - @Namespace private var glassNamespace - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - // Header row - HStack(spacing: 10) { - Image(systemName: "folder.fill") - .foregroundStyle(.secondary) - .font(.body) - - Text(displayTitle) - .font(.system(.subheadline, design: .rounded).weight(.semibold)) - .foregroundStyle(.primary) - .lineLimit(1) - - Spacer(minLength: 0) - - if isCreatingSession { - ProgressView() - .controlSize(.small) - } else { - if #available(iOS 26, *) { - Button(action: onCreateSession) { - Image(systemName: "plus") - .font(.system(.caption, weight: .semibold)) - } - .buttonStyle(.glass) - } else { - Button(action: onCreateSession) { - Image(systemName: "plus") - .font(.system(.caption, weight: .semibold)) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - } - } - - Image(systemName: isExpanded ? "chevron.up" : "chevron.down") - .font(.caption2) - .foregroundStyle(.tertiary) - } - .padding(.horizontal, 14) - .padding(.vertical, 12) - .contentShape(Rectangle()) - .onTapGesture(perform: onToggleExpand) - - // Expanded session list - if isExpanded { - if workspaceWithSessions.isLoading { - HStack { - Spacer() - ProgressView() - .controlSize(.small) - Spacer() - } - .padding(.vertical, 8) - } else if let error = workspaceWithSessions.error { - Text(error) - .font(.caption) - .foregroundStyle(.red) - .padding(.horizontal, 14) - .padding(.vertical, 8) - } else if sessions.isEmpty { - Text("No sessions") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.horizontal, 14) - .padding(.vertical, 8) - } else { - VStack(alignment: .leading, spacing: 0) { - 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 sessions") - .font(.caption) - .foregroundStyle(.accent) - } - .buttonStyle(.plain) - .padding(.horizontal, 14) - .padding(.vertical, 8) - } else if isFullyExpanded && hiddenCount > 0 { - Button(action: onToggleFullExpand) { - Text("Show less") - .font(.caption) - .foregroundStyle(.accent) - } - .buttonStyle(.plain) - .padding(.horizontal, 14) - .padding(.vertical, 8) - } - } - } - } - } - .workspaceCardGlass(isActive: isActive) - .workspaceCardGlassID(workspaceWithSessions.workspace.projectId, namespace: glassNamespace) - } - - @ViewBuilder - private func sessionRow(_ session: Session) -> some View { - Button { - onSelectSession(session.id) - } label: { - HStack(spacing: 8) { - if session.id == activeSessionId { - Circle() - .fill(Color.accentColor) - .frame(width: 6, height: 6) - } else { - Circle() - .fill(Color.clear) - .frame(width: 6, height: 6) - } - - Text(session.title ?? session.id.prefix(8).description) - .font(.subheadline) - .foregroundStyle(session.id == activeSessionId ? .primary : .secondary) - .lineLimit(1) - - Spacer() - } - .padding(.horizontal, 14) - .padding(.vertical, 8) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } -} - -// MARK: - Glass modifiers - -private extension View { - @ViewBuilder - func workspaceCardGlass(isActive: Bool) -> some View { - if #available(iOS 26, *) { - let glass: GlassEffect = isActive - ? .regular.tint(.accentColor).interactive() - : .regular.interactive() - self.glassEffect(glass, in: .rect(cornerRadius: 12)) - } else { - self.background( - isActive ? Color.accentColor.opacity(0.08) : Color(.secondarySystemGroupedBackground), - in: RoundedRectangle(cornerRadius: 12) - ) - } - } - - @ViewBuilder - func workspaceCardGlassID(_ id: String, namespace: Namespace.ID) -> some View { - if #available(iOS 26, *) { - self.glassEffectID(id, in: namespace) - } else { - self - } - } -} -``` - -- [ ] **Step 2: Verify it compiles by building the iOS target** - -Run: `cd /Users/ratulsarna/Developer/Projects/opencode-pocket && xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 16 Pro' build 2>&1 | tail -5` -Expected: BUILD SUCCEEDED (or verify via Xcode) - -- [ ] **Step 3: Commit** - -```bash -git add iosApp/iosApp/SwiftUIInterop/WorkspaceCardView.swift -git commit -m "feat: add WorkspaceCardView with glass styling and expand/collapse" -``` - ---- - -### Task 4: Create WorkspacesSidebarView - -**Files:** -- Create: `iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift` - -- [ ] **Step 1: Create the sidebar root view** - -This wraps the workspace cards in a `ScrollView` + `LazyVStack` with a toolbar for the "Add Workspace" button. - -```swift -import SwiftUI -import ComposeApp - -@MainActor -struct WorkspacesSidebarView: View { - let viewModel: SidebarViewModel - let onSelectSession: () -> Void - let onRequestAppReset: () -> 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 = "" - - var body: some View { - Group { - if let state = latestUiState { - sidebarContent(state: state) - } else { - ProgressView() - } - } - .navigationTitle("Workspaces") - .toolbar { - 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 - latestUiState = state - - // Auto-expand active workspace on first load - if expanded.isEmpty, let activeId = state.activeWorkspaceId { - expanded.insert(activeId) - viewModel.loadSessionsForWorkspace(projectId: activeId) - } - } - } - .onDisappear { - uiStateEvents.stop() - } - .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 a workspace switch is also pending, let that handle the reset - if latestUiState?.switchedWorkspaceId != nil { - return - } - onSelectSession() - } - } - - @ViewBuilder - private func sidebarContent(state: SidebarUiState) -> some View { - ScrollView { - glassContainerWrapper { - LazyVStack(spacing: 12) { - ForEach(state.workspaces, id: \.workspace.projectId) { workspaceWithSessions in - let projectId = workspaceWithSessions.workspace.projectId - let isActive = projectId == state.activeWorkspaceId - let isExp = expanded.contains(projectId) - let isFull = fullyExpanded.contains(projectId) - - WorkspaceCardView( - workspaceWithSessions: workspaceWithSessions, - isActive: isActive, - 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) - // Load sessions on first expand - if workspaceWithSessions.sessions.isEmpty && !workspaceWithSessions.isLoading { - viewModel.loadSessionsForWorkspace(projectId: projectId) - } - } - } - }, - onToggleFullExpand: { - withAnimation(.easeInOut(duration: 0.25)) { - if fullyExpanded.contains(projectId) { - fullyExpanded.remove(projectId) - } else { - fullyExpanded.insert(projectId) - } - } - }, - onSelectSession: { sessionId in - if isActive { - viewModel.switchSession(sessionId: sessionId) - onSelectSession() - } else { - viewModel.switchWorkspace(projectId: projectId, sessionId: sessionId) - } - }, - onCreateSession: { - viewModel.createSession(workspaceProjectId: projectId) - } - ) - } - } - .padding(.horizontal, 16) - .padding(.top, 8) - } - } - } - - @ViewBuilder - private func glassContainerWrapper(@ViewBuilder content: () -> Content) -> some View { - if #available(iOS 26, *) { - GlassEffectContainer(spacing: 12) { - content() - } - } else { - content() - } - } - - @ViewBuilder - private var addWorkspaceSheet: some View { - NavigationStack { - Form { - Section { - TextField("Directory path", text: $draftDirectory) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - } footer: { - Text("Enter the full directory path on the server machine.") - } - } - .navigationTitle("Add Workspace") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - draftDirectory = "" - isShowingAddWorkspace = false - } - } - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - let trimmed = draftDirectory.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - viewModel.addWorkspace(directoryInput: trimmed) - draftDirectory = "" - isShowingAddWorkspace = false - } - .disabled(draftDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - } - } -} -``` - -- [ ] **Step 2: Verify iOS build compiles** - -Run: `cd /Users/ratulsarna/Developer/Projects/opencode-pocket && xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 16 Pro' build 2>&1 | tail -5` -Expected: BUILD SUCCEEDED - -- [ ] **Step 3: Commit** - -```bash -git add iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift -git commit -m "feat: add WorkspacesSidebarView with expandable workspace cards" -``` - ---- - -## Chunk 3: Navigation & Toolbar Rewiring (Swift) - -### Task 5: Modify ChatToolbarGlassView — hamburger + new title/subtitle - -**Files:** -- Modify: `iosApp/iosApp/ChatUIKit/ChatScreenChromeView.swift:6-105` -- Modify: `iosApp/iosApp/ChatUIKit/SwiftUIChatUIKitView.swift:6-119` - -- [ ] **Step 1: Update `ChatToolbarGlassView` parameters and layout** - -In `ChatScreenChromeView.swift`, replace the `ChatToolbarGlassView` struct (lines 6-105): - -1. Replace `onOpenSessions` parameter with `onToggleSidebar`: - - Change line 10 from `let onOpenSessions: () -> Void` to `let onToggleSidebar: () -> Void` - -2. Add new parameters after `onRevert` (line 13): - - `let sessionTitle: String?` - - `let workspacePath: String?` - -3. Replace the `subtitle` computed property (lines 27-32) with: - ```swift - private var subtitle: String { - guard let path = workspacePath, !path.isEmpty else { - return "Pocket chat" - } - let lastComponent = (path as NSString).lastPathComponent - return lastComponent.isEmpty ? path : "…/\(lastComponent)" - } - ``` - -4. Replace the title `Text("OpenCode")` (lines 45-48) with: - ```swift - Text(sessionTitle ?? "OpenCode") - .font(.system(.title3, design: .rounded).weight(.semibold)) - .foregroundStyle(.primary) - .lineLimit(1) - ``` - -5. Restructure the `HStack` at lines 43-76 so the hamburger is on the left and the sessions button is removed: - ```swift - HStack(alignment: .center, spacing: 12) { - ChatToolbarIconButton(action: onToggleSidebar) { - Image(systemName: "line.3.horizontal") - } - - VStack(alignment: .leading, spacing: 3) { - Text(sessionTitle ?? "OpenCode") - .font(.system(.title3, design: .rounded).weight(.semibold)) - .foregroundStyle(.primary) - .lineLimit(1) - - Text(subtitle) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer(minLength: 0) - - HStack(spacing: 8) { - ChatToolbarIconButton(action: onRetry) { - if isRefreshing { - ProgressView() - .controlSize(.small) - } else { - Image(systemName: "arrow.clockwise") - } - } - .disabled(isRefreshing) - - ChatToolbarIconButton(action: onOpenSettings) { - Image(systemName: "gearshape") - } - } - } - ``` - -- [ ] **Step 2: Update `SwiftUIChatUIKitView` to pass new parameters** - -In `SwiftUIChatUIKitView.swift`: - -1. Replace `onOpenSessions` parameter (line 10) with `onToggleSidebar`: - - `let onToggleSidebar: () -> Void` - -2. Add new parameters: - - `let sessionTitle: String?` - - `let workspacePath: String?` - -3. Update the `init` (lines 19-31) to accept the new parameters. - -4. Update `toolbarOverlay` (lines 104-119) to pass the new parameters to `ChatToolbarGlassView`: - ```swift - ChatToolbarGlassView( - state: state, - isRefreshing: state.isRefreshing, - onRetry: viewModel.retry, - onToggleSidebar: onToggleSidebar, - onOpenSettings: onOpenSettings, - onDismissError: viewModel.dismissError, - onRevert: viewModel.revertToLastGood, - sessionTitle: sessionTitle, - workspacePath: workspacePath - ) - ``` - -- [ ] **Step 3: Verify iOS build compiles (will fail until Task 6 wires the call site)** - -This is expected — the call site in `SwiftUIAppRootView` still passes the old parameters. We fix that in Task 6. - -- [ ] **Step 4: Commit** - -```bash -git add iosApp/iosApp/ChatUIKit/ChatScreenChromeView.swift \ - iosApp/iosApp/ChatUIKit/SwiftUIChatUIKitView.swift -git commit -m "feat: replace sessions button with hamburger, add session title + workspace path to toolbar" -``` - ---- - -### Task 6: Rewire SwiftUIAppRootView — NavigationSplitView + sidebar - -**Files:** -- Modify: `iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift` - -- [ ] **Step 1: Update the route enum** - -Remove `.workspaces` case from `SwiftUIAppRoute` (line 9). The enum becomes: - -```swift -private enum SwiftUIAppRoute: Hashable { - case connect - case settings - case modelSelection - case markdownFile(path: String, openId: Int64) -} -``` - -- [ ] **Step 2: Replace `pairedAppView` with `NavigationSplitView`** - -Replace the `pairedAppView` function. Key changes: -- Remove `@State private var isShowingSessions: Bool = false` (line 21) -- Add `@State private var sidebarVisibility: NavigationSplitViewVisibility = .detailOnly` -- Replace `NavigationStack(path: $path)` with `NavigationSplitView(columnVisibility: $sidebarVisibility)` -- Add sidebar column with `WorkspacesSidebarView` -- Detail column contains the existing `NavigationStack` -- Remove `.sheet(isPresented: $isShowingSessions)` (lines 112-114) -- Remove `.workspaces` case from `navigationDestination` (lines 90-94) -- Remove `workspacesViewModel` instantiation (line 57) -- Add `sidebarViewModel` instantiation -- Remove `onOpenSessions` from `SwiftUISettingsView` (line 83) and `SwiftUIChatUIKitView` (line 64) -- Pass `onToggleSidebar`, `sessionTitle`, `workspacePath` to `SwiftUIChatUIKitView` -- Remove `onOpenWorkspaces` and `onOpenSessions` from `SwiftUISettingsView` - -The updated `pairedAppView` requires two new `@State` properties at the top of `SwiftUIAppRootView`: - -```swift -@State private var sidebarVisibility: NavigationSplitViewVisibility = .detailOnly -@State private var settingsUiState: SettingsUiState? -@StateObject private var sidebarUiStateEvents = KmpUiEventBridge() -@State private var sidebarUiState: SidebarUiState? -``` - -Remove the existing `@State private var isShowingSessions: Bool = false` (line 21). - -**Sub-step 2a: Store `settingsUiState` from the existing callback.** -In the existing `settingsUiStateEvents.start(flow:)` callback at line 120, add `self.settingsUiState = uiState` as the first line of the closure, before the theme logic. - -**Sub-step 2b: Add a `sidebarUiStateEvents` bridge.** -In the `.onAppear` block, add: -```swift -sidebarUiStateEvents.start(flow: sidebarViewModel.uiState) { state in - sidebarUiState = state -} -``` -In `.onDisappear`, add `sidebarUiStateEvents.stop()`. - -**Sub-step 2c: Derive session title from `SidebarUiState`.** -`SettingsUiState` does NOT have a session title field. Instead, derive it from the sidebar state: -- Find the active workspace in `sidebarUiState?.workspaces` using `sidebarUiState?.activeWorkspaceId` -- Find the active session in that workspace's sessions using `sidebarUiState?.activeSessionId` -- Use `session.title` as the toolbar title, falling back to the session ID prefix - -Add a computed helper in `SwiftUIAppRootView`: -```swift -private var activeSessionTitle: String? { - guard let state = sidebarUiState, - let activeWorkspaceId = state.activeWorkspaceId, - let activeSessionId = state.activeSessionId, - let workspace = state.workspaces.first(where: { $0.workspace.projectId == activeWorkspaceId }), - let session = workspace.sessions.first(where: { $0.id == activeSessionId }) - else { return nil } - return session.title -} -``` - -The new `pairedAppView`: - -```swift -@ViewBuilder -private func pairedAppView(connectViewModel: ConnectViewModel) -> some View { - let chatViewModel = kmp.owner.chatViewModel() - let settingsViewModel = kmp.owner.settingsViewModel() - let sidebarViewModel = kmp.owner.sidebarViewModel() - - NavigationSplitView(columnVisibility: $sidebarVisibility) { - WorkspacesSidebarView( - viewModel: sidebarViewModel, - onSelectSession: { - sidebarVisibility = .detailOnly - }, - onRequestAppReset: { onRequestAppReset() } - ) - } detail: { - NavigationStack(path: $path) { - Group { - SwiftUIChatUIKitView( - viewModel: chatViewModel, - onOpenSettings: { path.append(.settings) }, - onToggleSidebar: { - withAnimation { - sidebarVisibility = sidebarVisibility == .detailOnly - ? .doubleColumn - : .detailOnly - } - }, - 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) }, - themeRestartNotice: $showThemeRestartNotice - ) - - case .modelSelection: - SwiftUIModelSelectionView(viewModel: settingsViewModel) - - 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) - } - } - } - } - } - } - .navigationSplitViewStyle(.balanced) - .onAppear { - // Retain ALL existing onAppear logic from lines 115-145 of the original file: - // - IosThemeApplier.apply (line 118) - // - settingsUiStateEvents.start with theme logic (lines 120-140) — add settingsUiState = uiState as first line - // - shareEvents.start (lines 142-144) - // PLUS add: - sidebarUiStateEvents.start(flow: sidebarViewModel.uiState) { state in - sidebarUiState = state - } - } - .onDisappear { - // Retain existing: settingsUiStateEvents.stop(), shareEvents.stop(), pendingThemeVerification cancel - sidebarUiStateEvents.stop() - } - // Retain existing modifiers from lines 152-172: - // - .onChange(of: path) — markdown store pruning - // - .onChange(of: scenePhase) — foreground/background handling -} -``` - -- [ ] **Step 3: Verify iOS build compiles** - -Run: `cd /Users/ratulsarna/Developer/Projects/opencode-pocket && xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 16 Pro' build 2>&1 | tail -5` -Expected: BUILD SUCCEEDED - -- [ ] **Step 4: Commit** - -```bash -git add iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift -git commit -m "feat: replace NavigationStack with NavigationSplitView, wire sidebar" -``` - ---- - -### Task 7: Clean up SwiftUISettingsView - -**Files:** -- Modify: `iosApp/iosApp/SwiftUIInterop/SwiftUISettingsViews.swift:4-171` - -- [ ] **Step 1: Remove workspace and sessions parameters and rows** - -1. Remove `let onOpenWorkspaces: () -> Void` (line 9) -2. Remove `let onOpenSessions: () -> Void` (line 10) -3. Remove the Workspace button (lines 64-79 inside `Section("App")`) -4. Remove the entire `Section("Navigation")` block (lines 167-171) -5. Remove the now-unused `workspaceText(name:worktree:)` private function (around lines 336-349) which becomes dead code after the Workspace row is removed - -- [ ] **Step 2: Verify iOS build compiles** - -Run: `cd /Users/ratulsarna/Developer/Projects/opencode-pocket && xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 16 Pro' build 2>&1 | tail -5` -Expected: BUILD SUCCEEDED - -- [ ] **Step 3: Commit** - -```bash -git add iosApp/iosApp/SwiftUIInterop/SwiftUISettingsViews.swift -git commit -m "refactor: remove workspace and sessions navigation from Settings" -``` - ---- - -## Chunk 4: Cleanup & Deletion - -### Task 8: Delete old files and clean up DI - -**Files:** -- Delete: `iosApp/iosApp/SwiftUIInterop/SwiftUISessionsViews.swift` -- Delete: `iosApp/iosApp/SwiftUIInterop/SwiftUIWorkspacesViews.swift` -- Delete: `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModel.kt` -- Delete: `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/workspaces/WorkspacesViewModel.kt` -- Delete: `composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModelTest.kt` -- Modify: `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/di/AppModule.kt` -- Modify: `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/IosViewModelOwners.kt` - -- [ ] **Step 1: Delete the Swift view files** - -```bash -rm iosApp/iosApp/SwiftUIInterop/SwiftUISessionsViews.swift -rm iosApp/iosApp/SwiftUIInterop/SwiftUIWorkspacesViews.swift -``` - -If the Xcode project uses explicit file references in `.pbxproj` for these files, remove the corresponding entries. If using folder references (most likely), no `.pbxproj` changes are needed. - -- [ ] **Step 2: Delete the Kotlin ViewModel files and test** - -```bash -rm composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModel.kt -rm composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/workspaces/WorkspacesViewModel.kt -rm composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModelTest.kt -``` - -- [ ] **Step 3: Remove old factory methods from AppModule** - -In `AppModule.kt`: -- Remove `createSessionsViewModel()` (lines 263-268) -- Remove `createWorkspacesViewModel()` (lines 289-291) -- Remove the imports for `SessionsViewModel` and `WorkspacesViewModel` (lines 38-40) - -- [ ] **Step 4: Remove old accessors from IosViewModelOwners** - -In `IosViewModelOwners.kt`: -- Remove `workspacesViewModel()` from `IosAppViewModelOwner` (line 48) -- Remove `sessionsViewModel()` from `IosScreenViewModelOwner` (line 89) -- Remove the imports for `SessionsViewModel` and `WorkspacesViewModel` (lines 15, 17) - -- [ ] **Step 5: Verify both Kotlin and iOS builds compile** - -Run (in parallel): -- `cd /Users/ratulsarna/Developer/Projects/opencode-pocket && ./gradlew composeApp:compileKotlinJvm` -- `cd /Users/ratulsarna/Developer/Projects/opencode-pocket && xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 16 Pro' build 2>&1 | tail -5` - -Expected: Both BUILD SUCCESSFUL - -- [ ] **Step 6: Run all remaining tests** - -Run: `cd /Users/ratulsarna/Developer/Projects/opencode-pocket && ./gradlew composeApp:jvmTest` -Expected: All tests PASS (including the new SidebarViewModelTest) - -- [ ] **Step 7: Commit** - -```bash -git add composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/di/AppModule.kt \ - composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/IosViewModelOwners.kt -git rm iosApp/iosApp/SwiftUIInterop/SwiftUISessionsViews.swift \ - iosApp/iosApp/SwiftUIInterop/SwiftUIWorkspacesViews.swift \ - composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModel.kt \ - composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/workspaces/WorkspacesViewModel.kt \ - composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModelTest.kt -git commit -m "refactor: delete old Sessions/Workspaces views, ViewModels, and DI wiring" -``` diff --git a/docs/superpowers/specs/2026-03-15-sidebar-navigation-rework-design.md b/docs/superpowers/specs/2026-03-15-sidebar-navigation-rework-design.md deleted file mode 100644 index 9e9c928..0000000 --- a/docs/superpowers/specs/2026-03-15-sidebar-navigation-rework-design.md +++ /dev/null @@ -1,274 +0,0 @@ -# Sidebar Navigation Rework — Design Spec - -## Problem - -Workspaces and sessions are currently separate screens accessed through different paths — the Sessions sheet (modal from chat toolbar) and the Workspaces screen (pushed from Settings). This creates a disjointed experience where the two most closely related concepts (which project am I in, which conversation am I having) are managed in completely different places. - -## Solution - -Replace both with a unified sidebar that shows workspaces with nested sessions, accessed via a hamburger button on the chat toolbar. Use `NavigationSplitView` so the sidebar automatically adapts between iPhone (full push) and iPad (persistent sidebar) without code changes. - -## Design Decisions - -| Decision | Choice | Reasoning | -|----------|--------|-----------| -| Sidebar presentation | `NavigationSplitView` (full push on iPhone) | Native iPhone/iPad adaptation for free; forward-compatible | -| Sidebar content | Custom `ScrollView` + `LazyVStack` inside `NavigationSplitView` sidebar column | Avoids `List` styling conflicts with Liquid Glass | -| Workspace tap behavior | Expand/collapse session list | No separate "activate workspace" action; selecting a session implicitly activates its workspace | -| Default sessions shown | Top 3 per workspace (most recent) | Keeps sidebar compact; "View N more" CTA expands | -| "Add Workspace" location | Sidebar toolbar top-right `+` button | Always visible regardless of scroll position | -| "New Session" location | Per-workspace `+` button on each workspace row | Contextual to the workspace | -| Chat toolbar changes | Hamburger left, session title + workspace subtitle center, refresh + gear right | Simplified; sessions button removed; workspace/session context visible at a glance | -| Workspace path display | Leading ellipsis (`…/opencode-pocket`) | Shows the meaningful final folder name within limited toolbar space | -| Glass treatment | Glass on workspace cards; sessions are plain rows inside the card | Avoids cluttered glass-on-glass nesting; clear visual hierarchy | -| State management | New `SidebarViewModel` (Kotlin, app-scoped) absorbs `WorkspacesViewModel` + `SessionsViewModel` | Single source of truth for workspace + session state | - -## Navigation Architecture - -### Root View (`SwiftUIAppRootView`) - -``` -SwiftUIAppRootView -├── [NOT PAIRED] NavigationStack -│ └── SwiftUIConnectToOpenCodeView (full-screen, no hamburger, no sidebar) -│ -└── [PAIRED] NavigationSplitView(columnVisibility: $sidebarVisibility) - ├── sidebar: - │ └── WorkspacesSidebarView (custom ScrollView + LazyVStack) - │ - └── detail: - └── NavigationStack - ├── SwiftUIChatUIKitView (root) - └── .navigationDestination: - ├── .settings → SwiftUISettingsView - ├── .connect → SwiftUIConnectToOpenCodeView - ├── .modelSelection → SwiftUIModelSelectionView - └── .markdownFile → SwiftUIMarkdownFileViewerView -``` - -### Key Behaviors - -- `columnVisibility` is a `@State var sidebarVisibility: NavigationSplitViewVisibility`. -- Hamburger button toggles between `.detailOnly` (hidden) and `.doubleColumn` (shown). -- On iPhone, `.doubleColumn` presents the sidebar as a full-screen push. Tapping a session sets visibility back to `.detailOnly`. -- On iPad (future), the sidebar remains persistent in `.doubleColumn` — no code changes needed. -- The detail column retains its own `NavigationStack` for pushing Settings, Markdown viewer, etc. - -### First Run / Unpaired State - -- `NavigationSplitView` does not exist. The `[NOT PAIRED]` branch uses a plain `NavigationStack` with `SwiftUIConnectToOpenCodeView` as a full-screen experience. -- No hamburger, no sidebar, no toolbar. Identical to current behavior. -- Once pairing succeeds, app resets (new DI graph), root switches to the `[PAIRED]` branch. - -### Paired but Disconnected - -- Hamburger is present and functional. -- Sidebar shows last-known workspaces and sessions from cached state. -- Chat screen shows reconnecting banner as today. - -## Sidebar Content & Interaction - -### Layout - -``` -┌─────────────────────────────────────┐ -│ Workspaces [+] │ ← Sidebar toolbar: title + Add Workspace -├─────────────────────────────────────┤ -│ │ -│ ┌─────────────────────────────────┐│ -│ │ 📁 opencode-pocket [+] ││ ← Workspace row (+ = new session) -│ ├─────────────────────────────────┤│ -│ │ ● Fix auth token refresh ││ ← Active session (accent dot) -│ │ Add image paste support ││ -│ │ Refactor chat toolbar ││ ← 3rd session -│ │ View 12 more sessions ▾ ││ ← "See more" CTA -│ └─────────────────────────────────┘│ -│ │ -│ ┌─────────────────────────────────┐│ -│ │ 📁 backend-api [+] ││ ← Collapsed workspace -│ └─────────────────────────────────┘│ -│ │ -└─────────────────────────────────────┘ -``` - -### Interaction Model - -| Action | Behavior | -|--------|----------| -| Tap workspace row | Toggle expand/collapse of that workspace's session list (animated) | -| Tap `[+]` on workspace row | Create new session in that workspace. If different workspace than active, triggers workspace switch (app reset) + new session | -| Tap session row (same workspace) | Switch to that session, dismiss sidebar | -| Tap session row (different workspace) | Trigger workspace switch (app reset), then activate that session | -| Tap "View N more sessions" | Expand to show all sessions for that workspace (animated). CTA changes to "Show less" | -| Tap toolbar `[+]` (Add Workspace) | Present inline text field or sheet for directory path entry | - -### Sidebar State - -- `expanded: Set` — which workspaces show sessions. **Active workspace expanded by default** when sidebar opens. Others collapsed. -- `fullyExpanded: Set` — which workspaces show all sessions (past initial 3). Default: none. -- Both are SwiftUI `@State` — UI-only, not business logic. - -### Session Ordering - -Sessions within each workspace are sorted by `updatedAt` descending. The top 3 shown by default are the most recent. - -## Chat Toolbar - -### Layout - -``` -[☰] "Fix auth token refresh" / "…/opencode-pocket" [↻] [⚙] -``` - -| Element | Description | -|---------|-------------| -| Left: Hamburger (`line.3.horizontal`) | Toggles sidebar visibility | -| Center line 1: Session title | Active session's title string | -| Center line 2: Workspace path | Last path component with leading ellipsis (`…/opencode-pocket`). If path is short enough, show without ellipsis | -| Right: Refresh button | Existing refresh action | -| Right: Settings gear | Pushes to Settings screen | - -### Overlay Elements (Unchanged) - -- Indeterminate processing bar (agent working) -- Reconnecting banner -- Error banner with Dismiss / Retry / Revert actions - -## Liquid Glass Treatment - -### Sidebar - -| Element | Treatment | -|---------|-----------| -| Workspace card (collapsed) | `.glassEffect(.regular.interactive(), in: .rect(cornerRadius: 12))` | -| Workspace card (expanded) | Same glass wrapping the entire card including session rows | -| Active workspace card | `.glassEffect(.regular.tint(.accentColor).interactive(), in: .rect(cornerRadius: 12))` | -| Session rows | No individual glass — content within the workspace card's glass surface, separated by subtle spacing | -| "View N more" CTA | No glass — plain text button in accent color | -| Per-workspace `[+]` button | `.buttonStyle(.glass)` | -| Toolbar `[+]` button | `.buttonStyle(.glass)` | - -### Design Rationale - -Sessions don't get their own glass to avoid glass-on-glass nesting, which looks cluttered. The workspace card is the glass container; sessions are content within it. This creates a clear visual hierarchy: glass cards = workspaces, plain rows inside = sessions. - -### Animations - -- Sidebar wrapped in `GlassEffectContainer`. -- Each workspace card uses `glassEffectID` with `@Namespace` for smooth glass morphing on expand/collapse. -- Session rows animate with `.transition(.opacity.combined(with: .move(edge: .top)))` inside `withAnimation`. - -### Chat Toolbar - -- Existing `ChatToolbarGlassView` retains its glass treatment. -- Hamburger button gets `.buttonStyle(.glass)`. - -### iOS Version Gating - -All `glassEffect` calls gated behind `#available(iOS 26, *)` with fallback to `.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))`. - -## Data Flow - -### New ViewModel: `SidebarViewModel` (Kotlin, app-scoped) - -```kotlin -data class SidebarUiState( - val workspaces: List, - val activeWorkspaceId: String?, - val activeSessionId: String?, - val isCreatingSession: Boolean, - val isCreatingWorkspace: Boolean, - val isSwitchingWorkspace: Boolean -) - -data class WorkspaceWithSessions( - val workspace: Workspace, - val sessions: List, // sorted by updatedAt desc - val isLoading: Boolean, // loading sessions for this workspace - val error: String? // per-workspace loading error -) -``` - -### Session-Workspace Association - -Sessions are grouped into workspaces by matching `Session.directory` against `Workspace.worktree`. A session belongs to the workspace whose `worktree` path matches the session's `directory` field. - -### Session Fetching Strategy - -The current `SessionRepository.getSessions()` API fetches all sessions globally (no workspace/directory filter). The `SidebarViewModel` fetches all sessions once, then groups and filters client-side by matching `Session.directory` to each `Workspace.worktree`. This avoids API changes and is efficient given the expected session count. Lazy loading per-workspace means: the global fetch is triggered on first sidebar open, but the client-side grouping for a collapsed workspace is deferred until expansion. - -### Cross-Reset Session Persistence - -When `switchWorkspace(workspaceId, sessionId)` is called for a different workspace, the target session ID is written to `AppSettings` via `setCurrentSessionId(sessionId)` **before** triggering the app reset. After reset, the new DI graph reads `getCurrentSessionId()` during initialization and activates that session. This matches the existing pattern where `AppSettings` persists state across resets. - -Note: `SidebarUiState.activeSessionId` maps to `AppSettings.currentSessionId` — these refer to the same value, no new persistence key is needed. - -### Key Behaviors - -- On init, fetches workspaces. Sessions are fetched globally and grouped client-side by matching `Session.directory` to `Workspace.worktree`. -- `createSession(workspaceId)` — creates session, handles workspace switch if needed. -- `addWorkspace(path)` — adds workspace to the list. -- `switchSession(sessionId)` — for same-workspace session switches. -- `switchWorkspace(workspaceId, sessionId)` — writes target session ID to `AppSettings`, then triggers app reset. - -### Dropped Feature: Session Search - -The current `SessionsView` has `.searchable` for filtering sessions. This is intentionally dropped from the initial sidebar implementation to keep scope focused. Session search can be added later as a filter field within the sidebar if needed. - -### Swift Side - -- `SidebarViewModel` held in `IosAppViewModelOwner` (app-scoped). -- `WorkspacesSidebarView` observes via `Observing(viewModel.uiState)` SKIE pattern. -- Expand/collapse state is SwiftUI `@State` only. - -## Deletions - -### Files to Delete - -| File | Reason | -|------|--------| -| `iosApp/iosApp/SwiftUIInterop/SwiftUISessionsViews.swift` | Contains both `SwiftUISessionsSheetView` and `SwiftUISessionsView`. Replaced by sidebar | -| `iosApp/iosApp/SwiftUIInterop/SwiftUIWorkspacesViews.swift` | Contains `SwiftUIWorkspacesView`. Replaced by sidebar | -| `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModel.kt` | Logic absorbed into `SidebarViewModel` | -| `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/workspaces/WorkspacesViewModel.kt` | Logic absorbed into `SidebarViewModel` | -| `composeApp/src/jvmTest/kotlin/com/ratulsarna/ocmobile/ui/screen/sessions/SessionsViewModelTest.kt` | Tests for deleted `SessionsViewModel` — migrate relevant cases to `SidebarViewModelTest` | - -### Settings Cleanup (`SwiftUISettingsView`) - -Remove: -- "Workspace" row -- "Sessions" row - -Keep: -- Status pills (Connected/Disconnected, Context usage) -- Connect to OpenCode row -- Model selection row -- Agent selection row/sheet -- Theme picker -- Advanced section - -### Navigation Routes (`SwiftUIAppRoute`) - -Remove: -- `.workspaces` - -Keep: -- `.connect` -- `.settings` -- `.modelSelection` -- `.markdownFile(path:openId:)` - -### DI Cleanup - -- Remove `SessionsViewModel` from `IosScreenViewModelOwner`. -- Remove `WorkspacesViewModel` from `IosAppViewModelOwner`. -- Add `SidebarViewModel` to `IosAppViewModelOwner` (app-scoped). -- `IosScreenViewModelOwner` retains only `markdownFileViewerViewModel()` after this change. - -## New Files - -| File | Location | Purpose | -|------|----------|---------| -| `WorkspacesSidebarView.swift` | `iosApp/iosApp/SwiftUIInterop/` | Sidebar root view with toolbar and workspace list | -| `WorkspaceCardView.swift` | `iosApp/iosApp/SwiftUIInterop/` | Individual workspace card with expand/collapse, session rows, glass treatment | -| `SidebarViewModel.kt` | `composeApp/src/commonMain/kotlin/com/ratulsarna/ocmobile/ui/screen/sidebar/` | Combined workspace + session state management | From 60c2b12079b7597c3ee442cb70f90d6a7bea65f3 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 15 Mar 2026 16:52:57 +0530 Subject: [PATCH 22/27] feat: enhance workspace creation error handling in SidebarViewModel - 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. --- .../ui/screen/sidebar/SidebarViewModel.kt | 12 ++++-- .../ui/screen/sidebar/SidebarViewModelTest.kt | 25 +++++++++++++ .../WorkspacesSidebarView.swift | 37 +++++++++++++++++-- 3 files changed, 68 insertions(+), 6 deletions(-) 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 index 1cfca64..bfeb398 100644 --- 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 @@ -230,16 +230,21 @@ class SidebarViewModel( fun addWorkspace(directoryInput: String) { if (_uiState.value.isCreatingWorkspace) return - _uiState.update { it.copy(isCreatingWorkspace = true) } + _uiState.update { it.copy(isCreatingWorkspace = true, workspaceCreationError = null) } viewModelScope.launch { workspaceRepository.addWorkspace(directoryInput) .onSuccess { - _uiState.update { it.copy(isCreatingWorkspace = false) } + _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) } + _uiState.update { + it.copy( + isCreatingWorkspace = false, + workspaceCreationError = error.message ?: "Failed to add workspace" + ) + } } } } @@ -261,6 +266,7 @@ data class SidebarUiState( val activeSessionTitle: String? = null, val isCreatingSession: Boolean = false, val isCreatingWorkspace: Boolean = false, + val workspaceCreationError: String? = null, val isSwitchingWorkspace: Boolean = false, val switchedWorkspaceId: String? = null, val createdSessionId: String? = null 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 index 72d914b..dc1fc3c 100644 --- 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 @@ -264,6 +264,31 @@ class SidebarViewModelTest { 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) } private fun workspace(projectId: String, worktree: String, name: String? = null): Workspace = diff --git a/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift b/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift index e4e36fd..da8b269 100644 --- a/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift +++ b/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift @@ -14,6 +14,9 @@ struct WorkspacesSidebarView: View { @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 var body: some View { Group { @@ -47,6 +50,7 @@ struct WorkspacesSidebarView: View { } .onAppear { uiStateEvents.start(flow: viewModel.uiState) { state in + let previousState = latestUiState latestUiState = state // Auto-expand active workspace on first load @@ -54,6 +58,20 @@ struct WorkspacesSidebarView: View { 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 + } + } } } .onDisappear { @@ -136,28 +154,41 @@ struct WorkspacesSidebarView: View { 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) + .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) - draftDirectory = "" - isShowingAddWorkspace = false } - .disabled(draftDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .disabled( + draftDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || + latestUiState?.isCreatingWorkspace == true + ) } } } From 37b865da346a0e6bc53ffa56dc84601b4a28396a Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 15 Mar 2026 16:56:43 +0530 Subject: [PATCH 23/27] fix: update DEVELOPMENT_TEAM to use dynamic TEAM_ID variable - 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. --- iosApp/iosApp.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index cbf1a45..29310e8 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -437,7 +437,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = CDY7Z973ZG; + DEVELOPMENT_TEAM = "$(TEAM_ID)"; ENABLE_USER_SCRIPT_SANDBOXING = NO; "FRAMEWORK_SEARCH_PATHS[arch=*]" = "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(PLATFORM_NAME)$(SDK_VERSION)"; GENERATE_INFOPLIST_FILE = YES; @@ -477,7 +477,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = CDY7Z973ZG; + DEVELOPMENT_TEAM = "$(TEAM_ID)"; ENABLE_USER_SCRIPT_SANDBOXING = NO; "FRAMEWORK_SEARCH_PATHS[arch=*]" = "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(PLATFORM_NAME)$(SDK_VERSION)"; GENERATE_INFOPLIST_FILE = YES; @@ -645,7 +645,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = CDY7Z973ZG; + DEVELOPMENT_TEAM = "$(TEAM_ID)"; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; @@ -675,7 +675,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = CDY7Z973ZG; + DEVELOPMENT_TEAM = "$(TEAM_ID)"; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "iosApp/Info-Debug.plist"; From 0c54e0dd36f1190e96017f0747c3373fcccf08b5 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 15 Mar 2026 17:49:27 +0530 Subject: [PATCH 24/27] fix: address sidebar review feedback --- .../ui/screen/sidebar/SidebarViewModel.kt | 13 +++- .../ui/screen/sidebar/SidebarViewModelTest.kt | 73 +++++++++++++++++-- .../SwiftUIInterop/SwiftUIAppRootView.swift | 19 ++++- .../WorkspacesSidebarView.swift | 20 +---- 4 files changed, 97 insertions(+), 28 deletions(-) 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 index bfeb398..47aa7cc 100644 --- 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 @@ -37,6 +37,12 @@ class SidebarViewModel( 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}") } @@ -219,7 +225,12 @@ class SidebarViewModel( } .onFailure { error -> OcMobileLog.w(TAG, "Failed to create session: ${error.message}") - _uiState.update { it.copy(isCreatingSession = false) } + _uiState.update { + it.copy( + isCreatingSession = false, + switchedWorkspaceId = if (!isActiveWorkspace) workspaceProjectId else null + ) + } } } } 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 index dc1fc3c..e2b5d06 100644 --- 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 @@ -170,8 +170,8 @@ class SidebarViewModelTest { } @Test - fun SidebarViewModel_switchWorkspacePersistsSessionIdBeforeActivating() = runTest(dispatcher) { - val activatedIds = mutableListOf() + fun SidebarViewModel_switchWorkspaceActivatesBeforePersistingSessionId() = runTest(dispatcher) { + val operations = mutableListOf() val appSettings = MockAppSettings() appSettings.setActiveServerId("server-1") appSettings.setInstallationIdForServer("server-1", "inst-1") @@ -186,12 +186,13 @@ class SidebarViewModelTest { activeWorkspace = workspace1, appSettings = appSettings, activateHandler = { id -> - activatedIds.add(id) + operations.add("activate:$id") Result.success(Unit) } ) val sessionRepo = FakeSessionRepository( updateCurrentSessionIdHandler = { sessionId -> + operations.add("persist:$sessionId") appSettings.setCurrentSessionId(sessionId) Result.success(Unit) } @@ -209,10 +210,44 @@ class SidebarViewModelTest { assertEquals("proj-2", appSettings.getActiveWorkspaceSnapshot()?.projectId) assertEquals("ses-target", appSettings.getCurrentSessionIdSnapshot()) - assertEquals(listOf("proj-2"), activatedIds) + assertEquals(listOf("activate:proj-2", "persist:ses-target"), operations) assertEquals("proj-2", vm.uiState.value.switchedWorkspaceId) } + @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("proj-2", vm.uiState.value.switchedWorkspaceId) + assertEquals("proj-2", appSettings.getActiveWorkspaceSnapshot()?.projectId) + } + @Test fun SidebarViewModel_createSessionInActiveWorkspace() = runTest(dispatcher) { val repo = FakeWorkspaceRepository( @@ -291,6 +326,29 @@ class SidebarViewModelTest { 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) @@ -308,6 +366,7 @@ class SidebarViewModelTest { 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 { @@ -316,10 +375,10 @@ class SidebarViewModelTest { override fun getWorkspaces(): Flow> = _workspaces override fun getActiveWorkspace(): Flow = _active - override fun getActiveWorkspaceSnapshot(): Workspace? = activeWorkspace + override fun getActiveWorkspaceSnapshot(): Workspace? = _active.value override suspend fun ensureInitialized(): Result = - activeWorkspace?.let { Result.success(it) } ?: Result.failure(RuntimeException("no active")) - override suspend fun refresh(): Result = Result.success(Unit) + _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) diff --git a/iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift b/iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift index 8d99619..bf65e1b 100644 --- a/iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift +++ b/iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift @@ -128,10 +128,6 @@ struct SwiftUIAppRootView: View { withAnimation(.snappy(duration: 0.28)) { isSidebarPresented = false } - }, - onRequestAppReset: { - isSidebarPresented = false - onRequestAppReset() } ) } @@ -192,6 +188,21 @@ struct SwiftUIAppRootView: View { }) markdownManager.prune(activeKeys: activeMarkdownKeys) } + .onChange(of: sidebarUiState?.switchedWorkspaceId) { switchedWorkspaceId in + guard let switchedWorkspaceId, !switchedWorkspaceId.isEmpty else { return } + sidebarViewModel.clearCreatedSession() + sidebarViewModel.clearWorkspaceSwitch() + isSidebarPresented = false + onRequestAppReset() + } + .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: diff --git a/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift b/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift index da8b269..16e5aca 100644 --- a/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift +++ b/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift @@ -6,7 +6,6 @@ struct WorkspacesSidebarView: View { let viewModel: SidebarViewModel let onClose: () -> Void let onSelectSession: () -> Void - let onRequestAppReset: () -> Void @StateObject private var uiStateEvents = KmpUiEventBridge() @State private var latestUiState: SidebarUiState? @@ -17,6 +16,7 @@ struct WorkspacesSidebarView: View { @State private var pendingAddWorkspace = false @State private var addWorkspaceErrorMessage: String? @State private var isShowingAddWorkspaceError = false + @State private var hasSeededInitialExpansion = false var body: some View { Group { @@ -53,8 +53,8 @@ struct WorkspacesSidebarView: View { let previousState = latestUiState latestUiState = state - // Auto-expand active workspace on first load - if expanded.isEmpty, let activeId = state.activeWorkspaceId { + if !hasSeededInitialExpansion, let activeId = state.activeWorkspaceId { + hasSeededInitialExpansion = true expanded.insert(activeId) viewModel.loadSessionsForWorkspace(projectId: activeId) } @@ -77,19 +77,6 @@ struct WorkspacesSidebarView: View { .onDisappear { uiStateEvents.stop() } - .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() - } } @ViewBuilder @@ -161,6 +148,7 @@ struct WorkspacesSidebarView: View { } .navigationTitle("Add Workspace") .navigationBarTitleDisplayMode(.inline) + .interactiveDismissDisabled(latestUiState?.isCreatingWorkspace == true) .alert("Couldn’t Add Workspace", isPresented: $isShowingAddWorkspaceError) { Button("OK", role: .cancel) { addWorkspaceErrorMessage = nil From 24fa7c9f58034c29c245b25bddc153c67708850a Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 15 Mar 2026 19:52:02 +0530 Subject: [PATCH 25/27] fix: harden sidebar error handling --- .../ui/screen/sidebar/SidebarViewModel.kt | 41 ++++++-- .../ui/screen/sidebar/SidebarViewModelTest.kt | 96 ++++++++++++++++++- .../WorkspacesSidebarView.swift | 18 ++++ 3 files changed, 147 insertions(+), 8 deletions(-) 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 index 47aa7cc..993a50b 100644 --- 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 @@ -142,7 +142,7 @@ class SidebarViewModel( _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 }) } @@ -175,22 +175,39 @@ class SidebarViewModel( fun switchWorkspace(projectId: String, sessionId: String?) { if (_uiState.value.isSwitchingWorkspace) return - _uiState.update { it.copy(isSwitchingWorkspace = true) } + _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) } } - _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, + operationErrorMessage = error.message ?: "Failed to switch workspace" + ) + } } } } @@ -199,9 +216,13 @@ class SidebarViewModel( _uiState.update { it.copy(switchedWorkspaceId = null) } } + fun clearOperationError() { + _uiState.update { it.copy(operationErrorMessage = null) } + } + fun createSession(workspaceProjectId: String) { if (_uiState.value.isCreatingSession) return - _uiState.update { it.copy(isCreatingSession = true) } + _uiState.update { it.copy(isCreatingSession = true, operationErrorMessage = null) } viewModelScope.launch { val isActiveWorkspace = workspaceProjectId == _uiState.value.activeWorkspaceId @@ -210,7 +231,12 @@ class SidebarViewModel( workspaceRepository.activateWorkspace(workspaceProjectId) .onFailure { error -> OcMobileLog.w(TAG, "Failed to switch workspace for new session: ${error.message}") - _uiState.update { it.copy(isCreatingSession = false) } + _uiState.update { + it.copy( + isCreatingSession = false, + operationErrorMessage = error.message ?: "Failed to switch workspace" + ) + } return@launch } } @@ -228,7 +254,7 @@ class SidebarViewModel( _uiState.update { it.copy( isCreatingSession = false, - switchedWorkspaceId = if (!isActiveWorkspace) workspaceProjectId else null + operationErrorMessage = error.message ?: "Failed to create session" ) } } @@ -278,6 +304,7 @@ data class SidebarUiState( val isCreatingSession: Boolean = false, val isCreatingWorkspace: Boolean = false, val workspaceCreationError: String? = null, + val operationErrorMessage: String? = null, val isSwitchingWorkspace: Boolean = false, val switchedWorkspaceId: String? = null, val createdSessionId: String? = null 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 index e2b5d06..3cca44a 100644 --- 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 @@ -117,6 +117,42 @@ class SidebarViewModelTest { 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_switchSessionCallsRepository() = runTest(dispatcher) { val repo = FakeWorkspaceRepository( @@ -214,6 +250,39 @@ class SidebarViewModelTest { 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") @@ -244,10 +313,35 @@ class SidebarViewModelTest { advanceUntilIdle() assertEquals(false, vm.uiState.value.isCreatingSession) - assertEquals("proj-2", vm.uiState.value.switchedWorkspaceId) + 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( diff --git a/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift b/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift index 16e5aca..50ef8b9 100644 --- a/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift +++ b/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift @@ -16,6 +16,8 @@ struct WorkspacesSidebarView: View { @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 { @@ -72,6 +74,13 @@ struct WorkspacesSidebarView: View { isShowingAddWorkspace = false } } + + if let error = state.operationErrorMessage, + !error.isEmpty, + previousState?.operationErrorMessage != error { + operationErrorMessage = error + isShowingOperationError = true + } } } .onDisappear { @@ -126,10 +135,19 @@ struct WorkspacesSidebarView: View { viewModel.createSession(workspaceProjectId: projectId) } ) + .disabled(state.isSwitchingWorkspace) } } .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") + } } } From 8d185edec94c442892a4c1e9666cbcf73b663afe Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Mon, 16 Mar 2026 13:49:05 +0530 Subject: [PATCH 26/27] feat: improve session loading and switching functionality in SidebarViewModel - 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. --- .../ui/screen/sidebar/SidebarViewModel.kt | 97 +++++++++++++------ .../ui/screen/sidebar/SidebarViewModelTest.kt | 83 ++++++++++++++++ .../SwiftUIInterop/SwiftUIAppRootView.swift | 8 ++ .../WorkspacesSidebarView.swift | 3 +- 4 files changed, 158 insertions(+), 33 deletions(-) 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 index 993a50b..c6563a0 100644 --- 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 @@ -26,6 +26,7 @@ class SidebarViewModel( ) : ViewModel() { private val _uiState = MutableStateFlow(SidebarUiState()) + private val loadingWorkspaceIds = mutableSetOf() val uiState: StateFlow = _uiState.asStateFlow() init { @@ -110,7 +111,7 @@ class SidebarViewModel( fun loadSessionsForWorkspace(projectId: String) { val workspace = _uiState.value.workspaces.find { it.workspace.projectId == projectId } ?: return - if (workspace.isLoading) 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). @@ -128,46 +129,74 @@ class SidebarViewModel( } viewModelScope.launch { - 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 - }) + 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 - }) + .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" + ) + } } } } @@ -216,6 +245,10 @@ class SidebarViewModel( _uiState.update { it.copy(switchedWorkspaceId = null) } } + fun clearSwitchedSession() { + _uiState.update { it.copy(switchedSessionId = null) } + } + fun clearOperationError() { _uiState.update { it.copy(operationErrorMessage = null) } } @@ -305,7 +338,9 @@ data class SidebarUiState( 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 ) 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 index 3cca44a..858d3ef 100644 --- 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 @@ -153,6 +153,35 @@ class SidebarViewModelTest { 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( @@ -179,6 +208,60 @@ class SidebarViewModelTest { 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 diff --git a/iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift b/iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift index bf65e1b..48aa801 100644 --- a/iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift +++ b/iosApp/iosApp/SwiftUIInterop/SwiftUIAppRootView.swift @@ -191,10 +191,18 @@ struct SwiftUIAppRootView: View { .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 } diff --git a/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift b/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift index 50ef8b9..45812d6 100644 --- a/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift +++ b/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift @@ -126,7 +126,6 @@ struct WorkspacesSidebarView: View { let isActiveWorkspace = projectId == state.activeWorkspaceId if isActiveWorkspace { viewModel.switchSession(sessionId: sessionId) - onSelectSession() } else { viewModel.switchWorkspace(projectId: projectId, sessionId: sessionId) } @@ -135,7 +134,7 @@ struct WorkspacesSidebarView: View { viewModel.createSession(workspaceProjectId: projectId) } ) - .disabled(state.isSwitchingWorkspace) + .disabled(state.isSwitchingWorkspace || state.isSwitchingSession) } } .padding(.horizontal, 12) From 5de662df5449a5fc97b62fc837578e597af91c75 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Mon, 16 Mar 2026 15:05:05 +0530 Subject: [PATCH 27/27] fix: keep sidebar session state in sync --- .../ocmobile/ui/screen/chat/ChatState.kt | 1 + .../ocmobile/ui/screen/chat/ChatViewModel.kt | 39 ++++++++++++++++--- .../ChatUIKit/SwiftUIChatUIKitView.swift | 2 +- .../WorkspacesSidebarView.swift | 2 +- 4 files changed, 37 insertions(+), 7 deletions(-) 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/iosApp/iosApp/ChatUIKit/SwiftUIChatUIKitView.swift b/iosApp/iosApp/ChatUIKit/SwiftUIChatUIKitView.swift index 20acde4..33d389a 100644 --- a/iosApp/iosApp/ChatUIKit/SwiftUIChatUIKitView.swift +++ b/iosApp/iosApp/ChatUIKit/SwiftUIChatUIKitView.swift @@ -117,7 +117,7 @@ struct SwiftUIChatUIKitView: View { onOpenSettings: onOpenSettings, onDismissError: viewModel.dismissError, onRevert: viewModel.revertToLastGood, - sessionTitle: sessionTitle, + sessionTitle: state.currentSessionTitle ?? sessionTitle, workspacePath: workspacePath ) .padding(.horizontal, 12) diff --git a/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift b/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift index 45812d6..164d82f 100644 --- a/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift +++ b/iosApp/iosApp/SwiftUIInterop/WorkspacesSidebarView.swift @@ -134,7 +134,7 @@ struct WorkspacesSidebarView: View { viewModel.createSession(workspaceProjectId: projectId) } ) - .disabled(state.isSwitchingWorkspace || state.isSwitchingSession) + .disabled(state.isSwitchingWorkspace || state.isSwitchingSession || state.isCreatingSession) } } .padding(.horizontal, 12)