Skip to content
Open
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
Expand Up @@ -12,7 +12,6 @@ import androidx.core.view.doOnAttach
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
Expand All @@ -24,17 +23,21 @@ import com.itsaky.androidide.plugins.aicore.BuildConfig
import com.itsaky.androidide.plugins.aicore.R
import com.itsaky.androidide.plugins.aicore.adapters.ChatAdapter
import com.itsaky.androidide.plugins.aicore.databinding.FragmentChatBinding
import com.itsaky.androidide.plugins.aicore.logging.AgentTrace
import com.itsaky.androidide.plugins.aicore.logging.LOG_PREFIX
import com.itsaky.androidide.plugins.aicore.models.AgentState
import com.itsaky.androidide.plugins.aicore.models.isRunning
import com.itsaky.androidide.plugins.aicore.models.traceLabel
import com.itsaky.androidide.plugins.aicore.plugin.AiCorePlugin
import com.itsaky.androidide.plugins.aicore.viewmodel.ChatViewModel
import com.itsaky.androidide.plugins.aicore.viewmodel.ChatViewModelStore
import com.itsaky.androidide.plugins.base.PluginFragmentHelper
import com.itsaky.androidide.plugins.services.IdeProjectService
import com.itsaky.androidide.plugins.services.IdeTooltipService
import com.itsaky.androidide.plugins.services.IdeUIService
import io.noties.markwon.Markwon
import java.io.File
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.launch

private const val TAG = "$LOG_PREFIX.ChatFragment"
Expand Down Expand Up @@ -63,6 +66,9 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
/** The message list's layout-declared padding, before any cutout inset is added. */
private val basePadding = Rect()

/** Message count last logged, so streaming re-emissions do not each get a line. */
private var renderedMessageCount = -1

private val tooltipService: IdeTooltipService? by lazy {
try {
PluginFragmentHelper.getServiceRegistry(AiCorePlugin.PLUGIN_ID)
Expand Down Expand Up @@ -106,17 +112,43 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
return binding.root
}

/**
* Tears down the view only. It deliberately does **not** stop the agent: the host removes this
* fragment whenever the user switches bottom-sheet tabs, and cancelling here killed the run in
* flight — a `run_app` wait can be ten minutes long — so the build finished with nobody left to
* report it. Only Stop, Clear Chat and plugin dispose cancel a run.
*/
override fun onDestroyView() {
if (::chatAdapter.isInitialized) {
chatAdapter.stopAllAnimations()
}
super.onDestroyView()
viewModel.stopProcessing()
// runInFlight=true here, followed by that run still reporting, is the tab-switch fix
// working; the run being gone from the trace after it is the bug coming back. Guarded
// because a log line must never be the thing that takes the IDE down.
if (::viewModel.isInitialized) {
val state = viewModel.agentState.value
AgentTrace.stage(
"UI",
"chat view destroyed runInFlight=${state.isRunning} state=${state.traceLabel}"
)
}
composer?.detach()
composer = null
_binding = null
}

/**
* Writes the transcript out on the way off screen. The ViewModel now outlives this fragment, so
* its onCleared() no longer fires on a tab switch and is no longer the only writer.
*/
override fun onStop() {
super.onStop()
if (::viewModel.isInitialized) {
viewModel.persistState()
}
}

override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
// The process can be killed while backgrounded even though rotation never recreates us.
Expand All @@ -129,17 +161,28 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
initializeMarkwon()
initializeViewModel()
if (!viewModel.isStorageInitialized()) {
viewModel.initializeStorage(requireContext())
// Application context: the ViewModel outlives this fragment, and the activity.
viewModel.initializeStorage(requireContext().applicationContext)
}
setupToolbar()
setupRecyclerView()
setupInputArea()
restoreContextChips()
setupCutoutPadding()
setupComposer(savedInstanceState)
setupStatusBar()
setupBackendIndicator()
observeViewModel()

AgentTrace.stage(
"UI",
"chat view created runInFlight=${viewModel.agentState.value.isRunning} " +
"state=${viewModel.agentState.value.traceLabel} " +
"messages=${viewModel.messages.value.size} " +
"contextFiles=${contextFiles.size} " +
"pendingApproval=${viewModel.pendingApprovalRequest.value?.toolName}"
)

// Check for test prompt from broadcast receiver (E2E testing)
injectPendingTestPrompt()
}
Expand Down Expand Up @@ -199,12 +242,13 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
viewModel.refreshBackendLabel()
}

