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:
1 parent
bf6bf7e8bd
commit
1d67de5f12
8 files changed
+300
-52
No files matched your search
@@ -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.
Reference in new issue
Block a user