Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
32 changes: 24 additions & 8 deletions plugins/AI-Core/src/main/assets/docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,9 @@ <h2>Keeping several chats</h2>
the conversation. Its three parts are fixed in place except the middle one, which
is the only part that scrolls:</p>
<ul>
<li><b>New chat</b>, at the top, starts a fresh conversation. The one you were
in is kept, and you can go back to it whenever you like.</li>
<li>The <b>+</b> button, at the top, opens a menu. <b>New chat</b> starts a
fresh conversation — the one you were in is kept, and you can go back to it
whenever you like — and <b>Import chat</b> opens a chat exported earlier.</li>
<li><b>Recent</b>, in the middle, lists this project's conversations, newest
first, each named after its first message and dated from when it began, with
a tick on the one you are in. It holds the newest to begin with and loads
Expand All @@ -246,13 +247,28 @@ <h2>Keeping several chats</h2>
back.</li>
<li><b>Settings</b>, beside it, opens the Agent settings screen.</li>
</ul>
<p><b>New chat</b> sits at the right of the panel's top row; the button at the
left of that row closes the sidebar, as does tapping the dimmed conversation
<p>The <b>+</b> button at the right of the panel's top row opens a menu with
<b>New chat</b> and <b>Import chat</b>; the button at the left of that row closes
the sidebar, as does tapping the dimmed conversation
beside it or pressing <b>Back</b>.</p>
<p>The <b>⋮</b> button on a row in <b>Recent</b> renames or deletes that chat. A
name you give it replaces the first-message title; clearing the name gives the
first-message title back. Deleting cannot be undone — and deleting the last chat
leaves you an empty one to carry on in, rather than nothing.</p>
<p>The <b>⋮</b> button on a row in <b>Recent</b> renames, exports or deletes that
chat. A name you give it replaces the first-message title;
clearing the name gives the first-message title back. Deleting cannot be undone —
and deleting the last chat leaves you an empty one to carry on in, rather than
nothing.</p>
<p><b>Export</b> saves the chat as a plain <code>.txt</code> file wherever you
choose in the system file picker, named after the chat. The file lists every
message in order, each under a line saying who sent it — <code>USER</code>,
<code>AGENT</code>, or <code>SYSTEM</code> and <code>TOOL</code> for the notices
in between — so anyone can read it without extra tools. Use it to back a chat up,
send it to a teammate or to support, or carry it to another device. Export is
dimmed for a chat with no messages yet; tapping it says so.</p>
<p><b>Import chat</b>, in the <b>+</b> menu at the top of the sidebar, reads such
a file back and opens it as a new chat at the top of
<b>Recent</b>, with the same messages in the same order. It never changes a chat
you already have. A file that was not exported from the Agent, or that was edited
so it no longer reads as one, is refused with a message and nothing is added.
Cancelling either picker does nothing.</p>
<p>To clear out several at once, <b>long-press</b> a row. Every row gets a
checkbox, tapping a row ticks it instead of opening it, and the bin at the top of
the sidebar removes everything ticked in one step — with the same warning, since
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package com.itsaky.androidide.plugins.aicore.fragments

import android.content.ActivityNotFoundException
import android.content.Context
import android.content.res.Configuration
import android.graphics.Rect
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.view.ContextThemeWrapper
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
Expand All @@ -30,6 +33,8 @@ import com.itsaky.androidide.plugins.aicore.logging.AgentTrace
import com.itsaky.androidide.plugins.aicore.logging.LOG_PREFIX
import com.itsaky.androidide.plugins.aicore.managers.ProjectKey
import com.itsaky.androidide.plugins.aicore.models.AgentState
import com.itsaky.androidide.plugins.aicore.models.ChatTranscript
import com.itsaky.androidide.plugins.aicore.models.SessionRow
import com.itsaky.androidide.plugins.aicore.models.isRunning
import com.itsaky.androidide.plugins.aicore.models.traceLabel
import com.itsaky.androidide.plugins.aicore.plugin.AiCorePlugin
Expand All @@ -55,6 +60,9 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
private companion object {
/** Tag the approval dialog is shown under, so it can be found again after recreation. */
const val APPROVAL_DIALOG_TAG = "approval_dialog"

/** Saved-state key for [pendingExportSessionId], which must outlive the picker's round trip. */
const val STATE_PENDING_EXPORT = "pending_export_session_id"
}

private var _binding: FragmentChatBinding? = null
Expand All @@ -79,6 +87,31 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
}
}

/**
* The chat Export was picked for, held while the system file picker is up: the picker answers
* with a destination only, and the chat may have grown by the time it does.
*/
private var pendingExportSessionId: String? = null

/**
* The system picker for where an exported chat goes. Registered as a field, since a launcher
* must exist before the fragment is started; a null Uri is the user cancelling.
*/
private val exportLauncher = registerForActivityResult(
ActivityResultContracts.CreateDocument(ChatTranscript.MIME_TYPE)
) { uri ->
val sessionId = pendingExportSessionId
pendingExportSessionId = null
if (uri != null && sessionId != null) writeExport(sessionId, uri)
}

