From 110ebf47346e85c82d275990b332854c4e524b5f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 15:39:26 +0000 Subject: [PATCH] feat(mobile): add H3ERE pipeline scaffolding around memory cylinder Add a structural cage visualization around the 3D memory cylinder that lights up as SSE reasoning events flow through the H3ERE pipeline. - PipelineScaffolding.kt: Data model for 7 pipeline stages with glow timing, projection math for scaffold points on cylinder surface - ReasoningStreamClient: Emit PipelineStep events alongside Emoji events - InteractViewModel: Track PipelineState, reset on new thought rounds - LiveGraphBackground: Draw vertical struts + horizontal rings per stage, rings glow in stage color when activated, fade over 3 seconds - InteractScreen: Wire pipelineState through to background https://claude.ai/code/session_01V6SFhPJWn2SMncFyBP5cQW --- .../shared/api/ReasoningStreamClient.kt | 16 ++ .../shared/ui/screens/InteractScreen.kt | 5 +- .../ui/screens/graph/LiveGraphBackground.kt | 235 +++++++++++++++++- .../ui/screens/graph/PipelineScaffolding.kt | 136 ++++++++++ .../shared/viewmodels/InteractViewModel.kt | 18 ++ 5 files changed, 407 insertions(+), 3 deletions(-) create mode 100644 mobile/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/graph/PipelineScaffolding.kt diff --git a/mobile/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/api/ReasoningStreamClient.kt b/mobile/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/api/ReasoningStreamClient.kt index 07e1fafac..af3aba238 100644 --- a/mobile/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/api/ReasoningStreamClient.kt +++ b/mobile/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/api/ReasoningStreamClient.kt @@ -146,6 +146,12 @@ class ReasoningStreamClient( it.contains("task_complete") || it.contains("reject") } == true) + // Emit pipeline step for scaffolding visualization + result.add(ReasoningEvent.PipelineStep( + eventType = eventType, + isNewThought = eventType == "thought_start" + )) + result.add(ReasoningEvent.Emoji(emoji, eventType, isComplete)) } } catch (e: Exception) { @@ -167,4 +173,14 @@ sealed class ReasoningEvent { val eventType: String, val isComplete: Boolean = false ) : ReasoningEvent() + + /** + * Raw pipeline step event for scaffolding visualization. + * Emitted for every SSE event type so the UI can light up + * the corresponding H3ERE pipeline ring. + */ + data class PipelineStep( + val eventType: String, + val isNewThought: Boolean = false // true for thought_start (resets pipeline) + ) : ReasoningEvent() } diff --git a/mobile/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/InteractScreen.kt b/mobile/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/InteractScreen.kt index a6a56f6d7..c2b6cc961 100644 --- a/mobile/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/InteractScreen.kt +++ b/mobile/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/InteractScreen.kt @@ -70,6 +70,7 @@ import kotlinx.datetime.toLocalDateTime import ai.ciris.mobile.shared.api.CIRISApiClient import ai.ciris.mobile.shared.ui.screens.graph.GraphColors import ai.ciris.mobile.shared.ui.screens.graph.LiveGraphBackground +import ai.ciris.mobile.shared.ui.screens.graph.PipelineState import ai.ciris.mobile.shared.ui.theme.ColorTheme import ai.ciris.mobile.shared.ui.theme.InteractTheme @@ -128,6 +129,7 @@ fun InteractScreen( val creditStatus by viewModel.creditStatus.collectAsState() val trustStatus by viewModel.trustStatus.collectAsState() val attachedFiles by viewModel.attachedFiles.collectAsState() + val pipelineState by viewModel.pipelineState.collectAsState() // Observe text input requests for test automation val textInputRequest by TestAutomation.textInputRequests.collectAsState() @@ -225,7 +227,8 @@ fun InteractScreen( onSpinApartTriggered = { // Reset energy after explosion spinEnergy = 0f - } + }, + pipelineState = pipelineState // H3ERE scaffolding visualization ) } diff --git a/mobile/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/graph/LiveGraphBackground.kt b/mobile/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/graph/LiveGraphBackground.kt index cb6668c70..2e98cb79e 100644 --- a/mobile/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/graph/LiveGraphBackground.kt +++ b/mobile/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/graph/LiveGraphBackground.kt @@ -70,7 +70,8 @@ fun LiveGraphBackground( externalRotation: Float = 0f, // External rotation from swipe gestures (degrees) spinEnergy: Float = 0f, // Accumulated spin energy from multiple flicks spinEnergyThreshold: Float = 100f, // Energy threshold to trigger spin apart - onSpinApartTriggered: () -> Unit = {} // Callback when spin apart animation starts + onSpinApartTriggered: () -> Unit = {}, // Callback when spin apart animation starts + pipelineState: PipelineState = PipelineState() // H3ERE pipeline scaffolding state ) { // Log when composable is first called PlatformLogger.i(TAG, ">>> LiveGraphBackground COMPOSING (eventTrigger=$eventTrigger, opacity=$baseOpacity)") @@ -268,6 +269,20 @@ fun LiveGraphBackground( val centerY = size.height / 2 val cylinderRadius = minOf(size.width, size.height) * 0.35f val cylinderHeight = size.height * 0.6f + val currentTimeMs = kotlinx.datetime.Clock.System.now().toEpochMilliseconds() + + // Draw H3ERE pipeline scaffolding (behind everything) + drawPipelineScaffolding( + pipelineState = pipelineState, + rotationY = rotationY, + rotationX = rotationX, + centerX = centerX, + centerY = centerY, + cylinderRadius = cylinderRadius * 1.15f, // Slightly larger than node cylinder + cylinderHeight = cylinderHeight, + currentTimeMs = currentTimeMs, + baseOpacity = baseOpacity + ) // Project and draw nodes (with optional spin apart explosion) val projectedNodes = nodes.mapIndexed { index, node -> @@ -303,7 +318,6 @@ fun LiveGraphBackground( } // Draw nodes sorted by depth (furthest first) - val currentTimeMs = kotlinx.datetime.Clock.System.now().toEpochMilliseconds() projectedNodes.sortedByDescending { it.depth }.forEach { projected -> // Calculate birth animation progress (0 = just born, 1 = mature) val birthProgress = if (projected.birthTimeMs > 0) { @@ -539,3 +553,220 @@ private fun DrawScope.drawBackgroundEdge( ) ) } + +// ============================================================================= +// H3ERE Pipeline Scaffolding Drawing +// ============================================================================= + +/** + * Draw H3ERE pipeline scaffolding around the memory cylinder. + * + * The scaffolding consists of: + * 1. Vertical struts running along the cylinder surface + * 2. Horizontal rings at each pipeline stage height + * 3. Glow effects on rings when their stage is active + * + * Stages are distributed evenly along the cylinder height, with + * padding at top and bottom. The scaffolding radius is slightly + * larger than the node cylinder so it wraps around the data. + */ +private fun DrawScope.drawPipelineScaffolding( + pipelineState: PipelineState, + rotationY: Float, + rotationX: Float, + centerX: Float, + centerY: Float, + cylinderRadius: Float, + cylinderHeight: Float, + currentTimeMs: Long, + baseOpacity: Float +) { + val stages = pipelineState.stages + if (stages.isEmpty()) return + + val strutCount = PipelineStage.STRUT_COUNT + val verticalPadding = 0.1f // 10% padding top and bottom + + // Draw vertical struts first (behind rings) + drawScaffoldStruts( + strutCount = strutCount, + rotationY = rotationY, + rotationX = rotationX, + centerX = centerX, + centerY = centerY, + cylinderRadius = cylinderRadius, + cylinderHeight = cylinderHeight, + verticalPadding = verticalPadding, + baseOpacity = baseOpacity + ) + + // Draw horizontal rings for each pipeline stage + stages.forEachIndexed { index, stage -> + // Distribute stages evenly along cylinder height + val heightFraction = verticalPadding + + (index.toFloat() / (stages.size - 1).coerceAtLeast(1)) * (1f - 2 * verticalPadding) + + // Calculate glow intensity (1.0 when just activated, fading to 0) + val glowIntensity = if (stage.activatedAtMs > 0) { + val elapsed = currentTimeMs - stage.activatedAtMs + if (elapsed < PipelineStage.GLOW_DURATION_MS) { + 1f - (elapsed.toFloat() / PipelineStage.GLOW_DURATION_MS) + } else 0f + } else 0f + + drawScaffoldRing( + stage = stage, + heightFraction = heightFraction, + glowIntensity = glowIntensity, + rotationY = rotationY, + rotationX = rotationX, + centerX = centerX, + centerY = centerY, + cylinderRadius = cylinderRadius, + cylinderHeight = cylinderHeight, + baseOpacity = baseOpacity + ) + } +} + +/** + * Draw vertical struts connecting the top and bottom of the scaffolding. + * These give the scaffolding its cage-like structure. + */ +private fun DrawScope.drawScaffoldStruts( + strutCount: Int, + rotationY: Float, + rotationX: Float, + centerX: Float, + centerY: Float, + cylinderRadius: Float, + cylinderHeight: Float, + verticalPadding: Float, + baseOpacity: Float +) { + val topFraction = verticalPadding + val bottomFraction = 1f - verticalPadding + + for (i in 0 until strutCount) { + val theta = (i.toFloat() / strutCount) * 2 * PI.toFloat() + + val top = projectScaffoldPoint( + theta, topFraction, rotationY, rotationX, + centerX, centerY, cylinderRadius, cylinderHeight + ) + val bottom = projectScaffoldPoint( + theta, bottomFraction, rotationY, rotationX, + centerX, centerY, cylinderRadius, cylinderHeight + ) + + // Only draw struts on the visible side (or dimmed on back) + val avgAlpha = (top.alpha + bottom.alpha) / 2 * baseOpacity * 0.15f + + if (avgAlpha > 0.01f) { + drawLine( + color = Color.White.safeAlpha(avgAlpha), + start = Offset(top.screenX, top.screenY), + end = Offset(bottom.screenX, bottom.screenY), + strokeWidth = 0.8f + ) + } + } +} + +/** + * Draw a single horizontal pipeline ring at the given height. + * When glowIntensity > 0, the ring lights up in the stage's color. + */ +private fun DrawScope.drawScaffoldRing( + stage: PipelineStage, + heightFraction: Float, + glowIntensity: Float, + rotationY: Float, + rotationX: Float, + centerX: Float, + centerY: Float, + cylinderRadius: Float, + cylinderHeight: Float, + baseOpacity: Float +) { + // Sample points around the ring circumference + val segments = 48 // Smooth ring + val points = (0..segments).map { i -> + val theta = (i.toFloat() / segments) * 2 * PI.toFloat() + projectScaffoldPoint( + theta, heightFraction, rotationY, rotationX, + centerX, centerY, cylinderRadius, cylinderHeight + ) + } + + // Determine ring color and intensity + val isActive = glowIntensity > 0f + val ringColor = if (isActive) stage.color else Color.White + val baseRingAlpha = if (isActive) { + 0.2f + glowIntensity * 0.7f // Active: 0.2 to 0.9 + } else { + 0.08f // Inactive: very subtle + } + val ringWidth = if (isActive) { + 1.5f + glowIntensity * 2f // Active: 1.5 to 3.5px + } else { + 0.8f // Inactive: thin + } + + // Draw ring as connected line segments + val path = Path() + var started = false + + for (i in 0 until segments) { + val p1 = points[i] + val p2 = points[i + 1] + + val segAlpha = ((p1.alpha + p2.alpha) / 2 * baseOpacity * baseRingAlpha).coerceIn(0f, 1f) + + if (segAlpha > 0.01f) { + if (!started) { + path.moveTo(p1.screenX, p1.screenY) + started = true + } + path.lineTo(p2.screenX, p2.screenY) + } + } + + if (started) { + drawPath( + path = path, + color = ringColor.safeAlpha(baseRingAlpha * baseOpacity), + style = Stroke(width = ringWidth, cap = StrokeCap.Round) + ) + } + + // Draw outer glow for active rings (larger, more transparent) + if (isActive && glowIntensity > 0.1f) { + val glowPath = Path() + var glowStarted = false + + for (i in 0 until segments) { + val p1 = points[i] + val p2 = points[i + 1] + + val segAlpha = ((p1.alpha + p2.alpha) / 2 * baseOpacity * glowIntensity * 0.3f) + .coerceIn(0f, 1f) + + if (segAlpha > 0.01f) { + if (!glowStarted) { + glowPath.moveTo(p1.screenX, p1.screenY) + glowStarted = true + } + glowPath.lineTo(p2.screenX, p2.screenY) + } + } + + if (glowStarted) { + drawPath( + path = glowPath, + color = stage.color.safeAlpha(glowIntensity * 0.3f * baseOpacity), + style = Stroke(width = ringWidth + 6f, cap = StrokeCap.Round) + ) + } + } +} diff --git a/mobile/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/graph/PipelineScaffolding.kt b/mobile/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/graph/PipelineScaffolding.kt new file mode 100644 index 000000000..9937b1e0f --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/graph/PipelineScaffolding.kt @@ -0,0 +1,136 @@ +package ai.ciris.mobile.shared.ui.screens.graph + +import androidx.compose.ui.graphics.Color +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.sin + +/** + * H3ERE pipeline stage representation for scaffolding visualization. + * + * Each stage maps to a reasoning stream SSE event type and is drawn + * as a horizontal ring around the memory cylinder. Rings glow when + * their corresponding event fires, then fade over GLOW_DURATION_MS. + */ +data class PipelineStage( + val eventType: String, + val label: String, + val color: Color, + val activatedAtMs: Long = 0L // 0 = never activated +) { + companion object { + /** How long a ring glows after activation (ms) */ + const val GLOW_DURATION_MS = 3000L + + /** Number of vertical struts around the cylinder */ + const val STRUT_COUNT = 12 + + /** All H3ERE pipeline stages in order (top to bottom on cylinder) */ + fun defaultStages(): List = listOf( + PipelineStage("thought_start", "THINK", Color(0xFF60A5FA)), // Blue + PipelineStage("snapshot_and_context", "CONTEXT", Color(0xFF34D399)), // Green + PipelineStage("dma_results", "DMA", Color(0xFFFBBF24)), // Yellow + PipelineStage("idma_result", "IDMA", Color(0xFFF97316)), // Orange + PipelineStage("aspdma_result", "SELECT", Color(0xFFA78BFA)), // Purple + PipelineStage("conscience_result", "CONSCIENCE", Color(0xFF38BDF8)), // Sky + PipelineStage("action_result", "ACT", Color(0xFF4ADE80)) // Emerald + ) + } +} + +/** + * Immutable pipeline state passed to LiveGraphBackground for scaffolding rendering. + */ +data class PipelineState( + val stages: List = PipelineStage.defaultStages(), + val version: Int = 0 // Increments on each update to trigger recomposition +) { + /** + * Return a new state with the given event type activated at the current time. + */ + fun activate(eventType: String, currentTimeMs: Long): PipelineState { + val updated = stages.map { stage -> + if (stage.eventType == eventType) { + stage.copy(activatedAtMs = currentTimeMs) + } else { + stage + } + } + return copy(stages = updated, version = version + 1) + } + + /** + * Reset all stages (e.g., on new thought round). + */ + fun reset(): PipelineState { + return copy( + stages = PipelineStage.defaultStages(), + version = version + 1 + ) + } +} + +/** + * Projected scaffolding point on the cylinder surface. + */ +data class ScaffoldPoint( + val screenX: Float, + val screenY: Float, + val alpha: Float, // Depth-based alpha (back of cylinder is dimmer) + val isBehind: Boolean // True if on the back half +) + +/** + * Project a point on the scaffolding cylinder to 2D screen coordinates. + * + * @param theta Angle around cylinder (radians) + * @param heightFraction Vertical position 0=top, 1=bottom + * @param rotationY Current Y rotation (degrees) + * @param rotationX Current X tilt (degrees) + * @param centerX Screen center X + * @param centerY Screen center Y + * @param cylinderRadius Cylinder radius in pixels + * @param cylinderHeight Cylinder height in pixels + */ +fun projectScaffoldPoint( + theta: Float, + heightFraction: Float, + rotationY: Float, + rotationX: Float, + centerX: Float, + centerY: Float, + cylinderRadius: Float, + cylinderHeight: Float +): ScaffoldPoint { + // Apply Y rotation + val rotatedTheta = theta + (rotationY.toDouble() * PI / 180.0).toFloat() + + // 3D position on cylinder surface + val x3d = cos(rotatedTheta) * cylinderRadius + val z3d = sin(rotatedTheta) * cylinderRadius + val y3d = (heightFraction - 0.5f) * cylinderHeight // Center vertically + + // Apply X tilt + val rotX = rotationX.toDouble() * PI / 180.0 + val y3dRotated = (y3d * cos(rotX) - z3d * sin(rotX)).toFloat() + val z3dRotated = (y3d * sin(rotX) + z3d * cos(rotX)).toFloat() + + // Perspective projection + val perspective = 800f + val scale = perspective / (perspective + z3dRotated) + + val screenX = centerX + x3d * scale + val screenY = centerY + y3dRotated * scale + + // Depth-based alpha: front is brighter, back is dimmer + val normalizedDepth = (z3dRotated + cylinderRadius) / (2 * cylinderRadius) + val alpha = (0.15f + 0.85f * (1f - normalizedDepth)).coerceIn(0.05f, 1f) + + return ScaffoldPoint( + screenX = screenX, + screenY = screenY, + alpha = alpha, + isBehind = z3dRotated < 0 + ) +} diff --git a/mobile/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/viewmodels/InteractViewModel.kt b/mobile/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/viewmodels/InteractViewModel.kt index 645ea883e..d8d9bd140 100644 --- a/mobile/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/viewmodels/InteractViewModel.kt +++ b/mobile/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/viewmodels/InteractViewModel.kt @@ -5,6 +5,7 @@ import ai.ciris.api.models.ImagePayload import ai.ciris.mobile.shared.api.CIRISApiClient import ai.ciris.mobile.shared.api.ReasoningEvent import ai.ciris.mobile.shared.api.ReasoningStreamClient +import ai.ciris.mobile.shared.ui.screens.graph.PipelineState import ai.ciris.mobile.shared.auth.TokenManager import ai.ciris.mobile.shared.models.ActionDetails import ai.ciris.mobile.shared.models.ActionType @@ -179,6 +180,10 @@ class InteractViewModel( private val _timelineEvents = MutableStateFlow>(emptyList()) val timelineEvents: StateFlow> = _timelineEvents.asStateFlow() + // H3ERE pipeline scaffolding state - tracks which pipeline stages are active + private val _pipelineState = MutableStateFlow(PipelineState()) + val pipelineState: StateFlow = _pipelineState.asStateFlow() + // Show timeline popup private val _showTimeline = MutableStateFlow(false) val showTimeline: StateFlow = _showTimeline.asStateFlow() @@ -1157,6 +1162,19 @@ class InteractViewModel( _sseConnected.value = false _agentProcessingState.value = AgentProcessingState.IDLE } + is ReasoningEvent.PipelineStep -> { + // Update pipeline scaffolding visualization + val now = Clock.System.now().toEpochMilliseconds() + if (event.isNewThought) { + // New thought round - reset then activate + _pipelineState.value = _pipelineState.value + .reset() + .activate(event.eventType, now) + } else { + _pipelineState.value = _pipelineState.value + .activate(event.eventType, now) + } + } is ReasoningEvent.Emoji -> { // Add bubble emoji (floats up and disappears) addBubbleEmoji(event.emoji)