From 6557dda7f6c55a73795e8c06d6eb11471f91b4a9 Mon Sep 17 00:00:00 2001 From: weishu Date: Tue, 18 Aug 2026 17:05:02 +0800 Subject: [PATCH] feat: native agent brand icons on both platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port web's per-agent brand icons (web/src/components/AgentFlavorIcon.tsx, brand SVGs via @lobehub/icons v5.4.0) to Android and iOS — device feedback: neither native app showed agent icons. Android: 10 VectorDrawables (ic_agent_*) + AgentFlavorIcon composable (color variants render untinted Image; monos tint via LocalContentColor; copilot fixed #24292F/#E6EDF3; unknown -> "Un" badge) with light/dark previews. Codex/gemini keep their real gradients via aapt attrs; Antigravity's blurred-blob wing (SVG filters, unportable) is approximated with a green-yellow-red-blue linear gradient over the wing path. iOS: Assets.xcassets/AgentIcons imagesets (SVG, preserves-vector; monos template-intent) + AgentFlavorIconView. SVGs stay paths-only for Xcode's rasterizer, so gradient marks flatten: codex glyph -> #7A9DFF (middle stop), gemini -> #3186FF base star, agy -> #3186FF wing. Wired to match web placements on both apps: session-list row (icon before title; flavor label leaves the meta line), chat header meta line (icon + label), new-session agent picker (chip leadingIcon / Label icon). Verified: gradle :app:testDebugUnitTest :app:assembleDebug green; iOS Contents.json/SVGs machine-validated, swiftc -parse clean, geometry eyeballed via rendered contact sheet (app target still compile-unverified on Linux). --- .../hapi/companion/feature/chat/ChatScreen.kt | 28 +++- .../companion/feature/chat/ChatViewModel.kt | 3 + .../feature/newsession/NewSessionScreen.kt | 3 + .../feature/sessions/SessionListScreen.kt | 14 +- .../ui/components/AgentFlavorIcon.kt | 136 ++++++++++++++++++ .../src/main/res/drawable/ic_agent_agy.xml | 40 ++++++ .../src/main/res/drawable/ic_agent_claude.xml | 14 ++ .../src/main/res/drawable/ic_agent_codex.xml | 38 +++++ .../main/res/drawable/ic_agent_copilot.xml | 16 +++ .../src/main/res/drawable/ic_agent_cursor.xml | 15 ++ .../src/main/res/drawable/ic_agent_gemini.xml | 71 +++++++++ .../src/main/res/drawable/ic_agent_grok.xml | 15 ++ .../src/main/res/drawable/ic_agent_kimi.xml | 20 +++ .../main/res/drawable/ic_agent_opencode.xml | 15 ++ .../app/src/main/res/drawable/ic_agent_pi.xml | 18 +++ .../Assets.xcassets/AgentIcons/Contents.json | 6 + .../AgentIcons/agy.imageset/Contents.json | 16 +++ .../AgentIcons/agy.imageset/icon.svg | 3 + .../AgentIcons/claude.imageset/Contents.json | 16 +++ .../AgentIcons/claude.imageset/icon.svg | 3 + .../AgentIcons/codex.imageset/Contents.json | 16 +++ .../AgentIcons/codex.imageset/icon.svg | 4 + .../AgentIcons/copilot.imageset/Contents.json | 16 +++ .../AgentIcons/copilot.imageset/icon.svg | 3 + .../AgentIcons/cursor.imageset/Contents.json | 16 +++ .../AgentIcons/cursor.imageset/icon.svg | 3 + .../AgentIcons/gemini.imageset/Contents.json | 16 +++ .../AgentIcons/gemini.imageset/icon.svg | 3 + .../AgentIcons/grok.imageset/Contents.json | 16 +++ .../AgentIcons/grok.imageset/icon.svg | 3 + .../AgentIcons/kimi.imageset/Contents.json | 16 +++ .../AgentIcons/kimi.imageset/icon.svg | 4 + .../opencode.imageset/Contents.json | 16 +++ .../AgentIcons/opencode.imageset/icon.svg | 3 + .../AgentIcons/pi.imageset/Contents.json | 16 +++ .../AgentIcons/pi.imageset/icon.svg | 4 + ios/Hapi/Features/Chat/ChatModel.swift | 4 + ios/Hapi/Features/Chat/ChatView.swift | 16 ++- .../Features/NewSession/NewSessionView.swift | 14 +- .../Features/Sessions/AgentFlavorIcon.swift | 90 ++++++++++++ .../Features/Sessions/SessionListView.swift | 10 +- 41 files changed, 757 insertions(+), 22 deletions(-) create mode 100644 android/app/src/main/kotlin/app/hapi/companion/ui/components/AgentFlavorIcon.kt create mode 100644 android/app/src/main/res/drawable/ic_agent_agy.xml create mode 100644 android/app/src/main/res/drawable/ic_agent_claude.xml create mode 100644 android/app/src/main/res/drawable/ic_agent_codex.xml create mode 100644 android/app/src/main/res/drawable/ic_agent_copilot.xml create mode 100644 android/app/src/main/res/drawable/ic_agent_cursor.xml create mode 100644 android/app/src/main/res/drawable/ic_agent_gemini.xml create mode 100644 android/app/src/main/res/drawable/ic_agent_grok.xml create mode 100644 android/app/src/main/res/drawable/ic_agent_kimi.xml create mode 100644 android/app/src/main/res/drawable/ic_agent_opencode.xml create mode 100644 android/app/src/main/res/drawable/ic_agent_pi.xml create mode 100644 ios/Hapi/Assets.xcassets/AgentIcons/Contents.json create mode 100644 ios/Hapi/Assets.xcassets/AgentIcons/agy.imageset/Contents.json create mode 100644 ios/Hapi/Assets.xcassets/AgentIcons/agy.imageset/icon.svg create mode 100644 ios/Hapi/Assets.xcassets/AgentIcons/claude.imageset/Contents.json create mode 100644 ios/Hapi/Assets.xcassets/AgentIcons/claude.imageset/icon.svg create mode 100644 ios/Hapi/Assets.xcassets/AgentIcons/codex.imageset/Contents.json create mode 100644 ios/Hapi/Assets.xcassets/AgentIcons/codex.imageset/icon.svg create mode 100644 ios/Hapi/Assets.xcassets/AgentIcons/copilot.imageset/Contents.json create mode 100644 ios/Hapi/Assets.xcassets/AgentIcons/copilot.imageset/icon.svg create mode 100644 ios/Hapi/Assets.xcassets/AgentIcons/cursor.imageset/Contents.json create mode 100644 ios/Hapi/Assets.xcassets/AgentIcons/cursor.imageset/icon.svg create mode 100644 ios/Hapi/Assets.xcassets/AgentIcons/gemini.imageset/Contents.json create mode 100644 ios/Hapi/Assets.xcassets/AgentIcons/gemini.imageset/icon.svg create mode 100644 ios/Hapi/Assets.xcassets/AgentIcons/grok.imageset/Contents.json create mode 100644 ios/Hapi/Assets.xcassets/AgentIcons/grok.imageset/icon.svg create mode 100644 ios/Hapi/Assets.xcassets/AgentIcons/kimi.imageset/Contents.json create mode 100644 ios/Hapi/Assets.xcassets/AgentIcons/kimi.imageset/icon.svg create mode 100644 ios/Hapi/Assets.xcassets/AgentIcons/opencode.imageset/Contents.json create mode 100644 ios/Hapi/Assets.xcassets/AgentIcons/opencode.imageset/icon.svg create mode 100644 ios/Hapi/Assets.xcassets/AgentIcons/pi.imageset/Contents.json create mode 100644 ios/Hapi/Assets.xcassets/AgentIcons/pi.imageset/icon.svg create mode 100644 ios/Hapi/Features/Sessions/AgentFlavorIcon.swift diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatScreen.kt index ef144d76..fcec57b2 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatScreen.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost @@ -83,6 +84,7 @@ import app.hapi.companion.feature.chat.composer.QueuedMessagesBar import app.hapi.companion.feature.files.FolderGlyph import app.hapi.companion.feature.sessions.DeleteSessionDialog import app.hapi.companion.feature.sessions.RenameSessionDialog +import app.hapi.companion.ui.components.AgentFlavorIcon import app.hapi.companion.ui.markdown.LocalMarkdownLinkHandler import app.hapi.companion.ui.theme.hapi import app.hapi.protocol.chat.VisibleChatBlock @@ -550,13 +552,25 @@ private fun ChatTitle(header: ChatHeaderUi) { overflow = TextOverflow.Ellipsis, ) header.subtitle?.let { subtitle -> - Text( - text = subtitle, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + header.flavor?.let { flavor -> + // Hint-colored like the meta text (web: currentColor + // under --app-hint); color variants ignore the tint. + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant, + ) { + AgentFlavorIcon(flavor, modifier = Modifier.size(14.dp)) + } + Spacer(modifier = Modifier.width(4.dp)) + } + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt index 9735bafb..49f4962a 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt @@ -90,6 +90,8 @@ data class ChatHeaderUi( val title: String, /** "Flavor · machine · worktree/path" meta line; null when nothing known. */ val subtitle: String?, + /** Raw `metadata.flavor` — drives the brand icon next to the meta line. */ + val flavor: String? = null, /** Raw custom `metadata.name` (rename-dialog prefill; the title cascade may show more). */ val name: String? = null, val active: Boolean, @@ -1472,6 +1474,7 @@ class ChatViewModel( return ChatHeaderUi( title = title, subtitle = subtitle, + flavor = flavor, name = detail?.metadata?.name ?: summary?.metadata?.name, active = detail?.active ?: summary?.active ?: false, thinking = detail?.thinking ?: summary?.thinking ?: false, diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionScreen.kt index a12e925a..0fd4a381 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionScreen.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll @@ -50,6 +51,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import app.hapi.companion.R +import app.hapi.companion.ui.components.AgentFlavorIcon import app.hapi.companion.ui.theme.HapiTheme /** @@ -447,6 +449,7 @@ private fun AgentSection(state: NewSessionUiState, onAgentSelected: (String) -> selected = state.form.agent == agent.value, onClick = { onAgentSelected(agent.value) }, enabled = !state.isSpawning, + leadingIcon = { AgentFlavorIcon(agent.value, modifier = Modifier.size(16.dp)) }, label = { Text(agent.label) }, ) } diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListScreen.kt index ffc39909..ddaf9561 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListScreen.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListScreen.kt @@ -61,8 +61,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import app.hapi.companion.R +import app.hapi.companion.ui.components.AgentFlavorIcon import app.hapi.companion.ui.theme.HapiTheme -import app.hapi.protocol.catalog.Flavors import app.hapi.protocol.wire.PendingRequest import app.hapi.protocol.wire.SessionSummary import app.hapi.protocol.wire.SessionSummaryMetadata @@ -78,9 +78,9 @@ import app.hapi.protocol.wire.TodoProgress * pull-to-refresh, empty state; * - pinned section first (the sort already puts globalPinned/pinned rows on * top; a header + divider make the boundary visible); - * - per row: status dot (active / thinking pulse), title, flavor + machine - * labels, summary/path line, relative `updatedAt`, pending-request badge, - * todo-progress chip, unread dot; + * - per row: status dot (active / thinking pulse), flavor brand icon + + * title, machine + worktree labels, summary/path line, relative + * `updatedAt`, pending-request badge, todo-progress chip, unread dot; * - long-press → actions sheet: pin (none/project/global), rename (dialog), * reopen (inactive rows; navigates into the possibly-superseding id), * archive, delete (confirm; 409 while active) — optimistic store updates; @@ -312,6 +312,8 @@ private fun SessionRow( Spacer(modifier = Modifier.width(10.dp)) Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { + AgentFlavorIcon(row.flavor, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) // One weighted element only: the title takes ALL leftover // width (start-aligned, ellipsis on true overflow). Splitting // the slack with a weighted trailing spacer truncated even @@ -350,12 +352,12 @@ private fun SessionRow( } } +// The agent flavor moved into the title line as a brand icon (web parity: +// `SessionRowSummary`), so the meta line carries machine + worktree only. @Composable private fun MetaLine(row: SessionRowUi) { - val flavorLabel = row.flavor?.let(Flavors::label) val worktree = row.summary.metadata?.worktree val parts = buildList { - flavorLabel?.let(::add) row.machineLabel?.let(::add) worktree?.let { add(it.name.ifBlank { it.branch }) } } diff --git a/android/app/src/main/kotlin/app/hapi/companion/ui/components/AgentFlavorIcon.kt b/android/app/src/main/kotlin/app/hapi/companion/ui/components/AgentFlavorIcon.kt new file mode 100644 index 00000000..63c6a62c --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/ui/components/AgentFlavorIcon.kt @@ -0,0 +1,136 @@ +package app.hapi.companion.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.hapi.companion.R +import app.hapi.companion.ui.theme.HapiTheme +import app.hapi.companion.ui.theme.hapi + +/** + * Brand logo per agent flavor — port of `web/src/components/AgentFlavorIcon.tsx` + * (brand SVGs via @lobehub/icons, the same source the web ships). + * + * Color variants (agy/claude/codex/gemini) keep their literal brand fills and + * render untinted so they stay visible on both light and dark surfaces — the + * reason the web picked Color for them. Mono variants (cursor/grok/kimi/ + * opencode/pi) are currentColor upstream and tint with [LocalContentColor]; + * copilot mirrors the web's fixed GitHub-mark tint (#24292F light / + * #E6EDF3 dark). Unknown flavors fall back to the web's "Un" badge. + */ +@Composable +fun AgentFlavorIcon(flavor: String?, modifier: Modifier = Modifier.size(16.dp)) { + when (val normalized = flavor?.trim()?.lowercase().orEmpty()) { + in COLOR_FLAVOR_ICONS -> Image( + painter = painterResource(COLOR_FLAVOR_ICONS.getValue(normalized)), + contentDescription = null, + modifier = modifier, + ) + + in MONO_FLAVOR_ICONS -> Icon( + painter = painterResource(MONO_FLAVOR_ICONS.getValue(normalized)), + contentDescription = null, + modifier = modifier, + tint = LocalContentColor.current, + ) + + "copilot" -> Icon( + painter = painterResource(R.drawable.ic_agent_copilot), + contentDescription = null, + modifier = modifier, + tint = if (MaterialTheme.hapi.isDark) Color(0xFFE6EDF3) else Color(0xFF24292F), + ) + + else -> Box( + modifier = modifier.background( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(3.dp), + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Un", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 8.sp, + lineHeight = 8.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + softWrap = false, + ) + } + } +} + +private val COLOR_FLAVOR_ICONS: Map = mapOf( + "agy" to R.drawable.ic_agent_agy, + "claude" to R.drawable.ic_agent_claude, + "codex" to R.drawable.ic_agent_codex, + "gemini" to R.drawable.ic_agent_gemini, +) + +private val MONO_FLAVOR_ICONS: Map = mapOf( + "cursor" to R.drawable.ic_agent_cursor, + "grok" to R.drawable.ic_agent_grok, + "kimi" to R.drawable.ic_agent_kimi, + "opencode" to R.drawable.ic_agent_opencode, + "pi" to R.drawable.ic_agent_pi, +) + +// -------------------------------------------------------------- preview -- + +@Composable +private fun AgentFlavorIconStrip() { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val flavors = listOf( + "agy", "claude", "codex", "copilot", "cursor", + "gemini", "grok", "kimi", "opencode", "pi", "mystery", + ) + flavors.chunked(6).forEach { chunk -> + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + chunk.forEach { flavor -> AgentFlavorIcon(flavor) } + } + } + } +} + +@Preview(showBackground = true, name = "Agent icons · light") +@Composable +private fun AgentFlavorIconPreviewLight() { + HapiTheme(darkTheme = false, dynamicColor = false) { + Surface { AgentFlavorIconStrip() } + } +} + +@Preview(showBackground = true, name = "Agent icons · dark") +@Composable +private fun AgentFlavorIconPreviewDark() { + HapiTheme(darkTheme = true, dynamicColor = false) { + Surface { AgentFlavorIconStrip() } + } +} diff --git a/android/app/src/main/res/drawable/ic_agent_agy.xml b/android/app/src/main/res/drawable/ic_agent_agy.xml new file mode 100644 index 00000000..75a9f8a8 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_agent_agy.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_agent_claude.xml b/android/app/src/main/res/drawable/ic_agent_claude.xml new file mode 100644 index 00000000..7e98b9b2 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_agent_claude.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_agent_codex.xml b/android/app/src/main/res/drawable/ic_agent_codex.xml new file mode 100644 index 00000000..c19cd1f3 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_agent_codex.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_agent_copilot.xml b/android/app/src/main/res/drawable/ic_agent_copilot.xml new file mode 100644 index 00000000..e1e307b8 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_agent_copilot.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_agent_cursor.xml b/android/app/src/main/res/drawable/ic_agent_cursor.xml new file mode 100644 index 00000000..fdb6f1c4 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_agent_cursor.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_agent_gemini.xml b/android/app/src/main/res/drawable/ic_agent_gemini.xml new file mode 100644 index 00000000..5cb93588 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_agent_gemini.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_agent_grok.xml b/android/app/src/main/res/drawable/ic_agent_grok.xml new file mode 100644 index 00000000..ec0c6ef7 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_agent_grok.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_agent_kimi.xml b/android/app/src/main/res/drawable/ic_agent_kimi.xml new file mode 100644 index 00000000..ec462629 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_agent_kimi.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_agent_opencode.xml b/android/app/src/main/res/drawable/ic_agent_opencode.xml new file mode 100644 index 00000000..91977b1c --- /dev/null +++ b/android/app/src/main/res/drawable/ic_agent_opencode.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_agent_pi.xml b/android/app/src/main/res/drawable/ic_agent_pi.xml new file mode 100644 index 00000000..fe86096f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_agent_pi.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/ios/Hapi/Assets.xcassets/AgentIcons/Contents.json b/ios/Hapi/Assets.xcassets/AgentIcons/Contents.json new file mode 100644 index 00000000..74d6a722 --- /dev/null +++ b/ios/Hapi/Assets.xcassets/AgentIcons/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/Hapi/Assets.xcassets/AgentIcons/agy.imageset/Contents.json b/ios/Hapi/Assets.xcassets/AgentIcons/agy.imageset/Contents.json new file mode 100644 index 00000000..8589b966 --- /dev/null +++ b/ios/Hapi/Assets.xcassets/AgentIcons/agy.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "icon.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "original" + } +} diff --git a/ios/Hapi/Assets.xcassets/AgentIcons/agy.imageset/icon.svg b/ios/Hapi/Assets.xcassets/AgentIcons/agy.imageset/icon.svg new file mode 100644 index 00000000..2bf74b50 --- /dev/null +++ b/ios/Hapi/Assets.xcassets/AgentIcons/agy.imageset/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/ios/Hapi/Assets.xcassets/AgentIcons/claude.imageset/Contents.json b/ios/Hapi/Assets.xcassets/AgentIcons/claude.imageset/Contents.json new file mode 100644 index 00000000..8589b966 --- /dev/null +++ b/ios/Hapi/Assets.xcassets/AgentIcons/claude.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "icon.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "original" + } +} diff --git a/ios/Hapi/Assets.xcassets/AgentIcons/claude.imageset/icon.svg b/ios/Hapi/Assets.xcassets/AgentIcons/claude.imageset/icon.svg new file mode 100644 index 00000000..18bd9170 --- /dev/null +++ b/ios/Hapi/Assets.xcassets/AgentIcons/claude.imageset/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/ios/Hapi/Assets.xcassets/AgentIcons/codex.imageset/Contents.json b/ios/Hapi/Assets.xcassets/AgentIcons/codex.imageset/Contents.json new file mode 100644 index 00000000..8589b966 --- /dev/null +++ b/ios/Hapi/Assets.xcassets/AgentIcons/codex.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "icon.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "original" + } +} diff --git a/ios/Hapi/Assets.xcassets/AgentIcons/codex.imageset/icon.svg b/ios/Hapi/Assets.xcassets/AgentIcons/codex.imageset/icon.svg new file mode 100644 index 00000000..b5c87031 --- /dev/null +++ b/ios/Hapi/Assets.xcassets/AgentIcons/codex.imageset/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ios/Hapi/Assets.xcassets/AgentIcons/copilot.imageset/Contents.json b/ios/Hapi/Assets.xcassets/AgentIcons/copilot.imageset/Contents.json new file mode 100644 index 00000000..4ce8cd62 --- /dev/null +++ b/ios/Hapi/Assets.xcassets/AgentIcons/copilot.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "icon.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/Hapi/Assets.xcassets/AgentIcons/copilot.imageset/icon.svg b/ios/Hapi/Assets.xcassets/AgentIcons/copilot.imageset/icon.svg new file mode 100644 index 00000000..4fcb6994 --- /dev/null +++ b/ios/Hapi/Assets.xcassets/AgentIcons/copilot.imageset/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/ios/Hapi/Assets.xcassets/AgentIcons/cursor.imageset/Contents.json b/ios/Hapi/Assets.xcassets/AgentIcons/cursor.imageset/Contents.json new file mode 100644 index 00000000..4ce8cd62 --- /dev/null +++ b/ios/Hapi/Assets.xcassets/AgentIcons/cursor.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "icon.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/Hapi/Assets.xcassets/AgentIcons/cursor.imageset/icon.svg b/ios/Hapi/Assets.xcassets/AgentIcons/cursor.imageset/icon.svg new file mode 100644 index 00000000..eb55338b --- /dev/null +++ b/ios/Hapi/Assets.xcassets/AgentIcons/cursor.imageset/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/ios/Hapi/Assets.xcassets/AgentIcons/gemini.imageset/Contents.json b/ios/Hapi/Assets.xcassets/AgentIcons/gemini.imageset/Contents.json new file mode 100644 index 00000000..8589b966 --- /dev/null +++ b/ios/Hapi/Assets.xcassets/AgentIcons/gemini.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "icon.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "original" + } +} diff --git a/ios/Hapi/Assets.xcassets/AgentIcons/gemini.imageset/icon.svg b/ios/Hapi/Assets.xcassets/AgentIcons/gemini.imageset/icon.svg new file mode 100644 index 00000000..1e459373 --- /dev/null +++ b/ios/Hapi/Assets.xcassets/AgentIcons/gemini.imageset/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/ios/Hapi/Assets.xcassets/AgentIcons/grok.imageset/Contents.json b/ios/Hapi/Assets.xcassets/AgentIcons/grok.imageset/Contents.json new file mode 100644 index 00000000..4ce8cd62 --- /dev/null +++ b/ios/Hapi/Assets.xcassets/AgentIcons/grok.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "icon.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/Hapi/Assets.xcassets/AgentIcons/grok.imageset/icon.svg b/ios/Hapi/Assets.xcassets/AgentIcons/grok.imageset/icon.svg new file mode 100644 index 00000000..20251143 --- /dev/null +++ b/ios/Hapi/Assets.xcassets/AgentIcons/grok.imageset/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/ios/Hapi/Assets.xcassets/AgentIcons/kimi.imageset/Contents.json b/ios/Hapi/Assets.xcassets/AgentIcons/kimi.imageset/Contents.json new file mode 100644 index 00000000..4ce8cd62 --- /dev/null +++ b/ios/Hapi/Assets.xcassets/AgentIcons/kimi.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "icon.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/Hapi/Assets.xcassets/AgentIcons/kimi.imageset/icon.svg b/ios/Hapi/Assets.xcassets/AgentIcons/kimi.imageset/icon.svg new file mode 100644 index 00000000..35e57fd0 --- /dev/null +++ b/ios/Hapi/Assets.xcassets/AgentIcons/kimi.imageset/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ios/Hapi/Assets.xcassets/AgentIcons/opencode.imageset/Contents.json b/ios/Hapi/Assets.xcassets/AgentIcons/opencode.imageset/Contents.json new file mode 100644 index 00000000..4ce8cd62 --- /dev/null +++ b/ios/Hapi/Assets.xcassets/AgentIcons/opencode.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "icon.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/Hapi/Assets.xcassets/AgentIcons/opencode.imageset/icon.svg b/ios/Hapi/Assets.xcassets/AgentIcons/opencode.imageset/icon.svg new file mode 100644 index 00000000..eff71e7b --- /dev/null +++ b/ios/Hapi/Assets.xcassets/AgentIcons/opencode.imageset/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/ios/Hapi/Assets.xcassets/AgentIcons/pi.imageset/Contents.json b/ios/Hapi/Assets.xcassets/AgentIcons/pi.imageset/Contents.json new file mode 100644 index 00000000..4ce8cd62 --- /dev/null +++ b/ios/Hapi/Assets.xcassets/AgentIcons/pi.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "icon.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/Hapi/Assets.xcassets/AgentIcons/pi.imageset/icon.svg b/ios/Hapi/Assets.xcassets/AgentIcons/pi.imageset/icon.svg new file mode 100644 index 00000000..a038611d --- /dev/null +++ b/ios/Hapi/Assets.xcassets/AgentIcons/pi.imageset/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ios/Hapi/Features/Chat/ChatModel.swift b/ios/Hapi/Features/Chat/ChatModel.swift index 8d7ffcf5..f97d208f 100644 --- a/ios/Hapi/Features/Chat/ChatModel.swift +++ b/ios/Hapi/Features/Chat/ChatModel.swift @@ -8,6 +8,8 @@ struct ChatHeaderUI: Equatable { var title: String /// "Flavor · machine · worktree" meta line; nil when nothing is known. var subtitle: String? + /// Raw `metadata.flavor` — drives the brand icon next to the meta line. + var flavor: String? var active: Bool var thinking: Bool } @@ -110,6 +112,7 @@ final class ChatModel { self.header = ChatHeaderUI( title: String(sessionId.prefix(8)), subtitle: nil, + flavor: nil, active: false, thinking: false ) @@ -356,6 +359,7 @@ final class ChatModel { return ChatHeaderUI( title: title, subtitle: parts.isEmpty ? nil : parts.joined(separator: " · "), + flavor: flavor, active: detail?.active ?? summary?.active ?? false, thinking: detail?.thinking ?? summary?.thinking ?? false ) diff --git a/ios/Hapi/Features/Chat/ChatView.swift b/ios/Hapi/Features/Chat/ChatView.swift index d88441d8..ec111be1 100644 --- a/ios/Hapi/Features/Chat/ChatView.swift +++ b/ios/Hapi/Features/Chat/ChatView.swift @@ -324,10 +324,18 @@ struct ChatView: View { .font(.headline) .lineLimit(1) if let subtitle = model.header.subtitle { - Text(subtitle) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) + HStack(spacing: 4) { + if model.header.flavor != nil { + // Inherits .secondary like the meta text (web: + // currentColor under --app-hint); color variants + // ignore the tint. + AgentFlavorIconView(flavor: model.header.flavor, size: 12) + } + Text(subtitle) + .font(.caption2) + .lineLimit(1) + } + .foregroundStyle(.secondary) } } } diff --git a/ios/Hapi/Features/NewSession/NewSessionView.swift b/ios/Hapi/Features/NewSession/NewSessionView.swift index 628700d8..dffac79f 100644 --- a/ios/Hapi/Features/NewSession/NewSessionView.swift +++ b/ios/Hapi/Features/NewSession/NewSessionView.swift @@ -189,7 +189,19 @@ struct NewSessionView: View { Section("Agent") { Picker("Agent", selection: agentBinding) { ForEach(model.agents, id: \.value) { agent in - Text(agent.label).tag(agent.value) + // Plain `Image` icon so the menu representation keeps it + // (UIMenu drops custom icon views); template monos take + // the menu tint, color marks render as-is. + if let asset = AgentFlavorIconView.assetName(forFlavor: agent.value) { + Label { + Text(agent.label) + } icon: { + Image(asset) + } + .tag(agent.value) + } else { + Text(agent.label).tag(agent.value) + } } } } diff --git a/ios/Hapi/Features/Sessions/AgentFlavorIcon.swift b/ios/Hapi/Features/Sessions/AgentFlavorIcon.swift new file mode 100644 index 00000000..b3ec9a94 --- /dev/null +++ b/ios/Hapi/Features/Sessions/AgentFlavorIcon.swift @@ -0,0 +1,90 @@ +import SwiftUI + +/// Brand logo per agent flavor — port of `web/src/components/AgentFlavorIcon.tsx` +/// (brand SVGs via @lobehub/icons, the same source the web ships; assets live +/// in `Assets.xcassets/AgentIcons`). +/// +/// Color variants (agy/claude/codex/gemini) carry `original` rendering intent +/// so the literal brand fills stay visible on light and dark — the reason the +/// web picked Color for them. Mono variants (cursor/grok/kimi/opencode/pi) +/// are template images and inherit the environment foreground style (the +/// web's currentColor); copilot mirrors the web's fixed GitHub-mark tint +/// (#24292F light / #E6EDF3 dark). Unknown flavors fall back to the web's +/// "Un" badge. +struct AgentFlavorIconView: View { + let flavor: String? + var size: CGFloat = 16 + + @Environment(\.colorScheme) private var colorScheme + + /// Flavors with a bundled `AgentIcons/` image set. + private static let bundledFlavors: Set = [ + "agy", "claude", "codex", "copilot", "cursor", + "gemini", "grok", "kimi", "opencode", "pi", + ] + + /// Asset name for a raw `metadata.flavor` value; nil when no icon ships. + static func assetName(forFlavor flavor: String?) -> String? { + guard let flavor else { return nil } + let normalized = flavor.trimmingCharacters(in: .whitespaces).lowercased() + return bundledFlavors.contains(normalized) ? normalized : nil + } + + var body: some View { + content + .frame(width: size, height: size) + .accessibilityHidden(true) + } + + @ViewBuilder + private var content: some View { + if let name = Self.assetName(forFlavor: flavor) { + let icon = Image(name) + .resizable() + .scaledToFit() + if name == "copilot" { + icon.foregroundStyle(colorScheme == .dark + ? Color(red: 230 / 255, green: 237 / 255, blue: 243 / 255) + : Color(red: 36 / 255, green: 41 / 255, blue: 47 / 255)) + } else { + icon + } + } else { + Text("Un") + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.secondary.opacity(0.15), in: RoundedRectangle(cornerRadius: 3)) + } + } +} + +#Preview("Agent icons") { + AgentFlavorIconPreviewGrid() +} + +#Preview("Agent icons · dark") { + AgentFlavorIconPreviewGrid() + .preferredColorScheme(.dark) +} + +private struct AgentFlavorIconPreviewGrid: View { + private let flavors: [String?] = [ + "agy", "claude", "codex", "copilot", "cursor", + "gemini", "grok", "kimi", "opencode", "pi", "mystery", + ] + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + ForEach(flavors, id: \.self) { flavor in + HStack(spacing: 8) { + AgentFlavorIconView(flavor: flavor) + Text(flavor ?? "nil") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + .padding() + } +} diff --git a/ios/Hapi/Features/Sessions/SessionListView.swift b/ios/Hapi/Features/Sessions/SessionListView.swift index d7cbec3f..22048551 100644 --- a/ios/Hapi/Features/Sessions/SessionListView.swift +++ b/ios/Hapi/Features/Sessions/SessionListView.swift @@ -10,9 +10,9 @@ import SwiftUI /// pull-to-refresh, empty/loading states; /// - pinned section first (the sort already puts globalPinned/pinned rows on /// top; a header makes the boundary visible); -/// - per row: status dot (active / thinking pulse), title, flavor + machine + -/// worktree meta line, summary/path line, relative `updatedAt`, -/// pending-request badge, todo-progress chip, unread dot; +/// - per row: status dot (active / thinking pulse), flavor brand icon + +/// title, machine + worktree meta line, summary/path line, relative +/// `updatedAt`, pending-request badge, todo-progress chip, unread dot; /// - long-press context menu → pin (none/project/global) + archive with /// optimistic store updates; failures land in an alert. struct SessionListView: View { @@ -219,6 +219,7 @@ struct SessionRowView: View { private var titleLine: some View { HStack(spacing: 6) { + AgentFlavorIconView(flavor: row.flavor) Text(row.title) .font(.body) .fontWeight(row.unread ? .semibold : .regular) @@ -236,6 +237,8 @@ struct SessionRowView: View { } } + // The agent flavor moved into the title line as a brand icon (web parity: + // `SessionRowSummary`), so the meta line carries machine + worktree only. @ViewBuilder private var metaLine: some View { let worktree = row.summary.metadata?.worktree @@ -243,7 +246,6 @@ struct SessionRowView: View { tree.name.trimmingCharacters(in: .whitespaces).isEmpty ? tree.branch : tree.name } let parts: [String] = [ - row.flavor.map { flavorLabel(forFlavor: $0) }, row.machineLabel, worktreeLabel, ].compactMap { $0 }