superintelligence-android / android/app/src/main/java/net/kentonc/si/ui/ChatScreen.kt

raw file 13826 bytes
package net.kentonc.si.ui

import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import net.kentonc.si.AgentViewModel

/** The whole app surface: transcript, input bar, approval dialog. */
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(vm: AgentViewModel) {
    val rows by vm.rows.collectAsState()
    val busy by vm.busy.collectAsState()
    val approval by vm.approval.collectAsState()
    val attachments by vm.attachments.collectAsState()

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("superintelligence-android") },
                actions = {
                    UpdateAction(vm)
                    IconButton(onClick = { vm.clearConversation() }, enabled = rows.isNotEmpty() || busy) {
                        Icon(Icons.Filled.Delete, contentDescription = "Clear conversation")
                    }
                    IconButton(onClick = { vm.toggleSettings(true) }) {
                        Icon(Icons.Filled.Settings, contentDescription = "Settings")
                    }
                },
            )
        },
    ) { padding ->
        val listState = rememberLazyListState()
        val lastLen = (rows.lastOrNull() as? AgentViewModel.MessageRow)?.markdown?.length ?: 0
        val density = LocalDensity.current
        // Re-scroll when the keyboard appears/disappears too, so the draft stays visible.
        val imeVisible = WindowInsets.ime.getBottom(density) > 0
        LaunchedEffect(rows.size, lastLen, imeVisible) {
            if (rows.isNotEmpty()) listState.animateScrollToItem(rows.lastIndex)
        }

        // Without this the input bar would sit behind the keyboard: edge-to-edge
        // means the window never resizes, so the IME inset must be padded by the
        // content itself. consumeWindowInsets keeps the padding from stacking on
        // top of the navigation-bar inset the Scaffold already applied.
        Column(
            Modifier
                .padding(padding)
                .consumeWindowInsets(padding)
                .imePadding()
                .fillMaxSize(),
        ) {
            LazyColumn(state = listState, modifier = Modifier.weight(1f)) {
                items(rows, key = { it.id }) { row ->
                    when (row) {
                        is AgentViewModel.MessageRow -> MessageBubble(
                            row,
                            modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
                        )
                        is AgentViewModel.ToolRow -> ToolActivity(
                            row,
                            modifier = Modifier.padding(horizontal = 16.dp, vertical = 1.dp),
                        )
                        is AgentViewModel.ErrorRow -> Text(
                            "error: ${row.text}",
                            color = MaterialTheme.colorScheme.error,
                            fontSize = 13.sp,
                            modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp),
                        )
                    }
                }
            }
            AttachmentChips(attachments, vm::removeAttachment)
            InputBar(vm, busy)
        }
    }

    val request = approval
    if (request != null) ApprovalDialog(
        request,
        onResult = vm::answerApproval,
    )
}

@Composable
private fun MessageBubble(row: AgentViewModel.MessageRow, modifier: Modifier = Modifier) {
    val scheme = MaterialTheme.colorScheme
    when (row.role) {
        AgentViewModel.Role.User -> Column(
            modifier = modifier.fillMaxWidth(),
            horizontalAlignment = Alignment.End,
        ) {
            Surface(
                shape = RoundedCornerShape(14.dp, 4.dp, 14.dp, 14.dp),
                color = scheme.primaryContainer,
                modifier = Modifier.widthIn(max = 320.dp),
            ) {
                Text(
                    row.markdown,
                    style = MaterialTheme.typography.bodyMedium,
                    modifier = Modifier.padding(10.dp),
                )
            }
            if (row.images > 0) ImageNote(row.images)
        }
        // Assistant: plain markdown, widest use of the screen.
        AgentViewModel.Role.Assistant -> Column(modifier = modifier.fillMaxWidth()) {
            MarkdownText(row.markdown)
            if (row.images > 0) ImageNote(row.images)
        }
    }
}

@Composable
private fun ImageNote(count: Int) {
    Text(
        "πŸ“Ž $count image(s)",
        fontSize = 11.sp,
        color = MaterialTheme.colorScheme.onSurfaceVariant,
        modifier = Modifier.padding(top = 2.dp),
    )
}

// ─── in-app update ─────────────────────────────────────────────────────────────

/** The self-update tap: stat-dependent icon in the top bar. Appears whenever
 *  the hub has a newer release than the installed build. */
