Add new features and update configurations for improved functionality
This commit is contained in:
+10
-4
@@ -1,8 +1,9 @@
|
||||
import "package:clawd_code/ui/screens/new_home_screen.dart";
|
||||
import "package:go_router/go_router.dart";
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
import "providers/settings_provider.dart";
|
||||
import "routes/router.dart";
|
||||
|
||||
class ClawdApp extends StatelessWidget {
|
||||
const ClawdApp();
|
||||
@@ -11,10 +12,15 @@ class ClawdApp extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, _) {
|
||||
return ShadcnApp(
|
||||
return ShadcnApp.router(
|
||||
title: "Clawd",
|
||||
home: NewHomeScreen(),
|
||||
theme: ThemeData(colorScheme: ColorSchemes.darkNeutral, radius: 0.5),
|
||||
routerConfig: AppRouter.router,
|
||||
scaling: const AdaptiveScaling(0.9),
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorSchemes.darkGray.rose,
|
||||
density: Density.spaciousDensity,
|
||||
radius: 0.5,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
+20
-33
@@ -36,40 +36,27 @@ const List<SelectableAiModel> selectableAiModels = [
|
||||
group: "Recommended",
|
||||
id: "qwen/qwen3-coder-next",
|
||||
label: "Qwen3 Coder Next",
|
||||
),
|
||||
SelectableAiModel(
|
||||
group: "Recommended",
|
||||
id: "qwen/qwen3-235b-a22b-2507",
|
||||
label: "Qwen3 235B A22B-2507",
|
||||
),
|
||||
SelectableAiModel(
|
||||
group: "Recommended",
|
||||
id: "google/gemma-4-31b-it",
|
||||
label: "Gemma 4 31B IT",
|
||||
),
|
||||
SelectableAiModel(
|
||||
group: "Recommended",
|
||||
id: "qwen/qwen3.6-plus",
|
||||
label: "Qwen3.6 Plus",
|
||||
),
|
||||
SelectableAiModel(
|
||||
group: "Recommended",
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
label: "Claude Sonnet 4.6",
|
||||
)
|
||||
|
||||
|
||||
// SelectableAiModel(
|
||||
// group: "Anthropic",
|
||||
// id: "anthropic/claude-sonnet-4.6",
|
||||
// label: "Claude Sonnet 4.6",
|
||||
// ),
|
||||
// SelectableAiModel(
|
||||
// group: "Anthropic",
|
||||
// id: "anthropic/claude-opus-4.6",
|
||||
// label: "Claude Opus 4.6",
|
||||
// ),
|
||||
// SelectableAiModel(
|
||||
// group: "Anthropic",
|
||||
// id: "anthropic/claude-haiku-4.5",
|
||||
// label: "Claude Haiku 4.5",
|
||||
// ),
|
||||
// SelectableAiModel(group: "OpenAI", id: "openai/gpt-5.4", label: "GPT-5.4"),
|
||||
// SelectableAiModel(
|
||||
// group: "OpenAI",
|
||||
// id: "openai/gpt-5.4-mini",
|
||||
// label: "GPT-5.4 Mini",
|
||||
// ),
|
||||
// SelectableAiModel(group: "OpenAI", id: "openai/gpt-4.1", label: "GPT-4.1"),
|
||||
// SelectableAiModel(group: "Qwen", id: "qwen/qwen3.5-9b", label: "Qwen3.5-9B"),
|
||||
// SelectableAiModel(
|
||||
// group: "Qwen",
|
||||
// id: "qwen/qwen3.5-35b-a3b",
|
||||
// label: "Qwen3.5-35B-A3B",
|
||||
// ),
|
||||
// SelectableAiModel(
|
||||
// group: "Qwen",
|
||||
// id: "qwen/qwen3.5-flash-02-23",
|
||||
// label: "Qwen3.5-Flash",
|
||||
// ),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
class Attachment {
|
||||
final String name;
|
||||
final String mimeType;
|
||||
final Uint8List data;
|
||||
final DateTime createdAt;
|
||||
|
||||
Attachment({
|
||||
required this.name,
|
||||
required this.mimeType,
|
||||
required this.data,
|
||||
DateTime? createdAt,
|
||||
}) : createdAt = createdAt ?? DateTime.now();
|
||||
|
||||
bool get isImage => mimeType.startsWith('image/');
|
||||
bool get isPdf => mimeType == 'application/pdf';
|
||||
bool get isText => mimeType.startsWith('text/');
|
||||
|
||||
int get sizeInKB => (data.length / 1024).ceil();
|
||||
String get displayName => name;
|
||||
}
|
||||
+150
-612
@@ -1,18 +1,16 @@
|
||||
import "package:file_picker/file_picker.dart";
|
||||
import "package:go_router/go_router.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/home_coordinator.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";
|
||||
import "../../widgets/agents/agents_pane.dart";
|
||||
import "../../widgets/chat/chat_box.dart";
|
||||
import "../../widgets/chat/chat_view.dart";
|
||||
import "../../widgets/common/footer_bar.dart";
|
||||
import "../../widgets/sidebar/sidebar.dart";
|
||||
|
||||
class NewHomeScreen extends StatefulWidget {
|
||||
const NewHomeScreen({super.key});
|
||||
@@ -22,202 +20,34 @@ class NewHomeScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _NewHomeScreenState extends State<NewHomeScreen> {
|
||||
late final TextEditingController _messageController;
|
||||
|
||||
final ScrollController _chatScrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_messageController = TextEditingController();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<HomeCoordinator>().addListener(_onCoordinatorChanged);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_messageController.dispose();
|
||||
context.read<HomeCoordinator>().removeListener(_onCoordinatorChanged);
|
||||
_chatScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Iterable<MapEntry<String, List<String>>> _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<String, List<String>> get _modelGroups {
|
||||
final groups = <String, List<String>>{};
|
||||
for (final model in selectableAiModels) {
|
||||
groups.putIfAbsent(model.group, () => <String>[]).add(model.id);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
String _modelLabel(String modelId) {
|
||||
for (final model in selectableAiModels) {
|
||||
if (model.id == modelId) {
|
||||
return model.label;
|
||||
}
|
||||
}
|
||||
return modelId;
|
||||
}
|
||||
|
||||
Future<void> _pickProjectDirectory() async {
|
||||
try {
|
||||
final selectedDirectory = await FilePicker.platform.getDirectoryPath(
|
||||
dialogTitle: "Select project directory",
|
||||
);
|
||||
|
||||
if (selectedDirectory == null || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final projectsProvider = context.read<ProjectsProvider>();
|
||||
final sessionProvider = context.read<SessionProvider>();
|
||||
final chatProvider = context.read<ChatProvider>();
|
||||
|
||||
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());
|
||||
void _onCoordinatorChanged() {
|
||||
final coordinator = context.read<HomeCoordinator>();
|
||||
final err = coordinator.error;
|
||||
if (err != null) {
|
||||
coordinator.clearError();
|
||||
_showError(err);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createNewChat() async {
|
||||
final projectsProvider = context.read<ProjectsProvider>();
|
||||
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<SessionProvider>();
|
||||
final chatProvider = context.read<ChatProvider>();
|
||||
|
||||
await sessionProvider.createNewSession(
|
||||
workingDirectory: selectedProject.workingDirectory,
|
||||
name: "New Chat",
|
||||
);
|
||||
chatProvider.setConversation(sessionProvider.getConversationHistory());
|
||||
}
|
||||
|
||||
Future<void> _selectProject(ProjectRecord project) async {
|
||||
final projectsProvider = context.read<ProjectsProvider>();
|
||||
final sessionProvider = context.read<SessionProvider>();
|
||||
final chatProvider = context.read<ChatProvider>();
|
||||
|
||||
projectsProvider.selectProject(project.id);
|
||||
if (sessionProvider.currentSession?.workingDirectory ==
|
||||
project.workingDirectory) {
|
||||
return;
|
||||
}
|
||||
sessionProvider.clearCurrentSession(
|
||||
workingDirectory: project.workingDirectory,
|
||||
);
|
||||
chatProvider.clearConversation();
|
||||
}
|
||||
|
||||
Future<void> _openSession(SessionSummary session) async {
|
||||
final sessionProvider = context.read<SessionProvider>();
|
||||
final chatProvider = context.read<ChatProvider>();
|
||||
final projectsProvider = context.read<ProjectsProvider>();
|
||||
|
||||
await sessionProvider.loadSession(session.id);
|
||||
chatProvider.setConversation(sessionProvider.getConversationHistory());
|
||||
projectsProvider.selectProjectByWorkingDirectory(
|
||||
sessionProvider.activeWorkingDirectory,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _sendMessage() async {
|
||||
final text = _messageController.text.trim();
|
||||
if (text.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final sessionProvider = context.read<SessionProvider>();
|
||||
final projectsProvider = context.read<ProjectsProvider>();
|
||||
final chatProvider = context.read<ChatProvider>();
|
||||
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<SessionProvider>().refreshSessions();
|
||||
}
|
||||
}
|
||||
|
||||
void _stopMessage() {
|
||||
context.read<ChatProvider>().stopGenerating();
|
||||
}
|
||||
|
||||
void _openSettings() {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => const AlertDialog(content: SettingsSheet()),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showProjectPickerError(String message) {
|
||||
Future<void> _showError(String message) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
@@ -235,439 +65,78 @@ class _NewHomeScreenState extends State<NewHomeScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final projectsProvider = context.watch<ProjectsProvider>();
|
||||
final sessionProvider = context.watch<SessionProvider>();
|
||||
final chatProvider = context.watch<ChatProvider>();
|
||||
final settingsProvider = context.watch<SettingsProvider>();
|
||||
final costProvider = context.watch<CostProvider>();
|
||||
|
||||
// Group sessions by working directory
|
||||
final sessionsByProject = <String, List<SessionSummary>>{};
|
||||
for (final session in sessionProvider.sessions) {
|
||||
final workingDirectory = session.workingDirectory ?? '';
|
||||
if (!sessionsByProject.containsKey(workingDirectory)) {
|
||||
sessionsByProject[workingDirectory] = <SessionSummary>[];
|
||||
}
|
||||
sessionsByProject[workingDirectory]!.add(session);
|
||||
}
|
||||
|
||||
final selectedProject = projectsProvider.selectedProject;
|
||||
final selectedWorkingDirectory = selectedProject?.workingDirectory;
|
||||
final currentModel = settingsProvider.normalizeModelId(
|
||||
settingsProvider.settings.model,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
child: Row(
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 320,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
const Gap(16),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AppHeader(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
|
||||
Sidebar(),
|
||||
|
||||
Gap(1),
|
||||
|
||||
VerticalDivider(),
|
||||
|
||||
Expanded(
|
||||
child: Stack(
|
||||
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"),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
_ChatArea(scrollController: _chatScrollController),
|
||||
|
||||
Positioned(
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: 12,
|
||||
child: FullHeightScrollbar(controller: _chatScrollController),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
AgentsPane(),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
const VerticalDivider(),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
|
||||
if (selectedProject != null && sessionProvider.currentSession != null)...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
FooterBar(),
|
||||
|
||||
Icon(
|
||||
LucideIcons.messageCircle
|
||||
).iconSmall,
|
||||
|
||||
Gap(8),
|
||||
|
||||
Transform.translate(
|
||||
offset: Offset(0, -1),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
selectedProject.name
|
||||
).textSmall,
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Icon(
|
||||
LucideIcons.slash
|
||||
).iconX2Small,
|
||||
),
|
||||
|
||||
Text(
|
||||
sessionProvider.currentSession!.name
|
||||
).textSmall
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(),
|
||||
],
|
||||
|
||||
|
||||
const Gap(18),
|
||||
Expanded(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 600
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClipRect(
|
||||
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<String>(
|
||||
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;
|
||||
class _ChatArea extends StatelessWidget {
|
||||
|
||||
final ScrollController scrollController;
|
||||
|
||||
const _ChatArea({required this.scrollController});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Text(text).textSmall.muted,
|
||||
);
|
||||
}
|
||||
}
|
||||
final chatProvider = context.watch<ChatProvider>();
|
||||
|
||||
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<String, List<SessionSummary>> sessionsByProject;
|
||||
final ValueChanged<SessionSummary> onOpenSession;
|
||||
final ValueChanged<ProjectRecord> onSelectProject;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Sort sessions by update time (newest first) within each project
|
||||
final sortedSessionsByProject = <String, List<SessionSummary>>{};
|
||||
sessionsByProject.forEach((workingDirectory, sessions) {
|
||||
final sortedSessions = List<SessionSummary>.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),
|
||||
return Container(
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 600),
|
||||
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,
|
||||
|
||||
Expanded(
|
||||
child: chatProvider.messages.isEmpty
|
||||
? _EmptyChatState()
|
||||
: ChatView(scrollController: scrollController),
|
||||
),
|
||||
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,
|
||||
|
||||
ChatBox(),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -675,23 +144,92 @@ class _EmptyChatState extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
class _EmptyChatState extends StatelessWidget {
|
||||
|
||||
final month = timestamp.month.toString().padLeft(2, "0");
|
||||
final day = timestamp.day.toString().padLeft(2, "0");
|
||||
return "${timestamp.year}-$month-$day";
|
||||
const _EmptyChatState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final projectsProvider = context.watch<ProjectsProvider>();
|
||||
final projects = projectsProvider.projects;
|
||||
final selected = projectsProvider.selectedProject;
|
||||
final coordinator = context.read<HomeCoordinator>();
|
||||
|
||||
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(
|
||||
"Ask the agency anything",
|
||||
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
"Select a project and thread from the sidebar, or start a new chat.",
|
||||
textAlign: TextAlign.center,
|
||||
).textSmall.muted,
|
||||
|
||||
const Gap(24),
|
||||
|
||||
Select<ProjectRecord>(
|
||||
itemBuilder: (context, item) => Text(item.name),
|
||||
popup: SelectPopup.builder(
|
||||
searchPlaceholder: const Text("Search projects"),
|
||||
builder: (context, searchQuery) {
|
||||
final filtered = searchQuery == null || searchQuery.isEmpty
|
||||
? projects
|
||||
: projects.where((p) =>
|
||||
p.name.toLowerCase().contains(searchQuery.toLowerCase()) ||
|
||||
p.workingDirectory.toLowerCase().contains(searchQuery.toLowerCase())
|
||||
).toList();
|
||||
|
||||
return SelectItemList(
|
||||
children: [
|
||||
for (final project in filtered)
|
||||
SelectItemButton(
|
||||
value: project,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(project.name),
|
||||
Text(project.workingDirectory).textSmall.muted,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
onChanged: (project) {
|
||||
if (project != null) coordinator.selectProject(project);
|
||||
},
|
||||
constraints: const BoxConstraints(minWidth: 240),
|
||||
value: selected,
|
||||
placeholder: const Text("Select a project"),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
abstract class HomeScreenRoute {
|
||||
static const path = '/';
|
||||
static const name = 'home';
|
||||
|
||||
static GoRoute get route => GoRoute(
|
||||
path: path,
|
||||
name: name,
|
||||
builder: (context, state) => const NewHomeScreen(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
import "../../../../src/project_store.dart";
|
||||
import "../../../../src/session/session_types.dart";
|
||||
import "../../../providers/projects_provider.dart";
|
||||
import "../../../providers/session_provider.dart";
|
||||
|
||||
class ThreadsSection extends StatelessWidget {
|
||||
const ThreadsSection({
|
||||
required this.projectsProvider,
|
||||
required this.sessionProvider,
|
||||
required this.sessionsByProject,
|
||||
required this.onOpenSession,
|
||||
required this.onSelectProject,
|
||||
required this.onDeleteSession,
|
||||
});
|
||||
|
||||
final ProjectsProvider projectsProvider;
|
||||
final SessionProvider sessionProvider;
|
||||
final Map<String, List<SessionSummary>> sessionsByProject;
|
||||
final ValueChanged<SessionSummary> onOpenSession;
|
||||
final ValueChanged<ProjectRecord> onSelectProject;
|
||||
final ValueChanged<SessionSummary> onDeleteSession;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Sort sessions by update time (newest first) within each project
|
||||
final sortedSessionsByProject = <String, List<SessionSummary>>{};
|
||||
sessionsByProject.forEach((workingDirectory, sessions) {
|
||||
final sortedSessions = List<SessionSummary>.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),
|
||||
onDelete: () => onDeleteSession(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),
|
||||
onDelete: () => onDeleteSession(session),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 _SidebarSessionTile extends StatelessWidget {
|
||||
const _SidebarSessionTile({
|
||||
required this.session,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
final SessionSummary session;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ContextMenu(
|
||||
items: [
|
||||
MenuButton(
|
||||
onPressed: (context) {
|
||||
onDelete();
|
||||
},
|
||||
child: const Text("Delete"),
|
||||
),
|
||||
],
|
||||
child: 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();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import "package:go_router/go_router.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
class ProjectDetailPage extends StatelessWidget {
|
||||
const ProjectDetailPage({
|
||||
super.key,
|
||||
required this.projectId,
|
||||
this.tab = 'overview',
|
||||
});
|
||||
|
||||
final String projectId;
|
||||
final String tab;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
headers: [
|
||||
AppBar(
|
||||
title: Text("Project: $projectId"),
|
||||
leading: [
|
||||
IconButton.ghost(
|
||||
icon: const Icon(LucideIcons.arrowLeft),
|
||||
onPressed: () => context.go('/'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
child: Column(
|
||||
children: [
|
||||
// Tab navigation
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildTabButton(context, 'overview', 'Overview'),
|
||||
const Gap(8),
|
||||
_buildTabButton(context, 'files', 'Files'),
|
||||
const Gap(8),
|
||||
_buildTabButton(context, 'settings', 'Settings'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: _buildTabContent(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabButton(BuildContext context, String tabName, String label) {
|
||||
final isActive = tab == tabName;
|
||||
return Button(
|
||||
style: isActive ? ButtonStyle.secondary() : ButtonStyle.ghost(),
|
||||
onPressed: () => context.go(
|
||||
ProjectDetailRoute.pathWithParams(
|
||||
projectId: projectId,
|
||||
tab: tabName,
|
||||
),
|
||||
),
|
||||
child: Text(label),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabContent() {
|
||||
switch (tab) {
|
||||
case 'files':
|
||||
return const Center(child: Text("Files tab content"));
|
||||
case 'settings':
|
||||
return const Center(child: Text("Settings tab content"));
|
||||
case 'overview':
|
||||
default:
|
||||
return const Center(child: Text("Project overview content"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GoRouter routes for the project detail page
|
||||
abstract class ProjectDetailRoute {
|
||||
static const path = '/projects/:projectId';
|
||||
static const name = 'project_detail';
|
||||
|
||||
static String pathWithParams({
|
||||
required String projectId,
|
||||
String tab = 'overview',
|
||||
}) {
|
||||
return '/projects/$projectId?tab=$tab';
|
||||
}
|
||||
|
||||
static GoRoute get route => GoRoute(
|
||||
path: path,
|
||||
name: name,
|
||||
builder: (context, state) {
|
||||
final projectId = state.pathParameters['projectId']!;
|
||||
final tab = state.uri.queryParameters['tab'] ?? 'overview';
|
||||
|
||||
return ProjectDetailPage(
|
||||
projectId: projectId,
|
||||
tab: tab,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import "package:go_router/go_router.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
import "widgets/setting_card.dart";
|
||||
|
||||
class SettingsPage extends StatelessWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
headers: [
|
||||
AppBar(
|
||||
title: const Text("Settings"),
|
||||
leading: [
|
||||
IconButton.ghost(
|
||||
icon: const Icon(LucideIcons.arrowLeft),
|
||||
onPressed: () => context.go('/'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
SettingCard(
|
||||
title: "Appearance",
|
||||
description: "Customize theme, colors, and layout",
|
||||
icon: LucideIcons.palette,
|
||||
onTap: () {
|
||||
// Could navigate to appearance settings
|
||||
},
|
||||
),
|
||||
const Gap(12),
|
||||
SettingCard(
|
||||
title: "Models",
|
||||
description: "Configure AI model preferences",
|
||||
icon: LucideIcons.brain,
|
||||
onTap: () {
|
||||
// Could navigate to model settings
|
||||
},
|
||||
),
|
||||
const Gap(12),
|
||||
SettingCard(
|
||||
title: "Advanced",
|
||||
description: "Developer options and advanced settings",
|
||||
icon: LucideIcons.settings2,
|
||||
onTap: () {
|
||||
// Could navigate to advanced settings
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// GoRouter routes for the settings page
|
||||
abstract class SettingsRoute {
|
||||
static const path = '/settings';
|
||||
static const name = 'settings';
|
||||
|
||||
static GoRoute get route => GoRoute(
|
||||
path: path,
|
||||
name: name,
|
||||
builder: (context, state) => const SettingsPage(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
class SettingCard extends StatelessWidget {
|
||||
const SettingCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.icon,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String description;
|
||||
final IconData icon;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon).iconLarge,
|
||||
const Gap(12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title).textLarge,
|
||||
const Gap(4),
|
||||
Text(description).textSmall.muted,
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onTap != null) ...[
|
||||
const Gap(8),
|
||||
IconButton.ghost(
|
||||
onPressed: onTap,
|
||||
icon: const Icon(LucideIcons.chevronRight),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,48 +3,130 @@ import "dart:convert";
|
||||
|
||||
import "../../src/chat/tool_loop_service.dart";
|
||||
import "../../src/api/openrouter_client.dart";
|
||||
import "../../src/hooks/hook_loader.dart";
|
||||
import "../../src/hooks/hook_runner.dart";
|
||||
import "../../src/hooks/hook_types.dart";
|
||||
import "../../src/permissions/permission_types.dart";
|
||||
import "../../src/session/conversation_history.dart";
|
||||
import "../../src/session/session_store.dart";
|
||||
import "../../src/session/session_types.dart";
|
||||
import "../../src/services/cost_tracker.dart" as cost_tracker;
|
||||
import "settings_provider.dart";
|
||||
|
||||
enum QueuePriority {
|
||||
now(0),
|
||||
next(1),
|
||||
later(2);
|
||||
|
||||
final int order;
|
||||
const QueuePriority(this.order);
|
||||
}
|
||||
|
||||
class QueuedMessage {
|
||||
final String text;
|
||||
final QueuePriority priority;
|
||||
|
||||
const QueuedMessage({required this.text, required this.priority});
|
||||
}
|
||||
|
||||
|
||||
class ChatProvider extends ChangeNotifier {
|
||||
ChatProvider(this._settingsProvider);
|
||||
ChatProvider(this._settingsProvider) {
|
||||
_initHooks();
|
||||
}
|
||||
|
||||
final SettingsProvider _settingsProvider;
|
||||
final ToolLoopService _toolLoopService = ToolLoopService();
|
||||
ToolLoopService _toolLoopService = ToolLoopService();
|
||||
HookRunner? _hookRunner;
|
||||
ConversationHistory? _conversationHistory;
|
||||
OpenRouterClient? _client;
|
||||
bool _stopRequested = false;
|
||||
PendingPermission? _pendingPermission;
|
||||
|
||||
PendingPermission? get pendingPermission => _pendingPermission;
|
||||
|
||||
Future<void> _initHooks() async {
|
||||
try {
|
||||
final hooks = await HookLoader.loadHooks();
|
||||
_hookRunner = HookRunner(hooks: hooks);
|
||||
_toolLoopService = ToolLoopService(hookRunner: _hookRunner);
|
||||
} catch (e) {
|
||||
// hooks are optional, carry on without them
|
||||
print("Hook init failed: $e");
|
||||
}
|
||||
}
|
||||
|
||||
List<Message> _messages = <Message>[];
|
||||
List<Map<String, dynamic>> _apiMessages = <Map<String, dynamic>>[];
|
||||
bool isLoading = false;
|
||||
final List<QueuedMessage> _messageQueue = [];
|
||||
|
||||
List<Message> get messages => _messages;
|
||||
int get messageCount => _messages.length;
|
||||
List<Message> get messages => _conversationHistory?.getMessages() ?? const [];
|
||||
int get messageCount => messages.length;
|
||||
String? get workingDirectory => _conversationHistory?.session?.workingDirectory;
|
||||
|
||||
/// Context window size from the last API response — derived from persisted
|
||||
/// message data, same as Claude Code (walks backwards to find the last
|
||||
/// assistant message that has contextTokens set).
|
||||
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;
|
||||
}
|
||||
bool get hasConversation => _conversationHistory != null;
|
||||
bool get isStopping => _stopRequested;
|
||||
int get queuedMessageCount => _messageQueue.length;
|
||||
|
||||
// only user-visible messages (priority != now)
|
||||
List<String> get queuedMessages =>
|
||||
List.unmodifiable(_messageQueue.map((m) => m.text));
|
||||
|
||||
void removeQueuedMessage(int index) {
|
||||
if (index < 0 || index >= _messageQueue.length) return;
|
||||
_messageQueue.removeAt(index);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
QueuedMessage? _dequeue() {
|
||||
if (_messageQueue.isEmpty) return null;
|
||||
|
||||
int bestIdx = 0;
|
||||
for (int i = 1; i < _messageQueue.length; i++) {
|
||||
if (_messageQueue[i].priority.order < _messageQueue[bestIdx].priority.order) {
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
final cmd = _messageQueue[bestIdx];
|
||||
_messageQueue.removeAt(bestIdx);
|
||||
return cmd;
|
||||
}
|
||||
|
||||
void setConversation(ConversationHistory history) {
|
||||
_conversationHistory = history;
|
||||
_messages = history.getMessages();
|
||||
_apiMessages = _buildApiMessages(_messages);
|
||||
_apiMessages = _buildApiMessages(history.getMessages());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearConversation() {
|
||||
_conversationHistory = null;
|
||||
_messages = <Message>[];
|
||||
_apiMessages = <Map<String, dynamic>>[];
|
||||
_messageQueue.clear();
|
||||
isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> sendMessage(String text) async {
|
||||
Future<void> sendMessage(String text, {QueuePriority priority = QueuePriority.next}) async {
|
||||
if (text.isEmpty || _conversationHistory == null) return;
|
||||
|
||||
if (isLoading) {
|
||||
_messageQueue.add(QueuedMessage(text: text, priority: priority));
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final apiKey = _settingsProvider.settings.openRouterApiKey;
|
||||
if (apiKey == null || apiKey.isEmpty) {
|
||||
throw Exception(
|
||||
@@ -72,25 +154,35 @@ class ChatProvider extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// fire UserPromptSubmit hook
|
||||
await _hookRunner?.runHooksForKind(
|
||||
HookKind.userPromptSubmit,
|
||||
input: {"message": text},
|
||||
);
|
||||
|
||||
// add user message to conversation
|
||||
_conversationHistory!.addMessage("user", text);
|
||||
_messages = _conversationHistory!.getMessages();
|
||||
|
||||
_apiMessages.add(<String, dynamic>{"role": "user", "content": text});
|
||||
isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
final advisorModel = _settingsProvider.settings.advisorModel;
|
||||
|
||||
final toolLoopResult = await _toolLoopService.runTurn(
|
||||
client: _client!,
|
||||
model: model,
|
||||
apiKey: apiKey,
|
||||
getSettings: () => _settingsProvider.settings,
|
||||
apiMessages: _apiMessages.take(_apiMessages.length - 1).toList(),
|
||||
userText: text,
|
||||
workingDirectory: workingDirectory,
|
||||
advisorModel: advisorModel,
|
||||
onToolCall: (toolName, input) {
|
||||
_conversationHistory!.addMessage(
|
||||
"tool",
|
||||
_formatToolCall(toolName, input),
|
||||
);
|
||||
_messages = _conversationHistory!.getMessages();
|
||||
notifyListeners();
|
||||
},
|
||||
onToolResult: (toolName, result) {
|
||||
@@ -98,7 +190,6 @@ class ChatProvider extends ChangeNotifier {
|
||||
"tool",
|
||||
_formatToolResult(toolName, result),
|
||||
);
|
||||
_messages = _conversationHistory!.getMessages();
|
||||
notifyListeners();
|
||||
},
|
||||
onAssistantTextDelta: (delta) {
|
||||
@@ -107,26 +198,38 @@ class ChatProvider extends ChangeNotifier {
|
||||
hasStreamingAssistantMessage = true;
|
||||
}
|
||||
_conversationHistory!.appendToLastMessage(delta);
|
||||
_messages = _conversationHistory!.getMessages();
|
||||
notifyListeners();
|
||||
},
|
||||
onAssistantMessageComplete: () {
|
||||
hasStreamingAssistantMessage = false;
|
||||
_messages = _conversationHistory!.getMessages();
|
||||
notifyListeners();
|
||||
},
|
||||
onPermissionRequired: (toolName, input) async {
|
||||
final pending = PendingPermission(toolName: toolName, input: input);
|
||||
_pendingPermission = pending;
|
||||
notifyListeners();
|
||||
final decision = await pending.future;
|
||||
_pendingPermission = null;
|
||||
notifyListeners();
|
||||
return decision;
|
||||
},
|
||||
);
|
||||
_apiMessages = toolLoopResult.apiMessages;
|
||||
|
||||
final ct = toolLoopResult.response.contextTokens;
|
||||
|
||||
// add assistant message to visible conversation
|
||||
if (!toolLoopResult.finalResponseWasStreamed) {
|
||||
_conversationHistory!.addMessage(
|
||||
"assistant",
|
||||
toolLoopResult.responseText,
|
||||
tokens: toolLoopResult.response.outputTokens,
|
||||
contextTokens: ct,
|
||||
);
|
||||
} else {
|
||||
// streamed message was built incrementally — patch contextTokens onto it
|
||||
_conversationHistory!.setLastMessageContextTokens(ct);
|
||||
}
|
||||
_messages = _conversationHistory!.getMessages();
|
||||
|
||||
// track cost (set to 0 for now — OpenRouter pricing varies by model)
|
||||
final inputTokens = toolLoopResult.response.inputTokens ?? 0;
|
||||
@@ -138,6 +241,8 @@ class ChatProvider extends ChangeNotifier {
|
||||
outputTokens: outputTokens,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
webSearchRequests: toolLoopResult.webSearchRequests,
|
||||
webFetchRequests: toolLoopResult.webFetchRequests,
|
||||
model: toolLoopResult.response.model,
|
||||
);
|
||||
|
||||
@@ -154,7 +259,7 @@ class ChatProvider extends ChangeNotifier {
|
||||
if (error is RequestCancelledException) {
|
||||
_conversationHistory!.addMessage("assistant", "Generation stopped.");
|
||||
final session = _conversationHistory!.session;
|
||||
_messages = _conversationHistory!.getMessages();
|
||||
|
||||
if (session != null) {
|
||||
await SessionStore.instance.saveSession(session);
|
||||
}
|
||||
@@ -171,7 +276,7 @@ class ChatProvider extends ChangeNotifier {
|
||||
);
|
||||
|
||||
final session = _conversationHistory!.session;
|
||||
_messages = _conversationHistory!.getMessages();
|
||||
|
||||
if (session != null) {
|
||||
await SessionStore.instance.saveSession(session);
|
||||
}
|
||||
@@ -183,6 +288,26 @@ class ChatProvider extends ChangeNotifier {
|
||||
isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
final next = _dequeue();
|
||||
if (next != null) {
|
||||
notifyListeners();
|
||||
await sendMessage(next.text, priority: next.priority);
|
||||
}
|
||||
}
|
||||
|
||||
void resolvePermission(PermissionDecision decision) async {
|
||||
final pending = _pendingPermission;
|
||||
if (pending == null) return;
|
||||
|
||||
if (decision == PermissionDecision.allowAlways) {
|
||||
// persist to settings so this tool is auto-allowed from now on
|
||||
await _settingsProvider.addAlwaysAllowRule(pending.toolName);
|
||||
}
|
||||
|
||||
pending.resolve(decision);
|
||||
_pendingPermission = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void stopGenerating() {
|
||||
@@ -190,10 +315,15 @@ class ChatProvider extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingPermission?.resolve(PermissionDecision.reject);
|
||||
_pendingPermission = null;
|
||||
_messageQueue.clear();
|
||||
_stopRequested = true;
|
||||
print("Stopping active turn");
|
||||
_client?.cancelActiveRequest();
|
||||
notifyListeners();
|
||||
|
||||
_hookRunner?.runHooksForKind(HookKind.stop);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -232,7 +362,10 @@ class ChatProvider extends ChangeNotifier {
|
||||
|
||||
String _formatToolCall(String toolName, Map<String, dynamic> input) {
|
||||
const encoder = JsonEncoder.withIndent(" ");
|
||||
return "$toolName call\n${encoder.convert(input)}";
|
||||
final visibleInput = Map<String, dynamic>.fromEntries(
|
||||
input.entries.where((entry) => !entry.key.startsWith("_")),
|
||||
);
|
||||
return "$toolName call\n${encoder.convert(visibleInput)}";
|
||||
}
|
||||
|
||||
String _formatToolResult(String toolName, String result) {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import "package:file_picker/file_picker.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
|
||||
import "../../src/project_store.dart";
|
||||
import "../../src/session/session_types.dart";
|
||||
import "chat_provider.dart";
|
||||
import "projects_provider.dart";
|
||||
import "session_provider.dart";
|
||||
import "settings_provider.dart";
|
||||
|
||||
class HomeCoordinator extends ChangeNotifier {
|
||||
|
||||
HomeCoordinator(this._projects, this._session, this._chat, this._settings);
|
||||
|
||||
final ProjectsProvider _projects;
|
||||
final SessionProvider _session;
|
||||
final ChatProvider _chat;
|
||||
final SettingsProvider _settings;
|
||||
|
||||
String? _error;
|
||||
String? get error => _error;
|
||||
|
||||
void clearError() {
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _setError(String msg) {
|
||||
_error = msg;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
Future<void> pickProjectDirectory() async {
|
||||
try {
|
||||
final selectedDirectory = await FilePicker.platform.getDirectoryPath(
|
||||
dialogTitle: "Select project directory",
|
||||
);
|
||||
|
||||
if (selectedDirectory == null) return;
|
||||
|
||||
final project = await _projects.addProject(selectedDirectory);
|
||||
if (project == null) {
|
||||
_setError("The selected folder could not be added as a project.");
|
||||
return;
|
||||
}
|
||||
|
||||
_projects.selectProject(project.id);
|
||||
_session.clearCurrentSession(workingDirectory: project.workingDirectory);
|
||||
_chat.clearConversation();
|
||||
await _settings.setActiveProject(project.workingDirectory);
|
||||
} catch (e, st) {
|
||||
print("Project directory picker failed: $e");
|
||||
print(st);
|
||||
_setError(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createNewChat() async {
|
||||
final selectedProject = _projects.selectedProject;
|
||||
if (selectedProject == null) {
|
||||
_setError("Choose a project first so the new chat has a working directory.");
|
||||
return;
|
||||
}
|
||||
|
||||
await _session.createNewSession(
|
||||
workingDirectory: selectedProject.workingDirectory,
|
||||
name: "New Chat",
|
||||
model: _settings.settings.model,
|
||||
);
|
||||
_settings.setThreadModel(_settings.settings.model);
|
||||
_chat.setConversation(_session.getConversationHistory());
|
||||
}
|
||||
|
||||
Future<void> selectProject(ProjectRecord project) async {
|
||||
_projects.selectProject(project.id);
|
||||
await _settings.setActiveProject(project.workingDirectory);
|
||||
|
||||
if (_session.currentSession?.workingDirectory == project.workingDirectory) return;
|
||||
|
||||
_session.clearCurrentSession(workingDirectory: project.workingDirectory);
|
||||
_settings.setThreadModel(null);
|
||||
_chat.clearConversation();
|
||||
}
|
||||
|
||||
Future<void> openSession(SessionSummary session) async {
|
||||
await _session.loadSession(session);
|
||||
_chat.setConversation(_session.getConversationHistory());
|
||||
_projects.selectProjectByWorkingDirectory(_session.activeWorkingDirectory);
|
||||
_settings.setThreadModel(_session.currentSession?.model);
|
||||
}
|
||||
|
||||
Future<void> sendMessage(String text) async {
|
||||
if (text.isEmpty) return;
|
||||
|
||||
if (_session.currentSession == null) {
|
||||
final selectedProject = _projects.selectedProject;
|
||||
if (selectedProject == null) {
|
||||
_setError("Pick a project before starting a chat.");
|
||||
return;
|
||||
}
|
||||
await _session.createNewSession(
|
||||
workingDirectory: selectedProject.workingDirectory,
|
||||
name: "New Chat",
|
||||
model: _settings.settings.model,
|
||||
);
|
||||
_settings.setThreadModel(_settings.settings.model);
|
||||
_chat.setConversation(_session.getConversationHistory());
|
||||
}
|
||||
|
||||
try {
|
||||
await _chat.sendMessage(text);
|
||||
} catch (e, st) {
|
||||
print("Failed to send message: $e");
|
||||
print(st);
|
||||
_setError(e.toString());
|
||||
} finally {
|
||||
await _session.refreshSessions();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteSession(SessionSummary session) async {
|
||||
await _session.deleteSession(session);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:uuid/uuid.dart";
|
||||
|
||||
import "../../src/project_store.dart";
|
||||
import "../../src/session/conversation_history.dart";
|
||||
import "../../src/session/session_store.dart";
|
||||
import "../../src/session/session_types.dart";
|
||||
|
||||
class SessionProvider extends ChangeNotifier {
|
||||
SessionProvider() {
|
||||
SessionProvider(this._projectStore) {
|
||||
_loadSessions();
|
||||
}
|
||||
|
||||
final ProjectStore _projectStore;
|
||||
final SessionStore _sessionStore = SessionStore.instance;
|
||||
final ConversationHistory _conversationHistory = ConversationHistory();
|
||||
|
||||
@@ -59,7 +61,12 @@ class SessionProvider extends ChangeNotifier {
|
||||
|
||||
Future<void> _loadSessions() async {
|
||||
try {
|
||||
_sessions = await _sessionStore.listSessions();
|
||||
final workingDirs = _projectStore.projects
|
||||
.map((p) => p.workingDirectory)
|
||||
.where((d) => d.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
_sessions = await _sessionStore.listAllSessions(workingDirs);
|
||||
notifyListeners();
|
||||
} catch (error, stackTrace) {
|
||||
_logException("Failed to load sessions", error, stackTrace);
|
||||
@@ -70,6 +77,7 @@ class SessionProvider extends ChangeNotifier {
|
||||
Future<void> createNewSession({
|
||||
String? workingDirectory,
|
||||
String? name,
|
||||
String? model,
|
||||
}) async {
|
||||
try {
|
||||
const uuid = Uuid();
|
||||
@@ -86,6 +94,7 @@ class SessionProvider extends ChangeNotifier {
|
||||
normalizedDirectory == null || normalizedDirectory.isEmpty
|
||||
? null
|
||||
: normalizedDirectory,
|
||||
model: model,
|
||||
);
|
||||
|
||||
await _sessionStore.saveSession(newSession);
|
||||
@@ -101,29 +110,38 @@ class SessionProvider extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadSession(String id) async {
|
||||
Future<void> loadSession(SessionSummary summary) async {
|
||||
try {
|
||||
final session = await _sessionStore.loadSession(id);
|
||||
final workingDir = summary.workingDirectory;
|
||||
if (workingDir == null || workingDir.isEmpty) return;
|
||||
|
||||
final session = await _sessionStore.loadSession(
|
||||
summary.id,
|
||||
workingDirectory: workingDir,
|
||||
);
|
||||
if (session != null) {
|
||||
_conversationHistory.setSession(session);
|
||||
_currentSession = session;
|
||||
_currentSessionId = id;
|
||||
_currentSessionId = summary.id;
|
||||
_activeWorkingDirectory = session.workingDirectory;
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
_logException("Failed to load session $id", error, stackTrace);
|
||||
_logException("Failed to load session ${summary.id}", error, stackTrace);
|
||||
_currentSession = null;
|
||||
_currentSessionId = null;
|
||||
_activeWorkingDirectory = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteSession(String id) async {
|
||||
Future<void> deleteSession(SessionSummary summary) async {
|
||||
try {
|
||||
await _sessionStore.deleteSession(id);
|
||||
final workingDir = summary.workingDirectory;
|
||||
if (workingDir == null || workingDir.isEmpty) return;
|
||||
|
||||
if (_currentSessionId == id) {
|
||||
await _sessionStore.deleteSession(summary.id, workingDirectory: workingDir);
|
||||
|
||||
if (_currentSessionId == summary.id) {
|
||||
_conversationHistory.setSession(
|
||||
ConversationSession(
|
||||
id: "",
|
||||
@@ -140,7 +158,7 @@ class SessionProvider extends ChangeNotifier {
|
||||
await _loadSessions();
|
||||
notifyListeners();
|
||||
} catch (error, stackTrace) {
|
||||
_logException("Failed to delete session $id", error, stackTrace);
|
||||
_logException("Failed to delete session ${summary.id}", error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +170,15 @@ class SessionProvider extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Updates the model on the current in-memory session and persists it
|
||||
Future<void> updateSessionModel(String model) async {
|
||||
final session = _currentSession;
|
||||
if (session == null) return;
|
||||
|
||||
session.model = model;
|
||||
await _sessionStore.saveSession(session);
|
||||
}
|
||||
|
||||
ConversationHistory getConversationHistory() => _conversationHistory;
|
||||
|
||||
void _logException(String message, Object error, StackTrace stackTrace) {
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
import "package:flutter/foundation.dart";
|
||||
|
||||
import "../../src/local_state.dart";
|
||||
import "../../src/project_settings_store.dart";
|
||||
|
||||
class SettingsProvider extends ChangeNotifier {
|
||||
SettingsProvider(this._settingsStore) : settings = _settingsStore.settings;
|
||||
SettingsProvider(this._settingsStore) : _globalSettings = _settingsStore.settings;
|
||||
|
||||
static const Map<String, String> _legacyModelAliases = {
|
||||
"google/gemini-2.0-flash": "google/gemini-2.0-flash-001",
|
||||
};
|
||||
|
||||
final SettingsStore _settingsStore;
|
||||
LocalSettings settings;
|
||||
|
||||
LocalSettings _globalSettings;
|
||||
LocalSettings? _projectSettings;
|
||||
String? _threadModel;
|
||||
|
||||
String? _activeProjectDir;
|
||||
|
||||
// Effective settings: global → project override → thread model
|
||||
LocalSettings get settings {
|
||||
var merged = _globalSettings.mergeWith(_projectSettings);
|
||||
if (_threadModel != null && _threadModel!.isNotEmpty) {
|
||||
merged = merged.copyWith(model: _threadModel);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
String normalizeModelId(String? modelId) {
|
||||
if (modelId == null || modelId.isEmpty) {
|
||||
@@ -20,12 +35,36 @@ class SettingsProvider extends ChangeNotifier {
|
||||
return _legacyModelAliases[modelId] ?? modelId;
|
||||
}
|
||||
|
||||
// Called when the active project changes
|
||||
Future<void> setActiveProject(String? workingDirectory) async {
|
||||
_activeProjectDir = workingDirectory;
|
||||
_projectSettings = null;
|
||||
_threadModel = null;
|
||||
|
||||
if (workingDirectory != null && workingDirectory.isNotEmpty) {
|
||||
_projectSettings = await ProjectSettingsStore.instance.load(workingDirectory);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Called when a thread is loaded or cleared
|
||||
void setThreadModel(String? model) {
|
||||
_threadModel = model != null ? normalizeModelId(model) : null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> updateModel(String newModel) async {
|
||||
final normalizedModel = normalizeModelId(newModel);
|
||||
final normalized = normalizeModelId(newModel);
|
||||
|
||||
// update thread model in memory
|
||||
_threadModel = normalized;
|
||||
|
||||
// also persist to global settings as the new default
|
||||
await _settingsStore.update(
|
||||
(current) => current.copyWith(model: normalizedModel),
|
||||
(current) => current.copyWith(model: normalized),
|
||||
);
|
||||
settings = _settingsStore.settings;
|
||||
_globalSettings = _settingsStore.settings;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -33,13 +72,13 @@ class SettingsProvider extends ChangeNotifier {
|
||||
await _settingsStore.update(
|
||||
(current) => current.copyWith(openRouterApiKey: newKey),
|
||||
);
|
||||
settings = _settingsStore.settings;
|
||||
_globalSettings = _settingsStore.settings;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> updateTheme(String newTheme) async {
|
||||
await _settingsStore.update((current) => current.copyWith(theme: newTheme));
|
||||
settings = _settingsStore.settings;
|
||||
_globalSettings = _settingsStore.settings;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -47,13 +86,35 @@ class SettingsProvider extends ChangeNotifier {
|
||||
await _settingsStore.update(
|
||||
(current) => current.copyWith(effortLevel: newLevel),
|
||||
);
|
||||
settings = _settingsStore.settings;
|
||||
_globalSettings = _settingsStore.settings;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> addAlwaysAllowRule(String toolName) async {
|
||||
final current = _globalSettings.alwaysAllowRules;
|
||||
if (current.contains(toolName)) return;
|
||||
await _settingsStore.update(
|
||||
(s) => s.copyWith(alwaysAllowRules: [...current, toolName]),
|
||||
);
|
||||
_globalSettings = _settingsStore.settings;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> resetToDefaults() async {
|
||||
await _settingsStore.update((_) => const LocalSettings());
|
||||
settings = _settingsStore.settings;
|
||||
_globalSettings = _settingsStore.settings;
|
||||
_projectSettings = null;
|
||||
_threadModel = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Save project-level settings override
|
||||
Future<void> updateProjectSetting(LocalSettings projectOverride) async {
|
||||
final dir = _activeProjectDir;
|
||||
if (dir == null || dir.isEmpty) return;
|
||||
|
||||
await ProjectSettingsStore.instance.save(dir, projectOverride);
|
||||
_projectSettings = projectOverride;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import "package:go_router/go_router.dart";
|
||||
|
||||
import "../pages/home_screen/page.dart";
|
||||
import "../pages/settings/page.dart";
|
||||
import "../pages/project_detail/page.dart";
|
||||
|
||||
/// Application router configuration
|
||||
class AppRouter {
|
||||
/// List of all routes in the application
|
||||
static final routes = [
|
||||
HomeScreenRoute.route,
|
||||
SettingsRoute.route,
|
||||
ProjectDetailRoute.route,
|
||||
];
|
||||
|
||||
/// The main GoRouter instance
|
||||
static final GoRouter router = GoRouter(
|
||||
routes: routes,
|
||||
initialLocation: HomeScreenRoute.path,
|
||||
debugLogDiagnostics: true,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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 weeks = difference.inDays ~/ 7;
|
||||
return "${weeks}w";
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import "package:path/path.dart" as p;
|
||||
|
||||
String shortenPath(String fullPath, String? projectRoot) {
|
||||
if (projectRoot == null || projectRoot.isEmpty) return fullPath;
|
||||
|
||||
final root = p.normalize(projectRoot);
|
||||
final norm = p.normalize(fullPath);
|
||||
|
||||
if (norm.startsWith(root)) {
|
||||
final rel = norm.substring(root.length);
|
||||
// trim leading separator
|
||||
return rel.startsWith(p.separator) ? rel.substring(1) : rel;
|
||||
}
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class AgentsPane extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: OutlinedContainer(
|
||||
width: 300,
|
||||
child: Column(
|
||||
children: [
|
||||
|
||||
|
||||
],
|
||||
)
|
||||
),
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import "package:gpt_markdown/gpt_markdown.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
class AdvisorMessage extends StatelessWidget {
|
||||
const AdvisorMessage({super.key, required this.title, required this.body});
|
||||
|
||||
final String title;
|
||||
final String body;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
Row(
|
||||
children: [
|
||||
OutlinedContainer(
|
||||
padding: const EdgeInsets.all(10),
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
child: Icon(LucideIcons.brain).iconSmall,
|
||||
),
|
||||
Gap(8),
|
||||
Text(
|
||||
title,
|
||||
style: theme.typography.p.copyWith(fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (body.isNotEmpty) ...[
|
||||
Gap(8),
|
||||
OutlinedContainer(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: GptMarkdown(body),
|
||||
),
|
||||
],
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import '../../models/attachment.dart';
|
||||
import '../common/button.dart';
|
||||
|
||||
class AttachmentPreview extends StatelessWidget {
|
||||
final List<Attachment> attachments;
|
||||
final Function(int) onRemove;
|
||||
|
||||
const AttachmentPreview({
|
||||
required this.attachments,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (attachments.isEmpty) {
|
||||
return SizedBox.shrink();
|
||||
}
|
||||
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.basic,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (int i = 0; i < attachments.length; i++)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(right: 8),
|
||||
child: AttachmentItem(
|
||||
attachment: attachments[i],
|
||||
onRemove: () => onRemove(i),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AttachmentItem extends StatelessWidget {
|
||||
final Attachment attachment;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
const AttachmentItem({
|
||||
required this.attachment,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
String sanitisedName = attachment.displayName;
|
||||
String type = attachment.mimeType.split("/").last.toUpperCase();
|
||||
|
||||
return OutlinedContainer(
|
||||
height: 52,
|
||||
borderRadius: Theme.of(context).borderRadiusSm,
|
||||
padding: EdgeInsets.all(8),
|
||||
borderColor: Theme.of(context).colorScheme.border,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
OutlinedContainer(
|
||||
borderRadius: BorderRadius.circular(
|
||||
Theme.of(context).radiusSm - 4
|
||||
),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.zero,
|
||||
child: _buildPreview(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
Gap(8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
sanitisedName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).small.semiBold,
|
||||
Gap(2),
|
||||
Text(
|
||||
type,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).extraLight.small,
|
||||
],
|
||||
),
|
||||
Gap(8),
|
||||
SizedBox(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
Theme.of(context).radiusSm - 4
|
||||
),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: AgcGhostButton(
|
||||
onPressed: onRemove,
|
||||
child: Icon(LucideIcons.x, size: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPreview(BuildContext context) {
|
||||
if (attachment.isImage) {
|
||||
return Image.memory(
|
||||
attachment.data,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
}
|
||||
|
||||
final icon = _getIconForMimeType(attachment.mimeType);
|
||||
return Container(
|
||||
color: Theme.of(context).colorScheme.muted,
|
||||
child: Icon(icon).iconMedium,
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getIconForMimeType(String mimeType) {
|
||||
if (mimeType == 'application/pdf') {
|
||||
return LucideIcons.book;
|
||||
} else if (mimeType.startsWith('text/') || mimeType == 'application/json') {
|
||||
return LucideIcons.fileText;
|
||||
} else if (mimeType.startsWith('image/')) {
|
||||
return LucideIcons.image;
|
||||
} else {
|
||||
return LucideIcons.file;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import "package:gpt_markdown/gpt_markdown.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
class AssistantBubble extends StatelessWidget {
|
||||
const AssistantBubble({super.key, required this.content});
|
||||
|
||||
final String content;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GptMarkdown(content);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export "../../../../src/permissions/permission_types.dart" show PermissionDecision;
|
||||
@@ -0,0 +1,89 @@
|
||||
import "dart:convert";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "tools/advisor_bubble.dart";
|
||||
import "tools/bash_bubble.dart";
|
||||
import "tools/default_tool_bubble.dart";
|
||||
import "tools/edit_bubble.dart";
|
||||
import "tools/glob_bubble.dart";
|
||||
import "tools/grep_bubble.dart";
|
||||
import "tools/read_bubble.dart";
|
||||
import "tools/web_fetch_bubble.dart";
|
||||
import "tools/web_search_bubble.dart";
|
||||
import "tools/write_bubble.dart";
|
||||
|
||||
class ToolBubble extends StatelessWidget {
|
||||
const ToolBubble({
|
||||
super.key,
|
||||
required this.toolName,
|
||||
this.toolInput,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
});
|
||||
|
||||
final String toolName;
|
||||
final Map<String, dynamic>? toolInput;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
|
||||
// parse a tool message content string into (toolName, toolInput)
|
||||
// format: "$toolName call\n{json}" or "$toolName result\n..."
|
||||
static (String, Map<String, dynamic>?) parseContent(String content) {
|
||||
final newlineIdx = content.indexOf("\n");
|
||||
if (newlineIdx == -1) {
|
||||
// no body, just a label line
|
||||
final name = _extractName(content);
|
||||
return (name, null);
|
||||
}
|
||||
|
||||
final firstLine = content.substring(0, newlineIdx).trim();
|
||||
final rest = content.substring(newlineIdx + 1).trim();
|
||||
final name = _extractName(firstLine);
|
||||
|
||||
if (firstLine.endsWith(" call") && rest.isNotEmpty) {
|
||||
try {
|
||||
final decoded = jsonDecode(rest);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return (name, decoded);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return (name, null);
|
||||
}
|
||||
|
||||
static String _extractName(String line) {
|
||||
// strip trailing " call" or " result"
|
||||
if (line.endsWith(" call")) return line.substring(0, line.length - 5).trim();
|
||||
if (line.endsWith(" result")) return line.substring(0, line.length - 7).trim();
|
||||
return line.trim();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final input = toolInput ?? {};
|
||||
|
||||
switch (toolName) {
|
||||
case "Bash":
|
||||
return BashBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
case "Edit":
|
||||
return EditBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
case "Read":
|
||||
return ReadBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
case "Write":
|
||||
return WriteBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
case "Glob":
|
||||
return GlobBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
case "Grep":
|
||||
return GrepBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
case "WebSearch":
|
||||
return WebSearchBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
case "WebFetch":
|
||||
return WebFetchBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
case "Advisor":
|
||||
return AdvisorBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
|
||||
default:
|
||||
return DefaultToolBubble(toolName: toolName, input: toolInput, result: result, isPendingPermission: isPendingPermission);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class AdvisorBubble extends StatelessWidget {
|
||||
const AdvisorBubble({
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final model = input["model"] as String? ?? "";
|
||||
|
||||
return ToolBubbleBase(
|
||||
toolName: "Advisor",
|
||||
icon: LucideIcons.brain,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
detail: model,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class BashBubble extends StatelessWidget {
|
||||
const BashBubble({
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final command = input["command"] as String? ?? "";
|
||||
|
||||
return ToolBubbleBase(
|
||||
toolName: "Bash",
|
||||
icon: LucideIcons.terminal,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
detail: command,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import "dart:convert";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class DefaultToolBubble extends StatelessWidget {
|
||||
const DefaultToolBubble({
|
||||
super.key,
|
||||
required this.toolName,
|
||||
this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
});
|
||||
|
||||
final String toolName;
|
||||
final Map<String, dynamic>? input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return ToolBubbleBase(
|
||||
toolName: toolName,
|
||||
icon: LucideIcons.wrench,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
body: input != null && input!.isNotEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: Text(
|
||||
const JsonEncoder.withIndent(" ").convert(input),
|
||||
style: theme.typography.p.copyWith(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
fontFamily: "monospace",
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../../../providers/chat_provider.dart";
|
||||
import "../../../../utils/path_utils.dart";
|
||||
import "../../diff_view.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class EditBubble extends StatelessWidget {
|
||||
const EditBubble({
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final projectRoot = context.read<ChatProvider>().workingDirectory;
|
||||
final filePath = input["file_path"] as String? ?? "";
|
||||
final oldString = input["old_string"] as String? ?? "";
|
||||
final newString = input["new_string"] as String? ?? "";
|
||||
|
||||
return ToolBubbleBase(
|
||||
toolName: "Edit",
|
||||
icon: LucideIcons.filePen,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
detail: shortenPath(filePath, projectRoot),
|
||||
body: DiffView(
|
||||
oldString: oldString,
|
||||
newString: newString,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../../../providers/chat_provider.dart";
|
||||
import "../../../../utils/path_utils.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class GlobBubble extends StatelessWidget {
|
||||
const GlobBubble({
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final projectRoot = context.read<ChatProvider>().workingDirectory;
|
||||
final pattern = input["pattern"] as String? ?? "";
|
||||
final searchPath = input["path"] as String?;
|
||||
|
||||
final detail = searchPath != null && searchPath.isNotEmpty
|
||||
? "${shortenPath(searchPath, projectRoot)}/$pattern"
|
||||
: pattern;
|
||||
|
||||
return ToolBubbleBase(
|
||||
toolName: "Glob",
|
||||
icon: LucideIcons.folderSearch,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
detail: detail,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../../../providers/chat_provider.dart";
|
||||
import "../../../../utils/path_utils.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class GrepBubble extends StatelessWidget {
|
||||
const GrepBubble({
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final projectRoot = context.read<ChatProvider>().workingDirectory;
|
||||
final pattern = input["pattern"] as String? ?? "";
|
||||
final searchPath = input["path"] as String?;
|
||||
|
||||
final detail = searchPath != null && searchPath.isNotEmpty
|
||||
? "${shortenPath(searchPath, projectRoot)} — $pattern"
|
||||
: pattern;
|
||||
|
||||
return ToolBubbleBase(
|
||||
toolName: "Grep",
|
||||
icon: LucideIcons.search,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
detail: detail,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../../../providers/chat_provider.dart";
|
||||
import "../../../../utils/path_utils.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class ReadBubble extends StatelessWidget {
|
||||
const ReadBubble({
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final projectRoot = context.read<ChatProvider>().workingDirectory;
|
||||
final filePath = input["file_path"] as String? ?? "";
|
||||
|
||||
return ToolBubbleBase(
|
||||
toolName: "Read",
|
||||
icon: LucideIcons.fileText,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
detail: shortenPath(filePath, projectRoot),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../../../providers/chat_provider.dart";
|
||||
import "../permission_decision.dart";
|
||||
|
||||
class ToolBubbleBase extends StatelessWidget {
|
||||
const ToolBubbleBase({
|
||||
super.key,
|
||||
required this.toolName,
|
||||
required this.icon,
|
||||
this.detail,
|
||||
this.body,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
});
|
||||
|
||||
final String toolName;
|
||||
final IconData icon;
|
||||
final String? detail;
|
||||
final Widget? body;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
|
||||
OutlinedContainer(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
IntrinsicHeight(
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
color: theme.colorScheme.primary.scaleAlpha(0.5),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon).iconSmall,
|
||||
|
||||
Gap(8),
|
||||
|
||||
Text(
|
||||
toolName,
|
||||
).textSmall,
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
VerticalDivider(),
|
||||
|
||||
if (detail != null)...[
|
||||
Gap(16),
|
||||
Text(
|
||||
detail!,
|
||||
).mono.xSmall
|
||||
]
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
|
||||
if (body != null) ...[
|
||||
Divider(),
|
||||
|
||||
body!,
|
||||
],
|
||||
|
||||
if (result != null) ...[
|
||||
Divider(),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: SelectableText(
|
||||
"\u200B${result!}",
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
).xSmall.mono,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (isPendingPermission) ...[
|
||||
|
||||
Gap(8),
|
||||
Row(
|
||||
children: [
|
||||
|
||||
Expanded(
|
||||
child: Button.outline(
|
||||
leading: Icon(LucideIcons.check).iconSmall,
|
||||
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.allowOnce),
|
||||
child: Text("Allow").small,
|
||||
),
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
Expanded(
|
||||
child: Button.outline(
|
||||
leading: Icon(LucideIcons.checkCheck).iconSmall,
|
||||
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.allowAlways),
|
||||
child: Text("Allow always").small,
|
||||
),
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
Expanded(
|
||||
child: Button.destructive(
|
||||
leading: Icon(LucideIcons.x).iconSmall,
|
||||
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.reject),
|
||||
child: Text("Reject").small,
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class WebFetchBubble extends StatelessWidget {
|
||||
const WebFetchBubble({
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final url = input["url"] as String? ?? "";
|
||||
|
||||
return ToolBubbleBase(
|
||||
toolName: "WebFetch",
|
||||
icon: LucideIcons.link,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
detail: url,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class WebSearchBubble extends StatelessWidget {
|
||||
const WebSearchBubble({
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final query = input["query"] as String? ?? "";
|
||||
|
||||
return ToolBubbleBase(
|
||||
toolName: "WebSearch",
|
||||
icon: LucideIcons.globe,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
detail: query,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../../../providers/chat_provider.dart";
|
||||
import "../../../../utils/path_utils.dart";
|
||||
import "../../diff_view.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class WriteBubble extends StatelessWidget {
|
||||
const WriteBubble({
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final projectRoot = context.read<ChatProvider>().workingDirectory;
|
||||
final filePath = input["file_path"] as String? ?? "";
|
||||
final content = input["content"] as String? ?? "";
|
||||
|
||||
return ToolBubbleBase(
|
||||
toolName: "Write",
|
||||
icon: LucideIcons.filePlus,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
detail: shortenPath(filePath, projectRoot),
|
||||
body: DiffView(
|
||||
oldString: "",
|
||||
newString: content,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
class UserBubble extends StatelessWidget {
|
||||
const UserBubble({super.key, required this.content});
|
||||
|
||||
final String content;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: OutlinedContainer(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
backgroundColor: Theme.of(context).colorScheme.border,
|
||||
child: SelectableText(content),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import 'package:pasteboard/pasteboard.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'dart:io';
|
||||
import '../../constants.dart';
|
||||
import '../../models/attachment.dart';
|
||||
import '../../providers/chat_provider.dart';
|
||||
import '../../providers/home_coordinator.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
import 'attachment_preview.dart';
|
||||
import '../common/button.dart';
|
||||
import 'model_picker_dialog.dart';
|
||||
|
||||
class ChatBox extends StatefulWidget {
|
||||
const ChatBox({super.key});
|
||||
|
||||
@override
|
||||
State<ChatBox> createState() => _ChatBoxState();
|
||||
}
|
||||
|
||||
class _ChatBoxState extends State<ChatBox> {
|
||||
late TextEditingController _controller;
|
||||
late FocusNode _focusNode;
|
||||
final List<Attachment> _attachments = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController();
|
||||
_focusNode = FocusNode();
|
||||
_controller.addListener(_onTextChanged);
|
||||
}
|
||||
|
||||
Future<void> _onPastePressed() async {
|
||||
try {
|
||||
final filePaths = await Pasteboard.files();
|
||||
if (filePaths.isNotEmpty && mounted) {
|
||||
for (var filePath in filePaths) {
|
||||
try {
|
||||
final file = File(filePath);
|
||||
final fileBytes = await file.readAsBytes();
|
||||
final fileName = file.path.split('/').last;
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_attachments.add(
|
||||
Attachment(
|
||||
name: fileName,
|
||||
mimeType: _getMimeType(fileName, fileBytes),
|
||||
data: fileBytes,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// skip files that cant be read
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// no files in clipboard
|
||||
}
|
||||
|
||||
// fallback to raw image data (screenshots etc)
|
||||
try {
|
||||
final imageBytes = await Pasteboard.image;
|
||||
if (imageBytes != null && mounted) {
|
||||
final imageData = Uint8List.fromList(imageBytes);
|
||||
setState(() {
|
||||
_attachments.add(
|
||||
Attachment(
|
||||
name: 'image.png',
|
||||
mimeType: _getMimeType('image.png', imageData),
|
||||
data: imageData,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// no image in clipboard
|
||||
}
|
||||
}
|
||||
|
||||
String _getMimeType(String filename, Uint8List data) {
|
||||
if (data.length >= 4) {
|
||||
if (data[0] == 0x25 &&
|
||||
data[1] == 0x50 &&
|
||||
data[2] == 0x44 &&
|
||||
data[3] == 0x46) {
|
||||
return 'application/pdf';
|
||||
}
|
||||
if (data[0] == 0x89 &&
|
||||
data[1] == 0x50 &&
|
||||
data[2] == 0x4E &&
|
||||
data[3] == 0x47) {
|
||||
return 'image/png';
|
||||
}
|
||||
if (data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46) {
|
||||
return 'image/gif';
|
||||
}
|
||||
if (data[0] == 0x52 &&
|
||||
data[1] == 0x49 &&
|
||||
data[2] == 0x46 &&
|
||||
data[3] == 0x46) {
|
||||
if (data.length >= 12 &&
|
||||
data[8] == 0x57 &&
|
||||
data[9] == 0x45 &&
|
||||
data[10] == 0x42 &&
|
||||
data[11] == 0x50) {
|
||||
return 'image/webp';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final extension = filename.split('.').last.toLowerCase();
|
||||
switch (extension) {
|
||||
case 'pdf':
|
||||
return 'application/pdf';
|
||||
case 'txt':
|
||||
return 'text/plain';
|
||||
case 'json':
|
||||
return 'application/json';
|
||||
case 'csv':
|
||||
return 'text/csv';
|
||||
case 'md':
|
||||
return 'text/markdown';
|
||||
case 'png':
|
||||
return 'image/png';
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
return 'image/jpeg';
|
||||
case 'gif':
|
||||
return 'image/gif';
|
||||
case 'webp':
|
||||
return 'image/webp';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
void _onTextChanged() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onAttachPressed() async {
|
||||
final result = await FilePicker.platform.pickFiles(allowMultiple: true);
|
||||
if (result == null || !mounted) return;
|
||||
|
||||
for (final file in result.files) {
|
||||
if (file.path == null) continue;
|
||||
|
||||
try {
|
||||
final f = File(file.path!);
|
||||
final bytes = await f.readAsBytes();
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_attachments.add(
|
||||
Attachment(
|
||||
name: file.name,
|
||||
mimeType: _getMimeType(file.name, bytes),
|
||||
data: bytes,
|
||||
),
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
// skip unreadable files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _left(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 38,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: AgcGhostButton(
|
||||
borderRadius: BorderRadius.circular(Theme.of(context).radiusLg - 4),
|
||||
onPressed: _onAttachPressed,
|
||||
child: Icon(LucideIcons.paperclip),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openModelDialog(BuildContext context) async {
|
||||
final settings = context.read<SettingsProvider>();
|
||||
final session = context.read<SessionProvider>();
|
||||
final selectedModel = settings.normalizeModelId(settings.settings.model);
|
||||
|
||||
final result = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => ModelPickerDialog(
|
||||
models: selectableAiModels,
|
||||
selectedModel: selectedModel,
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
await settings.updateModel(result);
|
||||
await session.updateSessionModel(result);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _right(BuildContext context) {
|
||||
final settings = context.read<SettingsProvider>();
|
||||
final selectedModel = settings.normalizeModelId(settings.settings.model);
|
||||
|
||||
return SizedBox(
|
||||
height: 38,
|
||||
child: Row(
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 150,
|
||||
minHeight: double.infinity,
|
||||
),
|
||||
child: AgcGhostButton(
|
||||
borderRadius: BorderRadius.circular(
|
||||
Theme.of(context).radiusLg - 4,
|
||||
),
|
||||
onPressed: () => _openModelDialog(context),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
selectableAiModels
|
||||
.where((m) => m.id == selectedModel)
|
||||
.map((m) => m.label)
|
||||
.firstOrNull ??
|
||||
selectedModel,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).small,
|
||||
),
|
||||
Gap(8),
|
||||
Icon(LucideIcons.chevronsUpDown),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: AgcSecondaryButton(
|
||||
enabled: _controller.text.isNotEmpty,
|
||||
onPressed: () {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
context.read<HomeCoordinator>().sendMessage(text);
|
||||
_controller.clear();
|
||||
},
|
||||
child: Icon(LucideIcons.arrowUp),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLeading(BuildContext context, int numberOfLines) {
|
||||
if (numberOfLines > 1) return SizedBox.shrink();
|
||||
return _left(context);
|
||||
}
|
||||
|
||||
Widget _buildTrailing(int numberOfLines) {
|
||||
if (numberOfLines > 1) return SizedBox.shrink();
|
||||
return _right(context);
|
||||
}
|
||||
|
||||
Widget? _buildBottom(BuildContext context, int numberOfLines) {
|
||||
if (numberOfLines <= 1) return null;
|
||||
|
||||
return Container(
|
||||
margin: EdgeInsets.only(top: 8),
|
||||
height: 32,
|
||||
child: Row(children: [_left(context), Spacer(), _right(context)]),
|
||||
);
|
||||
}
|
||||
|
||||
String _fmtTokens(int n) {
|
||||
final s = n.toString();
|
||||
final buf = StringBuffer();
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
if (i > 0 && (s.length - i) % 3 == 0) buf.write(",");
|
||||
buf.write(s[i]);
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
void _removeAttachment(int index) {
|
||||
setState(() {
|
||||
_attachments.removeAt(index);
|
||||
});
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.removeListener(_onTextChanged);
|
||||
_controller.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chat = context.watch<ChatProvider>();
|
||||
context
|
||||
.watch<SettingsProvider>(); // needed so model label updates reactively
|
||||
|
||||
final queuedMessages = chat.queuedMessages;
|
||||
final contextTokens = chat.contextTokens;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final style = DefaultTextStyle.of(context).style;
|
||||
|
||||
const reservedForIcons = 34;
|
||||
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(text: _controller.text, style: style),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout(maxWidth: constraints.maxWidth - reservedForIcons);
|
||||
|
||||
final numberOfLines = painter.computeLineMetrics().length;
|
||||
|
||||
return Focus(
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is KeyDownEvent &&
|
||||
event.logicalKey == LogicalKeyboardKey.keyV &&
|
||||
(HardwareKeyboard.instance.isControlPressed ||
|
||||
HardwareKeyboard.instance.isMetaPressed)) {
|
||||
_onPastePressed();
|
||||
}
|
||||
|
||||
if (event is KeyDownEvent &&
|
||||
event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
if (HardwareKeyboard.instance.isShiftPressed) {
|
||||
final sel = _controller.selection;
|
||||
final text = _controller.text;
|
||||
final newText = text.replaceRange(sel.start, sel.end, '\n');
|
||||
_controller.value = TextEditingValue(
|
||||
text: newText,
|
||||
selection: TextSelection.collapsed(offset: sel.start + 1),
|
||||
);
|
||||
return KeyEventResult.handled;
|
||||
} else {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isNotEmpty) {
|
||||
context.read<HomeCoordinator>().sendMessage(text);
|
||||
_controller.clear();
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: OutlinedContainer(
|
||||
child: ButtonGroup.vertical(
|
||||
expands: true,
|
||||
children: [
|
||||
for (int i = 0; i < queuedMessages.length; i++) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.cornerDownRight,
|
||||
).iconSmall.iconMutedForeground,
|
||||
|
||||
Gap(14),
|
||||
|
||||
Expanded(
|
||||
child: Text(queuedMessages[i]).small.textMuted,
|
||||
),
|
||||
|
||||
IconButton.text(
|
||||
onPressed: () => chat.removeQueuedMessage(i),
|
||||
icon: const Icon(LucideIcons.trash2),
|
||||
).iconSmall,
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Divider(),
|
||||
],
|
||||
|
||||
TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
borderRadius: Theme.of(context).borderRadiusLg,
|
||||
placeholder: Text("Ask the agency anything"),
|
||||
minLines: 1,
|
||||
maxLines: numberOfLines > 1 ? 5 : 1,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
padding: EdgeInsets.all(8),
|
||||
features: [
|
||||
if (_attachments.isNotEmpty)
|
||||
InputFeature.above(
|
||||
AttachmentPreview(
|
||||
attachments: _attachments,
|
||||
onRemove: _removeAttachment,
|
||||
),
|
||||
),
|
||||
|
||||
InputFeature.leading(
|
||||
_buildLeading(context, numberOfLines),
|
||||
),
|
||||
|
||||
InputFeature.trailing(_buildTrailing(numberOfLines)),
|
||||
|
||||
InputFeature.below(
|
||||
_buildBottom(context, numberOfLines),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (chat.isLoading)
|
||||
SizedBox(
|
||||
height: 4,
|
||||
child: LinearProgressIndicator()
|
||||
)
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
import "../../../src/session/session_types.dart";
|
||||
import "../../providers/chat_provider.dart";
|
||||
import "bubbles/assistant_bubble.dart";
|
||||
import "bubbles/tool_bubble.dart";
|
||||
import "bubbles/user_bubble.dart";
|
||||
|
||||
class ChatView extends StatefulWidget {
|
||||
final ScrollController scrollController;
|
||||
|
||||
const ChatView({super.key, required this.scrollController});
|
||||
|
||||
@override
|
||||
State<ChatView> createState() => _ChatViewState();
|
||||
}
|
||||
|
||||
class _ChatViewState extends State<ChatView> {
|
||||
ScrollController get _scrollController => widget.scrollController;
|
||||
List<String> _previousMessageContents = [];
|
||||
bool _isUserScrolling = false;
|
||||
DateTime? _lastScrollTime;
|
||||
bool _showJumpToBottom = false;
|
||||
bool _hasNewMessagesWhileScrolledAway = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_handleScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_handleScroll);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleScroll() {
|
||||
_lastScrollTime = DateTime.now();
|
||||
_isUserScrolling = true;
|
||||
|
||||
if (_scrollController.hasClients) {
|
||||
final position = _scrollController.position;
|
||||
final isFarFromBottom = position.pixels < position.maxScrollExtent - 200;
|
||||
if (isFarFromBottom != _showJumpToBottom) {
|
||||
setState(() {
|
||||
_showJumpToBottom = isFarFromBottom;
|
||||
});
|
||||
}
|
||||
|
||||
if (!isFarFromBottom) {
|
||||
setState(() {
|
||||
_hasNewMessagesWhileScrolledAway = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 150), () {
|
||||
if (_lastScrollTime != null &&
|
||||
DateTime.now().difference(_lastScrollTime!) >= const Duration(milliseconds: 150)) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isUserScrolling = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bool _isNearBottom() {
|
||||
if (!_scrollController.hasClients) return false;
|
||||
final position = _scrollController.position;
|
||||
return position.pixels >= position.maxScrollExtent - 150;
|
||||
}
|
||||
|
||||
void _jumpToBottom() {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
setState(() {
|
||||
_showJumpToBottom = false;
|
||||
_hasNewMessagesWhileScrolledAway = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<ChatProvider>(
|
||||
builder: (context, chatProvider, _) {
|
||||
final currentMessages = chatProvider.messages;
|
||||
|
||||
bool messagesChanged = false;
|
||||
if (currentMessages.length != _previousMessageContents.length) {
|
||||
messagesChanged = true;
|
||||
} else {
|
||||
for (int i = 0; i < currentMessages.length; i++) {
|
||||
if (currentMessages[i].content != _previousMessageContents[i]) {
|
||||
messagesChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (messagesChanged && currentMessages.isNotEmpty) {
|
||||
final nearBottom = _isNearBottom();
|
||||
|
||||
if (nearBottom && !_isUserScrolling) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
_hasNewMessagesWhileScrolledAway = false;
|
||||
} else if (!nearBottom) {
|
||||
_hasNewMessagesWhileScrolledAway = true;
|
||||
}
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_previousMessageContents = currentMessages.map((m) => m.content).toList();
|
||||
});
|
||||
|
||||
final entries = _buildEntries(currentMessages);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
itemCount: entries.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
final pending = chatProvider.pendingPermission;
|
||||
|
||||
final isThisPending = pending != null &&
|
||||
index == entries.length - 1 &&
|
||||
entry is _ToolEntry &&
|
||||
entry.toolName == pending.toolName;
|
||||
|
||||
Widget bubble;
|
||||
if (entry is _MessageEntry) {
|
||||
final msg = entry.message;
|
||||
if (msg.role == "user") {
|
||||
bubble = UserBubble(content: msg.content);
|
||||
} else if (msg.role == "assistant") {
|
||||
bubble = AssistantBubble(content: msg.content);
|
||||
} else {
|
||||
bubble = Text(msg.content);
|
||||
}
|
||||
} else if (entry is _ToolEntry) {
|
||||
bubble = ToolBubble(
|
||||
toolName: entry.toolName,
|
||||
toolInput: entry.toolInput,
|
||||
result: entry.result,
|
||||
isPendingPermission: isThisPending,
|
||||
);
|
||||
} else {
|
||||
bubble = const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: bubble,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (_showJumpToBottom && _hasNewMessagesWhileScrolledAway)
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
child: GestureDetector(
|
||||
onTap: _jumpToBottom,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.background.withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF000000).withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.arrowDown,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.foreground,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
"New messages",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.foreground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// merge consecutive tool call + result messages into single entries
|
||||
List<_ChatEntry> _buildEntries(List<Message> messages) {
|
||||
final result = <_ChatEntry>[];
|
||||
int i = 0;
|
||||
while (i < messages.length) {
|
||||
final msg = messages[i];
|
||||
if (msg.role == "tool") {
|
||||
final firstLine = msg.content.split("\n").first.trim();
|
||||
|
||||
if (firstLine.endsWith(" call")) {
|
||||
final (toolName, toolInput) = ToolBubble.parseContent(msg.content);
|
||||
|
||||
// check if next message is the matching result
|
||||
String? toolResult;
|
||||
if (i + 1 < messages.length) {
|
||||
final next = messages[i + 1];
|
||||
final nextFirst = next.content.split("\n").first.trim();
|
||||
if (next.role == "tool" && nextFirst == "$toolName result") {
|
||||
final body = next.content.indexOf("\n");
|
||||
toolResult = body != -1 ? next.content.substring(body + 1).trim() : null;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
result.add(_ToolEntry(
|
||||
toolName: toolName,
|
||||
toolInput: toolInput,
|
||||
result: toolResult,
|
||||
));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// orphan result or unknown tool message — skip it
|
||||
// (already consumed as part of a call above, or genuinely standalone)
|
||||
final (toolName, _) = ToolBubble.parseContent(msg.content);
|
||||
result.add(_ToolEntry(toolName: toolName));
|
||||
i++;
|
||||
} else {
|
||||
result.add(_MessageEntry(msg));
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
sealed class _ChatEntry {}
|
||||
|
||||
class _MessageEntry extends _ChatEntry {
|
||||
_MessageEntry(this.message);
|
||||
final Message message;
|
||||
}
|
||||
|
||||
class _ToolEntry extends _ChatEntry {
|
||||
_ToolEntry({required this.toolName, this.toolInput, this.result});
|
||||
final String toolName;
|
||||
final Map<String, dynamic>? toolInput;
|
||||
final String? result;
|
||||
}
|
||||
|
||||
|
||||
class FullHeightScrollbar extends StatefulWidget {
|
||||
final ScrollController controller;
|
||||
|
||||
const FullHeightScrollbar({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
State<FullHeightScrollbar> createState() => _FullHeightScrollbarState();
|
||||
}
|
||||
|
||||
class _FullHeightScrollbarState extends State<FullHeightScrollbar> {
|
||||
bool _hovering = false;
|
||||
bool _scrolling = false;
|
||||
DateTime _lastScroll = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_onScroll);
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
_lastScroll = DateTime.now();
|
||||
setState(() => _scrolling = true);
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 800), () {
|
||||
if (!mounted) return;
|
||||
if (DateTime.now().difference(_lastScroll).inMilliseconds >= 800) {
|
||||
setState(() => _scrolling = false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onScroll);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final visible = _hovering || _scrolling;
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovering = true),
|
||||
onExit: (_) => setState(() => _hovering = false),
|
||||
child: AnimatedOpacity(
|
||||
opacity: visible ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final totalHeight = constraints.maxHeight;
|
||||
|
||||
if (!widget.controller.hasClients) return const SizedBox.shrink();
|
||||
|
||||
final pos = widget.controller.position;
|
||||
final maxScroll = pos.maxScrollExtent;
|
||||
|
||||
if (maxScroll <= 0) return const SizedBox.shrink();
|
||||
|
||||
final viewportFraction = pos.viewportDimension / (pos.viewportDimension + maxScroll);
|
||||
final thumbHeight = (viewportFraction * totalHeight).clamp(32.0, totalHeight);
|
||||
final scrollFraction = pos.pixels / maxScroll;
|
||||
final thumbTop = scrollFraction * (totalHeight - thumbHeight);
|
||||
|
||||
final color = Theme.of(context).colorScheme.mutedForeground.withValues(alpha: 0.4);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: thumbTop,
|
||||
left: 2,
|
||||
right: 2,
|
||||
height: thumbHeight,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import "package:diff_match_patch/diff_match_patch.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
const _contextLines = 3;
|
||||
|
||||
class DiffView extends StatelessWidget {
|
||||
const DiffView({
|
||||
super.key,
|
||||
this.oldString,
|
||||
this.newString,
|
||||
this.content,
|
||||
}) : assert(
|
||||
content != null || (oldString != null && newString != null),
|
||||
"Provide either content (view-only) or oldString+newString (diff)",
|
||||
);
|
||||
|
||||
final String? oldString;
|
||||
final String? newString;
|
||||
|
||||
// view-only mode — show content as plain code, no diff colors
|
||||
final String? content;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (content != null) {
|
||||
final lines = content!.split("\n");
|
||||
final viewLines = [
|
||||
for (int i = 0; i < lines.length; i++)
|
||||
_DiffLine(_LineKind.context, lines[i], newLine: i + 1),
|
||||
];
|
||||
final hunk = _Hunk(oldStart: 1, newStart: 1, lines: viewLines);
|
||||
return _HunkView(hunk: hunk);
|
||||
}
|
||||
|
||||
final hunks = _computeHunks(oldString!, newString!);
|
||||
|
||||
if (hunks.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final hunk in hunks) ...[
|
||||
|
||||
// is first
|
||||
if (hunk != hunks.first) ...[
|
||||
Divider(),
|
||||
Gap(1),
|
||||
Divider(),
|
||||
],
|
||||
|
||||
_HunkView(hunk: hunk)
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── data model ───────────────────────────────────────────────────────────────
|
||||
|
||||
enum _LineKind { context, added, removed }
|
||||
|
||||
class _DiffLine {
|
||||
const _DiffLine(this.kind, this.text, {this.oldLine, this.newLine});
|
||||
final _LineKind kind;
|
||||
final String text;
|
||||
final int? oldLine;
|
||||
final int? newLine;
|
||||
}
|
||||
|
||||
class _Hunk {
|
||||
_Hunk({
|
||||
required this.oldStart,
|
||||
required this.newStart,
|
||||
required this.lines,
|
||||
});
|
||||
final int oldStart;
|
||||
final int newStart;
|
||||
final List<_DiffLine> lines;
|
||||
|
||||
int get oldCount => lines.where((l) => l.kind != _LineKind.added).length;
|
||||
int get newCount => lines.where((l) => l.kind != _LineKind.removed).length;
|
||||
}
|
||||
|
||||
// ─── diff computation ─────────────────────────────────────────────────────────
|
||||
|
||||
List<_Hunk> _computeHunks(String oldStr, String newStr) {
|
||||
final dmp = DiffMatchPatch();
|
||||
|
||||
final oldLines = oldStr.split("\n");
|
||||
final newLines = newStr.split("\n");
|
||||
|
||||
// encode lines → single chars so dmp does line-level diff
|
||||
final enc = _encodeLines(oldLines, newLines);
|
||||
final diffs = dmp.diff(enc.oldEncoded, enc.newEncoded, false);
|
||||
dmp.diffCleanupSemantic(diffs);
|
||||
|
||||
// expand diffs back to line sequences
|
||||
final rawLines = <_DiffLine>[];
|
||||
int oldIdx = 0;
|
||||
int newIdx = 0;
|
||||
|
||||
for (final d in diffs) {
|
||||
final count = d.text.length; // each char == one line
|
||||
switch (d.operation) {
|
||||
case DIFF_EQUAL:
|
||||
for (int i = 0; i < count; i++) {
|
||||
rawLines.add(_DiffLine(
|
||||
_LineKind.context,
|
||||
enc.lines[d.text.codeUnitAt(i) - 0xE000],
|
||||
oldLine: oldIdx + 1,
|
||||
newLine: newIdx + 1,
|
||||
));
|
||||
oldIdx++;
|
||||
newIdx++;
|
||||
}
|
||||
break;
|
||||
|
||||
case DIFF_DELETE:
|
||||
for (int i = 0; i < count; i++) {
|
||||
rawLines.add(_DiffLine(
|
||||
_LineKind.removed,
|
||||
enc.lines[d.text.codeUnitAt(i) - 0xE000],
|
||||
oldLine: oldIdx + 1,
|
||||
));
|
||||
oldIdx++;
|
||||
}
|
||||
break;
|
||||
|
||||
case DIFF_INSERT:
|
||||
for (int i = 0; i < count; i++) {
|
||||
rawLines.add(_DiffLine(
|
||||
_LineKind.added,
|
||||
enc.lines[d.text.codeUnitAt(i) - 0xE000],
|
||||
newLine: newIdx + 1,
|
||||
));
|
||||
newIdx++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return _groupIntoHunks(rawLines);
|
||||
}
|
||||
|
||||
// keep only context lines that are within _contextLines of a change
|
||||
List<_Hunk> _groupIntoHunks(List<_DiffLine> rawLines) {
|
||||
final n = rawLines.length;
|
||||
|
||||
// mark which context lines to keep
|
||||
final keep = List<bool>.filled(n, false);
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (rawLines[i].kind != _LineKind.context) {
|
||||
for (int j = (i - _contextLines).clamp(0, n - 1);
|
||||
j <= (i + _contextLines).clamp(0, n - 1);
|
||||
j++) {
|
||||
keep[j] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final hunks = <_Hunk>[];
|
||||
int i = 0;
|
||||
|
||||
while (i < n) {
|
||||
if (!keep[i]) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// start of a new hunk
|
||||
final hunkLines = <_DiffLine>[];
|
||||
int oldStart = rawLines[i].oldLine ?? 1;
|
||||
int newStart = rawLines[i].newLine ?? 1;
|
||||
|
||||
while (i < n && keep[i]) {
|
||||
hunkLines.add(rawLines[i]);
|
||||
i++;
|
||||
}
|
||||
|
||||
hunks.add(_Hunk(
|
||||
oldStart: oldStart,
|
||||
newStart: newStart,
|
||||
lines: hunkLines,
|
||||
));
|
||||
}
|
||||
|
||||
return hunks;
|
||||
}
|
||||
|
||||
// line encoding — maps unique lines to single unicode chars starting at U+E000
|
||||
class _LineEncoding {
|
||||
final List<String> lines; // index → line text
|
||||
final String oldEncoded;
|
||||
final String newEncoded;
|
||||
const _LineEncoding(this.lines, this.oldEncoded, this.newEncoded);
|
||||
}
|
||||
|
||||
_LineEncoding _encodeLines(List<String> oldLines, List<String> newLines) {
|
||||
final lineIndex = <String, int>{};
|
||||
final lines = <String>[];
|
||||
|
||||
String encode(List<String> src) {
|
||||
final buf = StringBuffer();
|
||||
for (final line in src) {
|
||||
if (!lineIndex.containsKey(line)) {
|
||||
lineIndex[line] = lines.length;
|
||||
lines.add(line);
|
||||
}
|
||||
buf.writeCharCode(0xE000 + lineIndex[line]!);
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
final oldEncoded = encode(oldLines);
|
||||
final newEncoded = encode(newLines);
|
||||
return _LineEncoding(lines, oldEncoded, newEncoded);
|
||||
}
|
||||
|
||||
// ─── widgets ──────────────────────────────────────────────────────────────────
|
||||
|
||||
String _hunkSummary(_Hunk hunk) {
|
||||
final added = hunk.lines.where((l) => l.kind == _LineKind.added).length;
|
||||
final removed = hunk.lines.where((l) => l.kind == _LineKind.removed).length;
|
||||
|
||||
final parts = <String>[];
|
||||
if (added > 0) parts.add("Added $added ${added == 1 ? 'line' : 'lines'}");
|
||||
if (removed > 0) parts.add("removed $removed ${removed == 1 ? 'line' : 'lines'}");
|
||||
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
class _HunkView extends StatelessWidget {
|
||||
const _HunkView({required this.hunk});
|
||||
final _Hunk hunk;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
|
||||
// hunk header
|
||||
Container(
|
||||
// color: theme.colorScheme.muted.withValues(alpha: 0.4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Text(
|
||||
_hunkSummary(hunk),
|
||||
style: TextStyle(color: theme.colorScheme.mutedForeground),
|
||||
).xSmall.mono,
|
||||
),
|
||||
|
||||
Divider(),
|
||||
|
||||
for (final line in hunk.lines) _LineView(line: line),
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LineView extends StatelessWidget {
|
||||
const _LineView({required this.line});
|
||||
final _DiffLine line;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final Color bg;
|
||||
final Color fg;
|
||||
final String prefix;
|
||||
|
||||
switch (line.kind) {
|
||||
case _LineKind.added:
|
||||
bg = const Color(0xFF166534).withValues(alpha: 0.2);
|
||||
fg = const Color(0xFF4ADE80);
|
||||
prefix = "+";
|
||||
break;
|
||||
case _LineKind.removed:
|
||||
bg = const Color(0xFF991B1B).withValues(alpha: 0.2);
|
||||
fg = const Color(0xFFF87171);
|
||||
prefix = "-";
|
||||
break;
|
||||
case _LineKind.context:
|
||||
bg = Colors.transparent;
|
||||
fg = theme.colorScheme.mutedForeground;
|
||||
prefix = " ";
|
||||
break;
|
||||
}
|
||||
|
||||
final numColor = theme.colorScheme.mutedForeground.withValues(alpha: 0.5);
|
||||
final lineNum = line.kind == _LineKind.removed ? line.oldLine : line.newLine;
|
||||
|
||||
return Container(
|
||||
color: bg,
|
||||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
SizedBox(
|
||||
width: 32,
|
||||
child: Text(
|
||||
lineNum != null ? "$lineNum" : "",
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(color: numColor),
|
||||
).mono.xSmall,
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
SizedBox(
|
||||
width: 10,
|
||||
child: Text(prefix, style: TextStyle(color: fg)).mono.xSmall,
|
||||
),
|
||||
|
||||
const SizedBox(width: 4),
|
||||
|
||||
Expanded(
|
||||
child: Text(line.text, style: TextStyle(color: fg)).mono.xSmall,
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,21 @@ import "package:flutter/src/material/theme_data.dart";
|
||||
import "package:flutter_markdown/flutter_markdown.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
import "../../src/session/session_types.dart";
|
||||
import "../../../src/permissions/permission_types.dart";
|
||||
import "../../../src/session/session_types.dart";
|
||||
import "advisor_message.dart";
|
||||
import "../common/button.dart";
|
||||
|
||||
class MessageBubble extends StatelessWidget {
|
||||
const MessageBubble({required this.message});
|
||||
const MessageBubble({
|
||||
required this.message,
|
||||
this.isPendingPermission = false,
|
||||
this.onPermissionDecision,
|
||||
});
|
||||
|
||||
final Message message;
|
||||
final bool isPendingPermission;
|
||||
final void Function(PermissionDecision)? onPermissionDecision;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -19,23 +28,21 @@ class MessageBubble extends StatelessWidget {
|
||||
|
||||
|
||||
if (isUser) {
|
||||
return Row(
|
||||
children: [
|
||||
Spacer(),
|
||||
OutlinedContainer(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
backgroundColor: theme.colorScheme.border,
|
||||
child: MarkdownBody(
|
||||
data: message.content,
|
||||
selectable: true,
|
||||
shrinkWrap: true,
|
||||
styleSheet: _toolMarkdownStyleSheet(context),
|
||||
),
|
||||
return Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: OutlinedContainer(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
],
|
||||
backgroundColor: theme.colorScheme.border,
|
||||
child: MarkdownBody(
|
||||
data: message.content,
|
||||
selectable: true,
|
||||
shrinkWrap: true,
|
||||
styleSheet: _toolMarkdownStyleSheet(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (isAssistant) {
|
||||
return MarkdownBody(
|
||||
@@ -48,27 +55,66 @@ class MessageBubble extends StatelessWidget {
|
||||
|
||||
final lines = message.content.split("\n");
|
||||
final title = lines.first.trim();
|
||||
final isAdvisor = title.startsWith("Advisor");
|
||||
|
||||
return Row(
|
||||
if (isAdvisor) {
|
||||
final body = lines.skip(1).join("\n").trim();
|
||||
return AdvisorMessage(title: title, body: body);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
Container(
|
||||
height: 10,
|
||||
width: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
shape: BoxShape.circle
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
|
||||
OutlinedContainer(
|
||||
padding: const EdgeInsets.all(10),
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
child: Icon(LucideIcons.wrench).iconSmall,
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.typography.p.copyWith(fontSize: 13),
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
if (isPendingPermission) ...[
|
||||
Gap(8),
|
||||
Row(
|
||||
children: [
|
||||
|
||||
Text(
|
||||
title,
|
||||
style: theme.typography.p.copyWith(
|
||||
fontSize: 13
|
||||
AgcSecondaryButton(
|
||||
onPressed: () => onPermissionDecision?.call(PermissionDecision.allowOnce),
|
||||
child: Text("Allow").small,
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
AgcGhostButton(
|
||||
onPressed: () => onPermissionDecision?.call(PermissionDecision.allowAlways),
|
||||
child: Text("Allow always").small,
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
AgcGhostButton(
|
||||
onPressed: () => onPermissionDecision?.call(PermissionDecision.reject),
|
||||
child: Text("Reject").small,
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import "package:flutter/material.dart";
|
||||
import "package:provider/provider.dart";
|
||||
|
||||
import "../../src/api/openrouter_client.dart";
|
||||
import "../providers/settings_provider.dart";
|
||||
import "../../../src/api/openrouter_client.dart";
|
||||
import "../../providers/settings_provider.dart";
|
||||
|
||||
class ModelPicker extends StatefulWidget {
|
||||
const ModelPicker();
|
||||
@@ -0,0 +1,103 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../constants.dart";
|
||||
|
||||
class ModelPickerDialog extends StatefulWidget {
|
||||
|
||||
final List<SelectableAiModel> models;
|
||||
final String? selectedModel;
|
||||
|
||||
const ModelPickerDialog({
|
||||
super.key,
|
||||
required this.models,
|
||||
this.selectedModel,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ModelPickerDialog> createState() => _ModelPickerDialogState();
|
||||
}
|
||||
|
||||
class _ModelPickerDialogState extends State<ModelPickerDialog> {
|
||||
late TextEditingController _searchController;
|
||||
String _query = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchController = TextEditingController();
|
||||
_searchController.addListener(() {
|
||||
setState(() => _query = _searchController.text.trim().toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<SelectableAiModel> get _filtered {
|
||||
if (_query.isEmpty) return widget.models;
|
||||
return widget.models.where((m) =>
|
||||
m.label.toLowerCase().contains(_query) ||
|
||||
m.id.toLowerCase().contains(_query)
|
||||
).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final filtered = _filtered;
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('Select model'),
|
||||
content: SizedBox(
|
||||
width: 340,
|
||||
height: 380,
|
||||
child: Column(
|
||||
children: [
|
||||
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
autofocus: true,
|
||||
placeholder: const Text('Search models...'),
|
||||
features: const [InputFeature.clear()],
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
Expanded(
|
||||
child: filtered.isEmpty
|
||||
? Center(child: Text("No results").muted)
|
||||
: ListView.builder(
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (context, i) {
|
||||
final model = filtered[i];
|
||||
final isSelected = model.id == widget.selectedModel;
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button(
|
||||
style: isSelected ? ButtonStyle.secondary() : ButtonStyle.ghost(),
|
||||
disableFocusOutline: true,
|
||||
onPressed: () => Navigator.of(context).pop(model.id),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(model.label),
|
||||
Text(model.id).muted.small,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
import "../providers/chat_provider.dart";
|
||||
import "message_bubble.dart";
|
||||
|
||||
class ChatView extends StatefulWidget {
|
||||
const ChatView();
|
||||
|
||||
@override
|
||||
State<ChatView> createState() => _ChatViewState();
|
||||
}
|
||||
|
||||
class _ChatViewState extends State<ChatView> {
|
||||
late ScrollController _scrollController;
|
||||
List<String> _previousMessageContents = [];
|
||||
bool _isUserScrolling = false;
|
||||
DateTime? _lastScrollTime;
|
||||
bool _showJumpToBottom = false;
|
||||
bool _hasNewMessagesWhileScrolledAway = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController = ScrollController();
|
||||
_scrollController.addListener(_handleScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_handleScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleScroll() {
|
||||
_lastScrollTime = DateTime.now();
|
||||
_isUserScrolling = true;
|
||||
|
||||
// Update whether to show jump-to-bottom button
|
||||
if (_scrollController.hasClients) {
|
||||
final position = _scrollController.position;
|
||||
final isFarFromBottom = position.pixels < position.maxScrollExtent - 200;
|
||||
if (isFarFromBottom != _showJumpToBottom) {
|
||||
setState(() {
|
||||
_showJumpToBottom = isFarFromBottom;
|
||||
});
|
||||
}
|
||||
|
||||
// If user scrolls to bottom manually, clear the new messages flag
|
||||
if (!isFarFromBottom) {
|
||||
setState(() {
|
||||
_hasNewMessagesWhileScrolledAway = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if scrolling has stopped (no scroll events for 150ms)
|
||||
Future.delayed(const Duration(milliseconds: 150), () {
|
||||
if (_lastScrollTime != null &&
|
||||
DateTime.now().difference(_lastScrollTime!) >= const Duration(milliseconds: 150)) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isUserScrolling = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bool _isNearBottom() {
|
||||
if (!_scrollController.hasClients) return false;
|
||||
|
||||
final position = _scrollController.position;
|
||||
// Consider user to be "near bottom" if they're within 150 pixels of the bottom
|
||||
// Add a small buffer so we don't trigger on exact bottom
|
||||
return position.pixels >= position.maxScrollExtent - 150;
|
||||
}
|
||||
|
||||
void _jumpToBottom() {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
setState(() {
|
||||
_showJumpToBottom = false;
|
||||
_hasNewMessagesWhileScrolledAway = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<ChatProvider>(
|
||||
builder: (context, chatProvider, _) {
|
||||
// Get current messages
|
||||
final currentMessages = chatProvider.messages;
|
||||
|
||||
// Check if messages have actually changed (not just re-renders)
|
||||
bool messagesChanged = false;
|
||||
|
||||
if (currentMessages.length != _previousMessageContents.length) {
|
||||
messagesChanged = true;
|
||||
} else {
|
||||
for (int i = 0; i < currentMessages.length; i++) {
|
||||
if (i >= _previousMessageContents.length ||
|
||||
currentMessages[i].content != _previousMessageContents[i]) {
|
||||
messagesChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (messagesChanged && currentMessages.isNotEmpty) {
|
||||
// Check if we're near the bottom
|
||||
final nearBottom = _isNearBottom();
|
||||
|
||||
if (nearBottom && !_isUserScrolling) {
|
||||
// Auto-scroll to bottom if user is near bottom and not scrolling
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
_hasNewMessagesWhileScrolledAway = false;
|
||||
} else if (!nearBottom) {
|
||||
// User is scrolled away from bottom when new messages arrive
|
||||
_hasNewMessagesWhileScrolledAway = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Update previous message state for next build
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_previousMessageContents = currentMessages.map((m) => m.content).toList();
|
||||
});
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
ListView.builder(
|
||||
controller: _scrollController,
|
||||
itemCount: currentMessages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = currentMessages[index];
|
||||
return Padding(
|
||||
padding: EdgeInsetsGeometry.only(
|
||||
top: index != 0 ? 12 : 0
|
||||
),
|
||||
child: MessageBubble(message: message)
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_showJumpToBottom && _hasNewMessagesWhileScrolledAway)
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
child: GestureDetector(
|
||||
onTap: _jumpToBottom,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.background.withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF000000).withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.arrowDown,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.foreground,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
"New messages",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.foreground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
class AgcGhostButton extends StatefulWidget {
|
||||
|
||||
final Widget child;
|
||||
final VoidCallback? onPressed;
|
||||
final BorderRadius? borderRadius;
|
||||
|
||||
AgcGhostButton({
|
||||
required this.child,
|
||||
this.onPressed,
|
||||
this.borderRadius,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AgcGhostButton> createState() => _GhostButtonState();
|
||||
}
|
||||
|
||||
class _GhostButtonState extends State<AgcGhostButton> {
|
||||
|
||||
bool _hovering = false;
|
||||
bool _pressing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final radius = widget.borderRadius ?? BorderRadius.circular(
|
||||
Theme.of(context).radiusSm - 4
|
||||
);
|
||||
|
||||
Color bg = Colors.transparent;
|
||||
if (_pressing) {
|
||||
bg = colorScheme.accent.withOpacity(0.8);
|
||||
} else if (_hovering) {
|
||||
bg = colorScheme.accent.withOpacity(0.5);
|
||||
}
|
||||
|
||||
return MouseRegion(
|
||||
cursor: widget.onPressed != null ? SystemMouseCursors.click : MouseCursor.defer,
|
||||
onEnter: (_) => setState(() => _hovering = true),
|
||||
onExit: (_) => setState(() {
|
||||
_hovering = false;
|
||||
_pressing = false;
|
||||
}),
|
||||
|
||||
child: GestureDetector(
|
||||
onTapDown: (_) => setState(() => _pressing = true),
|
||||
onTapUp: (_) {
|
||||
setState(() => _pressing = false);
|
||||
if (widget.onPressed != null) widget.onPressed!();
|
||||
},
|
||||
onTapCancel: () => setState(() => _pressing = false),
|
||||
|
||||
child: AnimatedContainer(
|
||||
duration: Duration(milliseconds: 80),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: radius,
|
||||
),
|
||||
padding: EdgeInsets.all(4),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AgcSecondaryButton extends StatefulWidget {
|
||||
|
||||
final Widget child;
|
||||
final VoidCallback? onPressed;
|
||||
final BorderRadius? borderRadius;
|
||||
final bool enabled;
|
||||
|
||||
AgcSecondaryButton({
|
||||
required this.child,
|
||||
this.onPressed,
|
||||
this.borderRadius,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AgcSecondaryButton> createState() => _SecondaryButtonState();
|
||||
}
|
||||
|
||||
class _SecondaryButtonState extends State<AgcSecondaryButton> {
|
||||
|
||||
bool _hovering = false;
|
||||
bool _pressing = false;
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final radius = widget.borderRadius ?? BorderRadius.circular(
|
||||
Theme.of(context).radiusSm
|
||||
);
|
||||
|
||||
final bool active = widget.enabled && widget.onPressed != null;
|
||||
|
||||
Color bg = colorScheme.secondary;
|
||||
if (!active) {
|
||||
bg = colorScheme.secondary.withOpacity(0.4);
|
||||
} else if (_pressing) {
|
||||
bg = colorScheme.secondary.withOpacity(0.75);
|
||||
} else if (_hovering) {
|
||||
bg = colorScheme.secondary.withOpacity(0.85);
|
||||
}
|
||||
|
||||
return MouseRegion(
|
||||
cursor: active ? SystemMouseCursors.click : MouseCursor.defer,
|
||||
onEnter: (_) { if (active) setState(() => _hovering = true); },
|
||||
onExit: (_) => setState(() {
|
||||
_hovering = false;
|
||||
_pressing = false;
|
||||
}),
|
||||
|
||||
child: GestureDetector(
|
||||
onTapDown: active ? (_) => setState(() => _pressing = true) : null,
|
||||
onTapUp: active ? (_) {
|
||||
setState(() => _pressing = false);
|
||||
widget.onPressed!();
|
||||
} : null,
|
||||
onTapCancel: active ? () => setState(() => _pressing = false) : null,
|
||||
|
||||
child: AnimatedContainer(
|
||||
duration: Duration(milliseconds: 80),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: radius,
|
||||
),
|
||||
padding: EdgeInsets.all(4),
|
||||
child: DefaultTextStyle.merge(
|
||||
style: TextStyle(color: colorScheme.secondaryForeground),
|
||||
child: IconTheme.merge(
|
||||
data: IconThemeData(color: colorScheme.secondaryForeground),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AgcOutlinedButton extends StatefulWidget {
|
||||
|
||||
final Widget child;
|
||||
final VoidCallback? onPressed;
|
||||
final BorderRadius? borderRadius;
|
||||
final bool enabled;
|
||||
|
||||
AgcOutlinedButton({
|
||||
required this.child,
|
||||
this.onPressed,
|
||||
this.borderRadius,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AgcOutlinedButton> createState() => _OutlinedButtonState();
|
||||
}
|
||||
|
||||
class _OutlinedButtonState extends State<AgcOutlinedButton> {
|
||||
|
||||
bool _hovering = false;
|
||||
bool _pressing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final radius = widget.borderRadius ?? BorderRadius.circular(
|
||||
Theme.of(context).radiusSm
|
||||
);
|
||||
|
||||
final bool active = widget.enabled && widget.onPressed != null;
|
||||
|
||||
Color bg = Colors.transparent;
|
||||
if (_pressing && active) {
|
||||
bg = colorScheme.accent.withOpacity(0.6);
|
||||
} else if (_hovering && active) {
|
||||
bg = colorScheme.accent.withOpacity(0.35);
|
||||
}
|
||||
|
||||
final borderColor = active
|
||||
? colorScheme.border
|
||||
: colorScheme.border.withOpacity(0.4);
|
||||
|
||||
return MouseRegion(
|
||||
cursor: active ? SystemMouseCursors.click : MouseCursor.defer,
|
||||
onEnter: (_) { if (active) setState(() => _hovering = true); },
|
||||
onExit: (_) => setState(() {
|
||||
_hovering = false;
|
||||
_pressing = false;
|
||||
}),
|
||||
|
||||
child: GestureDetector(
|
||||
onTapDown: active ? (_) => setState(() => _pressing = true) : null,
|
||||
onTapUp: active ? (_) {
|
||||
setState(() => _pressing = false);
|
||||
widget.onPressed!();
|
||||
} : null,
|
||||
onTapCancel: active ? () => setState(() => _pressing = false) : null,
|
||||
|
||||
child: AnimatedContainer(
|
||||
duration: Duration(milliseconds: 80),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: radius,
|
||||
border: Border.all(color: borderColor),
|
||||
),
|
||||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: DefaultTextStyle.merge(
|
||||
style: TextStyle(
|
||||
color: active ? colorScheme.foreground : colorScheme.mutedForeground,
|
||||
),
|
||||
child: IconTheme.merge(
|
||||
data: IconThemeData(
|
||||
color: active ? colorScheme.foreground : colorScheme.mutedForeground,
|
||||
),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import "package:flutter/widgets.dart" hide Tooltip;
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart" hide Row, Expanded;
|
||||
|
||||
import "../../providers/chat_provider.dart";
|
||||
import "../../providers/cost_provider.dart";
|
||||
import "../../providers/settings_provider.dart";
|
||||
import "package:provider/provider.dart";
|
||||
|
||||
|
||||
|
||||
String _fmtTokens(int n) {
|
||||
final s = n.toString();
|
||||
final buf = StringBuffer();
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
if (i > 0 && (s.length - i) % 3 == 0) buf.write(",");
|
||||
buf.write(s[i]);
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
|
||||
class FooterBar extends StatelessWidget {
|
||||
const FooterBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final mutedFg = theme.colorScheme.mutedForeground;
|
||||
final borderColor = theme.colorScheme.border;
|
||||
final bg = theme.colorScheme.muted.scaleAlpha(0.3);
|
||||
|
||||
final costProvider = context.watch<CostProvider>();
|
||||
final settingsProvider = context.watch<SettingsProvider>();
|
||||
final chatProvider = context.watch<ChatProvider>();
|
||||
|
||||
final model = settingsProvider.settings.model ?? "unknown";
|
||||
final costUsd = costProvider.getTotalCostUsd();
|
||||
final cost = "\$${costUsd.toStringAsFixed(4)}";
|
||||
final inputToks = costProvider.getTotalInputTokens();
|
||||
final outputToks = costProvider.getTotalOutputTokens();
|
||||
final isLoading = chatProvider.isLoading;
|
||||
final contextTokens = chatProvider.contextTokens;
|
||||
|
||||
final textStyle = TextStyle(
|
||||
fontFamily: "monospace",
|
||||
fontSize: 11,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: mutedFg,
|
||||
);
|
||||
|
||||
Widget divider() => const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 5),
|
||||
child: SizedBox(height: 12, child: VerticalDivider(width: 1)),
|
||||
);
|
||||
|
||||
Widget copyrightBlock() {
|
||||
return Text(
|
||||
"© 2026 IMBENJI.NET LTD - The Agency",
|
||||
style: textStyle,
|
||||
);
|
||||
}
|
||||
|
||||
Widget statusBlock() {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
||||
Text(
|
||||
isLoading ? "running..." : "idle",
|
||||
style: textStyle.copyWith(
|
||||
color: isLoading
|
||||
? theme.colorScheme.primary
|
||||
: mutedFg,
|
||||
),
|
||||
),
|
||||
|
||||
divider(),
|
||||
|
||||
Text(model.split("/").last, style: textStyle),
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget statsBlock() {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
||||
if (contextTokens > 0) ...[
|
||||
Text(_fmtTokens(contextTokens), style: textStyle),
|
||||
Text(" tokens", style: textStyle),
|
||||
divider(),
|
||||
],
|
||||
|
||||
Tooltip(
|
||||
tooltip: (_) => TooltipContainer(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
child: Text(
|
||||
"In: $inputToks\nOut: $outputToks",
|
||||
style: const TextStyle(
|
||||
fontFamily: "monospace",
|
||||
fontSize: 11,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(cost, style: textStyle),
|
||||
),
|
||||
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
border: Border(top: BorderSide(color: borderColor, width: 1)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
Expanded(child: Row(children: [copyrightBlock()])),
|
||||
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [statusBlock()],
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [statsBlock()],
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
import "../providers/settings_provider.dart";
|
||||
import "model_picker.dart";
|
||||
import "../../providers/settings_provider.dart";
|
||||
import "../chat/model_picker.dart";
|
||||
|
||||
class SettingsSheet extends StatelessWidget {
|
||||
const SettingsSheet();
|
||||
@@ -0,0 +1,32 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
class AppLogo extends StatelessWidget {
|
||||
const AppLogo({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
"THE AGENCY",
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"by IMBENJI.NET LTD",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import '../../utils/format_relative_time.dart';
|
||||
|
||||
class ProjectButton extends StatefulWidget {
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final DateTime? lastMessage;
|
||||
final bool collapsed;
|
||||
|
||||
ProjectButton({
|
||||
required this.label,
|
||||
this.onPressed,
|
||||
this.lastMessage,
|
||||
this.collapsed = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ProjectButton> createState() => ProjectButtonState();
|
||||
}
|
||||
|
||||
class ProjectButtonState extends State<ProjectButton> with TickerProviderStateMixin {
|
||||
|
||||
bool _isHovering = false;
|
||||
late AnimationController _chevronController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_chevronController = AnimationController(
|
||||
duration: Duration(milliseconds: 100),
|
||||
vsync: this,
|
||||
);
|
||||
if (!widget.collapsed) {
|
||||
_chevronController.forward();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ProjectButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.collapsed != widget.collapsed) {
|
||||
if (widget.collapsed) {
|
||||
_chevronController.reverse();
|
||||
} else {
|
||||
_chevronController.forward();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_chevronController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
ColorScheme colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button(
|
||||
style: ButtonStyle.ghost().copyWith(
|
||||
padding: (context, state, edgeInsets) {
|
||||
return EdgeInsets.only(
|
||||
top: 8,
|
||||
left: 12,
|
||||
bottom: 8,
|
||||
right: 12
|
||||
);
|
||||
}
|
||||
),
|
||||
disableFocusOutline: true,
|
||||
onPressed: () {
|
||||
if (widget.onPressed != null) {
|
||||
widget.onPressed!();
|
||||
}
|
||||
},
|
||||
onHover: (isHovering) {
|
||||
setState(() {
|
||||
_isHovering = isHovering;
|
||||
});
|
||||
},
|
||||
leading: !_isHovering ? Icon(
|
||||
!widget.collapsed ? LucideIcons.folderOpen : LucideIcons.folderClosed
|
||||
).iconSmall : RotationTransition(
|
||||
turns: Tween(begin: 0.0, end: 0.25).animate(_chevronController),
|
||||
child: Icon(
|
||||
LucideIcons.chevronRight,
|
||||
color: colorScheme.mutedForeground,
|
||||
).iconSmall,
|
||||
),
|
||||
trailingGap: 32,
|
||||
trailing: widget.lastMessage != null ?
|
||||
Text(
|
||||
formatRelativeTime(widget.lastMessage!)
|
||||
).muted : null,
|
||||
child: Text(
|
||||
widget.label,
|
||||
style: TextStyle(
|
||||
color: colorScheme.mutedForeground
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).small,
|
||||
),
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import 'project_button.dart';
|
||||
|
||||
class ProjectSection extends StatefulWidget {
|
||||
|
||||
final String projectLabel;
|
||||
final List<Widget> children;
|
||||
final VoidCallback? onHeaderPressed;
|
||||
|
||||
ProjectSection({
|
||||
required this.projectLabel,
|
||||
this.children = const [],
|
||||
this.onHeaderPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ProjectSection> createState() => ProjectSectionState();
|
||||
}
|
||||
|
||||
class ProjectSectionState extends State<ProjectSection> with TickerProviderStateMixin {
|
||||
|
||||
bool _isCollapsed = true;
|
||||
late AnimationController _sizeController;
|
||||
late AnimationController _fadeController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_sizeController = AnimationController(
|
||||
duration: Duration(milliseconds: 150),
|
||||
vsync: this,
|
||||
);
|
||||
_fadeController = AnimationController(
|
||||
duration: Duration(milliseconds: 250),
|
||||
vsync: this,
|
||||
);
|
||||
if (!_isCollapsed) {
|
||||
_sizeController.forward();
|
||||
_fadeController.forward();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sizeController.dispose();
|
||||
_fadeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
|
||||
ProjectButton(
|
||||
label: widget.projectLabel,
|
||||
collapsed: _isCollapsed,
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isCollapsed = !_isCollapsed;
|
||||
if (_isCollapsed) {
|
||||
_fadeController.reverse();
|
||||
_sizeController.reverse();
|
||||
} else {
|
||||
_fadeController.forward();
|
||||
_sizeController.forward();
|
||||
}
|
||||
});
|
||||
widget.onHeaderPressed?.call();
|
||||
},
|
||||
),
|
||||
|
||||
Gap(2),
|
||||
|
||||
ClipRect(
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: FadeTransition(
|
||||
opacity: _fadeController,
|
||||
child: SizeTransition(
|
||||
sizeFactor: _sizeController,
|
||||
child: Column(
|
||||
spacing: 2,
|
||||
children: [
|
||||
|
||||
...widget.children
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
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 'app_logo.dart';
|
||||
import 'project_section.dart';
|
||||
import 'thread_button.dart';
|
||||
|
||||
class Sidebar extends StatelessWidget {
|
||||
|
||||
const Sidebar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final projectsProvider = context.watch<ProjectsProvider>();
|
||||
final sessionProvider = context.watch<SessionProvider>();
|
||||
final chatProvider = context.watch<ChatProvider>();
|
||||
final coordinator = context.read<HomeCoordinator>();
|
||||
|
||||
return Container(
|
||||
width: 300,
|
||||
color: Theme.of(context).colorScheme.input.scaleAlpha(0.3),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
Container(
|
||||
height: 100,
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AppLogo(),
|
||||
),
|
||||
|
||||
Divider(),
|
||||
|
||||
Gap(16),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: _fixedSection(context, coordinator, chatProvider),
|
||||
),
|
||||
|
||||
Gap(16),
|
||||
|
||||
Divider(),
|
||||
|
||||
Gap(16),
|
||||
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: _projectsSection(context, projectsProvider, sessionProvider, coordinator),
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _fixedSection(BuildContext context, HomeCoordinator coordinator, ChatProvider chatProvider) {
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button.ghost(
|
||||
style: ButtonStyle.ghost().copyWith(
|
||||
padding: (context, state, edgeInsets) {
|
||||
return EdgeInsets.only(
|
||||
top: 8,
|
||||
left: 8,
|
||||
bottom: 8,
|
||||
right: 10
|
||||
);
|
||||
}
|
||||
),
|
||||
onPressed: chatProvider.isLoading ? null : coordinator.createNewChat,
|
||||
disableFocusOutline: true,
|
||||
leading: Icon(LucideIcons.squarePen).iconSmall,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text("New Chat"),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button.ghost(
|
||||
style: ButtonStyle.ghost().copyWith(
|
||||
padding: (context, state, edgeInsets) {
|
||||
return EdgeInsets.only(
|
||||
top: 8,
|
||||
left: 8,
|
||||
bottom: 8,
|
||||
right: 10
|
||||
);
|
||||
}
|
||||
),
|
||||
onPressed: coordinator.pickProjectDirectory,
|
||||
disableFocusOutline: true,
|
||||
leading: Icon(LucideIcons.folderPlus).iconSmall,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text("New Project"),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _projectsSection(BuildContext context, ProjectsProvider projectsProvider, SessionProvider sessionProvider, HomeCoordinator coordinator) {
|
||||
if (projectsProvider.projects.isEmpty) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Text("No projects yet").textSmall.muted,
|
||||
);
|
||||
}
|
||||
|
||||
// group sessions by working directory
|
||||
final sessionsByProject = <String, List<SessionSummary>>{};
|
||||
for (final session in sessionProvider.sessions) {
|
||||
final dir = session.workingDirectory ?? '';
|
||||
sessionsByProject.putIfAbsent(dir, () => []).add(session);
|
||||
}
|
||||
|
||||
// sort sessions within each project newest first
|
||||
final sorted = <String, List<SessionSummary>>{};
|
||||
sessionsByProject.forEach((dir, sessions) {
|
||||
sorted[dir] = List<SessionSummary>.from(sessions)
|
||||
..sort((a, b) => b.updated.compareTo(a.updated));
|
||||
});
|
||||
|
||||
return ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text("Projects").textMuted,
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
for (final project in projectsProvider.projects) ...[
|
||||
|
||||
ProjectSection(
|
||||
projectLabel: project.name,
|
||||
children: [
|
||||
if (sorted[project.workingDirectory]?.isEmpty ?? true)
|
||||
ThreadButton(
|
||||
label: "No threads yet",
|
||||
muted: true,
|
||||
)
|
||||
else
|
||||
for (final session in sorted[project.workingDirectory]!)
|
||||
ThreadButton(
|
||||
label: session.name,
|
||||
lastMessage: session.updated,
|
||||
selected: sessionProvider.currentSessionId == session.id,
|
||||
onPressed: () => coordinator.openSession(session),
|
||||
onDelete: () => coordinator.deleteSession(session),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Gap(2),
|
||||
|
||||
],
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import '../../utils/format_relative_time.dart';
|
||||
|
||||
class ThreadButton extends StatelessWidget {
|
||||
|
||||
final String label;
|
||||
final IconData? icon;
|
||||
final VoidCallback? onPressed;
|
||||
final VoidCallback? onDelete;
|
||||
final DateTime? lastMessage;
|
||||
final bool selected;
|
||||
final bool muted;
|
||||
|
||||
ThreadButton({
|
||||
required this.label,
|
||||
this.icon,
|
||||
this.onPressed,
|
||||
this.onDelete,
|
||||
this.lastMessage,
|
||||
this.selected = false,
|
||||
this.muted = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
ButtonStyle style = selected ? ButtonStyle.secondary() : ButtonStyle.ghost();
|
||||
|
||||
ColorScheme colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final button = SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button(
|
||||
style: style.copyWith(
|
||||
padding: (context, state, edgeInsets) {
|
||||
return EdgeInsets.only(
|
||||
top: 8,
|
||||
left: 12,
|
||||
bottom: 8,
|
||||
right: 12
|
||||
);
|
||||
}
|
||||
),
|
||||
disableFocusOutline: true,
|
||||
onPressed: onPressed ?? () {},
|
||||
enabled: onPressed != null,
|
||||
leading: Icon(
|
||||
icon,
|
||||
color: icon == null ? Colors.transparent : (muted ? colorScheme.mutedForeground : null),
|
||||
).iconSmall,
|
||||
|
||||
trailingGap: 32,
|
||||
trailing: lastMessage != null ?
|
||||
Text(
|
||||
formatRelativeTime(lastMessage!)
|
||||
).muted.small.light : null,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: (muted ? colorScheme.mutedForeground : null)
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).small.light,
|
||||
),
|
||||
);
|
||||
|
||||
if (onDelete == null) return button;
|
||||
|
||||
return ContextMenu(
|
||||
items: [
|
||||
MenuButton(
|
||||
onPressed: (_) => onDelete!(),
|
||||
child: const Text("Delete"),
|
||||
),
|
||||
],
|
||||
child: button,
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user