/** The system picker for a transcript to import; a null Uri is the user cancelling. */
private val importLauncher = registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
if (uri != null) readImport(uri)
}

/** The message list's layout-declared padding, before any cutout inset is added. */
private val basePadding = Rect()

Expand All @@ -98,11 +131,21 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {

/** Shows this plugin's tooltip for [tag] when [view] is long-pressed (Tier 1/2 + guide). */
private fun wireTooltip(view: View, tag: String) {
view.setOnLongClickListener { anchor ->
val service = tooltipService ?: return@setOnLongClickListener false
service.showTooltip(anchor, AiCorePlugin.TOOLTIP_CATEGORY, tag)
true
}
view.setOnLongClickListener { anchor -> showTooltip(anchor, tag) }
}

/**
* Shows this plugin's tooltip for [tag] on [anchor] now.
*
* [anchor] must sit in an Activity or Dialog window: the host's tooltip is a PopupWindow, and
* one anchored inside another PopupWindow throws BadTokenException and takes the IDE down.
*
* @return false when the tooltip service is unavailable, so the long press is not consumed.
*/
private fun showTooltip(anchor: View, tag: String): Boolean {
val service = tooltipService ?: return false
service.showTooltip(anchor, AiCorePlugin.TOOLTIP_CATEGORY, tag)
return true
}

/**
Expand Down Expand Up @@ -171,6 +214,13 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
super.onSaveInstanceState(outState)
// The process can be killed while backgrounded even though rotation never recreates us.
composer?.saveState(outState)
// The same holds while the export picker is up, which is exactly when the IDE is backgrounded.
outState.putString(STATE_PENDING_EXPORT, pendingExportSessionId)
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
pendingExportSessionId = savedInstanceState?.getString(STATE_PENDING_EXPORT)
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
Expand Down Expand Up @@ -326,8 +376,12 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
viewModel = viewModel,
scope = viewLifecycleOwner.lifecycleScope,
wireTooltip = ::wireTooltip,
showTooltip = ::showTooltip,
showMessage = ::showInfoSnackbar,
dialogContext = { if (isAdded) themedDialogContext() else null },
onOpenSettings = ::openSettingsFragment,
onExportChat = ::launchExport,
onImportChat = ::launchImport,
onOpenChanged = { open ->
sidebarBackCallback.isEnabled = open
// A panel over the chat with the keyboard still up leaves the list two rows tall.
Expand All @@ -353,6 +407,70 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
private fun themedDialogContext(): Context =
ContextThemeWrapper(requireContext(), R.style.PluginTheme)

/**
* Asks the system file picker where to save [row]'s chat, suggesting a name from its title.
*
* @param row the conversation to export.
*/
private fun launchExport(row: SessionRow) {
pendingExportSessionId = row.id
try {
exportLauncher.launch(ChatTranscript.fileName(row.title))
} catch (e: ActivityNotFoundException) {
pendingExportSessionId = null
AiCorePlugin.getContext()?.logger?.warn("ChatFragment: no picker to export with", e)
showInfoSnackbar(getString(R.string.session_picker_unavailable))
}
}

/** Asks the system file picker for a transcript to read back as a new chat. */
private fun launchImport() {
try {
// text/* rather than text/plain alone: some providers label a .txt file text/x-log.
importLauncher.launch(arrayOf("text/*"))
} catch (e: ActivityNotFoundException) {
AiCorePlugin.getContext()?.logger?.warn("ChatFragment: no picker to import with", e)
showInfoSnackbar(getString(R.string.session_picker_unavailable))
}
}

/**
* Writes a chat to the file the picker returned and says how that went.
*
* On the fragment's own scope, not the view's: a tab switch mid-write must not abandon it.
*
* @param sessionId the chat to export.
* @param uri where the picker said to write it.
*/
private fun writeExport(sessionId: String, uri: Uri) {
val resolver = requireContext().contentResolver
lifecycleScope.launch {
val message = when (viewModel.exportSession(sessionId, resolver, uri)) {
ChatViewModel.ExportResult.EXPORTED -> R.string.session_exported
ChatViewModel.ExportResult.MISSING -> R.string.session_export_missing
ChatViewModel.ExportResult.FAILED -> R.string.session_export_failed
}
showInfoSnackbar(getString(message))
}
}

/**
* Imports the file the picker returned as a new chat, opening it, or reports that it could not.
*
* @param uri the file the user picked.
*/
private fun readImport(uri: Uri) {
val resolver = requireContext().contentResolver
lifecycleScope.launch {
if (!viewModel.importTranscript(resolver, uri)) {
showInfoSnackbar(getString(R.string.session_import_failed))
return@launch
}
sidebar?.close()
showInfoSnackbar(getString(R.string.session_imported))
}
}

private fun setupInputArea() {
binding.sendButton.setOnClickListener {
if (viewModel.agentState.value.isRunning) {
Expand Down
Loading
Loading