refactor: move from next js to vite and tanstack router

This commit is contained in:
Kacper
2025-12-17 20:11:16 +01:00
parent 9954feafd8
commit 5136c32b68
263 changed files with 11148 additions and 10276 deletions

View File

@@ -0,0 +1,247 @@
/**
* Agent Context Parser
* Extracts useful information from agent context files for display in kanban cards
*/
export interface AgentTaskInfo {
// Task list extracted from TodoWrite tool calls
todos: {
content: string;
status: "pending" | "in_progress" | "completed";
}[];
// Progress stats
toolCallCount: number;
lastToolUsed?: string;
// Phase info
currentPhase?: "planning" | "action" | "verification";
// Summary (if feature is completed)
summary?: string;
// Estimated progress percentage based on phase and tool calls
progressPercentage: number;
}
/**
* Default model used by the feature executor
*/
export const DEFAULT_MODEL = "claude-opus-4-5-20251101";
/**
* Formats a model name for display
*/
export function formatModelName(model: string): string {
if (model.includes("opus")) return "Opus 4.5";
if (model.includes("sonnet")) return "Sonnet 4.5";
if (model.includes("haiku")) return "Haiku 4.5";
return model.split("-").slice(1, 3).join(" ");
}
/**
* Extracts todos from the context content
* Looks for TodoWrite tool calls in the format:
* TodoWrite: [{"content": "...", "status": "..."}]
*/
function extractTodos(content: string): AgentTaskInfo["todos"] {
const todos: AgentTaskInfo["todos"] = [];
// Look for TodoWrite tool inputs
const todoMatches = content.matchAll(/TodoWrite.*?(?:"todos"\s*:\s*)?(\[[\s\S]*?\](?=\s*(?:\}|$|🔧|📋|⚡|✅|❌)))/g);
for (const match of todoMatches) {
try {
// Try to find JSON array in the match
const jsonStr = match[1] || match[0];
const arrayMatch = jsonStr.match(/\[[\s\S]*?\]/);
if (arrayMatch) {
const parsed = JSON.parse(arrayMatch[0]);
if (Array.isArray(parsed)) {
for (const item of parsed) {
if (item.content && item.status) {
// Check if this todo already exists (avoid duplicates)
if (!todos.some(t => t.content === item.content)) {
todos.push({
content: item.content,
status: item.status,
});
}
}
}
}
}
} catch {
// Ignore parse errors
}
}
// Also try to extract from markdown task lists
const markdownTodos = content.matchAll(/- \[([ xX])\] (.+)/g);
for (const match of markdownTodos) {
const isCompleted = match[1].toLowerCase() === "x";
const content = match[2].trim();
if (!todos.some(t => t.content === content)) {
todos.push({
content,
status: isCompleted ? "completed" : "pending",
});
}
}
return todos;
}
/**
* Counts tool calls in the content
*/
function countToolCalls(content: string): number {
const matches = content.match(/🔧\s*Tool:/g);
return matches?.length || 0;
}
/**
* Gets the last tool used
*/
function getLastToolUsed(content: string): string | undefined {
const matches = [...content.matchAll(/🔧\s*Tool:\s*(\S+)/g)];
if (matches.length > 0) {
return matches[matches.length - 1][1];
}
return undefined;
}
/**
* Determines the current phase from the content
*/
function getCurrentPhase(content: string): "planning" | "action" | "verification" | undefined {
// Find the last phase marker
const planningIndex = content.lastIndexOf("📋");
const actionIndex = content.lastIndexOf("⚡");
const verificationIndex = content.lastIndexOf("✅");
const maxIndex = Math.max(planningIndex, actionIndex, verificationIndex);
if (maxIndex === -1) return undefined;
if (maxIndex === verificationIndex) return "verification";
if (maxIndex === actionIndex) return "action";
return "planning";
}
/**
* Extracts a summary from completed feature context
*/
function extractSummary(content: string): string | undefined {
// Look for summary sections - capture everything including subsections (###)
// Stop at same-level ## sections (but not ###), or tool markers, or end
const summaryMatch = content.match(/## Summary[^\n]*\n([\s\S]*?)(?=\n## [^#]|\n🔧|$)/i);
if (summaryMatch) {
return summaryMatch[1].trim();
}
// Look for completion markers and extract surrounding text
const completionMatch = content.match(/✓ (?:Feature|Verification|Task) (?:successfully|completed|verified)[^\n]*(?:\n[^\n]{1,200})?/i);
if (completionMatch) {
return completionMatch[0].trim();
}
// Look for "What was done" type sections
const whatWasDoneMatch = content.match(/(?:What was done|Changes made|Implemented)[^\n]*\n([\s\S]*?)(?=\n## [^#]|\n🔧|$)/i);
if (whatWasDoneMatch) {
return whatWasDoneMatch[1].trim();
}
return undefined;
}
/**
* Calculates progress percentage based on phase and context
* Uses a more dynamic approach that better reflects actual progress
*/
function calculateProgress(phase: AgentTaskInfo["currentPhase"], toolCallCount: number, todos: AgentTaskInfo["todos"]): number {
// If we have todos, primarily use them for progress calculation
if (todos.length > 0) {
const completedCount = todos.filter(t => t.status === "completed").length;
const inProgressCount = todos.filter(t => t.status === "in_progress").length;
// Weight: completed = 1, in_progress = 0.5, pending = 0
const progress = ((completedCount + inProgressCount * 0.5) / todos.length) * 90;
// Add a small base amount and cap at 95%
return Math.min(5 + progress, 95);
}
// Fallback: use phase-based progress with tool call scaling
let phaseProgress = 0;
switch (phase) {
case "planning":
// Planning phase: 5-25%
phaseProgress = 5 + Math.min(toolCallCount * 1, 20);
break;
case "action":
// Action phase: 25-75% based on tool calls (logarithmic scaling)
phaseProgress = 25 + Math.min(Math.log2(toolCallCount + 1) * 10, 50);
break;
case "verification":
// Verification phase: 75-95%
phaseProgress = 75 + Math.min(toolCallCount * 0.5, 20);
break;
default:
// Starting: just use tool calls
phaseProgress = Math.min(toolCallCount * 0.5, 10);
}
return Math.min(Math.round(phaseProgress), 95);
}
/**
* Parses agent context content and extracts useful information
*/
export function parseAgentContext(content: string): AgentTaskInfo {
if (!content || !content.trim()) {
return {
todos: [],
toolCallCount: 0,
progressPercentage: 0,
};
}
const todos = extractTodos(content);
const toolCallCount = countToolCalls(content);
const lastToolUsed = getLastToolUsed(content);
const currentPhase = getCurrentPhase(content);
const summary = extractSummary(content);
const progressPercentage = calculateProgress(currentPhase, toolCallCount, todos);
return {
todos,
toolCallCount,
lastToolUsed,
currentPhase,
summary,
progressPercentage,
};
}
/**
* Quick stats for display in card badges
*/
export interface QuickStats {
toolCalls: number;
completedTasks: number;
totalTasks: number;
phase?: string;
}
/**
* Extracts quick stats from context for compact display
*/
export function getQuickStats(content: string): QuickStats {
const info = parseAgentContext(content);
return {
toolCalls: info.toolCallCount,
completedTasks: info.todos.filter(t => t.status === "completed").length,
totalTasks: info.todos.length,
phase: info.currentPhase,
};
}

2670
apps/ui/src/lib/electron.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,279 @@
/**
* File Picker Utility for Web Browsers
*
* Provides cross-platform file and directory selection using:
* 1. HTML5 webkitdirectory input - primary method (works on Windows)
* 2. File System Access API (showDirectoryPicker) - fallback for modern browsers
*
* Note: Browsers don't expose absolute file paths for security reasons.
* This implementation extracts directory information and may require
* user confirmation or server-side path resolution.
*/
/**
* Directory picker result with structure information for server-side resolution
*/
export interface DirectoryPickerResult {
directoryName: string;
sampleFiles: string[]; // Relative paths of sample files for identification
fileCount: number;
}
/**
* Opens a directory picker dialog
* @returns Promise resolving to directory information, or null if canceled
*
* Note: Browsers don't expose absolute file paths for security reasons.
* This function returns directory structure information that the server
* can use to locate the actual directory path.
*/
export async function openDirectoryPicker(): Promise<DirectoryPickerResult | null> {
// Use webkitdirectory (works on Windows and all modern browsers)
return new Promise<DirectoryPickerResult | null>((resolve) => {
let resolved = false;
const input = document.createElement("input");
input.type = "file";
input.webkitdirectory = true;
input.style.display = "none";
const cleanup = () => {
if (input.parentNode) {
document.body.removeChild(input);
}
};
let changeEventFired = false;
let focusTimeout: ReturnType<typeof setTimeout> | null = null;
const safeResolve = (value: DirectoryPickerResult | null) => {
if (!resolved) {
resolved = true;
changeEventFired = true;
if (focusTimeout) {
clearTimeout(focusTimeout);
focusTimeout = null;
}
cleanup();
resolve(value);
}
};
input.addEventListener("change", (e) => {
changeEventFired = true;
if (focusTimeout) {
clearTimeout(focusTimeout);
focusTimeout = null;
}
console.log("[FilePicker] Change event fired");
const files = input.files;
console.log("[FilePicker] Files selected:", files?.length || 0);
if (!files || files.length === 0) {
console.log("[FilePicker] No files selected");
safeResolve(null);
return;
}
const firstFile = files[0];
console.log("[FilePicker] First file:", {
name: firstFile.name,
webkitRelativePath: firstFile.webkitRelativePath,
// @ts-expect-error
path: firstFile.path,
});
// Extract directory name from webkitRelativePath
// webkitRelativePath format: "directoryName/subfolder/file.txt" or "directoryName/file.txt"
let directoryName = "Selected Directory";
// Method 1: Try to get absolute path from File object (non-standard, works in Electron/Chromium)
// @ts-expect-error - path property is non-standard but available in some browsers
if (firstFile.path) {
// @ts-expect-error
const filePath = firstFile.path as string;
console.log("[FilePicker] Found file.path:", filePath);
// Extract directory path (remove filename)
const lastSeparator = Math.max(
filePath.lastIndexOf("\\"),
filePath.lastIndexOf("/")
);
if (lastSeparator > 0) {
const absolutePath = filePath.substring(0, lastSeparator);
console.log("[FilePicker] Found absolute path:", absolutePath);
// Return as directory name for now - server can validate it directly
directoryName = absolutePath;
}
}
// Method 2: Extract directory name from webkitRelativePath
if (directoryName === "Selected Directory" && firstFile.webkitRelativePath) {
const relativePath = firstFile.webkitRelativePath;
console.log("[FilePicker] Using webkitRelativePath:", relativePath);
const pathParts = relativePath.split("/");
if (pathParts.length > 0) {
directoryName = pathParts[0]; // Top-level directory name
console.log("[FilePicker] Extracted directory name:", directoryName);
}
}
// Collect sample file paths for server-side directory matching
// Take first 10 files to identify the directory
const sampleFiles: string[] = [];
const maxSamples = 10;
for (let i = 0; i < Math.min(files.length, maxSamples); i++) {
const file = files[i];
if (file.webkitRelativePath) {
sampleFiles.push(file.webkitRelativePath);
} else if (file.name) {
sampleFiles.push(file.name);
}
}
console.log("[FilePicker] Directory info:", {
directoryName,
fileCount: files.length,
sampleFiles: sampleFiles.slice(0, 5), // Log first 5
});
safeResolve({
directoryName,
sampleFiles,
fileCount: files.length,
});
});
// Handle cancellation - but be very careful not to interfere with change event
// On Windows, the dialog might take time to process, so we wait longer
const handleFocus = () => {
// Wait longer on Windows - the dialog might take time to process
// Only resolve as canceled if change event hasn't fired after a delay
focusTimeout = setTimeout(() => {
if (!resolved && !changeEventFired && (!input.files || input.files.length === 0)) {
console.log("[FilePicker] Dialog canceled (no files after focus and no change event)");
safeResolve(null);
}
}, 2000); // Increased timeout for Windows - give it time
};
// Add to DOM temporarily
document.body.appendChild(input);
console.log("[FilePicker] Opening directory picker...");
// Try to show picker programmatically
if ("showPicker" in HTMLInputElement.prototype) {
try {
(input as any).showPicker();
console.log("[FilePicker] Using showPicker()");
} catch (error) {
console.log("[FilePicker] showPicker() failed, using click()", error);
input.click();
}
} else {
console.log("[FilePicker] Using click()");
input.click();
}
// Set up cancellation detection with longer delay
// Only add focus listener if we're not already resolved
window.addEventListener("focus", handleFocus, { once: true });
// Also handle blur as a cancellation signal (but with delay)
window.addEventListener("blur", () => {
// Dialog opened, wait for it to close
setTimeout(() => {
window.addEventListener("focus", handleFocus, { once: true });
}, 100);
}, { once: true });
});
}
/**
* Opens a file picker dialog
* @param options Optional configuration (multiple files, file types, etc.)
* @returns Promise resolving to selected file path(s), or null if canceled
*/
export async function openFilePicker(
options?: {
multiple?: boolean;
accept?: string;
}
): Promise<string | string[] | null> {
// Use standard file input (works on all browsers including Windows)
return new Promise<string | string[] | null>((resolve) => {
const input = document.createElement("input");
input.type = "file";
input.multiple = options?.multiple ?? false;
if (options?.accept) {
input.accept = options.accept;
}
input.style.display = "none";
const cleanup = () => {
if (input.parentNode) {
document.body.removeChild(input);
}
};
input.addEventListener("change", () => {
const files = input.files;
if (!files || files.length === 0) {
cleanup();
resolve(null);
return;
}
// Try to extract paths from File objects
const extractPath = (file: File): string => {
// Try to get path from File object (non-standard, but available in some browsers)
// @ts-expect-error - path property is non-standard
if (file.path) {
// @ts-expect-error
return file.path as string;
}
// Fallback to filename (server will need to resolve)
return file.name;
};
if (options?.multiple) {
const paths = Array.from(files).map(extractPath);
cleanup();
resolve(paths);
} else {
const path = extractPath(files[0]);
cleanup();
resolve(path);
}
});
// Handle window focus (user may have canceled)
const handleFocus = () => {
setTimeout(() => {
if (!input.files || input.files.length === 0) {
cleanup();
resolve(null);
}
}, 200);
};
// Add to DOM temporarily
document.body.appendChild(input);
// Try to show picker programmatically
// Note: showPicker() is available in modern browsers but TypeScript types it as void
// In practice, it may return a Promise in some implementations, but we'll handle errors via try/catch
if ("showPicker" in HTMLInputElement.prototype) {
try {
(input as any).showPicker();
} catch {
// Fallback to click if showPicker fails
input.click();
}
} else {
input.click();
}
// Set up cancellation detection
window.addEventListener("focus", handleFocus, { once: true });
});
}

View File

@@ -0,0 +1,882 @@
/**
* HTTP API Client for web mode
*
* This client provides the same API as the Electron IPC bridge,
* but communicates with the backend server via HTTP/WebSocket.
*/
import type {
ElectronAPI,
FileResult,
WriteResult,
ReaddirResult,
StatResult,
DialogResult,
SaveImageResult,
AutoModeAPI,
FeaturesAPI,
SuggestionsAPI,
SpecRegenerationAPI,
AutoModeEvent,
SuggestionsEvent,
SpecRegenerationEvent,
FeatureSuggestion,
SuggestionType,
} from "./electron";
import type { Message, SessionListItem } from "@/types/electron";
import type { Feature } from "@/store/app-store";
import type {
WorktreeAPI,
GitAPI,
ModelDefinition,
ProviderStatus,
} from "@/types/electron";
import { getGlobalFileBrowser } from "@/contexts/file-browser-context";
// Server URL - configurable via environment variable
const getServerUrl = (): string => {
if (typeof window !== "undefined") {
const envUrl = import.meta.env.VITE_SERVER_URL;
if (envUrl) return envUrl;
}
return "http://localhost:3008";
};
// Get API key from environment variable
const getApiKey = (): string | null => {
if (typeof window !== "undefined") {
return import.meta.env.VITE_AUTOMAKER_API_KEY || null;
}
return null;
};
type EventType =
| "agent:stream"
| "auto-mode:event"
| "suggestions:event"
| "spec-regeneration:event";
type EventCallback = (payload: unknown) => void;
interface EnhancePromptResult {
success: boolean;
enhancedText?: string;
error?: string;
}
/**
* HTTP API Client that implements ElectronAPI interface
*/
export class HttpApiClient implements ElectronAPI {
private serverUrl: string;
private ws: WebSocket | null = null;
private eventCallbacks: Map<EventType, Set<EventCallback>> = new Map();
private reconnectTimer: NodeJS.Timeout | null = null;
private isConnecting = false;
constructor() {
this.serverUrl = getServerUrl();
this.connectWebSocket();
}
private connectWebSocket(): void {
if (
this.isConnecting ||
(this.ws && this.ws.readyState === WebSocket.OPEN)
) {
return;
}
this.isConnecting = true;
try {
const wsUrl = this.serverUrl.replace(/^http/, "ws") + "/api/events";
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log("[HttpApiClient] WebSocket connected");
this.isConnecting = false;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
const callbacks = this.eventCallbacks.get(data.type);
if (callbacks) {
callbacks.forEach((cb) => cb(data.payload));
}
} catch (error) {
console.error(
"[HttpApiClient] Failed to parse WebSocket message:",
error
);
}
};
this.ws.onclose = () => {
console.log("[HttpApiClient] WebSocket disconnected");
this.isConnecting = false;
this.ws = null;
// Attempt to reconnect after 5 seconds
if (!this.reconnectTimer) {
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connectWebSocket();
}, 5000);
}
};
this.ws.onerror = (error) => {
console.error("[HttpApiClient] WebSocket error:", error);
this.isConnecting = false;
};
} catch (error) {
console.error("[HttpApiClient] Failed to create WebSocket:", error);
this.isConnecting = false;
}
}
private subscribeToEvent(
type: EventType,
callback: EventCallback
): () => void {
if (!this.eventCallbacks.has(type)) {
this.eventCallbacks.set(type, new Set());
}
this.eventCallbacks.get(type)!.add(callback);
// Ensure WebSocket is connected
this.connectWebSocket();
return () => {
const callbacks = this.eventCallbacks.get(type);
if (callbacks) {
callbacks.delete(callback);
}
};
}
private getHeaders(): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
const apiKey = getApiKey();
if (apiKey) {
headers["X-API-Key"] = apiKey;
}
return headers;
}
private async post<T>(endpoint: string, body?: unknown): Promise<T> {
const response = await fetch(`${this.serverUrl}${endpoint}`, {
method: "POST",
headers: this.getHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
return response.json();
}
private async get<T>(endpoint: string): Promise<T> {
const headers = this.getHeaders();
const response = await fetch(`${this.serverUrl}${endpoint}`, { headers });
return response.json();
}
private async put<T>(endpoint: string, body?: unknown): Promise<T> {
const response = await fetch(`${this.serverUrl}${endpoint}`, {
method: "PUT",
headers: this.getHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
return response.json();
}
private async httpDelete<T>(endpoint: string): Promise<T> {
const response = await fetch(`${this.serverUrl}${endpoint}`, {
method: "DELETE",
headers: this.getHeaders(),
});
return response.json();
}
// Basic operations
async ping(): Promise<string> {
const result = await this.get<{ status: string }>("/api/health");
return result.status === "ok" ? "pong" : "error";
}
async openExternalLink(
url: string
): Promise<{ success: boolean; error?: string }> {
// Open in new tab
window.open(url, "_blank", "noopener,noreferrer");
return { success: true };
}
// File picker - uses server-side file browser dialog
async openDirectory(): Promise<DialogResult> {
const fileBrowser = getGlobalFileBrowser();
if (!fileBrowser) {
console.error("File browser not initialized");
return { canceled: true, filePaths: [] };
}
const path = await fileBrowser();
if (!path) {
return { canceled: true, filePaths: [] };
}
// Validate with server
const result = await this.post<{
success: boolean;
path?: string;
error?: string;
}>("/api/fs/validate-path", { filePath: path });
if (result.success && result.path) {
return { canceled: false, filePaths: [result.path] };
}
console.error("Invalid directory:", result.error);
return { canceled: true, filePaths: [] };
}
async openFile(options?: object): Promise<DialogResult> {
const fileBrowser = getGlobalFileBrowser();
if (!fileBrowser) {
console.error("File browser not initialized");
return { canceled: true, filePaths: [] };
}
// For now, use the same directory browser (could be enhanced for file selection)
const path = await fileBrowser();
if (!path) {
return { canceled: true, filePaths: [] };
}
const result = await this.post<{ success: boolean; exists: boolean }>(
"/api/fs/exists",
{ filePath: path }
);
if (result.success && result.exists) {
return { canceled: false, filePaths: [path] };
}
console.error("File not found");
return { canceled: true, filePaths: [] };
}
// File system operations
async readFile(filePath: string): Promise<FileResult> {
return this.post("/api/fs/read", { filePath });
}
async writeFile(filePath: string, content: string): Promise<WriteResult> {
return this.post("/api/fs/write", { filePath, content });
}
async mkdir(dirPath: string): Promise<WriteResult> {
return this.post("/api/fs/mkdir", { dirPath });
}
async readdir(dirPath: string): Promise<ReaddirResult> {
return this.post("/api/fs/readdir", { dirPath });
}
async exists(filePath: string): Promise<boolean> {
const result = await this.post<{ success: boolean; exists: boolean }>(
"/api/fs/exists",
{ filePath }
);
return result.exists;
}
async stat(filePath: string): Promise<StatResult> {
return this.post("/api/fs/stat", { filePath });
}
async deleteFile(filePath: string): Promise<WriteResult> {
return this.post("/api/fs/delete", { filePath });
}
async trashItem(filePath: string): Promise<WriteResult> {
// In web mode, trash is just delete
return this.deleteFile(filePath);
}
async getPath(name: string): Promise<string> {
// Server provides data directory
if (name === "userData") {
const result = await this.get<{ dataDir: string }>(
"/api/health/detailed"
);
return result.dataDir || "/data";
}
return `/data/${name}`;
}
async saveImageToTemp(
data: string,
filename: string,
mimeType: string,
projectPath?: string
): Promise<SaveImageResult> {
return this.post("/api/fs/save-image", {
data,
filename,
mimeType,
projectPath,
});
}
async saveBoardBackground(
data: string,
filename: string,
mimeType: string,
projectPath: string
): Promise<{ success: boolean; path?: string; error?: string }> {
return this.post("/api/fs/save-board-background", {
data,
filename,
mimeType,
projectPath,
});
}
async deleteBoardBackground(
projectPath: string
): Promise<{ success: boolean; error?: string }> {
return this.post("/api/fs/delete-board-background", { projectPath });
}
// CLI checks - server-side
async checkClaudeCli(): Promise<{
success: boolean;
status?: string;
method?: string;
version?: string;
path?: string;
recommendation?: string;
installCommands?: {
macos?: string;
windows?: string;
linux?: string;
npm?: string;
};
error?: string;
}> {
return this.get("/api/setup/claude-status");
}
// Model API
model = {
getAvailable: async (): Promise<{
success: boolean;
models?: ModelDefinition[];
error?: string;
}> => {
return this.get("/api/models/available");
},
checkProviders: async (): Promise<{
success: boolean;
providers?: Record<string, ProviderStatus>;
error?: string;
}> => {
return this.get("/api/models/providers");
},
};
// Setup API
setup = {
getClaudeStatus: (): Promise<{
success: boolean;
status?: string;
installed?: boolean;
method?: string;
version?: string;
path?: string;
auth?: {
authenticated: boolean;
method: string;
hasCredentialsFile?: boolean;
hasToken?: boolean;
hasStoredOAuthToken?: boolean;
hasStoredApiKey?: boolean;
hasEnvApiKey?: boolean;
hasEnvOAuthToken?: boolean;
hasCliAuth?: boolean;
hasRecentActivity?: boolean;
};
error?: string;
}> => this.get("/api/setup/claude-status"),
installClaude: (): Promise<{
success: boolean;
message?: string;
error?: string;
}> => this.post("/api/setup/install-claude"),
authClaude: (): Promise<{
success: boolean;
token?: string;
requiresManualAuth?: boolean;
terminalOpened?: boolean;
command?: string;
error?: string;
message?: string;
output?: string;
}> => this.post("/api/setup/auth-claude"),
storeApiKey: (
provider: string,
apiKey: string
): Promise<{
success: boolean;
error?: string;
}> => this.post("/api/setup/store-api-key", { provider, apiKey }),
deleteApiKey: (
provider: string
): Promise<{
success: boolean;
error?: string;
message?: string;
}> => this.post("/api/setup/delete-api-key", { provider }),
getApiKeys: (): Promise<{
success: boolean;
hasAnthropicKey: boolean;
hasGoogleKey: boolean;
}> => this.get("/api/setup/api-keys"),
getPlatform: (): Promise<{
success: boolean;
platform: string;
arch: string;
homeDir: string;
isWindows: boolean;
isMac: boolean;
isLinux: boolean;
}> => this.get("/api/setup/platform"),
verifyClaudeAuth: (
authMethod?: "cli" | "api_key"
): Promise<{
success: boolean;
authenticated: boolean;
error?: string;
}> => this.post("/api/setup/verify-claude-auth", { authMethod }),
getGhStatus: (): Promise<{
success: boolean;
installed: boolean;
authenticated: boolean;
version: string | null;
path: string | null;
user: string | null;
error?: string;
}> => this.get("/api/setup/gh-status"),
onInstallProgress: (callback: (progress: unknown) => void) => {
return this.subscribeToEvent("agent:stream", callback);
},
onAuthProgress: (callback: (progress: unknown) => void) => {
return this.subscribeToEvent("agent:stream", callback);
},
};
// Features API
features: FeaturesAPI = {
getAll: (projectPath: string) =>
this.post("/api/features/list", { projectPath }),
get: (projectPath: string, featureId: string) =>
this.post("/api/features/get", { projectPath, featureId }),
create: (projectPath: string, feature: Feature) =>
this.post("/api/features/create", { projectPath, feature }),
update: (
projectPath: string,
featureId: string,
updates: Partial<Feature>
) => this.post("/api/features/update", { projectPath, featureId, updates }),
delete: (projectPath: string, featureId: string) =>
this.post("/api/features/delete", { projectPath, featureId }),
getAgentOutput: (projectPath: string, featureId: string) =>
this.post("/api/features/agent-output", { projectPath, featureId }),
};
// Auto Mode API
autoMode: AutoModeAPI = {
start: (projectPath: string, maxConcurrency?: number) =>
this.post("/api/auto-mode/start", { projectPath, maxConcurrency }),
stop: (projectPath: string) =>
this.post("/api/auto-mode/stop", { projectPath }),
stopFeature: (featureId: string) =>
this.post("/api/auto-mode/stop-feature", { featureId }),
status: (projectPath?: string) =>
this.post("/api/auto-mode/status", { projectPath }),
runFeature: (
projectPath: string,
featureId: string,
useWorktrees?: boolean,
worktreePath?: string
) =>
this.post("/api/auto-mode/run-feature", {
projectPath,
featureId,
useWorktrees,
worktreePath,
}),
verifyFeature: (projectPath: string, featureId: string) =>
this.post("/api/auto-mode/verify-feature", { projectPath, featureId }),
resumeFeature: (
projectPath: string,
featureId: string,
useWorktrees?: boolean
) =>
this.post("/api/auto-mode/resume-feature", {
projectPath,
featureId,
useWorktrees,
}),
contextExists: (projectPath: string, featureId: string) =>
this.post("/api/auto-mode/context-exists", { projectPath, featureId }),
analyzeProject: (projectPath: string) =>
this.post("/api/auto-mode/analyze-project", { projectPath }),
followUpFeature: (
projectPath: string,
featureId: string,
prompt: string,
imagePaths?: string[],
worktreePath?: string
) =>
this.post("/api/auto-mode/follow-up-feature", {
projectPath,
featureId,
prompt,
imagePaths,
worktreePath,
}),
commitFeature: (
projectPath: string,
featureId: string,
worktreePath?: string
) =>
this.post("/api/auto-mode/commit-feature", {
projectPath,
featureId,
worktreePath,
}),
onEvent: (callback: (event: AutoModeEvent) => void) => {
return this.subscribeToEvent(
"auto-mode:event",
callback as EventCallback
);
},
};
// Enhance Prompt API
enhancePrompt = {
enhance: (
originalText: string,
enhancementMode: string,
model?: string
): Promise<EnhancePromptResult> =>
this.post("/api/enhance-prompt", {
originalText,
enhancementMode,
model,
}),
};
// Worktree API
worktree: WorktreeAPI = {
mergeFeature: (projectPath: string, featureId: string, options?: object) =>
this.post("/api/worktree/merge", { projectPath, featureId, options }),
getInfo: (projectPath: string, featureId: string) =>
this.post("/api/worktree/info", { projectPath, featureId }),
getStatus: (projectPath: string, featureId: string) =>
this.post("/api/worktree/status", { projectPath, featureId }),
list: (projectPath: string) =>
this.post("/api/worktree/list", { projectPath }),
listAll: (projectPath: string, includeDetails?: boolean) =>
this.post("/api/worktree/list", { projectPath, includeDetails }),
create: (projectPath: string, branchName: string, baseBranch?: string) =>
this.post("/api/worktree/create", {
projectPath,
branchName,
baseBranch,
}),
delete: (
projectPath: string,
worktreePath: string,
deleteBranch?: boolean
) =>
this.post("/api/worktree/delete", {
projectPath,
worktreePath,
deleteBranch,
}),
commit: (worktreePath: string, message: string) =>
this.post("/api/worktree/commit", { worktreePath, message }),
push: (worktreePath: string, force?: boolean) =>
this.post("/api/worktree/push", { worktreePath, force }),
createPR: (worktreePath: string, options?: any) =>
this.post("/api/worktree/create-pr", { worktreePath, ...options }),
getDiffs: (projectPath: string, featureId: string) =>
this.post("/api/worktree/diffs", { projectPath, featureId }),
getFileDiff: (projectPath: string, featureId: string, filePath: string) =>
this.post("/api/worktree/file-diff", {
projectPath,
featureId,
filePath,
}),
pull: (worktreePath: string) =>
this.post("/api/worktree/pull", { worktreePath }),
checkoutBranch: (worktreePath: string, branchName: string) =>
this.post("/api/worktree/checkout-branch", { worktreePath, branchName }),
listBranches: (worktreePath: string) =>
this.post("/api/worktree/list-branches", { worktreePath }),
switchBranch: (worktreePath: string, branchName: string) =>
this.post("/api/worktree/switch-branch", { worktreePath, branchName }),
openInEditor: (worktreePath: string) =>
this.post("/api/worktree/open-in-editor", { worktreePath }),
getDefaultEditor: () => this.get("/api/worktree/default-editor"),
initGit: (projectPath: string) =>
this.post("/api/worktree/init-git", { projectPath }),
startDevServer: (projectPath: string, worktreePath: string) =>
this.post("/api/worktree/start-dev", { projectPath, worktreePath }),
stopDevServer: (worktreePath: string) =>
this.post("/api/worktree/stop-dev", { worktreePath }),
listDevServers: () => this.post("/api/worktree/list-dev-servers", {}),
};
// Git API
git: GitAPI = {
getDiffs: (projectPath: string) =>
this.post("/api/git/diffs", { projectPath }),
getFileDiff: (projectPath: string, filePath: string) =>
this.post("/api/git/file-diff", { projectPath, filePath }),
};
// Suggestions API
suggestions: SuggestionsAPI = {
generate: (projectPath: string, suggestionType?: SuggestionType) =>
this.post("/api/suggestions/generate", { projectPath, suggestionType }),
stop: () => this.post("/api/suggestions/stop"),
status: () => this.get("/api/suggestions/status"),
onEvent: (callback: (event: SuggestionsEvent) => void) => {
return this.subscribeToEvent(
"suggestions:event",
callback as EventCallback
);
},
};
// Spec Regeneration API
specRegeneration: SpecRegenerationAPI = {
create: (
projectPath: string,
projectOverview: string,
generateFeatures?: boolean,
analyzeProject?: boolean,
maxFeatures?: number
) =>
this.post("/api/spec-regeneration/create", {
projectPath,
projectOverview,
generateFeatures,
analyzeProject,
maxFeatures,
}),
generate: (
projectPath: string,
projectDefinition: string,
generateFeatures?: boolean,
analyzeProject?: boolean,
maxFeatures?: number
) =>
this.post("/api/spec-regeneration/generate", {
projectPath,
projectDefinition,
generateFeatures,
analyzeProject,
maxFeatures,
}),
generateFeatures: (projectPath: string, maxFeatures?: number) =>
this.post("/api/spec-regeneration/generate-features", {
projectPath,
maxFeatures,
}),
stop: () => this.post("/api/spec-regeneration/stop"),
status: () => this.get("/api/spec-regeneration/status"),
onEvent: (callback: (event: SpecRegenerationEvent) => void) => {
return this.subscribeToEvent(
"spec-regeneration:event",
callback as EventCallback
);
},
};
// Running Agents API
runningAgents = {
getAll: (): Promise<{
success: boolean;
runningAgents?: Array<{
featureId: string;
projectPath: string;
projectName: string;
isAutoMode: boolean;
}>;
totalCount?: number;
autoLoopRunning?: boolean;
error?: string;
}> => this.get("/api/running-agents"),
};
// Workspace API
workspace = {
getConfig: (): Promise<{
success: boolean;
configured: boolean;
workspaceDir?: string;
error?: string;
}> => this.get("/api/workspace/config"),
getDirectories: (): Promise<{
success: boolean;
directories?: Array<{ name: string; path: string }>;
error?: string;
}> => this.get("/api/workspace/directories"),
};
// Agent API
agent = {
start: (
sessionId: string,
workingDirectory?: string
): Promise<{
success: boolean;
messages?: Message[];
error?: string;
}> => this.post("/api/agent/start", { sessionId, workingDirectory }),
send: (
sessionId: string,
message: string,
workingDirectory?: string,
imagePaths?: string[],
model?: string
): Promise<{ success: boolean; error?: string }> =>
this.post("/api/agent/send", {
sessionId,
message,
workingDirectory,
imagePaths,
model,
}),
getHistory: (
sessionId: string
): Promise<{
success: boolean;
messages?: Message[];
isRunning?: boolean;
error?: string;
}> => this.post("/api/agent/history", { sessionId }),
stop: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
this.post("/api/agent/stop", { sessionId }),
clear: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
this.post("/api/agent/clear", { sessionId }),
onStream: (callback: (data: unknown) => void): (() => void) => {
return this.subscribeToEvent("agent:stream", callback as EventCallback);
},
};
// Templates API
templates = {
clone: (
repoUrl: string,
projectName: string,
parentDir: string
): Promise<{
success: boolean;
projectPath?: string;
projectName?: string;
error?: string;
}> =>
this.post("/api/templates/clone", { repoUrl, projectName, parentDir }),
};
// Sessions API
sessions = {
list: (
includeArchived?: boolean
): Promise<{
success: boolean;
sessions?: SessionListItem[];
error?: string;
}> => this.get(`/api/sessions?includeArchived=${includeArchived || false}`),
create: (
name: string,
projectPath: string,
workingDirectory?: string
): Promise<{
success: boolean;
session?: {
id: string;
name: string;
projectPath: string;
workingDirectory?: string;
createdAt: string;
updatedAt: string;
};
error?: string;
}> => this.post("/api/sessions", { name, projectPath, workingDirectory }),
update: (
sessionId: string,
name?: string,
tags?: string[]
): Promise<{ success: boolean; error?: string }> =>
this.put(`/api/sessions/${sessionId}`, { name, tags }),
archive: (
sessionId: string
): Promise<{ success: boolean; error?: string }> =>
this.post(`/api/sessions/${sessionId}/archive`, {}),
unarchive: (
sessionId: string
): Promise<{ success: boolean; error?: string }> =>
this.post(`/api/sessions/${sessionId}/unarchive`, {}),
delete: (
sessionId: string
): Promise<{ success: boolean; error?: string }> =>
this.httpDelete(`/api/sessions/${sessionId}`),
};
}
// Singleton instance
let httpApiClientInstance: HttpApiClient | null = null;
export function getHttpApiClient(): HttpApiClient {
if (!httpApiClientInstance) {
httpApiClientInstance = new HttpApiClient();
}
return httpApiClientInstance;
}

View File

@@ -0,0 +1,743 @@
/**
* Log Parser Utility
* Parses agent output into structured sections for display
*/
export type LogEntryType =
| "prompt"
| "tool_call"
| "tool_result"
| "phase"
| "error"
| "success"
| "info"
| "debug"
| "warning"
| "thinking";
export type ToolCategory = 'read' | 'edit' | 'write' | 'bash' | 'search' | 'todo' | 'task' | 'other';
const TOOL_CATEGORIES: Record<string, ToolCategory> = {
'Read': 'read',
'Edit': 'edit',
'Write': 'write',
'Bash': 'bash',
'Grep': 'search',
'Glob': 'search',
'WebSearch': 'search',
'WebFetch': 'read',
'TodoWrite': 'todo',
'Task': 'task',
'NotebookEdit': 'edit',
'KillShell': 'bash',
};
/**
* Categorizes a tool name into a predefined category
*/
export function categorizeToolName(toolName: string): ToolCategory {
return TOOL_CATEGORIES[toolName] || 'other';
}
export interface LogEntryMetadata {
toolName?: string;
toolCategory?: ToolCategory;
filePath?: string;
summary?: string;
phase?: string;
}
export interface LogEntry {
id: string;
type: LogEntryType;
title: string;
content: string;
timestamp?: string;
collapsed?: boolean;
metadata?: LogEntryMetadata;
}
/**
* Generates a deterministic ID based on content and position
* This ensures the same log entry always gets the same ID,
* preserving expanded/collapsed state when new logs stream in
*
* Uses only the first 200 characters of content to ensure stability
* even when entries are merged (which appends content at the end)
*/
const generateDeterministicId = (content: string, lineIndex: number): string => {
// Use first 200 chars to ensure stability when entries are merged
const stableContent = content.slice(0, 200);
// Simple hash function for the content
let hash = 0;
const str = stableContent + '|' + lineIndex.toString();
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return 'log_' + Math.abs(hash).toString(36);
};
/**
* Detects the type of log entry based on content patterns
*/
function detectEntryType(content: string): LogEntryType {
const trimmed = content.trim();
// Tool calls
if (trimmed.startsWith("🔧 Tool:") || trimmed.match(/^Tool:\s*/)) {
return "tool_call";
}
// Tool results / Input
if (trimmed.startsWith("Input:") || trimmed.startsWith("Result:") || trimmed.startsWith("Output:")) {
return "tool_result";
}
// Phase changes
if (
trimmed.startsWith("📋") ||
trimmed.startsWith("⚡") ||
trimmed.startsWith("✅") ||
trimmed.match(/^(Planning|Action|Verification)/i) ||
trimmed.match(/\[Phase:\s*([^\]]+)\]/) ||
trimmed.match(/Phase:\s*\w+/i)
) {
return "phase";
}
// Feature creation events
if (
trimmed.match(/\[Feature Creation\]/i) ||
trimmed.match(/Feature Creation/i) ||
trimmed.match(/Creating feature/i)
) {
return "success";
}
// Errors
if (trimmed.startsWith("❌") || trimmed.toLowerCase().includes("error:")) {
return "error";
}
// Success messages and summary sections
if (
trimmed.startsWith("✅") ||
trimmed.toLowerCase().includes("success") ||
trimmed.toLowerCase().includes("completed") ||
// Summary tags (preferred format from agent)
trimmed.startsWith("<summary>") ||
// Markdown summary headers (fallback)
trimmed.match(/^##\s+(Summary|Feature|Changes|Implementation)/i) ||
trimmed.match(/^(I've|I have) (successfully |now )?(completed|finished|implemented)/i)
) {
return "success";
}
// Warnings
if (trimmed.startsWith("⚠️") || trimmed.toLowerCase().includes("warning:")) {
return "warning";
}
// Thinking/Preparation info (be specific to avoid matching summary content)
if (
trimmed.toLowerCase().includes("ultrathink") ||
trimmed.match(/thinking level[:\s]*(low|medium|high|none|\d)/i) ||
trimmed.match(/^thinking level\s*$/i) ||
trimmed.toLowerCase().includes("estimated cost") ||
trimmed.toLowerCase().includes("estimated time") ||
trimmed.toLowerCase().includes("budget tokens") ||
trimmed.match(/thinking.*preparation/i)
) {
return "thinking";
}
// Debug info (JSON, stack traces, etc.)
if (
trimmed.startsWith("{") ||
trimmed.startsWith("[") ||
trimmed.includes("at ") ||
trimmed.match(/^\s*\d+\s*\|/)
) {
return "debug";
}
// Default to info
return "info";
}
/**
* Extracts tool name from a tool call entry
* Matches both "🔧 Tool: Name" and "Tool: Name" formats
*/
function extractToolName(content: string): string | undefined {
// Try emoji format first, then plain format
const match = content.match(/(?:🔧\s*)?Tool:\s*(\S+)/);
return match?.[1];
}
/**
* Extracts phase name from a phase entry
*/
function extractPhase(content: string): string | undefined {
if (content.includes("📋")) return "planning";
if (content.includes("⚡")) return "action";
if (content.includes("✅")) return "verification";
// Extract from [Phase: ...] format
const phaseMatch = content.match(/\[Phase:\s*([^\]]+)\]/);
if (phaseMatch) {
return phaseMatch[1].toLowerCase();
}
const match = content.match(/^(Planning|Action|Verification)/i);
return match?.[1]?.toLowerCase();
}
/**
* Extracts file path from tool input JSON
*/
function extractFilePath(content: string): string | undefined {
try {
const inputMatch = content.match(/Input:\s*([\s\S]*)/);
if (!inputMatch) return undefined;
const jsonStr = inputMatch[1].trim();
const parsed = JSON.parse(jsonStr) as Record<string, unknown>;
if (typeof parsed.file_path === 'string') return parsed.file_path;
if (typeof parsed.path === 'string') return parsed.path;
if (typeof parsed.notebook_path === 'string') return parsed.notebook_path;
return undefined;
} catch {
return undefined;
}
}
/**
* Generates a smart summary for tool calls based on the tool name and input
*/
export function generateToolSummary(toolName: string, content: string): string | undefined {
try {
// Try to parse JSON input
const inputMatch = content.match(/Input:\s*([\s\S]*)/);
if (!inputMatch) return undefined;
const jsonStr = inputMatch[1].trim();
const parsed = JSON.parse(jsonStr) as Record<string, unknown>;
switch (toolName) {
case 'Read': {
const filePath = parsed.file_path as string | undefined;
return `Reading ${filePath?.split('/').pop() || 'file'}`;
}
case 'Edit': {
const filePath = parsed.file_path as string | undefined;
const fileName = filePath?.split('/').pop() || 'file';
return `Editing ${fileName}`;
}
case 'Write': {
const filePath = parsed.file_path as string | undefined;
return `Writing ${filePath?.split('/').pop() || 'file'}`;
}
case 'Bash': {
const command = parsed.command as string | undefined;
const cmd = command?.slice(0, 50) || '';
return `Running: ${cmd}${(command?.length || 0) > 50 ? '...' : ''}`;
}
case 'Grep': {
const pattern = parsed.pattern as string | undefined;
return `Searching for "${pattern?.slice(0, 30) || ''}"`;
}
case 'Glob': {
const pattern = parsed.pattern as string | undefined;
return `Finding files: ${pattern || ''}`;
}
case 'TodoWrite': {
const todos = parsed.todos as unknown[] | undefined;
const todoCount = todos?.length || 0;
return `${todoCount} todo item${todoCount !== 1 ? 's' : ''}`;
}
case 'Task': {
const subagentType = parsed.subagent_type as string | undefined;
const description = parsed.description as string | undefined;
return `${subagentType || 'Agent'}: ${description || ''}`;
}
case 'WebSearch': {
const query = parsed.query as string | undefined;
return `Searching: "${query?.slice(0, 40) || ''}"`;
}
case 'WebFetch': {
const url = parsed.url as string | undefined;
return `Fetching: ${url?.slice(0, 40) || ''}`;
}
case 'NotebookEdit': {
const notebookPath = parsed.notebook_path as string | undefined;
return `Editing notebook: ${notebookPath?.split('/').pop() || 'notebook'}`;
}
case 'KillShell': {
return 'Terminating shell session';
}
default:
return undefined;
}
} catch {
return undefined;
}
}
/**
* Determines if an entry should be collapsed by default
*/
export function shouldCollapseByDefault(entry: LogEntry): boolean {
// Collapse if content is long
if (entry.content.length > 200) return true;
// Collapse if contains multi-line JSON (> 5 lines)
const lineCount = entry.content.split('\n').length;
if (lineCount > 5 && (entry.content.includes('{') || entry.content.includes('['))) {
return true;
}
// Collapse TodoWrite with multiple items
if (entry.metadata?.toolName === 'TodoWrite') {
try {
const inputMatch = entry.content.match(/Input:\s*([\s\S]*)/);
if (inputMatch) {
const parsed = JSON.parse(inputMatch[1].trim()) as Record<string, unknown>;
const todos = parsed.todos as unknown[] | undefined;
if (todos && todos.length > 1) return true;
}
} catch {
// Ignore parse errors
}
}
// Collapse Edit with code blocks
if (entry.metadata?.toolName === 'Edit' && entry.content.includes('old_string')) {
return true;
}
return false;
}
/**
* Generates a title for a log entry
*/
function generateTitle(type: LogEntryType, content: string): string {
switch (type) {
case "tool_call": {
const toolName = extractToolName(content);
return toolName ? `Tool Call: ${toolName}` : "Tool Call";
}
case "tool_result":
return "Tool Input/Result";
case "phase": {
const phase = extractPhase(content);
if (phase) {
// Capitalize first letter of each word
const formatted = phase.split(/\s+/).map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(" ");
return `Phase: ${formatted}`;
}
return "Phase Change";
}
case "error":
return "Error";
case "success": {
// Check if it's a summary section
if (content.startsWith("<summary>") || content.includes("<summary>")) {
return "Summary";
}
if (content.match(/^##\s+(Summary|Feature|Changes|Implementation)/i)) {
return "Summary";
}
if (content.match(/^All tasks completed/i) || content.match(/^(I've|I have) (successfully |now )?(completed|finished|implemented)/i)) {
return "Summary";
}
return "Success";
}
case "warning":
return "Warning";
case "thinking":
return "Thinking Level";
case "debug":
return "Debug Info";
case "prompt":
return "Prompt";
default:
return "Info";
}
}
/**
* Tracks bracket depth for JSON accumulation
*/
function calculateBracketDepth(line: string): { braceChange: number; bracketChange: number } {
let braceChange = 0;
let bracketChange = 0;
let inString = false;
let escapeNext = false;
for (const char of line) {
if (escapeNext) {
escapeNext = false;
continue;
}
if (char === '\\') {
escapeNext = true;
continue;
}
if (char === '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (char === '{') braceChange++;
else if (char === '}') braceChange--;
else if (char === '[') bracketChange++;
else if (char === ']') bracketChange--;
}
return { braceChange, bracketChange };
}
/**
* Parses raw log output into structured entries
*/
export function parseLogOutput(rawOutput: string): LogEntry[] {
if (!rawOutput || !rawOutput.trim()) {
return [];
}
const entries: LogEntry[] = [];
const lines = rawOutput.split("\n");
let currentEntry: Omit<LogEntry, 'id'> & { id?: string } | null = null;
let currentContent: string[] = [];
let entryStartLine = 0; // Track the starting line for deterministic ID generation
// JSON accumulation state
let inJsonAccumulation = false;
let jsonBraceDepth = 0;
let jsonBracketDepth = 0;
// Summary tag accumulation state
let inSummaryAccumulation = false;
const finalizeEntry = () => {
if (currentEntry && currentContent.length > 0) {
currentEntry.content = currentContent.join("\n").trim();
if (currentEntry.content) {
// Populate enhanced metadata for tool calls
const toolName = currentEntry.metadata?.toolName;
if (toolName && currentEntry.type === 'tool_call') {
const toolCategory = categorizeToolName(toolName);
const filePath = extractFilePath(currentEntry.content);
const summary = generateToolSummary(toolName, currentEntry.content);
currentEntry.metadata = {
...currentEntry.metadata,
toolCategory,
filePath,
summary,
};
}
// Generate deterministic ID based on content and position
const entryWithId: LogEntry = {
...currentEntry as Omit<LogEntry, 'id'>,
id: generateDeterministicId(currentEntry.content, entryStartLine),
};
entries.push(entryWithId);
}
}
currentContent = [];
inJsonAccumulation = false;
jsonBraceDepth = 0;
jsonBracketDepth = 0;
inSummaryAccumulation = false;
};
let lineIndex = 0;
for (const line of lines) {
const trimmedLine = line.trim();
// Skip empty lines at the beginning
if (!trimmedLine && !currentEntry) {
lineIndex++;
continue;
}
// If we're in JSON accumulation mode, keep accumulating until depth returns to 0
if (inJsonAccumulation) {
currentContent.push(line);
const { braceChange, bracketChange } = calculateBracketDepth(trimmedLine);
jsonBraceDepth += braceChange;
jsonBracketDepth += bracketChange;
// JSON is complete when depth returns to 0
if (jsonBraceDepth <= 0 && jsonBracketDepth <= 0) {
inJsonAccumulation = false;
jsonBraceDepth = 0;
jsonBracketDepth = 0;
}
lineIndex++;
continue;
}
// If we're in summary accumulation mode, keep accumulating until </summary>
if (inSummaryAccumulation) {
currentContent.push(line);
// Summary is complete when we see closing tag
if (trimmedLine.includes("</summary>")) {
inSummaryAccumulation = false;
// Don't finalize here - let normal flow handle it
}
lineIndex++;
continue;
}
// Detect if this line starts a new entry
const lineType = detectEntryType(trimmedLine);
const isNewEntry =
trimmedLine.startsWith("🔧") ||
trimmedLine.startsWith("📋") ||
trimmedLine.startsWith("⚡") ||
trimmedLine.startsWith("✅") ||
trimmedLine.startsWith("❌") ||
trimmedLine.startsWith("⚠️") ||
trimmedLine.startsWith("🧠") ||
trimmedLine.match(/\[Phase:\s*([^\]]+)\]/) ||
trimmedLine.match(/\[Feature Creation\]/i) ||
trimmedLine.match(/\[Tool\]/i) ||
trimmedLine.match(/\[Agent\]/i) ||
trimmedLine.match(/\[Complete\]/i) ||
trimmedLine.match(/\[ERROR\]/i) ||
trimmedLine.match(/\[Status\]/i) ||
trimmedLine.toLowerCase().includes("ultrathink preparation") ||
trimmedLine.match(/thinking level[:\s]*(low|medium|high|none|\d)/i) ||
// Summary tags (preferred format from agent)
trimmedLine.startsWith("<summary>") ||
// Agent summary sections (markdown headers - fallback)
trimmedLine.match(/^##\s+(Summary|Feature|Changes|Implementation)/i) ||
// Summary introduction lines
trimmedLine.match(/^All tasks completed/i) ||
trimmedLine.match(/^(I've|I have) (successfully |now )?(completed|finished|implemented)/i);
// Check if this is an Input: line that should trigger JSON accumulation
const isInputLine = trimmedLine.startsWith("Input:") && currentEntry?.type === "tool_call";
if (isNewEntry) {
// Finalize previous entry
finalizeEntry();
// Track starting line for deterministic ID
entryStartLine = lineIndex;
// Start new entry (ID will be generated when finalizing)
currentEntry = {
type: lineType,
title: generateTitle(lineType, trimmedLine),
content: "",
metadata: {
toolName: extractToolName(trimmedLine),
phase: extractPhase(trimmedLine),
},
};
currentContent.push(trimmedLine);
// If this is a <summary> tag, start summary accumulation mode
if (trimmedLine.startsWith("<summary>") && !trimmedLine.includes("</summary>")) {
inSummaryAccumulation = true;
}
} else if (isInputLine && currentEntry) {
// Start JSON accumulation mode
currentContent.push(trimmedLine);
// Check if there's JSON on the same line after "Input:"
const inputContent = trimmedLine.replace(/^Input:\s*/, '');
if (inputContent) {
const { braceChange, bracketChange } = calculateBracketDepth(inputContent);
jsonBraceDepth = braceChange;
jsonBracketDepth = bracketChange;
// Only enter accumulation mode if JSON is incomplete
if (jsonBraceDepth > 0 || jsonBracketDepth > 0) {
inJsonAccumulation = true;
}
} else {
// Input: line with JSON starting on next line
inJsonAccumulation = true;
}
} else if (currentEntry) {
// Continue current entry
currentContent.push(line);
// Check if this line starts a JSON block
if (trimmedLine.startsWith('{') || trimmedLine.startsWith('[')) {
const { braceChange, bracketChange } = calculateBracketDepth(trimmedLine);
if (braceChange > 0 || bracketChange > 0) {
jsonBraceDepth = braceChange;
jsonBracketDepth = bracketChange;
if (jsonBraceDepth > 0 || jsonBracketDepth > 0) {
inJsonAccumulation = true;
}
}
}
} else {
// Track starting line for deterministic ID
entryStartLine = lineIndex;
// No current entry, create a default info entry
currentEntry = {
type: "info",
title: "Info",
content: "",
};
currentContent.push(line);
}
lineIndex++;
}
// Finalize last entry
finalizeEntry();
// Merge consecutive entries of the same type if they're both debug or info
const mergedEntries = mergeConsecutiveEntries(entries);
return mergedEntries;
}
/**
* Merges consecutive entries of the same type for cleaner display
*/
function mergeConsecutiveEntries(entries: LogEntry[]): LogEntry[] {
if (entries.length <= 1) return entries;
const merged: LogEntry[] = [];
let current: LogEntry | null = null;
let mergeIndex = 0;
for (const entry of entries) {
if (
current &&
(current.type === "debug" || current.type === "info") &&
current.type === entry.type
) {
// Merge into current - regenerate ID based on merged content
current.content += "\n\n" + entry.content;
current.id = generateDeterministicId(current.content, mergeIndex);
} else {
if (current) {
merged.push(current);
}
current = { ...entry };
mergeIndex = merged.length;
}
}
if (current) {
merged.push(current);
}
return merged;
}
/**
* Gets the color classes for a log entry type
*/
export function getLogTypeColors(type: LogEntryType): {
bg: string;
border: string;
text: string;
icon: string;
badge: string;
} {
switch (type) {
case "prompt":
return {
bg: "bg-blue-500/10",
border: "border-l-blue-500",
text: "text-blue-300",
icon: "text-blue-400",
badge: "bg-blue-500/20 text-blue-300",
};
case "tool_call":
return {
bg: "bg-amber-500/10",
border: "border-l-amber-500",
text: "text-amber-300",
icon: "text-amber-400",
badge: "bg-amber-500/20 text-amber-300",
};
case "tool_result":
return {
bg: "bg-slate-500/10",
border: "border-l-slate-400",
text: "text-slate-300",
icon: "text-slate-400",
badge: "bg-slate-500/20 text-slate-300",
};
case "phase":
return {
bg: "bg-cyan-500/10",
border: "border-l-cyan-500",
text: "text-cyan-300",
icon: "text-cyan-400",
badge: "bg-cyan-500/20 text-cyan-300",
};
case "error":
return {
bg: "bg-red-500/10",
border: "border-l-red-500",
text: "text-red-300",
icon: "text-red-400",
badge: "bg-red-500/20 text-red-300",
};
case "success":
return {
bg: "bg-emerald-500/10",
border: "border-l-emerald-500",
text: "text-emerald-300",
icon: "text-emerald-400",
badge: "bg-emerald-500/20 text-emerald-300",
};
case "warning":
return {
bg: "bg-orange-500/10",
border: "border-l-orange-500",
text: "text-orange-300",
icon: "text-orange-400",
badge: "bg-orange-500/20 text-orange-300",
};
case "thinking":
return {
bg: "bg-indigo-500/10",
border: "border-l-indigo-500",
text: "text-indigo-300",
icon: "text-indigo-400",
badge: "bg-indigo-500/20 text-indigo-300",
};
case "debug":
return {
bg: "bg-primary/10",
border: "border-l-primary",
text: "text-primary",
icon: "text-primary",
badge: "bg-primary/20 text-primary",
};
default:
return {
bg: "bg-zinc-500/10",
border: "border-l-zinc-500",
text: "text-zinc-300",
icon: "text-zinc-400",
badge: "bg-zinc-500/20 text-zinc-300",
};
}
}

View File

@@ -0,0 +1,222 @@
/**
* Project initialization utilities
*
* Handles the setup of the .automaker directory structure when opening
* new or existing projects.
*/
import { getElectronAPI } from "./electron";
export interface ProjectInitResult {
success: boolean;
isNewProject: boolean;
error?: string;
createdFiles?: string[];
existingFiles?: string[];
}
/**
* Required files and directories in the .automaker directory
* Note: app_spec.txt is NOT created automatically - user must set it up via the spec editor
*/
const REQUIRED_STRUCTURE: {
directories: string[];
files: Record<string, string>;
} = {
directories: [
".automaker",
".automaker/context",
".automaker/features",
".automaker/images",
],
files: {
".automaker/categories.json": "[]",
},
};
/**
* Initializes the .automaker directory structure for a project
*
* @param projectPath - The root path of the project
* @returns Result indicating what was created or if the project was already initialized
*/
export async function initializeProject(
projectPath: string
): Promise<ProjectInitResult> {
const api = getElectronAPI();
const createdFiles: string[] = [];
const existingFiles: string[] = [];
try {
// Initialize git repository if it doesn't exist
const gitDirExists = await api.exists(`${projectPath}/.git`);
if (!gitDirExists) {
console.log("[project-init] Initializing git repository...");
try {
// Initialize git and create an initial empty commit via server route
const result = await api.worktree?.initGit(projectPath);
if (result?.success && result.result?.initialized) {
createdFiles.push(".git");
console.log("[project-init] Git repository initialized with initial commit");
} else if (result?.success && !result.result?.initialized) {
// Git already existed (shouldn't happen since we checked, but handle it)
existingFiles.push(".git");
console.log("[project-init] Git repository already exists");
} else {
console.warn("[project-init] Failed to initialize git repository:", result?.error);
}
} catch (gitError) {
console.warn("[project-init] Failed to initialize git repository:", gitError);
// Don't fail the whole initialization if git init fails
}
} else {
existingFiles.push(".git");
}
// Create all required directories
for (const dir of REQUIRED_STRUCTURE.directories) {
const fullPath = `${projectPath}/${dir}`;
await api.mkdir(fullPath);
}
// Check and create required files
for (const [relativePath, defaultContent] of Object.entries(
REQUIRED_STRUCTURE.files
)) {
const fullPath = `${projectPath}/${relativePath}`;
const exists = await api.exists(fullPath);
if (!exists) {
await api.writeFile(fullPath, defaultContent as string);
createdFiles.push(relativePath);
} else {
existingFiles.push(relativePath);
}
}
// Determine if this is a new project (no files needed to be created since features/ is empty by default)
const isNewProject =
createdFiles.length === 0 && existingFiles.length === 0;
return {
success: true,
isNewProject,
createdFiles,
existingFiles,
};
} catch (error) {
console.error("[project-init] Failed to initialize project:", error);
return {
success: false,
isNewProject: false,
error: error instanceof Error ? error.message : "Unknown error occurred",
};
}
}
/**
* Checks if a project has the required .automaker structure
*
* @param projectPath - The root path of the project
* @returns true if all required files/directories exist
*/
export async function isProjectInitialized(
projectPath: string
): Promise<boolean> {
const api = getElectronAPI();
try {
// Check all required directories exist (no files required - features/ folder is source of truth)
for (const dir of REQUIRED_STRUCTURE.directories) {
const fullPath = `${projectPath}/${dir}`;
const exists = await api.exists(fullPath);
if (!exists) {
return false;
}
}
return true;
} catch (error) {
console.error(
"[project-init] Error checking project initialization:",
error
);
return false;
}
}
/**
* Gets a summary of what needs to be initialized for a project
*
* @param projectPath - The root path of the project
* @returns List of missing files/directories
*/
export async function getProjectInitStatus(projectPath: string): Promise<{
initialized: boolean;
missingFiles: string[];
existingFiles: string[];
}> {
const api = getElectronAPI();
const missingFiles: string[] = [];
const existingFiles: string[] = [];
try {
// Check directories (no files required - features/ folder is source of truth)
for (const dir of REQUIRED_STRUCTURE.directories) {
const fullPath = `${projectPath}/${dir}`;
const exists = await api.exists(fullPath);
if (exists) {
existingFiles.push(dir);
} else {
missingFiles.push(dir);
}
}
return {
initialized: missingFiles.length === 0,
missingFiles,
existingFiles,
};
} catch (error) {
console.error("[project-init] Error getting project status:", error);
return {
initialized: false,
missingFiles: REQUIRED_STRUCTURE.directories,
existingFiles: [],
};
}
}
/**
* Checks if the app_spec.txt file exists for a project
*
* @param projectPath - The root path of the project
* @returns true if app_spec.txt exists
*/
export async function hasAppSpec(projectPath: string): Promise<boolean> {
const api = getElectronAPI();
try {
const fullPath = `${projectPath}/.automaker/app_spec.txt`;
return await api.exists(fullPath);
} catch (error) {
console.error("[project-init] Error checking app_spec.txt:", error);
return false;
}
}
/**
* Checks if the .automaker directory exists for a project
*
* @param projectPath - The root path of the project
* @returns true if .automaker directory exists
*/
export async function hasAutomakerDir(projectPath: string): Promise<boolean> {
const api = getElectronAPI();
try {
const fullPath = `${projectPath}/.automaker`;
return await api.exists(fullPath);
} catch (error) {
console.error("[project-init] Error checking .automaker dir:", error);
return false;
}
}

View File

@@ -0,0 +1,62 @@
/**
* Starter Kit Templates
*
* Define GitHub templates that users can clone when creating new projects.
*/
export interface StarterTemplate {
id: string;
name: string;
description: string;
repoUrl: string;
techStack: string[];
features: string[];
category: "fullstack" | "frontend" | "backend" | "ai" | "other";
author: string;
}
export const starterTemplates: StarterTemplate[] = [
{
id: "agentic-jumpstart",
name: "Agentic Jumpstart",
description: "A starter template for building agentic AI applications with a pre-configured development environment including database setup, Docker support, and TypeScript configuration.",
repoUrl: "https://github.com/webdevcody/agentic-jumpstart-starter-kit",
techStack: ["TypeScript", "Vite", "Drizzle ORM", "Docker", "PostCSS"],
features: [
"Pre-configured VS Code settings",
"Docker Compose setup",
"Database migrations with Drizzle",
"Type-safe development",
"Environment setup with .env.example"
],
category: "ai",
author: "webdevcody"
},
{
id: "full-stack-campus",
name: "Full Stack Campus",
description: "A feature-driven development template for building community platforms. Includes authentication, Stripe payments, file uploads, and real-time features using TanStack Start.",
repoUrl: "https://github.com/webdevcody/full-stack-campus",
techStack: ["TanStack Start", "PostgreSQL", "Drizzle ORM", "Better Auth", "Tailwind CSS", "Radix UI", "Stripe", "AWS S3/R2"],
features: [
"Community posts with comments and reactions",
"User profiles and portfolios",
"Calendar event management",
"Direct messaging",
"Member discovery directory",
"Real-time notifications",
"Tiered subscriptions (free/basic/pro)",
"File uploads with presigned URLs"
],
category: "fullstack",
author: "webdevcody"
}
];
export function getTemplateById(id: string): StarterTemplate | undefined {
return starterTemplates.find(t => t.id === id);
}
export function getTemplatesByCategory(category: StarterTemplate["category"]): StarterTemplate[] {
return starterTemplates.filter(t => t.category === category);
}

54
apps/ui/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,54 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import type { AgentModel } from "@/store/app-store"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/**
* Determine if the current model supports extended thinking controls
*/
export function modelSupportsThinking(model?: AgentModel | string): boolean {
// All Claude models support thinking
return true;
}
/**
* Get display name for a model
*/
export function getModelDisplayName(model: AgentModel | string): string {
const displayNames: Record<string, string> = {
haiku: "Claude Haiku",
sonnet: "Claude Sonnet",
opus: "Claude Opus",
};
return displayNames[model] || model;
}
/**
* Truncate a description string with ellipsis
*/
export function truncateDescription(description: string, maxLength = 50): string {
if (description.length <= maxLength) {
return description;
}
return `${description.slice(0, maxLength)}...`;
}
/**
* Normalize a file path to use forward slashes consistently.
* This is important for cross-platform compatibility (Windows uses backslashes).
*/
export function normalizePath(p: string): string {
return p.replace(/\\/g, "/");
}
/**
* Compare two paths for equality, handling cross-platform differences.
* Normalizes both paths to forward slashes before comparison.
*/
export function pathsEqual(p1: string | undefined | null, p2: string | undefined | null): boolean {
if (!p1 || !p2) return p1 === p2;
return normalizePath(p1) === normalizePath(p2);
}