Update project structure and enhance functionality with new features and dependencies

This commit is contained in:
ImBenji
2026-04-14 03:31:29 +01:00
parent 0b6b604c56
commit 3588783001
63 changed files with 10565 additions and 789 deletions
+67 -19
View File
@@ -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,
),
),
],
);
}
}
+15 -3
View File
@@ -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),
),
+438
View File
@@ -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,
],
),
),
),
),
);
}
}
+14 -4
View File
@@ -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(