import "package:clawd_code/ui/widgets/sidebar/account_button.dart"; import "package:flutter/services.dart" show Clipboard, ClipboardData; import "package:provider/provider.dart"; import "package:shadcn_flutter/shadcn_flutter.dart"; import "../../../src/session/session_types.dart"; import "../../providers/chat_provider.dart"; import "../../providers/home_coordinator.dart"; import "../../providers/projects_provider.dart"; import "../../providers/session_provider.dart"; import "../../utils/format_relative_time.dart"; import "app_logo.dart"; import "../common/button.dart"; class SidebarV2 extends StatelessWidget { final VoidCallback? onClose; const SidebarV2({super.key, this.onClose}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Container( width: 320, color: theme.colorScheme.input.scaleAlpha(0.3), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (onClose != null) ...[ Container( alignment: Alignment.topLeft, padding: EdgeInsets.all(8), child: IconButton.ghost( onPressed: onClose, icon: Icon( LucideIcons.panelLeftClose, size: 16, color: theme.colorScheme.mutedForeground, ), ), ), ], Padding( padding: const EdgeInsets.only( left: 16, bottom: 22 ), child: Row( children: [ Expanded(child: AppLogo()), ], ), ), // Divider(color: theme.colorScheme.border, height: 1), _ActionsSection(), // Divider(color: theme.colorScheme.border, height: 1), Divider(), Expanded( child: Padding( padding: const EdgeInsets.all(4), child: OutlinedContainer( borderRadius: BorderRadius.zero, child: _ProjectsSection() ), ) ), Divider(), AccountButton(), ], ), ); } } class _ActionsSection extends StatelessWidget { const _ActionsSection(); @override Widget build(BuildContext context) { final theme = Theme.of(context); final coordinator = context.read(); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // _SectionHeader(title: "Actions"), Divider(color: theme.colorScheme.border, height: 1), _PanelItem( icon: LucideIcons.squarePen, label: "New Chat", onTap: coordinator.createNewChat, ), Divider(color: theme.colorScheme.border, height: 1), _PanelItem( icon: LucideIcons.folderPlus, label: "New Project", onTap: coordinator.pickProjectDirectory, ), // Divider(color: theme.colorScheme.border, height: 1), ], ); } } class _ProjectsSection extends StatelessWidget { const _ProjectsSection(); @override Widget build(BuildContext context) { final theme = Theme.of(context); final projectsProvider = context.watch(); final sessionProvider = context.watch(); final chatProvider = context.watch(); final coordinator = context.read(); if (projectsProvider.projects.isEmpty) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _SectionHeader(title: "Projects"), Divider(color: theme.colorScheme.border, height: 1), Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Text( "No projects yet", style: TextStyle( fontSize: 11, color: theme.colorScheme.mutedForeground, ), ), ), ], ); } // group sessions by working directory, sorted newest first final sessionsByDir = >{}; for (final s in sessionProvider.sessions) { final dir = s.workingDirectory ?? ""; sessionsByDir.putIfAbsent(dir, () => []).add(s); } sessionsByDir.forEach((_, list) { list.sort((a, b) => b.updated.compareTo(a.updated)); }); final projects = List.of(projectsProvider.projects) ..sort((a, b) { final aLatest = sessionsByDir[a.workingDirectory]?.firstOrNull?.updated; final bLatest = sessionsByDir[b.workingDirectory]?.firstOrNull?.updated; if (aLatest == null && bLatest == null) return 0; if (aLatest == null) return 1; if (bLatest == null) return -1; return bLatest.compareTo(aLatest); }); return ListView( physics: const ClampingScrollPhysics(), padding: EdgeInsets.zero, children: [ // _SectionHeader(title: "PROJECTS", large: true), // // Divider(color: theme.colorScheme.background, height: 1), for (final project in projects) ...[ _CollapsibleProjectSection( projectName: project.name, sessions: sessionsByDir[project.workingDirectory] ?? [], currentSessionId: sessionProvider.currentSessionId, isSessionRunning: chatProvider.isSessionRunning, sessionNeedsAttention: chatProvider.sessionNeedsAttention, sessionHasUnreadResult: chatProvider.sessionHasUnreadResult, onOpenSession: coordinator.openSession, onDeleteSession: coordinator.deleteSession, ), Divider(color: theme.colorScheme.border, height: 1), ], ], ); } } class _CollapsibleProjectSection extends StatefulWidget { final String projectName; final List sessions; final String? currentSessionId; final bool Function(String sessionId) isSessionRunning; final bool Function(String sessionId) sessionNeedsAttention; final bool Function(String sessionId) sessionHasUnreadResult; final void Function(SessionSummary) onOpenSession; final void Function(SessionSummary) onDeleteSession; const _CollapsibleProjectSection({ required this.projectName, required this.sessions, required this.currentSessionId, required this.isSessionRunning, required this.sessionNeedsAttention, required this.sessionHasUnreadResult, required this.onOpenSession, required this.onDeleteSession, }); @override State<_CollapsibleProjectSection> createState() => _CollapsibleProjectSectionState(); } class _CollapsibleProjectSectionState extends State<_CollapsibleProjectSection> { bool _expanded = true; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ GestureDetector( onTap: () => setState(() => _expanded = !_expanded), behavior: HitTestBehavior.opaque, child: ColoredBox( color: theme.colorScheme.secondary, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( children: [ AnimatedRotation( turns: _expanded ? 0.0 : -0.25, duration: const Duration(milliseconds: 120), child: Icon( LucideIcons.chevronDown, size: 12, color: theme.colorScheme.mutedForeground, ), ), const SizedBox(width: 4), Text( widget.projectName, style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: theme.colorScheme.foreground, letterSpacing: 0.4, ), ), ], ), ), ), ), if (_expanded) ...[ Divider(color: theme.colorScheme.background, height: 1), ], if (_expanded) ...[ if (widget.sessions.isEmpty) _PanelItem( icon: LucideIcons.frown, label: "No sessions yet", onTap: null, ) else for (int i = 0; i < widget.sessions.length; i++) ...[ _ThreadItem( session: widget.sessions[i], selected: widget.currentSessionId == widget.sessions[i].id, isRunning: widget.isSessionRunning(widget.sessions[i].id), needsAttention: widget.sessionNeedsAttention(widget.sessions[i].id), hasUnreadResult: widget.sessionHasUnreadResult(widget.sessions[i].id), onTap: () => widget.onOpenSession(widget.sessions[i]), onDelete: () => widget.onDeleteSession(widget.sessions[i]), ), if (i < widget.sessions.length - 1) Divider(color: theme.colorScheme.border, height: 1), ], ] else ...[ Divider(color: theme.colorScheme.background,) ] ], ); } } class _SectionHeader extends StatelessWidget { final String title; final bool large; const _SectionHeader({required this.title, this.large = false}); @override Widget build(BuildContext context) { final theme = Theme.of(context); TextStyle style; if (large) { style = TextStyle( fontSize: 22, fontWeight: FontWeight.w600, color: theme.colorScheme.foreground, letterSpacing: 0.4, ); } else { style = TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: theme.colorScheme.foreground, letterSpacing: 0.4, ); } return ColoredBox( color: theme.colorScheme.secondary, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Text( title, style: style ), ), ); } } class _PanelItem extends StatelessWidget { final IconData icon; final String label; final VoidCallback? onTap; const _PanelItem({required this.icon, required this.label, this.onTap}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final muted = onTap == null; return Padding( padding: const EdgeInsets.all(0), child: AgcGhostButton( onPressed: onTap, borderRadius: BorderRadius.zero, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( children: [ Icon( icon, color: muted ? theme.colorScheme.mutedForeground : theme.colorScheme.foreground, ).iconSmall, const SizedBox(width: 8), Text( label, style: TextStyle( color: muted ? theme.colorScheme.mutedForeground : theme.colorScheme.foreground, ), ).small, ], ), ), ), ); } } class _ThreadItem extends StatelessWidget { final SessionSummary session; final bool selected; final bool isRunning; final bool needsAttention; final bool hasUnreadResult; final VoidCallback onTap; final VoidCallback onDelete; const _ThreadItem({ required this.session, required this.selected, this.isRunning = false, this.needsAttention = false, this.hasUnreadResult = false, required this.onTap, required this.onDelete, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); const amber = Color(0xFFF59E0B); const green = Color(0xFF22C55E); Color? glowColor; if (needsAttention) { glowColor = amber; } else if (hasUnreadResult) { glowColor = green; } Widget trailingWidget; if (needsAttention) { trailingWidget = Icon(LucideIcons.triangleAlert, size: 13, color: amber); } else if (isRunning) { trailingWidget = SizedBox( width: 12, height: 12, child: CircularProgressIndicator( strokeWidth: 1.5, color: theme.colorScheme.primary, ), ); } else if (hasUnreadResult) { trailingWidget = Icon(LucideIcons.circleCheck, size: 13, color: green); } else { trailingWidget = Text( formatRelativeTime(session.updated), style: TextStyle(color: theme.colorScheme.mutedForeground), ).muted.xSmall.light; } return ContextMenu( items: [ MenuButton( leading: const Icon(LucideIcons.copy).iconSmall, onPressed: (_) { final dir = session.workingDirectory ?? ""; final path = "$dir/.the_agency/sessions/${session.id}.json"; Clipboard.setData(ClipboardData(text: path)); }, child: const Text("Copy session path"), ), MenuButton( leading: const Icon(LucideIcons.trash2).iconSmall, onPressed: (_) => onDelete(), child: const Text("Delete"), ), ], child: Container( // margin: EdgeInsets.all(1), decoration: BoxDecoration( color: glowColor != null ? glowColor.withAlpha(selected ? 55 : 30) : selected ? theme.colorScheme.accent : Colors.transparent, border: glowColor != null ? Border(left: BorderSide(color: glowColor, width: 2)) : null, ), child: AgcGhostButton( onPressed: onTap, borderRadius: BorderRadius.zero, child: Padding( padding: EdgeInsets.only( left: glowColor != null ? 22 : 24, right: 12, top: 8, bottom: 8, ), child: Row( children: [ Expanded( child: Text( session.name, style: TextStyle( color: selected ? theme.colorScheme.accentForeground : theme.colorScheme.foreground, ), overflow: TextOverflow.ellipsis, maxLines: 1, ).small, ), Gap(8), trailingWidget, ], ), ), ), ), ); } }