claude_session_viewer/lib/screens/home/home_screen.dart
Mathias Beaulieu-Duncan 5c693bf3d8 feat: Browse picks folder, Load picks single file, recursive .jsonl scan
- Browse button now opens a folder picker and recursively scans all
  subfolders for .jsonl files, displaying them in the session list
- New Load button for loading a single .jsonl file (old Browse behavior)
- Default path remains ~/.claude/projects on launch
- Signed, notarized, stapled DMG
2026-04-07 13:45:24 -04:00

957 lines
32 KiB
Dart

import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../providers/session_provider.dart';
import '../../theme/app_theme.dart';
// ─── Data models ─────────────────────────────────────────────
class _Project {
final String dirPath;
final String rawDirName;
final String displayName;
final String fullPath; // the actual filesystem path it maps to
final List<_SessionFile> sessions;
final DateTime lastModified;
_Project({
required this.dirPath,
required this.rawDirName,
required this.displayName,
required this.fullPath,
required this.sessions,
required this.lastModified,
});
int get sessionCount => sessions.length;
}
class _SessionFile {
final File file;
final FileStat stat;
final String sessionId;
_SessionFile({
required this.file,
required this.stat,
required this.sessionId,
});
}
// ─── Color helpers for project icons ─────────────────────────
const _projectColors = [
Color(0xFF3B82F6),
Color(0xFF10B981),
Color(0xFFA855F7),
Color(0xFFF59E0B),
Color(0xFFEF4444),
Color(0xFF06B6D4),
Color(0xFFF97316),
Color(0xFF8B5CF6),
Color(0xFFEC4899),
Color(0xFF14B8A6),
];
Color _colorForProject(String name) {
final hash = name.codeUnits.fold<int>(0, (prev, c) => prev + c);
return _projectColors[hash % _projectColors.length];
}
const _projectIcons = [
Icons.folder_outlined,
Icons.code,
Icons.terminal,
Icons.cloud_outlined,
Icons.devices_outlined,
Icons.science_outlined,
Icons.hub_outlined,
Icons.inventory_2_outlined,
Icons.web_outlined,
Icons.phone_iphone_outlined,
];
IconData _iconForProject(String name) {
final lower = name.toLowerCase();
if (lower.contains('flutter') || lower.contains('app')) return Icons.phone_iphone_outlined;
if (lower.contains('api') || lower.contains('backend')) return Icons.cloud_outlined;
if (lower.contains('docker') || lower.contains('helm') || lower.contains('kubernetes') || lower.contains('talos')) return Icons.dns_outlined;
if (lower.contains('web') || lower.contains('frontend')) return Icons.web_outlined;
if (lower.contains('agent') || lower.contains('claude') || lower.contains('auto')) return Icons.smart_toy_outlined;
if (lower.contains('dashboard') || lower.contains('cortex')) return Icons.dashboard_outlined;
if (lower.contains('doc')) return Icons.description_outlined;
final hash = name.codeUnits.fold<int>(0, (prev, c) => prev + c);
return _projectIcons[hash % _projectIcons.length];
}
// ─── Main widget ─────────────────────────────────────────────
class HomeScreen extends StatefulWidget {
final VoidCallback onSessionLoaded;
const HomeScreen({super.key, required this.onSessionLoaded});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
List<_Project> _projects = [];
bool _scanning = true;
String _searchQuery = '';
_Project? _selectedProject;
_Project? _customFolderProject;
@override
void initState() {
super.initState();
_scanProjects();
}
/// Returns the real user home directory, even inside App Sandbox.
/// In sandbox, HOME points to ~/Library/Containers/<bundleid>/Data.
String? _getRealHome() {
final home = Platform.environment['HOME'];
if (home == null) return null;
final match = RegExp(r'^(/Users/[^/]+)/Library/Containers/').firstMatch(home);
if (match != null) return match.group(1);
return home;
}
/// Converts the encoded dir name to a human-readable project name.
/// "-Users-mathias-Documents-workspaces-svrnty-talos-rpi5" → "svrnty / talos-rpi5"
/// "-Users-mathias" → "~ (home)"
String _parsePrettyName(String dirName) {
final home = _getRealHome() ?? '';
final username = home.split('/').last;
// Try to reconstruct the actual filesystem path
final reconstructed = _reconstructPath(dirName, home);
if (reconstructed != null) {
final segs = reconstructed.split('/').where((s) => s.isNotEmpty).toList();
// Remove common uninteresting segments
final skip = {'Users', username, 'Documents', 'workspaces', 'Workspaces',
'Applications', 'Contents', 'Resources', 'Volumes'};
final meaningful = segs.where((s) => !skip.contains(s)).toList();
if (meaningful.isEmpty) return '~ (home)';
if (meaningful.length >= 2) {
return '${meaningful[meaningful.length - 2]} / ${meaningful.last}';
}
return meaningful.last;
}
// Fallback: build dynamic prefixes from the detected username
final prefixes = [
'-Users-$username-Documents-workspaces-',
'-Users-$username-Documents-',
'-Users-$username-Workspaces-',
'-Users-$username-',
'-Volumes-Workspaces-',
'-Volumes-',
'-Applications-',
];
String path = dirName;
for (final p in prefixes) {
if (path.startsWith(p)) {
path = path.substring(p.length);
break;
}
}
if (path.isEmpty) return '~ (home)';
return path.replaceAll('--', ' / ').replaceAll('-', ' ');
}
/// Try to reconstruct the actual filesystem path from the encoded dir name.
String? _reconstructPath(String dirName, String home) {
// The encoding is: replace / with - and prepend -
// So /Users/mathias/foo becomes -Users-mathias-foo
// Problem: folder names with dashes are ambiguous.
// Strategy: try splitting greedily from left, checking if dirs exist.
if (!dirName.startsWith('-')) return null;
final candidate = dirName.replaceFirst('-', '/');
// Try the obvious: replace all - with /
final simple = candidate.replaceAll('-', '/');
if (Directory(simple).existsSync()) return simple;
// Otherwise, do a greedy left-to-right directory walk
final parts = dirName.substring(1).split('-');
String current = '';
final resolved = <String>[];
for (int i = 0; i < parts.length; i++) {
if (current.isEmpty) {
current = parts[i];
} else {
current = '$current-${parts[i]}';
}
final testPath = '/${resolved.join('/')}/$current';
if (Directory(testPath).existsSync() || File(testPath).existsSync()) {
resolved.add(current);
current = '';
}
}
if (current.isNotEmpty) resolved.add(current);
final result = '/${resolved.join('/')}';
if (Directory(result).existsSync()) return result;
return result; // return anyway as best guess
}
/// Locates the Claude Code data directory.
/// Claude Code always stores data at ~/.claude/ regardless of where
/// the binary is installed (npm global, homebrew, etc).
Future<Directory?> _findClaudeDataDir() async {
final home = _getRealHome();
debugPrint('[Claude] HOME env: ${Platform.environment['HOME']}');
debugPrint('[Claude] Real home resolved to: $home');
if (home == null) {
debugPrint('[Claude] ERROR: Could not determine home directory');
return null;
}
final claudeDir = Directory('$home/.claude');
final exists = await claudeDir.exists();
debugPrint('[Claude] Checking ${claudeDir.path} -> exists: $exists');
if (exists) return claudeDir;
return null;
}
Future<void> _scanProjects() async {
final claudeData = await _findClaudeDataDir();
if (claudeData == null) {
debugPrint('[Claude] No Claude Code data directory found');
setState(() => _scanning = false);
return;
}
final projectsDir = Directory('${claudeData.path}/projects');
final projExists = await projectsDir.exists();
debugPrint('[Claude] Projects dir: ${projectsDir.path} -> exists: $projExists');
if (!projExists) {
setState(() => _scanning = false);
return;
}
final home = _getRealHome() ?? '';
final projects = <_Project>[];
await for (final projectDir in projectsDir.list()) {
if (projectDir is! Directory) continue;
final dirName = projectDir.path.split('/').last;
final sessions = <_SessionFile>[];
// Scan .jsonl files in the project dir
try {
await for (final entity in projectDir.list()) {
if (entity is File && entity.path.endsWith('.jsonl')) {
try {
final stat = entity.statSync();
final fileName = entity.path.split('/').last;
sessions.add(_SessionFile(
file: entity,
stat: stat,
sessionId: fileName.replaceAll('.jsonl', ''),
));
} catch (_) {}
}
}
} catch (_) {}
if (sessions.isEmpty) continue;
sessions.sort((a, b) => b.stat.modified.compareTo(a.stat.modified));
final fullPath = _reconstructPath(dirName, home) ?? dirName;
projects.add(_Project(
dirPath: projectDir.path,
rawDirName: dirName,
displayName: _parsePrettyName(dirName),
fullPath: fullPath,
sessions: sessions,
lastModified: sessions.first.stat.modified,
));
}
projects.sort((a, b) => b.lastModified.compareTo(a.lastModified));
if (mounted) {
setState(() {
_projects = projects;
_scanning = false;
});
}
}
Future<void> _pickFolder() async {
final result = await FilePicker.platform.getDirectoryPath(
dialogTitle: 'Select a folder containing Claude session files',
);
if (result != null) {
await _scanFolder(result);
}
}
Future<void> _scanFolder(String folderPath) async {
setState(() {
_scanning = true;
_selectedProject = null;
});
final sessions = <_SessionFile>[];
// Recursively scan for .jsonl files
final dir = Directory(folderPath);
if (await dir.exists()) {
await _scanFolderRecursive(dir, sessions);
}
sessions.sort((a, b) => b.stat.modified.compareTo(a.stat.modified));
if (mounted) {
setState(() {
_customFolderProject = _Project(
dirPath: folderPath,
rawDirName: folderPath.split('/').last,
displayName: folderPath.split('/').last,
fullPath: folderPath,
sessions: sessions,
lastModified: sessions.isNotEmpty ? sessions.first.stat.modified : DateTime.now(),
);
_selectedProject = _customFolderProject;
_scanning = false;
});
}
}
Future<void> _scanFolderRecursive(Directory dir, List<_SessionFile> sessions) async {
try {
await for (final entity in dir.list()) {
if (entity is File && entity.path.endsWith('.jsonl')) {
try {
final stat = entity.statSync();
final fileName = entity.path.split('/').last;
sessions.add(_SessionFile(
file: entity,
stat: stat,
sessionId: fileName.replaceAll('.jsonl', ''),
));
} catch (_) {}
} else if (entity is Directory) {
await _scanFolderRecursive(entity, sessions);
}
}
} catch (_) {}
}
Future<void> _loadSingleFile() async {
final home = _getRealHome() ?? '';
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['jsonl'],
initialDirectory: '$home/.claude/projects',
);
if (result != null && result.files.single.path != null) {
await _loadFile(result.files.single.path!);
}
}
Future<void> _loadFile(String path) async {
final provider = context.read<SessionProvider>();
await provider.loadSession(path);
if (provider.error == null && mounted) {
widget.onSessionLoaded();
}
}
List<_Project> get _filteredProjects {
if (_searchQuery.isEmpty) return _projects;
final q = _searchQuery.toLowerCase();
return _projects.where((p) =>
p.displayName.toLowerCase().contains(q) ||
p.fullPath.toLowerCase().contains(q)).toList();
}
@override
Widget build(BuildContext context) {
final provider = context.watch<SessionProvider>();
return Scaffold(
backgroundColor: AppColors.background,
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header with breadcrumb
_buildHeader(provider),
const SizedBox(height: 16),
_buildSearchBar(),
const SizedBox(height: 16),
if (provider.isLoading)
const Padding(
padding: EdgeInsets.only(bottom: 12),
child: LinearProgressIndicator(
color: AppColors.assistant,
backgroundColor: AppColors.surfaceLight,
),
),
if (provider.error != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.errorBg,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.error.withAlpha(80)),
),
child: Text(
provider.error!,
style: const TextStyle(color: AppColors.error, fontSize: 13),
),
),
),
// Content
if (_scanning)
const Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(color: AppColors.assistant),
SizedBox(height: 12),
Text('Scanning projects...',
style: TextStyle(color: AppColors.textMuted, fontSize: 13)),
],
),
),
)
else if (_selectedProject != null)
Expanded(child: _buildSessionList(_selectedProject!))
else
Expanded(child: _buildProjectGrid()),
],
),
),
);
}
// ─── Header with breadcrumb ──────────────────────────────
Widget _buildHeader(SessionProvider provider) {
return Row(
children: [
const Icon(Icons.terminal, size: 24, color: AppColors.assistant),
const SizedBox(width: 10),
// Breadcrumb
InkWell(
onTap: () => setState(() => _selectedProject = null),
borderRadius: BorderRadius.circular(4),
child: Text(
'Projects',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _selectedProject != null
? AppColors.assistant
: AppColors.textPrimary,
decoration: _selectedProject != null
? TextDecoration.underline
: TextDecoration.none,
decorationColor: AppColors.assistant,
),
),
),
if (_selectedProject != null) ...[
const Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Icon(Icons.chevron_right, size: 18, color: AppColors.textMuted),
),
Flexible(
child: Text(
_selectedProject!.displayName,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
overflow: TextOverflow.ellipsis,
),
),
],
const Spacer(),
Text(
_selectedProject != null
? '${_selectedProject!.sessionCount} session${_selectedProject!.sessionCount == 1 ? '' : 's'}'
: '${_projects.length} projects',
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
),
const SizedBox(width: 12),
ElevatedButton.icon(
onPressed: _loadSingleFile,
icon: const Icon(Icons.insert_drive_file_outlined, size: 14),
label: const Text('Load'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.surfaceLight,
foregroundColor: AppColors.textPrimary,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
side: const BorderSide(color: AppColors.surfaceBorder),
),
textStyle: const TextStyle(fontSize: 12),
),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: _pickFolder,
icon: const Icon(Icons.folder_open, size: 14),
label: const Text('Browse'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.surfaceLight,
foregroundColor: AppColors.textPrimary,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
side: const BorderSide(color: AppColors.surfaceBorder),
),
textStyle: const TextStyle(fontSize: 12),
),
),
],
);
}
// ─── Search bar ──────────────────────────────────────────
Widget _buildSearchBar() {
return TextField(
onChanged: (v) => setState(() => _searchQuery = v),
style: const TextStyle(fontSize: 13, color: AppColors.textPrimary),
decoration: InputDecoration(
hintText: _selectedProject != null
? 'Search sessions...'
: 'Search projects...',
hintStyle: const TextStyle(fontSize: 13, color: AppColors.textMuted),
prefixIcon: const Icon(Icons.search, size: 18, color: AppColors.textMuted),
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
filled: true,
fillColor: AppColors.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.surfaceBorder),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.surfaceBorder),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.assistant),
),
),
);
}
// ─── Project grid ────────────────────────────────────────
Widget _buildProjectGrid() {
final filtered = _filteredProjects;
if (filtered.isEmpty) {
return const Center(
child: Text('No projects found', style: TextStyle(color: AppColors.textMuted)),
);
}
return LayoutBuilder(builder: (context, constraints) {
final crossAxisCount = constraints.maxWidth > 900
? 4
: constraints.maxWidth > 600
? 3
: 2;
return GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.6,
),
itemCount: filtered.length,
itemBuilder: (context, index) => _ProjectCard(
project: filtered[index],
onTap: () => setState(() {
_selectedProject = filtered[index];
_searchQuery = '';
}),
),
);
});
}
// ─── Session list (inside a project) ─────────────────────
Widget _buildSessionList(_Project project) {
final sessions = _searchQuery.isEmpty
? project.sessions
: project.sessions.where((s) =>
s.sessionId.toLowerCase().contains(_searchQuery.toLowerCase())).toList();
if (sessions.isEmpty) {
return const Center(
child: Text('No matching sessions', style: TextStyle(color: AppColors.textMuted)),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Project info bar
Container(
padding: const EdgeInsets.all(14),
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.surfaceBorder),
),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: _colorForProject(project.displayName).withAlpha(25),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
_iconForProject(project.displayName),
size: 18,
color: _colorForProject(project.displayName),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
project.displayName,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
Text(
project.fullPath,
style: const TextStyle(
fontSize: 11,
color: AppColors.textMuted,
fontFamily: 'JetBrains Mono',
),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
// Session list
Expanded(
child: ListView.builder(
itemCount: sessions.length,
itemBuilder: (context, index) {
final session = sessions[index];
return _SessionTile(
session: session,
index: index,
isActive: context.read<SessionProvider>().filePath == session.file.path,
onTap: () => _loadFile(session.file.path),
);
},
),
),
],
);
}
// ─── Formatting helpers ──────────────────────────────────
static String formatDate(DateTime date) {
final now = DateTime.now();
final diff = now.difference(date);
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
if (diff.inDays < 7) return '${diff.inDays}d ago';
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return '${months[date.month - 1]} ${date.day}';
}
static String formatDateTime(DateTime date) {
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return '${months[date.month - 1]} ${date.day}, ${date.year} at '
'${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
}
static String formatSize(int bytes) {
final kb = bytes / 1024;
final mb = kb / 1024;
if (mb >= 1) return '${mb.toStringAsFixed(1)} MB';
return '${kb.toStringAsFixed(0)} KB';
}
}
// ─── Project card widget ───────────────────────────────────
class _ProjectCard extends StatefulWidget {
final _Project project;
final VoidCallback onTap;
const _ProjectCard({required this.project, required this.onTap});
@override
State<_ProjectCard> createState() => _ProjectCardState();
}
class _ProjectCardState extends State<_ProjectCard> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final color = _colorForProject(widget.project.displayName);
final icon = _iconForProject(widget.project.displayName);
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _hovered ? AppColors.surfaceLight : AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _hovered ? color.withAlpha(60) : AppColors.surfaceBorder,
width: _hovered ? 1.5 : 1,
),
boxShadow: _hovered
? [BoxShadow(color: color.withAlpha(15), blurRadius: 20, spreadRadius: 2)]
: null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: color.withAlpha(25),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, size: 18, color: color),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: color.withAlpha(15),
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${widget.project.sessionCount}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: color,
),
),
),
],
),
const Spacer(),
Text(
widget.project.displayName,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
_HomeScreenState.formatDate(widget.project.lastModified),
style: const TextStyle(fontSize: 11, color: AppColors.textMuted),
),
],
),
),
),
);
}
}
// ─── Session tile widget ───────────────────────────────────
class _SessionTile extends StatefulWidget {
final _SessionFile session;
final int index;
final bool isActive;
final VoidCallback onTap;
const _SessionTile({
required this.session,
required this.index,
required this.isActive,
required this.onTap,
});
@override
State<_SessionTile> createState() => _SessionTileState();
}
class _SessionTileState extends State<_SessionTile> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final dt = widget.session.stat.modified;
final isActive = widget.isActive;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
margin: const EdgeInsets.only(bottom: 6),
decoration: BoxDecoration(
color: isActive
? AppColors.assistant.withAlpha(12)
: _hovered
? AppColors.surfaceLight
: AppColors.surface,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isActive
? AppColors.assistant.withAlpha(50)
: _hovered
? AppColors.surfaceBorder.withAlpha(200)
: AppColors.surfaceBorder,
),
),
child: Row(
children: [
// Session number circle
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: isActive
? AppColors.assistant.withAlpha(30)
: AppColors.surfaceLight,
borderRadius: BorderRadius.circular(18),
),
child: Center(
child: Text(
'${widget.index + 1}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isActive ? AppColors.assistant : AppColors.textSecondary,
),
),
),
),
const SizedBox(width: 14),
// Session info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_HomeScreenState.formatDateTime(dt),
style: TextStyle(
fontSize: 13,
fontWeight: isActive ? FontWeight.w600 : FontWeight.w500,
color: isActive ? AppColors.assistant : AppColors.textPrimary,
),
),
const SizedBox(height: 3),
Text(
widget.session.sessionId,
style: const TextStyle(
fontSize: 11,
color: AppColors.textMuted,
fontFamily: 'JetBrains Mono',
),
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 12),
// Size + relative time
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_HomeScreenState.formatSize(widget.session.stat.size),
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
fontFamily: 'JetBrains Mono',
),
),
const SizedBox(height: 2),
Text(
_HomeScreenState.formatDate(dt),
style: const TextStyle(
fontSize: 11,
color: AppColors.textMuted,
),
),
],
),
const SizedBox(width: 8),
Icon(
Icons.chevron_right,
size: 18,
color: isActive
? AppColors.assistant
: _hovered
? AppColors.textSecondary
: Colors.transparent,
),
],
),
),
),
);
}
}