Update project structure and enhance functionality with new features and dependencies
This commit is contained in:
@@ -1,31 +1,79 @@
|
||||
import "dart:io";
|
||||
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
|
||||
class AppHeader extends StatelessWidget {
|
||||
const AppHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"THE AGENCY",
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.background,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: theme.colorScheme.border, width: 1),
|
||||
),
|
||||
Text(
|
||||
"by IMBENJI.NET LTD",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 8, bottom: 8),
|
||||
child: _Logo(),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
SizedBox(width: Platform.isMacOS ? 64 : 12),
|
||||
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _Logo extends StatelessWidget {
|
||||
const _Logo();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final muted = Theme.of(context).colorScheme.mutedForeground;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
||||
Text(
|
||||
"THE AGENCY",
|
||||
style: TextStyle(
|
||||
fontFamily: "monospace",
|
||||
fontSize: 13,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
|
||||
Text(
|
||||
"by IMBENJI.NET LTD",
|
||||
style: TextStyle(
|
||||
fontFamily: "monospace",
|
||||
fontSize: 10,
|
||||
height: 1.4,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: muted,
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ class Sidebar extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: _projectsSection(context, projectsProvider, sessionProvider, coordinator),
|
||||
child: _projectsSection(context, projectsProvider, sessionProvider, coordinator, chatProvider),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -109,7 +109,7 @@ class Sidebar extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _projectsSection(BuildContext context, ProjectsProvider projectsProvider, SessionProvider sessionProvider, HomeCoordinator coordinator) {
|
||||
Widget _projectsSection(BuildContext context, ProjectsProvider projectsProvider, SessionProvider sessionProvider, HomeCoordinator coordinator, ChatProvider chatProvider) {
|
||||
if (projectsProvider.projects.isEmpty) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
@@ -131,6 +131,17 @@ class Sidebar extends StatelessWidget {
|
||||
..sort((a, b) => b.updated.compareTo(a.updated));
|
||||
});
|
||||
|
||||
final projects = List.of(projectsProvider.projects)
|
||||
..sort((a, b) {
|
||||
final aLatest = sorted[a.workingDirectory]?.firstOrNull?.updated;
|
||||
final bLatest = sorted[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(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
@@ -142,7 +153,7 @@ class Sidebar extends StatelessWidget {
|
||||
|
||||
Gap(8),
|
||||
|
||||
for (final project in projectsProvider.projects) ...[
|
||||
for (final project in projects) ...[
|
||||
|
||||
ProjectSection(
|
||||
projectLabel: project.name,
|
||||
@@ -158,6 +169,7 @@ class Sidebar extends StatelessWidget {
|
||||
label: session.name,
|
||||
lastMessage: session.updated,
|
||||
selected: sessionProvider.currentSessionId == session.id,
|
||||
isRunning: chatProvider.isSessionRunning(session.id),
|
||||
onPressed: () => coordinator.openSession(session),
|
||||
onDelete: () => coordinator.deleteSession(session),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
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 {
|
||||
const SidebarV2({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
width: 300,
|
||||
color: theme.colorScheme.input.scaleAlpha(0.3),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 28),
|
||||
child: AppLogo(),
|
||||
),
|
||||
|
||||
// Divider(color: theme.colorScheme.border, height: 1),
|
||||
_ActionsSection(),
|
||||
|
||||
Divider(color: theme.colorScheme.border, height: 1),
|
||||
|
||||
Expanded(child: _ProjectsSection()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionsSection extends StatelessWidget {
|
||||
const _ActionsSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final coordinator = context.read<HomeCoordinator>();
|
||||
|
||||
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<ProjectsProvider>();
|
||||
final sessionProvider = context.watch<SessionProvider>();
|
||||
final chatProvider = context.watch<ChatProvider>();
|
||||
final coordinator = context.read<HomeCoordinator>();
|
||||
|
||||
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 = <String, List<SessionSummary>>{};
|
||||
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: [
|
||||
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<SessionSummary> 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Divider(color: theme.colorScheme.border, 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;
|
||||
|
||||
const _SectionHeader({required this.title});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return ColoredBox(
|
||||
color: theme.colorScheme.secondary,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.foreground,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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(1),
|
||||
child: AgcGhostButton(
|
||||
onPressed: onTap,
|
||||
borderRadius: BorderRadius.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
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.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,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ class ThreadButton extends StatelessWidget {
|
||||
final DateTime? lastMessage;
|
||||
final bool selected;
|
||||
final bool muted;
|
||||
final bool isRunning;
|
||||
|
||||
ThreadButton({
|
||||
required this.label,
|
||||
@@ -19,6 +20,7 @@ class ThreadButton extends StatelessWidget {
|
||||
this.lastMessage,
|
||||
this.selected = false,
|
||||
this.muted = false,
|
||||
this.isRunning = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -50,10 +52,18 @@ class ThreadButton extends StatelessWidget {
|
||||
).iconSmall,
|
||||
|
||||
trailingGap: 32,
|
||||
trailing: lastMessage != null ?
|
||||
Text(
|
||||
formatRelativeTime(lastMessage!)
|
||||
).muted.small.light : null,
|
||||
trailing: isRunning
|
||||
? SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.5,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
)
|
||||
: lastMessage != null
|
||||
? Text(formatRelativeTime(lastMessage!)).muted.small.light
|
||||
: null,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
|
||||
Reference in New Issue
Block a user