File size: 8,209 Bytes
1dbc34b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import type { ModelAlias, ModelProvider } from '@/store/app-store';
import {
  normalizeThinkingLevelForModel,
  normalizeReasoningEffortForModel,
  LEGACY_CLAUDE_ALIAS_MAP,
  type PhaseModelEntry,
} from '@automaker/types';

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

// Re-export getErrorMessage from @automaker/utils to maintain backward compatibility
// for components that already import it from here
// NOTE: Using subpath export to avoid pulling in Node.js-specific dependencies
// (the main @automaker/utils barrel imports modules that depend on @automaker/platform)
export { getErrorMessage } from '@automaker/utils/error-handler';

/**
 * Migrate legacy model aliases to canonical prefixed IDs.
 * Returns the canonical ID if it's a legacy alias, otherwise returns the input unchanged.
 */
export function migrateModelId(modelId: string | undefined): string | undefined {
  if (!modelId) return modelId;
  return LEGACY_CLAUDE_ALIAS_MAP[modelId as keyof typeof LEGACY_CLAUDE_ALIAS_MAP] || modelId;
}

/**
 * Normalize a model entry by ensuring thinking levels and reasoning efforts
 * are valid for the selected model.
 */
export function normalizeModelEntry(entry: PhaseModelEntry): PhaseModelEntry {
  const model = entry.model;

  return {
    model,
    providerId: entry.providerId,
    thinkingLevel: normalizeThinkingLevelForModel(model, entry.thinkingLevel),
    reasoningEffort: normalizeReasoningEffortForModel(model, entry.reasoningEffort),
  };
}

/**
 * Determine if the current model supports extended thinking controls
 * Note: This is for Claude's "thinking levels" only, not Codex's "reasoning effort"
 *
 * Rules:
 * - Claude models: support thinking (sonnet-4.5-thinking, opus-4.5-thinking, etc.)
 * - Cursor models: NO thinking controls (handled internally by Cursor CLI)
 * - Codex models: NO thinking controls (they use reasoningEffort instead)
 */
export function modelSupportsThinking(_model?: ModelAlias | string): boolean {
  if (!_model) return true;

  // Cursor models - don't show thinking controls
  if (_model.startsWith('cursor-')) {
    return false;
  }

  // Codex models - use reasoningEffort, not thinkingLevel
  if (_model.startsWith('codex-')) {
    return false;
  }

  // Bare gpt- models (legacy) - assume Codex, no thinking controls
  if (_model.startsWith('gpt-')) {
    return false;
  }

  // All Claude models support thinking
  return true;
}

/**
 * Determine the provider from a model string
 * Mirrors the logic in apps/server/src/providers/provider-factory.ts
 */
export function getProviderFromModel(model?: string): ModelProvider {
  if (!model) return 'claude';

  // Check for Cursor models (cursor- prefix)
  if (model.startsWith('cursor-') || model.startsWith('cursor:')) {
    return 'cursor';
  }

  // Check for Codex/OpenAI models (codex- prefix, gpt- prefix, or o-series)
  if (
    model.startsWith('codex-') ||
    model.startsWith('codex:') ||
    model.startsWith('gpt-') ||
    /^o\d/.test(model)
  ) {
    return 'codex';
  }

  // Default to Claude
  return 'claude';
}

/**
 * Get display name for a model
 * Handles both aliases (e.g., "sonnet") and full model IDs (e.g., "claude-sonnet-4-20250514")
 */
export function getModelDisplayName(model: ModelAlias | string): string {
  const displayNames: Record<string, string> = {
    // Claude aliases
    haiku: 'Claude Haiku',
    sonnet: 'Claude Sonnet',
    opus: 'Claude Opus',
    // Claude canonical IDs (without version suffix)
    'claude-haiku': 'Claude Haiku',
    'claude-sonnet': 'Claude Sonnet',
    'claude-opus': 'Claude Opus',
    // Claude full model IDs (returned by server)
    'claude-haiku-4-5': 'Claude Haiku',
    'claude-sonnet-4-20250514': 'Claude Sonnet',
    'claude-opus-4-6': 'Claude Opus',
    // Codex models
    'codex-gpt-5.2': 'GPT-5.2',
    'codex-gpt-5.1-codex-max': 'GPT-5.1 Codex Max',
    'codex-gpt-5.1-codex': 'GPT-5.1 Codex',
    'codex-gpt-5.1-codex-mini': 'GPT-5.1 Codex Mini',
    'codex-gpt-5.1': 'GPT-5.1',
    // Cursor models (common ones)
    'cursor-auto': 'Cursor Auto',
    'cursor-composer-1': 'Composer 1',
    'cursor-gpt-5.2': 'GPT-5.2',
    'cursor-gpt-5.1': 'GPT-5.1',
  };
  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);
}

/**
 * Detect if running on macOS.
 * Checks Electron process.platform first, then falls back to navigator APIs.
 */
export const isMac =
  typeof process !== 'undefined' && process.platform === 'darwin'
    ? true
    : 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.getRandomValues() which works in all modern browsers,
 * including 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 {
  if (typeof crypto === 'undefined' || typeof crypto.getRandomValues === 'undefined') {
    throw new Error('Cryptographically secure random number generator not available.');
  }
  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)}`;
}

/**
 * Format a date as relative time (e.g., "2 minutes ago", "3 hours ago")
 */
export function formatRelativeTime(date: Date): string {
  const now = new Date();
  const diffMs = now.getTime() - date.getTime();
  const diffSec = Math.floor(diffMs / 1000);

  if (diffSec < 0) return date.toLocaleDateString();

  const diffMin = Math.floor(diffSec / 60);
  const diffHour = Math.floor(diffMin / 60);
  const diffDay = Math.floor(diffHour / 24);

  if (diffSec < 60) return 'just now';
  if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
  if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
  if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
  return date.toLocaleDateString();
}