terminal name generator
This commit is contained in:
@@ -260,7 +260,7 @@ export function registerTaskHandlers(
|
||||
async (
|
||||
_,
|
||||
taskId: string,
|
||||
updates: { title?: string; description?: string }
|
||||
updates: { title?: string; description?: string; metadata?: Partial<TaskMetadata> }
|
||||
): Promise<IPCResult<Task>> => {
|
||||
try {
|
||||
// Find task and project
|
||||
@@ -363,11 +363,80 @@ export function registerTaskHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
// Update metadata if provided
|
||||
let updatedMetadata = task.metadata;
|
||||
if (updates.metadata) {
|
||||
updatedMetadata = { ...task.metadata, ...updates.metadata };
|
||||
|
||||
// Process and save attached images if provided
|
||||
if (updates.metadata.attachedImages && updates.metadata.attachedImages.length > 0) {
|
||||
const attachmentsDir = path.join(specDir, 'attachments');
|
||||
mkdirSync(attachmentsDir, { recursive: true });
|
||||
|
||||
const savedImages: typeof updates.metadata.attachedImages = [];
|
||||
|
||||
for (const image of updates.metadata.attachedImages) {
|
||||
// If image has data (new image), save it
|
||||
if (image.data) {
|
||||
try {
|
||||
const buffer = Buffer.from(image.data, 'base64');
|
||||
const imagePath = path.join(attachmentsDir, image.filename);
|
||||
writeFileSync(imagePath, buffer);
|
||||
|
||||
savedImages.push({
|
||||
id: image.id,
|
||||
filename: image.filename,
|
||||
mimeType: image.mimeType,
|
||||
size: image.size,
|
||||
path: `attachments/${image.filename}`
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Failed to save image ${image.filename}:`, err);
|
||||
}
|
||||
} else if (image.path) {
|
||||
// Existing image, keep it
|
||||
savedImages.push(image);
|
||||
}
|
||||
}
|
||||
|
||||
updatedMetadata.attachedImages = savedImages;
|
||||
}
|
||||
|
||||
// Update task_metadata.json
|
||||
const metadataPath = path.join(specDir, 'task_metadata.json');
|
||||
try {
|
||||
writeFileSync(metadataPath, JSON.stringify(updatedMetadata, null, 2));
|
||||
} catch (err) {
|
||||
console.error('Failed to update task_metadata.json:', err);
|
||||
}
|
||||
|
||||
// Update requirements.json if it exists
|
||||
const requirementsPath = path.join(specDir, 'requirements.json');
|
||||
if (existsSync(requirementsPath)) {
|
||||
try {
|
||||
const requirementsContent = readFileSync(requirementsPath, 'utf-8');
|
||||
const requirements = JSON.parse(requirementsContent);
|
||||
|
||||
if (updates.description !== undefined) {
|
||||
requirements.task_description = updates.description;
|
||||
}
|
||||
if (updates.metadata.category) {
|
||||
requirements.workflow_type = updates.metadata.category;
|
||||
}
|
||||
|
||||
writeFileSync(requirementsPath, JSON.stringify(requirements, null, 2));
|
||||
} catch (err) {
|
||||
console.error('Failed to update requirements.json:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the updated task object
|
||||
const updatedTask: Task = {
|
||||
...task,
|
||||
title: finalTitle ?? task.title,
|
||||
description: updates.description ?? task.description,
|
||||
metadata: updatedMetadata,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { IPCResult, TerminalCreateOptions, ClaudeProfile, ClaudeProfileSett
|
||||
import { getClaudeProfileManager } from '../claude-profile-manager';
|
||||
import { TerminalManager } from '../terminal-manager';
|
||||
import { projectStore } from '../project-store';
|
||||
import { terminalNameGenerator } from '../terminal-name-generator';
|
||||
|
||||
|
||||
/**
|
||||
@@ -53,6 +54,25 @@ export function registerTerminalHandlers(
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_GENERATE_NAME,
|
||||
async (_, command: string, cwd?: string): Promise<IPCResult<string>> => {
|
||||
try {
|
||||
const name = await terminalNameGenerator.generateName(command, cwd);
|
||||
if (name) {
|
||||
return { success: true, data: name };
|
||||
} else {
|
||||
return { success: false, error: 'Failed to generate terminal name' };
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to generate terminal name'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Claude profile management (multi-account support)
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILES_GET,
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { spawn } from 'child_process';
|
||||
import { app } from 'electron';
|
||||
import { EventEmitter } from 'events';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from './rate-limit-detector';
|
||||
|
||||
/**
|
||||
* Debug logging - only logs when AUTO_CLAUDE_DEBUG env var is set
|
||||
*/
|
||||
const DEBUG = process.env.AUTO_CLAUDE_DEBUG === 'true' || process.env.AUTO_CLAUDE_DEBUG === '1';
|
||||
|
||||
function debug(...args: unknown[]): void {
|
||||
if (DEBUG) {
|
||||
console.log('[TerminalNameGenerator]', ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for generating terminal names from commands using Claude AI
|
||||
*/
|
||||
export class TerminalNameGenerator extends EventEmitter {
|
||||
private pythonPath: string = 'python3';
|
||||
private autoBuildSourcePath: string = '';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
debug('TerminalNameGenerator initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure paths for Python and auto-claude source
|
||||
*/
|
||||
configure(pythonPath?: string, autoBuildSourcePath?: string): void {
|
||||
if (pythonPath) {
|
||||
this.pythonPath = pythonPath;
|
||||
}
|
||||
if (autoBuildSourcePath) {
|
||||
this.autoBuildSourcePath = autoBuildSourcePath;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the auto-claude source path (detects automatically if not configured)
|
||||
*/
|
||||
private getAutoBuildSourcePath(): string | null {
|
||||
if (this.autoBuildSourcePath && existsSync(this.autoBuildSourcePath)) {
|
||||
return this.autoBuildSourcePath;
|
||||
}
|
||||
|
||||
const possiblePaths = [
|
||||
path.resolve(__dirname, '..', '..', '..', 'auto-claude'),
|
||||
path.resolve(app.getAppPath(), '..', 'auto-claude'),
|
||||
path.resolve(process.cwd(), 'auto-claude')
|
||||
];
|
||||
|
||||
for (const p of possiblePaths) {
|
||||
if (existsSync(p) && existsSync(path.join(p, 'VERSION'))) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load environment variables from auto-claude .env file
|
||||
*/
|
||||
private loadAutoBuildEnv(): Record<string, string> {
|
||||
const autoBuildSource = this.getAutoBuildSourcePath();
|
||||
if (!autoBuildSource) return {};
|
||||
|
||||
const envPath = path.join(autoBuildSource, '.env');
|
||||
if (!existsSync(envPath)) return {};
|
||||
|
||||
try {
|
||||
const envContent = readFileSync(envPath, 'utf-8');
|
||||
const envVars: Record<string, string> = {};
|
||||
|
||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
||||
for (const line of envContent.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex > 0) {
|
||||
const key = trimmed.substring(0, eqIndex).trim();
|
||||
let value = trimmed.substring(eqIndex + 1).trim();
|
||||
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
envVars[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return envVars;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a terminal name from a command using Claude AI
|
||||
* @param command - The command or recent output to generate a name from
|
||||
* @param cwd - Current working directory for context
|
||||
* @returns Promise resolving to the generated name (2-3 words) or null on failure
|
||||
*/
|
||||
async generateName(command: string, cwd?: string): Promise<string | null> {
|
||||
const autoBuildSource = this.getAutoBuildSourcePath();
|
||||
|
||||
if (!autoBuildSource) {
|
||||
debug('Auto-claude source path not found');
|
||||
return null;
|
||||
}
|
||||
|
||||
const prompt = this.createNamePrompt(command, cwd);
|
||||
const script = this.createGenerationScript(prompt);
|
||||
|
||||
debug('Generating terminal name for command:', command.substring(0, 100) + '...');
|
||||
|
||||
const autoBuildEnv = this.loadAutoBuildEnv();
|
||||
debug('Environment loaded', {
|
||||
hasOAuthToken: !!autoBuildEnv.CLAUDE_CODE_OAUTH_TOKEN
|
||||
});
|
||||
|
||||
// Get active Claude profile environment (CLAUDE_CONFIG_DIR if not default)
|
||||
const profileEnv = getProfileEnv();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const childProcess = spawn(this.pythonPath, ['-c', script], {
|
||||
cwd: autoBuildSource,
|
||||
env: {
|
||||
...process.env,
|
||||
...autoBuildEnv,
|
||||
...profileEnv, // Include active Claude profile config
|
||||
PYTHONUNBUFFERED: '1'
|
||||
}
|
||||
});
|
||||
|
||||
let output = '';
|
||||
let errorOutput = '';
|
||||
const timeout = setTimeout(() => {
|
||||
debug('Terminal name generation timed out after 30s');
|
||||
childProcess.kill();
|
||||
resolve(null);
|
||||
}, 30000); // 30 second timeout
|
||||
|
||||
childProcess.stdout?.on('data', (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
childProcess.stderr?.on('data', (data: Buffer) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
|
||||
childProcess.on('exit', (code: number | null) => {
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (code === 0 && output.trim()) {
|
||||
const name = this.cleanName(output.trim());
|
||||
debug('Generated terminal name:', name);
|
||||
resolve(name);
|
||||
} else {
|
||||
// Check for rate limit
|
||||
const combinedOutput = `${output}\n${errorOutput}`;
|
||||
const rateLimitDetection = detectRateLimit(combinedOutput);
|
||||
if (rateLimitDetection.isRateLimited) {
|
||||
debug('Rate limit detected:', {
|
||||
resetTime: rateLimitDetection.resetTime,
|
||||
limitType: rateLimitDetection.limitType,
|
||||
suggestedProfile: rateLimitDetection.suggestedProfile?.name
|
||||
});
|
||||
|
||||
const rateLimitInfo = createSDKRateLimitInfo('terminal-name-generator', rateLimitDetection);
|
||||
this.emit('sdk-rate-limit', rateLimitInfo);
|
||||
}
|
||||
|
||||
if (!rateLimitDetection.isRateLimited) {
|
||||
debug('Terminal name generation failed', {
|
||||
code,
|
||||
errorOutput: errorOutput.substring(0, 500)
|
||||
});
|
||||
}
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
|
||||
childProcess.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
debug('Process error:', err.message);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the prompt for terminal name generation
|
||||
*/
|
||||
private createNamePrompt(command: string, cwd?: string): string {
|
||||
let prompt = `Generate a very short, descriptive name (2-3 words MAX) for a terminal window based on what it's doing. The name should be concise and help identify the terminal at a glance.
|
||||
|
||||
Command or activity:
|
||||
${command}`;
|
||||
|
||||
if (cwd) {
|
||||
prompt += `
|
||||
|
||||
Working directory:
|
||||
${cwd}`;
|
||||
}
|
||||
|
||||
prompt += `
|
||||
|
||||
Output ONLY the name (2-3 words), nothing else. Examples: "npm build", "git logs", "python tests", "claude dev"`;
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Python script to generate terminal name using Claude Agent SDK
|
||||
*/
|
||||
private createGenerationScript(prompt: string): string {
|
||||
// Escape the prompt for Python string - use JSON.stringify for safe escaping
|
||||
const escapedPrompt = JSON.stringify(prompt);
|
||||
|
||||
return `
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
async def generate_name():
|
||||
try:
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
|
||||
prompt = ${escapedPrompt}
|
||||
|
||||
# Create a minimal client for simple text generation (no tools needed)
|
||||
client = ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model="claude-haiku-4-5",
|
||||
system_prompt="You generate very short, concise terminal names (2-3 words MAX). Output ONLY the name, nothing else. No quotes, no explanation, no preamble. Keep it as short as possible while being descriptive.",
|
||||
max_turns=1,
|
||||
)
|
||||
)
|
||||
|
||||
async with client:
|
||||
# Send the query
|
||||
await client.query(prompt)
|
||||
|
||||
# Collect response text from AssistantMessage
|
||||
response_text = ""
|
||||
async for msg in client.receive_response():
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
|
||||
if response_text:
|
||||
# Clean up the result
|
||||
name = response_text.strip()
|
||||
# Remove any quotes
|
||||
name = name.strip('"').strip("'")
|
||||
# Take first line only
|
||||
name = name.split('\\n')[0].strip()
|
||||
if name:
|
||||
print(name)
|
||||
sys.exit(0)
|
||||
|
||||
# If we get here, no valid response
|
||||
sys.exit(1)
|
||||
|
||||
except ImportError as e:
|
||||
print(f"Import error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
asyncio.run(generate_name())
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up the generated name
|
||||
*/
|
||||
private cleanName(name: string): string {
|
||||
// Remove quotes if present
|
||||
let cleaned = name.replace(/^["']|["']$/g, '');
|
||||
|
||||
// Remove any "Terminal:" or similar prefixes
|
||||
cleaned = cleaned.replace(/^(terminal|name)[:\s]*/i, '');
|
||||
|
||||
// Truncate if too long (max 30 chars for terminal names)
|
||||
if (cleaned.length > 30) {
|
||||
cleaned = cleaned.substring(0, 27) + '...';
|
||||
}
|
||||
|
||||
return cleaned.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const terminalNameGenerator = new TerminalNameGenerator();
|
||||
@@ -15,6 +15,7 @@ export interface TerminalAPI {
|
||||
sendTerminalInput: (id: string, data: string) => void;
|
||||
resizeTerminal: (id: string, cols: number, rows: number) => void;
|
||||
invokeClaudeInTerminal: (id: string, cwd?: string) => void;
|
||||
generateTerminalName: (command: string, cwd?: string) => Promise<IPCResult<string>>;
|
||||
|
||||
// Terminal Session Management
|
||||
getTerminalSessions: (projectPath: string) => Promise<IPCResult<import('../../shared/types').TerminalSession[]>>;
|
||||
@@ -81,6 +82,9 @@ export const createTerminalAPI = (): TerminalAPI => ({
|
||||
invokeClaudeInTerminal: (id: string, cwd?: string): void =>
|
||||
ipcRenderer.send(IPC_CHANNELS.TERMINAL_INVOKE_CLAUDE, id, cwd),
|
||||
|
||||
generateTerminalName: (command: string, cwd?: string): Promise<IPCResult<string>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_GENERATE_NAME, command, cwd),
|
||||
|
||||
// Terminal Session Management
|
||||
getTerminalSessions: (projectPath: string): Promise<IPCResult<import('../../shared/types').TerminalSession[]>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_GET_SESSIONS, projectPath),
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
/**
|
||||
* TaskEditDialog - Dialog for editing task title and description
|
||||
* TaskEditDialog - Dialog for editing task details
|
||||
*
|
||||
* Allows users to modify the title and description of an existing task.
|
||||
* Allows users to modify all task properties including title, description,
|
||||
* classification fields, images, and review settings.
|
||||
* Follows the same dialog pattern as TaskCreationWizard for consistency.
|
||||
*
|
||||
* Features:
|
||||
* - Pre-populates form with current task values
|
||||
* - Form validation (title and description required)
|
||||
* - Shows attached images in read-only mode (if task has images)
|
||||
* - Form validation (description required)
|
||||
* - Editable classification fields (category, priority, complexity, impact)
|
||||
* - Editable image attachments (add/remove images)
|
||||
* - Editable review settings (requireReviewBeforeCoding)
|
||||
* - Saves changes via persistUpdateTask (updates store + spec files)
|
||||
* - Prevents save when no changes have been made
|
||||
*
|
||||
@@ -21,8 +24,8 @@
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Loader2, Image as ImageIcon, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useState, useEffect, useCallback, useRef, type ClipboardEvent } from 'react';
|
||||
import { Loader2, Image as ImageIcon, ChevronDown, ChevronUp, X } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -35,9 +38,33 @@ import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Textarea } from './ui/textarea';
|
||||
import { Label } from './ui/label';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from './ui/select';
|
||||
import {
|
||||
ImageUpload,
|
||||
generateImageId,
|
||||
blobToBase64,
|
||||
createThumbnail,
|
||||
isValidImageMimeType,
|
||||
resolveFilename
|
||||
} from './ImageUpload';
|
||||
import { persistUpdateTask } from '../stores/task-store';
|
||||
import { cn } from '../lib/utils';
|
||||
import type { Task, ImageAttachment } from '../../shared/types';
|
||||
import type { Task, ImageAttachment, TaskCategory, TaskPriority, TaskComplexity, TaskImpact } from '../../shared/types';
|
||||
import {
|
||||
TASK_CATEGORY_LABELS,
|
||||
TASK_PRIORITY_LABELS,
|
||||
TASK_COMPLEXITY_LABELS,
|
||||
TASK_IMPACT_LABELS,
|
||||
MAX_IMAGES_PER_TASK,
|
||||
ALLOWED_IMAGE_TYPES_DISPLAY
|
||||
} from '../../shared/constants';
|
||||
|
||||
/**
|
||||
* Props for the TaskEditDialog component
|
||||
@@ -58,21 +85,129 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
|
||||
const [description, setDescription] = useState(task.description);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [showImages, setShowImages] = useState(false);
|
||||
const [pasteSuccess, setPasteSuccess] = useState(false);
|
||||
|
||||
// Get attached images from task metadata
|
||||
const attachedImages: ImageAttachment[] = task.metadata?.attachedImages || [];
|
||||
// Classification fields
|
||||
const [category, setCategory] = useState<TaskCategory | ''>(task.metadata?.category || '');
|
||||
const [priority, setPriority] = useState<TaskPriority | ''>(task.metadata?.priority || '');
|
||||
const [complexity, setComplexity] = useState<TaskComplexity | ''>(task.metadata?.complexity || '');
|
||||
const [impact, setImpact] = useState<TaskImpact | ''>(task.metadata?.impact || '');
|
||||
|
||||
// Image attachments
|
||||
const [images, setImages] = useState<ImageAttachment[]>(task.metadata?.attachedImages || []);
|
||||
|
||||
// Review setting
|
||||
const [requireReviewBeforeCoding, setRequireReviewBeforeCoding] = useState(
|
||||
task.metadata?.requireReviewBeforeCoding ?? false
|
||||
);
|
||||
|
||||
// Ref for the textarea to handle paste events
|
||||
const descriptionRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Reset form when task changes or dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTitle(task.title);
|
||||
setDescription(task.description);
|
||||
setCategory(task.metadata?.category || '');
|
||||
setPriority(task.metadata?.priority || '');
|
||||
setComplexity(task.metadata?.complexity || '');
|
||||
setImpact(task.metadata?.impact || '');
|
||||
setImages(task.metadata?.attachedImages || []);
|
||||
setRequireReviewBeforeCoding(task.metadata?.requireReviewBeforeCoding ?? false);
|
||||
setError(null);
|
||||
|
||||
// Auto-expand sections if they have content
|
||||
if (task.metadata?.category || task.metadata?.priority || task.metadata?.complexity || task.metadata?.impact) {
|
||||
setShowAdvanced(true);
|
||||
}
|
||||
// Auto-expand images section if task has images
|
||||
setShowImages(attachedImages.length > 0);
|
||||
setShowImages((task.metadata?.attachedImages || []).length > 0);
|
||||
setPasteSuccess(false);
|
||||
}
|
||||
}, [open, task.title, task.description, attachedImages.length]);
|
||||
}, [open, task]);
|
||||
|
||||
/**
|
||||
* Handle paste event for screenshot support
|
||||
*/
|
||||
const handlePaste = useCallback(async (e: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const clipboardItems = e.clipboardData?.items;
|
||||
if (!clipboardItems) return;
|
||||
|
||||
// Find image items in clipboard
|
||||
const imageItems: DataTransferItem[] = [];
|
||||
for (let i = 0; i < clipboardItems.length; i++) {
|
||||
const item = clipboardItems[i];
|
||||
if (item.type.startsWith('image/')) {
|
||||
imageItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// If no images, allow normal paste behavior
|
||||
if (imageItems.length === 0) return;
|
||||
|
||||
// Prevent default paste when we have images
|
||||
e.preventDefault();
|
||||
|
||||
// Check if we can add more images
|
||||
const remainingSlots = MAX_IMAGES_PER_TASK - images.length;
|
||||
if (remainingSlots <= 0) {
|
||||
setError(`Maximum of ${MAX_IMAGES_PER_TASK} images allowed`);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
// Process image items
|
||||
const newImages: ImageAttachment[] = [];
|
||||
const existingFilenames = images.map(img => img.filename);
|
||||
|
||||
for (const item of imageItems.slice(0, remainingSlots)) {
|
||||
const file = item.getAsFile();
|
||||
if (!file) continue;
|
||||
|
||||
// Validate image type
|
||||
if (!isValidImageMimeType(file.type)) {
|
||||
setError(`Invalid image type. Allowed: ${ALLOWED_IMAGE_TYPES_DISPLAY}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const dataUrl = await blobToBase64(file);
|
||||
const thumbnail = await createThumbnail(dataUrl);
|
||||
|
||||
// Generate filename for pasted images (screenshot-timestamp.ext)
|
||||
const extension = file.type.split('/')[1] || 'png';
|
||||
const baseFilename = `screenshot-${Date.now()}.${extension}`;
|
||||
const resolvedFilename = resolveFilename(baseFilename, [
|
||||
...existingFilenames,
|
||||
...newImages.map(img => img.filename)
|
||||
]);
|
||||
|
||||
newImages.push({
|
||||
id: generateImageId(),
|
||||
filename: resolvedFilename,
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
data: dataUrl.split(',')[1], // Store base64 without data URL prefix
|
||||
thumbnail
|
||||
});
|
||||
} catch {
|
||||
setError('Failed to process pasted image');
|
||||
}
|
||||
}
|
||||
|
||||
if (newImages.length > 0) {
|
||||
setImages(prev => [...prev, ...newImages]);
|
||||
// Auto-expand images section
|
||||
setShowImages(true);
|
||||
// Show success feedback
|
||||
setPasteSuccess(true);
|
||||
setTimeout(() => setPasteSuccess(false), 2000);
|
||||
}
|
||||
}, [images]);
|
||||
|
||||
const handleSave = async () => {
|
||||
// Validate input - only description is required
|
||||
@@ -84,7 +219,17 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
|
||||
// Check if anything changed
|
||||
const trimmedTitle = title.trim();
|
||||
const trimmedDescription = description.trim();
|
||||
if (trimmedTitle === task.title && trimmedDescription === task.description) {
|
||||
const hasChanges =
|
||||
trimmedTitle !== task.title ||
|
||||
trimmedDescription !== task.description ||
|
||||
category !== (task.metadata?.category || '') ||
|
||||
priority !== (task.metadata?.priority || '') ||
|
||||
complexity !== (task.metadata?.complexity || '') ||
|
||||
impact !== (task.metadata?.impact || '') ||
|
||||
requireReviewBeforeCoding !== (task.metadata?.requireReviewBeforeCoding ?? false) ||
|
||||
JSON.stringify(images) !== JSON.stringify(task.metadata?.attachedImages || []);
|
||||
|
||||
if (!hasChanges) {
|
||||
// No changes, just close
|
||||
onOpenChange(false);
|
||||
return;
|
||||
@@ -93,10 +238,20 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
// Build metadata updates
|
||||
const metadataUpdates: Partial<typeof task.metadata> = {};
|
||||
if (category) metadataUpdates.category = category;
|
||||
if (priority) metadataUpdates.priority = priority;
|
||||
if (complexity) metadataUpdates.complexity = complexity;
|
||||
if (impact) metadataUpdates.impact = impact;
|
||||
if (images.length > 0) metadataUpdates.attachedImages = images;
|
||||
metadataUpdates.requireReviewBeforeCoding = requireReviewBeforeCoding;
|
||||
|
||||
// Title is optional - if empty, it will be auto-generated by the backend
|
||||
const success = await persistUpdateTask(task.id, {
|
||||
title: trimmedTitle,
|
||||
description: trimmedDescription
|
||||
description: trimmedDescription,
|
||||
metadata: metadataUpdates
|
||||
});
|
||||
|
||||
if (success) {
|
||||
@@ -124,7 +279,7 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-foreground">Edit Task</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the task title and description. Changes will be saved to the spec files.
|
||||
Update task details including title, description, classification, images, and settings. Changes will be saved to the spec files.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -135,13 +290,18 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
|
||||
Description <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
ref={descriptionRef}
|
||||
id="edit-description"
|
||||
placeholder="Describe the feature, bug fix, or improvement..."
|
||||
placeholder="Describe the feature, bug fix, or improvement. Be as specific as possible about requirements, constraints, and expected behavior."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onPaste={handlePaste}
|
||||
rows={5}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tip: Paste screenshots directly with {navigator.platform.includes('Mac') ? '⌘V' : 'Ctrl+V'} to add reference images.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Title (Optional - Auto-generated if empty) */}
|
||||
@@ -161,71 +321,202 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Read-only Images Section (only shown if task has images) */}
|
||||
{attachedImages.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowImages(!showImages)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors',
|
||||
'w-full justify-between py-2 px-3 rounded-md hover:bg-muted/50'
|
||||
)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
Attached Images
|
||||
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded">
|
||||
{attachedImages.length}
|
||||
</span>
|
||||
</span>
|
||||
{showImages ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showImages && (
|
||||
<div className="space-y-3 p-4 rounded-lg border border-border bg-muted/30">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
These images were attached when the task was created. They cannot be modified.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{attachedImages.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="relative group rounded-lg overflow-hidden border border-border bg-background"
|
||||
>
|
||||
{image.thumbnail ? (
|
||||
<img
|
||||
src={`data:${image.mimeType};base64,${image.thumbnail}`}
|
||||
alt={image.filename}
|
||||
className="w-full h-24 object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-24 flex items-center justify-center bg-muted">
|
||||
<ImageIcon className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-2 py-1">
|
||||
<p className="text-xs text-white truncate" title={image.filename}>
|
||||
{image.filename}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
{/* Paste Success Indicator */}
|
||||
{pasteSuccess && (
|
||||
<div className="flex items-center gap-2 text-sm text-success animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
Image added successfully!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced Options Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors',
|
||||
'w-full justify-between py-2 px-3 rounded-md hover:bg-muted/50'
|
||||
)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<span>Classification (optional)</span>
|
||||
{showAdvanced ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Advanced Options */}
|
||||
{showAdvanced && (
|
||||
<div className="space-y-4 p-4 rounded-lg border border-border bg-muted/30">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Category */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-category" className="text-xs font-medium text-muted-foreground">
|
||||
Category
|
||||
</Label>
|
||||
<Select
|
||||
value={category}
|
||||
onValueChange={(value) => setCategory(value as TaskCategory)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<SelectTrigger id="edit-category" className="h-9">
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(TASK_CATEGORY_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-priority" className="text-xs font-medium text-muted-foreground">
|
||||
Priority
|
||||
</Label>
|
||||
<Select
|
||||
value={priority}
|
||||
onValueChange={(value) => setPriority(value as TaskPriority)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<SelectTrigger id="edit-priority" className="h-9">
|
||||
<SelectValue placeholder="Select priority" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(TASK_PRIORITY_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Complexity */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-complexity" className="text-xs font-medium text-muted-foreground">
|
||||
Complexity
|
||||
</Label>
|
||||
<Select
|
||||
value={complexity}
|
||||
onValueChange={(value) => setComplexity(value as TaskComplexity)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<SelectTrigger id="edit-complexity" className="h-9">
|
||||
<SelectValue placeholder="Select complexity" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(TASK_COMPLEXITY_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Impact */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-impact" className="text-xs font-medium text-muted-foreground">
|
||||
Impact
|
||||
</Label>
|
||||
<Select
|
||||
value={impact}
|
||||
onValueChange={(value) => setImpact(value as TaskImpact)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<SelectTrigger id="edit-impact" className="h-9">
|
||||
<SelectValue placeholder="Select impact" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(TASK_IMPACT_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
These labels help organize and prioritize tasks. They're optional but useful for filtering.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Images Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowImages(!showImages)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors',
|
||||
'w-full justify-between py-2 px-3 rounded-md hover:bg-muted/50'
|
||||
)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
Reference Images (optional)
|
||||
{images.length > 0 && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded">
|
||||
{images.length}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{showImages ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Image Upload Section */}
|
||||
{showImages && (
|
||||
<div className="space-y-3 p-4 rounded-lg border border-border bg-muted/30">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Attach screenshots, mockups, or diagrams to provide visual context for the AI.
|
||||
</p>
|
||||
<ImageUpload
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Review Requirement Toggle */}
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg border border-border bg-muted/30">
|
||||
<Checkbox
|
||||
id="edit-require-review"
|
||||
checked={requireReviewBeforeCoding}
|
||||
onCheckedChange={(checked) => setRequireReviewBeforeCoding(checked === true)}
|
||||
disabled={isSaving}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label
|
||||
htmlFor="edit-require-review"
|
||||
className="text-sm font-medium text-foreground cursor-pointer"
|
||||
>
|
||||
Require human review before coding
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, you'll be prompted to review the spec and implementation plan before the coding phase begins. This allows you to approve, request changes, or provide feedback.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-[var(--error-light)] border border-[var(--error)]/30 p-3 text-sm text-[var(--error)]">
|
||||
{error}
|
||||
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive">
|
||||
<X className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { X, Sparkles, TerminalSquare, ListTodo, FileDown, ChevronDown, Circle, L
|
||||
import { Button } from './ui/button';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useTerminalStore, type TerminalStatus } from '../stores/terminal-store';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import type { Task, ExecutionPhase } from '../../shared/types';
|
||||
import {
|
||||
Select,
|
||||
@@ -67,6 +68,9 @@ export function Terminal({ id, cwd, projectPath, isActive, onClose, onActivate,
|
||||
const isCreatedRef = useRef(false);
|
||||
const isMountedRef = useRef(true);
|
||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||
const commandBufferRef = useRef<string>('');
|
||||
const lastCommandRef = useRef<string>('');
|
||||
const autoNameTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Title editing state
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
@@ -77,6 +81,7 @@ export function Terminal({ id, cwd, projectPath, isActive, onClose, onActivate,
|
||||
const setClaudeMode = useTerminalStore((state) => state.setClaudeMode);
|
||||
const updateTerminal = useTerminalStore((state) => state.updateTerminal);
|
||||
const setAssociatedTask = useTerminalStore((state) => state.setAssociatedTask);
|
||||
const autoNameTerminals = useSettingsStore((state) => state.settings.autoNameTerminals);
|
||||
|
||||
// Filter tasks to only show backlog (Planning) status tasks for dropdown
|
||||
const backlogTasks = tasks.filter((t) => t.status === 'backlog');
|
||||
@@ -92,6 +97,30 @@ export function Terminal({ id, cwd, projectPath, isActive, onClose, onActivate,
|
||||
data: { type: 'terminal', terminalId: id }
|
||||
});
|
||||
|
||||
// Auto-naming function - generates terminal name based on commands
|
||||
const triggerAutoNaming = useCallback(async () => {
|
||||
// Only auto-name if setting is enabled, not in Claude mode, and we have a command
|
||||
if (!autoNameTerminals || terminal?.isClaudeMode || !lastCommandRef.current.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const command = lastCommandRef.current.trim();
|
||||
// Skip very short or common commands
|
||||
if (command.length < 2 || ['ls', 'cd', 'll', 'pwd', 'exit', 'clear'].includes(command)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.generateTerminalName(command, terminal?.cwd || cwd);
|
||||
if (result.success && result.data) {
|
||||
updateTerminal(id, { title: result.data });
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail - auto-naming is not critical
|
||||
console.debug('[Terminal] Auto-naming failed:', error);
|
||||
}
|
||||
}, [autoNameTerminals, terminal?.isClaudeMode, terminal?.cwd, cwd, id, updateTerminal]);
|
||||
|
||||
// Initialize xterm.js UI (separate from PTY creation)
|
||||
useEffect(() => {
|
||||
if (!terminalRef.current || xtermRef.current) return;
|
||||
@@ -167,6 +196,32 @@ export function Terminal({ id, cwd, projectPath, isActive, onClose, onActivate,
|
||||
// Handle terminal input - send to main process
|
||||
xterm.onData((data) => {
|
||||
window.electronAPI.sendTerminalInput(id, data);
|
||||
|
||||
// Track commands for auto-naming
|
||||
if (data === '\r' || data === '\n') {
|
||||
// Enter pressed - save the command and schedule auto-naming
|
||||
lastCommandRef.current = commandBufferRef.current;
|
||||
commandBufferRef.current = '';
|
||||
|
||||
// Clear any pending auto-naming
|
||||
if (autoNameTimeoutRef.current) {
|
||||
clearTimeout(autoNameTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Trigger auto-naming after a delay (wait for command to execute and produce output)
|
||||
autoNameTimeoutRef.current = setTimeout(() => {
|
||||
triggerAutoNaming();
|
||||
}, 1500); // 1.5 second delay
|
||||
} else if (data === '\x7f' || data === '\b') {
|
||||
// Backspace - remove last char from buffer
|
||||
commandBufferRef.current = commandBufferRef.current.slice(0, -1);
|
||||
} else if (data === '\x03') {
|
||||
// Ctrl+C - clear buffer
|
||||
commandBufferRef.current = '';
|
||||
} else if (data.charCodeAt(0) >= 32 && data.charCodeAt(0) < 127) {
|
||||
// Printable character - add to buffer
|
||||
commandBufferRef.current += data;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle resize
|
||||
@@ -346,6 +401,12 @@ export function Terminal({ id, cwd, projectPath, isActive, onClose, onActivate,
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
|
||||
// Clear auto-naming timeout
|
||||
if (autoNameTimeoutRef.current) {
|
||||
clearTimeout(autoNameTimeoutRef.current);
|
||||
autoNameTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Delay cleanup to skip StrictMode's immediate remount
|
||||
setTimeout(() => {
|
||||
if (!isMountedRef.current) {
|
||||
|
||||
@@ -80,13 +80,28 @@ export function TerminalGrid({ projectPath, onNewTaskClick }: TerminalGridProps)
|
||||
fetchSessionDates();
|
||||
}, [projectPath]);
|
||||
|
||||
// Get addRestoredTerminal from store
|
||||
const addRestoredTerminal = useTerminalStore((state) => state.addRestoredTerminal);
|
||||
|
||||
// Handle restoring sessions from a specific date
|
||||
const handleRestoreFromDate = useCallback(async (date: string) => {
|
||||
if (!projectPath || isRestoring) return;
|
||||
|
||||
setIsRestoring(true);
|
||||
try {
|
||||
// First close all existing terminals
|
||||
// First get the session data for this date (we need it after restore)
|
||||
const sessionsResult = await window.electronAPI.getTerminalSessionsForDate(date, projectPath);
|
||||
const sessionsToRestore = sessionsResult.success ? sessionsResult.data || [] : [];
|
||||
|
||||
console.log(`[TerminalGrid] Found ${sessionsToRestore.length} sessions to restore from ${date}`);
|
||||
|
||||
if (sessionsToRestore.length === 0) {
|
||||
console.log('[TerminalGrid] No sessions found for this date');
|
||||
setIsRestoring(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Close all existing terminals
|
||||
for (const terminal of terminals) {
|
||||
await window.electronAPI.destroyTerminal(terminal.id);
|
||||
removeTerminal(terminal.id);
|
||||
@@ -95,7 +110,7 @@ export function TerminalGrid({ projectPath, onNewTaskClick }: TerminalGridProps)
|
||||
// Small delay to ensure cleanup
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Restore sessions from the selected date
|
||||
// Restore sessions from the selected date (creates PTYs in main process)
|
||||
const result = await window.electronAPI.restoreTerminalSessionsFromDate(
|
||||
date,
|
||||
projectPath,
|
||||
@@ -104,9 +119,20 @@ export function TerminalGrid({ projectPath, onNewTaskClick }: TerminalGridProps)
|
||||
);
|
||||
|
||||
if (result.success && result.data) {
|
||||
console.log(`Restored ${result.data.restored} sessions from ${date}`);
|
||||
// The terminal-store's restoreTerminalSessions will be called by App.tsx
|
||||
// Or we can manually add the restored terminals
|
||||
console.log(`[TerminalGrid] Main process restored ${result.data.restored} sessions from ${date}`);
|
||||
|
||||
// Add each successfully restored session to the renderer's terminal store
|
||||
for (const sessionResult of result.data.sessions) {
|
||||
if (sessionResult.success) {
|
||||
// Find the full session data
|
||||
const fullSession = sessionsToRestore.find(s => s.id === sessionResult.id);
|
||||
if (fullSession) {
|
||||
console.log(`[TerminalGrid] Adding restored terminal to store: ${fullSession.id}`);
|
||||
addRestoredTerminal(fullSession);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh session dates to update counts
|
||||
const datesResult = await window.electronAPI.getTerminalSessionDates(projectPath);
|
||||
if (datesResult.success && datesResult.data) {
|
||||
@@ -118,7 +144,7 @@ export function TerminalGrid({ projectPath, onNewTaskClick }: TerminalGridProps)
|
||||
} finally {
|
||||
setIsRestoring(false);
|
||||
}
|
||||
}, [projectPath, terminals, removeTerminal, isRestoring]);
|
||||
}, [projectPath, terminals, removeTerminal, addRestoredTerminal, isRestoring]);
|
||||
|
||||
// Setup drag sensors
|
||||
const sensors = useSensors(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Label } from '../ui/label';
|
||||
import { Input } from '../ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import { Switch } from '../ui/switch';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { AVAILABLE_MODELS } from '../../../shared/constants';
|
||||
import type { AppSettings } from '../../../shared/types';
|
||||
@@ -56,6 +57,23 @@ export function GeneralSettings({ settings, onSettingsChange, section }: General
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between max-w-md">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="autoNameTerminals" className="text-sm font-medium text-foreground">
|
||||
AI Terminal Naming
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically name terminals based on commands (uses Haiku)
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="autoNameTerminals"
|
||||
checked={settings.autoNameTerminals}
|
||||
onCheckedChange={(checked) => onSettingsChange({ ...settings, autoNameTerminals: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -49,21 +49,23 @@ export function ProjectSelector({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2 w-full overflow-hidden">
|
||||
<Select
|
||||
value={selectedProjectId || ''}
|
||||
onValueChange={handleValueChange}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<SelectTrigger className="w-full [&_span]:truncate">
|
||||
<SelectTrigger className="w-full max-w-full overflow-hidden">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1 overflow-hidden">
|
||||
<FolderOpen className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<SelectValue placeholder="Select a project..." className="truncate min-w-0 flex-1" />
|
||||
<span className="truncate min-w-0 flex-1 text-left">
|
||||
{selectedProject?.name || 'Select a project...'}
|
||||
</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
className="min-w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"
|
||||
<SelectContent
|
||||
className="w-[var(--radix-select-trigger-width)] max-w-[280px]"
|
||||
position="popper"
|
||||
sideOffset={4}
|
||||
>
|
||||
@@ -73,18 +75,18 @@ export function ProjectSelector({
|
||||
</div>
|
||||
) : (
|
||||
projects.map((project) => (
|
||||
<SelectItem
|
||||
key={project.id}
|
||||
<SelectItem
|
||||
key={project.id}
|
||||
value={project.id}
|
||||
className="pr-8"
|
||||
className="pr-2"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full min-w-0">
|
||||
<span className="truncate flex-1" title={`${project.name} - ${project.path}`}>
|
||||
<div className="flex items-center gap-2 w-full min-w-0 max-w-full">
|
||||
<span className="truncate flex-1 min-w-0" title={`${project.name} - ${project.path}`}>
|
||||
{project.name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md hover:bg-destructive/10 transition-colors"
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded hover:bg-destructive/10 transition-colors"
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
@@ -93,7 +95,7 @@ export function ProjectSelector({
|
||||
}}
|
||||
onClick={(e) => handleRemoveProject(project.id, e)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
<Trash2 className="h-3 w-3 text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
</SelectItem>
|
||||
@@ -111,8 +113,8 @@ export function ProjectSelector({
|
||||
|
||||
{/* Project path - shown when project is selected */}
|
||||
{selectedProject && (
|
||||
<p
|
||||
className="text-xs text-muted-foreground px-1 break-words leading-relaxed"
|
||||
<p
|
||||
className="text-xs text-muted-foreground px-1 truncate"
|
||||
title={selectedProject.path}
|
||||
>
|
||||
{selectedProject.path}
|
||||
|
||||
@@ -272,11 +272,11 @@ export async function persistTaskStatus(
|
||||
}
|
||||
|
||||
/**
|
||||
* Update task title/description and persist to file
|
||||
* Update task title/description/metadata and persist to file
|
||||
*/
|
||||
export async function persistUpdateTask(
|
||||
taskId: string,
|
||||
updates: { title?: string; description?: string }
|
||||
updates: { title?: string; description?: string; metadata?: Partial<TaskMetadata> }
|
||||
): Promise<boolean> {
|
||||
const store = useTaskStore.getState();
|
||||
|
||||
@@ -289,6 +289,7 @@ export async function persistUpdateTask(
|
||||
store.updateTask(taskId, {
|
||||
title: result.data.title,
|
||||
description: result.data.description,
|
||||
metadata: result.data.metadata,
|
||||
updatedAt: new Date()
|
||||
});
|
||||
return true;
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
|
||||
/* Semantic colors */
|
||||
--color-success: var(--success);
|
||||
--color-success-foreground: var(--success-foreground);
|
||||
@@ -115,6 +118,9 @@
|
||||
--sidebar: #FFFFFF;
|
||||
--sidebar-foreground: #0B0B0F;
|
||||
|
||||
--popover: #FFFFFF;
|
||||
--popover-foreground: #0B0B0F;
|
||||
|
||||
/* Semantic colors */
|
||||
--success: #4EBE96;
|
||||
--success-foreground: #FFFFFF;
|
||||
@@ -173,6 +179,9 @@
|
||||
--sidebar: #0E0E12;
|
||||
--sidebar-foreground: #E6E6E6;
|
||||
|
||||
--popover: #1A1A1F;
|
||||
--popover-foreground: #E6E6E6;
|
||||
|
||||
/* Semantic colors - muted for dark mode */
|
||||
--success: #4EBE96;
|
||||
--success-foreground: #0B0B0F;
|
||||
|
||||
@@ -89,6 +89,7 @@ export const DEFAULT_APP_SETTINGS = {
|
||||
pythonPath: undefined as string | undefined,
|
||||
autoBuildPath: undefined as string | undefined,
|
||||
autoUpdateAutoBuild: true,
|
||||
autoNameTerminals: true,
|
||||
notifications: {
|
||||
onTaskComplete: true,
|
||||
onTaskFailed: true,
|
||||
@@ -171,6 +172,7 @@ export const IPC_CHANNELS = {
|
||||
TERMINAL_INPUT: 'terminal:input',
|
||||
TERMINAL_RESIZE: 'terminal:resize',
|
||||
TERMINAL_INVOKE_CLAUDE: 'terminal:invokeClaude',
|
||||
TERMINAL_GENERATE_NAME: 'terminal:generateName',
|
||||
|
||||
// Terminal session management
|
||||
TERMINAL_GET_SESSIONS: 'terminal:getSessions',
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface AppSettings {
|
||||
pythonPath?: string;
|
||||
autoBuildPath?: string;
|
||||
autoUpdateAutoBuild: boolean;
|
||||
autoNameTerminals: boolean;
|
||||
notifications: NotificationSettings;
|
||||
// Global API keys (used as defaults for all projects)
|
||||
globalClaudeOAuthToken?: string;
|
||||
|
||||
Reference in New Issue
Block a user