Improve changelog feature, version tracking, markdown/preview, persistent styling options

This commit is contained in:
AndyMik90
2025-12-17 15:32:58 +01:00
parent 473b04530a
commit a0d142ba95
20 changed files with 1530 additions and 42 deletions
+3
View File
@@ -45,6 +45,7 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-virtual": "^3.13.13", "@tanstack/react-virtual": "^3.13.13",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0", "@xterm/addon-web-links": "^0.11.0",
@@ -58,7 +59,9 @@
"node-pty": "^1.0.0", "node-pty": "^1.0.0",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6", "react-resizable-panels": "^3.0.6",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"zustand": "^5.0.9" "zustand": "^5.0.9"
+885
View File
File diff suppressed because it is too large Load Diff
@@ -17,6 +17,7 @@ import type {
GitTagInfo GitTagInfo
} from '../../shared/types'; } from '../../shared/types';
import { ChangelogGenerator } from './generator'; import { ChangelogGenerator } from './generator';
import { VersionSuggester } from './version-suggester';
import { parseExistingChangelog } from './parser'; import { parseExistingChangelog } from './parser';
import { import {
getBranches, getBranches,
@@ -38,6 +39,7 @@ export class ChangelogService extends EventEmitter {
private cachedEnv: Record<string, string> | null = null; private cachedEnv: Record<string, string> | null = null;
private debugEnabled: boolean | null = null; private debugEnabled: boolean | null = null;
private generator: ChangelogGenerator | null = null; private generator: ChangelogGenerator | null = null;
private versionSuggester: VersionSuggester | null = null;
constructor() { constructor() {
super(); super();
@@ -240,6 +242,32 @@ export class ChangelogService extends EventEmitter {
return this.generator; return this.generator;
} }
/**
* Get or create the version suggester instance
*/
private getVersionSuggester(): VersionSuggester {
if (!this.versionSuggester) {
const autoBuildSource = this.getAutoBuildSourcePath();
if (!autoBuildSource) {
throw new Error('Auto-build source path not found');
}
// Verify claude CLI is available
if (this.claudePath !== 'claude' && !existsSync(this.claudePath)) {
throw new Error(`Claude CLI not found. Please ensure Claude Code is installed. Looked for: ${this.claudePath}`);
}
this.versionSuggester = new VersionSuggester(
this.pythonPath,
this.claudePath,
autoBuildSource,
this.isDebugEnabled()
);
}
return this.versionSuggester;
}
// ============================================ // ============================================
// Task Management // Task Management
// ============================================ // ============================================
@@ -432,7 +460,7 @@ export class ChangelogService extends EventEmitter {
} }
/** /**
* Suggest next version based on task types * Suggest next version based on task types (rule-based)
*/ */
suggestVersion(specs: TaskSpecContent[], currentVersion?: string): string { suggestVersion(specs: TaskSpecContent[], currentVersion?: string): string {
// Default starting version // Default starting version
@@ -445,7 +473,7 @@ export class ChangelogService extends EventEmitter {
return '1.0.0'; return '1.0.0';
} }
let [major, minor, patch] = parts; const [major, minor, patch] = parts;
// Analyze specs for version increment decision // Analyze specs for version increment decision
let hasBreakingChanges = false; let hasBreakingChanges = false;
@@ -473,6 +501,46 @@ export class ChangelogService extends EventEmitter {
return `${major}.${minor}.${patch + 1}`; return `${major}.${minor}.${patch + 1}`;
} }
} }
/**
* Suggest version using AI analysis of git commits
*/
async suggestVersionFromCommits(
projectPath: string,
commits: import('../../shared/types').GitCommit[],
currentVersion?: string
): Promise<{ version: string; reason: string }> {
try {
// Default starting version
if (!currentVersion) {
return { version: '1.0.0', reason: 'Initial version' };
}
const parts = currentVersion.split('.').map(Number);
if (parts.length !== 3 || parts.some(isNaN)) {
return { version: '1.0.0', reason: 'Invalid current version, resetting to 1.0.0' };
}
// Use AI to analyze commits and suggest version bump
const suggester = this.getVersionSuggester();
const suggestion = await suggester.suggestVersionBump(commits, currentVersion);
this.debug('AI version suggestion', suggestion);
return {
version: suggestion.version,
reason: suggestion.reason
};
} catch (error) {
this.debug('Error in AI version suggestion, falling back to patch bump', error);
// Fallback to patch bump if AI fails
const [major, minor, patch] = (currentVersion || '1.0.0').split('.').map(Number);
return {
version: `${major}.${minor}.${patch + 1}`,
reason: 'Patch version bump (AI analysis failed)'
};
}
}
} }
// Export singleton instance // Export singleton instance
@@ -31,7 +31,7 @@ const FORMAT_TEMPLATES = {
**Bug Fixes:** **Bug Fixes:**
- [List fixes]`, - [List fixes]`,
'github-release': (version: string) => `## What's New in v${version} 'github-release': (version: string, date: string) => `## ${version} - ${date}
### New Features ### New Features
@@ -223,9 +223,9 @@ export function buildGitPrompt(
let formatSpecificInstructions = ''; let formatSpecificInstructions = '';
if (request.format === 'github-release') { if (request.format === 'github-release') {
formatSpecificInstructions = ` formatSpecificInstructions = `
For GitHub Release format, create TWO parts: For GitHub Release format, create TWO parts after the version header:
PART 1 - "What's New" (summarized changes): PART 1 - Categorized changes (summarized):
- Use category sections: New Features, Improvements, Bug Fixes, Documentation, Other Changes - Use category sections: New Features, Improvements, Bug Fixes, Documentation, Other Changes
- ONLY include sections that have actual changes - skip empty sections entirely - ONLY include sections that have actual changes - skip empty sections entirely
- Add a blank line between each bullet point for cleaner formatting - Add a blank line between each bullet point for cleaner formatting
@@ -4,6 +4,7 @@
* Architecture: * Architecture:
* - changelog-service.ts: Main service facade (orchestrates all operations) * - changelog-service.ts: Main service facade (orchestrates all operations)
* - generator.ts: AI-powered changelog generation * - generator.ts: AI-powered changelog generation
* - version-suggester.ts: AI-powered version bump suggestions
* - parser.ts: Changelog and spec parsing logic * - parser.ts: Changelog and spec parsing logic
* - formatter.ts: Prompt building and formatting * - formatter.ts: Prompt building and formatting
* - git-integration.ts: Git operations (branches, tags, commits) * - git-integration.ts: Git operations (branches, tags, commits)
@@ -12,6 +13,7 @@
export { ChangelogService, changelogService } from './changelog-service'; export { ChangelogService, changelogService } from './changelog-service';
export { ChangelogGenerator } from './generator'; export { ChangelogGenerator } from './generator';
export { VersionSuggester } from './version-suggester';
export * from './parser'; export * from './parser';
export * from './formatter'; export * from './formatter';
export * from './git-integration'; export * from './git-integration';
@@ -0,0 +1,245 @@
import { spawn } from 'child_process';
import * as path from 'path';
import * as os from 'os';
import type { GitCommit } from '../../shared/types';
import { getProfileEnv } from '../rate-limit-detector';
interface VersionSuggestion {
version: string;
reason: string;
bumpType: 'major' | 'minor' | 'patch';
}
/**
* AI-powered version bump suggester using Claude SDK with haiku model
* Analyzes commits to intelligently suggest semantic version bumps
*/
export class VersionSuggester {
private debugEnabled: boolean;
constructor(
private pythonPath: string,
private claudePath: string,
private autoBuildSourcePath: string,
debugEnabled: boolean
) {
this.debugEnabled = debugEnabled;
}
private debug(...args: unknown[]): void {
if (this.debugEnabled) {
console.log('[VersionSuggester]', ...args);
}
}
/**
* Suggest version bump using AI analysis of commits
*/
async suggestVersionBump(
commits: GitCommit[],
currentVersion: string
): Promise<VersionSuggestion> {
this.debug('suggestVersionBump called', {
commitCount: commits.length,
currentVersion
});
// Build prompt for Claude to analyze commits
const prompt = this.buildPrompt(commits, currentVersion);
const script = this.createAnalysisScript(prompt);
// Build environment
const spawnEnv = this.buildSpawnEnvironment();
return new Promise((resolve, reject) => {
const childProcess = spawn(this.pythonPath, ['-c', script], {
cwd: this.autoBuildSourcePath,
env: spawnEnv
});
let output = '';
let errorOutput = '';
childProcess.stdout?.on('data', (data: Buffer) => {
output += data.toString();
});
childProcess.stderr?.on('data', (data: Buffer) => {
errorOutput += data.toString();
});
childProcess.on('exit', (code: number | null) => {
if (code === 0 && output.trim()) {
try {
const result = this.parseAIResponse(output.trim(), currentVersion);
this.debug('AI suggestion parsed', result);
resolve(result);
} catch (error) {
this.debug('Failed to parse AI response', error);
// Fallback to simple bump
resolve(this.fallbackSuggestion(currentVersion));
}
} else {
this.debug('AI analysis failed', { code, error: errorOutput });
// Fallback to simple bump
resolve(this.fallbackSuggestion(currentVersion));
}
});
childProcess.on('error', (err: Error) => {
this.debug('Process error', err);
resolve(this.fallbackSuggestion(currentVersion));
});
});
}
/**
* Build prompt for Claude to analyze commits and suggest version bump
*/
private buildPrompt(commits: GitCommit[], currentVersion: string): string {
const commitSummary = commits
.map((c, i) => `${i + 1}. ${c.shortHash} - ${c.message}`)
.join('\n');
return `You are a semantic versioning expert analyzing git commits to suggest the appropriate version bump.
Current version: ${currentVersion}
Analyze these ${commits.length} commits and determine the appropriate semantic version bump:
${commitSummary}
Consider:
- MAJOR (X.0.0): Breaking changes, API changes, removed features, architectural changes
- MINOR (0.X.0): New features, enhancements, additions that maintain backward compatibility
- PATCH (0.0.X): Bug fixes, small tweaks, documentation updates, refactoring without new features
Respond with ONLY a JSON object in this exact format (no markdown, no extra text):
{
"bumpType": "major|minor|patch",
"reason": "Brief explanation of the decision"
}`;
}
/**
* Create Python script to run Claude analysis
*/
private createAnalysisScript(prompt: string): string {
// Escape the prompt for Python string literal
const escapedPrompt = prompt
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n');
return `
import subprocess
import sys
# Use haiku model for fast, cost-effective analysis
prompt = "${escapedPrompt}"
try:
result = subprocess.run(
["${this.claudePath}", "chat", "--model", "haiku", "--prompt", prompt],
capture_output=True,
text=True,
check=True
)
print(result.stdout)
except subprocess.CalledProcessError as e:
print(f"Error: {e.stderr}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Error: {str(e)}", file=sys.stderr)
sys.exit(1)
`;
}
/**
* Parse AI response to extract version suggestion
*/
private parseAIResponse(output: string, currentVersion: string): VersionSuggestion {
// Extract JSON from output (Claude might wrap it in markdown or other text)
const jsonMatch = output.match(/\{[\s\S]*"bumpType"[\s\S]*"reason"[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('No JSON found in AI response');
}
const parsed = JSON.parse(jsonMatch[0]);
const bumpType = parsed.bumpType as 'major' | 'minor' | 'patch';
const reason = parsed.reason || 'AI analysis of commits';
// Calculate new version
const [major, minor, patch] = currentVersion.split('.').map(Number);
let newVersion: string;
switch (bumpType) {
case 'major':
newVersion = `${major + 1}.0.0`;
break;
case 'minor':
newVersion = `${major}.${minor + 1}.0`;
break;
case 'patch':
default:
newVersion = `${major}.${minor}.${patch + 1}`;
break;
}
return {
version: newVersion,
reason,
bumpType
};
}
/**
* Fallback suggestion if AI analysis fails
*/
private fallbackSuggestion(currentVersion: string): VersionSuggestion {
const [major, minor, patch] = currentVersion.split('.').map(Number);
return {
version: `${major}.${minor}.${patch + 1}`,
reason: 'Patch version bump (default)',
bumpType: 'patch'
};
}
/**
* Build spawn environment with proper PATH and auth settings
*/
private buildSpawnEnvironment(): Record<string, string> {
const homeDir = os.homedir();
const isWindows = process.platform === 'win32';
// Build PATH with platform-appropriate separator and locations
const pathAdditions = isWindows
? [
path.join(homeDir, 'AppData', 'Local', 'Programs', 'claude'),
path.join(homeDir, 'AppData', 'Roaming', 'npm'),
path.join(homeDir, '.local', 'bin'),
'C:\\Program Files\\Claude',
'C:\\Program Files (x86)\\Claude'
]
: [
'/usr/local/bin',
'/opt/homebrew/bin',
path.join(homeDir, '.local', 'bin'),
path.join(homeDir, 'bin')
];
// Get active Claude profile environment
const profileEnv = getProfileEnv();
const spawnEnv: Record<string, string> = {
...process.env as Record<string, string>,
...profileEnv,
...(isWindows ? { USERPROFILE: homeDir } : { HOME: homeDir }),
USER: process.env.USER || process.env.USERNAME || 'user',
PATH: [process.env.PATH || '', ...pathAdditions].filter(Boolean).join(path.delimiter),
PYTHONUNBUFFERED: '1'
};
return spawnEnv;
}
}
@@ -210,6 +210,48 @@ export function registerChangelogHandlers(
} }
); );
ipcMain.handle(
IPC_CHANNELS.CHANGELOG_SUGGEST_VERSION_FROM_COMMITS,
async (_, projectId: string, commits: GitCommit[]): Promise<IPCResult<{ version: string; reason: string }>> => {
const project = projectStore.getProject(projectId);
if (!project) {
return { success: false, error: 'Project not found' };
}
try {
// Get current version from existing changelog or git tags
const existing = changelogService.readExistingChangelog(project.path);
let currentVersion = existing.lastVersion;
// If no version in changelog, try to get latest tag
if (!currentVersion) {
const tags = changelogService.getTags(project.path);
if (tags.length > 0) {
// Extract version from tag name (e.g., "v2.1.0" -> "2.1.0")
currentVersion = tags[0].name.replace(/^v/, '');
}
}
// Use AI to analyze commits and suggest version
const result = await changelogService.suggestVersionFromCommits(
project.path,
commits,
currentVersion
);
return {
success: true,
data: result
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to suggest version from commits'
};
}
}
);
// ============================================ // ============================================
// Changelog Git Operations // Changelog Git Operations
// ============================================ // ============================================
@@ -0,0 +1,54 @@
import { ipcMain } from 'electron';
import { IPC_CHANNELS } from '../../../shared/constants';
import type { IPCResult } from '../../../shared/types';
import { projectStore } from '../../project-store';
/**
* Register task archive handlers
*/
export function registerTaskArchiveHandlers(): void {
/**
* Archive tasks
*/
ipcMain.handle(
IPC_CHANNELS.TASK_ARCHIVE,
async (
_,
projectId: string,
taskIds: string[],
version?: string
): Promise<IPCResult<boolean>> => {
console.log('[IPC] TASK_ARCHIVE called with projectId:', projectId, 'taskIds:', taskIds);
const result = projectStore.archiveTasks(projectId, taskIds, version);
if (result) {
console.log('[IPC] TASK_ARCHIVE success');
return { success: true, data: true };
} else {
console.error('[IPC] TASK_ARCHIVE failed');
return { success: false, error: 'Failed to archive tasks' };
}
}
);
/**
* Unarchive tasks
*/
ipcMain.handle(
IPC_CHANNELS.TASK_UNARCHIVE,
async (_, projectId: string, taskIds: string[]): Promise<IPCResult<boolean>> => {
console.log('[IPC] TASK_UNARCHIVE called with projectId:', projectId, 'taskIds:', taskIds);
const result = projectStore.unarchiveTasks(projectId, taskIds);
if (result) {
console.log('[IPC] TASK_UNARCHIVE success');
return { success: true, data: true };
} else {
console.error('[IPC] TASK_UNARCHIVE failed');
return { success: false, error: 'Failed to unarchive tasks' };
}
}
);
}
@@ -15,6 +15,7 @@ import { registerTaskCRUDHandlers } from './crud-handlers';
import { registerTaskExecutionHandlers } from './execution-handlers'; import { registerTaskExecutionHandlers } from './execution-handlers';
import { registerWorktreeHandlers } from './worktree-handlers'; import { registerWorktreeHandlers } from './worktree-handlers';
import { registerTaskLogsHandlers } from './logs-handlers'; import { registerTaskLogsHandlers } from './logs-handlers';
import { registerTaskArchiveHandlers } from './archive-handlers';
/** /**
* Register all task-related IPC handlers * Register all task-related IPC handlers
@@ -35,6 +36,9 @@ export function registerTaskHandlers(
// Register logs handlers (get, watch, unwatch) // Register logs handlers (get, watch, unwatch)
registerTaskLogsHandlers(getMainWindow); registerTaskLogsHandlers(getMainWindow);
// Register archive handlers (archive, unarchive)
registerTaskArchiveHandlers();
} }
// Export shared utilities for use by other modules if needed // Export shared utilities for use by other modules if needed
@@ -32,6 +32,10 @@ export interface ChangelogAPI {
projectId: string, projectId: string,
taskIds: string[] taskIds: string[]
) => Promise<IPCResult<{ version: string; reason: string }>>; ) => Promise<IPCResult<{ version: string; reason: string }>>;
suggestChangelogVersionFromCommits: (
projectId: string,
commits: GitCommit[]
) => Promise<IPCResult<{ version: string; reason: string }>>;
getChangelogBranches: (projectId: string) => Promise<IPCResult<GitBranchInfo[]>>; getChangelogBranches: (projectId: string) => Promise<IPCResult<GitBranchInfo[]>>;
getChangelogTags: (projectId: string) => Promise<IPCResult<GitTagInfo[]>>; getChangelogTags: (projectId: string) => Promise<IPCResult<GitTagInfo[]>>;
getChangelogCommitsPreview: ( getChangelogCommitsPreview: (
@@ -83,6 +87,12 @@ export const createChangelogAPI = (): ChangelogAPI => ({
): Promise<IPCResult<{ version: string; reason: string }>> => ): Promise<IPCResult<{ version: string; reason: string }>> =>
invokeIpc(IPC_CHANNELS.CHANGELOG_SUGGEST_VERSION, projectId, taskIds), invokeIpc(IPC_CHANNELS.CHANGELOG_SUGGEST_VERSION, projectId, taskIds),
suggestChangelogVersionFromCommits: (
projectId: string,
commits: GitCommit[]
): Promise<IPCResult<{ version: string; reason: string }>> =>
invokeIpc(IPC_CHANNELS.CHANGELOG_SUGGEST_VERSION_FROM_COMMITS, projectId, commits),
getChangelogBranches: (projectId: string): Promise<IPCResult<GitBranchInfo[]>> => getChangelogBranches: (projectId: string): Promise<IPCResult<GitBranchInfo[]>> =>
invokeIpc(IPC_CHANNELS.CHANGELOG_GET_BRANCHES, projectId), invokeIpc(IPC_CHANNELS.CHANGELOG_GET_BRANCHES, projectId),
@@ -26,7 +26,7 @@ import { TaskCard } from './TaskCard';
import { SortableTaskCard } from './SortableTaskCard'; import { SortableTaskCard } from './SortableTaskCard';
import { TASK_STATUS_COLUMNS, TASK_STATUS_LABELS } from '../../shared/constants'; import { TASK_STATUS_COLUMNS, TASK_STATUS_LABELS } from '../../shared/constants';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { persistTaskStatus } from '../stores/task-store'; import { persistTaskStatus, archiveTasks } from '../stores/task-store';
import type { Task, TaskStatus } from '../../shared/types'; import type { Task, TaskStatus } from '../../shared/types';
interface KanbanBoardProps { interface KanbanBoardProps {
@@ -41,6 +41,7 @@ interface DroppableColumnProps {
onTaskClick: (task: Task) => void; onTaskClick: (task: Task) => void;
isOver: boolean; isOver: boolean;
onAddClick?: () => void; onAddClick?: () => void;
onArchiveAll?: () => void;
} }
// Empty state content for each column // Empty state content for each column
@@ -84,7 +85,7 @@ const getEmptyStateContent = (status: TaskStatus): { icon: React.ReactNode; mess
} }
}; };
function DroppableColumn({ status, tasks, onTaskClick, isOver, onAddClick }: DroppableColumnProps) { function DroppableColumn({ status, tasks, onTaskClick, isOver, onAddClick, onArchiveAll }: DroppableColumnProps) {
const { setNodeRef } = useDroppable({ const { setNodeRef } = useDroppable({
id: status id: status
}); });
@@ -130,16 +131,29 @@ function DroppableColumn({ status, tasks, onTaskClick, isOver, onAddClick }: Dro
{tasks.length} {tasks.length}
</span> </span>
</div> </div>
{status === 'backlog' && onAddClick && ( <div className="flex items-center gap-1">
<Button {status === 'backlog' && onAddClick && (
variant="ghost" <Button
size="icon" variant="ghost"
className="h-7 w-7 hover:bg-primary/10 hover:text-primary transition-colors" size="icon"
onClick={onAddClick} className="h-7 w-7 hover:bg-primary/10 hover:text-primary transition-colors"
> onClick={onAddClick}
<Plus className="h-4 w-4" /> >
</Button> <Plus className="h-4 w-4" />
)} </Button>
)}
{status === 'done' && onArchiveAll && tasks.length > 0 && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 hover:bg-muted-foreground/10 hover:text-muted-foreground transition-colors"
onClick={onArchiveAll}
title="Archive all done tasks"
>
<Archive className="h-4 w-4" />
</Button>
)}
</div>
</div> </div>
{/* Task list */} {/* Task list */}
@@ -242,6 +256,20 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick }: KanbanBoardP
return grouped; return grouped;
}, [filteredTasks]); }, [filteredTasks]);
const handleArchiveAll = async () => {
// Get projectId from the first task (all tasks should have the same projectId)
const projectId = tasks[0]?.projectId;
if (!projectId) {
console.error('No projectId found');
return;
}
const doneTaskIds = tasksByStatus.done.map((t) => t.id);
if (doneTaskIds.length === 0) return;
await archiveTasks(projectId, doneTaskIds);
};
const handleDragStart = (event: DragStartEvent) => { const handleDragStart = (event: DragStartEvent) => {
const { active } = event; const { active } = event;
const task = tasks.find((t) => t.id === active.id); const task = tasks.find((t) => t.id === active.id);
@@ -348,6 +376,7 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick }: KanbanBoardP
onTaskClick={onTaskClick} onTaskClick={onTaskClick}
isOver={overColumnId === status} isOver={overColumnId === status}
onAddClick={status === 'backlog' ? onNewTaskClick : undefined} onAddClick={status === 'backlog' ? onNewTaskClick : undefined}
onArchiveAll={status === 'done' ? handleArchiveAll : undefined}
/> />
))} ))}
</div> </div>
@@ -17,7 +17,7 @@ import {
EXECUTION_PHASE_LABELS, EXECUTION_PHASE_LABELS,
EXECUTION_PHASE_BADGE_COLORS EXECUTION_PHASE_BADGE_COLORS
} from '../../shared/constants'; } from '../../shared/constants';
import { startTask, stopTask, checkTaskRunning, recoverStuckTask, isIncompleteHumanReview } from '../stores/task-store'; import { startTask, stopTask, checkTaskRunning, recoverStuckTask, isIncompleteHumanReview, archiveTasks } from '../stores/task-store';
import type { Task, TaskCategory, ExecutionPhase, ReviewReason } from '../../shared/types'; import type { Task, TaskCategory, ExecutionPhase, ReviewReason } from '../../shared/types';
// Category icon mapping // Category icon mapping
@@ -85,6 +85,11 @@ export function TaskCard({ task, onClick }: TaskCardProps) {
setIsRecovering(false); setIsRecovering(false);
}; };
const handleArchive = async (e: React.MouseEvent) => {
e.stopPropagation();
await archiveTasks(task.projectId, [task.id]);
};
const getStatusBadgeVariant = (status: string) => { const getStatusBadgeVariant = (status: string) => {
switch (status) { switch (status) {
case 'in_progress': case 'in_progress':
@@ -330,6 +335,17 @@ export function TaskCard({ task, onClick }: TaskCardProps) {
<Play className="mr-1.5 h-3 w-3" /> <Play className="mr-1.5 h-3 w-3" />
Resume Resume
</Button> </Button>
) : task.status === 'done' && !task.metadata?.archivedAt ? (
<Button
variant="ghost"
size="sm"
className="h-7 px-2.5 hover:bg-muted-foreground/10"
onClick={handleArchive}
title="Archive task"
>
<Archive className="mr-1.5 h-3 w-3" />
Archive
</Button>
) : (task.status === 'backlog' || task.status === 'in_progress') && ( ) : (task.status === 'backlog' || task.status === 'in_progress') && (
<Button <Button
variant={isRunning ? 'destructive' : 'default'} variant={isRunning ? 'destructive' : 'default'}
@@ -1,7 +1,10 @@
import { useState } from 'react';
import { FileText, Copy, Save, CheckCircle, Image as ImageIcon } from 'lucide-react'; import { FileText, Copy, Save, CheckCircle, Image as ImageIcon } from 'lucide-react';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { Textarea } from '../ui/textarea'; import { Textarea } from '../ui/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
interface PreviewPanelProps { interface PreviewPanelProps {
generatedChangelog: string; generatedChangelog: string;
@@ -36,11 +39,33 @@ export function PreviewPanel({
onDragLeave, onDragLeave,
onDrop onDrop
}: PreviewPanelProps) { }: PreviewPanelProps) {
const [viewMode, setViewMode] = useState<'markdown' | 'preview'>('markdown');
return ( return (
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
{/* Preview Header */} {/* Preview Header */}
<div className="flex items-center justify-between border-b border-border px-6 py-3"> <div className="flex items-center justify-between border-b border-border px-6 py-3">
<h2 className="font-medium">Preview</h2> <div className="flex items-center gap-3">
<h2 className="font-medium">Preview</h2>
<div className="flex items-center gap-1 rounded-md border border-border p-1">
<Button
variant={viewMode === 'markdown' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('markdown')}
className="h-7 px-3 text-xs"
>
Markdown
</Button>
<Button
variant={viewMode === 'preview' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('preview')}
className="h-7 px-3 text-xs"
>
Preview
</Button>
</div>
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -103,14 +128,30 @@ export function PreviewPanel({
{imageError} {imageError}
</div> </div>
)} )}
<Textarea {viewMode === 'markdown' ? (
ref={textareaRef} <div className="flex h-full flex-col gap-2">
className="h-full w-full resize-none font-mono text-sm" <div className="flex items-center gap-2 text-xs text-muted-foreground">
value={generatedChangelog} <ImageIcon className="h-3.5 w-3.5" />
onChange={(e) => onChangelogEdit(e.target.value)} <span>Paste images into the description to save them to the changelog</span>
onPaste={onPaste} </div>
placeholder="Generated changelog will appear here... (Drag & drop or paste images to add)" <Textarea
/> ref={textareaRef}
className="flex-1 w-full resize-none font-mono text-sm"
value={generatedChangelog}
onChange={(e) => onChangelogEdit(e.target.value)}
onPaste={onPaste}
placeholder="Generated changelog will appear here..."
/>
</div>
) : (
<div className="h-full overflow-auto">
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{generatedChangelog}
</ReactMarkdown>
</div>
</div>
)}
</> </>
) : ( ) : (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
@@ -119,8 +160,9 @@ export function PreviewPanel({
<p className="mt-4 text-sm text-muted-foreground"> <p className="mt-4 text-sm text-muted-foreground">
Click "Generate Changelog" to create release notes. Click "Generate Changelog" to create release notes.
</p> </p>
<p className="mt-2 text-xs text-muted-foreground"> <p className="mt-2 text-xs text-muted-foreground flex items-center justify-center gap-2">
You can drag & drop or paste images (Ctrl+V / Cmd+V) after generating <ImageIcon className="h-3.5 w-3.5" />
<span>Paste images into the description to save them to the changelog</span>
</p> </p>
</div> </div>
</div> </div>
@@ -99,6 +99,12 @@ export function useChangelog() {
const [copySuccess, setCopySuccess] = useState(false); const [copySuccess, setCopySuccess] = useState(false);
const [versionReason, setVersionReason] = useState<string | null>(null); const [versionReason, setVersionReason] = useState<string | null>(null);
// Initialize changelog preferences from settings on mount
const initializeFromSettings = useChangelogStore((state) => state.initializeFromSettings);
useEffect(() => {
initializeFromSettings();
}, [initializeFromSettings]);
// Load data when project changes // Load data when project changes
useEffect(() => { useEffect(() => {
if (selectedProjectId) { if (selectedProjectId) {
@@ -204,17 +210,32 @@ export function useChangelog() {
}; };
const handleContinue = async () => { const handleContinue = async () => {
if (selectedProjectId && selectedTaskIds.length > 0) { if (selectedProjectId) {
try { try {
const result = await window.electronAPI.suggestChangelogVersion( // Use different version suggestion based on source mode
selectedProjectId, if (sourceMode === 'tasks' && selectedTaskIds.length > 0) {
selectedTaskIds // Task-based: Use rule-based suggester
); const result = await window.electronAPI.suggestChangelogVersion(
if (result.success && result.data) { selectedProjectId,
setVersion(result.data.version); selectedTaskIds
setVersionReason(result.data.reason); );
if (result.success && result.data) {
setVersion(result.data.version);
setVersionReason(result.data.reason);
}
} else if ((sourceMode === 'git-history' || sourceMode === 'branch-diff') && previewCommits.length > 0) {
// Git-based: Use AI-powered suggester with commits
const result = await window.electronAPI.suggestChangelogVersionFromCommits(
selectedProjectId,
previewCommits
);
if (result.success && result.data) {
setVersion(result.data.version);
setVersionReason(result.data.reason);
}
} }
} catch { } catch (error) {
console.error('Failed to suggest version:', error);
setVersionReason(null); setVersionReason(null);
} }
} }
@@ -17,6 +17,8 @@ import type {
BranchDiffOptions BranchDiffOptions
} from '../../shared/types'; } from '../../shared/types';
import { useTaskStore } from './task-store'; import { useTaskStore } from './task-store';
import { useSettingsStore } from './settings-store';
import { saveSettings } from './settings-store';
interface ChangelogState { interface ChangelogState {
// Data // Data
@@ -105,6 +107,7 @@ interface ChangelogState {
setAudience: (audience: ChangelogAudience) => void; setAudience: (audience: ChangelogAudience) => void;
setEmojiLevel: (level: ChangelogEmojiLevel) => void; setEmojiLevel: (level: ChangelogEmojiLevel) => void;
setCustomInstructions: (instructions: string) => void; setCustomInstructions: (instructions: string) => void;
initializeFromSettings: () => void;
// Generation actions // Generation actions
setGenerationProgress: (progress: ChangelogGenerationProgress | null) => void; setGenerationProgress: (progress: ChangelogGenerationProgress | null) => void;
@@ -239,10 +242,27 @@ export const useChangelogStore = create<ChangelogState>((set, get) => ({
// Config actions // Config actions
setVersion: (version) => set({ version }), setVersion: (version) => set({ version }),
setDate: (date) => set({ date }), setDate: (date) => set({ date }),
setFormat: (format) => set({ format }), setFormat: (format) => {
setAudience: (audience) => set({ audience }), set({ format });
setEmojiLevel: (level) => set({ emojiLevel: level }), saveSettings({ changelogFormat: format });
},
setAudience: (audience) => {
set({ audience });
saveSettings({ changelogAudience: audience });
},
setEmojiLevel: (level) => {
set({ emojiLevel: level });
saveSettings({ changelogEmojiLevel: level });
},
setCustomInstructions: (instructions) => set({ customInstructions: instructions }), setCustomInstructions: (instructions) => set({ customInstructions: instructions }),
initializeFromSettings: () => {
const settings = useSettingsStore.getState().settings;
set({
format: settings.changelogFormat || 'keep-a-changelog',
audience: settings.changelogAudience || 'user-facing',
emojiLevel: settings.changelogEmojiLevel || 'none'
});
},
// Generation actions // Generation actions
setGenerationProgress: (progress) => set({ generationProgress: progress }), setGenerationProgress: (progress) => set({ generationProgress: progress }),
@@ -341,6 +361,11 @@ export async function loadGitData(projectId: string): Promise<void> {
if (tagsResult.data.length > 1 && !store.gitHistoryToTag) { if (tagsResult.data.length > 1 && !store.gitHistoryToTag) {
store.setGitHistoryToTag(tagsResult.data[1].name); store.setGitHistoryToTag(tagsResult.data[1].name);
} }
// Auto-set since-version to newest tag if not already set
if (tagsResult.data.length > 0 && !store.gitHistorySinceVersion) {
store.setGitHistorySinceVersion(tagsResult.data[0].name);
}
} }
} catch (error) { } catch (error) {
store.setError(error instanceof Error ? error.message : 'Failed to load git data'); store.setError(error instanceof Error ? error.message : 'Failed to load git data');
@@ -393,6 +393,37 @@ export async function deleteTask(
} }
} }
/**
* Archive tasks
* Marks tasks as archived by adding archivedAt timestamp to metadata
*/
export async function archiveTasks(
projectId: string,
taskIds: string[],
version?: string
): Promise<{ success: boolean; error?: string }> {
try {
const result = await window.electronAPI.archiveTasks(projectId, taskIds, version);
if (result.success) {
// Reload tasks to update the UI (archived tasks will be filtered out by default)
await loadTasks(projectId);
return { success: true };
}
return {
success: false,
error: result.error || 'Failed to archive tasks'
};
} catch (error) {
console.error('Error archiving tasks:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
// ============================================ // ============================================
// Task Creation Draft Management // Task Creation Draft Management
// ============================================ // ============================================
@@ -1,4 +1,5 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "@tailwindcss/typography";
/* Auto-Build UI Design System - Oscura Midnight Theme */ /* Auto-Build UI Design System - Oscura Midnight Theme */
/* A modern, professional design system with deep dark mode and warm yellow accents */ /* A modern, professional design system with deep dark mode and warm yellow accents */
@@ -26,7 +26,11 @@ export const DEFAULT_APP_SETTINGS = {
globalClaudeOAuthToken: undefined as string | undefined, globalClaudeOAuthToken: undefined as string | undefined,
globalOpenAIApiKey: undefined as string | undefined, globalOpenAIApiKey: undefined as string | undefined,
// Selected agent profile - defaults to 'balanced' for good speed/quality balance // Selected agent profile - defaults to 'balanced' for good speed/quality balance
selectedAgentProfile: 'balanced' selectedAgentProfile: 'balanced',
// Changelog preferences (persisted between sessions)
changelogFormat: 'keep-a-changelog' as const,
changelogAudience: 'user-facing' as const,
changelogEmojiLevel: 'none' as const
}; };
// ============================================ // ============================================
@@ -222,6 +222,7 @@ export const IPC_CHANNELS = {
CHANGELOG_SAVE: 'changelog:save', CHANGELOG_SAVE: 'changelog:save',
CHANGELOG_READ_EXISTING: 'changelog:readExisting', CHANGELOG_READ_EXISTING: 'changelog:readExisting',
CHANGELOG_SUGGEST_VERSION: 'changelog:suggestVersion', CHANGELOG_SUGGEST_VERSION: 'changelog:suggestVersion',
CHANGELOG_SUGGEST_VERSION_FROM_COMMITS: 'changelog:suggestVersionFromCommits',
// Changelog git operations (for git-based changelog generation) // Changelog git operations (for git-based changelog generation)
CHANGELOG_GET_BRANCHES: 'changelog:getBranches', CHANGELOG_GET_BRANCHES: 'changelog:getBranches',
@@ -3,6 +3,7 @@
*/ */
import type { NotificationSettings } from './project'; import type { NotificationSettings } from './project';
import type { ChangelogFormat, ChangelogAudience, ChangelogEmojiLevel } from './changelog';
// Thinking level for Claude model (budget token allocation) // Thinking level for Claude model (budget token allocation)
export type ThinkingLevel = 'none' | 'low' | 'medium' | 'high' | 'ultrathink'; export type ThinkingLevel = 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
@@ -33,6 +34,10 @@ export interface AppSettings {
onboardingCompleted?: boolean; onboardingCompleted?: boolean;
// Selected agent profile for preset model/thinking configurations // Selected agent profile for preset model/thinking configurations
selectedAgentProfile?: string; selectedAgentProfile?: string;
// Changelog preferences
changelogFormat?: ChangelogFormat;
changelogAudience?: ChangelogAudience;
changelogEmojiLevel?: ChangelogEmojiLevel;
} }
// Auto-Claude Source Environment Configuration (for auto-claude repo .env) // Auto-Claude Source Environment Configuration (for auto-claude repo .env)