Skip to content

Commit 49d64cd

Browse files
committed
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.
1 parent 4fdef5d commit 49d64cd

21 files changed

Lines changed: 1106 additions & 83 deletions

File tree

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

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -229,8 +229,9 @@ <h2>Keeping several chats</h2>
229229
the conversation. Its three parts are fixed in place except the middle one, which
230230
is the only part that scrolls:</p>
231231
<ul>
232-
<li><b>New chat</b>, at the top, starts a fresh conversation. The one you were
233-
in is kept, and you can go back to it whenever you like.</li>
232+
<li>The <b>+</b> button, at the top, opens a menu. <b>New chat</b> starts a
233+
fresh conversation — the one you were in is kept, and you can go back to it
234+
whenever you like — and <b>Import chat</b> opens a chat exported earlier.</li>
234235
<li><b>Recent</b>, in the middle, lists this project's conversations, newest
235236
first, each named after its first message and dated from when it began, with
236237
a tick on the one you are in. It holds the newest to begin with and loads
@@ -246,13 +247,28 @@ <h2>Keeping several chats</h2>
246247
back.</li>
247248
<li><b>Settings</b>, beside it, opens the Agent settings screen.</li>
248249
</ul>
249-
<p><b>New chat</b> sits at the right of the panel's top row; the button at the
250-
left of that row closes the sidebar, as does tapping the dimmed conversation
250+
<p>The <b>+</b> button at the right of the panel's top row opens a menu with
251+
<b>New chat</b> and <b>Import chat</b>; the button at the left of that row closes
252+
the sidebar, as does tapping the dimmed conversation
251253
beside it or pressing <b>Back</b>.</p>
252-
<p>The <b>⋮</b> button on a row in <b>Recent</b> renames or deletes that chat. A
253-
name you give it replaces the first-message title; clearing the name gives the
254-
first-message title back. Deleting cannot be undone — and deleting the last chat
255-
leaves you an empty one to carry on in, rather than nothing.</p>
254+
<p>The <b>⋮</b> button on a row in <b>Recent</b> renames, exports or deletes that
255+
chat. A name you give it replaces the first-message title;
256+
clearing the name gives the first-message title back. Deleting cannot be undone —
257+
and deleting the last chat leaves you an empty one to carry on in, rather than
258+
nothing.</p>
259+
<p><b>Export</b> saves the chat as a plain <code>.txt</code> file wherever you
260+
choose in the system file picker, named after the chat. The file lists every
261+
message in order, each under a line saying who sent it — <code>USER</code>,
262+
<code>AGENT</code>, or <code>SYSTEM</code> and <code>TOOL</code> for the notices
263+
in between — so anyone can read it without extra tools. Use it to back a chat up,
264+
send it to a teammate or to support, or carry it to another device. Export is
265+
dimmed for a chat with no messages yet; tapping it says so.</p>
266+
<p><b>Import chat</b>, in the <b>+</b> menu at the top of the sidebar, reads such
267+
a file back and opens it as a new chat at the top of
268+
<b>Recent</b>, with the same messages in the same order. It never changes a chat
269+
you already have. A file that was not exported from the Agent, or that was edited
270+
so it no longer reads as one, is refused with a message and nothing is added.
271+
Cancelling either picker does nothing.</p>
256272
<p>To clear out several at once, <b>long-press</b> a row. Every row gets a
257273
checkbox, tapping a row ticks it instead of opening it, and the bin at the top of
258274
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: 123 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
package com.itsaky.androidide.plugins.aicore.fragments
22

