Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .memory/inline-generative-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,18 @@ Spec: `docs/superpowers/specs/2026-10-08-inline-generative-ui-design.md`. Plan:
- **Phones.** iOS (`AidenChatVisual`, `AidenChatVisualPresentation` in `Models/AidenChat.swift`) and Android (`AidenChatVisual`, `AidenChatVisualListSerializer` in `models/AidenChat.kt`, row helpers in `AidenChatDetailScreen.kt`) decode visuals per entry and place each after the tool step that drew it, splitting a same-offset tool group at that step. A snapshot counts only when exactly one PNG/JPEG with that id is on the message. An html visual without one gets no row; its can't-view card stands for it. iOS keeps an unknown kind as `.other`; desktop and Android drop it. Live streaming rows never carry visuals.
- **Remote turns.** `remoteGenerationSurface` follows the Mac's Appearance setting (injected `inlineVisuals()` reading `configStore` appearance) and sets `visualAudience: "remote"`, which adds a system-prompt note that readers see still images.

- **Phone viewer (contract revision 35, provisional; `chat-visual-viewer-v1`).** Owner chose tap to open (2026-10-16). Plan: `docs/plans/phone-visual-viewer-plan.md`.
- `GET /chats/{chatId}/visuals/{visualId}/document?scheme&theme` returns one self-contained HTML document (`main/services/visual-viewer-document.ts`, Electron-free). Visual entries gained no key: their parsers are closed on every client, so a new key would drop all visuals on older phones.
- The viewer page is `renderer/visual-viewer/main.tsx`, built by `vite.visual-viewer.config.ts` (lib IIFE, assets inlined, KaTeX woff/ttf stripped) into `build/renderer/visual-viewer/` after the main renderer build. The feature is announced only when those files exist (`visual-viewer-main.ts`).
- Theme: the page applies the phone's preset with `resolveThemeTokens` (no storage; opaque origins throw on localStorage). HTML guests start with light defaults, so the iframe stays hidden until the guest's `ready` gets a theme message.
- HTML guests inline only the host libraries their source names (`Chart`, `Plotly`, `katex`). `inlineGenerativeUiHostLibraries` is shared with export (and the CLI) and uses a function replacement so `$&` in library source stays literal.
- Native channel: iOS `aidenVisual` handler, Android `AidenVisualBridge`; messages `ready`, `prompt`, `openUrl`. `AidenUiBlock` takes `onFollowUp` so the viewer hands prompts to the phone instead of drawing its own Send chip; the phone confirms and only fills the composer. Links use `ExternalLinkOpenerProvider`.

## Tests

- `npm run test:generative-ui`: units, the vendor-script test, and Chromium containment (bridge, draft, shrink, rem).
- happy-dom component tests: `renderer/components/html-artifact-frame.test.tsx`, `message-list-visuals.test.tsx`. Compare DOM nodes as booleans; printing a happy-dom node in an assert diff hangs.
- e2e: `settings-unification.spec.ts` "inline visuals preference…", and `inline-visuals.spec.ts` (fake model scripts `render_ui` + `render_artifact` via `enqueueToolScenario`, then checks both snapshots are PNG attachments and the capture window stays hidden).
- Phone viewer: `main/services/visual-viewer-document.test.ts`, the visual-document cases in `aiden-remote-chats.test.ts` and `aiden-remote-router.test.ts`, and `tests/generative-ui/visual-viewer.spec.ts` (Chromium, network blocked; needs `npm run build:visual-viewer`, which `pretest:generative-ui` runs).
- Phase 3: `main/services/visual-snapshot-core.test.ts`, `renderer/shared/visual-snapshots.test.ts`, `main/services/visual-snapshot-store.test.ts`, `renderer/components/message-attachments-snapshots.test.tsx`, and the visuals cases in `aiden-remote-chats.test.ts` / `aiden-remote-protocol.test.ts`.
- Phase 2: `renderer/shared/aiden-ui/*.test.ts` (parser vectors including every streaming prefix, `fixtures/expressions.json`, the compile corpus in `fixtures/markup/*.aum`), `renderer/components/ui-primitives.test.tsx`, `renderer/components/aiden-ui/aiden-ui-block.test.tsx` (Chart.js stubbed), `main/services/aiden-ui-{tool,draft}.test.ts`, `main/handlers/chat-ui-visual-state.test.ts`, `renderer/lib/ui-visual-transcript.test.ts`. The CLI needs `npm ci` in `packages/cli` before `npm run build && npm test`.
6 changes: 6 additions & 0 deletions android/app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,9 @@
-dontwarn org.bouncycastle.jsse.**
-dontwarn org.conscrypt.**
-dontwarn org.openjsse.**

