Skip to content

Commit 5bac6ee

Browse files
authored
ADFA-6247 | Export and import chats as .txt (#110)
* feat(ai-core): export and import chats as .txt (ADFA-6247) Chat row menu: Rename, Export, Delete; Export saves via the system file picker and is dimmed for an empty chat. The + menu adds Import chat, which opens an exported file as a new chat. Transcript I/O runs in ChatViewModel off the main thread; menus are a shared Material 3 popup that TalkBack can read. * fix(ai-core): keep historyText and trailing newlines in chat transcripts (ADFA-6247) Export the model's own words under a `--- MODEL WROTE` marker when they differ from the bubble, so an imported chat doesn't hand the model its rendered text as its earlier turn. On import, drop only the one blank separator line instead of every trailing newline. * fix(ai-core): check the transcript header before buffering an import (ADFA-6247) * fix(ai-core): warn when an export is too large to import back (ADFA-6247)
1 parent a31bf62 commit 5bac6ee

21 files changed

Lines changed: 1257 additions & 81 deletions

File tree

‎plugins/AI-Core/src/main/assets/docs/index.html‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -249,8 +249,9 @@ <h2>Keeping several chats</h2>
249249
the conversation. Its three parts are fixed in place except the middle one, which
250250
is the only part that scrolls:</p>
251251
<ul>
252-
<li><b>New chat</b>, at the top, starts a fresh conversation. The one you were
253-
in is kept, and you can go back to it whenever you like.</li>
252+
<li>The <b>+</b> button, at the top, opens a menu. <b>New chat</b> starts a
253+
fresh conversation — the one you were in is kept, and you can go back to it
254+
whenever you like — and <b>Import chat</b> opens a chat exported earlier.</li>
254255
<li><b>Recent</b>, in the middle, lists this project's conversations, newest
255256
first, each named with a short title and dated from when it began, with
256257
a tick on the one you are in. It holds the newest to begin with and loads
@@ -266,13 +267,27 @@ <h2>Keeping several chats</h2>
266267
back.</li>
267268
<li><b>Settings</b>, beside it, opens the Agent settings screen.</li>
268269
</ul>
269-
<p><b>New chat</b> sits at the right of the panel's top row; the button at the
270-
left of that row closes the sidebar, as does tapping the dimmed conversation
270+
<p>The <b>+</b> button at the right of the panel's top row opens a menu with
271+
<b>New chat</b> and <b>Import chat</b>; the button at the left of that row closes
272+
the sidebar, as does tapping the dimmed conversation
271273
beside it or pressing <b>Back</b>.</p>
272-
<p>The <b>⋮</b> button on a row in <b>Recent</b> renames or deletes that chat. A
273-
name you give it replaces the automatic title; clearing the name gives the
274+
<p>The <b>⋮</b> button on a row in <b>Recent</b> renames, exports or deletes that
275+
chat. A name you give it replaces the automatic title; clearing the name gives the
274276
automatic title back. Deleting cannot be undone — and deleting the last chat
275277
leaves you an empty one to carry on in, rather than nothing.</p>
278+
<p><b>Export</b> saves the chat as a plain <code>.txt</code> file wherever you
279+
choose in the system file picker, named after the chat. The file lists every
280+
message in order, each under a line saying who sent it — <code>USER</code>,
281+
<code>AGENT</code>, or <code>SYSTEM</code> and <code>TOOL</code> for the notices
282+
in between — so anyone can read it without extra tools. Use it to back a chat up,
283+
send it to a teammate or to support, or carry it to another device. Export is
284+
dimmed for a chat with no messages yet; tapping it says so.</p>
285+
<p><b>Import chat</b>, in the <b>+</b> menu at the top of the sidebar, reads such
286+
a file back and opens it as a new chat at the top of
287+
<b>Recent</b>, with the same messages in the same order. It never changes a chat
288+
you already have. A file that was not exported from the Agent, or that was edited
289+
so it no longer reads as one, is refused with a message and nothing is added.
290+
Cancelling either picker does nothing.</p>
276291
<p>To clear out several at once, <b>long-press</b> a row. Every row gets a
277292
checkbox, tapping a row ticks it instead of opening it, and the bin at the top of
278293
the sidebar removes everything ticked in one step — with the same warning, since

‎plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatFragment.kt‎

Lines changed: 135 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
package com.itsaky.androidide.plugins.aicore.fragments
22

3+
import android.content.ActivityNotFoundException
34
import android.content.ClipData
45
import android.content.ClipboardManager
56
import android.content.Context
67
import android.content.res.Configuration
78
import android.graphics.Rect
9+
import android.net.Uri
810
import android.os.Bundle
911
import android.view.LayoutInflater
1012
import android.view.View
1113
import android.view.ViewGroup
1214
import androidx.activity.OnBackPressedCallback
15+
import androidx.activity.result.contract.ActivityResultContracts
1316
import androidx.appcompat.view.ContextThemeWrapper
1417
import androidx.core.view.ViewCompat
1518
import androidx.core.view.WindowInsetsCompat
@@ -32,6 +35,8 @@ import com.itsaky.androidide.plugins.aicore.logging.AgentTrace
3235
import com.itsaky.androidide.plugins.aicore.logging.LOG_PREFIX
3336
import com.itsaky.androidide.plugins.aicore.managers.ProjectKey
3437
import com.itsaky.androidide.plugins.aicore.models.AgentState
38+
import com.itsaky.androidide.plugins.aicore.models.ChatTranscript
39+
import com.itsaky.androidide.plugins.aicore.models.SessionRow
3540
import com.itsaky.androidide.plugins.aicore.models.isRunning
3641
import com.itsaky.androidide.plugins.aicore.models.traceLabel
3742
import com.itsaky.androidide.plugins.aicore.plugin.AiCorePlugin
@@ -69,6 +74,9 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
6974
private companion object {
7075
/** Tag the approval dialog is shown under, so it can be found again after recreation. */
7176
const val APPROVAL_DIALOG_TAG = "approval_dialog"
77+
78+
/** Saved-state key for [pendingExportSessionId], which must outlive the picker's round trip. */
79+
const val STATE_PENDING_EXPORT = "pending_export_session_id"
7280
}
7381

7482
private var _binding: FragmentChatBinding? = null
@@ -93,6 +101,31 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
93101
}
94102
}
95103

