Compare commits
No commits in common. "659dade82def81ca8546e55554ceb202b726cae7" and "364877d3763f134eb33f80cd80bc5e415f8b964b" have entirely different histories.
659dade82d
...
364877d376
@ -5,18 +5,18 @@ import 'theme/app_theme.dart';
|
|||||||
import 'widgets/navigation/app_shell.dart';
|
import 'widgets/navigation/app_shell.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
runApp(const ClaudeSessionAnalysisApp());
|
runApp(const ClaudeSessionViewerApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
class ClaudeSessionAnalysisApp extends StatelessWidget {
|
class ClaudeSessionViewerApp extends StatelessWidget {
|
||||||
const ClaudeSessionAnalysisApp({super.key});
|
const ClaudeSessionViewerApp({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ChangeNotifierProvider(
|
return ChangeNotifierProvider(
|
||||||
create: (_) => SessionProvider(),
|
create: (_) => SessionProvider(),
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
title: 'Claude Session Analysis',
|
title: 'Claude Session Viewer',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: AppTheme.dark,
|
theme: AppTheme.dark,
|
||||||
home: const Scaffold(
|
home: const Scaffold(
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import '../models/agent_info.dart';
|
import '../models/agent_info.dart';
|
||||||
import '../models/log_entry.dart';
|
import '../models/log_entry.dart';
|
||||||
@ -12,21 +10,12 @@ class SessionProvider with ChangeNotifier {
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
String? _filePath;
|
String? _filePath;
|
||||||
List<ParseError> _parseErrors = const [];
|
|
||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
String? _selectedAgentId;
|
String? _selectedAgentId;
|
||||||
Set<String> _visibleTypes = {'user', 'assistant', 'system'};
|
Set<String> _visibleTypes = {'user', 'assistant', 'system'};
|
||||||
String _searchQuery = '';
|
String _searchQuery = '';
|
||||||
|
|
||||||
// Cached filtered results
|
|
||||||
List<LogEntry> _cachedFilteredEntries = const [];
|
|
||||||
// Cache key — tracks what was used to compute the cache
|
|
||||||
_FilterKey? _cachedFilterKey;
|
|
||||||
|
|
||||||
// Debounce timer for search
|
|
||||||
Timer? _searchDebounce;
|
|
||||||
|
|
||||||
SessionLog? get session => _session;
|
SessionLog? get session => _session;
|
||||||
bool get isLoading => _isLoading;
|
bool get isLoading => _isLoading;
|
||||||
String? get error => _error;
|
String? get error => _error;
|
||||||
@ -34,30 +23,15 @@ class SessionProvider with ChangeNotifier {
|
|||||||
String? get selectedAgentId => _selectedAgentId;
|
String? get selectedAgentId => _selectedAgentId;
|
||||||
Set<String> get visibleTypes => _visibleTypes;
|
Set<String> get visibleTypes => _visibleTypes;
|
||||||
String get searchQuery => _searchQuery;
|
String get searchQuery => _searchQuery;
|
||||||
List<ParseError> get parseErrors => _parseErrors;
|
|
||||||
|
|
||||||
AgentInfo? get selectedAgent {
|
AgentInfo? get selectedAgent {
|
||||||
if (_session == null || _selectedAgentId == null) return null;
|
if (_session == null || _selectedAgentId == null) return null;
|
||||||
return _session!.agents.where((a) => a.id == _selectedAgentId).firstOrNull;
|
return _session!.agents
|
||||||
|
.where((a) => a.id == _selectedAgentId)
|
||||||
|
.firstOrNull;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<LogEntry> get filteredEntries {
|
List<LogEntry> get filteredEntries {
|
||||||
final key = _FilterKey(
|
|
||||||
selectedAgentId: _selectedAgentId,
|
|
||||||
visibleTypes: _visibleTypes,
|
|
||||||
searchQuery: _searchQuery,
|
|
||||||
hasSession: _session != null,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (_cachedFilterKey == key) return _cachedFilteredEntries;
|
|
||||||
|
|
||||||
// Recompute
|
|
||||||
_cachedFilterKey = key;
|
|
||||||
_cachedFilteredEntries = _computeFilteredEntries();
|
|
||||||
return _cachedFilteredEntries;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<LogEntry> _computeFilteredEntries() {
|
|
||||||
if (_session == null) return [];
|
if (_session == null) return [];
|
||||||
|
|
||||||
List<LogEntry> entries;
|
List<LogEntry> entries;
|
||||||
@ -99,14 +73,10 @@ class SessionProvider with ChangeNotifier {
|
|||||||
_filePath = filePath;
|
_filePath = filePath;
|
||||||
_selectedAgentId = null;
|
_selectedAgentId = null;
|
||||||
_searchQuery = '';
|
_searchQuery = '';
|
||||||
_parseErrors = const [];
|
|
||||||
_invalidateCache();
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final result = await _parser.parseFile(filePath);
|
_session = await _parser.parseFile(filePath);
|
||||||
_session = result.sessionLog;
|
|
||||||
_parseErrors = result.errors;
|
|
||||||
_error = null;
|
_error = null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_error = e.toString();
|
_error = e.toString();
|
||||||
@ -119,7 +89,6 @@ class SessionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
void selectAgent(String? agentId) {
|
void selectAgent(String? agentId) {
|
||||||
_selectedAgentId = agentId;
|
_selectedAgentId = agentId;
|
||||||
_invalidateCache();
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -129,18 +98,12 @@ class SessionProvider with ChangeNotifier {
|
|||||||
} else {
|
} else {
|
||||||
_visibleTypes = Set.from(_visibleTypes)..add(type);
|
_visibleTypes = Set.from(_visibleTypes)..add(type);
|
||||||
}
|
}
|
||||||
_invalidateCache();
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setSearchQuery(String query) {
|
void setSearchQuery(String query) {
|
||||||
_searchQuery = query;
|
_searchQuery = query;
|
||||||
// Update cache immediately but debounce notifyListeners
|
notifyListeners();
|
||||||
_invalidateCache();
|
|
||||||
_searchDebounce?.cancel();
|
|
||||||
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
|
|
||||||
notifyListeners();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearSession() {
|
void clearSession() {
|
||||||
@ -149,55 +112,6 @@ class SessionProvider with ChangeNotifier {
|
|||||||
_error = null;
|
_error = null;
|
||||||
_selectedAgentId = null;
|
_selectedAgentId = null;
|
||||||
_searchQuery = '';
|
_searchQuery = '';
|
||||||
_parseErrors = const [];
|
|
||||||
_searchDebounce?.cancel();
|
|
||||||
_invalidateCache();
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _invalidateCache() {
|
|
||||||
_cachedFilterKey = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_searchDebounce?.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Immutable key to detect whether the filter result has changed.
|
|
||||||
class _FilterKey {
|
|
||||||
final String? selectedAgentId;
|
|
||||||
final Set<String> visibleTypes;
|
|
||||||
final String searchQuery;
|
|
||||||
final bool hasSession;
|
|
||||||
|
|
||||||
const _FilterKey({
|
|
||||||
required this.selectedAgentId,
|
|
||||||
required this.visibleTypes,
|
|
||||||
required this.searchQuery,
|
|
||||||
required this.hasSession,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) =>
|
|
||||||
identical(this, other) ||
|
|
||||||
other is _FilterKey &&
|
|
||||||
selectedAgentId == other.selectedAgentId &&
|
|
||||||
_setEquals(visibleTypes, other.visibleTypes) &&
|
|
||||||
searchQuery == other.searchQuery &&
|
|
||||||
hasSession == other.hasSession;
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode =>
|
|
||||||
Object.hash(selectedAgentId, Object.hashAll(visibleTypes), searchQuery, hasSession);
|
|
||||||
|
|
||||||
static bool _setEquals<T>(Set<T> a, Set<T> b) {
|
|
||||||
if (a.length != b.length) return false;
|
|
||||||
for (final v in a) {
|
|
||||||
if (!b.contains(v)) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -107,58 +107,58 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
_scanProjects();
|
_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.
|
/// Converts the encoded dir name to a human-readable project name.
|
||||||
/// "-Users-mathias-Documents-workspaces-svrnty-talos-rpi5" → "svrnty / talos-rpi5"
|
/// "-Users-mathias-Documents-workspaces-svrnty-talos-rpi5" → "svrnty / talos-rpi5"
|
||||||
/// "-Users-mathias" → "~ (home)"
|
/// "-Users-mathias" → "~ (home)"
|
||||||
|
/// "-Applications-Auto-Claude-app-Contents-Resources-backend" → "Auto-Claude / backend"
|
||||||
String _parsePrettyName(String dirName) {
|
String _parsePrettyName(String dirName) {
|
||||||
final home = _getRealHome() ?? '';
|
// Reconstruct the original path: leading - is /, inner - are /
|
||||||
final username = home.split('/').last;
|
// But careful: "a-gent-maf-debug" is a single folder name with dashes.
|
||||||
|
// The trick: the encoded path uses - as separator for EVERY path component.
|
||||||
|
// We know the common prefixes so we can strip them.
|
||||||
|
String path = dirName;
|
||||||
|
|
||||||
// Try to reconstruct the actual filesystem path
|
// Strip known prefixes to get to the interesting part
|
||||||
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 = [
|
final prefixes = [
|
||||||
'-Users-$username-Documents-workspaces-',
|
'-Users-mathias-Documents-workspaces-',
|
||||||
'-Users-$username-Documents-',
|
'-Users-mathias-Documents-',
|
||||||
'-Users-$username-Workspaces-',
|
'-Users-mathias-',
|
||||||
'-Users-$username-',
|
|
||||||
'-Volumes-Workspaces-',
|
|
||||||
'-Volumes-',
|
|
||||||
'-Applications-',
|
'-Applications-',
|
||||||
];
|
];
|
||||||
|
|
||||||
String path = dirName;
|
String prefix = '';
|
||||||
for (final p in prefixes) {
|
for (final p in prefixes) {
|
||||||
if (path.startsWith(p)) {
|
if (path.startsWith(p)) {
|
||||||
|
prefix = p;
|
||||||
path = path.substring(p.length);
|
path = path.substring(p.length);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path.isEmpty) return '~ (home)';
|
if (path.isEmpty) {
|
||||||
|
if (dirName == '-Users-mathias') return '~ (home)';
|
||||||
|
return dirName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now `path` is something like "svrnty-talos-rpi5--out-fondation"
|
||||||
|
// Double dashes (--) were actual dashes in folder names? No — they represent
|
||||||
|
// a subfolder that itself has a dash. We need to figure out the actual
|
||||||
|
// filesystem path. Let's just check if the reconstructed path exists.
|
||||||
|
final home = Platform.environment['HOME'] ?? '';
|
||||||
|
final reconstructed = _reconstructPath(dirName, home);
|
||||||
|
if (reconstructed != null) {
|
||||||
|
// Get last 2 meaningful segments
|
||||||
|
final segs = reconstructed.split('/').where((s) => s.isNotEmpty).toList();
|
||||||
|
// Remove common uninteresting segments
|
||||||
|
final skip = {'Users', 'mathias', 'Documents', 'workspaces', 'Applications', 'Contents', 'Resources'};
|
||||||
|
final meaningful = segs.where((s) => !skip.contains(s)).toList();
|
||||||
|
if (meaningful.length >= 2) {
|
||||||
|
return '${meaningful[meaningful.length - 2]} / ${meaningful.last}';
|
||||||
|
}
|
||||||
|
if (meaningful.isNotEmpty) return meaningful.last;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: just clean up the raw name
|
||||||
return path.replaceAll('--', ' / ').replaceAll('-', ' ');
|
return path.replaceAll('--', ' / ').replaceAll('-', ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -200,48 +200,18 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
return result; // return anyway as best guess
|
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 {
|
Future<void> _scanProjects() async {
|
||||||
final claudeData = await _findClaudeDataDir();
|
final home = Platform.environment['HOME'];
|
||||||
if (claudeData == null) {
|
if (home == null) return;
|
||||||
debugPrint('[Claude] No Claude Code data directory found');
|
final claudeDir = Directory('$home/.claude/projects');
|
||||||
|
if (!await claudeDir.exists()) {
|
||||||
setState(() => _scanning = false);
|
setState(() => _scanning = false);
|
||||||
return;
|
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>[];
|
final projects = <_Project>[];
|
||||||
|
|
||||||
await for (final projectDir in projectsDir.list()) {
|
await for (final projectDir in claudeDir.list()) {
|
||||||
if (projectDir is! Directory) continue;
|
if (projectDir is! Directory) continue;
|
||||||
final dirName = projectDir.path.split('/').last;
|
final dirName = projectDir.path.split('/').last;
|
||||||
|
|
||||||
@ -250,7 +220,9 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
// Scan .jsonl files in the project dir
|
// Scan .jsonl files in the project dir
|
||||||
try {
|
try {
|
||||||
await for (final entity in projectDir.list()) {
|
await for (final entity in projectDir.list()) {
|
||||||
if (entity is File && entity.path.endsWith('.jsonl')) {
|
if (entity is File &&
|
||||||
|
entity.path.endsWith('.jsonl') &&
|
||||||
|
!entity.path.endsWith('sessions-index.json')) {
|
||||||
try {
|
try {
|
||||||
final stat = entity.statSync();
|
final stat = entity.statSync();
|
||||||
final fileName = entity.path.split('/').last;
|
final fileName = entity.path.split('/').last;
|
||||||
@ -264,6 +236,26 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
|
// Also scan sessions/ subdirectory
|
||||||
|
final sessionsDir = Directory('${projectDir.path}/sessions');
|
||||||
|
if (await sessionsDir.exists()) {
|
||||||
|
try {
|
||||||
|
await for (final entity in sessionsDir.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;
|
if (sessions.isEmpty) continue;
|
||||||
|
|
||||||
sessions.sort((a, b) => b.stat.modified.compareTo(a.stat.modified));
|
sessions.sort((a, b) => b.stat.modified.compareTo(a.stat.modified));
|
||||||
@ -291,7 +283,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickFile() async {
|
Future<void> _pickFile() async {
|
||||||
final home = _getRealHome() ?? '';
|
final home = Platform.environment['HOME'] ?? '';
|
||||||
final result = await FilePicker.platform.pickFiles(
|
final result = await FilePicker.platform.pickFiles(
|
||||||
type: FileType.custom,
|
type: FileType.custom,
|
||||||
allowedExtensions: ['jsonl'],
|
allowedExtensions: ['jsonl'],
|
||||||
|
|||||||
@ -1,212 +1,43 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../../models/agent_info.dart';
|
|
||||||
import '../../models/log_entry.dart';
|
import '../../models/log_entry.dart';
|
||||||
import '../../providers/session_provider.dart';
|
import '../../providers/session_provider.dart';
|
||||||
import '../../services/jsonl_parser.dart';
|
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
import 'widgets/assistant_message_card.dart';
|
import 'widgets/assistant_message_card.dart';
|
||||||
import 'widgets/system_message_card.dart';
|
import 'widgets/system_message_card.dart';
|
||||||
import 'widgets/user_message_card.dart';
|
import 'widgets/user_message_card.dart';
|
||||||
|
|
||||||
// ─── Flat timeline item ──────────────────────────────────────
|
class TimelineScreen extends StatelessWidget {
|
||||||
|
|
||||||
enum _TimelineItemType { userPrompt, assistantResponse, systemMessage }
|
|
||||||
|
|
||||||
class _TimelineItem {
|
|
||||||
final _TimelineItemType type;
|
|
||||||
final LogEntry entry;
|
|
||||||
final int turnNumber;
|
|
||||||
final bool isFirstInTurn;
|
|
||||||
final bool isLastInTurn;
|
|
||||||
|
|
||||||
const _TimelineItem({
|
|
||||||
required this.type,
|
|
||||||
required this.entry,
|
|
||||||
required this.turnNumber,
|
|
||||||
this.isFirstInTurn = false,
|
|
||||||
this.isLastInTurn = false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
List<_TimelineItem> _flattenTurns(List<LogEntry> entries) {
|
|
||||||
final items = <_TimelineItem>[];
|
|
||||||
UserEntry? currentPrompt;
|
|
||||||
int turnNum = 0;
|
|
||||||
bool hadPrompt = false;
|
|
||||||
|
|
||||||
for (final entry in entries) {
|
|
||||||
if (entry is UserEntry && !entry.isToolResult) {
|
|
||||||
// Flush previous turn
|
|
||||||
if (hadPrompt && currentPrompt != null) {
|
|
||||||
// already flushed — this is a new prompt
|
|
||||||
}
|
|
||||||
if (currentPrompt != null) {
|
|
||||||
// The previous turn is already flushed (items added inline below)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit user prompt item
|
|
||||||
turnNum++;
|
|
||||||
currentPrompt = entry;
|
|
||||||
hadPrompt = true;
|
|
||||||
|
|
||||||
items.add(_TimelineItem(
|
|
||||||
type: _TimelineItemType.userPrompt,
|
|
||||||
entry: entry,
|
|
||||||
turnNumber: turnNum,
|
|
||||||
isFirstInTurn: true,
|
|
||||||
));
|
|
||||||
} else if (entry is UserEntry && entry.isToolResult) {
|
|
||||||
continue;
|
|
||||||
} else if (hadPrompt) {
|
|
||||||
final isAssistant = entry is AssistantEntry;
|
|
||||||
final isSystem = entry is SystemEntry;
|
|
||||||
|
|
||||||
items.add(_TimelineItem(
|
|
||||||
type: isAssistant
|
|
||||||
? _TimelineItemType.assistantResponse
|
|
||||||
: isSystem
|
|
||||||
? _TimelineItemType.systemMessage
|
|
||||||
: _TimelineItemType.systemMessage,
|
|
||||||
entry: entry,
|
|
||||||
turnNumber: turnNum,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
// else: orphaned entries before first user prompt — skip
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark last item in each turn
|
|
||||||
for (int i = 0; i < items.length; i++) {
|
|
||||||
final nextIsNewTurn =
|
|
||||||
i + 1 >= items.length || items[i + 1].isFirstInTurn;
|
|
||||||
if (!items[i].isFirstInTurn && nextIsNewTurn) {
|
|
||||||
items[i] = _TimelineItem(
|
|
||||||
type: items[i].type,
|
|
||||||
entry: items[i].entry,
|
|
||||||
turnNumber: items[i].turnNumber,
|
|
||||||
isFirstInTurn: items[i].isFirstInTurn,
|
|
||||||
isLastInTurn: true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Turn grouping for filter bar stats ──────────────────────
|
|
||||||
|
|
||||||
class ConversationTurn {
|
|
||||||
final UserEntry prompt;
|
|
||||||
final List<LogEntry> responses;
|
|
||||||
final int turnNumber;
|
|
||||||
|
|
||||||
ConversationTurn({
|
|
||||||
required this.prompt,
|
|
||||||
required this.responses,
|
|
||||||
required this.turnNumber,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
List<ConversationTurn> groupIntoTurns(List<LogEntry> entries) {
|
|
||||||
final turns = <ConversationTurn>[];
|
|
||||||
UserEntry? currentPrompt;
|
|
||||||
List<LogEntry> currentResponses = [];
|
|
||||||
List<LogEntry> orphanedEntries = [];
|
|
||||||
int turnNum = 0;
|
|
||||||
|
|
||||||
for (final entry in entries) {
|
|
||||||
if (entry is UserEntry && !entry.isToolResult) {
|
|
||||||
if (currentPrompt != null) {
|
|
||||||
turns.add(ConversationTurn(
|
|
||||||
prompt: currentPrompt,
|
|
||||||
responses: currentResponses,
|
|
||||||
turnNumber: turnNum,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if (orphanedEntries.isNotEmpty) {
|
|
||||||
currentResponses = [];
|
|
||||||
turnNum++;
|
|
||||||
currentPrompt = entry;
|
|
||||||
currentResponses.addAll(orphanedEntries);
|
|
||||||
orphanedEntries = [];
|
|
||||||
} else {
|
|
||||||
currentPrompt = entry;
|
|
||||||
currentResponses = [];
|
|
||||||
turnNum++;
|
|
||||||
}
|
|
||||||
} else if (entry is UserEntry && entry.isToolResult) {
|
|
||||||
continue;
|
|
||||||
} else if (currentPrompt != null) {
|
|
||||||
currentResponses.add(entry);
|
|
||||||
} else {
|
|
||||||
orphanedEntries.add(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentPrompt != null) {
|
|
||||||
turns.add(ConversationTurn(
|
|
||||||
prompt: currentPrompt,
|
|
||||||
responses: currentResponses,
|
|
||||||
turnNumber: turnNum,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return turns;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main screen ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TimelineScreen extends StatefulWidget {
|
|
||||||
const TimelineScreen({super.key});
|
const TimelineScreen({super.key});
|
||||||
|
|
||||||
@override
|
|
||||||
State<TimelineScreen> createState() => _TimelineScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _TimelineScreenState extends State<TimelineScreen> {
|
|
||||||
List<_TimelineItem> _cachedItems = const [];
|
|
||||||
List<LogEntry>? _cachedEntriesKey;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final session = context.select<SessionProvider, SessionLog?>((p) => p.session);
|
final provider = context.watch<SessionProvider>();
|
||||||
|
final session = provider.session;
|
||||||
if (session == null) {
|
if (session == null) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: Text('No session loaded',
|
child: Text('No session loaded', style: TextStyle(color: AppColors.textMuted)),
|
||||||
style: TextStyle(color: AppColors.textMuted)),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get filtered entries and flatten to items (cached)
|
final entries = provider.filteredEntries;
|
||||||
final entries = context.select<SessionProvider, List<LogEntry>>(
|
|
||||||
(p) => p.filteredEntries);
|
|
||||||
|
|
||||||
// Cache the flat items list — only recompute when entries reference changes
|
|
||||||
if (!identical(_cachedEntriesKey, entries)) {
|
|
||||||
_cachedEntriesKey = entries;
|
|
||||||
_cachedItems = _flattenTurns(entries);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.background,
|
backgroundColor: AppColors.background,
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
const _FilterBar(),
|
_FilterBar(provider: provider),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _cachedItems.isEmpty
|
child: entries.isEmpty
|
||||||
? const Center(
|
? const Center(
|
||||||
child: Text('No matching entries',
|
child: Text('No matching entries',
|
||||||
style: TextStyle(color: AppColors.textMuted)),
|
style: TextStyle(color: AppColors.textMuted)),
|
||||||
)
|
)
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
|
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
|
||||||
itemCount: _cachedItems.length,
|
itemCount: entries.length,
|
||||||
addRepaintBoundaries: true,
|
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
return _TimelineItemWidget(
|
return _buildEntryCard(entries[index], index);
|
||||||
item: _cachedItems[index],
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -214,171 +45,38 @@ class _TimelineScreenState extends State<TimelineScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Timeline item widget ────────────────────────────────────
|
Widget _buildEntryCard(LogEntry entry, int index) {
|
||||||
|
if (entry is UserEntry && !entry.isToolResult) {
|
||||||
class _TimelineItemWidget extends StatelessWidget {
|
|
||||||
final _TimelineItem item;
|
|
||||||
|
|
||||||
const _TimelineItemWidget({required this.item});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final isPrompt = item.type == _TimelineItemType.userPrompt;
|
|
||||||
|
|
||||||
if (isPrompt) {
|
|
||||||
// User prompt — no tree line, just the card with turn indicator
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.only(
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
bottom: item.isLastInTurn ? 0 : 6,
|
child: UserMessageCard(entry: entry, index: index),
|
||||||
top: 2,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// Turn dot
|
|
||||||
SizedBox(
|
|
||||||
width: 24,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
Container(
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.user,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: AppColors.user.withAlpha(60),
|
|
||||||
blurRadius: 6,
|
|
||||||
spreadRadius: 1,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 10),
|
|
||||||
child: RepaintBoundary(
|
|
||||||
child: UserMessageCard(
|
|
||||||
entry: item.entry as UserEntry,
|
|
||||||
index: item.turnNumber,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (entry is AssistantEntry) {
|
||||||
// Response/system — with tree connector line
|
return Padding(
|
||||||
return Padding(
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
padding: const EdgeInsets.only(bottom: 6),
|
child: AssistantMessageCard(entry: entry, index: index),
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// Tree line column
|
|
||||||
SizedBox(
|
|
||||||
width: 24,
|
|
||||||
height: 36, // Fixed height for the connector
|
|
||||||
child: CustomPaint(
|
|
||||||
painter: _TreeLinePainter(
|
|
||||||
color: AppColors.user.withAlpha(60),
|
|
||||||
isLastChild: item.isLastInTurn,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 4),
|
|
||||||
child: RepaintBoundary(
|
|
||||||
child: _buildCard(item),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildCard(_TimelineItem item) {
|
|
||||||
if (item.entry is AssistantEntry) {
|
|
||||||
return AssistantMessageCard(
|
|
||||||
entry: item.entry as AssistantEntry,
|
|
||||||
index: item.turnNumber,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (item.entry is SystemEntry) {
|
if (entry is SystemEntry) {
|
||||||
return SystemMessageCard(entry: item.entry as SystemEntry);
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: SystemMessageCard(entry: entry),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Custom painter for tree lines ────────────────────────────
|
|
||||||
|
|
||||||
class _TreeLinePainter extends CustomPainter {
|
|
||||||
final Color color;
|
|
||||||
final bool isLastChild;
|
|
||||||
|
|
||||||
const _TreeLinePainter({
|
|
||||||
required this.color,
|
|
||||||
required this.isLastChild,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
void paint(Canvas canvas, Size size) {
|
|
||||||
final paint = Paint()
|
|
||||||
..color = color
|
|
||||||
..strokeWidth = 2
|
|
||||||
..style = PaintingStyle.stroke
|
|
||||||
..strokeCap = StrokeCap.round;
|
|
||||||
|
|
||||||
final centerX = size.width / 2;
|
|
||||||
const connectorY = 18.0;
|
|
||||||
|
|
||||||
// Vertical line from top to connector (or to bottom if not last)
|
|
||||||
canvas.drawLine(
|
|
||||||
Offset(centerX, 0),
|
|
||||||
Offset(centerX, isLastChild ? connectorY : size.height),
|
|
||||||
paint,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Horizontal connector to right edge
|
|
||||||
canvas.drawLine(
|
|
||||||
Offset(centerX, connectorY),
|
|
||||||
Offset(size.width, connectorY),
|
|
||||||
paint,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool shouldRepaint(covariant _TreeLinePainter oldDelegate) {
|
|
||||||
return oldDelegate.color != color || oldDelegate.isLastChild != isLastChild;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Filter bar ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
class _FilterBar extends StatelessWidget {
|
class _FilterBar extends StatelessWidget {
|
||||||
const _FilterBar();
|
final SessionProvider provider;
|
||||||
|
|
||||||
|
const _FilterBar({required this.provider});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final provider = context.watch<SessionProvider>();
|
|
||||||
final session = provider.session!;
|
final session = provider.session!;
|
||||||
final entries = provider.filteredEntries;
|
|
||||||
final turns = groupIntoTurns(entries);
|
|
||||||
|
|
||||||
// Parse errors warning
|
|
||||||
final parseErrors = context.select<SessionProvider, List<ParseError>>(
|
|
||||||
(p) => p.parseErrors);
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 12),
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 12),
|
||||||
@ -388,137 +86,120 @@ class _FilterBar extends StatelessWidget {
|
|||||||
bottom: BorderSide(color: AppColors.surfaceBorder, width: 1),
|
bottom: BorderSide(color: AppColors.surfaceBorder, width: 1),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
Row(
|
// Session info
|
||||||
children: [
|
Expanded(
|
||||||
// Session info
|
child: Row(
|
||||||
Expanded(
|
children: [
|
||||||
child: Row(
|
Text(
|
||||||
children: [
|
session.sessionId?.substring(0, 8) ?? 'Session',
|
||||||
Text(
|
style: const TextStyle(
|
||||||
session.sessionId?.substring(0, 8) ?? 'Session',
|
fontSize: 14,
|
||||||
style: const TextStyle(
|
fontWeight: FontWeight.w600,
|
||||||
fontSize: 14,
|
color: AppColors.textPrimary,
|
||||||
fontWeight: FontWeight.w600,
|
),
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
if (session.version != null)
|
|
||||||
_InfoChip(label: 'v${session.version}'),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_InfoChip(label: '${turns.length} turns'),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_InfoChip(
|
|
||||||
label:
|
|
||||||
'${_formatTokens(session.totalUsage.totalTokens)} tokens'),
|
|
||||||
// Parse errors warning
|
|
||||||
if (parseErrors.isNotEmpty) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Tooltip(
|
|
||||||
message:
|
|
||||||
'${parseErrors.length} lines could not be parsed',
|
|
||||||
child: _InfoChip(
|
|
||||||
label: '⚠ ${parseErrors.length} parse errors',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
// Agent filter
|
|
||||||
if (session.agents.length > 1) ...[
|
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Container(
|
if (session.version != null)
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
_InfoChip(label: 'v${session.version}'),
|
||||||
decoration: BoxDecoration(
|
const SizedBox(width: 8),
|
||||||
color: AppColors.surfaceLight,
|
_InfoChip(
|
||||||
borderRadius: BorderRadius.circular(6),
|
label: '${provider.filteredEntries.length} entries'),
|
||||||
border: Border.all(color: AppColors.surfaceBorder),
|
const SizedBox(width: 8),
|
||||||
),
|
_InfoChip(
|
||||||
child: DropdownButtonHideUnderline(
|
label:
|
||||||
child: DropdownButton<String?>(
|
'${_formatTokens(session.totalUsage.totalTokens)} tokens'),
|
||||||
value: provider.selectedAgentId,
|
|
||||||
isDense: true,
|
|
||||||
dropdownColor: AppColors.surfaceLight,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12, color: AppColors.textPrimary),
|
|
||||||
hint: const Text('All Agents',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12, color: AppColors.textSecondary)),
|
|
||||||
items: [
|
|
||||||
const DropdownMenuItem(
|
|
||||||
value: null,
|
|
||||||
child: Text('All Agents'),
|
|
||||||
),
|
|
||||||
...session.agents.map((a) => DropdownMenuItem(
|
|
||||||
value: a.id,
|
|
||||||
child: Text(a.name,
|
|
||||||
overflow: TextOverflow.ellipsis),
|
|
||||||
)),
|
|
||||||
],
|
|
||||||
onChanged: (v) => provider.selectAgent(v),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
const SizedBox(width: 12),
|
),
|
||||||
// Type toggles
|
),
|
||||||
_TypeToggle(
|
// Agent filter
|
||||||
label: 'User',
|
if (session.agents.length > 1) ...[
|
||||||
color: AppColors.user,
|
const SizedBox(width: 12),
|
||||||
active: provider.visibleTypes.contains('user'),
|
Container(
|
||||||
onTap: () => provider.toggleTypeFilter('user'),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surfaceLight,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(color: AppColors.surfaceBorder),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
child: DropdownButtonHideUnderline(
|
||||||
_TypeToggle(
|
child: DropdownButton<String?>(
|
||||||
label: 'Assistant',
|
value: provider.selectedAgentId,
|
||||||
color: AppColors.assistant,
|
isDense: true,
|
||||||
active: provider.visibleTypes.contains('assistant'),
|
dropdownColor: AppColors.surfaceLight,
|
||||||
onTap: () => provider.toggleTypeFilter('assistant'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
_TypeToggle(
|
|
||||||
label: 'System',
|
|
||||||
color: AppColors.system,
|
|
||||||
active: provider.visibleTypes.contains('system'),
|
|
||||||
onTap: () => provider.toggleTypeFilter('system'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
// Search
|
|
||||||
SizedBox(
|
|
||||||
width: 180,
|
|
||||||
child: TextField(
|
|
||||||
onChanged: provider.setSearchQuery,
|
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12, color: AppColors.textPrimary),
|
fontSize: 12, color: AppColors.textPrimary),
|
||||||
decoration: InputDecoration(
|
hint: const Text('All Agents',
|
||||||
hintText: 'Search...',
|
style: TextStyle(
|
||||||
hintStyle: const TextStyle(
|
fontSize: 12, color: AppColors.textSecondary)),
|
||||||
fontSize: 12, color: AppColors.textMuted),
|
items: [
|
||||||
prefixIcon: const Icon(Icons.search,
|
const DropdownMenuItem(
|
||||||
size: 16, color: AppColors.textMuted),
|
value: null,
|
||||||
isDense: true,
|
child: Text('All Agents'),
|
||||||
contentPadding:
|
|
||||||
const EdgeInsets.symmetric(vertical: 8),
|
|
||||||
filled: true,
|
|
||||||
fillColor: AppColors.surfaceLight,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
borderSide:
|
|
||||||
const BorderSide(color: AppColors.surfaceBorder),
|
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
...session.agents.map((a) => DropdownMenuItem(
|
||||||
borderRadius: BorderRadius.circular(6),
|
value: a.id,
|
||||||
borderSide:
|
child: Text(a.name,
|
||||||
const BorderSide(color: AppColors.surfaceBorder),
|
overflow: TextOverflow.ellipsis),
|
||||||
),
|
)),
|
||||||
),
|
],
|
||||||
|
onChanged: (v) => provider.selectAgent(v),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// Type toggles
|
||||||
|
_TypeToggle(
|
||||||
|
label: 'User',
|
||||||
|
color: AppColors.user,
|
||||||
|
active: provider.visibleTypes.contains('user'),
|
||||||
|
onTap: () => provider.toggleTypeFilter('user'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
_TypeToggle(
|
||||||
|
label: 'Assistant',
|
||||||
|
color: AppColors.assistant,
|
||||||
|
active: provider.visibleTypes.contains('assistant'),
|
||||||
|
onTap: () => provider.toggleTypeFilter('assistant'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
_TypeToggle(
|
||||||
|
label: 'System',
|
||||||
|
color: AppColors.system,
|
||||||
|
active: provider.visibleTypes.contains('system'),
|
||||||
|
onTap: () => provider.toggleTypeFilter('system'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// Search
|
||||||
|
SizedBox(
|
||||||
|
width: 180,
|
||||||
|
child: TextField(
|
||||||
|
onChanged: provider.setSearchQuery,
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppColors.textPrimary),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search...',
|
||||||
|
hintStyle:
|
||||||
|
const TextStyle(fontSize: 12, color: AppColors.textMuted),
|
||||||
|
prefixIcon: const Icon(Icons.search,
|
||||||
|
size: 16, color: AppColors.textMuted),
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppColors.surfaceLight,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
borderSide:
|
||||||
|
const BorderSide(color: AppColors.surfaceBorder),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
borderSide:
|
||||||
|
const BorderSide(color: AppColors.surfaceBorder),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -174,8 +174,7 @@ class TokensScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 24),
|
const SizedBox(width: 24),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: Column(
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: session.agents
|
children: session.agents
|
||||||
@ -226,7 +225,6 @@ class TokensScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,322 +1,278 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io' as io;
|
import 'dart:io';
|
||||||
import 'dart:isolate';
|
|
||||||
|
|
||||||
import '../models/agent_info.dart';
|
import '../models/agent_info.dart';
|
||||||
import '../models/content_block.dart';
|
import '../models/content_block.dart';
|
||||||
import '../models/log_entry.dart';
|
import '../models/log_entry.dart';
|
||||||
import '../models/token_usage.dart';
|
import '../models/token_usage.dart';
|
||||||
|
|
||||||
class ParseError {
|
|
||||||
final int? line;
|
|
||||||
final String? source;
|
|
||||||
final String error;
|
|
||||||
const ParseError({this.line, this.source, required this.error});
|
|
||||||
}
|
|
||||||
|
|
||||||
class ParseResult {
|
|
||||||
final SessionLog sessionLog;
|
|
||||||
final List<ParseError> errors;
|
|
||||||
const ParseResult(this.sessionLog, this.errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _IsolateInput {
|
|
||||||
final List<String> mainLines;
|
|
||||||
final String sessionFileName;
|
|
||||||
final Map<String, List<String>> subagentLines;
|
|
||||||
final Map<String, String> subagentMetaRaw;
|
|
||||||
const _IsolateInput({
|
|
||||||
required this.mainLines,
|
|
||||||
required this.sessionFileName,
|
|
||||||
required this.subagentLines,
|
|
||||||
required this.subagentMetaRaw,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
class JsonlParser {
|
class JsonlParser {
|
||||||
Future<ParseResult> parseFile(String filePath) async {
|
Future<SessionLog> parseFile(String filePath) async {
|
||||||
final file = io.File(filePath);
|
final file = File(filePath);
|
||||||
final mainLines = await file.readAsLines(encoding: utf8);
|
final lines = await file.readAsLines(encoding: utf8);
|
||||||
|
|
||||||
final sessionFileName =
|
final entries = <LogEntry>[];
|
||||||
file.uri.pathSegments.last.replaceAll('.jsonl', '');
|
for (final line in lines) {
|
||||||
|
if (line.trim().isEmpty) continue;
|
||||||
|
try {
|
||||||
|
final json = jsonDecode(line) as Map<String, dynamic>;
|
||||||
|
entries.add(LogEntry.fromJson(json));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the subagents directory from the session file path
|
||||||
|
// e.g. /path/to/project/64a8386a.jsonl → /path/to/project/64a8386a/subagents/
|
||||||
|
final sessionFileName = file.uri.pathSegments.last.replaceAll('.jsonl', '');
|
||||||
final parentDir = file.parent.path;
|
final parentDir = file.parent.path;
|
||||||
final subagentsDir =
|
final subagentsDir = Directory('$parentDir/$sessionFileName/subagents');
|
||||||
io.Directory('$parentDir/$sessionFileName/subagents');
|
|
||||||
|
|
||||||
final subagentLines = <String, List<String>>{};
|
|
||||||
final subagentMetaRaw = <String, String>{};
|
|
||||||
|
|
||||||
|
// Load all subagent .jsonl files
|
||||||
|
final subagentFiles = <String, List<LogEntry>>{};
|
||||||
|
final subagentMeta = <String, Map<String, dynamic>>{};
|
||||||
if (await subagentsDir.exists()) {
|
if (await subagentsDir.exists()) {
|
||||||
await for (final entity in subagentsDir.list()) {
|
await for (final entity in subagentsDir.list()) {
|
||||||
if (entity is io.File) {
|
if (entity is File && entity.path.endsWith('.jsonl')) {
|
||||||
if (entity.path.endsWith('.jsonl')) {
|
final agentFileName = entity.uri.pathSegments.last;
|
||||||
final agentFileName = entity.uri.pathSegments.last;
|
// agent-a014e30b71de602bb.jsonl → a014e30b71de602bb
|
||||||
final agentId = agentFileName
|
final agentId = agentFileName
|
||||||
.replaceAll('.jsonl', '')
|
.replaceAll('.jsonl', '')
|
||||||
.replaceFirst('agent-', '');
|
.replaceFirst('agent-', '');
|
||||||
try {
|
|
||||||
final lines = await entity.readAsLines(encoding: utf8);
|
final agentEntries = <LogEntry>[];
|
||||||
subagentLines[agentId] = lines;
|
try {
|
||||||
} catch (_) {}
|
final agentLines = await entity.readAsLines(encoding: utf8);
|
||||||
} else if (entity.path.endsWith('.meta.json')) {
|
for (final line in agentLines) {
|
||||||
|
if (line.trim().isEmpty) continue;
|
||||||
|
try {
|
||||||
|
final json = jsonDecode(line) as Map<String, dynamic>;
|
||||||
|
agentEntries.add(LogEntry.fromJson(json));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
if (agentEntries.isNotEmpty) {
|
||||||
|
subagentFiles[agentId] = agentEntries;
|
||||||
|
}
|
||||||
|
} else if (entity is File && entity.path.endsWith('.meta.json')) {
|
||||||
|
try {
|
||||||
|
final metaContent = await entity.readAsString();
|
||||||
|
final meta = jsonDecode(metaContent) as Map<String, dynamic>;
|
||||||
final agentFileName = entity.uri.pathSegments.last;
|
final agentFileName = entity.uri.pathSegments.last;
|
||||||
final agentId = agentFileName
|
final agentId = agentFileName
|
||||||
.replaceAll('.meta.json', '')
|
.replaceAll('.meta.json', '')
|
||||||
.replaceFirst('agent-', '');
|
.replaceFirst('agent-', '');
|
||||||
try {
|
subagentMeta[agentId] = meta;
|
||||||
subagentMetaRaw[agentId] = await entity.readAsString();
|
} catch (_) {}
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run CPU-bound parsing in a background isolate
|
return _buildSessionLog(entries, subagentFiles, subagentMeta);
|
||||||
return Isolate.run(
|
}
|
||||||
() => _parseInIsolate(_IsolateInput(
|
|
||||||
mainLines: mainLines,
|
SessionLog _buildSessionLog(
|
||||||
sessionFileName: sessionFileName,
|
List<LogEntry> entries,
|
||||||
subagentLines: subagentLines,
|
Map<String, List<LogEntry>> subagentFiles,
|
||||||
subagentMetaRaw: subagentMetaRaw,
|
Map<String, Map<String, dynamic>> subagentMeta,
|
||||||
)),
|
) {
|
||||||
|
// Extract metadata from first user/assistant entry
|
||||||
|
String? sessionId, cwd, version, gitBranch;
|
||||||
|
for (final entry in entries) {
|
||||||
|
if (entry.sessionId != null) {
|
||||||
|
sessionId = entry.sessionId;
|
||||||
|
cwd = entry.cwd;
|
||||||
|
version = entry.version;
|
||||||
|
gitBranch = entry.gitBranch;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link tool results to tool use blocks in main session
|
||||||
|
_linkToolResults(entries);
|
||||||
|
|
||||||
|
// Link tool results inside each subagent session
|
||||||
|
for (final agentEntries in subagentFiles.values) {
|
||||||
|
_linkToolResults(agentEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate main conversation from progress entries
|
||||||
|
final mainConversation = entries
|
||||||
|
.where((e) => e.type != 'progress' && e.type != 'file-history-snapshot')
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Build agent info
|
||||||
|
final agents = _buildAgents(
|
||||||
|
mainConversation,
|
||||||
|
subagentFiles,
|
||||||
|
subagentMeta,
|
||||||
|
);
|
||||||
|
final mainAgent = agents.firstWhere((a) => a.id == 'main');
|
||||||
|
|
||||||
|
// Compute total usage
|
||||||
|
var totalUsage = const TokenUsage();
|
||||||
|
for (final agent in agents) {
|
||||||
|
totalUsage = totalUsage + agent.aggregatedUsage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build tools by name index
|
||||||
|
final toolsByName = <String, List<ToolUseBlock>>{};
|
||||||
|
for (final agent in agents) {
|
||||||
|
for (final tool in agent.toolsUsed) {
|
||||||
|
toolsByName.putIfAbsent(tool.name, () => []).add(tool);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
final timestamps = entries
|
||||||
|
.where((e) => e.timestamp != null)
|
||||||
|
.map((e) => e.timestamp!)
|
||||||
|
.toList();
|
||||||
|
timestamps.sort();
|
||||||
|
|
||||||
|
return SessionLog(
|
||||||
|
sessionId: sessionId,
|
||||||
|
cwd: cwd,
|
||||||
|
version: version,
|
||||||
|
gitBranch: gitBranch,
|
||||||
|
allEntries: entries,
|
||||||
|
mainConversation: mainConversation,
|
||||||
|
agents: agents,
|
||||||
|
mainAgent: mainAgent,
|
||||||
|
totalUsage: totalUsage,
|
||||||
|
toolsByName: toolsByName,
|
||||||
|
startTime: timestamps.isNotEmpty ? timestamps.first : null,
|
||||||
|
endTime: timestamps.isNotEmpty ? timestamps.last : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
ParseResult _parseInIsolate(_IsolateInput input) {
|
void _linkToolResults(List<LogEntry> entries) {
|
||||||
final errors = <ParseError>[];
|
final resultMap = <String, ToolResultData>{};
|
||||||
final entries = <LogEntry>[];
|
for (final entry in entries) {
|
||||||
|
if (entry is UserEntry && entry.isToolResult) {
|
||||||
for (var i = 0; i < input.mainLines.length; i++) {
|
for (final result in entry.toolResults) {
|
||||||
final line = input.mainLines[i];
|
resultMap[result.toolUseId] = result;
|
||||||
if (line.trim().isEmpty) continue;
|
}
|
||||||
try {
|
|
||||||
final json = jsonDecode(line) as Map<String, dynamic>;
|
|
||||||
entries.add(LogEntry.fromJson(json));
|
|
||||||
} catch (e) {
|
|
||||||
errors.add(ParseError(line: i, error: e.toString()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse subagent entries
|
|
||||||
final subagentFiles = <String, List<LogEntry>>{};
|
|
||||||
for (final entry in input.subagentLines.entries) {
|
|
||||||
final agentEntries = <LogEntry>[];
|
|
||||||
for (var i = 0; i < entry.value.length; i++) {
|
|
||||||
final line = entry.value[i];
|
|
||||||
if (line.trim().isEmpty) continue;
|
|
||||||
try {
|
|
||||||
final json = jsonDecode(line) as Map<String, dynamic>;
|
|
||||||
agentEntries.add(LogEntry.fromJson(json));
|
|
||||||
} catch (e) {
|
|
||||||
errors.add(ParseError(
|
|
||||||
line: i, source: entry.key, error: e.toString()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (agentEntries.isNotEmpty) {
|
for (final entry in entries) {
|
||||||
subagentFiles[entry.key] = agentEntries;
|
if (entry is AssistantEntry) {
|
||||||
}
|
for (final block in entry.toolUseBlocks) {
|
||||||
}
|
block.linkedResult = resultMap[block.id];
|
||||||
|
|
||||||
// Parse subagent metadata
|
|
||||||
final subagentMeta = <String, Map<String, dynamic>>{};
|
|
||||||
for (final entry in input.subagentMetaRaw.entries) {
|
|
||||||
try {
|
|
||||||
subagentMeta[entry.key] =
|
|
||||||
jsonDecode(entry.value) as Map<String, dynamic>;
|
|
||||||
} catch (e) {
|
|
||||||
errors.add(ParseError(source: entry.key, error: e.toString()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final sessionLog = _buildSessionLog(entries, subagentFiles, subagentMeta);
|
|
||||||
return ParseResult(sessionLog, errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
SessionLog _buildSessionLog(
|
|
||||||
List<LogEntry> entries,
|
|
||||||
Map<String, List<LogEntry>> subagentFiles,
|
|
||||||
Map<String, Map<String, dynamic>> subagentMeta,
|
|
||||||
) {
|
|
||||||
// Extract metadata from first user/assistant entry
|
|
||||||
String? sessionId, cwd, version, gitBranch;
|
|
||||||
for (final entry in entries) {
|
|
||||||
if (entry.sessionId != null) {
|
|
||||||
sessionId = entry.sessionId;
|
|
||||||
cwd = entry.cwd;
|
|
||||||
version = entry.version;
|
|
||||||
gitBranch = entry.gitBranch;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_linkToolResults(entries);
|
|
||||||
for (final agentEntries in subagentFiles.values) {
|
|
||||||
_linkToolResults(agentEntries);
|
|
||||||
}
|
|
||||||
|
|
||||||
final mainConversation = entries
|
|
||||||
.where(
|
|
||||||
(e) => e.type != 'progress' && e.type != 'file-history-snapshot')
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
final agents = _buildAgents(mainConversation, subagentFiles, subagentMeta);
|
|
||||||
final mainAgent = agents.firstWhere((a) => a.id == 'main');
|
|
||||||
|
|
||||||
var totalUsage = const TokenUsage();
|
|
||||||
for (final agent in agents) {
|
|
||||||
totalUsage = totalUsage + agent.aggregatedUsage;
|
|
||||||
}
|
|
||||||
|
|
||||||
final toolsByName = <String, List<ToolUseBlock>>{};
|
|
||||||
for (final agent in agents) {
|
|
||||||
for (final tool in agent.toolsUsed) {
|
|
||||||
toolsByName.putIfAbsent(tool.name, () => []).add(tool);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final timestamps = entries
|
|
||||||
.where((e) => e.timestamp != null)
|
|
||||||
.map((e) => e.timestamp!)
|
|
||||||
.toList();
|
|
||||||
timestamps.sort();
|
|
||||||
|
|
||||||
return SessionLog(
|
|
||||||
sessionId: sessionId,
|
|
||||||
cwd: cwd,
|
|
||||||
version: version,
|
|
||||||
gitBranch: gitBranch,
|
|
||||||
allEntries: entries,
|
|
||||||
mainConversation: mainConversation,
|
|
||||||
agents: agents,
|
|
||||||
mainAgent: mainAgent,
|
|
||||||
totalUsage: totalUsage,
|
|
||||||
toolsByName: toolsByName,
|
|
||||||
startTime: timestamps.isNotEmpty ? timestamps.first : null,
|
|
||||||
endTime: timestamps.isNotEmpty ? timestamps.last : null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _linkToolResults(List<LogEntry> entries) {
|
|
||||||
final resultMap = <String, ToolResultData>{};
|
|
||||||
for (final entry in entries) {
|
|
||||||
if (entry is UserEntry && entry.isToolResult) {
|
|
||||||
for (final result in entry.toolResults) {
|
|
||||||
resultMap[result.toolUseId] = result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (final entry in entries) {
|
|
||||||
if (entry is AssistantEntry) {
|
|
||||||
for (final block in entry.toolUseBlocks) {
|
|
||||||
block.linkedResult = resultMap[block.id];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
List<AgentInfo> _buildAgents(
|
|
||||||
List<LogEntry> mainEntries,
|
|
||||||
Map<String, List<LogEntry>> subagentFiles,
|
|
||||||
Map<String, Map<String, dynamic>> subagentMeta,
|
|
||||||
) {
|
|
||||||
final agents = <AgentInfo>[];
|
|
||||||
|
|
||||||
final mainToolUses = <ToolUseBlock>[];
|
|
||||||
String? mainModel;
|
|
||||||
var mainUsage = const TokenUsage();
|
|
||||||
|
|
||||||
for (final entry in mainEntries) {
|
|
||||||
if (entry is AssistantEntry) {
|
|
||||||
mainModel ??= entry.model;
|
|
||||||
if (entry.usage != null) {
|
|
||||||
mainUsage = mainUsage + entry.usage!;
|
|
||||||
}
|
|
||||||
for (final block in entry.toolUseBlocks) {
|
|
||||||
if (!block.isAgentCall) {
|
|
||||||
mainToolUses.add(block);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
agents.add(AgentInfo(
|
List<AgentInfo> _buildAgents(
|
||||||
id: 'main',
|
List<LogEntry> mainEntries,
|
||||||
name: 'Main Assistant',
|
Map<String, List<LogEntry>> subagentFiles,
|
||||||
model: mainModel,
|
Map<String, Map<String, dynamic>> subagentMeta,
|
||||||
messages: mainEntries,
|
) {
|
||||||
toolsUsed: mainToolUses,
|
final agents = <AgentInfo>[];
|
||||||
aggregatedUsage: mainUsage,
|
|
||||||
));
|
|
||||||
|
|
||||||
final agentToolCalls = <String, ToolUseBlock>{};
|
// Main agent tools and usage
|
||||||
for (final entry in mainEntries) {
|
final mainToolUses = <ToolUseBlock>[];
|
||||||
if (entry is AssistantEntry) {
|
String? mainModel;
|
||||||
for (final block in entry.toolUseBlocks) {
|
var mainUsage = const TokenUsage();
|
||||||
if (block.isAgentCall) {
|
|
||||||
agentToolCalls[block.id] = block;
|
for (final entry in mainEntries) {
|
||||||
|
if (entry is AssistantEntry) {
|
||||||
|
mainModel ??= entry.model;
|
||||||
|
if (entry.usage != null) {
|
||||||
|
mainUsage = mainUsage + entry.usage!;
|
||||||
}
|
}
|
||||||
}
|
for (final block in entry.toolUseBlocks) {
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (final entry in subagentFiles.entries) {
|
|
||||||
final agentId = entry.key;
|
|
||||||
final agentEntries = entry.value;
|
|
||||||
final meta = subagentMeta[agentId] ?? {};
|
|
||||||
|
|
||||||
final subToolUses = <ToolUseBlock>[];
|
|
||||||
var subUsage = const TokenUsage();
|
|
||||||
String? subModel;
|
|
||||||
|
|
||||||
for (final e in agentEntries) {
|
|
||||||
if (e is AssistantEntry) {
|
|
||||||
subModel ??= e.model;
|
|
||||||
if (e.usage != null) {
|
|
||||||
subUsage = subUsage + e.usage!;
|
|
||||||
}
|
|
||||||
for (final block in e.toolUseBlocks) {
|
|
||||||
if (!block.isAgentCall) {
|
if (!block.isAgentCall) {
|
||||||
subToolUses.add(block);
|
mainToolUses.add(block);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolUseBlock? matchedCall;
|
|
||||||
for (final call in agentToolCalls.entries) {
|
|
||||||
if (call.key.contains(agentId) || agentId.contains(call.key)) {
|
|
||||||
matchedCall = call.value;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String? slug;
|
|
||||||
for (final e in agentEntries) {
|
|
||||||
if (e.raw.containsKey('slug')) {
|
|
||||||
slug = e.raw['slug'] as String?;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final agentType =
|
|
||||||
meta['agentType'] as String? ?? matchedCall?.subagentType;
|
|
||||||
final description =
|
|
||||||
matchedCall?.agentDescription ?? slug ?? agentType ?? 'Subagent';
|
|
||||||
|
|
||||||
agents.add(AgentInfo(
|
agents.add(AgentInfo(
|
||||||
id: agentId,
|
id: 'main',
|
||||||
name: description,
|
name: 'Main Assistant',
|
||||||
subagentType: agentType,
|
model: mainModel,
|
||||||
description: matchedCall?.agentDescription,
|
messages: mainEntries,
|
||||||
prompt: matchedCall?.agentPrompt,
|
toolsUsed: mainToolUses,
|
||||||
model: subModel,
|
aggregatedUsage: mainUsage,
|
||||||
messages: agentEntries,
|
|
||||||
toolsUsed: subToolUses,
|
|
||||||
aggregatedUsage: subUsage,
|
|
||||||
));
|
));
|
||||||
}
|
|
||||||
|
|
||||||
return agents;
|
// Find Agent tool calls from the main conversation to get descriptions
|
||||||
|
final agentToolCalls = <String, ToolUseBlock>{};
|
||||||
|
for (final entry in mainEntries) {
|
||||||
|
if (entry is AssistantEntry) {
|
||||||
|
for (final block in entry.toolUseBlocks) {
|
||||||
|
if (block.isAgentCall) {
|
||||||
|
agentToolCalls[block.id] = block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build subagent infos from the actual subagent .jsonl files
|
||||||
|
for (final entry in subagentFiles.entries) {
|
||||||
|
final agentId = entry.key;
|
||||||
|
final agentEntries = entry.value;
|
||||||
|
final meta = subagentMeta[agentId] ?? {};
|
||||||
|
|
||||||
|
// Extract tool uses, model, and usage from subagent entries
|
||||||
|
final subToolUses = <ToolUseBlock>[];
|
||||||
|
var subUsage = const TokenUsage();
|
||||||
|
String? subModel;
|
||||||
|
|
||||||
|
for (final e in agentEntries) {
|
||||||
|
if (e is AssistantEntry) {
|
||||||
|
subModel ??= e.model;
|
||||||
|
if (e.usage != null) {
|
||||||
|
subUsage = subUsage + e.usage!;
|
||||||
|
}
|
||||||
|
for (final block in e.toolUseBlocks) {
|
||||||
|
if (!block.isAgentCall) {
|
||||||
|
subToolUses.add(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to match this subagent to an Agent tool call for description/prompt.
|
||||||
|
// The agentId in the file might be a prefix of the tool_use id.
|
||||||
|
ToolUseBlock? matchedCall;
|
||||||
|
for (final call in agentToolCalls.entries) {
|
||||||
|
if (call.key.contains(agentId) || agentId.contains(call.key)) {
|
||||||
|
matchedCall = call.value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check the slug from the first entry to match
|
||||||
|
String? slug;
|
||||||
|
for (final e in agentEntries) {
|
||||||
|
if (e.raw.containsKey('slug')) {
|
||||||
|
slug = e.raw['slug'] as String?;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final agentType = meta['agentType'] as String? ??
|
||||||
|
matchedCall?.subagentType;
|
||||||
|
final description = matchedCall?.agentDescription ??
|
||||||
|
slug ??
|
||||||
|
agentType ??
|
||||||
|
'Subagent';
|
||||||
|
|
||||||
|
agents.add(AgentInfo(
|
||||||
|
id: agentId,
|
||||||
|
name: description,
|
||||||
|
subagentType: agentType,
|
||||||
|
description: matchedCall?.agentDescription,
|
||||||
|
prompt: matchedCall?.agentPrompt,
|
||||||
|
model: subModel,
|
||||||
|
messages: agentEntries,
|
||||||
|
toolsUsed: subToolUses,
|
||||||
|
aggregatedUsage: subUsage,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return agents;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,13 +26,11 @@ class _ExpandableCardState extends State<ExpandableCard>
|
|||||||
late bool _expanded;
|
late bool _expanded;
|
||||||
late AnimationController _controller;
|
late AnimationController _controller;
|
||||||
late Animation<double> _rotation;
|
late Animation<double> _rotation;
|
||||||
bool _hasBeenExpanded = false; // Track if ever expanded to lazy-build
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_expanded = widget.initiallyExpanded;
|
_expanded = widget.initiallyExpanded;
|
||||||
_hasBeenExpanded = _expanded;
|
|
||||||
_controller = AnimationController(
|
_controller = AnimationController(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
vsync: this,
|
vsync: this,
|
||||||
@ -53,7 +51,6 @@ class _ExpandableCardState extends State<ExpandableCard>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_expanded = !_expanded;
|
_expanded = !_expanded;
|
||||||
if (_expanded) {
|
if (_expanded) {
|
||||||
_hasBeenExpanded = true;
|
|
||||||
_controller.forward();
|
_controller.forward();
|
||||||
} else {
|
} else {
|
||||||
_controller.reverse();
|
_controller.reverse();
|
||||||
@ -79,8 +76,7 @@ class _ExpandableCardState extends State<ExpandableCard>
|
|||||||
onTap: _toggle,
|
onTap: _toggle,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding:
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
RotationTransition(
|
RotationTransition(
|
||||||
@ -97,18 +93,19 @@ class _ExpandableCardState extends State<ExpandableCard>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Lazy animated expand: only build child once ever expanded
|
ClipRect(
|
||||||
if (_hasBeenExpanded)
|
child: AnimatedCrossFade(
|
||||||
ClipRect(
|
firstChild: const SizedBox.shrink(),
|
||||||
child: SizeTransition(
|
secondChild: Padding(
|
||||||
sizeFactor: _controller,
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||||
axisAlignment: -1.0,
|
child: widget.child,
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
|
||||||
child: widget.child,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
crossFadeState: _expanded
|
||||||
|
? CrossFadeState.showSecond
|
||||||
|
: CrossFadeState.showFirst,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -39,18 +39,24 @@ class _AppShellState extends State<AppShell> {
|
|||||||
hasSession: hasSession,
|
hasSession: hasSession,
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: IndexedStack(
|
child: _buildScreen(),
|
||||||
index: _screen.index,
|
|
||||||
children: [
|
|
||||||
HomeScreen(onSessionLoaded: _onSessionLoaded),
|
|
||||||
const TimelineScreen(),
|
|
||||||
const AgentsScreen(),
|
|
||||||
const ToolbeltScreen(),
|
|
||||||
const TokensScreen(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildScreen() {
|
||||||
|
switch (_screen) {
|
||||||
|
case SidebarScreen.home:
|
||||||
|
return HomeScreen(onSessionLoaded: _onSessionLoaded);
|
||||||
|
case SidebarScreen.timeline:
|
||||||
|
return const TimelineScreen();
|
||||||
|
case SidebarScreen.agents:
|
||||||
|
return const AgentsScreen();
|
||||||
|
case SidebarScreen.toolbelt:
|
||||||
|
return const ToolbeltScreen();
|
||||||
|
case SidebarScreen.tokens:
|
||||||
|
return const TokensScreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,7 +48,7 @@ class Sidebar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
const Text(
|
const Text(
|
||||||
'Session Analysis',
|
'Session Viewer',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@ -107,7 +107,7 @@ class Sidebar extends StatelessWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Claude Session Analysis v1.0',
|
'Claude Session Viewer v0.1',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: AppColors.textMuted.withAlpha(128),
|
color: AppColors.textMuted.withAlpha(128),
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
platform :osx, '13.0'
|
platform :osx, '10.15'
|
||||||
|
|
||||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||||
|
|||||||
@ -1,16 +1,29 @@
|
|||||||
PODS:
|
PODS:
|
||||||
|
- file_picker (0.0.1):
|
||||||
|
- FlutterMacOS
|
||||||
- FlutterMacOS (1.0.0)
|
- FlutterMacOS (1.0.0)
|
||||||
|
- shared_preferences_foundation (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
|
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
|
||||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||||
|
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
|
file_picker:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
|
||||||
FlutterMacOS:
|
FlutterMacOS:
|
||||||
:path: Flutter/ephemeral
|
:path: Flutter/ephemeral
|
||||||
|
shared_preferences_foundation:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
|
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
|
||||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||||
|
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||||
|
|
||||||
PODFILE CHECKSUM: 89c84cf5c2351c1e554c6dea18d31a879fc3a19e
|
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@ -27,7 +27,6 @@
|
|||||||
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
|
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
|
||||||
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
|
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
|
||||||
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
|
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
|
||||||
33CC10FF2044A3C60003C045 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10FE2044A3C60003C045 /* PrivacyInfo.xcprivacy */; };
|
|
||||||
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
|
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
|
||||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||||
79F3DEC2140214566E19F388 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6CB8C54BF040E1E6BF05BCBD /* Pods_RunnerTests.framework */; };
|
79F3DEC2140214566E19F388 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6CB8C54BF040E1E6BF05BCBD /* Pods_RunnerTests.framework */; };
|
||||||
@ -69,7 +68,7 @@
|
|||||||
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||||
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
|
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
|
||||||
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
|
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
|
||||||
33CC10ED2044A3C60003C045 /* Claude Session Analysis.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Claude Session Analysis.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
33CC10ED2044A3C60003C045 /* claude_session_viewer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = claude_session_viewer.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
|
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
|
||||||
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
|
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
|
||||||
@ -78,7 +77,6 @@
|
|||||||
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = "<group>"; };
|
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = "<group>"; };
|
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = "<group>"; };
|
||||||
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = "<group>"; };
|
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = "<group>"; };
|
||||||
33CC10FE2044A3C60003C045 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
|
||||||
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
|
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
|
||||||
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
|
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
|
||||||
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
|
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
|
||||||
@ -149,7 +147,7 @@
|
|||||||
33CC10EE2044A3C60003C045 /* Products */ = {
|
33CC10EE2044A3C60003C045 /* Products */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
33CC10ED2044A3C60003C045 /* Claude Session Analysis.app */,
|
33CC10ED2044A3C60003C045 /* claude_session_viewer.app */,
|
||||||
331C80D5294CF71000263BE5 /* RunnerTests.xctest */,
|
331C80D5294CF71000263BE5 /* RunnerTests.xctest */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
@ -183,7 +181,6 @@
|
|||||||
children = (
|
children = (
|
||||||
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
|
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
|
||||||
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
|
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
|
||||||
33CC10FE2044A3C60003C045 /* PrivacyInfo.xcprivacy */,
|
|
||||||
33E51913231747F40026EE4D /* DebugProfile.entitlements */,
|
33E51913231747F40026EE4D /* DebugProfile.entitlements */,
|
||||||
33E51914231749380026EE4D /* Release.entitlements */,
|
33E51914231749380026EE4D /* Release.entitlements */,
|
||||||
33CC11242044D66E0003C045 /* Resources */,
|
33CC11242044D66E0003C045 /* Resources */,
|
||||||
@ -247,6 +244,7 @@
|
|||||||
33CC10EB2044A3C60003C045 /* Resources */,
|
33CC10EB2044A3C60003C045 /* Resources */,
|
||||||
33CC110E2044A8840003C045 /* Bundle Framework */,
|
33CC110E2044A8840003C045 /* Bundle Framework */,
|
||||||
3399D490228B24CF009A79C7 /* ShellScript */,
|
3399D490228B24CF009A79C7 /* ShellScript */,
|
||||||
|
2C302F33045D329C15CB5562 /* [CP] Embed Pods Frameworks */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@ -258,7 +256,7 @@
|
|||||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||||
);
|
);
|
||||||
productName = Runner;
|
productName = Runner;
|
||||||
productReference = 33CC10ED2044A3C60003C045 /* Claude Session Analysis.app */;
|
productReference = 33CC10ED2044A3C60003C045 /* claude_session_viewer.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
@ -329,13 +327,29 @@
|
|||||||
files = (
|
files = (
|
||||||
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */,
|
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */,
|
||||||
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */,
|
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */,
|
||||||
33CC10FF2044A3C60003C045 /* PrivacyInfo.xcprivacy in Resources */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXShellScriptBuildPhase section */
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
|
2C302F33045D329C15CB5562 /* [CP] Embed Pods Frameworks */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
|
);
|
||||||
|
name = "[CP] Embed Pods Frameworks";
|
||||||
|
outputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
2D5E86AC9DD4210FFD8C9DE8 /* [CP] Check Pods Manifest.lock */ = {
|
2D5E86AC9DD4210FFD8C9DE8 /* [CP] Check Pods Manifest.lock */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@ -475,10 +489,10 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.svrnty.claudeSessionAnalysis.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.svrnty.claudeSessionViewer.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Claude Session Analysis.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Claude Session Analysis";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/claude_session_viewer.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/claude_session_viewer";
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@ -490,10 +504,10 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.svrnty.claudeSessionAnalysis.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.svrnty.claudeSessionViewer.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Claude Session Analysis.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Claude Session Analysis";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/claude_session_viewer.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/claude_session_viewer";
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
@ -505,10 +519,10 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.svrnty.claudeSessionAnalysis.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.svrnty.claudeSessionViewer.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Claude Session Analysis.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Claude Session Analysis";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/claude_session_viewer.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/claude_session_viewer";
|
||||||
};
|
};
|
||||||
name = Profile;
|
name = Profile;
|
||||||
};
|
};
|
||||||
@ -553,7 +567,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 10.15;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
@ -635,7 +649,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 10.15;
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
@ -671,7 +685,7 @@
|
|||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
CODE_SIGN_IDENTITY = "Developer ID Application";
|
CODE_SIGN_IDENTITY = "-";
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
@ -685,7 +699,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 10.15;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
@ -720,11 +734,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Developer ID Application";
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CODE_SIGN_STYLE = Manual;
|
|
||||||
"DEVELOPMENT_TEAM[sdk=macosx*]" = LD76P8L42W;
|
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
|
||||||
OTHER_CODE_SIGN_FLAGS = "--timestamp --options runtime";
|
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
|||||||
@ -5,10 +5,10 @@
|
|||||||
// 'flutter create' template.
|
// 'flutter create' template.
|
||||||
|
|
||||||
// The application's name. By default this is also the title of the Flutter window.
|
// The application's name. By default this is also the title of the Flutter window.
|
||||||
PRODUCT_NAME = Claude Session Viewer
|
PRODUCT_NAME = claude_session_viewer
|
||||||
|
|
||||||
// The application's bundle identifier
|
// The application's bundle identifier
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.svrnty.claude-session-viewer
|
PRODUCT_BUNDLE_IDENTIFIER = com.svrnty.claudeSessionViewer
|
||||||
|
|
||||||
// The copyright displayed in application information
|
// The copyright displayed in application information
|
||||||
PRODUCT_COPYRIGHT = Copyright © 2026 Svrnty. All rights reserved.
|
PRODUCT_COPYRIGHT = Copyright © 2026 com.svrnty. All rights reserved.
|
||||||
|
|||||||
@ -22,8 +22,6 @@
|
|||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||||
<key>LSApplicationCategoryType</key>
|
|
||||||
<string>public.app-category.developer-tools</string>
|
|
||||||
<key>NSHumanReadableCopyright</key>
|
<key>NSHumanReadableCopyright</key>
|
||||||
<string>$(PRODUCT_COPYRIGHT)</string>
|
<string>$(PRODUCT_COPYRIGHT)</string>
|
||||||
<key>NSMainNibFile</key>
|
<key>NSMainNibFile</key>
|
||||||
|
|||||||
@ -8,7 +8,7 @@ class MainFlutterWindow: NSWindow {
|
|||||||
self.contentViewController = flutterViewController
|
self.contentViewController = flutterViewController
|
||||||
self.setFrame(windowFrame, display: true)
|
self.setFrame(windowFrame, display: true)
|
||||||
self.minSize = NSSize(width: 1200, height: 700)
|
self.minSize = NSSize(width: 1200, height: 700)
|
||||||
self.title = "Claude Session Analysis"
|
self.title = "Claude Session Viewer"
|
||||||
|
|
||||||
RegisterGeneratedPlugins(registry: flutterViewController)
|
RegisterGeneratedPlugins(registry: flutterViewController)
|
||||||
|
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>NSPrivacyTracking</key>
|
|
||||||
<false/>
|
|
||||||
<key>NSPrivacyTrackingDomains</key>
|
|
||||||
<array/>
|
|
||||||
<key>NSPrivacyCollectedDataTypes</key>
|
|
||||||
<array/>
|
|
||||||
<key>NSPrivacyAccessedAPITypes</key>
|
|
||||||
<array>
|
|
||||||
<dict>
|
|
||||||
<key>NSPrivacyAccessedAPIType</key>
|
|
||||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
|
||||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
|
||||||
<array>
|
|
||||||
<string>C617.1</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
<dict>
|
|
||||||
<key>NSPrivacyAccessedAPIType</key>
|
|
||||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
|
||||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
|
||||||
<array>
|
|
||||||
<string>CA92.1</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
16
pubspec.lock
16
pubspec.lock
@ -29,10 +29,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.0"
|
||||||
clock:
|
clock:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -276,18 +276,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.19"
|
version: "0.12.17"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.13.0"
|
version: "0.11.1"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -521,10 +521,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.10"
|
version: "0.7.7"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
name: claude_session_analysis
|
name: claude_session_viewer
|
||||||
description: "Analyze and explore Claude Code session transcripts with rich visualizations, token usage tracking, and agent timeline views."
|
description: "A new Flutter project."
|
||||||
# The following line prevents the package from being accidentally published to
|
# The following line prevents the package from being accidentally published to
|
||||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:claude_session_analysis/main.dart';
|
import 'package:claude_session_viewer/main.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('App launches', (WidgetTester tester) async {
|
testWidgets('App launches', (WidgetTester tester) async {
|
||||||
await tester.pumpWidget(const ClaudeSessionAnalysisApp());
|
await tester.pumpWidget(const ClaudeSessionViewerApp());
|
||||||
expect(find.text('Claude Session Analysis'), findsOneWidget);
|
expect(find.text('Claude Session Viewer'), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user