3+
import android.content.ActivityNotFoundException
34
import android.content.Context
45
import android.content.res.Configuration
56
import android.graphics.Rect
7+
import android.net.Uri
68
import android.os.Bundle
79
import android.view.LayoutInflater
810
import android.view.View
911
import android.view.ViewGroup
1012
import androidx.activity.OnBackPressedCallback
13+
import androidx.activity.result.contract.ActivityResultContracts
1114
import androidx.appcompat.view.ContextThemeWrapper
1215
import androidx.core.view.ViewCompat
1316
import androidx.core.view.WindowInsetsCompat
@@ -30,6 +33,8 @@ import com.itsaky.androidide.plugins.aicore.logging.AgentTrace
3033
import com.itsaky.androidide.plugins.aicore.logging.LOG_PREFIX
3134
import com.itsaky.androidide.plugins.aicore.managers.ProjectKey
3235
import com.itsaky.androidide.plugins.aicore.models.AgentState
36+
import com.itsaky.androidide.plugins.aicore.models.ChatTranscript
37+
import com.itsaky.androidide.plugins.aicore.models.SessionRow
3338
import com.itsaky.androidide.plugins.aicore.models.isRunning
3439
import com.itsaky.androidide.plugins.aicore.models.traceLabel
3540
import com.itsaky.androidide.plugins.aicore.plugin.AiCorePlugin
@@ -55,6 +60,9 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
5560
private companion object {
5661
/** Tag the approval dialog is shown under, so it can be found again after recreation. */
5762
const val APPROVAL_DIALOG_TAG = "approval_dialog"
63+
64+
/** Saved-state key for [pendingExportSessionId], which must outlive the picker's round trip. */
65+
const val STATE_PENDING_EXPORT = "pending_export_session_id"
5866
}
5967

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

90+
/**
91+
* The chat Export was picked for, held while the system file picker is up: the picker answers
92+
* with a destination only, and the chat may have grown by the time it does.
93+
*/
94+
private var pendingExportSessionId: String? = null
95+
96+
/**
97+
* The system picker for where an exported chat goes. Registered as a field, since a launcher
98+
* must exist before the fragment is started; a null Uri is the user cancelling.
99+
*/
100+
private val exportLauncher = registerForActivityResult(
101+
ActivityResultContracts.CreateDocument(ChatTranscript.MIME_TYPE)
102+
) { uri ->
103+
val sessionId = pendingExportSessionId
104+
pendingExportSessionId = null
105+
if (uri != null && sessionId != null) writeExport(sessionId, uri)
106+
}
107+
108+
/** The system picker for a transcript to import; a null Uri is the user cancelling. */
109+
private val importLauncher = registerForActivityResult(
110+
ActivityResultContracts.OpenDocument()
111+
) { uri ->
112+
if (uri != null) readImport(uri)
113+
}
114+
82115
/** The message list's layout-declared padding, before any cutout inset is added. */
83116
private val basePadding = Rect()
84117

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

99132
/** Shows this plugin's tooltip for [tag] when [view] is long-pressed (Tier 1/2 + guide). */
100133
private fun wireTooltip(view: View, tag: String) {
101-
view.setOnLongClickListener { anchor ->
102-
val service = tooltipService ?: return@setOnLongClickListener false
103-
service.showTooltip(anchor, AiCorePlugin.TOOLTIP_CATEGORY, tag)
104-
true
105-
}
134+
view.setOnLongClickListener { anchor -> showTooltip(anchor, tag) }
135+
}
136+
137+
/**
138+
* Shows this plugin's tooltip for [tag] on [anchor] now.
139+
*
140+
* [anchor] must sit in an Activity or Dialog window: the host's tooltip is a PopupWindow, and
141+
* one anchored inside another PopupWindow throws BadTokenException and takes the IDE down.
142+
*
143+
* @return false when the tooltip service is unavailable, so the long press is not consumed.
144+
*/
145+
private fun showTooltip(anchor: View, tag: String): Boolean {
146+
val service = tooltipService ?: return false
147+
service.showTooltip(anchor, AiCorePlugin.TOOLTIP_CATEGORY, tag)
148+
return true
106149
}
107150