/**
* Resolves the plugin-scoped ViewModel rather than a fragment-scoped one, so an agent run
* survives this fragment being removed and re-attaches to the tab that comes back. See
* [ChatViewModelStore].
*/
private fun initializeViewModel() {
// A context getter, not the service, so the ViewModel resolves it lazily.
viewModel = ViewModelProvider(
this,
ChatViewModelFactory { getPluginContext() }
)[ChatViewModel::class.java]
viewModel = ChatViewModelStore.get()
}

private fun getPluginContext(): PluginContext? {
Expand Down Expand Up @@ -252,7 +296,7 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
private fun setupInputArea() {
binding.sendButton.setOnClickListener {
if (viewModel.agentState.value.isRunning) {
viewModel.stopProcessing()
viewModel.stopProcessing(reason = "stop button")
} else {
val message = binding.promptInputEdittext.text?.toString() ?: return@setOnClickListener
if (message.isNotBlank()) {
Expand Down Expand Up @@ -363,20 +407,23 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
}

private suspend fun observeMessages() {
android.util.Log.d(TAG, "observeMessages: Starting to collect messages")
viewModel.messages.collect { messages ->
val binding = _binding ?: return@collect
android.util.Log.d(TAG, "observeMessages: Received ${messages.size} messages")
messages.forEachIndexed { index, msg ->
android.util.Log.d(TAG, " Message $index: sender=${msg.sender}, text=${msg.text.take(50)}")
// One line, and only when the count moves: this collector re-emits on every streamed
// token, and a per-message dump here made the log unreadable during a run.
if (messages.size != renderedMessageCount) {
renderedMessageCount = messages.size
AgentTrace.detail(
"UI",
"rendering messages=$renderedMessageCount " +
"last=${messages.lastOrNull()?.sender}"
)
}
binding.emptyChatView.isVisible = messages.isEmpty()
android.util.Log.d(TAG, "observeMessages: Calling submitList with ${messages.size} messages")
// Sampled before the list changes: streaming re-emits on every token, so scrolling
// unconditionally would drag the user back down whenever they scrolled up to read.
val stickToBottom = binding.chatRecyclerView.isAtBottom()
chatAdapter.submitList(messages) {
android.util.Log.d(TAG, "observeMessages: submitList callback - scrolling to ${messages.size - 1}")
if (stickToBottom && messages.isNotEmpty()) {
// Null after onDestroyView: submitList posts this callback.
_binding?.chatRecyclerView?.scrollToPosition(messages.lastIndex)
Expand All @@ -395,8 +442,17 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
is AgentState.Idle -> binding.agentStatusContainer.isVisible = false
is AgentState.Executing -> {
binding.agentStatusContainer.isVisible = true
binding.agentStatusMessage.text = state.formattedProgress
binding.agentStatusTimer.text = state.formattedTiming
binding.agentStatusMessage.text = getString(
R.string.state_executing,
state.stepNumber,
state.totalSteps,
state.description,
)
binding.agentStatusTimer.text = getString(
R.string.state_executing_timing,
formatDuration(state.elapsedMillis),
formatDuration(state.estimatedTotalMillis),
)
viewModel.startStateTimer(state)
}
is AgentState.Processing -> {
Expand All @@ -408,6 +464,9 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
binding.agentStatusContainer.isVisible = false
viewModel.stopStateTimer()
showErrorSnackbar(state.message)
// One-shot: an error raised while this tab was gone must not re-raise on every
// later re-attach, now that the state outlives the fragment.
viewModel.clearErrorState()
}
else -> Unit
}
Expand All @@ -416,6 +475,23 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
}
}

/**
* Renders a duration the way the status timer reads it: `4.5s`, or `1m 4.5s` past the minute.
*
* @param millis the duration; a clock that ran backwards reads as zero.
* @return the localised figure, with no surrounding words.
*/
private fun formatDuration(millis: Long): String {
val total = millis.coerceAtLeast(0)
val minutes = TimeUnit.MILLISECONDS.toMinutes(total)
val seconds = (total - TimeUnit.MINUTES.toMillis(minutes)) / 1000.0
return if (minutes > 0) {
getString(R.string.time_format_minutes, minutes, seconds)
} else {
getString(R.string.time_format_seconds, seconds)
}
}

/**
* The approval dialog currently on screen, looked up by tag rather than held in a field:
* after a configuration change this fragment is a new instance, and a field would be null
Expand All @@ -438,13 +514,15 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
private fun showApprovalDialog(request: com.itsaky.androidide.plugins.aicore.tool.ApprovalRequest) {
// childFragmentManager makes this fragment the parent, which is how Host is resolved.
if (currentApprovalDialog() != null) return
AgentTrace.detail("UI", "approval dialog shown tool=${request.toolName}")
ApprovalDialogFragment.newInstance(request).show(childFragmentManager, APPROVAL_DIALOG_TAG)
}

override fun onApprovalDecision(
result: com.itsaky.androidide.plugins.aicore.tool.ApprovalResult,
correction: String?,
) {
AgentTrace.detail("UI", "approval decided choice=$result corrected=${correction != null}")
viewModel.submitApproval(result, correction)
}

Expand All @@ -468,6 +546,17 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
dialog.show(parentFragmentManager, "file_picker")
}

/**
* Rebuilds the attached-file chips from the ViewModel. It keeps the attachments across a tab
* switch, so without this they would still go into the next prompt with nothing on screen
* naming them.
*/
private fun restoreContextChips() {
contextFiles.clear()
contextFiles.addAll(viewModel.contextFiles)
contextFiles.forEach(::addChipForFile)
}

private fun addContextFiles(files: List<File>) {
files.forEach { file ->
if (!contextFiles.contains(file)) {
Expand Down Expand Up @@ -559,18 +648,3 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
}

}

/**
* Factory for creating ChatViewModel with PluginContext dependency.
*/
class ChatViewModelFactory(
private val getContext: () -> PluginContext?
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(ChatViewModel::class.java)) {
return ChatViewModel(getContext) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,15 @@ object AgentTrace {
fun endRun(outcome: String, turns: Int? = null) {
stage("DONE", "outcome=$outcome" + (turns?.let { " turns=$it" } ?: ""))
runId = "-"
// Reset too, or the UI lines that follow a run report an elapsed time measured from a run
// that is already over.
runStartMs = 0L
}

/**
* Logs a milestone in the run at INFO — the lines you want when following the flow.
* @param stage short uppercase phase name (PROMPT, LLM, TOOL, APPROVAL, EXEC, …).
* @param stage short uppercase phase name (PROMPT, LLM, PARSE, APPROVAL, EXEC, RESULT, BUILD,
* STATE, UI, CANCEL, DONE).
* @param detail structured `key=value` facts.
* @param preview optional free text, already previewed by the caller.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package com.itsaky.androidide.plugins.aicore.logging
/**
* Prefix on every logcat tag this plugin writes, so a line names the plugin that emitted it — every
* AI feature shares the host IDE's process, where a bare `ToolRouter` tag names no `.cgp`. Tags read
* `"$LOG_PREFIX.ClassName"`, so `adb logcat -s AiCore.*` is this plugin's whole log.
* `"$LOG_PREFIX.ClassName"`. `logcat -s` matches a tag exactly, so the whole plugin log is
* `adb logcat | grep AiCore.`; one class is `adb logcat -s AiCore.ChatFragment:V`, and the whole
* agent run on one stream is `adb logcat -s AiCore.AgentTrace:V`.
*/
internal const val LOG_PREFIX = "AiCore"
Original file line number Diff line number Diff line change
Expand Up @@ -29,36 +29,20 @@ sealed class AgentState {
val startTime: Long = System.currentTimeMillis(),
val elapsedMillis: Long = 0
) : AgentState() {
val formattedProgress: String
get() = "Step ${currentStepIndex + 1} of $totalSteps: $description"
/** Step number as the status line counts it, from 1. */
val stepNumber: Int
get() = currentStepIndex + 1

val formattedTiming: String
/**
* Projected duration of the whole run, from the average time per step so far. The words
* around these figures are resources, so the rendering itself belongs to the UI layer.
*/
val estimatedTotalMillis: Long
get() {
val elapsed = formatTime(elapsedMillis)
// Estimate total time based on average time per step
val estimatedTotal = if (currentStepIndex > 0) {
val avgPerStep = elapsedMillis / (currentStepIndex + 1)
avgPerStep * totalSteps
} else {
elapsedMillis * totalSteps
}
val total = formatTime(estimatedTotal)
return "($elapsed of $total)"
// A device clock change can put the elapsed time behind the start.
val elapsed = elapsedMillis.coerceAtLeast(0)
return (elapsed / stepNumber) * totalSteps
}

private fun formatTime(millis: Long): String {
if (millis < 0) return "0.0s"
val minutes = java.util.concurrent.TimeUnit.MILLISECONDS.toMinutes(millis)
val seconds = java.util.concurrent.TimeUnit.MILLISECONDS.toSeconds(millis) -
java.util.concurrent.TimeUnit.MINUTES.toSeconds(minutes)
val remainingMillis = millis % 1000
val totalSeconds = seconds + (remainingMillis / 1000.0)
return if (minutes > 0) {
String.format(java.util.Locale.US, "%dm %.1fs", minutes, totalSeconds)
} else {
String.format(java.util.Locale.US, "%.1fs", totalSeconds)
}
}
}

/**
Expand All @@ -77,6 +61,22 @@ sealed class AgentState {
data class Error(val message: String) : AgentState()
}

/**
* Short name for the trace log: the state and what it is doing, without a data class's field dump
* and without the error text, which is already in the transcript. Logcat only — nothing here
* reaches the UI, which renders each state from string resources.
*/
val AgentState.traceLabel: String
get() = when (this) {
is AgentState.Idle -> "Idle"
is AgentState.Initializing -> "Initializing"
is AgentState.Thinking -> "Thinking"
is AgentState.Executing -> "Executing(step ${currentStepIndex + 1}/$totalSteps $description)"
is AgentState.Processing -> "Processing"
is AgentState.Cancelling -> "Cancelling"
is AgentState.Error -> "Error"
}

Comment thread
jatezzz marked this conversation as resolved.
/**
* True while a run is in flight, which is what the composer keys its Stop control off. One
* definition so the UI cannot drift from it a state at a time.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.itsaky.androidide.plugins.aicore.services.LlmInferenceServiceImpl
import com.itsaky.androidide.plugins.aicore.services.ToolSourceRegistryImpl
import com.itsaky.androidide.plugins.aicore.tool.handlers.PathGuard
import com.itsaky.androidide.plugins.aicore.tool.sources.ToolSourceStore
import com.itsaky.androidide.plugins.aicore.viewmodel.ChatViewModelStore
import com.itsaky.androidide.plugins.extensions.DocumentationExtension
import com.itsaky.androidide.plugins.extensions.MenuItem
import com.itsaky.androidide.plugins.extensions.PluginSettingsEntry
Expand Down Expand Up @@ -162,6 +163,10 @@ class AiCorePlugin : IPlugin, UIExtension, DocumentationExtension, SettingsExten
// dispose(); this only stops whatever this plugin still has in flight.
llmService?.cancelGeneration()

// The chat ViewModel is plugin-scoped, not fragment-scoped, so this is what ends a run
// that outlived its fragment; clearing it persists the transcript first.
ChatViewModelStore.clear()

PathGuard.setProjectRootProvider(null)
pluginContext = null
llmService = null
Expand Down
Loading
Loading