M6: desktop app (tray, two-pane layout, shortcuts, inspector, jpackage)

This commit is contained in:
ARIA committed 2026-08-19 20:43:47 +02:00
1 parent 2ecfe1c05c
commit 0cc8b7aafe
12 files changed
+1312 -109

No files matched your search

@@ -77,6 +77,10 @@ class GatewayClient(
private val _state = MutableStateFlow<State>(State.Disconnected)
val state: StateFlow<State> = _state.asStateFlow()
/** The paired gateway URL (M6 inspector pane). */
val serverUrl: String
get() = store.serverUrl
private val _events = MutableSharedFlow<Frame>(extraBufferCapacity = 128)
val events: SharedFlow<Frame> = _events.asSharedFlow()
@@ -0,0 +1,4 @@
package iris.platform
/** True on the desktop target (M6: big-screen layout, shortcuts, tray). */
expect val isDesktop: Boolean
@@ -2,6 +2,7 @@ package iris.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -25,6 +26,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
@@ -48,8 +50,18 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isCtrlPressed
import androidx.compose.ui.input.key.isMetaPressed
import androidx.compose.ui.input.key.key
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.LocalFocusManager
@@ -67,11 +79,13 @@ import iris.data.ToolItem
import iris.net.GatewayClient
import iris.platform.MediaFilePicker
import iris.platform.MediaPlayerView
import iris.platform.isDesktop
import iris.platform.loadMediaImage
import iris.platform.openDocument
import iris.protocol.ChannelInfo
import iris.protocol.KIND_DOCUMENT
import iris.protocol.KIND_IMAGE
import iris.protocol.ROLE_ASSISTANT
import iris.protocol.ROLE_USER
import iris.protocol.SearchHit
import iris.state.IrisController
@@ -110,6 +124,15 @@ fun ChatScreen(controller: IrisController) {
val drawerScope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
// M6 (desktop): two-pane layout, command palette, inspector, shortcuts.
var showPalette by remember { mutableStateOf(false) }
var showInspector by remember { mutableStateOf(false) }
val composerFocusRequester = remember { FocusRequester() }
var composerFocused by remember { mutableStateOf(false) }
val railFocusRequester = remember { FocusRequester() }
var railIndex by remember { mutableStateOf(0) }
val nonThreadChannels = channels.filter { it.kind != "thread" }
fun doSend() {
val ready = attachments.filter { it.mediaRef != null && it.error == null }
if (input.isBlank() && ready.isEmpty()) return
@@ -125,28 +148,46 @@ fun ChatScreen(controller: IrisController) {
if (last >= 0) listState.animateScrollToItem(last)
}
ModalNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = drawerState.isOpen,
drawerContent = {
ModalDrawerSheet(modifier = Modifier.width(280.dp)) {
ChannelDrawer(
channels = channels.filter { it.kind != "thread" },
currentChatId = currentChatId,
onOpen = { ch ->
controller.openChannel(ch.chatId)
drawerScope.launch { drawerState.close() }
},
onNewChannel = {
showNewChannel = true
drawerScope.launch { drawerState.close() }
},
onSetDefault = { ch -> controller.setDefaultChannel(ch.chatId) },
onDelete = { ch -> controller.deleteChannel(ch.chatId) },
)
// M6: keep the rail selection in sync with the current channel.
LaunchedEffect(currentChatId, nonThreadChannels) {
val idx = nonThreadChannels.indexOfFirst { it.chatId == currentChatId }
if (idx >= 0) railIndex = idx
}
// M6: keyboard shortcuts (docs/11 §11.3) — desktop only.
val shortcutsModifier = if (isDesktop) {
Modifier.onPreviewKeyEvent { e ->
if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
val ctrlOrMeta = e.isCtrlPressed || e.isMetaPressed
val overlayOpen =
showSearch || showPicker || showPalette || showNewChannel || showNewThread
when {
ctrlOrMeta && e.key == Key.N -> { showNewChannel = true; true }
ctrlOrMeta && e.key == Key.T -> { showNewThread = true; true }
ctrlOrMeta && e.key == Key.F -> { showSearch = true; true }
ctrlOrMeta && e.key == Key.K -> { showPalette = true; true }
e.key == Key.Escape && overlayOpen -> {
showSearch = false
showPicker = false
showPalette = false
showNewChannel = false
showNewThread = false
true
}
e.key == Key.Slash && !composerFocused && !overlayOpen -> {
composerFocusRequester.requestFocus()
true
}
else -> false
}
},
) {
}
} else {
Modifier
}
// M6: the chat column (header + list + composer), shared by both layouts.
@Composable
fun ChatContent(showHamburger: Boolean, onOpenDrawer: () -> Unit) {
Column(modifier = Modifier.fillMaxSize()) {
// Header
Row(
@@ -155,8 +196,10 @@ fun ChatScreen(controller: IrisController) {
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = { drawerScope.launch { drawerState.open() } }) {
Text("☰", fontSize = 18.sp)
if (showHamburger) {
IconButton(onClick = onOpenDrawer) {
Text("☰", fontSize = 18.sp)
}
}
Text(
currentChannel?.name ?: "Iris",
@@ -175,6 +218,20 @@ fun ChatScreen(controller: IrisController) {
TextButton(onClick = { controller.cycleToolDetail() }) {
Text("tools: ${toolDetail.label}", fontSize = 11.sp)
}
if (isDesktop) {
IconButton(onClick = { showInspector = !showInspector }) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(
if (showInspector) Color(0xFF2A3550) else Color.Transparent,
)
.padding(2.dp),
) {
Text("ⓘ", fontSize = 16.sp)
}
}
}
}
// Topic switcher (threads on)
@@ -276,7 +333,9 @@ fun ChatScreen(controller: IrisController) {
onValueChange = { input = it },
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp, max = 160.dp),
.heightIn(min = 48.dp, max = 160.dp)
.focusRequester(composerFocusRequester)
.onFocusChanged { composerFocused = it.isFocused },
placeholder = { Text("Message") },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { doSend() }),
@@ -292,6 +351,71 @@ fun ChatScreen(controller: IrisController) {
}
}
Box(modifier = Modifier.fillMaxSize().then(shortcutsModifier)) {
if (isDesktop) {
// M6: two-pane default (docs/11 §11.3): persistent channel rail +
// chat, optional inspector third pane.
Row(modifier = Modifier.fillMaxSize()) {
ChannelRail(
channels = nonThreadChannels,
currentChatId = currentChatId,
selectedIndex = railIndex,
onSelectedIndex = { railIndex = it },
focusRequester = railFocusRequester,
onOpen = { ch -> controller.openChannel(ch.chatId) },
onNewChannel = { showNewChannel = true },
onSetDefault = { ch -> controller.setDefaultChannel(ch.chatId) },
onDelete = { ch -> controller.deleteChannel(ch.chatId) },
)
Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFF2A2E3B)))
Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
ChatContent(showHamburger = false, onOpenDrawer = {})
}
if (showInspector) {
Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFF2A2E3B)))
InspectorPane(
controller = controller,
state = state,
currentChannel = currentChannel,
threads = threads,
items = items,
toolDetail = toolDetail,
threadsEnabled = threadsEnabled,
)
}
}
} else {
ModalNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = drawerState.isOpen,
drawerContent = {
ModalDrawerSheet(modifier = Modifier.width(280.dp)) {
ChannelDrawer(
channels = nonThreadChannels,
currentChatId = currentChatId,
onOpen = { ch ->
controller.openChannel(ch.chatId)
drawerScope.launch { drawerState.close() }
},
onNewChannel = {
showNewChannel = true
drawerScope.launch { drawerState.close() }
},
onSetDefault = { ch -> controller.setDefaultChannel(ch.chatId) },
onDelete = { ch -> controller.deleteChannel(ch.chatId) },
)
}
},
) {
ChatContent(
showHamburger = true,
onOpenDrawer = { drawerScope.launch { drawerState.open() } },
)
}
}
}
// File picker overlay (M4)
if (showPicker) {
Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.5f))) {
@@ -306,6 +430,8 @@ fun ChatScreen(controller: IrisController) {
if (showSearch) {
SearchOverlay(
controller = controller,
currentChatId = currentChatId,
currentThreadId = currentThreadId,
onJump = { hit ->
if (hit.threadId.isNullOrBlank()) controller.openChannel(hit.chatId)
else controller.openThread(hit.chatId, hit.threadId)
@@ -338,6 +464,32 @@ fun ChatScreen(controller: IrisController) {
onDismiss = { showNewThread = false },
)
}
// M6: command palette (Ctrl/Cmd+K)
if (showPalette) {
CommandPalette(
actions = listOf(
"new-channel" to "New channel (Ctrl+N)",
"new-thread" to "New topic (Ctrl+T)",
"search" to "Search (Ctrl+F)",
"toggle-threads" to "Toggle threads",
"cycle-tool-detail" to "Cycle tool verbosity",
"toggle-inspector" to "Toggle inspector",
),
onAction = { id ->
when (id) {
"new-channel" -> showNewChannel = true
"new-thread" -> showNewThread = true
"search" -> showSearch = true
"toggle-threads" -> controller.toggleThreads()
"cycle-tool-detail" -> controller.cycleToolDetail()
"toggle-inspector" -> showInspector = !showInspector
}
showPalette = false
},
onDismiss = { showPalette = false },
)
}
}
/** Left drawer: channel list + create / set-default / delete. */
@@ -393,6 +545,210 @@ private fun ChannelDrawer(
}
}
/**
* M6: persistent channel rail (two-pane default, docs/11 §11.3). Keyboard:
* ↑/↓ move the selection, Enter opens (when the rail has focus).
*/
@Composable
private fun ChannelRail(
channels: List<ChannelInfo>,
currentChatId: String,
selectedIndex: Int,
onSelectedIndex: (Int) -> Unit,
focusRequester: FocusRequester,
onOpen: (ChannelInfo) -> Unit,
onNewChannel: () -> Unit,
onSetDefault: (ChannelInfo) -> Unit,
onDelete: (ChannelInfo) -> Unit,
) {
Column(
modifier = Modifier
.width(280.dp)
.fillMaxHeight()
.focusRequester(focusRequester)
.focusable()
.onPreviewKeyEvent { e ->
if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (e.key) {
Key.DirectionUp -> {
if (channels.isNotEmpty()) {
onSelectedIndex((selectedIndex - 1 + channels.size) % channels.size)
}
true
}
Key.DirectionDown -> {
if (channels.isNotEmpty()) onSelectedIndex((selectedIndex + 1) % channels.size)
true
}
Key.Enter -> {
channels.getOrNull(selectedIndex)?.let { onOpen(it) }
true
}
else -> false
}
}
.padding(12.dp),
) {
Text(
"Channels",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp),
)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.weight(1f),
) {
items(channels, key = { it.chatId }) { ch ->
val isCurrent = ch.chatId == currentChatId
val isSelected = ch.chatId == channels.getOrNull(selectedIndex)?.chatId
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(
when {
isCurrent -> Color(0xFF2A3550)
isSelected -> Color(0xFF232838)
else -> Color.Transparent
},
)
.clickable { onOpen(ch) }
.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
ch.name + if (ch.isDefault) " ★" else "",
fontSize = 14.sp,
modifier = Modifier.weight(1f),
)
if (!ch.isDefault) {
TextButton(onClick = { onSetDefault(ch) }) {
Text("★", fontSize = 12.sp)
}
TextButton(onClick = { onDelete(ch) }) {
Text("✕", fontSize = 12.sp)
}
}
}
}
}
Button(onClick = onNewChannel, modifier = Modifier.fillMaxWidth()) {
Text("+ New channel")
}
}
}
/** M6: optional third pane — session info, channel info, last reply, settings. */
@Composable
private fun InspectorPane(
controller: IrisController,
state: GatewayClient.State,
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
} as? MessageItem
Column(
modifier = Modifier
.width(300.dp)
.fillMaxHeight()
.verticalScroll(rememberScrollState())
.padding(12.dp),
) {
Text(
"Inspector",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp),
)
Text("Connection", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(bottom = 4.dp))
InfoRow("Status", statusLabel(state))
InfoRow("Server", controller.client.serverUrl.ifBlank { "— (not paired)" })
Spacer(modifier = Modifier.height(8.dp))
Text("Channel", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(bottom = 4.dp))
InfoRow("Name", currentChannel?.name ?: "—")
InfoRow("ID", currentChannel?.chatId ?: "—")
InfoRow("Default", if (currentChannel?.isDefault == true) "yes" else "no")
InfoRow("Threads", threads.size.toString())
Spacer(modifier = Modifier.height(8.dp))
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")
}
}
}
}
@Composable
private fun InfoRow(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Text(label, fontSize = 12.sp, color = Color(0xFF8A93A6), modifier = Modifier.width(70.dp))
Text(value, fontSize = 12.sp, modifier = Modifier.weight(1f))
}
}
private fun statusLabel(state: GatewayClient.State): String = when (state) {
GatewayClient.State.Disconnected -> "offline"
GatewayClient.State.Connecting -> "connecting…"
GatewayClient.State.Reconnecting -> "reconnecting…"
is GatewayClient.State.Connected -> "connected"
is GatewayClient.State.AuthFailed -> "auth failed"
}
/** M6: command palette (Ctrl/Cmd+K) — all actions, filterable. */
@Composable
private fun CommandPalette(
actions: List<Pair<String, String>>,
onAction: (String) -> Unit,
onDismiss: () -> Unit,
) {
var query by remember { mutableStateOf("") }
val filtered = actions.filter { it.second.contains(query, ignoreCase = true) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Command palette") },
text = {
Column {
OutlinedTextField(
value = query,
onValueChange = { query = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Type a command…") },
)
Spacer(modifier = Modifier.height(8.dp))
if (filtered.isEmpty()) {
Text("No matching commands.", fontSize = 12.sp, color = Color(0xFF8A93A6))
} else {
filtered.forEach { (id, label) ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(6.dp))
.clickable { onAction(id) }
.padding(vertical = 6.dp, horizontal = 4.dp),
) {
Text(label, fontSize = 13.sp)
}
}
}
}
},
confirmButton = {},
dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } },
)
}
/** Horizontal topic chips: General + threads + "new topic". */
@Composable
private fun TopicSwitcher(
@@ -430,10 +786,12 @@ private fun TopicChip(label: String, selected: Boolean, onClick: () -> Unit) {
}
}
/** Full-screen search overlay (M3). */
/** Full-screen search overlay (M3; M6: all / this-chat scope toggle). */
@Composable
private fun SearchOverlay(
controller: IrisController,
currentChatId: String,
currentThreadId: String?,
onJump: (SearchHit) -> Unit,
onDismiss: () -> Unit,
) {
@@ -441,6 +799,15 @@ private fun SearchOverlay(
val searching by controller.searching.collectAsState()
val lastQuery by controller.lastQuery.collectAsState()
var query by remember { mutableStateOf(lastQuery) }
var scope by remember { mutableStateOf("all") }
fun doSearch() {
if (scope == "chat") {
controller.search(query, scope = "chat", chatId = currentChatId, threadId = currentThreadId)
} else {
controller.search(query)
}
}
Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.5f))) {
Column(
@@ -460,13 +827,26 @@ private fun SearchOverlay(
modifier = Modifier.weight(1f),
placeholder = { Text("Search messages…") },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { controller.search(query) }),
keyboardActions = KeyboardActions(onSearch = { doSearch() }),
)
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = { controller.search(query) }, enabled = query.isNotBlank() && !searching) {
Button(onClick = { doSearch() }, enabled = query.isNotBlank() && !searching) {
Text(if (searching) "…" else "search")
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(top = 4.dp),
) {
Text("Scope:", fontSize = 12.sp, color = Color(0xFF8A93A6))
TextButton(onClick = { scope = "all" }) {
Text(if (scope == "all") "● all" else "○ all", fontSize = 12.sp)
}
TextButton(onClick = { scope = "chat" }) {
Text(if (scope == "chat") "● this chat" else "○ this chat", fontSize = 12.sp)
}
}
Spacer(modifier = Modifier.height(12.dp))
if (results.isEmpty() && !searching) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {