import "package:file_picker/file_picker.dart"; import "package:provider/provider.dart"; import "package:shadcn_flutter/shadcn_flutter.dart"; import "../../src/project_store.dart"; import "../../src/session/session_types.dart"; import "../constants.dart"; import "../providers/chat_provider.dart"; import "../providers/cost_provider.dart"; import "../providers/projects_provider.dart"; import "../providers/session_provider.dart"; import "../providers/settings_provider.dart"; import "../widgets/app_header.dart"; import "../widgets/chat_view.dart"; import "../widgets/settings_sheet.dart"; class NewHomeScreen extends StatefulWidget { const NewHomeScreen({super.key}); @override State createState() => _NewHomeScreenState(); } class _NewHomeScreenState extends State { late final TextEditingController _messageController; @override void initState() { super.initState(); _messageController = TextEditingController(); } @override void dispose() { _messageController.dispose(); super.dispose(); } Iterable>> _filteredModels(String searchQuery) { final normalizedQuery = searchQuery.trim().toLowerCase(); if (normalizedQuery.isEmpty) { return _modelGroups.entries; } return _modelGroups.entries .map((entry) { final matchingModels = entry.value .where( (modelId) => modelId.toLowerCase().contains(normalizedQuery) || _modelLabel( modelId, ).toLowerCase().contains(normalizedQuery), ) .toList(); return MapEntry(entry.key, matchingModels); }) .where((entry) => entry.value.isNotEmpty); } Map> get _modelGroups { final groups = >{}; for (final model in selectableAiModels) { groups.putIfAbsent(model.group, () => []).add(model.id); } return groups; } String _modelLabel(String modelId) { for (final model in selectableAiModels) { if (model.id == modelId) { return model.label; } } return modelId; } Future _pickProjectDirectory() async { try { final selectedDirectory = await FilePicker.platform.getDirectoryPath( dialogTitle: "Select project directory", ); if (selectedDirectory == null || !mounted) { return; } final projectsProvider = context.read(); final sessionProvider = context.read(); final chatProvider = context.read(); final project = await projectsProvider.addProject(selectedDirectory); if (project == null && mounted) { await _showProjectPickerError( "The selected folder could not be added as a project.", ); return; } projectsProvider.selectProject(project!.id); sessionProvider.clearCurrentSession( workingDirectory: project.workingDirectory, ); chatProvider.clearConversation(); } catch (error, stackTrace) { print("Project directory picker failed: $error"); print(stackTrace); if (!mounted) { return; } await _showProjectPickerError(error.toString()); } } Future _createNewChat() async { final projectsProvider = context.read(); final selectedProject = projectsProvider.selectedProject; if (selectedProject == null) { await _showProjectPickerError( "Choose a project first so the new chat has a working directory.", ); return; } final sessionProvider = context.read(); final chatProvider = context.read(); await sessionProvider.createNewSession( workingDirectory: selectedProject.workingDirectory, name: "New Chat", ); chatProvider.setConversation(sessionProvider.getConversationHistory()); } Future _selectProject(ProjectRecord project) async { final projectsProvider = context.read(); final sessionProvider = context.read(); final chatProvider = context.read(); projectsProvider.selectProject(project.id); if (sessionProvider.currentSession?.workingDirectory == project.workingDirectory) { return; } sessionProvider.clearCurrentSession( workingDirectory: project.workingDirectory, ); chatProvider.clearConversation(); } Future _openSession(SessionSummary session) async { final sessionProvider = context.read(); final chatProvider = context.read(); final projectsProvider = context.read(); await sessionProvider.loadSession(session.id); chatProvider.setConversation(sessionProvider.getConversationHistory()); projectsProvider.selectProjectByWorkingDirectory( sessionProvider.activeWorkingDirectory, ); } Future _sendMessage() async { final text = _messageController.text.trim(); if (text.isEmpty) { return; } final sessionProvider = context.read(); final projectsProvider = context.read(); final chatProvider = context.read(); final selectedProject = projectsProvider.selectedProject; if (sessionProvider.currentSession == null) { if (selectedProject == null) { await _showProjectPickerError("Pick a project before starting a chat."); return; } await sessionProvider.createNewSession( workingDirectory: selectedProject.workingDirectory, name: "New Chat", ); chatProvider.setConversation(sessionProvider.getConversationHistory()); } _messageController.clear(); try { await chatProvider.sendMessage(text); if (!mounted) { return; } } catch (error, stackTrace) { print("Failed to send message from home screen: $error"); print(stackTrace); if (!mounted) { return; } await _showProjectPickerError(error.toString()); } finally { if (!mounted) { return; } await context.read().refreshSessions(); } } void _stopMessage() { context.read().stopGenerating(); } void _openSettings() { showDialog( context: context, builder: (_) => const AlertDialog(content: SettingsSheet()), ); } Future _showProjectPickerError(String message) { return showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text("Heads up"), content: Text(message), actions: [ Button.primary( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text("OK"), ), ], ), ); } @override Widget build(BuildContext context) { final projectsProvider = context.watch(); final sessionProvider = context.watch(); final chatProvider = context.watch(); final settingsProvider = context.watch(); final costProvider = context.watch(); // Group sessions by working directory final sessionsByProject = >{}; for (final session in sessionProvider.sessions) { final workingDirectory = session.workingDirectory ?? ''; if (!sessionsByProject.containsKey(workingDirectory)) { sessionsByProject[workingDirectory] = []; } sessionsByProject[workingDirectory]!.add(session); } final selectedProject = projectsProvider.selectedProject; final selectedWorkingDirectory = selectedProject?.workingDirectory; final currentModel = settingsProvider.normalizeModelId( settingsProvider.settings.model, ); return Scaffold( child: Row( children: [ SizedBox( width: 320, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Gap(16), const Padding( padding: EdgeInsets.symmetric(horizontal: 16), child: GarageHeader(), ), Padding( padding: const EdgeInsets.all(8), child: Column( children: [ SizedBox( width: double.infinity, child: Button.ghost( leading: const Icon(LucideIcons.folderPlus), leadingGap: 12, onPressed: _pickProjectDirectory, child: Transform.translate( offset: const Offset(0, 1), child: const Align( alignment: Alignment.centerLeft, child: Text("New Project"), ), ), ), ), const Gap(8), SizedBox( width: double.infinity, child: Button.ghost( leading: const Icon(LucideIcons.circlePlus), leadingGap: 12, onPressed: selectedProject == null || chatProvider.isLoading ? null : _createNewChat, child: Transform.translate( offset: const Offset(0, 1), child: const Align( alignment: Alignment.centerLeft, child: Text("New Chat"), ), ), ), ), ], ), ), const Divider(), Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Text("All Threads").textSmall.muted, ), Expanded( child: _ThreadsSection( projectsProvider: projectsProvider, sessionProvider: sessionProvider, sessionsByProject: sessionsByProject, onOpenSession: _openSession, onSelectProject: _selectProject, ), ), ], ), ), const VerticalDivider(), Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 18), child: Column( children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( selectedProject?.name ?? "Choose a project", style: const TextStyle( fontSize: 24, fontWeight: FontWeight.w700, ), ), const Gap(6), Text( sessionProvider.currentSession?.name ?? (selectedProject == null ? "Use the file picker to choose a working directory." : "Start a new chat in this working directory."), ).textSmall.muted, ], ), ), const Gap(16), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ OutlinedContainer( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10, ), child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text("Working Directory").textSmall.muted, const Gap(4), SizedBox( width: 280, child: Text( selectedWorkingDirectory ?? "Not selected", textAlign: TextAlign.right, overflow: TextOverflow.ellipsis, ).textSmall, ), ], ), ), const Gap(8), Row( mainAxisSize: MainAxisSize.min, children: [ OutlinedContainer( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10, ), child: Text( "Session cost ${costProvider.getFormattedTotalCost()}", ).textSmall, ), const Gap(8), IconButton.ghost( onPressed: _openSettings, icon: const Icon(LucideIcons.settings2), ), ], ), ], ), ], ), const Gap(20), Expanded( child: ClipRect( child: OutlinedContainer( child: chatProvider.messages.isEmpty ? _EmptyChatState( projectName: selectedProject?.name, hasProject: selectedProject != null, ) : const ChatView(), ), ), ), const Gap(16), TextField( controller: _messageController, minLines: 3, maxLines: 6, enabled: !chatProvider.isLoading, placeholder: Text( selectedProject == null ? "Choose a project to start chatting" : "Ask a question or type a message", ), onSubmitted: chatProvider.isLoading ? null : (_) => _sendMessage(), features: [ InputFeature.below( Row( children: [ IconButton.ghost( onPressed: _pickProjectDirectory, icon: const Icon(LucideIcons.folderSearch), ), const Spacer(), Select( itemBuilder: (context, item) { return Text(_modelLabel(item)); }, popup: SelectPopup.builder( searchPlaceholder: const Text("Search models"), builder: (context, searchQuery) { final filteredModels = searchQuery == null ? _modelGroups.entries : _filteredModels(searchQuery); return SelectItemList( children: [ for (final entry in filteredModels) SelectGroup( headers: [ SelectLabel(child: Text(entry.key)), ], children: [ for (final modelId in entry.value) SelectItemButton( value: modelId, child: Text( _modelLabel(modelId), ), ), ], ), ], ); }, ), onChanged: (value) { if (value != null) { settingsProvider.updateModel(value); } }, constraints: const BoxConstraints(minWidth: 220), value: currentModel, placeholder: const Text("Select a model"), ), const Gap(10), Button.primary( onPressed: chatProvider.isLoading ? _stopMessage : _sendMessage, child: chatProvider.isLoading ? Text( chatProvider.isStopping ? "Stopping..." : "Stop", ) : const Text("Send"), ), ], ), ), ], ), ], ), ), ), ], ), ); } } class _SidebarHint extends StatelessWidget { const _SidebarHint({required this.text}); final String text; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), child: Text(text).textSmall.muted, ); } } class _ThreadsSection extends StatelessWidget { const _ThreadsSection({ required this.projectsProvider, required this.sessionProvider, required this.sessionsByProject, required this.onOpenSession, required this.onSelectProject, }); final ProjectsProvider projectsProvider; final SessionProvider sessionProvider; final Map> sessionsByProject; final ValueChanged onOpenSession; final ValueChanged onSelectProject; @override Widget build(BuildContext context) { // Sort sessions by update time (newest first) within each project final sortedSessionsByProject = >{}; sessionsByProject.forEach((workingDirectory, sessions) { final sortedSessions = List.from(sessions) ..sort((a, b) => b.updated.compareTo(a.updated)); sortedSessionsByProject[workingDirectory] = sortedSessions; }); return ListView( padding: const EdgeInsets.fromLTRB(8, 0, 8, 12), children: [ if (projectsProvider.projects.isEmpty) const _SidebarHint(text: "No projects yet") else for (final project in projectsProvider.projects) ...[ // Project header SizedBox( width: double.infinity, child: Button.ghost( onPressed: () => onSelectProject(project), child: Container( padding: const EdgeInsets.symmetric(vertical: 6), child: Text( project.name, style: TextStyle( fontWeight: FontWeight.w600, fontSize: 12, color: Theme.of(context).colorScheme.mutedForeground, ), ), ), ), ), // Project sessions if (sortedSessionsByProject[project.workingDirectory]?.isEmpty ?? true) const Padding( padding: EdgeInsets.fromLTRB(8, 4, 8, 8), child: _SidebarHint(text: "No threads yet"), ) else for (final session in sortedSessionsByProject[project.workingDirectory]!) _SidebarSessionTile( session: session, isSelected: sessionProvider.currentSessionId == session.id, onTap: () => onOpenSession(session), ), const Divider(height: 16), ], // Handle sessions that don't belong to any current project if (sortedSessionsByProject.keys.any((key) => !projectsProvider.projects.any((project) => project.workingDirectory == key))) ...[ Padding( padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), child: Text( "Sessions Without Projects", style: TextStyle( fontWeight: FontWeight.w600, fontSize: 12, color: Theme.of(context).colorScheme.mutedForeground, ), ), ), for (final entry in sortedSessionsByProject.entries) if (!projectsProvider.projects.any((project) => project.workingDirectory == entry.key) && entry.key.isNotEmpty) for (final session in entry.value) _SidebarSessionTile( session: session, isSelected: sessionProvider.currentSessionId == session.id, onTap: () => onOpenSession(session), ), ], ], ); } } class _SidebarSessionTile extends StatelessWidget { const _SidebarSessionTile({ required this.session, required this.isSelected, required this.onTap, }); final SessionSummary session; final bool isSelected; final VoidCallback onTap; @override Widget build(BuildContext context) { return SizedBox( width: double.infinity, child: Button( style: isSelected ? ButtonStyle.secondary() : ButtonStyle.ghost(), child: Text( session.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontWeight: FontWeight.w400, fontSize: 13), ).textSmall, trailing: Text( _formatRelativeTime(session.updated), style: TextStyle(fontWeight: FontWeight.w400, fontSize: 13), ).muted.textSmall, onPressed: () { onTap(); }, ), ); } } class _EmptyChatState extends StatelessWidget { const _EmptyChatState({required this.projectName, required this.hasProject}); final String? projectName; final bool hasProject; @override Widget build(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(LucideIcons.messagesSquare, size: 28), const Gap(16), Text( hasProject ? "Ready to chat about ${projectName ?? "this project"}" : "Choose a project to begin", style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700), textAlign: TextAlign.center, ), const Gap(8), Text( hasProject ? "This chat will use the selected folder as its working directory." : "The desktop app uses the picked folder instead of the shell launch directory.", textAlign: TextAlign.center, ).textSmall.muted, ], ), ), ); } } String _formatRelativeTime(DateTime timestamp) { final difference = DateTime.now().toUtc().difference(timestamp.toUtc()); if (difference.inMinutes < 1) { return "just now"; } if (difference.inHours < 1) { return "${difference.inMinutes}m"; } if (difference.inDays < 1) { return "${difference.inHours}h"; } if (difference.inDays < 7) { return "${difference.inDays}d"; } final month = timestamp.month.toString().padLeft(2, "0"); final day = timestamp.day.toString().padLeft(2, "0"); return "${timestamp.year}-$month-$day"; }