108151
/**
@@ -171,6 +214,13 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
171214
super.onSaveInstanceState(outState)
172215
// The process can be killed while backgrounded even though rotation never recreates us.
173216
composer?.saveState(outState)
217+
// The same holds while the export picker is up, which is exactly when the IDE is backgrounded.
218+
outState.putString(STATE_PENDING_EXPORT, pendingExportSessionId)
219+
}
220+
221+
override fun onCreate(savedInstanceState: Bundle?) {
222+
super.onCreate(savedInstanceState)
223+
pendingExportSessionId = savedInstanceState?.getString(STATE_PENDING_EXPORT)
174224
}
175225

176226
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -326,8 +376,12 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
326376
viewModel = viewModel,
327377
scope = viewLifecycleOwner.lifecycleScope,
328378
wireTooltip = ::wireTooltip,
379+
showTooltip = ::showTooltip,
380+
showMessage = ::showInfoSnackbar,
329381
dialogContext = { if (isAdded) themedDialogContext() else null },
330382
onOpenSettings = ::openSettingsFragment,
383+
onExportChat = ::launchExport,
384+
onImportChat = ::launchImport,
331385
onOpenChanged = { open ->
332386
sidebarBackCallback.isEnabled = open
333387
// A panel over the chat with the keyboard still up leaves the list two rows tall.
@@ -353,6 +407,70 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
353407
private fun themedDialogContext(): Context =
354408
ContextThemeWrapper(requireContext(), R.style.PluginTheme)
355409

410+
/**
411+
* Asks the system file picker where to save [row]'s chat, suggesting a name from its title.
412+
*
413+
* @param row the conversation to export.
414+
*/
415+
private fun launchExport(row: SessionRow) {
416+
pendingExportSessionId = row.id
417+
try {
418+
exportLauncher.launch(ChatTranscript.fileName(row.title))
419+
} catch (e: ActivityNotFoundException) {
420+
pendingExportSessionId = null
421+
AiCorePlugin.getContext()?.logger?.warn("ChatFragment: no picker to export with", e)
422+
showInfoSnackbar(getString(R.string.session_picker_unavailable))
423+
}
424+
}
425+
426+
/** Asks the system file picker for a transcript to read back as a new chat. */
427+
private fun launchImport() {
428+
try {
429+
// text/* rather than text/plain alone: some providers label a .txt file text/x-log.
430+
importLauncher.launch(arrayOf("text/*"))
431+
} catch (e: ActivityNotFoundException) {
432+
AiCorePlugin.getContext()?.logger?.warn("ChatFragment: no picker to import with", e)
433+
showInfoSnackbar(getString(R.string.session_picker_unavailable))
434+
}
435+
}
436+
437+
/**
438+
* Writes a chat to the file the picker returned and says how that went.
439+
*
440+
* On the fragment's own scope, not the view's: a tab switch mid-write must not abandon it.
441+
*
442+
* @param sessionId the chat to export.
443+
* @param uri where the picker said to write it.
444+
*/
445+
private fun writeExport(sessionId: String, uri: Uri) {
446+
val resolver = requireContext().contentResolver
447+
lifecycleScope.launch {
448+
val message = when (viewModel.exportSession(sessionId, resolver, uri)) {
449+
ChatViewModel.ExportResult.EXPORTED -> R.string.session_exported
450+
ChatViewModel.ExportResult.MISSING -> R.string.session_export_missing
451+
ChatViewModel.ExportResult.FAILED -> R.string.session_export_failed
452+
}
453+
showInfoSnackbar(getString(message))
454+
}
455+
}
456+
457+
/**
458+
* Imports the file the picker returned as a new chat, opening it, or reports that it could not.
459+
*
460+
* @param uri the file the user picked.
461+
*/
462+
private fun readImport(uri: Uri) {
463+
val resolver = requireContext().contentResolver
464+
lifecycleScope.launch {
465+
if (!viewModel.importTranscript(resolver, uri)) {
466+
showInfoSnackbar(getString(R.string.session_import_failed))
467+
return@launch
468+
}
469+
sidebar?.close()
470+
showInfoSnackbar(getString(R.string.session_imported))
471+
}
472+
}
473+
356474
private fun setupInputArea() {
357475
binding.sendButton.setOnClickListener {
358476
if (viewModel.agentState.value.isRunning) {

0 commit comments

Comments
 (0)