Composer auto-grow (1→5 lines) + in-field attach; settings screen + persisted prefs

- Composer input starts at one line and auto-grows up to 5 lines (then scrolls),
  measured via onTextLayout; attach (paperclip) moved inside the field, right of
  the text, per the Telegram reference.

- New SettingsScreen (drawer/rail entry) for Threads + Tool detail; both now
  persist via SecureStore (Android prefs / desktop JSON).

- Add AGENTS.md; rename reference screenshot to telegram_screenshot_reference.jpg.
This commit is contained in:
ARIA committed 2026-08-20 13:12:24 +02:00
1 parent bf6bf7e8bd
commit e678caafdc
8 files changed
+300 -52

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
# AGENTS.md
## Hard rules
- `hermes-agent/` is a **read-only research reference** (git-ignored). Never commit, push, or modify it — a pre-commit hook (`scripts/guard_hermes_agent.sh --staged`) fails any commit that stages it. Never modify hermes core; we only install our plugin into the live hermes home.
- The plugin is installed by symlink: `~/.hermes/plugins/android` → `<repo>/gateway-plugin` (already set up on this machine).
## Layout
- `gateway-plugin/` — Python hermes platform plugin (`android`). No build step, zero new deps (stdlib + hermes-provided `websockets`/`httpx`). `protocol.py` is the frame source of truth, mirrored in `app/shared/.../protocol/Protocol.kt` and `docs/protocol/frames.schema.json`.
- `app/` — one Compose Multiplatform Gradle project: `:shared` (KMP, most of the code; `jvmMain` is shared by the android and desktop targets since both are JVM-based), `:androidApp` (thin shell, package `dev.iris.app`), `:desktopApp` (thin shell).
- `docs/` — numbered reference library; read `docs/00-overview.md` first. Locked decisions: `docs/16-open-questions.md`.
## Commands
- `hermes` is **not on PATH**: use `hermes-agent/.venv/bin/hermes` (venv from `cd hermes-agent && uv sync`).
- Gateway: `hermes gateway setup` (one-time; generates `ANDROID_TOKEN` in `~/.hermes/.env`, prints the token only once) → `hermes gateway` (run) → `hermes gateway status`.
- Android: check the device is connected first (`adb devices` → `a5ca2a4b` listed as `device`); then `cd app && ./gradlew :androidApp:installDebug` to install on the phone and live-verify changes (launch/screenshot: see ADB below).
- Desktop: `cd app && ./gradlew :desktopApp:run`; packaging: `:desktopApp:jpackage` (app-image; `-PjpackageType=deb` for a .deb).
- Python tests — **never bare `pytest`**: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py` (no args = full suite).
- Kotlin tests: `cd app && ./gradlew :shared:testDebugUnitTest` / `:shared:desktopTest` (no test sources exist yet).
- WS probe (gateway must be running): `hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py --token <ANDROID_TOKEN> --send "hello"` — assertion flags documented in `gateway-plugin/tests/README.md`.
- E2E driver (gateway must be running; it never starts/stops it): `hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py`.
## Environment / pairing quirks
- Pairing token: `ANDROID_TOKEN` in `~/.hermes/.env`. The app has **no QR scanner** — pairing is manual URL + token entry.
- WS default bind is `127.0.0.1`; for a phone on the LAN set `ANDROID_WS_HOST` to the gateway's LAN IP.
- `app/local.properties` (`sdk.dir`) is git-ignored and required for Android builds.
- `google-services.json` is optional: without it FCM is inert and ntfy is the push path. Public ntfy.sh SSE is flaky — self-host ntfy.
- JDK 17; no system Gradle — always the wrapper (`./gradlew`).
- Desktop jpackage on Linux/JDK 17 prints a non-fatal `pure virtual method called` (JDK-8348560); the app works.
## Testing quirks
- `hermes-agent/tests/gateway/test_android.py` is a thin mirror that imports the **live `gateway-plugin/` package from this repo** (override with `ANDROID_PLUGIN_DIR`); HERMES_HOME is sandboxed per-test by the conftest. Tests must never touch the real `~/.hermes`.
- e2e scenarios 3 (reasoning) and 5 (commentary) are model-dependent → SKIP; 11 (push) and 12 (reconnect) are PARTIAL by design.
- ADB: launch `adb shell am start -n dev.iris.app/.MainActivity`; reset pairing state `adb shell pm clear dev.iris.app`; screenshot `adb exec-out screencap -p > /tmp/shot.png`.
- Gateway logs: `~/.hermes/logs/gateway.log` or `hermes logs --follow`.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 376 KiB

@@ -97,6 +97,14 @@ class AndroidSecureStore(context: Context) : SecureStore {
get() = prefs.getString(KEY_NTFY_SERVER, "").orEmpty() get() = prefs.getString(KEY_NTFY_SERVER, "").orEmpty()
set(value) = prefs.edit().putString(KEY_NTFY_SERVER, value).apply() set(value) = prefs.edit().putString(KEY_NTFY_SERVER, value).apply()
override var threadsEnabled: Boolean
get() = prefs.getBoolean(KEY_THREADS_ENABLED, false)
set(value) = prefs.edit().putBoolean(KEY_THREADS_ENABLED, value).apply()
override var toolDetail: String
get() = prefs.getString(KEY_TOOL_DETAIL, "truncated").orEmpty().ifBlank { "truncated" }
set(value) = prefs.edit().putString(KEY_TOOL_DETAIL, value).apply()
override fun savePairing(url: String, token: String) { override fun savePairing(url: String, token: String) {
serverUrl = url serverUrl = url
this.token = token this.token = token
@@ -116,5 +124,7 @@ class AndroidSecureStore(context: Context) : SecureStore {
const val KEY_FCM_TOKEN = "fcm_token" const val KEY_FCM_TOKEN = "fcm_token"
const val KEY_NTFY_TOPIC = "ntfy_topic" const val KEY_NTFY_TOPIC = "ntfy_topic"
const val KEY_NTFY_SERVER = "ntfy_server" const val KEY_NTFY_SERVER = "ntfy_server"
const val KEY_THREADS_ENABLED = "threads_enabled"
const val KEY_TOOL_DETAIL = "tool_detail"
} }
} }
@@ -30,6 +30,12 @@ interface SecureStore {
/** ntfy server URL (M5; from hello.ack server_caps; default ntfy.sh). */ /** ntfy server URL (M5; from hello.ack server_caps; default ntfy.sh). */
var ntfyServer: String var ntfyServer: String
/** UI setting: show threads (topics) in the chat view. */
var threadsEnabled: Boolean
/** UI setting: tool verbosity — "everything" / "truncated" / "nothing". */
var toolDetail: String
fun savePairing(url: String, token: String) fun savePairing(url: String, token: String)
fun clear() fun clear()
} }
@@ -80,8 +80,9 @@ class IrisController(
private val _typing = MutableStateFlow(false) private val _typing = MutableStateFlow(false)
val typing: StateFlow<Boolean> = _typing.asStateFlow() val typing: StateFlow<Boolean> = _typing.asStateFlow()
/** Tool-card verbosity (M2). NOTHING suppresses tool cards entirely. */ /** Tool-card verbosity (M2). NOTHING suppresses tool cards entirely.
private val _toolDetail = MutableStateFlow(ToolDetail.TRUNCATED) * Persisted (Settings → "Tool detail"). */
private val _toolDetail = MutableStateFlow(ToolDetail.fromName(store.toolDetail))
val toolDetail: StateFlow<ToolDetail> = _toolDetail.asStateFlow() val toolDetail: StateFlow<ToolDetail> = _toolDetail.asStateFlow()
fun cycleToolDetail() { fun cycleToolDetail() {
@@ -90,6 +91,13 @@ class IrisController(
ToolDetail.TRUNCATED -> ToolDetail.NOTHING ToolDetail.TRUNCATED -> ToolDetail.NOTHING
ToolDetail.NOTHING -> ToolDetail.EVERYTHING ToolDetail.NOTHING -> ToolDetail.EVERYTHING
} }
store.toolDetail = _toolDetail.value.name.lowercase()
}
fun setToolDetail(detail: ToolDetail) {
if (detail == _toolDetail.value) return
_toolDetail.value = detail
store.toolDetail = detail.name.lowercase()
} }
/** Home channel id (from hello.ack; default until then). */ /** Home channel id (from hello.ack; default until then). */
@@ -101,11 +109,13 @@ class IrisController(
val gatewayStatus: StateFlow<String?> = _gatewayStatus.asStateFlow() val gatewayStatus: StateFlow<String?> = _gatewayStatus.asStateFlow()
// ── M3: threads toggle (per-app for now; per-channel lands later) ───── // ── M3: threads toggle (per-app for now; per-channel lands later) ─────
private val _threadsEnabled = MutableStateFlow(false) // Persisted (Settings → "Threads").
private val _threadsEnabled = MutableStateFlow(store.threadsEnabled)
val threadsEnabled: StateFlow<Boolean> = _threadsEnabled.asStateFlow() val threadsEnabled: StateFlow<Boolean> = _threadsEnabled.asStateFlow()
fun toggleThreads() { fun toggleThreads() {
_threadsEnabled.value = !_threadsEnabled.value _threadsEnabled.value = !_threadsEnabled.value
store.threadsEnabled = _threadsEnabled.value
} }
// ── M3: search state ────────────────────────────────────────────────── // ── M3: search state ──────────────────────────────────────────────────
@@ -487,4 +497,11 @@ enum class ToolDetail {
EVERYTHING, // name + full args (collapsible) + output preview EVERYTHING, // name + full args (collapsible) + output preview
TRUNCATED, // one-liner "emoji name: preview", collapsible (default) TRUNCATED, // one-liner "emoji name: preview", collapsible (default)
NOTHING, // suppress tool cards entirely NOTHING, // suppress tool cards entirely
;
companion object {
/** Parse a persisted value; unknown/blank falls back to the default. */
fun fromName(name: String): ToolDetail =
entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: TRUNCATED
}
} }
@@ -41,6 +41,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -49,6 +50,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -70,6 +72,7 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
@@ -107,7 +110,7 @@ import iris.util.localDayKey
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
* Chat screen (M3): channel drawer + thread toggle + topic switcher + search, * Chat screen (M3): channel drawer + topic switcher + search + settings,
* on top of the M2 message list / composer. Messages are shown for the current * on top of the M2 message list / composer. Messages are shown for the current
* lane (a channel's flat lane, or a specific thread). * lane (a channel's flat lane, or a specific thread).
*/ */
@@ -132,6 +135,7 @@ fun ChatScreen(controller: IrisController) {
var showNewChannel by remember { mutableStateOf(false) } var showNewChannel by remember { mutableStateOf(false) }
var showNewThread by remember { mutableStateOf(false) } var showNewThread by remember { mutableStateOf(false) }
var showPicker by remember { mutableStateOf(false) } var showPicker by remember { mutableStateOf(false) }
var showSettings by remember { mutableStateOf(false) }
val attachments by controller.attachments.collectAsState() val attachments by controller.attachments.collectAsState()
val banners by controller.banners.collectAsState() val banners by controller.banners.collectAsState()
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
@@ -174,7 +178,7 @@ fun ChatScreen(controller: IrisController) {
if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
val ctrlOrMeta = e.isCtrlPressed || e.isMetaPressed val ctrlOrMeta = e.isCtrlPressed || e.isMetaPressed
val overlayOpen = val overlayOpen =
showSearch || showPicker || showPalette || showNewChannel || showNewThread showSearch || showPicker || showPalette || showNewChannel || showNewThread || showSettings
when { when {
ctrlOrMeta && e.key == Key.N -> { showNewChannel = true; true } ctrlOrMeta && e.key == Key.N -> { showNewChannel = true; true }
ctrlOrMeta && e.key == Key.T -> { showNewThread = true; true } ctrlOrMeta && e.key == Key.T -> { showNewThread = true; true }
@@ -186,6 +190,7 @@ fun ChatScreen(controller: IrisController) {
showPalette = false showPalette = false
showNewChannel = false showNewChannel = false
showNewThread = false showNewThread = false
showSettings = false
true true
} }
e.key == Key.Slash && !composerFocused && !overlayOpen -> { e.key == Key.Slash && !composerFocused && !overlayOpen -> {
@@ -231,12 +236,6 @@ fun ChatScreen(controller: IrisController) {
HeaderIconButton(onClick = { showSearch = true }) { HeaderIconButton(onClick = { showSearch = true }) {
Text("🔍", fontSize = 16.sp) Text("🔍", fontSize = 16.sp)
} }
HeaderIconButton(onClick = { controller.toggleThreads() }) {
Text(if (threadsEnabled) "🧵✓" else "🧵", fontSize = 16.sp)
}
TextButton(onClick = { controller.cycleToolDetail() }) {
Text("tools: ${toolDetail.label}", fontSize = 11.sp)
}
if (isDesktop) { if (isDesktop) {
IconButton(onClick = { showInspector = !showInspector }) { IconButton(onClick = { showInspector = !showInspector }) {
Box( Box(
@@ -290,12 +289,6 @@ fun ChatScreen(controller: IrisController) {
HeaderIconButton(onClick = { showSearch = true }) { HeaderIconButton(onClick = { showSearch = true }) {
Text("🔍", fontSize = 16.sp) Text("🔍", fontSize = 16.sp)
} }
HeaderIconButton(onClick = { controller.toggleThreads() }) {
Text(if (threadsEnabled) "🧵✓" else "🧵", fontSize = 16.sp)
}
TextButton(onClick = { controller.cycleToolDetail() }) {
Text("tools: ${toolDetail.label}", fontSize = 11.sp)
}
if (isDesktop) { if (isDesktop) {
IconButton(onClick = { showInspector = !showInspector }) { IconButton(onClick = { showInspector = !showInspector }) {
Box( Box(
@@ -438,6 +431,8 @@ fun ChatScreen(controller: IrisController) {
// Composer (M7: rounded pill + accent circular send button) // Composer (M7: rounded pill + accent circular send button)
val canSend = input.isNotBlank() || attachments.any { it.mediaRef != null && it.error == null } val canSend = input.isNotBlank() || attachments.any { it.mediaRef != null && it.error == null }
val layoutDensity = LocalDensity.current.density
var textHeightPx by remember { mutableFloatStateOf(0f) }
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -447,15 +442,32 @@ fun ChatScreen(controller: IrisController) {
.padding(horizontal = 6.dp, vertical = 4.dp), .padding(horizontal = 6.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
IconButton(onClick = { showPicker = true }) { Row(
Text("📎", fontSize = 18.sp)
}
Box(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.heightIn(min = 40.dp, max = 160.dp)
.padding(horizontal = 6.dp, vertical = 10.dp), .padding(horizontal = 6.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
Box(modifier = Modifier.weight(1f)) {
BasicTextField(
value = input,
onValueChange = { input = it },
modifier = Modifier
.fillMaxWidth()
.heightIn(max = if (textHeightPx > 0f) (textHeightPx / layoutDensity).dp else Dp.Unspecified)
.focusRequester(composerFocusRequester)
.onFocusChanged { composerFocused = it.isFocused },
textStyle = MaterialTheme.typography.bodyLarge.copy(color = IrisColors.onSurface),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { doSend() }),
onTextLayout = { result ->
if (result.lineCount > 0) {
val totalHeight = result.getLineBottom(result.lineCount - 1)
val lineHeight = totalHeight / result.lineCount
textHeightPx = minOf(totalHeight, lineHeight * 5f)
}
},
)
if (input.isEmpty()) { if (input.isEmpty()) {
Text( Text(
"Message", "Message",
@@ -464,17 +476,10 @@ fun ChatScreen(controller: IrisController) {
modifier = Modifier.align(Alignment.TopStart), modifier = Modifier.align(Alignment.TopStart),
) )
} }
BasicTextField( }
value = input, IconButton(onClick = { showPicker = true }) {
onValueChange = { input = it }, Text("📎", fontSize = 18.sp)
modifier = Modifier }
.fillMaxSize()
.focusRequester(composerFocusRequester)
.onFocusChanged { composerFocused = it.isFocused },
textStyle = MaterialTheme.typography.bodyLarge.copy(color = IrisColors.onSurface),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { doSend() }),
)
} }
Box( Box(
modifier = Modifier modifier = Modifier
@@ -537,6 +542,7 @@ fun ChatScreen(controller: IrisController) {
onNewChannel = { showNewChannel = true }, onNewChannel = { showNewChannel = true },
onSetDefault = { ch -> controller.setDefaultChannel(ch.chatId) }, onSetDefault = { ch -> controller.setDefaultChannel(ch.chatId) },
onDelete = { ch -> controller.deleteChannel(ch.chatId) }, onDelete = { ch -> controller.deleteChannel(ch.chatId) },
onOpenSettings = { showSettings = true },
) )
Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(IrisColors.divider)) Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(IrisColors.divider))
Box(modifier = Modifier.weight(1f).fillMaxHeight()) { Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
@@ -550,8 +556,6 @@ fun ChatScreen(controller: IrisController) {
currentChannel = currentChannel, currentChannel = currentChannel,
threads = threads, threads = threads,
items = items, items = items,
toolDetail = toolDetail,
threadsEnabled = threadsEnabled,
) )
} }
} }
@@ -574,6 +578,10 @@ fun ChatScreen(controller: IrisController) {
}, },
onSetDefault = { ch -> controller.setDefaultChannel(ch.chatId) }, onSetDefault = { ch -> controller.setDefaultChannel(ch.chatId) },
onDelete = { ch -> controller.deleteChannel(ch.chatId) }, onDelete = { ch -> controller.deleteChannel(ch.chatId) },
onOpenSettings = {
showSettings = true
drawerScope.launch { drawerState.close() }
},
) )
} }
}, },
@@ -612,6 +620,11 @@ fun ChatScreen(controller: IrisController) {
) )
} }
// Settings overlay (full screen; covers the drawer)
if (showSettings) {
SettingsScreen(controller = controller, onBack = { showSettings = false })
}
// New channel dialog // New channel dialog
if (showNewChannel) { if (showNewChannel) {
NameDialog( NameDialog(
@@ -741,7 +754,7 @@ private fun TitlePill(channelName: String, modifier: Modifier = Modifier) {
/** M7: compact 36dp header icon button (smaller than the 48dp default). */ /** M7: compact 36dp header icon button (smaller than the 48dp default). */
@Composable @Composable
private fun HeaderIconButton(onClick: () -> Unit, content: @Composable () -> Unit) { internal fun HeaderIconButton(onClick: () -> Unit, content: @Composable () -> Unit) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(36.dp) .size(36.dp)
@@ -804,7 +817,7 @@ private fun LetterAvatar(name: String, size: Dp, color: Color) {
} }
} }
/** Left drawer: channel list + create / set-default / delete. */ /** Left drawer: channel list + create / set-default / delete / settings. */
@Composable @Composable
private fun ChannelDrawer( private fun ChannelDrawer(
channels: List<ChannelInfo>, channels: List<ChannelInfo>,
@@ -813,6 +826,7 @@ private fun ChannelDrawer(
onNewChannel: () -> Unit, onNewChannel: () -> Unit,
onSetDefault: (ChannelInfo) -> Unit, onSetDefault: (ChannelInfo) -> Unit,
onDelete: (ChannelInfo) -> Unit, onDelete: (ChannelInfo) -> Unit,
onOpenSettings: () -> Unit,
) { ) {
Column(modifier = Modifier.fillMaxSize().padding(12.dp)) { Column(modifier = Modifier.fillMaxSize().padding(12.dp)) {
Text( Text(
@@ -858,6 +872,10 @@ private fun ChannelDrawer(
Button(onClick = onNewChannel, modifier = Modifier.fillMaxWidth()) { Button(onClick = onNewChannel, modifier = Modifier.fillMaxWidth()) {
Text("+ New channel") Text("+ New channel")
} }
Spacer(modifier = Modifier.height(4.dp))
OutlinedButton(onClick = onOpenSettings, modifier = Modifier.fillMaxWidth()) {
Text("⚙ Settings")
}
} }
} }
@@ -876,6 +894,7 @@ private fun ChannelRail(
onNewChannel: () -> Unit, onNewChannel: () -> Unit,
onSetDefault: (ChannelInfo) -> Unit, onSetDefault: (ChannelInfo) -> Unit,
onDelete: (ChannelInfo) -> Unit, onDelete: (ChannelInfo) -> Unit,
onOpenSettings: () -> Unit,
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -965,10 +984,14 @@ private fun ChannelRail(
Button(onClick = onNewChannel, modifier = Modifier.fillMaxWidth()) { Button(onClick = onNewChannel, modifier = Modifier.fillMaxWidth()) {
Text("+ New channel") Text("+ New channel")
} }
Spacer(modifier = Modifier.height(4.dp))
OutlinedButton(onClick = onOpenSettings, modifier = Modifier.fillMaxWidth()) {
Text("⚙ Settings")
}
} }
} }
/** M6: optional third pane — session info, channel info, last reply, settings. */ /** M6: optional third pane — session info, channel info, last reply. */
@Composable @Composable
private fun InspectorPane( private fun InspectorPane(
controller: IrisController, controller: IrisController,
@@ -976,8 +999,6 @@ private fun InspectorPane(
currentChannel: ChannelInfo?, currentChannel: ChannelInfo?,
threads: List<ChannelInfo>, threads: List<ChannelInfo>,
items: List<ChatItem>, items: List<ChatItem>,
toolDetail: ToolDetail,
threadsEnabled: Boolean,
) { ) {
val lastAssistant = items.lastOrNull { val lastAssistant = items.lastOrNull {
it is MessageItem && it.role == ROLE_ASSISTANT && !it.streaming it is MessageItem && it.role == ROLE_ASSISTANT && !it.streaming
@@ -1007,16 +1028,6 @@ private fun InspectorPane(
Text("Last reply", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(bottom = 4.dp)) Text("Last reply", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(bottom = 4.dp))
InfoRow("Model", lastAssistant?.model ?: "—") InfoRow("Model", lastAssistant?.model ?: "—")
InfoRow("Tokens", lastAssistant?.tokens?.toString() ?: "—") InfoRow("Tokens", lastAssistant?.tokens?.toString() ?: "—")
Spacer(modifier = Modifier.height(8.dp))
Text("Settings", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(bottom = 4.dp))
Row {
TextButton(onClick = { controller.cycleToolDetail() }) {
Text("tools: ${toolDetail.label}")
}
TextButton(onClick = { controller.toggleThreads() }) {
Text(if (threadsEnabled) "threads: on" else "threads: off")
}
}
} }
} }
@@ -1104,7 +1115,7 @@ private fun TopicSwitcher(
} }
@Composable @Composable
private fun TopicChip(label: String, selected: Boolean, onClick: () -> Unit) { internal fun TopicChip(label: String, selected: Boolean, onClick: () -> Unit) {
Box( Box(
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
@@ -1683,7 +1694,7 @@ private fun ToolCard(tool: ToolItem, detail: ToolDetail) {
} }
} }
private val ToolDetail.label: String internal val ToolDetail.label: String
get() = when (this) { get() = when (this) {
ToolDetail.EVERYTHING -> "everything" ToolDetail.EVERYTHING -> "everything"
ToolDetail.TRUNCATED -> "truncated" ToolDetail.TRUNCATED -> "truncated"
@@ -0,0 +1,149 @@
package iris.ui.screens
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import iris.state.IrisController
import iris.state.ToolDetail
import iris.ui.theme.IrisColors
/**
* Settings screen (post-M7): app-level preferences, opened from the channel
* drawer / rail. More settings land here over time.
*/
@Composable
fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
val threadsEnabled by controller.threadsEnabled.collectAsState()
val toolDetail by controller.toolDetail.collectAsState()
Box(modifier = Modifier.fillMaxSize().background(IrisColors.background)) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(12.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 12.dp),
) {
HeaderIconButton(onClick = onBack) {
BackArrow()
}
Spacer(modifier = Modifier.width(8.dp))
Text("Settings", style = MaterialTheme.typography.titleMedium)
}
Text(
"Chat",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(bottom = 4.dp),
)
SettingsCard {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text("🧵 Threads", fontSize = 14.sp)
Text(
"Show topics in the chat view",
fontSize = 12.sp,
color = IrisColors.textDim,
)
}
Switch(
checked = threadsEnabled,
onCheckedChange = { controller.toggleThreads() },
)
}
}
Text(
"Tools",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
)
SettingsCard {
Text("🔧 Tool detail", fontSize = 14.sp)
Text(
"How much tool activity to show",
fontSize = 12.sp,
color = IrisColors.textDim,
)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
ToolDetail.entries.forEach { d ->
TopicChip(
label = d.label.replaceFirstChar { it.uppercaseChar() },
selected = toolDetail == d,
onClick = { controller.setToolDetail(d) },
)
}
}
}
}
}
}
/** Material-style back arrow (vector, matches the app's icon weight). */
@Composable
private fun BackArrow(size: Dp = 22.dp, color: Color = IrisColors.onSurface) {
Canvas(modifier = Modifier.size(size)) {
val s = this.size.width / 24f
val path = Path().apply {
moveTo(20f * s, 11f * s)
lineTo(7.83f * s, 11f * s)
lineTo(13.42f * s, 5.41f * s)
lineTo(12f * s, 4f * s)
lineTo(4f * s, 12f * s)
lineTo(12f * s, 20f * s)
lineTo(13.41f * s, 18.59f * s)
lineTo(7.83f * s, 13f * s)
lineTo(20f * s, 13f * s)
close()
}
drawPath(path, color)
}
}
/** Settings row container (panel card). */
@Composable
private fun SettingsCard(content: @Composable () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(IrisColors.panel)
.padding(12.dp),
) {
content()
}
}
@@ -34,6 +34,8 @@ class DesktopSecureStore : SecureStore {
val fcmToken: String = "", val fcmToken: String = "",
val ntfyTopic: String = "", val ntfyTopic: String = "",
val ntfyServer: String = "", val ntfyServer: String = "",
val threadsEnabled: Boolean = false,
val toolDetail: String = "truncated",
) )
init { init {
@@ -133,6 +135,20 @@ class DesktopSecureStore : SecureStore {
save(d.copy(ntfyServer = value)) save(d.copy(ntfyServer = value))
} }
override var threadsEnabled: Boolean
get() = load().threadsEnabled
set(value) {
val d = load()
save(d.copy(threadsEnabled = value))
}
override var toolDetail: String
get() = load().toolDetail.ifBlank { "truncated" }
set(value) {
val d = load()
save(d.copy(toolDetail = value))
}
override fun savePairing(url: String, token: String) { override fun savePairing(url: String, token: String) {
val d = load() val d = load()
save(d.copy(serverUrl = url.trim())) save(d.copy(serverUrl = url.trim()))