Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<AppState>` 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<AppState>,
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<Map<String, PendingLlmSuggestion>> = 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).
Comment on lines +61 to +63
*/
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) }
}
}
128 changes: 40 additions & 88 deletions kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/StelekitViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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<Map<String, dev.stapler.stelekit.llm.PendingLlmSuggestion>> =
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<String> = mutableListOf()

Expand Down Expand Up @@ -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<Map<String, dev.stapler.stelekit.llm.PendingLlmSuggestion>> =
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.
Expand All @@ -504,7 +456,7 @@ class StelekitViewModel(

updateCommands()
gitSyncCoordinator.observeSyncState()
observeLlmSuggestions()
llmSuggestionCoordinator.observeLlmSuggestions()

// Initialize graph if path exists
val path = _uiState.value.currentGraphPath
Expand Down
Loading