M6: desktop app (tray, two-pane layout, shortcuts, inspector, jpackage)
This commit is contained in:
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) {
|
||||
|
||||
Reference in new issue
Block a user