Switching conversation was a step back to the list and a step down into another, which disposed the session being left and refetched its whole transcript over the tunnel on the way back. A right swipe now pulls MainScreen itself over the open session -- the screen Back would have shown, moved over the session instead of replacing it -- and swiping it back off returns to a live stream, an unsent draft and the scroll position it had. Tapping the session already open is that same swipe back; tapping another is a screen of its own; deleting the one underneath closes the screen, since there is nothing left to return to. One gesture drives both this and the subagent panel (SidePanels.kt, now the home of the drag and animation SubagentPanel had): two draggables over the same content cannot share a horizontal drag, so the position is a single signed reveal, negative left and positive right, which also makes it impossible to have both open. The panels exist only inside a session, so nothing on the main screen swipes anywhere. Full width and no tonal step for this one, because a screen standing in for another must be the same colour as it; the subagent panel keeps its 88% and its sliver. The list keeps its rows while it asks again -- the panel refetches on every open, and blanking it each time handed the reader an empty screen about something never in doubt -- with a bar over the top while an answer is outstanding. Where the panel has got to is read from draw lambdas only: it changes every frame of a drag, and a body that reads it recomposes the session beneath once per frame. Composition sees booleans that change twice per gesture, the same correction the keyboard inset needed. Verified on the emulator against the sandbox with ui-trace: the panel opens and closes on the two swipes, tapping another session replaces the screen, deleting the open one leaves for the list, the subagent panel is unchanged, and neither swipe does anything on the main screen. ktfmt, compile, lint and the unit tests are clean.
160 lines
7.2 KiB
Kotlin
160 lines
7.2 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import androidx.activity.compose.BackHandler
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.PrimaryTabRow
|
|
import androidx.compose.material3.Tab
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableIntStateOf
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.lifecycle.Lifecycle
|
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
|
import androidx.lifecycle.repeatOnLifecycle
|
|
|
|
/**
|
|
* The app's root: one title, and three views of the backend behind it.
|
|
*
|
|
* These were screens reached by words in a row under the title, and the row was already full. Tabs
|
|
* say the same thing in less space and say one more thing besides: that these are places to be
|
|
* rather than errands to run. Sessions, the machine's importable history and the machines
|
|
* themselves are all *the same backend*, looked at three ways, and none is a step down from
|
|
* another. Settings still is, which is why it stays a pushed screen with its own Back.
|
|
*
|
|
* Models were a fourth tab until 2026-09-19. They are a machine's models now -- downloaded onto the
|
|
* machine that has to serve them -- so they live under that machine's llama.cpp provider, beside
|
|
* the settings deciding how each one is loaded. A tab about "the models" was a claim that there is
|
|
* one such set, and there is one per machine.
|
|
*/
|
|
private enum class MainTab(val label: String) {
|
|
Sessions("Sessions"),
|
|
Import("Import"),
|
|
Machines("Machines"),
|
|
}
|
|
|
|
@Composable
|
|
fun MainScreen(
|
|
settings: ServerSettings,
|
|
reloadToken: Int,
|
|
/** What another app shared in and no session has taken yet; see [ShareRequest]. */
|
|
share: ShareRequest? = null,
|
|
onOpen: (SessionSummary) -> Unit,
|
|
onSpawn: () -> Unit,
|
|
onImported: (SessionSummary) -> Unit,
|
|
onSettings: () -> Unit,
|
|
/** One machine's provider, opened from the machines tab. */
|
|
onProvider: (String, String) -> Unit,
|
|
/** A session the list has just deleted; see [SessionListScreen]. */
|
|
onDeleted: (String) -> Unit = {},
|
|
) {
|
|
var tab by remember { mutableStateOf(MainTab.Sessions) }
|
|
var refreshToken by remember { mutableIntStateOf(0) }
|
|
|
|
// Coming back to the app asks again, on whichever tab is showing.
|
|
//
|
|
// What these four draw is a snapshot of a backend they are not connected to, so it is only as
|
|
// fresh as the last answer -- and a *failed* answer is the one that outstays its welcome. A
|
|
// phone that was away while the tunnel was down came back to "Couldn't reach the server"
|
|
// sitting at the top of a list the server would now answer for perfectly well. A stale failure
|
|
// is worse than a stale list: it is a claim about right now.
|
|
//
|
|
// Through the same token the Refresh button uses, so this is one instruction the tabs already
|
|
// understand. Not on the first entry: the tab composing already asks.
|
|
val lifecycleOwner = LocalLifecycleOwner.current
|
|
LaunchedEffect(lifecycleOwner) {
|
|
var opening = true
|
|
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
|
if (!opening) refreshToken++
|
|
opening = false
|
|
}
|
|
}
|
|
|
|
// A tab the app put over the list has to step back to it rather than fall through to the system
|
|
// default, which closes the app. Nested inside AppRoot's handler, so it wins while enabled.
|
|
BackHandler(enabled = tab != MainTab.Sessions) { tab = MainTab.Sessions }
|
|
|
|
Column(Modifier.fillMaxSize()) {
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 16.dp),
|
|
) {
|
|
Text(
|
|
"AI Sessions",
|
|
style = MaterialTheme.typography.headlineSmall,
|
|
modifier = Modifier.weight(1f),
|
|
)
|
|
// Glyphs rather than the words they replaced: neither ever changes, both are read
|
|
// faster than they are spelled, and together they take the width that let the title
|
|
// keep its own line. They sit on the title's row because they act on the whole screen.
|
|
//
|
|
// Flush against each other: a glyph button carries its own padding, so two side by side
|
|
// already have two rings between their marks.
|
|
Row {
|
|
GlyphButton(REFRESH_GLYPH, "Refresh", { refreshToken++ })
|
|
GlyphButton(SETTINGS_GLYPH, "Settings", onSettings)
|
|
}
|
|
}
|
|
// What is waiting to be attached, and what to do about it. Said here because the list below
|
|
// is where the choice is made, and a share that arrived with nothing on screen saying so
|
|
// would read as a tap that did nothing.
|
|
share?.let {
|
|
Text(
|
|
it.summary() + " -- open the session it belongs in.",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
modifier =
|
|
Modifier.fillMaxWidth()
|
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
.background(
|
|
MaterialTheme.colorScheme.primaryContainer,
|
|
MaterialTheme.shapes.small,
|
|
)
|
|
.padding(12.dp),
|
|
)
|
|
}
|
|
// Primary rather than the plain TabRow, which is deprecated in favour of the two that say
|
|
// where they sit: these are the app's top-level destinations.
|
|
PrimaryTabRow(selectedTabIndex = tab.ordinal) {
|
|
MainTab.entries.forEach { entry ->
|
|
Tab(
|
|
selected = tab == entry,
|
|
onClick = { tab = entry },
|
|
text = { Text(entry.label) },
|
|
)
|
|
}
|
|
}
|
|
|
|
// Refreshing means "ask again about what I am looking at", so the button feeds the tab that
|
|
// is showing. The token from above means something else already changed what these show;
|
|
// the two are the same instruction, so they are summed rather than tracked apart.
|
|
val token = reloadToken + refreshToken
|
|
when (tab) {
|
|
MainTab.Sessions ->
|
|
SessionListScreen(
|
|
settings = settings,
|
|
reloadToken = token,
|
|
onOpen = onOpen,
|
|
onSpawn = onSpawn,
|
|
onDeleted = onDeleted,
|
|
)
|
|
MainTab.Import ->
|
|
ImportScreen(settings = settings, reloadToken = token, onImported = onImported)
|
|
MainTab.Machines ->
|
|
MachinesScreen(settings = settings, reloadToken = token, onProvider = onProvider)
|
|
}
|
|
}
|
|
}
|