104+
/**
105+
* The chat Export was picked for, held while the system file picker is up: the picker answers
106+
* with a destination only, and the chat may have grown by the time it does.
107+
*/
108+
private var pendingExportSessionId: String? = null
109+
110+
/**
111+
* The system picker for where an exported chat goes. Registered as a field, since a launcher
112+
* must exist before the fragment is started; a null Uri is the user cancelling.
113+
*/
114+
private val exportLauncher = registerForActivityResult(
115+
ActivityResultContracts.CreateDocument(ChatTranscript.MIME_TYPE)
116+
) { uri ->
117+
val sessionId = pendingExportSessionId
118+
pendingExportSessionId = null
119+
if (uri != null && sessionId != null) writeExport(sessionId, uri)
120+
}
121+
122+
/** The system picker for a transcript to import; a null Uri is the user cancelling. */
123+
private val importLauncher = registerForActivityResult(
124+
ActivityResultContracts.OpenDocument()
125+
) { uri ->
126+
if (uri != null) readImport(uri)
127+
}
128+
96129
/** The message list's layout-declared padding, before any cutout inset is added. */
97130
private val basePadding = Rect()
98131

@@ -112,11 +145,21 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
112145

113146
/** Shows this plugin's tooltip for [tag] when [view] is long-pressed (Tier 1/2 + guide). */
114147
private fun wireTooltip(view: View, tag: String) {
115-
view.setOnLongClickListener { anchor ->
116-
val service = tooltipService ?: return@setOnLongClickListener false
117-
service.showTooltip(anchor, AiCorePlugin.TOOLTIP_CATEGORY, tag)
118-
true
119-
}
148+
view.setOnLongClickListener { anchor -> showTooltip(anchor, tag) }
149+
}
150+
151+
/**
152+
* Shows this plugin's tooltip for [tag] on [anchor] now.
153+
*
154+
* [anchor] must sit in an Activity or Dialog window: the host's tooltip is a PopupWindow, and
155+
* one anchored inside another PopupWindow throws BadTokenException and takes the IDE down.
156+
*
157+
* @return false when the tooltip service is unavailable, so the long press is not consumed.
158+
*/
159+
private fun showTooltip(anchor: View, tag: String): Boolean {
160+
val service = tooltipService ?: return false
161+
service.showTooltip(anchor, AiCorePlugin.TOOLTIP_CATEGORY, tag)
162+
return true
120163
}
121164

