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()
|
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()))
|
||||||
|
|||||||
File renamed without changes.
Reference in new issue
Block a user