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 1d67de5f12
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`.
@@ -97,6 +97,14 @@ class AndroidSecureStore(context: Context) : SecureStore {
get() = prefs.getString(KEY_NTFY_SERVER, "").orEmpty()
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) {
serverUrl = url
this.token = token
@@ -116,5 +124,7 @@ class AndroidSecureStore(context: Context) : SecureStore {
const val KEY_FCM_TOKEN = "fcm_token"
const val KEY_NTFY_TOPIC = "ntfy_topic"
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). */
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 clear()
}
@@ -80,8 +80,9 @@ class IrisController(
private val _typing = MutableStateFlow(false)
val typing: StateFlow<Boolean> = _typing.asStateFlow()
/** Tool-card verbosity (M2). NOTHING suppresses tool cards entirely. */
private val _toolDetail = MutableStateFlow(ToolDetail.TRUNCATED)
/** Tool-card verbosity (M2). NOTHING suppresses tool cards entirely.
* Persisted (Settings → "Tool detail"). */
private val _toolDetail = MutableStateFlow(ToolDetail.fromName(store.toolDetail))
val toolDetail: StateFlow<ToolDetail> = _toolDetail.asStateFlow()
fun cycleToolDetail() {
@@ -90,6 +91,13 @@ class IrisController(
ToolDetail.TRUNCATED -> ToolDetail.NOTHING
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). */
@@ -101,11 +109,13 @@ class IrisController(
val gatewayStatus: StateFlow<String?> = _gatewayStatus.asStateFlow()
// ── 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()
fun toggleThreads() {
_threadsEnabled.value = !_threadsEnabled.value
store.threadsEnabled = _threadsEnabled.value
}
// ── M3: search state ──────────────────────────────────────────────────
@@ -487,4 +497,11 @@ enum class ToolDetail {
EVERYTHING, // name + full args (collapsible) + output preview
TRUNCATED, // one-liner "emoji name: preview", collapsible (default)
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.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -49,6 +50,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
@@ -107,7 +110,7 @@ import iris.util.localDayKey
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
* 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 showNewThread by remember { mutableStateOf(false) }
var showPicker by remember { mutableStateOf(false) }
var showSettings by remember { mutableStateOf(false) }
val attachments by controller.attachments.collectAsState()
val banners by controller.banners.collectAsState()
val drawerState = rememberDrawerState(DrawerValue.Closed)
@@ -174,7 +178,7 @@ fun ChatScreen(controller: IrisController) {
if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
val ctrlOrMeta = e.isCtrlPressed || e.isMetaPressed
val overlayOpen =
showSearch || showPicker || showPalette || showNewChannel || showNewThread
showSearch || showPicker || showPalette || showNewChannel || showNewThread || showSettings
when {
ctrlOrMeta && e.key == Key.N -> { showNewChannel = true; true }
ctrlOrMeta && e.key == Key.T -> { showNewThread = true; true }
@@ -186,6 +190,7 @@ fun ChatScreen(controller: IrisController) {
showPalette = false
showNewChannel = false
showNewThread = false
showSettings = false
true
}
e.key == Key.Slash && !composerFocused && !overlayOpen -> {
@@ -231,12 +236,6 @@ fun ChatScreen(controller: IrisController) {
HeaderIconButton(onClick = { showSearch = true }) {
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) {
IconButton(onClick = { showInspector = !showInspector }) {
Box(
@@ -290,12 +289,6 @@ fun ChatScreen(controller: IrisController) {
HeaderIconButton(onClick = { showSearch = true }) {
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) {
IconButton(onClick = { showInspector = !showInspector }) {
Box(
@@ -438,6 +431,8 @@ fun ChatScreen(controller: IrisController) {
// Composer (M7: rounded pill + accent circular send button)
val canSend = input.isNotBlank() || attachments.any { it.mediaRef != null && it.error == null }
val layoutDensity = LocalDensity.current.density
var textHeightPx by remember { mutableFloatStateOf(0f) }
Row(
modifier = Modifier
.fillMaxWidth()
@@ -447,15 +442,32 @@ fun ChatScreen(controller: IrisController) {
.padding(horizontal = 6.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = { showPicker = true }) {
Text("📎", fontSize = 18.sp)
}
Box(
Row(
modifier = Modifier
.weight(1f)
.heightIn(min = 40.dp, max = 160.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()) {
Text(
"Message",
@@ -464,17 +476,10 @@ fun ChatScreen(controller: IrisController) {
modifier = Modifier.align(Alignment.TopStart),
)
}
BasicTextField(
value = input,
onValueChange = { input = it },
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() }),
)
}
IconButton(onClick = { showPicker = true }) {
Text("📎", fontSize = 18.sp)
}
}
Box(
modifier = Modifier
@@ -537,6 +542,7 @@ fun ChatScreen(controller: IrisController) {
onNewChannel = { showNewChannel = true },
onSetDefault = { ch -> controller.setDefaultChannel(ch.chatId) },
onDelete = { ch -> controller.deleteChannel(ch.chatId) },
onOpenSettings = { showSettings = true },
)
Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(IrisColors.divider))
Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
@@ -550,8 +556,6 @@ fun ChatScreen(controller: IrisController) {
currentChannel = currentChannel,
threads = threads,
items = items,
toolDetail = toolDetail,
threadsEnabled = threadsEnabled,
)
}
}
@@ -574,6 +578,10 @@ fun ChatScreen(controller: IrisController) {
},
onSetDefault = { ch -> controller.setDefaultChannel(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
if (showNewChannel) {
NameDialog(
@@ -741,7 +754,7 @@ private fun TitlePill(channelName: String, modifier: Modifier = Modifier) {
/** M7: compact 36dp header icon button (smaller than the 48dp default). */
@Composable
private fun HeaderIconButton(onClick: () -> Unit, content: @Composable () -> Unit) {
internal fun HeaderIconButton(onClick: () -> Unit, content: @Composable () -> Unit) {
Box(
modifier = Modifier
.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
private fun ChannelDrawer(
channels: List<ChannelInfo>,
@@ -813,6 +826,7 @@ private fun ChannelDrawer(
onNewChannel: () -> Unit,
onSetDefault: (ChannelInfo) -> Unit,
onDelete: (ChannelInfo) -> Unit,
onOpenSettings: () -> Unit,
) {
Column(modifier = Modifier.fillMaxSize().padding(12.dp)) {
Text(
@@ -858,6 +872,10 @@ private fun ChannelDrawer(
Button(onClick = onNewChannel, modifier = Modifier.fillMaxWidth()) {
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,
onSetDefault: (ChannelInfo) -> Unit,
onDelete: (ChannelInfo) -> Unit,
onOpenSettings: () -> Unit,
) {
Column(
modifier = Modifier
@@ -965,10 +984,14 @@ private fun ChannelRail(
Button(onClick = onNewChannel, modifier = Modifier.fillMaxWidth()) {
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
private fun InspectorPane(
controller: IrisController,
@@ -976,8 +999,6 @@ private fun InspectorPane(
currentChannel: ChannelInfo?,
threads: List<ChannelInfo>,
items: List<ChatItem>,
toolDetail: ToolDetail,
threadsEnabled: Boolean,
) {
val lastAssistant = items.lastOrNull {
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))
InfoRow("Model", lastAssistant?.model ?: "—")
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
private fun TopicChip(label: String, selected: Boolean, onClick: () -> Unit) {
internal fun TopicChip(label: String, selected: Boolean, onClick: () -> Unit) {
Box(
modifier = Modifier
.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) {
ToolDetail.EVERYTHING -> "everything"
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 ntfyTopic: String = "",
val ntfyServer: String = "",
val threadsEnabled: Boolean = false,
val toolDetail: String = "truncated",
)
init {
@@ -133,6 +135,20 @@ class DesktopSecureStore : SecureStore {
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) {
val d = load()
save(d.copy(serverUrl = url.trim()))
File renamed without changes.