import "package:flutter/foundation.dart"; import "dart:typed_data"; import "../../src/chat/tool_loop_service.dart"; import "../../src/compact/compact_service.dart"; import "../../src/hooks/hook_loader.dart"; import "../../src/hooks/hook_runner.dart"; import "../../src/permissions/permission_types.dart"; import "../../src/session/session_runtime.dart"; import "../../src/session/session_types.dart"; import "../models/attachment.dart"; import "settings_provider.dart"; // ChatProvider is now a thin registry over SessionRuntime instances. // // Each thread gets its own SessionRuntime which holds all the mutable state // that used to live here — api messages, the http client, loading flags, etc. // Switching threads just changes _activeSessionId. Background threads keep // running and save themselves to disk; when you switch back you see their // live state. class ChatProvider extends ChangeNotifier { ChatProvider(this._settingsProvider) { _initHooks(); } final SettingsProvider _settingsProvider; ToolLoopService _toolLoopService = ToolLoopService(); HookRunner? _hookRunner; final Map _runtimes = {}; String? _activeSessionId; // ─── hooks ────────────────────────────────────────────────────────────────── Future _initHooks() async { try { final hooks = await HookLoader.loadHooks(); _hookRunner = HookRunner(hooks: hooks); _toolLoopService = ToolLoopService(hookRunner: _hookRunner); } catch (e) { print("Hook init failed: $e"); } } // ─── active runtime accessors ──────────────────────────────────────────────── SessionRuntime? get _active => _activeSessionId != null ? _runtimes[_activeSessionId] : null; List get messages => _active?.messages ?? const []; int get messageCount => messages.length; String? get workingDirectory => _active?.workingDirectory; bool get hasConversation => _active != null; bool get isLoading => _active?.isLoading ?? false; bool get isCompacting => _active?.isCompacting ?? false; bool get isStopping => _active?.isStopping ?? false; int get queuedMessageCount => _active?.queuedMessageCount ?? 0; List get queuedMessages => _active?.queuedMessages ?? const []; PendingPermission? get pendingPermission => _active?.pendingPermission; String? get lastCompactSummary => _active?.lastCompactSummary; TokenWarningState? get tokenWarningState => _active?.tokenWarningState; String get threadPermissionMode => _active?.permissionModeOverride ?? "default"; Future setThreadPermissionMode(String mode) => _active?.setPermissionModeOverride(mode) ?? Future.value(); bool isSessionRunning(String sessionId) { final r = _runtimes[sessionId]; return r != null && (r.isLoading || r.isCompacting); } bool sessionNeedsAttention(String sessionId) { final r = _runtimes[sessionId]; return r != null && r.pendingPermission != null; } bool sessionHasUnreadResult(String sessionId) { final r = _runtimes[sessionId]; return r != null && r.hasUnreadResult; } int get contextTokens { final msgs = messages; for (var i = msgs.length - 1; i >= 0; i--) { final ct = msgs[i].contextTokens; if (ct != null && ct > 0) return ct; } return 0; } // ─── session lifecycle ─────────────────────────────────────────────────────── // Called when the user switches to (or creates) a session. // Creates a new runtime if one doesn't already exist for this session. void activateSession(ConversationSession session) { final id = session.id; if (!_runtimes.containsKey(id)) { _runtimes[id] = SessionRuntime( session: session, toolLoopService: _toolLoopService, hookRunner: _hookRunner, getSettings: () => _settingsProvider.settings, normalizeModelId: (m) => _settingsProvider.normalizeModelId(m), onChanged: notifyListeners, ); } _activeSessionId = id; _runtimes[id]?.markRead(); notifyListeners(); } // Fast-path: switch focus to an already-running runtime without touching disk. void activateSessionById(String sessionId) { if (_runtimes.containsKey(sessionId)) { _activeSessionId = sessionId; _runtimes[sessionId]?.markRead(); notifyListeners(); } } // Called when the user starts a new blank chat — no session exists yet. void clearConversation() { _activeSessionId = null; // prune dead runtimes that are done _runtimes.removeWhere((_, r) => !r.isLoading && !r.isCompacting); notifyListeners(); } // Legacy compat — kept so HomeCoordinator doesn't need parallel changes // for paths that still call this. Routes to activateSession. void setConversation(ConversationSession session) => activateSession(session); // ─── actions — delegate to active runtime ─────────────────────────────────── Future sendMessage( String text, { QueuePriority priority = QueuePriority.next, List? attachments, }) async { final runtime = _active; if (runtime == null) return; final adapted = attachments ?.map((a) => AttachmentData( name: a.name, mimeType: a.mimeType, data: a.data, )) .toList(); await runtime.sendMessage(text, priority: priority, attachments: adapted); } void stopGenerating() => _active?.stopGenerating(); Future runCompact({String? customInstructions}) => _active?.runCompact(customInstructions: customInstructions) ?? Future.value(); Future resolvePermission(PermissionDecision decision) => _active?.resolvePermission(decision) ?? Future.value(); void removeQueuedMessage(int index) => _active?.removeQueuedMessage(index); // ─── dispose ──────────────────────────────────────────────────────────────── @override void dispose() { for (final r in _runtimes.values) { r.dispose(); } super.dispose(); } }