Merge branch 'v0.13.0rc' into feat/react-query

Merged latest changes from v0.13.0rc into feat/react-query while preserving
React Query migration. Key merge decisions:

- Kept React Query hooks for data fetching (useRunningAgents, useStopFeature, etc.)
- Added backlog plan handling to running-agents-view stop functionality
- Imported both SkeletonPulse and Spinner for CLI status components
- Used Spinner for refresh buttons across all settings sections
- Preserved isBacklogPlan check in agent-output-modal TaskProgressPanel
- Added handleOpenInIntegratedTerminal to worktree actions while keeping React Query mutations
This commit is contained in:
Shirone
2026-01-19 13:28:43 +01:00
387 changed files with 28102 additions and 6881 deletions

View File

@@ -185,7 +185,13 @@ export function getAuthenticatedImageUrl(
if (apiKey) {
params.set('apiKey', apiKey);
}
// Note: Session token auth relies on cookies which are sent automatically by the browser
// Web mode: also add session token as query param for image loads
// This ensures images load correctly even if cookies aren't sent (e.g., cross-origin proxy scenarios)
const sessionToken = getSessionToken();
if (sessionToken) {
params.set('token', sessionToken);
}
return `${serverUrl}/api/fs/image?${params.toString()}`;
}

View File

@@ -437,6 +437,10 @@ export interface SpecRegenerationAPI {
success: boolean;
error?: string;
}>;
sync: (projectPath: string) => Promise<{
success: boolean;
error?: string;
}>;
stop: (projectPath?: string) => Promise<{ success: boolean; error?: string }>;
status: (projectPath?: string) => Promise<{
success: boolean;
@@ -491,10 +495,12 @@ export interface AutoModeAPI {
status: (projectPath?: string) => Promise<{
success: boolean;
isRunning?: boolean;
isAutoLoopRunning?: boolean;
currentFeatureId?: string | null;
runningFeatures?: string[];
runningProjects?: string[];
runningCount?: number;
maxConcurrency?: number;
error?: string;
}>;
runFeature: (
@@ -550,6 +556,88 @@ export interface SaveImageResult {
error?: string;
}
// Notifications API interface
import type {
Notification,
StoredEvent,
StoredEventSummary,
EventHistoryFilter,
EventReplayResult,
} from '@automaker/types';
export interface NotificationsAPI {
list: (projectPath: string) => Promise<{
success: boolean;
notifications?: Notification[];
error?: string;
}>;
getUnreadCount: (projectPath: string) => Promise<{
success: boolean;
count?: number;
error?: string;
}>;
markAsRead: (
projectPath: string,
notificationId?: string
) => Promise<{
success: boolean;
notification?: Notification;
count?: number;
error?: string;
}>;
dismiss: (
projectPath: string,
notificationId?: string
) => Promise<{
success: boolean;
dismissed?: boolean;
count?: number;
error?: string;
}>;
}
// Event History API interface
export interface EventHistoryAPI {
list: (
projectPath: string,
filter?: EventHistoryFilter
) => Promise<{
success: boolean;
events?: StoredEventSummary[];
total?: number;
error?: string;
}>;
get: (
projectPath: string,
eventId: string
) => Promise<{
success: boolean;
event?: StoredEvent;
error?: string;
}>;
delete: (
projectPath: string,
eventId: string
) => Promise<{
success: boolean;
error?: string;
}>;
clear: (projectPath: string) => Promise<{
success: boolean;
cleared?: number;
error?: string;
}>;
replay: (
projectPath: string,
eventId: string,
hookIds?: string[]
) => Promise<{
success: boolean;
result?: EventReplayResult;
error?: string;
}>;
}
export interface ElectronAPI {
ping: () => Promise<string>;
getApiKey?: () => Promise<string | null>;
@@ -639,7 +727,30 @@ export interface ElectronAPI {
model?: string
) => Promise<{ success: boolean; error?: string }>;
stop: () => Promise<{ success: boolean; error?: string }>;
status: () => Promise<{ success: boolean; isRunning?: boolean; error?: string }>;
status: (projectPath: string) => Promise<{
success: boolean;
isRunning?: boolean;
savedPlan?: {
savedAt: string;
prompt: string;
model?: string;
result: {
changes: Array<{
type: 'add' | 'update' | 'delete';
featureId?: string;
feature?: Record<string, unknown>;
reason: string;
}>;
summary: string;
dependencyUpdates: Array<{
featureId: string;
removedDependencies: string[];
addedDependencies: string[];
}>;
};
} | null;
error?: string;
}>;
apply: (
projectPath: string,
plan: {
@@ -658,6 +769,7 @@ export interface ElectronAPI {
},
branchName?: string
) => Promise<{ success: boolean; appliedChanges?: string[]; error?: string }>;
clear: (projectPath: string) => Promise<{ success: boolean; error?: string }>;
onEvent: (callback: (data: unknown) => void) => () => void;
};
// Setup API surface is implemented by the main process and mirrored by HttpApiClient.
@@ -736,6 +848,8 @@ export interface ElectronAPI {
}>;
};
ideation?: IdeationAPI;
notifications?: NotificationsAPI;
eventHistory?: EventHistoryAPI;
codex?: {
getUsage: () => Promise<CodexUsageResponse>;
getModels: (refresh?: boolean) => Promise<{
@@ -1484,10 +1598,15 @@ function createMockWorktreeAPI(): WorktreeAPI {
return { success: true, worktrees: [] };
},
listAll: async (projectPath: string, includeDetails?: boolean) => {
listAll: async (
projectPath: string,
includeDetails?: boolean,
forceRefreshGitHub?: boolean
) => {
console.log('[Mock] Listing all worktrees:', {
projectPath,
includeDetails,
forceRefreshGitHub,
});
return {
success: true,
@@ -1735,6 +1854,56 @@ function createMockWorktreeAPI(): WorktreeAPI {
};
},
getAvailableTerminals: async () => {
console.log('[Mock] Getting available terminals');
return {
success: true,
result: {
terminals: [
{ id: 'iterm2', name: 'iTerm2', command: 'open -a iTerm' },
{ id: 'terminal-macos', name: 'Terminal', command: 'open -a Terminal' },
],
},
};
},
getDefaultTerminal: async () => {
console.log('[Mock] Getting default terminal');
return {
success: true,
result: {
terminalId: 'iterm2',
terminalName: 'iTerm2',
terminalCommand: 'open -a iTerm',
},
};
},
refreshTerminals: async () => {
console.log('[Mock] Refreshing available terminals');
return {
success: true,
result: {
terminals: [
{ id: 'iterm2', name: 'iTerm2', command: 'open -a iTerm' },
{ id: 'terminal-macos', name: 'Terminal', command: 'open -a Terminal' },
],
message: 'Found 2 available terminals',
},
};
},
openInExternalTerminal: async (worktreePath: string, terminalId?: string) => {
console.log('[Mock] Opening in external terminal:', worktreePath, terminalId);
return {
success: true,
result: {
message: `Opened ${worktreePath} in ${terminalId ?? 'default terminal'}`,
terminalName: terminalId ?? 'Terminal',
},
};
},
initGit: async (projectPath: string) => {
console.log('[Mock] Initializing git:', projectPath);
return {
@@ -2634,6 +2803,30 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
return { success: true };
},
sync: async (projectPath: string) => {
if (mockSpecRegenerationRunning) {
return {
success: false,
error: 'Spec sync is already running',
};
}
mockSpecRegenerationRunning = true;
console.log(`[Mock] Syncing spec for: ${projectPath}`);
// Simulate async spec sync (similar to feature generation but simpler)
setTimeout(() => {
emitSpecRegenerationEvent({
type: 'spec_regeneration_complete',
message: 'Spec synchronized successfully',
projectPath,
});
mockSpecRegenerationRunning = false;
}, 1000);
return { success: true };
},
stop: async (_projectPath?: string) => {
mockSpecRegenerationRunning = false;
mockSpecRegenerationPhase = '';
@@ -3085,7 +3278,7 @@ function createMockGitHubAPI(): GitHubAPI {
estimatedComplexity: 'moderate' as const,
},
projectPath,
model: model || 'sonnet',
model: model || 'claude-sonnet',
})
);
}, 2000);
@@ -3151,6 +3344,8 @@ export interface Project {
path: string;
lastOpened?: string;
theme?: string; // Per-project theme override (uses ThemeMode from app-store)
fontFamilySans?: string; // Per-project UI/sans font override
fontFamilyMono?: string; // Per-project code/mono font override
isFavorite?: boolean; // Pin project to top of dashboard
icon?: string; // Lucide icon name for project identification
customIconPath?: string; // Path to custom uploaded icon image in .automaker/images/

View File

@@ -32,7 +32,10 @@ import type {
CreateIdeaInput,
UpdateIdeaInput,
ConvertToFeatureOptions,
NotificationsAPI,
EventHistoryAPI,
} from './electron';
import type { EventHistoryFilter } from '@automaker/types';
import type { Message, SessionListItem } from '@/types/electron';
import type { Feature, ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store';
import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron';
@@ -153,8 +156,16 @@ const getServerUrl = (): string => {
if (typeof window !== 'undefined') {
const envUrl = import.meta.env.VITE_SERVER_URL;
if (envUrl) return envUrl;
// In web mode (not Electron), use relative URL to leverage Vite proxy
// This avoids CORS issues since requests appear same-origin
if (!window.electron) {
return '';
}
}
return 'http://localhost:3008';
// Use VITE_HOSTNAME if set, otherwise default to localhost
const hostname = import.meta.env.VITE_HOSTNAME || 'localhost';
return `http://${hostname}:3008`;
};
/**
@@ -168,8 +179,24 @@ let apiKeyInitialized = false;
let apiKeyInitPromise: Promise<void> | null = null;
// Cached session token for authentication (Web mode - explicit header auth)
// Only used in-memory after fresh login; on refresh we rely on HTTP-only cookies
// Persisted to localStorage to survive page reloads
let cachedSessionToken: string | null = null;
const SESSION_TOKEN_KEY = 'automaker:sessionToken';
// Initialize cached session token from localStorage on module load
// This ensures web mode survives page reloads with valid authentication
const initSessionToken = (): void => {
if (typeof window === 'undefined') return; // Skip in SSR
try {
cachedSessionToken = window.localStorage.getItem(SESSION_TOKEN_KEY);
} catch {
// localStorage might be disabled or unavailable
cachedSessionToken = null;
}
};
// Initialize on module load
initSessionToken();
// Get API key for Electron mode (returns cached value after initialization)
// Exported for use in WebSocket connections that need auth
@@ -189,14 +216,30 @@ export const waitForApiKeyInit = (): Promise<void> => {
// Get session token for Web mode (returns cached value after login)
export const getSessionToken = (): string | null => cachedSessionToken;
// Set session token (called after login)
// Set session token (called after login) - persists to localStorage for page reload survival
export const setSessionToken = (token: string | null): void => {
cachedSessionToken = token;
if (typeof window === 'undefined') return; // Skip in SSR
try {
if (token) {
window.localStorage.setItem(SESSION_TOKEN_KEY, token);
} else {
window.localStorage.removeItem(SESSION_TOKEN_KEY);
}
} catch {
// localStorage might be disabled; continue with in-memory cache
}
};
// Clear session token (called on logout)
export const clearSessionToken = (): void => {
cachedSessionToken = null;
if (typeof window === 'undefined') return; // Skip in SSR
try {
window.localStorage.removeItem(SESSION_TOKEN_KEY);
} catch {
// localStorage might be disabled
}
};
/**
@@ -478,6 +521,7 @@ export const verifySession = async (): Promise<boolean> => {
*/
export const checkSandboxEnvironment = async (): Promise<{
isContainerized: boolean;
skipSandboxWarning?: boolean;
error?: string;
}> => {
try {
@@ -493,7 +537,10 @@ export const checkSandboxEnvironment = async (): Promise<{
}
const data = await response.json();
return { isContainerized: data.isContainerized ?? false };
return {
isContainerized: data.isContainerized ?? false,
skipSandboxWarning: data.skipSandboxWarning ?? false,
};
} catch (error) {
logger.error('Sandbox environment check failed:', error);
return { isContainerized: false, error: 'Network error' };
@@ -514,7 +561,8 @@ type EventType =
| 'worktree:init-completed'
| 'dev-server:started'
| 'dev-server:output'
| 'dev-server:stopped';
| 'dev-server:stopped'
| 'notification:created';
/**
* Dev server log event payloads for WebSocket streaming
@@ -553,6 +601,7 @@ export interface DevServerLogsResponse {
result?: {
worktreePath: string;
port: number;
url: string;
logs: string;
startedAt: string;
};
@@ -1717,8 +1766,8 @@ export class HttpApiClient implements ElectronAPI {
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 }),
listAll: (projectPath: string, includeDetails?: boolean, forceRefreshGitHub?: boolean) =>
this.post('/api/worktree/list', { projectPath, includeDetails, forceRefreshGitHub }),
create: (projectPath: string, branchName: string, baseBranch?: string) =>
this.post('/api/worktree/create', {
projectPath,
@@ -1759,6 +1808,11 @@ export class HttpApiClient implements ElectronAPI {
getDefaultEditor: () => this.get('/api/worktree/default-editor'),
getAvailableEditors: () => this.get('/api/worktree/available-editors'),
refreshEditors: () => this.post('/api/worktree/refresh-editors', {}),
getAvailableTerminals: () => this.get('/api/worktree/available-terminals'),
getDefaultTerminal: () => this.get('/api/worktree/default-terminal'),
refreshTerminals: () => this.post('/api/worktree/refresh-terminals', {}),
openInExternalTerminal: (worktreePath: string, terminalId?: string) =>
this.post('/api/worktree/open-in-external-terminal', { worktreePath, terminalId }),
initGit: (projectPath: string) => this.post('/api/worktree/init-git', { projectPath }),
startDevServer: (projectPath: string, worktreePath: string) =>
this.post('/api/worktree/start-dev', { projectPath, worktreePath }),
@@ -1875,6 +1929,7 @@ export class HttpApiClient implements ElectronAPI {
projectPath,
maxFeatures,
}),
sync: (projectPath: string) => this.post('/api/spec-regeneration/sync', { projectPath }),
stop: (projectPath?: string) => this.post('/api/spec-regeneration/stop', { projectPath }),
status: (projectPath?: string) =>
this.get(
@@ -2171,6 +2226,9 @@ export class HttpApiClient implements ElectronAPI {
hideScrollbar: boolean;
};
worktreePanelVisible?: boolean;
showInitScriptIndicator?: boolean;
defaultDeleteBranchWithWorktree?: boolean;
autoDismissInitScriptIndicator?: boolean;
lastSelectedSessionId?: string;
};
error?: string;
@@ -2325,8 +2383,32 @@ export class HttpApiClient implements ElectronAPI {
stop: (): Promise<{ success: boolean; error?: string }> =>
this.post('/api/backlog-plan/stop', {}),
status: (): Promise<{ success: boolean; isRunning?: boolean; error?: string }> =>
this.get('/api/backlog-plan/status'),
status: (
projectPath: string
): Promise<{
success: boolean;
isRunning?: boolean;
savedPlan?: {
savedAt: string;
prompt: string;
model?: string;
result: {
changes: Array<{
type: 'add' | 'update' | 'delete';
featureId?: string;
feature?: Record<string, unknown>;
reason: string;
}>;
summary: string;
dependencyUpdates: Array<{
featureId: string;
removedDependencies: string[];
addedDependencies: string[];
}>;
};
} | null;
error?: string;
}> => this.get(`/api/backlog-plan/status?projectPath=${encodeURIComponent(projectPath)}`),
apply: (
projectPath: string,
@@ -2348,6 +2430,9 @@ export class HttpApiClient implements ElectronAPI {
): Promise<{ success: boolean; appliedChanges?: string[]; error?: string }> =>
this.post('/api/backlog-plan/apply', { projectPath, plan, branchName }),
clear: (projectPath: string): Promise<{ success: boolean; error?: string }> =>
this.post('/api/backlog-plan/clear', { projectPath }),
onEvent: (callback: (data: unknown) => void): (() => void) => {
return this.subscribeToEvent('backlog-plan:event', callback as EventCallback);
},
@@ -2413,6 +2498,43 @@ export class HttpApiClient implements ElectronAPI {
},
};
// Notifications API - project-level notifications
notifications: NotificationsAPI & {
onNotificationCreated: (callback: (notification: any) => void) => () => void;
} = {
list: (projectPath: string) => this.post('/api/notifications/list', { projectPath }),
getUnreadCount: (projectPath: string) =>
this.post('/api/notifications/unread-count', { projectPath }),
markAsRead: (projectPath: string, notificationId?: string) =>
this.post('/api/notifications/mark-read', { projectPath, notificationId }),
dismiss: (projectPath: string, notificationId?: string) =>
this.post('/api/notifications/dismiss', { projectPath, notificationId }),
onNotificationCreated: (callback: (notification: any) => void): (() => void) => {
return this.subscribeToEvent('notification:created', callback as EventCallback);
},
};
// Event History API - stored events for debugging and replay
eventHistory: EventHistoryAPI = {
list: (projectPath: string, filter?: EventHistoryFilter) =>
this.post('/api/event-history/list', { projectPath, filter }),
get: (projectPath: string, eventId: string) =>
this.post('/api/event-history/get', { projectPath, eventId }),
delete: (projectPath: string, eventId: string) =>
this.post('/api/event-history/delete', { projectPath, eventId }),
clear: (projectPath: string) => this.post('/api/event-history/clear', { projectPath }),
replay: (projectPath: string, eventId: string, hookIds?: string[]) =>
this.post('/api/event-history/replay', { projectPath, eventId, hookIds }),
};
// MCP API - Test MCP server connections and list tools
// SECURITY: Only accepts serverId, not arbitrary serverConfig, to prevent
// drive-by command execution attacks. Servers must be saved first.

View File

@@ -124,3 +124,67 @@ export const isMac =
: typeof navigator !== 'undefined' &&
(/Mac/.test(navigator.userAgent) ||
(navigator.platform ? navigator.platform.toLowerCase().includes('mac') : false));
/**
* Sanitize a string for use in data-testid attributes.
* Creates a deterministic, URL-safe identifier from any input string.
*
* Transformations:
* - Convert to lowercase
* - Replace spaces with hyphens
* - Remove all non-alphanumeric characters (except hyphens)
* - Collapse multiple consecutive hyphens into a single hyphen
* - Trim leading/trailing hyphens
*
* @param name - The string to sanitize (e.g., project name, feature title)
* @returns A sanitized string safe for CSS selectors and test IDs
*
* @example
* sanitizeForTestId("My Awesome Project!") // "my-awesome-project"
* sanitizeForTestId("test-project-123") // "test-project-123"
* sanitizeForTestId(" Foo Bar ") // "foo-bar"
*/
export function sanitizeForTestId(name: string): string {
return name
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
/**
* Generate a UUID v4 string.
*
* Uses crypto.randomUUID() when available (secure contexts: HTTPS or localhost).
* Falls back to crypto.getRandomValues() for non-secure contexts (e.g., Docker via HTTP).
*
* @returns A RFC 4122 compliant UUID v4 string (e.g., "550e8400-e29b-41d4-a716-446655440000")
*/
export function generateUUID(): string {
// Use native randomUUID if available (secure contexts: HTTPS or localhost)
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
// Fallback using crypto.getRandomValues() (works in all modern browsers, including non-secure contexts)
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
// Set version (4) and variant (RFC 4122) bits
bytes[6] = (bytes[6] & 0x0f) | 0x40; // Version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // Variant RFC 4122
// Convert to hex string with proper UUID format
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
// Last resort fallback using Math.random() - less secure but ensures functionality
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}