Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion android/samples/mobile-2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,35 @@ An Android Jetpack Compose chat client that connects to a TypeAgent agent-server
- TypeAgent agent-server RPC protocol:
- `joinConversation` / `submitCommand`
- Inbound `appendDisplay`, `setDisplay`, `setDisplayInfo`, and command completion events
- Incremental assistant response streaming into a single bubble per `requestId`
- Inbound `takeAction` client actions
- Incremental assistant response streaming into a single bubble per `requestId`, honouring
the SDK's `DisplayAppendMode` (`inline`, `block`, `temporary`, `step`) and
`DisplayMessageKind` styling the same way the Electron shell does
- DevTunnel authentication via `X-Tunnel-Authorization` header
- Build-time configuration via environment variables and `BuildConfig`

## Device actions

The app implements the `takeAction` client actions emitted by the `androidMobile`
agent. Unknown actions are logged and ignored, so the app stays compatible with
servers that emit actions this sample does not support.

| Client action | Android intent | Notes |
|---|---|---|
| `set-alarm` | `AlarmClock.ACTION_SET_ALARM` | Opens the clock app so the user can confirm the alarm. |
| `set-timer` | `AlarmClock.ACTION_SET_TIMER` | Starts the countdown in the background (`EXTRA_SKIP_UI = true`) and confirms with a toast, so a chat request never yanks the user out of the conversation. Durations outside the documented 1..86400 second range are rejected rather than clamped. |

Both actions require the app to be in the foreground: Android 10+ silently refuses
background activity starts (no exception is thrown), so the app checks its own
lifecycle state first and reports a failure rather than a false confirmation.

Both require the `com.android.alarm.permission.SET_ALARM` permission (declared in
the manifest, install-time only) and matching `<queries>` entries so
`resolveActivity` works under Android 11+ package visibility rules.

> `set-timer` requires the server-side `androidMobile` `setTimer` action. Install
> the agent with `@package install androidMobile` in the TypeAgent CLI/shell.

## Prerequisites

- Android Studio (recent stable version)
Expand Down
3 changes: 3 additions & 0 deletions android/samples/mobile-2/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
<intent>
<action android:name="android.intent.action.SET_ALARM" />
</intent>
<intent>
<action android:name="android.intent.action.SET_TIMER" />
</intent>
</queries>

<uses-permission android:name="android.permission.INTERNET" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package com.example.typeagentchat

/**
* Mirrors TypeAgent's `DisplayAppendMode` from `@typeagent/agent-sdk`.
*
* `REPLACE` stands in for the `undefined` append mode the shell uses when
* `ClientIO.setDisplay` is called: it clears everything already rendered for
* the request before rendering the new content.
*/
internal enum class DisplayAppendMode {
REPLACE,
INLINE,
BLOCK,
TEMPORARY,
STEP
}

internal fun parseDisplayAppendMode(raw: String?): DisplayAppendMode {
return when (raw?.trim()?.lowercase()) {
"inline" -> DisplayAppendMode.INLINE
"temporary" -> DisplayAppendMode.TEMPORARY
"step" -> DisplayAppendMode.STEP
else -> DisplayAppendMode.BLOCK
}
}

/**
* `AgentMessageKind` values that the shell renders outside of the agent bubble.
*/
internal fun isEphemeralAgentMessageKind(kind: String?): Boolean {
return when (kind?.trim()?.lowercase()) {
"toast", "inline" -> true
else -> false
}
}

internal data class RenderedAgentMessage(
val segments: List<MessageSegment>
) {
val text: String
get() = segments.joinToString("\n\n") { it.text }

val format: MessageFormat
get() = segments.fold(MessageFormat.TEXT) { acc, segment ->
acc.mergeWith(segment.format)
}

val isEmpty: Boolean
get() = segments.isEmpty()
}

