feat(agent): the agent in the session drawer and as a bubble over the game

This commit is contained in:
Kurt Himebauch committed 2026-11-10 00:40:35 -05:00
1 parent 71481ac2ff
commit 2d15205ff7
7 files changed
+118 -4

No files matched your search

@@ -43,6 +43,7 @@ class App : Application() {
process == "$packageName:shortcuts_sync" || process == "$packageName:bottom_desktop") return
CrashHandler.install(this)
com.droiddeck.launcher.lab.LabNotice.started(this)
com.droiddeck.launcher.agent.AgentHost.refresh(this)
com.droiddeck.launcher.core.SteamIntegration.syncShortcutsListing(this)
com.droiddeck.launcher.session.QuestExperimental.initialize(this)
com.droiddeck.launcher.session.SessionPrefs.applyNtsyncDefault(this)
@@ -1148,6 +1148,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onAndroidKeyboard = { pcKeyboardOpen = false; keyboard?.toggle() },
onClose = { pcKeyboardOpen = false },
)
if (!drawerOpen && !pcKeyboardOpen) com.droiddeck.launcher.ui.AgentBubble {
drawerPage = SessionDrawerPage.AGENT
drawerOpen = true
}
if (!drawerDocked) SessionMenu(docked = false)
LaunchedEffect(achievementsOpen) {
if (achievementsOpen) {
@@ -42,6 +42,15 @@ object AgentHost {
private set
var installed by mutableStateOf(false)
private set
/** A reply finished since the conversation was last on screen. */
var unseen by mutableStateOf(false)
private set
var failed by mutableStateOf(false)
private set
fun seen() {
unseen = false
}
private val main = Handler(Looper.getMainLooper())
private var process: Process? = null
@@ -150,6 +159,8 @@ object AgentHost {
private fun finish(app: Context, error: String?) {
record(app, Entry.Finished(error))
state = State.IDLE
unseen = true
failed = error != null
AgentHostService.stop(app)
}
@@ -34,6 +34,15 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.animation.core.animateFloat
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.testTag
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.offset
import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
@@ -45,9 +54,10 @@ import com.droiddeck.launcher.agent.AgentHost
import com.droiddeck.launcher.agent.AgentHost.Entry
@Composable
internal fun AgentPage(modifier: Modifier) {
internal fun AgentPage(modifier: Modifier, inputModifier: Modifier = Modifier) {
val context = LocalContext.current
remember { AgentHost.refresh(context) }
LaunchedEffect(AgentHost.unseen) { AgentHost.seen() }
if (AgentHost.state == AgentHost.State.MISSING) {
Column(modifier) { Note(stringResource(R.string.agent_missing)) }
return
@@ -79,7 +89,7 @@ internal fun AgentPage(modifier: Modifier) {
Icon(Icons.Outlined.Add, stringResource(R.string.agent_new))
}
OutlinedTextField(
draft, { draft = it }, Modifier.weight(1f),
draft, { draft = it }, Modifier.weight(1f).then(inputModifier),
placeholder = { Text(stringResource(R.string.agent_hint)) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { send() }),
@@ -111,3 +121,73 @@ private fun AgentEntry(entry: Entry) {
}
}
}
private object BubblePlace {
var x by mutableStateOf(Float.NaN)
var y by mutableStateOf(Float.NaN)
var hidden by mutableStateOf(false)
}
/**
* The agent's bubble over a session: there once the agent has been used, pulsing while it works,
* marked when a reply or failure has not been seen. Drag it to either edge; a long press hides it
* until the next session.
*/
@Composable
internal fun AgentBubble(onOpen: () -> Unit) {
if (!AgentHost.installed || BubblePlace.hidden || AgentHost.entries.isEmpty()) return
val colors = MaterialTheme.colorScheme
val density = androidx.compose.ui.platform.LocalDensity.current
val size = 56.dp
val sizePx = with(density) { size.toPx() }
val margin = with(density) { 12.dp.toPx() }
val working = AgentHost.state == AgentHost.State.WORKING
val pulse = androidx.compose.animation.core.rememberInfiniteTransition(label = "agentPulse")
val ring by pulse.animateFloat(
0.35f, 1f,
androidx.compose.animation.core.infiniteRepeatable(androidx.compose.animation.core.tween(900), androidx.compose.animation.core.RepeatMode.Reverse),
label = "agentRing",
)
androidx.compose.foundation.layout.BoxWithConstraints(Modifier.fillMaxSize()) {
val width = constraints.maxWidth.toFloat()
val height = constraints.maxHeight.toFloat()
if (BubblePlace.x.isNaN()) {
BubblePlace.x = width - sizePx - margin
BubblePlace.y = height * 0.3f
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.offset { androidx.compose.ui.unit.IntOffset(BubblePlace.x.toInt(), BubblePlace.y.toInt()) }
.size(size)
.graphicsLayer { alpha = if (working || AgentHost.unseen) 1f else 0.7f }
.clip(androidx.compose.foundation.shape.CircleShape)
.background(colors.surface)
.border(
2.dp,
if (working) colors.primary.copy(alpha = ring) else if (AgentHost.failed && AgentHost.unseen) colors.error else colors.outlineVariant,
androidx.compose.foundation.shape.CircleShape,
)
.pointerInput(width, height) {
detectDragGestures(onDragEnd = {
BubblePlace.x = if (BubblePlace.x + sizePx / 2 < width / 2) margin else width - sizePx - margin
}) { change, drag ->
change.consume()
BubblePlace.x = (BubblePlace.x + drag.x).coerceIn(0f, width - sizePx)
BubblePlace.y = (BubblePlace.y + drag.y).coerceIn(0f, height - sizePx)
}
}
.pointerInput(Unit) {
detectTapGestures(onTap = { onOpen() }, onLongPress = { BubblePlace.hidden = true })
}
.testTag("agent-bubble"),
) {
Icon(androidx.compose.material.icons.Icons.Outlined.AutoAwesome, stringResource(R.string.rail_agent), tint = colors.primary)
if (AgentHost.unseen && !working) Box(
Modifier.align(Alignment.TopEnd).padding(6.dp).size(10.dp)
.clip(androidx.compose.foundation.shape.CircleShape)
.background(if (AgentHost.failed) colors.error else colors.primary),
)
}
}
}
@@ -9,12 +9,16 @@ internal enum class SessionDrawerPage(val firstControl: String) {
EFFECTS("look"),
VR("steamvr"),
GAMES("anisotropy"),
AGENT("agent-input"),
SESSION("suspend"),
}
internal fun sessionDrawerPages(hasSecondScreen: Boolean, hasGlasses: Boolean = false): List<SessionDrawerPage> =
internal fun sessionDrawerPages(
hasSecondScreen: Boolean, hasGlasses: Boolean = false, hasAgent: Boolean = com.droiddeck.launcher.agent.AgentHost.installed,
): List<SessionDrawerPage> =
SessionDrawerPage.entries.filter {
(hasSecondScreen || it != SessionDrawerPage.SECOND_SCREEN) && (hasGlasses || it != SessionDrawerPage.GLASSES)
(hasSecondScreen || it != SessionDrawerPage.SECOND_SCREEN) && (hasGlasses || it != SessionDrawerPage.GLASSES) &&
(hasAgent || it != SessionDrawerPage.AGENT)
}
internal fun List<SessionDrawerPage>.step(current: SessionDrawerPage, direction: Int): SessionDrawerPage {
@@ -43,6 +43,7 @@ import androidx.compose.material.icons.outlined.PowerSettingsNew
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.material.icons.outlined.ScreenShare
import androidx.compose.material.icons.outlined.Pause
import androidx.compose.material.icons.outlined.PictureInPictureAlt
@@ -172,6 +173,7 @@ private val SessionDrawerPage.title: Int get() = when (this) {
SessionDrawerPage.VR -> R.string.vr_title
SessionDrawerPage.EFFECTS -> R.string.drawer_page_effects
SessionDrawerPage.GAMES -> R.string.game_settings_title
SessionDrawerPage.AGENT -> R.string.rail_agent
SessionDrawerPage.SESSION -> R.string.mode_session
}
@@ -184,6 +186,7 @@ private val SessionDrawerPage.icon get() = when (this) {
SessionDrawerPage.VR -> VrHeadsetIcon
SessionDrawerPage.EFFECTS -> Icons.Outlined.AutoFixHigh
SessionDrawerPage.GAMES -> Icons.Outlined.Layers
SessionDrawerPage.AGENT -> Icons.Outlined.AutoAwesome
SessionDrawerPage.SESSION -> Icons.Outlined.Settings
}
@@ -762,6 +765,10 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
track = { key -> focus.track(page, key) },
)
}
SessionDrawerPage.AGENT -> AgentPage(
Modifier.fillMaxWidth().height(460.dp).padding(horizontal = 14.dp),
inputModifier = focus.track(page, "agent-input"),
)
SessionDrawerPage.SESSION -> {
if (a.isHomeApp) SettingsGroup(stringResource(R.string.drawer_android_apps)) {
SettingsRow(stringResource(R.string.drawer_launch_app), null) {
@@ -26,4 +26,11 @@ class SessionDrawerPageTest {
assertEquals(SessionDrawerPage.SESSION, pages.step(SessionDrawerPage.GAMES, 1))
}
}
@Test fun theAgentPageComesBeforeSessionOnlyWhenTheAgentIsInstalled() {
val with = sessionDrawerPages(false, hasAgent = true)
assertEquals(SessionDrawerPage.AGENT, with.step(SessionDrawerPage.GAMES, 1))
assertEquals(SessionDrawerPage.SESSION, with.step(SessionDrawerPage.AGENT, 1))
assertFalse(SessionDrawerPage.AGENT in sessionDrawerPages(false, hasAgent = false))
}
}