Skip to content

Commit 176994e

Browse files
committed
Add Android control detail parity
1 parent 9750695 commit 176994e

2 files changed

Lines changed: 264 additions & 11 deletions

File tree

android_dashboard/app/src/main/java/sh/sandboxed/dashboard/ui/control/ControlScreen.kt

Lines changed: 131 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import androidx.compose.ui.unit.sp
7272
import sh.sandboxed.dashboard.data.AppContainer
7373
import sh.sandboxed.dashboard.data.Backend
7474
import sh.sandboxed.dashboard.data.BackendAgent
75+
import sh.sandboxed.dashboard.data.BuiltinCommandsResponse
7576
import sh.sandboxed.dashboard.data.ChatMessage
7677
import sh.sandboxed.dashboard.data.ChatMessageKind
7778
import sh.sandboxed.dashboard.data.Mission
@@ -80,6 +81,7 @@ import sh.sandboxed.dashboard.data.Provider
8081
import sh.sandboxed.dashboard.data.QueuedMessage
8182
import sh.sandboxed.dashboard.data.RunningMissionInfo
8283
import sh.sandboxed.dashboard.data.SharedFile
84+
import sh.sandboxed.dashboard.data.SlashCommand
8385
import sh.sandboxed.dashboard.data.Workspace
8486
import sh.sandboxed.dashboard.ui.components.ErrorBanner
8587
import sh.sandboxed.dashboard.ui.components.GlassCard
@@ -101,6 +103,14 @@ fun ControlScreen(
101103
var showNewMission by remember { mutableStateOf(false) }
102104
var showMissionSwitcher by remember { mutableStateOf(false) }
103105
var showWorkers by remember { mutableStateOf(false) }
106+
val slashSuggestions = remember(state.draft, state.mission?.backend, state.slashCommands) {
107+
visibleSlashSuggestions(
108+
draft = state.draft,
109+
backend = state.mission?.backend,
110+
catalog = state.slashCommands,
111+
)
112+
}
113+
val slashPanelActive = isSlashPanelActive(state.draft)
104114

105115
LaunchedEffect(state.messages.size) {
106116
if (state.messages.isNotEmpty()) listState.animateScrollToItem(state.messages.lastIndex)
@@ -122,6 +132,8 @@ fun ControlScreen(
122132
canResume = state.mission?.let { it.status.canResume || it.resumable } == true,
123133
workerCount = state.childMissions.size,
124134
runningCount = state.parallel.size,
135+
runState = state.runState,
136+
progress = state.progress,
125137
onResume = { haptics.success(); vm.resume() },
126138
onAutomations = { state.mission?.id?.let(onOpenAutomations) },
127139
onNewMission = { showNewMission = true },
@@ -143,6 +155,16 @@ fun ControlScreen(
143155
) {
144156
items(state.messages, key = { it.id }) { msg -> MessageRow(msg) }
145157
}
158+
if (slashPanelActive && (slashSuggestions.isNotEmpty() || state.slashCommandsLoading)) {
159+
SlashSuggestions(
160+
commands = slashSuggestions,
161+
loading = state.slashCommandsLoading && slashSuggestions.isEmpty(),
162+
onSelect = {
163+
haptics.selection()
164+
vm.applySlashCommand(it)
165+
},
166+
)
167+
}
146168
Composer(
147169
value = state.draft,
148170
onChange = vm::setDraft,
@@ -220,6 +242,8 @@ private fun TopBar(
220242
canResume: Boolean,
221243
workerCount: Int,
222244
runningCount: Int,
245+
runState: ControlRunState,
246+
progress: ExecutionProgress?,
223247
onResume: () -> Unit,
224248
onAutomations: () -> Unit,
225249
onNewMission: () -> Unit,
@@ -231,9 +255,19 @@ private fun TopBar(
231255
Row(verticalAlignment = Alignment.CenterVertically) {
232256
Column(Modifier.weight(1f)) {
233257
Text(mission?.title ?: "New mission", style = MaterialTheme.typography.titleMedium, color = Palette.TextPrimary, maxLines = 1)
234-
Text(if (connected) "Connected" else "Reconnecting…",
235-
style = MaterialTheme.typography.bodySmall,
236-
color = if (connected) Palette.Success else Palette.Warning)
258+
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
259+
Text(
260+
if (connected) "Connected" else "Reconnecting…",
261+
style = MaterialTheme.typography.bodySmall,
262+
color = if (connected) Palette.Success else Palette.Warning,
263+
)
264+
Text("", color = Palette.TextTertiary, style = MaterialTheme.typography.bodySmall)
265+
Text(runState.label, color = runStateColor(runState), style = MaterialTheme.typography.bodySmall)
266+
progress?.takeIf { it.total > 0 }?.let {
267+
Text("", color = Palette.TextTertiary, style = MaterialTheme.typography.bodySmall)
268+
Text(it.displayText, color = Palette.Success, style = MaterialTheme.typography.bodySmall)
269+
}
270+
}
237271
}
238272
mission?.status?.let { StatusBadge(it) }
239273
if (canResume) {
@@ -270,6 +304,12 @@ private fun TopBar(
270304
}
271305
}
272306

307+
private fun runStateColor(runState: ControlRunState): Color = when (runState) {
308+
ControlRunState.IDLE -> Palette.TextSecondary
309+
ControlRunState.RUNNING -> Palette.Success
310+
ControlRunState.WAITING_FOR_TOOL -> Palette.Warning
311+
}
312+
273313
@Composable
274314
private fun Tag(text: String) {
275315
Text(
@@ -745,6 +785,94 @@ private fun LinearLoading() {
745785
}
746786
}
747787

788+
@Composable
789+
private fun SlashSuggestions(
790+
commands: List<SlashCommand>,
791+
loading: Boolean,
792+
onSelect: (SlashCommand) -> Unit,
793+
) {
794+
Column(
795+
modifier = Modifier
796+
.fillMaxWidth()
797+
.background(Palette.BackgroundSecondary)
798+
.padding(horizontal = 12.dp, vertical = 8.dp),
799+
verticalArrangement = Arrangement.spacedBy(6.dp),
800+
) {
801+
if (loading) {
802+
Row(
803+
modifier = Modifier
804+
.fillMaxWidth()
805+
.background(Palette.Card, RoundedCornerShape(10.dp))
806+
.border(1.dp, Palette.Border, RoundedCornerShape(10.dp))
807+
.padding(horizontal = 12.dp, vertical = 10.dp),
808+
verticalAlignment = Alignment.CenterVertically,
809+
) {
810+
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(14.dp), color = Palette.Accent)
811+
Spacer(Modifier.width(8.dp))
812+
Text("Loading commands…", color = Palette.TextSecondary, style = MaterialTheme.typography.bodySmall)
813+
}
814+
} else {
815+
commands.take(8).forEach { command ->
816+
Row(
817+
modifier = Modifier
818+
.fillMaxWidth()
819+
.background(Palette.Card, RoundedCornerShape(10.dp))
820+
.border(1.dp, Palette.Border, RoundedCornerShape(10.dp))
821+
.clickable { onSelect(command) }
822+
.padding(horizontal = 12.dp, vertical = 10.dp),
823+
verticalAlignment = Alignment.CenterVertically,
824+
) {
825+
Text("/${command.name}", color = Palette.AccentLight, style = MaterialTheme.typography.labelLarge, modifier = Modifier.widthIn(min = 92.dp))
826+
Column(Modifier.weight(1f)) {
827+
command.description?.takeIf { it.isNotBlank() }?.let {
828+
Text(it, color = Palette.TextSecondary, style = MaterialTheme.typography.bodySmall, maxLines = 2)
829+
}
830+
val hint = slashCommandHint(command)
831+
if (hint.isNotBlank()) {
832+
Text(hint, color = Palette.TextTertiary, style = MaterialTheme.typography.labelSmall)
833+
}
834+
}
835+
}
836+
}
837+
}
838+
}
839+
}
840+
841+
private fun isSlashPanelActive(draft: String): Boolean {
842+
val trimmed = draft.trim()
843+
if (!trimmed.startsWith("/")) return false
844+
return !trimmed.drop(1).any { it.isWhitespace() }
845+
}
846+
847+
private fun visibleSlashSuggestions(
848+
draft: String,
849+
backend: String?,
850+
catalog: BuiltinCommandsResponse?,
851+
): List<SlashCommand> {
852+
catalog ?: return emptyList()
853+
val trimmed = draft.trim()
854+
if (!trimmed.startsWith("/")) return emptyList()
855+
val fragment = trimmed.drop(1)
856+
if (fragment.any { it.isWhitespace() }) return emptyList()
857+
val pool = when (backend) {
858+
"codex" -> catalog.codex
859+
"claudecode" -> catalog.claudecode
860+
"opencode" -> catalog.opencode
861+
else -> catalog.opencode + catalog.claudecode + catalog.codex
862+
}
863+
return pool
864+
.filter { command ->
865+
fragment.isBlank() ||
866+
command.name.startsWith(fragment, ignoreCase = true)
867+
}
868+
.distinctBy { "${it.path}:${it.name}" }
869+
}
870+
871+
private fun slashCommandHint(command: SlashCommand): String =
872+
command.params.joinToString(" ") { param ->
873+
if (param.required) "<${param.name}>" else "[${param.name}]"
874+
}
875+
748876
@Composable
749877
private fun MessageRow(msg: ChatMessage) {
750878
when (val k = msg.kind) {

0 commit comments

Comments
 (0)