From c7465b1a3692194aee97bc2674a9746c2d691f19 Mon Sep 17 00:00:00 2001 From: Gustaf Eden Date: Fri, 19 Dec 2025 23:47:17 +0100 Subject: [PATCH 1/2] feat: Add activity tips during long operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows "did you know" facts after 4 seconds of agent activity. Facts are pre-loaded from remote source at startup. Minimum 8-second display duration prevents flickering. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- lib/main.dart | 4 + .../mixins/activity_tip_mixin.dart | 157 ++++++++++++++++++ .../agent_network/network_execution_page.dart | 34 +++- lib/modules/haiku/fact_source_service.dart | 86 ++++++++++ 4 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 lib/modules/agent_network/mixins/activity_tip_mixin.dart create mode 100644 lib/modules/haiku/fact_source_service.dart diff --git a/lib/main.dart b/lib/main.dart index c334a1a0..f05aa4d0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,7 @@ import 'package:vide_cli/theme/theme.dart'; import 'package:vide_core/vide_core.dart'; import 'package:vide_cli/modules/agent_network/state/agent_networks_state_notifier.dart'; import 'package:vide_cli/services/sentry_service.dart'; +import 'package:vide_cli/modules/haiku/fact_source_service.dart'; /// Provider for sidebar focus state, shared across the app. /// Pages can update this to give focus to the sidebar. @@ -69,6 +70,9 @@ void main(List args, {List overrides = const []}) async { // before launching the actual binary. The version indicator shows "ready" when // an update has been downloaded and will be applied on next launch. + // Pre-fetch facts for activity tips (non-blocking) + FactSourceService.instance.initialize(); + await container.read(agentNetworksStateNotifierProvider.notifier).init(); await runApp( diff --git a/lib/modules/agent_network/mixins/activity_tip_mixin.dart b/lib/modules/agent_network/mixins/activity_tip_mixin.dart new file mode 100644 index 00000000..37377921 --- /dev/null +++ b/lib/modules/agent_network/mixins/activity_tip_mixin.dart @@ -0,0 +1,157 @@ +import 'dart:async'; +import 'package:nocterm/nocterm.dart'; +import 'package:nocterm_riverpod/nocterm_riverpod.dart'; +import 'package:claude_api/claude_api.dart'; +import 'package:vide_cli/modules/haiku/haiku_providers.dart'; +import 'package:vide_cli/modules/haiku/fact_source_service.dart'; +import 'package:vide_cli/modules/agent_network/models/agent_status.dart'; +import 'package:vide_cli/modules/agent_network/state/agent_status_manager.dart'; + +/// Mixin that provides activity tip functionality. +/// +/// Shows "did you know" facts after 4 seconds of agent activity. +/// Facts display for a minimum of 8 seconds to prevent flickering. +/// +/// Usage: +/// 1. Add `with ActivityTipMixin` to your State class +/// 2. Implement `activityTipConversation` and `activityTipAgentId` getters +/// 3. Call `initActivityTips()` in initState +/// 4. Call `disposeActivityTips()` in dispose +/// 5. Call start/stop at conversation state change points +/// 6. Call `handleAgentStatusChange()` from build when agent status changes +mixin ActivityTipMixin on State { + // State + Timer? _activityTipTimer; + static const _activityTipThreshold = Duration(seconds: 4); + bool _isGeneratingActivityTip = false; + + // Minimum fact display duration tracking + DateTime? _factShownAt; + Timer? _factDisplayTimer; + static const _minimumFactDisplayDuration = Duration(seconds: 8); + + /// Override this to provide the current conversation state + Conversation get activityTipConversation; + + /// Override this to provide the agent session ID for status lookups + String get activityTipAgentId; + + /// Call in initState if already receiving response + void initActivityTips() { + if (activityTipConversation.state == ConversationState.receivingResponse) { + startActivityTipTimer(); + } + } + + /// Call in dispose to clean up both timers + void disposeActivityTips() { + _activityTipTimer?.cancel(); + _factDisplayTimer?.cancel(); + } + + void startActivityTipTimer() { + _stopActivityTipTimerInternal(); + _activityTipTimer = Timer(_activityTipThreshold, _onActivityTipThresholdReached); + } + + void stopActivityTipTimer() { + _stopActivityTipTimerInternal(); + _clearActivityTipWithMinimumDuration(); + } + + void _stopActivityTipTimerInternal() { + _activityTipTimer?.cancel(); + _activityTipTimer = null; + _isGeneratingActivityTip = false; + } + + /// Check if we should show activity tips. + /// Tips show when: agent is receiving response OR waiting for subagents. + bool shouldShowActivityTips() { + final isReceiving = activityTipConversation.state == ConversationState.receivingResponse; + final agentStatus = context.read(agentStatusProvider(activityTipAgentId)); + final isWaitingForAgent = agentStatus == AgentStatus.waitingForAgent; + return isReceiving || isWaitingForAgent; + } + + void _onActivityTipThresholdReached() { + if (!mounted || _isGeneratingActivityTip) return; + + final shouldShow = shouldShowActivityTips(); + if (!shouldShow) return; + + _isGeneratingActivityTip = true; + + // Get a random pre-generated fact directly (no Haiku needed) + final fact = FactSourceService.instance.getRandomFact(); + + _isGeneratingActivityTip = false; + if (!mounted) return; + + final shouldShowNow = shouldShowActivityTips(); + + if (fact != null && shouldShowNow) { + context.read(activityTipProvider.notifier).state = fact; + // Record when fact was shown and start timer to clear after minimum duration + _factShownAt = DateTime.now(); + _factDisplayTimer?.cancel(); + _factDisplayTimer = Timer(_minimumFactDisplayDuration, _onFactDisplayTimerExpired); + } + } + + /// Called when the minimum fact display duration has elapsed. + /// Clears the fact if conditions no longer warrant showing it. + void _onFactDisplayTimerExpired() { + _factDisplayTimer = null; + _factShownAt = null; + if (!mounted) return; + // Only clear if we're no longer in a state that should show tips + if (!shouldShowActivityTips()) { + context.read(activityTipProvider.notifier).state = null; + } + } + + /// Clear activity tip, respecting minimum display duration + void _clearActivityTipWithMinimumDuration() { + if (_factShownAt != null) { + final elapsed = DateTime.now().difference(_factShownAt!); + if (elapsed >= _minimumFactDisplayDuration) { + // Minimum time elapsed, safe to clear immediately + _factDisplayTimer?.cancel(); + _factDisplayTimer = null; + _factShownAt = null; + context.read(activityTipProvider.notifier).state = null; + } + // else: let _factDisplayTimer handle cleanup after minimum duration + } else { + // No fact showing, just clear + context.read(activityTipProvider.notifier).state = null; + } + } + + /// Handle agent status changes from build(). + /// Call this when agentStatus changes to start/stop timer accordingly. + /// [agentStatus] The current agent status + /// [conversationState] The current conversation state + void handleAgentStatusChange(AgentStatus agentStatus, ConversationState conversationState) { + // Start timer when waiting for agent (if not already running) + if (agentStatus == AgentStatus.waitingForAgent && + _activityTipTimer == null && + !_isGeneratingActivityTip) { + Future.microtask(() { + if (mounted) startActivityTipTimer(); + }); + } + // Stop timer when no longer waiting for agent and not receiving response + else if (agentStatus != AgentStatus.waitingForAgent && + conversationState != ConversationState.receivingResponse && + _activityTipTimer != null) { + Future.microtask(() { + if (mounted) { + _stopActivityTipTimerInternal(); + _clearActivityTipWithMinimumDuration(); + } + }); + } + } +} diff --git a/lib/modules/agent_network/network_execution_page.dart b/lib/modules/agent_network/network_execution_page.dart index b48f6f7d..f9c28517 100644 --- a/lib/modules/agent_network/network_execution_page.dart +++ b/lib/modules/agent_network/network_execution_page.dart @@ -24,6 +24,7 @@ import 'package:vide_cli/modules/haiku/message_enhancement_service.dart'; import 'package:vide_core/vide_core.dart'; import 'package:vide_cli/modules/permissions/permission_service.dart'; import 'package:vide_cli/theme/theme.dart'; +import 'package:vide_cli/modules/agent_network/mixins/activity_tip_mixin.dart'; import '../permissions/permission_scope.dart'; import '../../components/typing_text.dart'; @@ -237,7 +238,7 @@ class _AgentChat extends StatefulComponent { State<_AgentChat> createState() => _AgentChatState(); } -class _AgentChatState extends State<_AgentChat> { +class _AgentChatState extends State<_AgentChat> with ActivityTipMixin { StreamSubscription? _conversationSubscription; StreamSubscription? _queueSubscription; Conversation _conversation = Conversation.empty(); @@ -249,6 +250,13 @@ class _AgentChatState extends State<_AgentChat> { // Track conversation state changes for response timing ConversationState? _lastConversationState; + // ActivityTipMixin required getters + @override + Conversation get activityTipConversation => _conversation; + + @override + String get activityTipAgentId => component.client.sessionId; + @override void initState() { super.initState(); @@ -261,9 +269,11 @@ class _AgentChatState extends State<_AgentChat> { if (conversation.state == ConversationState.receivingResponse && _lastConversationState != ConversationState.receivingResponse) { AgentResponseTimes.startIfNeeded(component.client.sessionId); + startActivityTipTimer(); } else if (_lastConversationState == ConversationState.receivingResponse && conversation.state != ConversationState.receivingResponse) { AgentResponseTimes.clear(component.client.sessionId); + stopActivityTipTimer(); } _lastConversationState = conversation.state; @@ -288,6 +298,9 @@ class _AgentChatState extends State<_AgentChat> { setState(() => _queuedMessage = text); }); _queuedMessage = component.client.currentQueuedMessage; + + // Initialize activity tips + initActivityTips(); } void _syncTokenStats(Conversation conversation) { @@ -308,6 +321,7 @@ class _AgentChatState extends State<_AgentChat> { void dispose() { _conversationSubscription?.cancel(); _queueSubscription?.cancel(); + disposeActivityTips(); super.dispose(); } @@ -570,6 +584,16 @@ class _AgentChatState extends State<_AgentChat> { // Get dynamic loading words from provider final dynamicLoadingWords = context.watch(loadingWordsProvider); + // Watch activity tip and agent status for activity tips feature + final activityTip = context.watch(activityTipProvider); + final agentStatus = context.watch(agentStatusProvider(component.client.sessionId)); + + // Handle agent status changes for activity tip timer + handleAgentStatusChange(agentStatus, _conversation.state); + + // Check if actively working (for activity tip display) + final isActivelyWorking = _conversation.state == ConversationState.receivingResponse; + return Focusable( onKeyEvent: _handleKeyEvent, focused: true, @@ -637,6 +661,14 @@ class _AgentChatState extends State<_AgentChat> { ) else Text(' '), // Reserve 1 line when loading indicator is hidden + + // Show activity tip during long operations + if (activityTip != null && (isActivelyWorking || agentStatus == AgentStatus.waitingForAgent)) + Text( + activityTip, + style: TextStyle(color: Colors.green.withOpacity(0.7)), + ), + // Show quit warning if active if (component.showQuitWarning) Text( diff --git a/lib/modules/haiku/fact_source_service.dart b/lib/modules/haiku/fact_source_service.dart new file mode 100644 index 00000000..9ebaec96 --- /dev/null +++ b/lib/modules/haiku/fact_source_service.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; +import 'dart:io' show HttpClient; +import 'dart:math'; + +/// Service that fetches pre-generated facts from a curated pipeline. +/// Facts are fetched once at startup and randomly selected at runtime. +class FactSourceService { + // Singleton pattern + static final FactSourceService instance = FactSourceService._(); + FactSourceService._(); + + static const _factsUrl = + 'https://storage.googleapis.com/atelier-cms.firebasestorage.app/facts/latest.json'; + + // List of pre-generated facts + final List _facts = []; + + // Track which facts have been shown to avoid repeats within a session + final Set _shownIndices = {}; + + // Random generator + final _random = Random(); + + // Whether initial fetch has completed + bool _initialized = false; + + /// Whether the service has been initialized + bool get initialized => _initialized; + + /// Initialize by fetching facts from the curated pipeline. + Future initialize() async { + if (_initialized) return; + + try { + final client = HttpClient(); + client.connectionTimeout = const Duration(seconds: 5); + + final request = await client.getUrl(Uri.parse(_factsUrl)); + final response = await request.close(); + + if (response.statusCode == 200) { + final body = await response.transform(utf8.decoder).join(); + final data = jsonDecode(body) as Map; + + // Extract facts from the JSON + final factsList = data['facts'] as List? ?? []; + for (final fact in factsList) { + final text = fact['text'] as String?; + if (text != null && text.isNotEmpty) { + _facts.add(text); + } + } + } + } catch (e) { + // Graceful degradation - facts list stays empty + } + + _initialized = true; + } + + /// Get a random fact. Returns null if no facts available. + /// Tracks shown facts to avoid repeats within a session. + String? getRandomFact() { + if (!_initialized || _facts.isEmpty) return null; + + // Reset shown indices if we've shown all facts + if (_shownIndices.length >= _facts.length) { + _shownIndices.clear(); + } + + // Find an unshown fact + int index; + do { + index = _random.nextInt(_facts.length); + } while (_shownIndices.contains(index) && _shownIndices.length < _facts.length); + + _shownIndices.add(index); + return _facts[index]; + } + + /// Get next fact context (for compatibility with existing code) + /// Now just returns a random fact directly instead of source material. + String? getNextFactContext() { + return getRandomFact(); + } +} From 03fdbdb3501dd5786ee42c3f64e0412e43e0a474 Mon Sep 17 00:00:00 2001 From: Gustaf Eden Date: Sat, 20 Dec 2025 00:25:18 +0100 Subject: [PATCH 2/2] fix: Activity tips display below prompt with minimum 10s duration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move activity tip display from above input to below the prompt - Increase minimum display duration from 8 to 10 seconds - Fix tip disappearing immediately when agent finishes - now respects minimum duration timer before clearing - Remove unused import and variable 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../mixins/activity_tip_mixin.dart | 2 +- .../agent_network/network_execution_page.dart | 17 +++++++---------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/lib/modules/agent_network/mixins/activity_tip_mixin.dart b/lib/modules/agent_network/mixins/activity_tip_mixin.dart index 37377921..2be068f1 100644 --- a/lib/modules/agent_network/mixins/activity_tip_mixin.dart +++ b/lib/modules/agent_network/mixins/activity_tip_mixin.dart @@ -28,7 +28,7 @@ mixin ActivityTipMixin on State { // Minimum fact display duration tracking DateTime? _factShownAt; Timer? _factDisplayTimer; - static const _minimumFactDisplayDuration = Duration(seconds: 8); + static const _minimumFactDisplayDuration = Duration(seconds: 10); /// Override this to provide the current conversation state Conversation get activityTipConversation; diff --git a/lib/modules/agent_network/network_execution_page.dart b/lib/modules/agent_network/network_execution_page.dart index f9c28517..cc99f71e 100644 --- a/lib/modules/agent_network/network_execution_page.dart +++ b/lib/modules/agent_network/network_execution_page.dart @@ -591,9 +591,6 @@ class _AgentChatState extends State<_AgentChat> with ActivityTipMixin { // Handle agent status changes for activity tip timer handleAgentStatusChange(agentStatus, _conversation.state); - // Check if actively working (for activity tip display) - final isActivelyWorking = _conversation.state == ConversationState.receivingResponse; - return Focusable( onKeyEvent: _handleKeyEvent, focused: true, @@ -662,13 +659,6 @@ class _AgentChatState extends State<_AgentChat> with ActivityTipMixin { else Text(' '), // Reserve 1 line when loading indicator is hidden - // Show activity tip during long operations - if (activityTip != null && (isActivelyWorking || agentStatus == AgentStatus.waitingForAgent)) - Text( - activityTip, - style: TextStyle(color: Colors.green.withOpacity(0.7)), - ), - // Show quit warning if active if (component.showQuitWarning) Text( @@ -798,6 +788,13 @@ class _AgentChatState extends State<_AgentChat> with ActivityTipMixin { // Context usage bar with compact button _buildContextUsageSection(theme), + + // Show activity tip below prompt (shown for minimum duration even after agent finishes) + if (activityTip != null) + Text( + activityTip, + style: TextStyle(color: Colors.green.withOpacity(0.7)), + ), ], ), ],