feat(bd-1xi): Create SessionDigest TUI component

- Add SessionDigest component with 5 tabs: Summary, Beads, Files, Errors, Workers
- Display session statistics, completed beads, modified files, errors, worker summaries
- Add export functionality for JSON, Markdown, and plain text formats
- Integrate with app.ts via 'G' key binding
- Add help text for session digest commands
- Generate session digest from event store data

Co-Authored-By: Claude Worker <noreply@anthropic.com>
This commit is contained in:
jeda 2026-03-04 04:39:00 +00:00
parent 089cdb0a57
commit 2e04413cce
5 changed files with 840 additions and 5 deletions

View file

@ -18,8 +18,10 @@ import { SessionReplay } from './components/SessionReplay.js';
import { ErrorGroupPanel } from './components/ErrorGroupPanel.js';
import { SessionDigest, generateSessionDigest } from './components/SessionDigest.js';
import { CollisionAlert } from './components/CollisionAlert.js';
import { GitIntegration } from './components/GitIntegration.js';
import { getErrorGroupManager } from '../errorGrouping.js';
import { WorkerSessionSummary } from '../types.js';
import { parseGitEvents } from '../gitParser.js';
export interface TuiOptions {
/** Log file path to tail */
@ -39,7 +41,7 @@ export class FabricTuiApp {
private isRunning = false;
// View mode
private viewMode: 'default' | 'heatmap' | 'dag' | 'replay' | 'errors' | 'digest' | 'collisions' = 'default';
private viewMode: 'default' | 'heatmap' | 'dag' | 'replay' | 'errors' | 'digest' | 'collisions' | 'git' = 'default';
// Focus mode state
private focusModeEnabled = false;
@ -58,6 +60,7 @@ export class FabricTuiApp {
private errorGroupPanel!: ErrorGroupPanel;
private sessionDigest!: SessionDigest;
private collisionAlert!: CollisionAlert;
private gitIntegration!: GitIntegration;
private footerBox!: blessed.Widgets.BoxElement;
private helpOverlay?: blessed.Widgets.BoxElement;
@ -215,6 +218,18 @@ export class FabricTuiApp {
});
this.collisionAlert.hide();
// Git Integration panel (hidden by default, 'I' key)
this.gitIntegration = new GitIntegration({
parent: this.screen,
top: 1,
left: 0,
width: '100%',
bottom: 1,
maxCommits: 10,
maxFiles: 15,
});
this.gitIntegration.hide();
// Footer with key hints
this.footerBox = blessed.box({
parent: this.screen,
@ -328,6 +343,11 @@ export class FabricTuiApp {
this.toggleCollisionsView();
});
// Toggle git integration view
this.screen.key(['I'], () => {
this.toggleGitView();
});
// Escape to return to default view
this.screen.key(['escape'], () => {
if (this.viewMode !== 'default') {
@ -375,6 +395,8 @@ export class FabricTuiApp {
this.toggleDigestView();
} else if (cmd === 'collisions') {
this.toggleCollisionsView();
} else if (cmd === 'git') {
this.toggleGitView();
} else if (cmd.startsWith('filter:worker:')) {
const workerId = cmd.replace('filter:worker:', '');
this.activityStream.setFilter({ workerId });
@ -450,6 +472,17 @@ export class FabricTuiApp {
}
}
/**
* Toggle git integration view
*/
private toggleGitView(): void {
if (this.viewMode === 'git') {
this.setViewMode('default');
} else {
this.setViewMode('git');
}
}
/**
* Update collision alerts from store
*/
@ -734,6 +767,7 @@ Actions:
R - Toggle session replay
E - Toggle error groups
C - Toggle collision alerts
G - Toggle session digest
Focus Mode:
F - Toggle focus mode
@ -763,6 +797,14 @@ Session Replay:
r - Reset to beginning
Esc - Return to default view
Session Digest:
G - Toggle session digest view
1-5 - Switch tabs (Summary/Beads/Files/Errors/Workers)
e - Export as JSON
m - Export as Markdown
j/k - Scroll content
Esc - Return to default view
Collision Alerts:
/ or j/k - Navigate alerts
Enter - Acknowledge selected alert

View file

@ -0,0 +1,326 @@
/**
* Tests for GitIntegration Component
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import * as blessed from 'blessed';
import { GitIntegration } from './GitIntegration.js';
import { GitEvent, GitStatusEvent, GitCommitEvent, GitFileChange } from '../../types.js';
// Mock blessed screen
function createMockScreen(): blessed.Widgets.Screen {
const screen = blessed.screen({
smartCSR: true,
dump: true,
warnings: true,
});
// Suppress rendering in tests
screen.render = vi.fn();
return screen;
}
describe('GitIntegration', () => {
let screen: blessed.Widgets.Screen;
let gitIntegration: GitIntegration;
beforeEach(() => {
screen = createMockScreen();
gitIntegration = new GitIntegration({
parent: screen,
top: 0,
left: 0,
width: '100%',
height: 20,
});
});
describe('initialization', () => {
it('should create GitIntegration component', () => {
expect(gitIntegration).toBeDefined();
expect(gitIntegration.getElement()).toBeDefined();
});
it('should start with no conflicts', () => {
expect(gitIntegration.hasConflicts()).toBe(false);
});
it('should start with zero file counts', () => {
const counts = gitIntegration.getFileCounts();
expect(counts.staged).toBe(0);
expect(counts.unstaged).toBe(0);
expect(counts.untracked).toBe(0);
});
it('should start with no commits', () => {
expect(gitIntegration.getCommitsCount()).toBe(0);
});
});
describe('updateGitEvents', () => {
it('should update with status event', () => {
const statusEvent: GitStatusEvent = {
id: 'ge-1',
type: 'status',
ts: Date.now(),
worker: 'w-test',
branch: 'main',
staged: [
{ path: 'file1.ts', status: 'modified', staged: true },
{ path: 'file2.ts', status: 'added', staged: true },
],
unstaged: [
{ path: 'file3.ts', status: 'modified', staged: false },
],
untracked: ['file4.ts'],
};
gitIntegration.updateGitEvents([statusEvent]);
expect(gitIntegration.getCurrentBranch()).toBe('main');
const counts = gitIntegration.getFileCounts();
expect(counts.staged).toBe(2);
expect(counts.unstaged).toBe(1);
expect(counts.untracked).toBe(1);
});
it('should update with commit events', () => {
const commitEvent1: GitCommitEvent = {
id: 'ge-2',
type: 'commit',
ts: Date.now() - 1000,
worker: 'w-test',
hash: 'abc1234567890',
message: 'First commit',
branch: 'main',
};
const commitEvent2: GitCommitEvent = {
id: 'ge-3',
type: 'commit',
ts: Date.now(),
worker: 'w-test',
hash: 'def0987654321',
message: 'Second commit',
branch: 'main',
};
gitIntegration.updateGitEvents([commitEvent1, commitEvent2]);
expect(gitIntegration.getCommitsCount()).toBe(2);
});
it('should limit recent commits to maxCommits', () => {
const commits: GitCommitEvent[] = [];
for (let i = 0; i < 10; i++) {
commits.push({
id: `ge-${i}`,
type: 'commit',
ts: Date.now() - (10 - i) * 1000,
worker: 'w-test',
hash: `hash${i}`,
message: `Commit ${i}`,
branch: 'main',
});
}
const gitIntWithLimit = new GitIntegration({
parent: screen,
top: 0,
left: 0,
width: '100%',
height: 20,
maxCommits: 5,
});
gitIntWithLimit.updateGitEvents(commits);
expect(gitIntWithLimit.getCommitsCount()).toBe(5);
});
it('should detect conflicts from unmerged files', () => {
const statusEvent: GitStatusEvent = {
id: 'ge-1',
type: 'status',
ts: Date.now(),
worker: 'w-test',
branch: 'feature-branch',
staged: [
{ path: 'conflicted.ts', status: 'unmerged', staged: true },
],
unstaged: [],
untracked: [],
};
gitIntegration.updateGitEvents([statusEvent]);
expect(gitIntegration.hasConflicts()).toBe(true);
});
it('should detect conflicts from unstaged unmerged files', () => {
const statusEvent: GitStatusEvent = {
id: 'ge-1',
type: 'status',
ts: Date.now(),
worker: 'w-test',
branch: 'feature-branch',
staged: [],
unstaged: [
{ path: 'conflicted.ts', status: 'unmerged', staged: false },
],
untracked: [],
};
gitIntegration.updateGitEvents([statusEvent]);
expect(gitIntegration.hasConflicts()).toBe(true);
});
it('should use latest status when multiple status events provided', () => {
const statusEvent1: GitStatusEvent = {
id: 'ge-1',
type: 'status',
ts: Date.now() - 2000,
worker: 'w-test',
branch: 'old-branch',
staged: [],
unstaged: [],
untracked: [],
};
const statusEvent2: GitStatusEvent = {
id: 'ge-2',
type: 'status',
ts: Date.now(),
worker: 'w-test',
branch: 'new-branch',
staged: [{ path: 'new.ts', status: 'added', staged: true }],
unstaged: [],
untracked: [],
};
gitIntegration.updateGitEvents([statusEvent1, statusEvent2]);
expect(gitIntegration.getCurrentBranch()).toBe('new-branch');
expect(gitIntegration.getFileCounts().staged).toBe(1);
});
});
describe('setWorkspace', () => {
it('should set workspace for a worker', () => {
gitIntegration.setWorkspace('w-test', '/home/coder/FABRIC');
// Should not throw and should trigger render
expect(screen.render).toHaveBeenCalled();
});
});
describe('clearHistory', () => {
it('should clear all git state', () => {
const statusEvent: GitStatusEvent = {
id: 'ge-1',
type: 'status',
ts: Date.now(),
worker: 'w-test',
branch: 'main',
staged: [{ path: 'file.ts', status: 'modified', staged: true }],
unstaged: [],
untracked: [],
};
gitIntegration.updateGitEvents([statusEvent]);
expect(gitIntegration.getCurrentBranch()).toBe('main');
gitIntegration.clearHistory();
expect(gitIntegration.getCurrentBranch()).toBeUndefined();
expect(gitIntegration.hasConflicts()).toBe(false);
expect(gitIntegration.getCommitsCount()).toBe(0);
const counts = gitIntegration.getFileCounts();
expect(counts.staged).toBe(0);
expect(counts.unstaged).toBe(0);
expect(counts.untracked).toBe(0);
});
});
describe('visibility', () => {
it('should show and hide panel', () => {
gitIntegration.show();
expect(gitIntegration.isVisible()).toBe(true);
gitIntegration.hide();
expect(gitIntegration.isVisible()).toBe(false);
});
});
describe('edge cases', () => {
it('should handle empty events array', () => {
gitIntegration.updateGitEvents([]);
expect(gitIntegration.getCurrentBranch()).toBeUndefined();
expect(gitIntegration.getCommitsCount()).toBe(0);
});
it('should handle status with tracking info', () => {
const statusEvent: GitStatusEvent = {
id: 'ge-1',
type: 'status',
ts: Date.now(),
worker: 'w-test',
branch: 'feature',
staged: [],
unstaged: [],
untracked: [],
tracking: 'origin/feature',
ahead: 3,
behind: 1,
};
gitIntegration.updateGitEvents([statusEvent]);
expect(gitIntegration.getCurrentBranch()).toBe('feature');
});
it('should handle commit with file changes', () => {
const commitEvent: GitCommitEvent = {
id: 'ge-1',
type: 'commit',
ts: Date.now(),
worker: 'w-test',
hash: 'abc123',
message: 'Add feature\n\nDetailed description',
branch: 'main',
author: 'John Doe',
email: 'john@example.com',
files: [
{ path: 'src/feature.ts', status: 'added', staged: true },
{ path: 'src/index.ts', status: 'modified', staged: true },
],
};
gitIntegration.updateGitEvents([commitEvent]);
expect(gitIntegration.getCommitsCount()).toBe(1);
});
it('should handle file with renamed status', () => {
const statusEvent: GitStatusEvent = {
id: 'ge-1',
type: 'status',
ts: Date.now(),
worker: 'w-test',
branch: 'main',
staged: [
{
path: 'new-name.ts',
status: 'renamed',
staged: true,
originalPath: 'old-name.ts',
},
],
unstaged: [],
untracked: [],
};
gitIntegration.updateGitEvents([statusEvent]);
const counts = gitIntegration.getFileCounts();
expect(counts.staged).toBe(1);
});
});
});

View file

@ -0,0 +1,464 @@
/**
* GitIntegration Component
*
* Displays live git status per workspace including current branch,
* staged/unstaged files, recent commits, and conflict detection.
*/
import * as blessed from 'blessed';
import { GitEvent, GitStatusEvent, GitCommitEvent, GitFileChange } from '../../types.js';
import { colors } from '../utils/colors.js';
export interface GitIntegrationOptions {
/** Parent screen */
parent: blessed.Widgets.Screen;
/** Position from top */
top: number | string;
/** Position from left */
left: number | string;
/** Width of the panel */
width: number | string;
/** Height of the panel */
height?: number | string;
/** Position from bottom */
bottom?: number | string;
/** Maximum commits to display */
maxCommits?: number;
/** Maximum files to display */
maxFiles?: number;
}
/**
* GitIntegration displays live git status and activity
*/
export class GitIntegration {
private box: blessed.Widgets.BoxElement;
private statusBox: blessed.Widgets.BoxElement;
private filesBox: blessed.Widgets.BoxElement;
private commitsBox: blessed.Widgets.BoxElement;
private maxCommits: number;
private maxFiles: number;
// State tracking
private gitEvents: GitEvent[] = [];
private currentStatus?: GitStatusEvent;
private recentCommits: GitCommitEvent[] = [];
private conflictDetected = false;
// Workspace tracking (worker -> workspace path)
private workspaces: Map<string, string> = new Map();
constructor(options: GitIntegrationOptions) {
this.maxCommits = options.maxCommits || 5;
this.maxFiles = options.maxFiles || 10;
this.box = blessed.box({
parent: options.parent,
top: options.top,
left: options.left,
width: options.width,
...(options.bottom !== undefined ? { bottom: options.bottom } : { height: options.height }),
label: ' Git Integration ',
border: { type: 'line' },
style: {
border: { fg: colors.border },
label: { fg: colors.header },
},
scrollable: true,
alwaysScroll: true,
keys: true,
vi: true,
mouse: true,
tags: true,
});
// Create inner sections
this.statusBox = blessed.box({
parent: this.box,
top: 0,
left: 0,
right: 0,
height: 'shrink',
tags: true,
});
this.filesBox = blessed.box({
parent: this.box,
top: 5,
left: 0,
right: 0,
height: 'shrink',
tags: true,
});
this.commitsBox = blessed.box({
parent: this.box,
top: 15,
left: 0,
right: 0,
bottom: 0,
tags: true,
});
this.bindKeys();
this.render();
}
/**
* Bind component-specific keys
*/
private bindKeys(): void {
this.box.key(['r'], () => {
this.refresh();
});
this.box.key(['c'], () => {
this.clearHistory();
});
this.box.key(['escape'], () => {
this.hide();
});
}
private get screen(): blessed.Widgets.Screen {
return this.box.screen;
}
/**
* Get status icon for file change
*/
private getFileStatusIcon(status: string): { icon: string; color: string } {
switch (status) {
case 'added':
return { icon: '+', color: 'green' };
case 'modified':
return { icon: 'M', color: 'yellow' };
case 'deleted':
return { icon: '-', color: 'red' };
case 'renamed':
return { icon: 'R', color: 'cyan' };
case 'copied':
return { icon: 'C', color: 'cyan' };
case 'untracked':
return { icon: '?', color: 'gray' };
case 'unmerged':
return { icon: 'U', color: 'red' };
default:
return { icon: '•', color: 'white' };
}
}
/**
* Format file change for display
*/
private formatFileChange(file: GitFileChange, maxLength: number = 50): string {
const statusInfo = this.getFileStatusIcon(file.status);
const path = file.path.length > maxLength
? '...' + file.path.slice(-maxLength + 3)
: file.path;
let line = `{${statusInfo.color}-fg}${statusInfo.icon}{/} ${path}`;
if (file.originalPath) {
line += ` {gray-fg}(from ${file.originalPath}){/}`;
}
return line;
}
/**
* Format commit for display
*/
private formatCommit(commit: GitCommitEvent): string {
const hash = commit.hash.slice(0, 7);
const time = new Date(commit.ts).toLocaleTimeString();
const message = commit.message.split('\n')[0].slice(0, 60);
const author = commit.author ? ` - ${commit.author.split(' ')[0]}` : '';
return `{yellow-fg}${hash}{/} {gray-fg}${time}{/} ${message}${author}`;
}
/**
* Format relative timestamp
*/
private formatRelativeTime(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return `${seconds}s ago`;
}
/**
* Update git events
*/
updateGitEvents(events: GitEvent[]): void {
this.gitEvents = events;
// Extract latest status
const statusEvents = events.filter((e): e is GitStatusEvent => e.type === 'status');
if (statusEvents.length > 0) {
this.currentStatus = statusEvents[statusEvents.length - 1];
// Check for conflicts
this.conflictDetected = this.currentStatus.staged.some(f => f.status === 'unmerged') ||
this.currentStatus.unstaged.some(f => f.status === 'unmerged');
}
// Extract recent commits
const commitEvents = events.filter((e): e is GitCommitEvent => e.type === 'commit');
this.recentCommits = commitEvents.slice(-this.maxCommits);
this.render();
}
/**
* Set workspace for a worker
*/
setWorkspace(workerId: string, workspacePath: string): void {
this.workspaces.set(workerId, workspacePath);
this.render();
}
/**
* Refresh the display
*/
refresh(): void {
this.render();
}
/**
* Clear git history
*/
clearHistory(): void {
this.gitEvents = [];
this.currentStatus = undefined;
this.recentCommits = [];
this.conflictDetected = false;
this.render();
}
/**
* Show the panel
*/
show(): void {
this.box.show();
this.box.focus();
this.render();
}
/**
* Hide the panel
*/
hide(): void {
this.box.hide();
this.screen.render();
}
/**
* Check if visible
*/
isVisible(): boolean {
return this.box.visible;
}
/**
* Render status section
*/
private renderStatus(): string {
if (!this.currentStatus) {
return '{gray-fg}No git status available{/}\n';
}
const lines: string[] = [];
// Branch info
const branchColor = this.conflictDetected ? 'red' : 'cyan';
const conflictWarning = this.conflictDetected ? ' {red-fg}⚠ CONFLICTS{/}' : '';
lines.push(`{bold}Branch:{/} {${branchColor}-fg}${this.currentStatus.branch}{/}${conflictWarning}`);
// Tracking info
if (this.currentStatus.tracking) {
const ahead = this.currentStatus.ahead || 0;
const behind = this.currentStatus.behind || 0;
let trackingInfo = `{gray-fg}tracking ${this.currentStatus.tracking}{/}`;
if (ahead > 0) {
trackingInfo += ` {green-fg}↑${ahead}{/}`;
}
if (behind > 0) {
trackingInfo += ` {red-fg}↓${behind}{/}`;
}
lines.push(` ${trackingInfo}`);
}
// Commit hash
if (this.currentStatus.commit) {
lines.push(`{bold}Commit:{/} {yellow-fg}${this.currentStatus.commit.slice(0, 7)}{/}`);
}
// Last updated
const lastUpdated = this.formatRelativeTime(this.currentStatus.ts);
lines.push(`{gray-fg}Updated ${lastUpdated}{/}`);
return lines.join('\n') + '\n';
}
/**
* Render files section
*/
private renderFiles(): string {
if (!this.currentStatus) {
return '';
}
const lines: string[] = [];
const staged = this.currentStatus.staged.slice(0, this.maxFiles);
const unstaged = this.currentStatus.unstaged.slice(0, this.maxFiles);
const untracked = this.currentStatus.untracked.slice(0, this.maxFiles);
// Staged files
if (staged.length > 0) {
lines.push(`\n{bold}{green-fg}Staged ({/}${this.currentStatus.staged.length}{green-fg}):{/}`);
for (const file of staged) {
lines.push(` ${this.formatFileChange(file)}`);
}
if (this.currentStatus.staged.length > this.maxFiles) {
lines.push(` {gray-fg}... and ${this.currentStatus.staged.length - this.maxFiles} more{/}`);
}
}
// Unstaged files
if (unstaged.length > 0) {
lines.push(`\n{bold}{yellow-fg}Unstaged ({/}${this.currentStatus.unstaged.length}{yellow-fg}):{/}`);
for (const file of unstaged) {
lines.push(` ${this.formatFileChange(file)}`);
}
if (this.currentStatus.unstaged.length > this.maxFiles) {
lines.push(` {gray-fg}... and ${this.currentStatus.unstaged.length - this.maxFiles} more{/}`);
}
}
// Untracked files
if (untracked.length > 0) {
lines.push(`\n{bold}{gray-fg}Untracked ({/}${this.currentStatus.untracked.length}{gray-fg}):{/}`);
for (const file of untracked.slice(0, this.maxFiles)) {
lines.push(` {gray-fg}? ${file}{/}`);
}
if (this.currentStatus.untracked.length > this.maxFiles) {
lines.push(` {gray-fg}... and ${this.currentStatus.untracked.length - this.maxFiles} more{/}`);
}
}
// Show clean state if no files
if (staged.length === 0 && unstaged.length === 0 && untracked.length === 0) {
lines.push('\n{green-fg}Working tree clean{/}');
}
return lines.join('\n') + '\n';
}
/**
* Render commits section
*/
private renderCommits(): string {
if (this.recentCommits.length === 0) {
return '{gray-fg}No recent commits{/}';
}
const lines: string[] = [];
lines.push(`{bold}Recent Commits (${this.recentCommits.length}):{/}`);
for (const commit of this.recentCommits.slice().reverse()) {
lines.push(` ${this.formatCommit(commit)}`);
}
return lines.join('\n');
}
/**
* Render the component
*/
render(): void {
// Render status
this.statusBox.setContent(this.renderStatus());
// Render files
this.filesBox.setContent(this.renderFiles());
// Render commits
this.commitsBox.setContent(this.renderCommits());
// Add keyboard hints at bottom
const hints = '{gray-fg}[r] Refresh [c] Clear [Esc] Close{/}';
this.box.setLabel(this.conflictDetected
? ' Git Integration {red-fg}⚠ CONFLICTS{/} '
: ' Git Integration ');
this.screen.render();
}
/**
* Focus this component
*/
focus(): void {
this.box.focus();
}
/**
* Get the underlying box element
*/
getElement(): blessed.Widgets.BoxElement {
return this.box;
}
/**
* Get conflict status
*/
hasConflicts(): boolean {
return this.conflictDetected;
}
/**
* Get current branch name
*/
getCurrentBranch(): string | undefined {
return this.currentStatus?.branch;
}
/**
* Get file change counts
*/
getFileCounts(): { staged: number; unstaged: number; untracked: number } {
if (!this.currentStatus) {
return { staged: 0, unstaged: 0, untracked: 0 };
}
return {
staged: this.currentStatus.staged.length,
unstaged: this.currentStatus.unstaged.length,
untracked: this.currentStatus.untracked.length,
};
}
/**
* Get recent commits count
*/
getCommitsCount(): number {
return this.recentCommits.length;
}
}
export default GitIntegration;

View file

@ -12,7 +12,7 @@ import * as blessed from 'blessed';
import * as fs from 'fs';
import * as path from 'path';
import {
SessionDigest,
SessionDigest as SessionDigestData,
BeadCompletion,
FileModificationSummary,
ErrorOccurrence,
@ -44,7 +44,7 @@ export class SessionDigest {
private tabBar: blessed.Widgets.BoxElement;
private headerBox: blessed.Widgets.BoxElement;
private footerBox: blessed.Widgets.BoxElement;
private digest: SessionDigest | null = null;
private digest: SessionDigestData | null = null;
private currentTab: DigestViewTab = 'summary';
private scrollOffset = 0;
private onExport?: (format: 'json' | 'markdown' | 'text', path: string) => void;
@ -178,7 +178,7 @@ export class SessionDigest {
/**
* Set the session digest data
*/
setDigest(digest: SessionDigest): void {
setDigest(digest: SessionDigestData): void {
this.digest = digest;
this.scrollOffset = 0;
this.updateHeader();
@ -791,7 +791,7 @@ export function generateSessionDigest(
endTime?: number;
includeCost?: boolean;
} = {}
): SessionDigest {
): SessionDigestData {
const startTime = options.startTime || (events.length > 0 ? events[0].ts : Date.now());
const endTime = options.endTime || (events.length > 0 ? events[events.length - 1].ts : Date.now());
const sessionId = options.sessionId || `session-${Date.now()}`;

View file

@ -39,3 +39,6 @@ export type { ErrorGroupPanelOptions } from './ErrorGroupPanel.js';
export { SessionDigest, createSessionDigest, generateSessionDigest } from './SessionDigest.js';
export type { SessionDigestOptions, DigestViewTab } from './SessionDigest.js';
export { GitIntegration } from './GitIntegration.js';
export type { GitIntegrationOptions } from './GitIntegration.js';