Files
ai-app/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt
T
irisandClaude Fable 5.1 6180663f14 Attach any file, take shares from other apps, and survive a backwards highlight
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>
2026-09-03 08:15:10 -04:00

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)
}
}
}