122165
/**
@@ -185,6 +228,13 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
185228
super.onSaveInstanceState(outState)
186229
// The process can be killed while backgrounded even though rotation never recreates us.
187230
composer?.saveState(outState)
231+
// The same holds while the export picker is up, which is exactly when the IDE is backgrounded.
232+
outState.putString(STATE_PENDING_EXPORT, pendingExportSessionId)
233+
}
234+
235+
override fun onCreate(savedInstanceState: Bundle?) {
236+
super.onCreate(savedInstanceState)
237+
pendingExportSessionId = savedInstanceState?.getString(STATE_PENDING_EXPORT)
188238
}
189239

190240
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -385,8 +435,12 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
385435
viewModel = viewModel,
386436
scope = viewLifecycleOwner.lifecycleScope,
387437
wireTooltip = ::wireTooltip,
438+
showTooltip = ::showTooltip,
439+
showMessage = ::showInfoSnackbar,
388440
dialogContext = { if (isAdded) themedDialogContext() else null },
389441
onOpenSettings = ::openSettingsFragment,
442+
onExportChat = ::launchExport,
443+
onImportChat = ::launchImport,
390444
onOpenChanged = { open ->
391445
sidebarBackCallback.isEnabled = open
392446
// A panel over the chat with the keyboard still up leaves the list two rows tall.
@@ -412,6 +466,82 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
412466
private fun themedDialogContext(): Context =
413467
ContextThemeWrapper(requireContext(), R.style.PluginTheme)
414468

469+
/**
470+
* Asks the system file picker where to save [row]'s chat, suggesting a name from its title.
471+
*
472+
* @param row the conversation to export.
473+
*/
474+
private fun launchExport(row: SessionRow) {
475+
pendingExportSessionId = row.id
476+
try {
477+
exportLauncher.launch(ChatTranscript.fileName(row.title))
478+
} catch (e: ActivityNotFoundException) {
479+
pendingExportSessionId = null
480+
AiCorePlugin.getContext()?.logger?.warn("ChatFragment: no picker to export with", e)
481+
showInfoSnackbar(getString(R.string.session_picker_unavailable))
482+
}
483+
}
484+
485+
/** Asks the system file picker for a transcript to read back as a new chat. */
486+
private fun launchImport() {
487+
try {
488+
// text/* rather than text/plain alone: some providers label a .txt file text/x-log.
489+
importLauncher.launch(arrayOf("text/*"))
490+
} catch (e: ActivityNotFoundException) {
491+
AiCorePlugin.getContext()?.logger?.warn("ChatFragment: no picker to import with", e)
492+
showInfoSnackbar(getString(R.string.session_picker_unavailable))
493+
}
494+
}
495+
496+
/**
497+
* Writes a chat to the file the picker returned and says how that went.
498+
*
499+
* On the fragment's own scope, not the view's: a tab switch mid-write must not abandon it.
500+
*
501+
* @param sessionId the chat to export.
502+
* @param uri where the picker said to write it.
503+
*/
504+
private fun writeExport(sessionId: String, uri: Uri) {
505+
val resolver = requireContext().contentResolver
506+
lifecycleScope.launch {
507+
val message = when (viewModel.exportSession(sessionId, resolver, uri)) {
508+
ChatViewModel.ExportResult.EXPORTED -> getString(R.string.session_exported)
509+
ChatViewModel.ExportResult.TOO_LARGE_TO_IMPORT ->
510+
getString(R.string.session_exported_too_large, importLimitMegabytes())
511+
ChatViewModel.ExportResult.MISSING -> getString(R.string.session_export_missing)
512+
ChatViewModel.ExportResult.FAILED -> getString(R.string.session_export_failed)
513+
}
514+
showInfoSnackbar(message)
515+
}
516+
}
517+
518+
/**
519+
* Imports the file the picker returned as a new chat, opening it, or reports that it could not.
520+
*
521+
* @param uri the file the user picked.
522+
*/
523+
private fun readImport(uri: Uri) {
524+
val resolver = requireContext().contentResolver
525+
lifecycleScope.launch {
526+
when (viewModel.importTranscript(resolver, uri)) {
527+
ChatViewModel.ImportResult.IMPORTED -> Unit
528+
ChatViewModel.ImportResult.TOO_LARGE -> {
529+
showInfoSnackbar(getString(R.string.session_import_too_large, importLimitMegabytes()))
530+
return@launch
531+
}
532+
ChatViewModel.ImportResult.FAILED -> {
533+
showInfoSnackbar(getString(R.string.session_import_failed))
534+
return@launch
535+
}
536+
}
537+
sidebar?.close()
538+
showInfoSnackbar(getString(R.string.session_imported))
539+
}
540+
}
541+
542+
/** [ChatTranscript.MAX_IMPORT_BYTES] as the user is told it. */
543+
private fun importLimitMegabytes(): Int = (ChatTranscript.MAX_IMPORT_BYTES / (1024 * 1024)).toInt()
544+
415545
private fun setupInputArea() {
416546
binding.sendButton.setOnClickListener {
417547
if (viewModel.agentState.value.isRunning) {

0 commit comments

Comments
 (0)