Attachments were images only. Now any file can be attached: from the file chooser behind the "+" menu, or from Android's share sheet, which the app is now in. An image still goes to the model as a picture; anything else is stored under its own name (`<hex>-<name>`, cleaned by `safe_file_name`) and the Claude driver ends the message with `Attached file: /abs/path`, since the CLI reads files by path and a model cannot be shown a trace. The user-message field is renamed `images` -> `attachments` on both sides, with a serde alias reading the rows written before. A share arrives before anyone has said which session it is for, so it is held in AppRoot with a banner on the list until a session takes it; an open session takes it at once. Unreadable shares are reported beside the composer, not thrown. The tool card crashed the app when opened on a command holding a quoted glob such as `-path '*/.git/*'`: highlights 1.1.0's shell lexer answers `x '*/a/*'` with a span whose end is before its start, and AnnotatedString refuses the range. Such spans are dropped; the library is the place for the fix. The echo driver gains `/bash <command>` so a card with a given command can be produced on the emulator. ui-sandbox.sh's token salvage read the tokens block's close only at a line start, ran past the compact `),],` the server writes, and copied `setups` into the new config twice, which the server then refused. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
162 lines
7.5 KiB
Kotlin
162 lines
7.5 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 four views of the backend behind it.
|
|
*
|
|
* These were four screens reached by four words in a row under the title, and the row was already
|
|
* full -- the comment it replaced recorded that a fifth would have to go somewhere else. 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, the models on it and the
|
|
* machines themselves are all *the same backend*, looked at four ways, and none of them is a step
|
|
* down from another. Settings still is a step down, which is why it stays a pushed screen and keeps
|
|
* its own Back.
|
|
*/
|
|
private enum class MainTab(val label: String) {
|
|
Sessions("Sessions"),
|
|
Import("Import"),
|
|
Models("Models"),
|
|
Setups("Setups"),
|
|
}
|
|
|
|
@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,
|
|
) {
|
|
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, or that fetched before the network came up,
|
|
// came back to "Couldn't reach the server" sitting at the top of a list the server would now
|
|
// answer for perfectly well, and nothing took it off until somebody pressed Refresh. 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 rather than a second path into each of them -- which is also what makes it cover
|
|
// all four rather than the one the report came from.
|
|
//
|
|
// Not on the first entry: the tab composing already asks, and bumping here would make every
|
|
// cold start fetch twice.
|
|
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 -- that reads as a crash to somebody who only meant to
|
|
// get back to their sessions. Nested inside AppRoot's handler, so it wins while it is 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 -- everything below this row is one tab's business, and a control belongs
|
|
// with the thing it acts on.
|
|
// Flush against each other: a glyph button carries its own padding, so two of them
|
|
// side by side already have two rings between their marks and one ring plus this
|
|
// row's padding to the screen edge.
|
|
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 to the tab below, so they are summed rather than
|
|
// tracked apart -- either one moving moves the sum, which is all a tab watches.
|
|
val token = reloadToken + refreshToken
|
|
when (tab) {
|
|
MainTab.Sessions ->
|
|
SessionListScreen(
|
|
settings = settings,
|
|
reloadToken = token,
|
|
onOpen = onOpen,
|
|
onSpawn = onSpawn,
|
|
)
|
|
MainTab.Import ->
|
|
ImportScreen(settings = settings, reloadToken = token, onImported = onImported)
|
|
MainTab.Models -> ModelsScreen(settings = settings, reloadToken = token)
|
|
MainTab.Setups -> SetupsScreen(settings = settings, reloadToken = token)
|
|
}
|
|
}
|
|
}
|