/**
* Kotlin port of the shell's `AgentMessageContainer.setMessage` accumulation
* rules (ts/packages/chat-ui/src/chatPanel.ts). Content for one request is kept
* as an ordered list of chunks so that a trailing `temporary` chunk can be
* discarded when the next display update arrives - which is how the shell ends
* up showing only "Created list: grocery" instead of every progress update.
*/
internal class AgentDisplayThread {

private class Chunk(
var text: String,
var format: MessageFormat,
val kind: MessageKind
)

private val chunks = mutableListOf<Chunk>()
private var lastMode: DisplayAppendMode? = null
private var hasTemporaryTail = false

val isEmpty: Boolean
get() = chunks.isEmpty()

fun setMessage(content: ParsedDisplayContent, mode: DisplayAppendMode) {
val text = content.text
// An empty update is a no-op for every mode but REPLACE. Suppressed
// content (e.g. a reasoning trace stripped by DisplayContentParser)
// must not flush the temporary tail, because emptying the thread makes
// the caller drop the bubble and re-create it at the end of the
// transcript, losing its chronological position.
if (text.isEmpty() && mode != DisplayAppendMode.REPLACE) {
return
}
flushTemporary()

when (mode) {
DisplayAppendMode.REPLACE -> {
chunks.clear()
if (text.isNotEmpty()) {
chunks += Chunk(text, content.format, content.kind)
}
lastMode = null
}

DisplayAppendMode.INLINE -> {
if (text.isNotEmpty()) {
// The shell only reuses the previous content div when its
// kind style matches (`matchKindStyle`).
val last = chunks.lastOrNull()
if (lastMode == DisplayAppendMode.INLINE &&
last != null &&
last.kind == content.kind
) {
last.text += text
last.format = last.format.mergeWith(content.format)
} else {
chunks += Chunk(text, content.format, content.kind)
}
}
lastMode = DisplayAppendMode.INLINE
}

DisplayAppendMode.TEMPORARY -> {
if (text.isNotEmpty()) {
chunks += Chunk(text, content.format, content.kind)
hasTemporaryTail = true
}
lastMode = DisplayAppendMode.TEMPORARY
}

DisplayAppendMode.BLOCK, DisplayAppendMode.STEP -> {
if (text.isNotEmpty()) {
chunks += Chunk(text, content.format, content.kind)
}
lastMode = mode
}
}
}

/**
* Drops a trailing `temporary` chunk, matching the shell's
* `messageDiv.lastChild?.remove()` flush. Returns true when content changed.
*/
fun flushTemporary(): Boolean {
if (!hasTemporaryTail) {
return false
}
hasTemporaryTail = false
lastMode = null
return chunks.removeLastOrNull() != null
}

fun render(): RenderedAgentMessage {
val segments = chunks
.map { chunk ->
MessageSegment(
text = chunk.text.trim('\n'),
format = chunk.format,
kind = chunk.kind
)
}
.filter { it.text.isNotEmpty() }
return RenderedAgentMessage(segments)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import org.json.JSONObject

internal data class ParsedDisplayContent(
val text: String,
val format: MessageFormat
val format: MessageFormat,
val kind: MessageKind = MessageKind.NONE
)

internal fun extractAgentMessageContent(value: Any?): ParsedDisplayContent {
Expand All @@ -16,6 +17,16 @@ internal fun extractAgentMessageContent(value: Any?): ParsedDisplayContent {
return extractDisplayContent(agentMessage.optNullable("message"))
}

/**
* `IAgentMessage.kind` (AgentMessageKind). The shell routes "toast" and
* "inline" kinds outside of the chat bubble entirely.
*/
internal fun extractAgentMessageKind(value: Any?): String? {
val agentMessage = value as? JSONObject ?: return null
val kind = agentMessage.optString("kind")
return kind.ifBlank { null }
}

private fun extractDisplayContent(value: Any?): ParsedDisplayContent {
return when (value) {
null, JSONObject.NULL -> ParsedDisplayContent("", MessageFormat.TEXT)
Expand All @@ -26,12 +37,14 @@ private fun extractDisplayContent(value: Any?): ParsedDisplayContent {
)

is JSONObject -> {
val kind = MessageKind.parse(value.optString("kind"))
when {
value.optString("type") == "structured" -> {
extractBestAlternate(value.optJSONArray("alternates"))
extractBestAlternate(value.optJSONArray("alternates"), kind)
?: ParsedDisplayContent(
text = "Structured content is not supported in this client yet.",
format = MessageFormat.TEXT
format = MessageFormat.TEXT,
kind = kind
)
}

Expand All @@ -40,7 +53,8 @@ private fun extractDisplayContent(value: Any?): ParsedDisplayContent {
extractSupportedTypedContent(
declaredType = declaredType,
content = value.optNullable("content"),
alternates = value.optJSONArray("alternates")
alternates = value.optJSONArray("alternates"),
kind = kind
)
}

Expand All @@ -56,43 +70,53 @@ private fun extractDisplayContent(value: Any?): ParsedDisplayContent {
private fun extractSupportedTypedContent(
declaredType: String,
content: Any?,
alternates: JSONArray?
alternates: JSONArray?,
kind: MessageKind
): ParsedDisplayContent {
val normalizedType = declaredType.lowercase()
return when (normalizedType) {
"markdown" -> ParsedDisplayContent(
text = messageContentToText(content, declaredType),
format = MessageFormat.MARKDOWN
format = MessageFormat.MARKDOWN,
kind = kind
)

"text" -> ParsedDisplayContent(
text = messageContentToText(content, declaredType),
format = MessageFormat.TEXT
format = MessageFormat.TEXT,
kind = kind
)

else -> extractBestAlternate(alternates) ?: fallbackTypedContent(normalizedType, content)
else -> extractBestAlternate(alternates, kind)
?: fallbackTypedContent(normalizedType, content, kind)
}
}

private fun fallbackTypedContent(
declaredType: String,
content: Any?
content: Any?,
kind: MessageKind
): ParsedDisplayContent {
val fallbackText = messageContentToText(content, declaredType)
return when (declaredType) {
"html", "iframe" -> ParsedDisplayContent(
text = htmlFallbackText(fallbackText),
format = MessageFormat.TEXT
format = MessageFormat.TEXT,
kind = kind
)

else -> ParsedDisplayContent(
text = fallbackText,
format = MessageFormat.TEXT
format = MessageFormat.TEXT,
kind = kind
)
}
}

private fun extractBestAlternate(alternates: JSONArray?): ParsedDisplayContent? {
private fun extractBestAlternate(
alternates: JSONArray?,
kind: MessageKind
): ParsedDisplayContent? {
if (alternates == null || alternates.length() == 0) {
return null
}
Expand All @@ -109,7 +133,8 @@ private fun extractBestAlternate(alternates: JSONArray?): ParsedDisplayContent?
return extractSupportedTypedContent(
declaredType = alternateType,
content = alternate.optNullable("content"),
alternates = null
alternates = null,
kind = kind
)
}
}
Expand Down
Loading
Loading