# The interactive visual viewer's WebView bridge (contract revision 35) is called
# by name from JavaScript, so its annotated methods must keep their names.
-keepclassmembers class sbtbiswas.AidenOnTheGo.features.chat.** {
@android.webkit.JavascriptInterface <methods>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ class AidenAppContainer(context: Context) {
navigationStore = navigationStore,
intentCatalogStore = intentCatalogStore,
scope = scope,
botOutboxDir = appContext.noBackupFilesDir
botOutboxDir = appContext.noBackupFilesDir,
visualDocumentDir = File(appContext.cacheDir, "visual_documents")
)
private val settingsCache = AidenSettingsCache(File(filesDir, "settings_cache"))
val settingsStore = AidenSettingsStore(settingsCache, scope)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,8 @@ fun AidenChatDetailScreen(
var requestedNotificationPermission by rememberSaveable { mutableStateOf(false) }
var progressSheet by rememberSaveable { mutableStateOf<String?>(null) }
var selectTextFor by remember { mutableStateOf<String?>(null) }
var visualViewerRequest by remember(chatId) { mutableStateOf<AidenVisualViewerRequest?>(null) }
var visualSnapshotGallery by remember(chatId) { mutableStateOf<AidenMessageAttachment?>(null) }
val currentDraft by rememberUpdatedState(draft)
val currentVoiceMode by rememberUpdatedState(voiceInputMode)

Expand Down Expand Up @@ -908,6 +910,11 @@ fun AidenChatDetailScreen(
val onAskAboutMessage: ((String) -> Unit)? = if (viewModel.isReadOnlyPresentation) null else
remember(viewModel) { { text: String -> viewModel.askAbout(text); Unit } }
val onSelectMessageText = remember { { text: String -> selectTextFor = text } }
// Revision 35: a tapped snapshot opens the interactive viewer when the Mac serves it.
val canOpenInteractiveVisuals = remember(serverInfo, viewModel) { viewModel.canOpenInteractiveVisuals }
val openVisualViewer = remember { { request: AidenVisualViewerRequest -> visualViewerRequest = request } }
val onOpenVisual: ((AidenVisualViewerRequest) -> Unit)? =
if (canOpenInteractiveVisuals) openVisualViewer else null
val canForkMessages = viewModel.canFork && !isForking
val canForkMessagesWithSummary = canForkMessages && viewModel.canForkWithSummary
val forkLineage = chat?.forkedFrom
Expand Down Expand Up @@ -1069,7 +1076,8 @@ fun AidenChatDetailScreen(
isPinnedToStreamingEdge = isPinnedToStreamingEdge,
imageArrivalRevealGenerationId = message.timeline?.generationId
?.takeIf { it in imageArrivalRevealIds },
onImageArrivalRevealed = viewModel::finishImageArrivalReveal
onImageArrivalRevealed = viewModel::finishImageArrivalReveal,
onOpenVisual = onOpenVisual
)
}
// The summary stands where the copied conversation ends.
Expand Down Expand Up @@ -1183,6 +1191,36 @@ fun AidenChatDetailScreen(
)
}

visualViewerRequest?.let { request ->
AidenVisualViewerScreen(
request = request,
chatId = chatId,
hostIdentity = viewModel.visualDocumentHostIdentity,
cache = viewModel.visualDocumentCache,
fetch = viewModel::visualDocument,
onShowImage = request.snapshot?.let { snapshot ->
{
visualViewerRequest = null
visualSnapshotGallery = snapshot
}
},
onAddToMessage = if (viewModel.isReadOnlyPresentation) null else ({ text: String ->
visualViewerRequest = null
viewModel.addVisualPrompt(text)
Unit
}),
onDismiss = { visualViewerRequest = null }
)
}
visualSnapshotGallery?.let { snapshot ->
AidenAttachmentGallery(
attachments = listOf(snapshot),
initialPage = 0,
loadData = viewModel::attachmentImageData,
onDismiss = { visualSnapshotGallery = null }
)
}

forkWithSummaryMessageId?.let { messageId ->
AidenForkSummaryDialog(
palette = palette,
Expand Down Expand Up @@ -1346,7 +1384,9 @@ private fun AssistantMessageRow(
isPinnedToStreamingEdge: () -> Boolean = { false },
/** Set while this message's generated image should play the one-shot arrival reveal. */
imageArrivalRevealGenerationId: String? = null,
onImageArrivalRevealed: (String) -> Unit = {}
onImageArrivalRevealed: (String) -> Unit = {},
/** Opens a visual in the interactive viewer; null keeps snapshot taps on the image viewer. */
onOpenVisual: ((AidenVisualViewerRequest) -> Unit)? = null
) {
val projection = if (isBotChat) {
AidenBotReplyProjection.resolve(message.text, message.timeline, isActive = false)
Expand All @@ -1369,7 +1409,8 @@ private fun AssistantMessageRow(
visual = visual,
attachments = message.attachments.orEmpty(),
loadAttachmentImage = loadAttachmentImage,
palette = palette
palette = palette,
onOpenInteractive = onOpenVisual
)
}

Expand Down Expand Up @@ -1610,7 +1651,9 @@ internal fun AidenVisualRow(
attachments: List<AidenMessageAttachment>,
loadAttachmentImage: suspend (AidenMessageAttachment) -> ByteArray?,
palette: sbtbiswas.AidenOnTheGo.config.AidenPalette,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
/** Set when the Mac serves interactive visuals (revision 35); tapping the snapshot opens it. */
onOpenInteractive: ((AidenVisualViewerRequest) -> Unit)? = null
) {
val display = remember(visual, attachments) { aidenVisualDisplay(visual, attachments) }
val snapshot = display.snapshot
Expand All @@ -1620,7 +1663,10 @@ internal fun AidenVisualRow(
attachment = snapshot,
title = visual.title,
wide = visual.layout == AidenChatVisual.LAYOUT_WIDE,
loadData = loadAttachmentImage
loadData = loadAttachmentImage,
onOpenInteractive = onOpenInteractive?.let { open ->
{ open(AidenVisualViewerRequest(visual = visual, snapshot = snapshot)) }
}
)
display.text?.let { AidenVisualFallbackText(text = it, visualId = visual.id, palette = palette) }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1178,6 +1178,59 @@ class AidenChatViewModel(
return true
}

/**
* The Mac announced `chat-visual-viewer-v1` (revision 35), so a tapped visual
* snapshot opens the interactive viewer instead of the image viewer.
*/
val canOpenInteractiveVisuals: Boolean
get() = instanceId.isNotEmpty() && AidenVisualViewerGate.isAvailable(
coordinator.serverInfo.value?.features,
coordinator.installationStore.activeInstallation
?.takeIf { it.instanceId == instanceId && it.deviceId == deviceId }
?.serverFeatures
)

/** Saved viewer documents; the coordinator purges them with any pairing. */
val visualDocumentCache: sbtbiswas.AidenOnTheGo.persistence.AidenVisualDocumentCache
get() = coordinator.visualDocumentCache

/** Names this pairing in visual document cache keys. */
val visualDocumentHostIdentity: String
get() = "$instanceId\u001f$deviceId"

/** The viewer page for [visualId] from the bound Mac (revision 35). */
suspend fun visualDocument(
visualId: String,
scheme: AidenVisualDocumentScheme,
theme: String
): String {
val client = activeClient() ?: throw AidenRemoteClientException.Disconnected()
val html = try {
client.visualDocument(chatId, visualId, scheme, theme)
} catch (error: AidenRemoteClientException.Server) {
// A revoked credential removes the pairing, and with it every saved document.
if (error.isCredentialRevoked && activeClient() === client &&
coordinator.installationStore.activeInstallation?.instanceId == instanceId) {
coordinator.removeInstallation(instanceId)
}
throw error
}
if (activeClient() !== client) throw AidenRemoteClientException.Disconnected()
return html
}

/**
* Appends a follow-up a visual asked for, after the user confirmed it, to the
* composer draft. It is never sent from here. Returns false when the chat is
* read-only or the text is blank.
*/
fun addVisualPrompt(text: String): Boolean {
if (isReadOnlyPresentation) return false
val next = AidenVisualPromptDraft.appended(_draft.value, text) ?: return false
updateDraft(next)
return true
}

fun updateDraft(text: String) {
_draft.value = text
draftWriter?.schedule(text)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,15 +259,18 @@ internal object AidenVisualSnapshotFrame {

/**
* A visual's snapshot, described by the visual's [title] rather than the
* attachment's file name. Tapping opens the same full-screen viewer as photos.
* attachment's file name. Tapping opens the same full-screen viewer as photos,
* or, when the Mac serves interactive visuals ([onOpenInteractive], revision 35),
* the interactive viewer; the image viewer then stays one accessibility action away.
*/
@Composable
internal fun AidenVisualSnapshotImage(
attachment: AidenMessageAttachment,
title: String,
wide: Boolean,
loadData: suspend (AidenMessageAttachment) -> ByteArray?,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
onOpenInteractive: (() -> Unit)? = null
) {
var galleryOpen by remember { mutableStateOf(false) }
// An image this session already decoded sizes the row on its first frame.
Expand All @@ -279,6 +282,15 @@ internal fun AidenVisualSnapshotImage(
}
val snapshotState = stringResource(R.string.chat_visual_snapshot_description)
val openLabel = stringResource(R.string.chat_visual_snapshot_open)
val openInteractiveLabel = stringResource(R.string.chat_visual_open_interactive)
val interactiveSemantics = if (onOpenInteractive != null) {
Modifier.semantics {
customActions = listOf(
CustomAccessibilityAction(openInteractiveLabel) { onOpenInteractive(); true },
CustomAccessibilityAction(openLabel) { galleryOpen = true; true }
)
}
} else Modifier
AidenAttachmentImage(
attachment = attachment,
maximumPixelSize = AidenVisualSnapshotFrame.MAXIMUM_PIXEL_SIZE,
Expand All @@ -289,7 +301,17 @@ internal fun AidenVisualSnapshotImage(
.fillMaxWidth()
.aspectRatio(AidenVisualSnapshotFrame.aspectRatio(imageSize?.first, imageSize?.second, wide))
.semantics { stateDescription = snapshotState }
.clickable(onClickLabel = openLabel, role = Role.Button) { galleryOpen = true },
.then(interactiveSemantics)
.clickable(
onClickLabel = if (onOpenInteractive != null) openInteractiveLabel else openLabel,
role = Role.Button
) {
if (onOpenInteractive != null) {
onOpenInteractive()
} else {
galleryOpen = true
}
},
alignment = Alignment.CenterStart,
imageCornerRadius = 16.dp
)
Expand Down Expand Up @@ -572,7 +594,7 @@ private fun AidenAttachmentImage(
}

@Composable
private fun AidenAttachmentGallery(
internal fun AidenAttachmentGallery(
attachments: List<AidenMessageAttachment>,
initialPage: Int,
loadData: suspend (AidenMessageAttachment) -> ByteArray?,
Expand Down
Loading
Loading