@Composable
private fun UpdateAction(vm: AgentViewModel) {
    val status by vm.update.collectAsState()
    when (val s = status) {
        is net.kentonc.si.UpdateStatus.Available -> IconButton(onClick = vm::startUpdate) {
            Icon(Icons.Filled.SystemUpdate, contentDescription = "Update available: ${s.version}")
        }
        is net.kentonc.si.UpdateStatus.Downloading -> CircularProgressIndicator(
            progress = { s.percent / 100f },
            strokeWidth = 3.dp,
            modifier = Modifier.size(22.dp),
        )
        is net.kentonc.si.UpdateStatus.NeedsPermission -> TextButton(onClick = vm::startUpdate) {
            Text("Allow installs", fontSize = 13.sp)
        }
        is net.kentonc.si.UpdateStatus.Failed -> IconButton(onClick = vm::startUpdate) {
            Icon(Icons.Filled.ErrorOutline, contentDescription = "Update check failed: ${s.message}", tint = MaterialTheme.colorScheme.error)
        }
        is net.kentonc.si.UpdateStatus.Ready -> IconButton(onClick = vm::startUpdate) {
            Icon(Icons.Filled.SystemUpdate, contentDescription = "Installer ready for ${s.version}")
        }
        is net.kentonc.si.UpdateStatus.Idle -> {}
    }
}

// ─── tool activity ─────────────────────────────────────────────────────────────

/** Dim, column-aligned machinery lines, mirroring the terminal OutputSink look. */
@Composable
private fun ToolActivity(row: AgentViewModel.ToolRow, modifier: Modifier = Modifier) {
    val outcome = row.outcome
    Column(modifier) {
        Text(
            text = row.summary,
            fontFamily = FontFamily.Monospace,
            fontSize = 12.sp,
            color = MaterialTheme.colorScheme.onSurfaceVariant,
        )
        if (outcome != null) {
            val (label, color) = when {
                outcome.startsWith("error:") ->
                    "╰─ $outcome" to MaterialTheme.colorScheme.error
                outcome == "skipped" ->
                    "╰─ $outcome" to MaterialTheme.colorScheme.tertiary
                else ->
                    "╰─ $outcome" to MaterialTheme.colorScheme.primary
            }
            Text(
                text = label,
                fontFamily = FontFamily.Monospace,
                fontSize = 12.sp,
                color = color.copy(alpha = color.alpha * 0.9f),
            )
        }
    }
}

// ─── input bar ─────────────────────────────────────────────────────────────────

@Composable
private fun AttachmentChips(
    attachments: List<AgentViewModel.Attachment>,
    onRemove: (String) -> Unit,
) {
    if (attachments.isEmpty()) return
    Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(horizontal = 12.dp)) {
        for (attachment in attachments) {
            Surface(shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.secondaryContainer) {
                Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(6.dp)) {
                    Text("πŸ“Ž ${attachment.name}", fontSize = 12.sp)
                    IconButton(onClick = { onRemove(attachment.name) }) {
                        Icon(Icons.Filled.Close, contentDescription = "Remove attachment", modifier = Modifier)
                    }
                }
            }
        }
    }
}

@Composable
private fun InputBar(vm: AgentViewModel, busy: Boolean) {
    var draft by remember { mutableStateOf("") }
    val picker = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
        uri?.let(vm::addAttachment)
    }

    Row(
        verticalAlignment = Alignment.Bottom,
        modifier = Modifier
            .fillMaxWidth()
            .padding(8.dp),
    ) {
        IconButton(
            onClick = {
                picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
            },
            enabled = !busy,
        ) {
            Icon(Icons.Filled.AttachFile, contentDescription = "Attach image")
        }
        OutlinedTextField(
            value = draft,
            onValueChange = { draft = it },
            placeholder = { Text("Ask anything…") },
            shape = RoundedCornerShape(22.dp),
            modifier = Modifier.weight(1f),
        )
        IconButton(
            onClick = {
                if (busy) {
                    vm.stop()
                } else {
                    vm.send(draft)
                    draft = ""
                }
            },
            enabled = !busy || draft.isNotBlank(),
        ) {
            Icon(
                if (busy) Icons.Filled.Close else Icons.AutoMirrored.Filled.Send,
                contentDescription = if (busy) "Stop" else "Send",
            )
        }
    }
}

// ─── approval ──────────────────────────────────────────────────────────────────

/** Mirrors the Rust "run it? [Y/n]" stop: mutating tools wait for a decision. */
@Composable
private fun ApprovalDialog(request: AgentViewModel.ApprovalRequest, onResult: (Boolean) -> Unit) {
    AlertDialog(
        onDismissRequest = { onResult(false) },
        title = { Text("Run \"${request.name}\"?", style = MaterialTheme.typography.titleMedium) },
        text = {
            Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(8.dp)) {
                Text(
                    request.detail,
                    fontFamily = FontFamily.Monospace,
                    fontSize = 13.sp,
                    modifier = Modifier.padding(8.dp),
                    textAlign = TextAlign.Start,
                )
            }
        },
        confirmButton = {
            TextButton(onClick = { onResult(true) }) { Text("Run it") }
        },
        dismissButton = {
            TextButton(onClick = { onResult(false) }) { Text("Decline") }
        },
    )
}