diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/LlmSuggestionCoordinator.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/LlmSuggestionCoordinator.kt new file mode 100644 index 00000000..990707c9 --- /dev/null +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/LlmSuggestionCoordinator.kt @@ -0,0 +1,138 @@ +// Copyright (c) 2026 Tyler Stapler +// SPDX-License-Identifier: Elastic-2.0 +// https://www.elastic.co/licensing/elastic-license + +package dev.stapler.stelekit.ui + +import dev.stapler.stelekit.llm.LlmSuggestionInbox +import dev.stapler.stelekit.llm.LlmSuggestionWriter +import dev.stapler.stelekit.llm.PendingLlmSuggestion +import dev.stapler.stelekit.logging.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Owns the LLM approval-gated edit workflow (Epic 7): the pending-suggestion review screen's + * visibility and the AI-provider settings surface. Almost a pure facade — the actual suggestion + * storage and write logic already live in the injected [llmSuggestionInbox]/[llmSuggestionWriter] + * collaborators; this class only orchestrates between them and [AppState]. + * + * Extracted from [StelekitViewModel] (Phase 2 of the decomposition plan in + * `project_plans/stelekit-viewmodel-decomposition/plan.md`), following the same + * parameter-object-driven collaborator pattern [SectionManagementCoordinator] established in + * Phase 1. + * + * Shares the ViewModel's [AppState] directly via [uiState] rather than owning a separate + * `MutableStateFlow` of its own (contrast [LlmSuggestionInbox] or + * [dev.stapler.stelekit.ui.annotate.DepthEstimationCoordinator], which introduce new state not + * previously part of `AppState`): the two fields this coordinator owns + * (`llmSuggestionReviewVisible`/`llmProviderSettingsVisible`) are pre-existing `AppState` fields + * read directly by Compose call sites across the app (`GraphDialogLayer`) via + * `uiState.value.xxx`. Splitting them into a second `StateFlow` would require reworking every one + * of those read sites to combine two flows — out of scope for a mechanical, behavior-preserving + * extraction — so this coordinator mutates the same backing `MutableStateFlow` the + * ViewModel exposes as `uiState`, exactly as the original methods did before the move. + * + * Reuses the ViewModel's own [scope] rather than creating a new one: that scope already carries + * the ViewModel's `CoroutineExceptionHandler` guard and is cancelled in [StelekitViewModel.close], + * so this coordinator's launched work is cancelled for free at the same point. Safe because the + * coordinator is held as a `private val` field with the same lifetime as the ViewModel itself — + * never a `rememberCoroutineScope()`-derived scope. + */ +class LlmSuggestionCoordinator( + private val llmSuggestionInbox: LlmSuggestionInbox, + private val llmSuggestionWriter: LlmSuggestionWriter, + private val scope: CoroutineScope, + private val uiState: MutableStateFlow, + private val activeGraphIdProvider: () -> String?, + private val sendSnackbar: (String) -> Unit, +) { + private val logger = Logger("LlmSuggestionCoordinator") + + /** Live pending-suggestion map — exposed for the review screen. */ + val llmSuggestions: StateFlow> = llmSuggestionInbox.pending + + /** + * Observes [llmSuggestionInbox], flipping [AppState.llmSuggestionReviewVisible] to `true` + * when the currently active graph gains at least one pending suggestion. Structurally + * parallel to `StelekitViewModel.observeSyncState`'s `syncState.collect` — does NOT + * auto-dismiss when the inbox becomes empty via accept/reject (those explicitly set + * visibility, same "do NOT auto-dismiss" rule as journal-merge review). + */ + internal fun observeLlmSuggestions() { + scope.launch { + llmSuggestionInbox.pending.collect { pending -> + val currentGraphId = activeGraphIdProvider() ?: uiState.value.currentGraphId + val hasPendingForCurrentGraph = currentGraphId != null && + pending.values.any { it.graphId == currentGraphId } + if (hasPendingForCurrentGraph) { + uiState.update { it.copy(llmSuggestionReviewVisible = true) } + } + } + } + } + + /** Routes a suggestion from TagSuggestionViewModel's scan into the inbox. */ + fun proposeLlmSuggestion(suggestion: PendingLlmSuggestion) { + llmSuggestionInbox.propose(suggestion) + } + + /** Dismisses the LLM suggestion review screen without accepting or rejecting anything. */ + fun dismissLlmSuggestionReview() { + uiState.update { it.copy(llmSuggestionReviewVisible = false) } + } + + /** + * Rejects a pending LLM suggestion. Pure in-memory removal, cannot fail — no confirmation + * dialog required at the call site (features research §3's "reject should be a single tap, + * no are-you-sure" recommendation). + */ + fun rejectLlmSuggestion(id: String) { + llmSuggestionInbox.remove(id) + } + + /** + * Accepts a pending LLM suggestion: re-validates it is still present and still scoped to + * the currently active graph, optimistically removes it from the inbox, then materializes + * and writes it via [llmSuggestionWriter] (Story 7.4's staleness re-check + `GraphWriterPort` + * call). Errors are surfaced via [sendSnackbar] — never silently swallowed. + */ + fun acceptLlmSuggestion(id: String) { + // Re-validate: already resolved/expired — matches abortJournalMerge's "state may have + // advanced" guard shape. + val suggestion = llmSuggestionInbox.pending.value[id] ?: return + + val currentGraphId = activeGraphIdProvider() ?: uiState.value.currentGraphId + if (suggestion.graphId != currentGraphId) { + // Do not apply, and do not remove from the inbox — it's still there if the user + // switches back to the graph this suggestion targets. + sendSnackbar("Switch back to the graph this suggestion targets to review it") + return + } + + val graphPath = uiState.value.currentGraphPath ?: return + + llmSuggestionInbox.remove(id) + + scope.launch { + val result = llmSuggestionWriter.materializeAndWrite(suggestion, graphPath) + result.onLeft { error -> + logger.error("acceptLlmSuggestion failed for id=$id: ${error.message}") + sendSnackbar(error.message) + } + } + } + + /** Opens the LLM provider settings surface ("Settings → AI Providers"). */ + fun openLlmProviderSettings() { + uiState.update { it.copy(llmProviderSettingsVisible = true) } + } + + /** Dismisses the LLM provider settings surface. */ + fun dismissLlmProviderSettings() { + uiState.update { it.copy(llmProviderSettingsVisible = false) } + } +} diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/StelekitViewModel.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/StelekitViewModel.kt index ae703540..8c687112 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/StelekitViewModel.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/StelekitViewModel.kt @@ -273,16 +273,6 @@ class StelekitViewModel( /** Opens the git setup wizard pre-navigated to Step 3 (credentials). */ fun openGitSetupForCredentials() = gitSyncCoordinator.openGitSetupForCredentials() - /** Opens the LLM provider settings surface ("Settings → AI Providers"). */ - fun openLlmProviderSettings() { - _uiState.update { it.copy(llmProviderSettingsVisible = true) } - } - - /** Dismisses the LLM provider settings surface. */ - fun dismissLlmProviderSettings() { - _uiState.update { it.copy(llmProviderSettingsVisible = false) } - } - /** Opens the git setup wizard in clone-from-URL mode (pre-selects clone, starts at step 2). */ fun openGitSetupForClone() = gitSyncCoordinator.openGitSetupForClone() @@ -316,83 +306,6 @@ class StelekitViewModel( /** Dismisses the "not synced to disk" browser-only-storage banner for the given graph. */ fun dismissBrowserOnlySyncBanner(graphId: String) = gitSyncCoordinator.dismissBrowserOnlySyncBanner(graphId) - // --- LLM approval-gated edit workflow (Epic 7) --- - - /** Live pending-suggestion map — exposed for the review screen. */ - val llmSuggestions: StateFlow> = - llmSuggestionInbox.pending - - /** - * Observes [llmSuggestionInbox], flipping [AppState.llmSuggestionReviewVisible] to `true` - * when the currently active graph gains at least one pending suggestion. Structurally - * parallel to [GitSyncCoordinator.observeSyncState]'s `syncState.collect` — does NOT auto-dismiss when the - * inbox becomes empty via accept/reject (those explicitly set visibility, same "do NOT - * auto-dismiss" rule as journal-merge review). - */ - private fun observeLlmSuggestions() { - scope.launch { - llmSuggestionInbox.pending.collect { pending -> - val currentGraphId = activeGraphIdProvider() ?: _uiState.value.currentGraphId - val hasPendingForCurrentGraph = currentGraphId != null && - pending.values.any { it.graphId == currentGraphId } - if (hasPendingForCurrentGraph) { - _uiState.update { it.copy(llmSuggestionReviewVisible = true) } - } - } - } - } - - /** Routes a suggestion from TagSuggestionViewModel's scan into the inbox. */ - fun proposeLlmSuggestion(suggestion: dev.stapler.stelekit.llm.PendingLlmSuggestion) { - llmSuggestionInbox.propose(suggestion) - } - - /** Dismisses the LLM suggestion review screen without accepting or rejecting anything. */ - fun dismissLlmSuggestionReview() { - _uiState.update { it.copy(llmSuggestionReviewVisible = false) } - } - - /** - * Rejects a pending LLM suggestion. Pure in-memory removal, cannot fail — no confirmation - * dialog required at the call site (features research §3's "reject should be a single tap, - * no are-you-sure" recommendation). - */ - fun rejectLlmSuggestion(id: String) { - llmSuggestionInbox.remove(id) - } - - /** - * Accepts a pending LLM suggestion: re-validates it is still present and still scoped to - * the currently active graph, optimistically removes it from the inbox, then materializes - * and writes it via [llmSuggestionWriter] (Story 7.4's staleness re-check + [GraphWriterPort] - * call). Errors are surfaced via [sendSnackbar] — never silently swallowed. - */ - fun acceptLlmSuggestion(id: String) { - // Re-validate: already resolved/expired — matches abortJournalMerge's "state may have - // advanced" guard shape. - val suggestion = llmSuggestionInbox.pending.value[id] ?: return - - val currentGraphId = activeGraphIdProvider() ?: _uiState.value.currentGraphId - if (suggestion.graphId != currentGraphId) { - // Do not apply, and do not remove from the inbox — it's still there if the user - // switches back to the graph this suggestion targets. - sendSnackbar("Switch back to the graph this suggestion targets to review it") - return - } - - val graphPath = _uiState.value.currentGraphPath ?: return - - llmSuggestionInbox.remove(id) - - scope.launch { - val result = llmSuggestionWriter.materializeAndWrite(suggestion, graphPath) - result.onLeft { error -> - logger.error("acceptLlmSuggestion failed for id=$id: ${error.message}") - sendSnackbar(error.message) - } - } - } - // Track recent pages manually to avoid "recently loaded" issues private var recentPageUuids: MutableList = mutableListOf() @@ -482,6 +395,45 @@ class StelekitViewModel( onJournalPageCreated = { page -> navigateTo(Screen.PageView(page)) }, ) + // See LlmSuggestionCoordinator's class doc for why this shares _uiState directly rather + // than owning a separate StateFlow: the fields it owns are pre-existing AppState fields read + // by Compose call sites across the app. + private val llmSuggestionCoordinator = LlmSuggestionCoordinator( + llmSuggestionInbox = llmSuggestionInbox, + llmSuggestionWriter = llmSuggestionWriter, + scope = scope, + uiState = _uiState, + activeGraphIdProvider = activeGraphIdProvider, + sendSnackbar = { message -> sendSnackbar(message) }, + ) + + // --- LLM approval-gated edit workflow (Epic 7) --- + // Implementation lives in LlmSuggestionCoordinator (see its class doc and + // project_plans/stelekit-viewmodel-decomposition/plan.md, Phase 2). + + /** Live pending-suggestion map — exposed for the review screen. */ + val llmSuggestions: StateFlow> = + llmSuggestionCoordinator.llmSuggestions + + /** Routes a suggestion from TagSuggestionViewModel's scan into the inbox. */ + fun proposeLlmSuggestion(suggestion: dev.stapler.stelekit.llm.PendingLlmSuggestion) = + llmSuggestionCoordinator.proposeLlmSuggestion(suggestion) + + /** Dismisses the LLM suggestion review screen without accepting or rejecting anything. */ + fun dismissLlmSuggestionReview() = llmSuggestionCoordinator.dismissLlmSuggestionReview() + + /** Rejects a pending LLM suggestion. */ + fun rejectLlmSuggestion(id: String) = llmSuggestionCoordinator.rejectLlmSuggestion(id) + + /** Accepts a pending LLM suggestion. */ + fun acceptLlmSuggestion(id: String) = llmSuggestionCoordinator.acceptLlmSuggestion(id) + + /** Opens the LLM provider settings surface ("Settings → AI Providers"). */ + fun openLlmProviderSettings() = llmSuggestionCoordinator.openLlmProviderSettings() + + /** Dismisses the LLM provider settings surface. */ + fun dismissLlmProviderSettings() = llmSuggestionCoordinator.dismissLlmProviderSettings() + // See ShareExportCoordinator's class doc for why this shares _uiState directly rather than // owning a separate StateFlow, and why blockStateManager is passed as a plain dependency // rather than a provider lambda. @@ -504,7 +456,7 @@ class StelekitViewModel( updateCommands() gitSyncCoordinator.observeSyncState() - observeLlmSuggestions() + llmSuggestionCoordinator.observeLlmSuggestions() // Initialize graph if path exists val path = _uiState.value.currentGraphPath