diff --git a/src/__tests__/main/cue/cue-concurrency.test.ts b/src/__tests__/main/cue/cue-concurrency.test.ts index 6366f945fd..ea45e74dd0 100644 --- a/src/__tests__/main/cue/cue-concurrency.test.ts +++ b/src/__tests__/main/cue/cue-concurrency.test.ts @@ -28,6 +28,16 @@ vi.mock('../../../main/cue/cue-file-watcher', () => ({ createCueFileWatcher: (...args: unknown[]) => mockCreateCueFileWatcher(args[0]), })); +// Mock the database +vi.mock('../../../main/cue/cue-db', () => ({ + initCueDb: vi.fn(), + closeCueDb: vi.fn(), + pruneCueEvents: vi.fn(), + isCueDbReady: () => true, + recordCueEvent: vi.fn(), + updateCueEventStatus: vi.fn(), +})); + // Mock crypto vi.mock('crypto', () => ({ randomUUID: vi.fn(() => `uuid-${Math.random().toString(36).slice(2, 8)}`), diff --git a/src/__tests__/main/cue/cue-engine.test.ts b/src/__tests__/main/cue/cue-engine.test.ts index fedfeb388e..873a6ff1c5 100644 --- a/src/__tests__/main/cue/cue-engine.test.ts +++ b/src/__tests__/main/cue/cue-engine.test.ts @@ -42,6 +42,19 @@ vi.mock('../../../main/cue/cue-task-scanner', () => ({ createCueTaskScanner: (...args: unknown[]) => mockCreateCueTaskScanner(args[0]), })); +// Mock the database +const mockInitCueDb = vi.fn(); +const mockCloseCueDb = vi.fn(); +const mockPruneCueEvents = vi.fn(); +vi.mock('../../../main/cue/cue-db', () => ({ + initCueDb: (...args: unknown[]) => mockInitCueDb(...args), + closeCueDb: () => mockCloseCueDb(), + pruneCueEvents: (...args: unknown[]) => mockPruneCueEvents(...args), + isCueDbReady: () => true, + recordCueEvent: vi.fn(), + updateCueEventStatus: vi.fn(), +})); + // Mock crypto vi.mock('crypto', () => ({ randomUUID: vi.fn(() => `uuid-${Math.random().toString(36).slice(2, 8)}`), @@ -113,6 +126,67 @@ describe('CueEngine', () => { expect(deps.onLog).toHaveBeenCalledWith('cue', expect.stringContaining('started')); expect(deps.onLog).toHaveBeenCalledWith('cue', expect.stringContaining('stopped')); }); + + it('does not enable when initCueDb throws', () => { + mockInitCueDb.mockImplementation(() => { + throw new Error('DB corrupted'); + }); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + expect(engine.isEnabled()).toBe(false); + }); + + it('logs error when initCueDb throws', () => { + mockInitCueDb.mockImplementation(() => { + throw new Error('DB corrupted'); + }); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + expect(deps.onLog).toHaveBeenCalledWith( + 'error', + expect.stringContaining('Failed to initialize Cue database') + ); + }); + + it('does not initialize sessions when initCueDb throws', () => { + mockInitCueDb.mockImplementation(() => { + throw new Error('DB corrupted'); + }); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + expect(mockLoadCueConfig).not.toHaveBeenCalled(); + }); + + it('does not start heartbeat when initCueDb throws', () => { + mockInitCueDb.mockImplementation(() => { + throw new Error('DB corrupted'); + }); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + // Engine is not enabled, so getStatus should return empty + expect(engine.getStatus()).toEqual([]); + }); + + it('can retry start after DB failure', () => { + mockInitCueDb + .mockImplementationOnce(() => { + throw new Error('DB corrupted'); + }) + .mockImplementation(() => {}); + mockLoadCueConfig.mockReturnValue(null); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + + engine.start(); + expect(engine.isEnabled()).toBe(false); + + engine.start(); + expect(engine.isEnabled()).toBe(true); + }); }); describe('session initialization', () => { diff --git a/src/__tests__/main/cue/cue-event-factory.test.ts b/src/__tests__/main/cue/cue-event-factory.test.ts new file mode 100644 index 0000000000..e15b35eb47 --- /dev/null +++ b/src/__tests__/main/cue/cue-event-factory.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { createCueEvent } from '../../../main/cue/cue-types'; + +describe('createCueEvent', () => { + it('returns an object with all 5 CueEvent fields', () => { + const event = createCueEvent('time.heartbeat', 'my-trigger', { foo: 'bar' }); + expect(event).toHaveProperty('id'); + expect(event).toHaveProperty('type'); + expect(event).toHaveProperty('timestamp'); + expect(event).toHaveProperty('triggerName'); + expect(event).toHaveProperty('payload'); + }); + + it('generates a valid UUID for id', () => { + const event = createCueEvent('file.changed', 'watcher'); + expect(event.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + }); + + it('generates a valid ISO timestamp', () => { + const event = createCueEvent('time.scheduled', 'daily-check'); + const parsed = new Date(event.timestamp).getTime(); + expect(Number.isNaN(parsed)).toBe(false); + expect(event.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('sets type to the provided event type', () => { + const event = createCueEvent('github.pull_request', 'pr-watcher'); + expect(event.type).toBe('github.pull_request'); + }); + + it('sets triggerName to the provided value', () => { + const event = createCueEvent('task.pending', 'task-scanner'); + expect(event.triggerName).toBe('task-scanner'); + }); + + it('defaults payload to empty object when omitted', () => { + const event = createCueEvent('time.heartbeat', 'heartbeat'); + expect(event.payload).toEqual({}); + }); + + it('includes provided payload values', () => { + const payload = { interval_minutes: 30, reconciled: true }; + const event = createCueEvent('time.heartbeat', 'heartbeat', payload); + expect(event.payload).toEqual(payload); + }); + + it('generates a unique id on each call', () => { + const event1 = createCueEvent('time.heartbeat', 'a'); + const event2 = createCueEvent('time.heartbeat', 'a'); + expect(event1.id).not.toBe(event2.id); + }); +}); diff --git a/src/__tests__/main/cue/cue-sleep-wake.test.ts b/src/__tests__/main/cue/cue-sleep-wake.test.ts index 00f47ad6e8..268c14f7fc 100644 --- a/src/__tests__/main/cue/cue-sleep-wake.test.ts +++ b/src/__tests__/main/cue/cue-sleep-wake.test.ts @@ -75,6 +75,8 @@ describe('CueEngine sleep/wake detection', () => { beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); + // Reset mockInitCueDb to a no-op (clearAllMocks doesn't reset mockImplementation) + mockInitCueDb.mockReset(); mockWatchCueYaml.mockReturnValue(vi.fn()); mockLoadCueConfig.mockReturnValue(createMockConfig()); mockGetLastHeartbeat.mockReturnValue(null); @@ -229,13 +231,12 @@ describe('CueEngine sleep/wake detection', () => { // Should not throw expect(() => engine.start()).not.toThrow(); - // Should log the warning + // Should log the error and not enable the engine expect(deps.onLog).toHaveBeenCalledWith( - 'warn', + 'error', expect.stringContaining('Failed to initialize Cue database') ); - - engine.stop(); + expect(engine.isEnabled()).toBe(false); }); it('should handle heartbeat read failure gracefully during sleep detection', () => { diff --git a/src/__tests__/renderer/hooks/useCue.test.ts b/src/__tests__/renderer/hooks/useCue.test.ts index ce3f4459b2..cbac60b359 100644 --- a/src/__tests__/renderer/hooks/useCue.test.ts +++ b/src/__tests__/renderer/hooks/useCue.test.ts @@ -231,11 +231,49 @@ describe('useCue', () => { }); }); + describe('error state', () => { + it('error is null on successful refresh', async () => { + const { result } = await renderAndSettle(); + expect(result.current.error).toBeNull(); + }); + + it('error is set when refresh fails', async () => { + mockGetStatus.mockRejectedValue(new Error('Network error')); + const { result } = await renderAndSettle(); + expect(result.current.error).toBe('Network error'); + }); + + it('error clears on successful retry', async () => { + mockGetStatus.mockRejectedValue(new Error('Network error')); + const { result } = await renderAndSettle(); + expect(result.current.error).toBe('Network error'); + + mockGetStatus.mockResolvedValue([]); + await act(async () => { + await result.current.refresh(); + }); + expect(result.current.error).toBeNull(); + }); + + it('error captures message from Error objects', async () => { + mockGetStatus.mockRejectedValue(new Error('IPC channel closed')); + const { result } = await renderAndSettle(); + expect(result.current.error).toBe('IPC channel closed'); + }); + + it('error uses fallback for non-Error rejections', async () => { + mockGetStatus.mockRejectedValue('string rejection'); + const { result } = await renderAndSettle(); + expect(result.current.error).toBe('Failed to fetch Cue status'); + }); + }); + describe('return value shape', () => { it('should return all expected properties', async () => { const { result } = await renderAndSettle(); expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); expect(Array.isArray(result.current.sessions)).toBe(true); expect(Array.isArray(result.current.activeRuns)).toBe(true); expect(Array.isArray(result.current.activityLog)).toBe(true); diff --git a/src/main/cue/cue-engine.ts b/src/main/cue/cue-engine.ts index 7ca5634e79..94e32de022 100644 --- a/src/main/cue/cue-engine.ts +++ b/src/main/cue/cue-engine.ts @@ -13,17 +13,19 @@ import * as crypto from 'crypto'; import type { MainLogLevel } from '../../shared/logger-types'; import type { SessionInfo } from '../../shared/types'; -import type { - AgentCompletionData, - CueConfig, - CueEvent, - CueGraphSession, - CueRunResult, - CueSessionStatus, - CueSettings, - CueSubscription, +import { + createCueEvent, + DEFAULT_CUE_SETTINGS, + type AgentCompletionData, + type CueConfig, + type CueEvent, + type CueGraphSession, + type CueRunResult, + type CueSessionStatus, + type CueSettings, + type CueSubscription, } from './cue-types'; -import { DEFAULT_CUE_SETTINGS } from './cue-types'; +import { captureException } from '../utils/sentry'; import { loadCueConfig, watchCueYaml } from './cue-yaml-loader'; import { matchesFilter, describeFilter } from './cue-filter'; import { @@ -140,17 +142,24 @@ export class CueEngine { start(): void { if (this.enabled) return; - this.enabled = true; - this.deps.onLog('cue', '[CUE] Engine started'); - - // Initialize Cue database and prune old events + // Initialize Cue database and prune old events — bail if this fails try { initCueDb((level, msg) => this.deps.onLog(level as MainLogLevel, msg)); pruneCueEvents(EVENT_PRUNE_AGE_MS); } catch (error) { - this.deps.onLog('warn', `[CUE] Failed to initialize Cue database: ${error}`); + this.deps.onLog( + 'error', + `[CUE] Failed to initialize Cue database — engine will not start: ${error}` + ); + captureException(error instanceof Error ? error : new Error(String(error)), { + extra: { operation: 'cue.dbInit' }, + }); + return; } + this.enabled = true; + this.deps.onLog('cue', '[CUE] Engine started'); + const sessions = this.deps.getSessions(); for (const session of sessions) { this.initSession(session); @@ -402,13 +411,7 @@ export class CueEngine { if (sub.name !== subscriptionName) continue; if (sub.agent_id && sub.agent_id !== sessionId) continue; - const event: CueEvent = { - id: crypto.randomUUID(), - type: sub.event, - timestamp: new Date().toISOString(), - triggerName: sub.name, - payload: { manual: true }, - }; + const event = createCueEvent(sub.event, sub.name, { manual: true }); this.deps.onLog('cue', `[CUE] "${sub.name}" manually triggered`); state.lastTriggered = event.timestamp; @@ -491,22 +494,16 @@ export class CueEngine { if (sources.length === 1) { // Single source — fire immediately - const event: CueEvent = { - id: crypto.randomUUID(), - type: 'agent.completed', - timestamp: new Date().toISOString(), - triggerName: sub.name, - payload: { - sourceSession: completingName, - sourceSessionId: sessionId, - status: completionData?.status ?? 'completed', - exitCode: completionData?.exitCode ?? null, - durationMs: completionData?.durationMs ?? 0, - sourceOutput: (completionData?.stdout ?? '').slice(-SOURCE_OUTPUT_MAX_CHARS), - outputTruncated: (completionData?.stdout ?? '').length > SOURCE_OUTPUT_MAX_CHARS, - triggeredBy: completionData?.triggeredBy, - }, - }; + const event = createCueEvent('agent.completed', sub.name, { + sourceSession: completingName, + sourceSessionId: sessionId, + status: completionData?.status ?? 'completed', + exitCode: completionData?.exitCode ?? null, + durationMs: completionData?.durationMs ?? 0, + sourceOutput: (completionData?.stdout ?? '').slice(-SOURCE_OUTPUT_MAX_CHARS), + outputTruncated: (completionData?.stdout ?? '').length > SOURCE_OUTPUT_MAX_CHARS, + triggeredBy: completionData?.triggeredBy, + }); // Check payload filter if (sub.filter && !matchesFilter(event.payload, sub.filter)) { diff --git a/src/main/cue/cue-fan-in-tracker.ts b/src/main/cue/cue-fan-in-tracker.ts index d59ad892a6..d9d62ae628 100644 --- a/src/main/cue/cue-fan-in-tracker.ts +++ b/src/main/cue/cue-fan-in-tracker.ts @@ -7,10 +7,15 @@ * (or on timeout, depending on the timeout_on_fail setting). */ -import * as crypto from 'crypto'; import type { MainLogLevel } from '../../shared/logger-types'; import type { SessionInfo } from '../../shared/types'; -import type { AgentCompletionData, CueEvent, CueSettings, CueSubscription } from './cue-types'; +import { + createCueEvent, + type AgentCompletionData, + type CueEvent, + type CueSettings, + type CueSubscription, +} from './cue-types'; export const SOURCE_OUTPUT_MAX_CHARS = 5000; @@ -80,20 +85,14 @@ export function createCueFanInTracker(deps: CueFanInDeps): CueFanInTracker { const completions = [...tracker.values()]; fanInTrackers.delete(key); - const event: CueEvent = { - id: crypto.randomUUID(), - type: 'agent.completed', - timestamp: new Date().toISOString(), - triggerName: sub.name, - payload: { - completedSessions: completions.map((c) => c.sessionId), - timedOutSessions: timedOutSources, - sourceSession: completions.map((c) => c.sessionName).join(', '), - sourceOutput: completions.map((c) => c.output).join('\n---\n'), - outputTruncated: completions.some((c) => c.truncated), - partial: true, - }, - }; + const event = createCueEvent('agent.completed', sub.name, { + completedSessions: completions.map((c) => c.sessionId), + timedOutSessions: timedOutSources, + sourceSession: completions.map((c) => c.sessionName).join(', '), + sourceOutput: completions.map((c) => c.output).join('\n---\n'), + outputTruncated: completions.some((c) => c.truncated), + partial: true, + }); const maxChainDepth = completions.length > 0 ? Math.max(...completions.map((c) => c.chainDepth)) : 0; deps.onLog( @@ -166,18 +165,12 @@ export function createCueFanInTracker(deps: CueFanInDeps): CueFanInTracker { fanInTrackers.delete(key); const completions = [...tracker.values()]; - const event: CueEvent = { - id: crypto.randomUUID(), - type: 'agent.completed', - timestamp: new Date().toISOString(), - triggerName: sub.name, - payload: { - completedSessions: completions.map((c) => c.sessionId), - sourceSession: completions.map((c) => c.sessionName).join(', '), - sourceOutput: completions.map((c) => c.output).join('\n---\n'), - outputTruncated: completions.some((c) => c.truncated), - }, - }; + const event = createCueEvent('agent.completed', sub.name, { + completedSessions: completions.map((c) => c.sessionId), + sourceSession: completions.map((c) => c.sessionName).join(', '), + sourceOutput: completions.map((c) => c.output).join('\n---\n'), + outputTruncated: completions.some((c) => c.truncated), + }); const maxChainDepth = completions.length > 0 ? Math.max(...completions.map((c) => c.chainDepth)) : 0; deps.onLog('cue', `[CUE] "${sub.name}" triggered (agent.completed, fan-in complete)`); diff --git a/src/main/cue/cue-file-watcher.ts b/src/main/cue/cue-file-watcher.ts index 78b3a11f2f..0a39623138 100644 --- a/src/main/cue/cue-file-watcher.ts +++ b/src/main/cue/cue-file-watcher.ts @@ -6,9 +6,8 @@ */ import * as path from 'path'; -import * as crypto from 'crypto'; import * as chokidar from 'chokidar'; -import type { CueEvent } from './cue-types'; +import { createCueEvent, type CueEvent } from './cue-types'; export interface CueFileWatcherConfig { watchGlob: string; @@ -45,19 +44,13 @@ export function createCueFileWatcher(config: CueFileWatcherConfig): () => void { debounceTimers.delete(filePath); const absolutePath = path.resolve(projectRoot, filePath); - const event: CueEvent = { - id: crypto.randomUUID(), - type: 'file.changed', - timestamp: new Date().toISOString(), - triggerName, - payload: { - path: absolutePath, - filename: path.basename(filePath), - directory: path.dirname(absolutePath), - extension: path.extname(filePath), - changeType, - }, - }; + const event = createCueEvent('file.changed', triggerName, { + path: absolutePath, + filename: path.basename(filePath), + directory: path.dirname(absolutePath), + extension: path.extname(filePath), + changeType, + }); onEvent(event); }, debounceMs) diff --git a/src/main/cue/cue-github-poller.ts b/src/main/cue/cue-github-poller.ts index 8f65e7f971..efae2386d3 100644 --- a/src/main/cue/cue-github-poller.ts +++ b/src/main/cue/cue-github-poller.ts @@ -6,8 +6,7 @@ */ import { execFile as cpExecFile } from 'child_process'; -import * as crypto from 'crypto'; -import type { CueEvent } from './cue-types'; +import { createCueEvent, type CueEvent } from './cue-types'; import { isGitHubItemSeen, markGitHubItemSeen, hasAnyGitHubSeen, pruneGitHubSeen } from './cue-db'; function execFileAsync( @@ -135,29 +134,23 @@ export function createCueGitHubPoller(config: CueGitHubPollerConfig): () => void if (isGitHubItemSeen(subscriptionId, itemKey)) continue; - const event: CueEvent = { - id: crypto.randomUUID(), - type: 'github.pull_request', - timestamp: new Date().toISOString(), - triggerName, - payload: { - type: 'pull_request', - number: item.number, - title: item.title, - author: item.author?.login ?? 'unknown', - url: item.url, - body: (item.body ?? '').slice(0, 5000), - state: item.mergedAt ? 'merged' : (item.state?.toLowerCase() ?? 'open'), - draft: item.isDraft ?? false, - labels: (item.labels ?? []).map((l: { name: string }) => l.name).join(','), - head_branch: item.headRefName ?? '', - base_branch: item.baseRefName ?? '', - repo, - created_at: item.createdAt ?? '', - updated_at: item.updatedAt ?? '', - merged_at: item.mergedAt ?? '', - }, - }; + const event = createCueEvent('github.pull_request', triggerName, { + type: 'pull_request', + number: item.number, + title: item.title, + author: item.author?.login ?? 'unknown', + url: item.url, + body: (item.body ?? '').slice(0, 5000), + state: item.mergedAt ? 'merged' : (item.state?.toLowerCase() ?? 'open'), + draft: item.isDraft ?? false, + labels: (item.labels ?? []).map((l: { name: string }) => l.name).join(','), + head_branch: item.headRefName ?? '', + base_branch: item.baseRefName ?? '', + repo, + created_at: item.createdAt ?? '', + updated_at: item.updatedAt ?? '', + merged_at: item.mergedAt ?? '', + }); onEvent(event); markGitHubItemSeen(subscriptionId, itemKey); @@ -200,26 +193,20 @@ export function createCueGitHubPoller(config: CueGitHubPollerConfig): () => void if (isGitHubItemSeen(subscriptionId, itemKey)) continue; - const event: CueEvent = { - id: crypto.randomUUID(), - type: 'github.issue', - timestamp: new Date().toISOString(), - triggerName, - payload: { - type: 'issue', - number: item.number, - title: item.title, - author: item.author?.login ?? 'unknown', - url: item.url, - body: (item.body ?? '').slice(0, 5000), - state: item.state?.toLowerCase() ?? 'open', - labels: (item.labels ?? []).map((l: { name: string }) => l.name).join(','), - assignees: (item.assignees ?? []).map((a: { login: string }) => a.login).join(','), - repo, - created_at: item.createdAt ?? '', - updated_at: item.updatedAt ?? '', - }, - }; + const event = createCueEvent('github.issue', triggerName, { + type: 'issue', + number: item.number, + title: item.title, + author: item.author?.login ?? 'unknown', + url: item.url, + body: (item.body ?? '').slice(0, 5000), + state: item.state?.toLowerCase() ?? 'open', + labels: (item.labels ?? []).map((l: { name: string }) => l.name).join(','), + assignees: (item.assignees ?? []).map((a: { login: string }) => a.login).join(','), + repo, + created_at: item.createdAt ?? '', + updated_at: item.updatedAt ?? '', + }); onEvent(event); markGitHubItemSeen(subscriptionId, itemKey); diff --git a/src/main/cue/cue-reconciler.ts b/src/main/cue/cue-reconciler.ts index 82f242da4e..88991c2b99 100644 --- a/src/main/cue/cue-reconciler.ts +++ b/src/main/cue/cue-reconciler.ts @@ -9,8 +9,7 @@ * naturally and agent completions are durable through the fan-in tracker. */ -import * as crypto from 'crypto'; -import type { CueConfig, CueEvent, CueSubscription } from './cue-types'; +import { createCueEvent, type CueConfig, type CueEvent, type CueSubscription } from './cue-types'; export interface ReconcileSessionInfo { config: CueConfig; @@ -54,18 +53,12 @@ export function reconcileMissedTimeEvents(config: ReconcileConfig): void { `[CUE] Reconciling "${sub.name}": ${missedCount} interval(s) missed during sleep, firing catch-up` ); - const event: CueEvent = { - id: crypto.randomUUID(), - type: 'time.heartbeat', - timestamp: new Date().toISOString(), - triggerName: sub.name, - payload: { - interval_minutes: sub.interval_minutes, - reconciled: true, - missedCount, - sleepDurationMs: gapMs, - }, - }; + const event = createCueEvent('time.heartbeat', sub.name, { + interval_minutes: sub.interval_minutes, + reconciled: true, + missedCount, + sleepDurationMs: gapMs, + }); // Route through normal dispatch path to respect concurrency limits onDispatch(sessionId, sub, event); diff --git a/src/main/cue/cue-subscription-setup.ts b/src/main/cue/cue-subscription-setup.ts index 67411bea65..01ac2b5bf4 100644 --- a/src/main/cue/cue-subscription-setup.ts +++ b/src/main/cue/cue-subscription-setup.ts @@ -6,10 +6,9 @@ * and wires them to the engine's event dispatch pipeline. */ -import * as crypto from 'crypto'; import type { MainLogLevel } from '../../shared/logger-types'; import type { SessionInfo } from '../../shared/types'; -import type { CueEvent, CueSubscription } from './cue-types'; +import { createCueEvent, type CueEvent, type CueSubscription } from './cue-types'; import { createCueFileWatcher } from './cue-file-watcher'; import { createCueGitHubPoller } from './cue-github-poller'; import { createCueTaskScanner } from './cue-task-scanner'; @@ -94,13 +93,9 @@ export function setupHeartbeatSubscription( if (intervalMs <= 0) return; // Fire immediately on first setup - const immediateEvent: CueEvent = { - id: crypto.randomUUID(), - type: 'time.heartbeat', - timestamp: new Date().toISOString(), - triggerName: sub.name, - payload: { interval_minutes: sub.interval_minutes }, - }; + const immediateEvent = createCueEvent('time.heartbeat', sub.name, { + interval_minutes: sub.interval_minutes, + }); // Check payload filter (even for timer events) if (!sub.filter || matchesFilter(immediateEvent.payload, sub.filter)) { @@ -121,13 +116,9 @@ export function setupHeartbeatSubscription( const timer = setInterval(() => { if (!deps.enabled()) return; - const event: CueEvent = { - id: crypto.randomUUID(), - type: 'time.heartbeat', - timestamp: new Date().toISOString(), - triggerName: sub.name, - payload: { interval_minutes: sub.interval_minutes }, - }; + const event = createCueEvent('time.heartbeat', sub.name, { + interval_minutes: sub.interval_minutes, + }); // Check payload filter if (sub.filter && !matchesFilter(event.payload, sub.filter)) { @@ -200,18 +191,12 @@ export function setupScheduledSubscription( } deps.scheduledFiredKeys.add(firedKey); - const event: CueEvent = { - id: crypto.randomUUID(), - type: 'time.scheduled', - timestamp: now.toISOString(), - triggerName: sub.name, - payload: { - schedule_times: sub.schedule_times, - schedule_days: sub.schedule_days, - matched_time: currentTime, - matched_day: currentDay, - }, - }; + const event = createCueEvent('time.scheduled', sub.name, { + schedule_times: sub.schedule_times, + schedule_days: sub.schedule_days, + matched_time: currentTime, + matched_day: currentDay, + }); // Refresh next trigger time regardless of filter outcome so the UI stays current const nextMs = calculateNextScheduledTime(times, sub.schedule_days); diff --git a/src/main/cue/cue-task-scanner.ts b/src/main/cue/cue-task-scanner.ts index 74482d85c8..6573e3d77d 100644 --- a/src/main/cue/cue-task-scanner.ts +++ b/src/main/cue/cue-task-scanner.ts @@ -8,11 +8,11 @@ * Follows the same factory pattern as cue-file-watcher.ts and cue-github-poller.ts. */ +import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; -import * as crypto from 'crypto'; import picomatch from 'picomatch'; -import type { CueEvent } from './cue-types'; +import { createCueEvent, type CueEvent } from './cue-types'; export interface CueTaskScannerConfig { watchGlob: string; @@ -142,22 +142,16 @@ export function createCueTaskScanner(config: CueTaskScannerConfig): () => void { const taskList = pendingTasks.map((t) => `L${t.line}: ${t.text}`).join('\n'); - const event: CueEvent = { - id: crypto.randomUUID(), - type: 'task.pending', - timestamp: new Date().toISOString(), - triggerName, - payload: { - path: absPath, - filename: path.basename(relPath), - directory: path.dirname(absPath), - extension: path.extname(relPath), - taskCount: pendingTasks.length, - taskList, - tasks: pendingTasks, - content: content.slice(0, 10000), - }, - }; + const event = createCueEvent('task.pending', triggerName, { + path: absPath, + filename: path.basename(relPath), + directory: path.dirname(absPath), + extension: path.extname(relPath), + taskCount: pendingTasks.length, + taskList, + tasks: pendingTasks, + content: content.slice(0, 10000), + }); onEvent(event); } diff --git a/src/main/cue/cue-types.ts b/src/main/cue/cue-types.ts index c1f4740269..23389085dc 100644 --- a/src/main/cue/cue-types.ts +++ b/src/main/cue/cue-types.ts @@ -1,3 +1,5 @@ +import * as crypto from 'crypto'; + /** * Core type definitions for the Maestro Cue event-driven automation system. * @@ -161,6 +163,21 @@ export interface CueGraphSession { subscriptions: CueSubscription[]; } +/** Create a CueEvent with auto-generated id and timestamp */ +export function createCueEvent( + type: CueEventType, + triggerName: string, + payload: Record = {} +): CueEvent { + return { + id: crypto.randomUUID(), + type, + timestamp: new Date().toISOString(), + triggerName, + payload, + }; +} + /** Default filename for Cue configuration */ export const CUE_YAML_FILENAME = 'maestro-cue.yaml'; diff --git a/src/renderer/components/CueModal.tsx b/src/renderer/components/CueModal.tsx deleted file mode 100644 index 7a8b6d6de7..0000000000 --- a/src/renderer/components/CueModal.tsx +++ /dev/null @@ -1,1009 +0,0 @@ -import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; -import { createPortal } from 'react-dom'; -import { - X, - Zap, - Square, - HelpCircle, - StopCircle, - LayoutDashboard, - GitFork, - ArrowLeft, - FileCode, - Trash2, - Play, - ChevronDown, - ChevronRight, - Clock, - Terminal, - AlertTriangle, -} from 'lucide-react'; -import type { Theme } from '../types'; -import { useLayerStack } from '../contexts/LayerStackContext'; -import { MODAL_PRIORITIES } from '../constants/modalPriorities'; -import { useCue } from '../hooks/useCue'; -import type { CueSessionStatus, CueRunResult } from '../hooks/useCue'; -import { CueHelpContent } from './CueHelpModal'; -import { CuePipelineEditor } from './CuePipelineEditor'; -import { useSessionStore } from '../stores/sessionStore'; -import { getModalActions } from '../stores/modalStore'; -import type { CuePipeline, CueGraphSession } from '../../shared/cue-pipeline-types'; -import { getPipelineColorForAgent } from './CuePipelineEditor/pipelineColors'; -import { graphSessionsToPipelines } from './CuePipelineEditor/utils/yamlToPipeline'; - -type CueModalTab = 'dashboard' | 'pipeline'; - -interface CueModalProps { - theme: Theme; - onClose: () => void; - cueShortcutKeys?: string[]; -} - -const CUE_TEAL = '#06b6d4'; - -function formatRelativeTime(dateStr?: string): string { - if (!dateStr) return '—'; - const diff = Date.now() - new Date(dateStr).getTime(); - if (diff < 0) return 'just now'; - const seconds = Math.floor(diff / 1000); - if (seconds < 60) return `${seconds}s ago`; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - return `${days}d ago`; -} - -function formatDuration(ms: number): string { - const seconds = Math.floor(ms / 1000); - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - const remainSeconds = seconds % 60; - return `${minutes}m ${remainSeconds}s`; -} - -function formatElapsed(startedAt: string): string { - const diff = Date.now() - new Date(startedAt).getTime(); - return formatDuration(Math.max(0, diff)); -} - -function StatusDot({ status }: { status: 'active' | 'paused' | 'none' }) { - const color = status === 'active' ? '#22c55e' : status === 'paused' ? '#eab308' : '#6b7280'; - return ; -} - -function PipelineDot({ color, name }: { color: string; name: string }) { - return ( - - ); -} - -/** Maps subscription names to pipeline info by checking name prefixes. */ -function buildSubscriptionPipelineMap( - pipelines: CuePipeline[] -): Map { - const map = new Map(); - for (const pipeline of pipelines) { - // Pipeline subscriptions are named: pipelineName, pipelineName-chain-N - map.set(pipeline.name, { name: pipeline.name, color: pipeline.color }); - } - return map; -} - -/** Looks up the pipeline for a subscription name by matching the base name prefix. */ -function getPipelineForSubscription( - subscriptionName: string, - pipelineMap: Map -): { name: string; color: string } | null { - // Strip -chain-N suffix to get base pipeline name - const baseName = subscriptionName.replace(/-chain-\d+$/, '').replace(/-fanin$/, ''); - return pipelineMap.get(baseName) ?? null; -} - -function SessionsTable({ - sessions, - theme, - onViewInPipeline, - onEditYaml, - onRemoveCue, - onTriggerSubscription, - queueStatus, - pipelines, - graphSessions, -}: { - sessions: CueSessionStatus[]; - theme: Theme; - onViewInPipeline: (session: CueSessionStatus) => void; - onEditYaml: (session: CueSessionStatus) => void; - onRemoveCue: (session: CueSessionStatus) => void; - onTriggerSubscription: (subscriptionName: string) => void; - queueStatus: Record; - pipelines: CuePipeline[]; - graphSessions: CueGraphSession[]; -}) { - if (sessions.length === 0) { - return ( -
- No sessions have a cue config file. Create .maestro/cue.yaml in your project to get started. -
- ); - } - - return ( - - - - - - - - - - - - - - - {sessions.map((s) => { - const status = !s.enabled ? 'paused' : s.subscriptionCount > 0 ? 'active' : 'none'; - return ( - - - - - - - - - - - ); - })} - -
SessionAgentPipelinesStatusLast TriggeredSubsQueue
- {s.sessionName} - - {s.toolType} - - {(() => { - const colors = getPipelineColorForAgent(s.sessionId, pipelines); - if (colors.length === 0) { - return ; - } - const pipelineNames = pipelines - .filter((p) => colors.includes(p.color)) - .map((p) => p.name); - return ( - - {colors.map((color, i) => ( - - ))} - - ); - })()} - - - - - {status === 'active' ? 'Active' : status === 'paused' ? 'Paused' : 'No Config'} - - - - {formatRelativeTime(s.lastTriggered)} - - {s.subscriptionCount} - - {queueStatus[s.sessionId] ? `${queueStatus[s.sessionId]} queued` : '—'} - - - {(() => { - const gs = graphSessions.find((g) => g.sessionId === s.sessionId); - const subs = gs?.subscriptions.filter((sub) => sub.enabled !== false) ?? []; - if (subs.length === 0 || !s.enabled) return null; - return ( - - ); - })()} - - - - -
- ); -} - -function ActiveRunsList({ - runs, - theme, - onStopRun, - onStopAll, - subscriptionPipelineMap, -}: { - runs: CueRunResult[]; - theme: Theme; - onStopRun: (runId: string) => void; - onStopAll: () => void; - subscriptionPipelineMap: Map; -}) { - if (runs.length === 0) { - return ( -
- No active runs -
- ); - } - - return ( -
- {runs.length > 1 && ( -
- -
- )} - {runs.map((run) => ( -
- -
- {(() => { - const pInfo = getPipelineForSubscription( - run.subscriptionName, - subscriptionPipelineMap - ); - return pInfo ? : null; - })()} - {run.sessionName} - - "{run.subscriptionName}" -
- - {formatElapsed(run.startedAt)} - -
- ))} -
- ); -} - -/** Formats event payload into human-readable key-value pairs, filtering out noise. */ -function formatPayloadEntries(payload: Record): Array<[string, string]> { - const skipKeys = new Set(['outputPromptPhase', 'manual']); - const entries: Array<[string, string]> = []; - for (const [key, value] of Object.entries(payload)) { - if (skipKeys.has(key)) continue; - if (value === undefined || value === null || value === '') continue; - const strValue = typeof value === 'object' ? JSON.stringify(value) : String(value); - // Truncate very long values for display - entries.push([key, strValue.length > 500 ? strValue.slice(0, 500) + '…' : strValue]); - } - return entries; -} - -function ActivityLogDetail({ entry, theme }: { entry: CueRunResult; theme: Theme }) { - const payloadEntries = formatPayloadEntries(entry.event.payload); - const hasStdout = entry.stdout.trim().length > 0; - const hasStderr = entry.stderr.trim().length > 0; - - return ( -
- {/* Execution metadata */} -
-
- - Started: - - {new Date(entry.startedAt).toLocaleString()} - -
-
- - Duration: - {formatDuration(entry.durationMs)} -
-
- - Event: - {entry.event.type} -
-
- - Exit code: - - {entry.exitCode ?? '—'} - -
-
- Session: - {entry.sessionName} -
-
- Run ID: - - {entry.runId.slice(0, 8)} - -
-
- - {/* Event payload */} - {payloadEntries.length > 0 && ( -
-
- Event Payload -
-
- {payloadEntries.map(([key, value]) => ( -
- - {key}: - - - {value} - -
- ))} -
-
- )} - - {/* stdout */} - {hasStdout && ( -
-
- Output -
-
-						{entry.stdout.slice(-5000)}
-					
-
- )} - - {/* stderr */} - {hasStderr && ( -
-
- - Errors -
-
-						{entry.stderr.slice(-3000)}
-					
-
- )} -
- ); -} - -function ActivityLog({ - log, - theme, - subscriptionPipelineMap, -}: { - log: CueRunResult[]; - theme: Theme; - subscriptionPipelineMap: Map; -}) { - const [visibleCount, setVisibleCount] = useState(100); - const [expandedRunId, setExpandedRunId] = useState(null); - - if (log.length === 0) { - return ( -
- No activity yet -
- ); - } - - const visible = log.slice(0, visibleCount); - - return ( -
- {visible.map((entry) => { - const isFailed = entry.status === 'failed' || entry.status === 'timeout'; - const eventType = entry.event.type; - const filePayload = - eventType === 'file.changed' && entry.event.payload?.file - ? ` (${String(entry.event.payload.file).split('/').pop()})` - : ''; - const taskPayload = - eventType === 'task.pending' && entry.event.payload?.filename - ? ` (${String(entry.event.payload.filename)}: ${String(entry.event.payload.taskCount ?? 0)} task(s))` - : ''; - const githubPayload = - (eventType === 'github.pull_request' || eventType === 'github.issue') && - entry.event.payload?.number - ? ` (#${String(entry.event.payload.number)} ${String(entry.event.payload.title ?? '')})` - : ''; - const isReconciled = entry.event.payload?.reconciled === true; - const isExpanded = expandedRunId === entry.runId; - - return ( -
- - {isExpanded && } -
- ); - })} - {log.length > visibleCount && ( - - )} -
- ); -} - -export function CueModal({ theme, onClose, cueShortcutKeys }: CueModalProps) { - const { registerLayer, unregisterLayer } = useLayerStack(); - const layerIdRef = useRef(); - const onCloseRef = useRef(onClose); - onCloseRef.current = onClose; - - const { - sessions, - activeRuns, - activityLog, - queueStatus, - loading, - enable, - disable, - stopRun, - stopAll, - triggerSubscription, - refresh, - } = useCue(); - - const allSessions = useSessionStore((state) => state.sessions); - const groups = useSessionStore((state) => state.groups); - const setActiveSessionId = useSessionStore((state) => state.setActiveSessionId); - - const sessionInfoList = useMemo( - () => - allSessions.map((s) => ({ - id: s.id, - groupId: s.groupId, - name: s.name, - toolType: s.toolType, - projectRoot: s.projectRoot, - })), - [allSessions] - ); - - const [graphSessions, setGraphSessions] = useState([]); - - const handleSwitchToSession = useCallback( - (id: string) => { - setActiveSessionId(id); - onClose(); - }, - [setActiveSessionId, onClose] - ); - - const isEnabled = sessions.some((s) => s.enabled); - - const handleToggle = useCallback(() => { - if (isEnabled) { - disable(); - } else { - enable(); - } - }, [isEnabled, enable, disable]); - - // Register layer on mount - useEffect(() => { - const id = registerLayer({ - type: 'modal', - priority: MODAL_PRIORITIES.CUE_MODAL, - blocksLowerLayers: true, - capturesFocus: true, - focusTrap: 'strict', - onEscape: () => { - if (showHelpRef.current) { - setShowHelp(false); - return; - } - if (pipelineDirtyRef.current) { - const confirmed = window.confirm( - 'You have unsaved changes in the pipeline editor. Discard and close?' - ); - if (!confirmed) return; - } - onCloseRef.current(); - }, - }); - layerIdRef.current = id; - - return () => { - if (layerIdRef.current) { - unregisterLayer(layerIdRef.current); - } - }; - }, [registerLayer, unregisterLayer]); - - // Tab state - const [activeTab, setActiveTab] = useState('pipeline'); - - // Fetch graph data on mount and when tab changes (needed for both dashboard and pipeline tabs) - useEffect(() => { - let cancelled = false; - window.maestro.cue - .getGraphData() - .then((data: CueGraphSession[]) => { - if (!cancelled) setGraphSessions(data); - }) - .catch(() => {}); - return () => { - cancelled = true; - }; - }, [activeTab]); - - // Compute pipelines from graph sessions for dashboard pipeline info - const dashboardPipelines = useMemo(() => { - if (graphSessions.length === 0) return []; - return graphSessionsToPipelines(graphSessions, sessionInfoList); - }, [graphSessions, sessionInfoList]); - - // Build subscription-to-pipeline lookup map - const subscriptionPipelineMap = useMemo( - () => buildSubscriptionPipelineMap(dashboardPipelines), - [dashboardPipelines] - ); - - // Help modal state - const [showHelp, setShowHelp] = useState(false); - const showHelpRef = useRef(false); - showHelpRef.current = showHelp; - - // Pipeline dirty state (unsaved changes) - const [pipelineDirty, setPipelineDirty] = useState(false); - const pipelineDirtyRef = useRef(false); - pipelineDirtyRef.current = pipelineDirty; - - const handleEditYaml = useCallback((session: CueSessionStatus) => { - getModalActions().openCueYamlEditor(session.sessionId, session.projectRoot); - }, []); - - const handleViewInPipeline = useCallback((_session: CueSessionStatus) => { - setActiveTab('pipeline'); - }, []); - - const handleRemoveCue = useCallback( - async (session: CueSessionStatus) => { - const confirmed = window.confirm( - `Remove Cue configuration for "${session.sessionName}"?\n\nThis will delete the cue.yaml file from this project. This cannot be undone.` - ); - if (!confirmed) return; - await window.maestro.cue.deleteYaml(session.projectRoot); - await refresh(); - }, - [refresh] - ); - - // Close with unsaved changes confirmation - const handleCloseWithConfirm = useCallback(() => { - if (pipelineDirtyRef.current) { - const confirmed = window.confirm( - 'You have unsaved changes in the pipeline editor. Discard and close?' - ); - if (!confirmed) return; - } - onClose(); - }, [onClose]); - - // Active runs section is collapsible when empty - const [activeRunsExpanded, setActiveRunsExpanded] = useState(true); - - return ( - <> - {createPortal( -
{ - if (e.target === e.currentTarget) handleCloseWithConfirm(); - }} - > - {/* Backdrop */} -
- - {/* Modal */} -
- {/* Header */} -
-
- {showHelp ? ( - <> - - -

- Maestro Cue Guide -

- - ) : ( - <> - -

- Maestro Cue -

- - {/* Tab bar */} -
- - -
- - )} -
-
- {!showHelp && ( - <> - {/* Master toggle */} - - - {/* Help button */} - - - )} - - {/* Close button */} - -
-
- - {/* Body */} - {showHelp ? ( -
- -
- ) : activeTab === 'dashboard' ? ( -
- {loading ? ( -
- Loading Cue status... -
- ) : ( - <> - {/* Section 1: Sessions with Cue */} -
-

- Sessions with Cue -

- -
- - {/* Section 2: Active Runs */} -
- - {activeRunsExpanded && ( - - )} -
- - {/* Section 3: Activity Log */} -
-

- Activity Log -

-
- -
-
- - )} -
- ) : ( - - )} -
-
, - document.body - )} - - ); -} diff --git a/src/renderer/components/CueModal/ActiveRunsList.tsx b/src/renderer/components/CueModal/ActiveRunsList.tsx new file mode 100644 index 0000000000..2cf3fffcc9 --- /dev/null +++ b/src/renderer/components/CueModal/ActiveRunsList.tsx @@ -0,0 +1,81 @@ +/** + * ActiveRunsList — Displays currently running Cue tasks with stop controls. + */ + +import { Square, StopCircle } from 'lucide-react'; +import type { Theme } from '../../types'; +import type { CueRunResult } from '../../hooks/useCue'; +import { CUE_COLOR } from '../../../shared/cue-pipeline-types'; +import { PipelineDot } from './StatusDot'; +import { formatElapsed, getPipelineForSubscription } from './cueModalUtils'; + +interface ActiveRunsListProps { + runs: CueRunResult[]; + theme: Theme; + onStopRun: (runId: string) => void; + onStopAll: () => void; + subscriptionPipelineMap: Map; +} + +export function ActiveRunsList({ + runs, + theme, + onStopRun, + onStopAll, + subscriptionPipelineMap, +}: ActiveRunsListProps) { + if (runs.length === 0) { + return ( +
+ No active runs +
+ ); + } + + return ( +
+ {runs.length > 1 && ( +
+ +
+ )} + {runs.map((run) => ( +
+ +
+ {(() => { + const pInfo = getPipelineForSubscription( + run.subscriptionName, + subscriptionPipelineMap + ); + return pInfo ? : null; + })()} + {run.sessionName} + + "{run.subscriptionName}" +
+ + {formatElapsed(run.startedAt)} + +
+ ))} +
+ ); +} diff --git a/src/renderer/components/CueModal/ActivityLog.tsx b/src/renderer/components/CueModal/ActivityLog.tsx new file mode 100644 index 0000000000..a7d2ae2dee --- /dev/null +++ b/src/renderer/components/CueModal/ActivityLog.tsx @@ -0,0 +1,128 @@ +/** + * ActivityLog — Expandable run history with load-more pagination. + */ + +import { useState } from 'react'; +import { ChevronDown, ChevronRight, Zap } from 'lucide-react'; +import type { Theme } from '../../types'; +import type { CueRunResult } from '../../hooks/useCue'; +import { CUE_COLOR } from '../../../shared/cue-pipeline-types'; +import { PipelineDot } from './StatusDot'; +import { ActivityLogDetail } from './ActivityLogDetail'; +import { formatDuration, getPipelineForSubscription } from './cueModalUtils'; + +interface ActivityLogProps { + log: CueRunResult[]; + theme: Theme; + subscriptionPipelineMap: Map; +} + +export function ActivityLog({ log, theme, subscriptionPipelineMap }: ActivityLogProps) { + const [visibleCount, setVisibleCount] = useState(100); + const [expandedRunId, setExpandedRunId] = useState(null); + + if (log.length === 0) { + return ( +
+ No activity yet +
+ ); + } + + const visible = log.slice(0, visibleCount); + + return ( +
+ {visible.map((entry) => { + const isFailed = entry.status === 'failed' || entry.status === 'timeout'; + const eventType = entry.event.type; + const filePayload = + eventType === 'file.changed' && entry.event.payload?.file + ? ` (${String(entry.event.payload.file).split('/').pop()})` + : ''; + const taskPayload = + eventType === 'task.pending' && entry.event.payload?.filename + ? ` (${String(entry.event.payload.filename)}: ${String(entry.event.payload.taskCount ?? 0)} task(s))` + : ''; + const githubPayload = + (eventType === 'github.pull_request' || eventType === 'github.issue') && + entry.event.payload?.number + ? ` (#${String(entry.event.payload.number)} ${String(entry.event.payload.title ?? '')})` + : ''; + const isReconciled = entry.event.payload?.reconciled === true; + const isExpanded = expandedRunId === entry.runId; + + return ( +
+ + {isExpanded && } +
+ ); + })} + {log.length > visibleCount && ( + + )} +
+ ); +} diff --git a/src/renderer/components/CueModal/ActivityLogDetail.tsx b/src/renderer/components/CueModal/ActivityLogDetail.tsx new file mode 100644 index 0000000000..0f05fcdf5e --- /dev/null +++ b/src/renderer/components/CueModal/ActivityLogDetail.tsx @@ -0,0 +1,137 @@ +/** + * ActivityLogDetail — Expanded detail view for a single Cue run execution. + * + * Shows metadata grid, event payload, stdout, and stderr. + */ + +import { Clock, Zap, Terminal, AlertTriangle } from 'lucide-react'; +import type { Theme } from '../../types'; +import type { CueRunResult } from '../../hooks/useCue'; +import { CUE_COLOR } from '../../../shared/cue-pipeline-types'; +import { formatDuration, formatPayloadEntries } from './cueModalUtils'; + +interface ActivityLogDetailProps { + entry: CueRunResult; + theme: Theme; +} + +export function ActivityLogDetail({ entry, theme }: ActivityLogDetailProps) { + const payloadEntries = formatPayloadEntries(entry.event.payload); + const hasStdout = entry.stdout.trim().length > 0; + const hasStderr = entry.stderr.trim().length > 0; + + return ( +
+ {/* Execution metadata */} +
+
+ + Started: + + {new Date(entry.startedAt).toLocaleString()} + +
+
+ + Duration: + {formatDuration(entry.durationMs)} +
+
+ + Event: + {entry.event.type} +
+
+ + Exit code: + + {entry.exitCode ?? '—'} + +
+
+ Session: + {entry.sessionName} +
+
+ Run ID: + + {entry.runId.slice(0, 8)} + +
+
+ + {/* Event payload */} + {payloadEntries.length > 0 && ( +
+
+ Event Payload +
+
+ {payloadEntries.map(([key, value]) => ( +
+ + {key}: + + + {value} + +
+ ))} +
+
+ )} + + {/* stdout */} + {hasStdout && ( +
+
+ Output +
+
+						{entry.stdout.slice(-5000)}
+					
+
+ )} + + {/* stderr */} + {hasStderr && ( +
+
+ + Errors +
+
+						{entry.stderr.slice(-3000)}
+					
+
+ )} +
+ ); +} diff --git a/src/renderer/components/CueModal/CueModal.tsx b/src/renderer/components/CueModal/CueModal.tsx new file mode 100644 index 0000000000..fb455a49ca --- /dev/null +++ b/src/renderer/components/CueModal/CueModal.tsx @@ -0,0 +1,506 @@ +/** + * CueModal — Main modal for Maestro Cue dashboard and pipeline editor. + * + * Thin shell: tab switching, master toggle, help overlay, layer stack, + * unsaved changes confirmation. Sub-components handle dashboard sections. + */ + +import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { + X, + Zap, + HelpCircle, + LayoutDashboard, + GitFork, + ArrowLeft, + AlertTriangle, +} from 'lucide-react'; +import type { Theme } from '../../types'; +import { useLayerStack } from '../../contexts/LayerStackContext'; +import { MODAL_PRIORITIES } from '../../constants/modalPriorities'; +import { useCue } from '../../hooks/useCue'; +import type { CueSessionStatus } from '../../hooks/useCue'; +import { CueHelpContent } from '../CueHelpModal'; +import { CuePipelineEditor } from '../CuePipelineEditor'; +import { useSessionStore } from '../../stores/sessionStore'; +import { getModalActions } from '../../stores/modalStore'; +import { CUE_COLOR, type CueGraphSession } from '../../../shared/cue-pipeline-types'; +import { graphSessionsToPipelines } from '../CuePipelineEditor/utils/yamlToPipeline'; +import { SessionsTable } from './SessionsTable'; +import { ActiveRunsList } from './ActiveRunsList'; +import { ActivityLog } from './ActivityLog'; +import { buildSubscriptionPipelineMap } from './cueModalUtils'; + +type CueModalTab = 'dashboard' | 'pipeline'; + +export interface CueModalProps { + theme: Theme; + onClose: () => void; + cueShortcutKeys?: string[]; +} + +export function CueModal({ theme, onClose, cueShortcutKeys }: CueModalProps) { + const { registerLayer, unregisterLayer } = useLayerStack(); + const layerIdRef = useRef(); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + const { + sessions, + activeRuns, + activityLog, + queueStatus, + loading, + error, + enable, + disable, + stopRun, + stopAll, + triggerSubscription, + refresh, + } = useCue(); + + const allSessions = useSessionStore((state) => state.sessions); + const groups = useSessionStore((state) => state.groups); + const setActiveSessionId = useSessionStore((state) => state.setActiveSessionId); + + const sessionInfoList = useMemo( + () => + allSessions.map((s) => ({ + id: s.id, + groupId: s.groupId, + name: s.name, + toolType: s.toolType, + projectRoot: s.projectRoot, + })), + [allSessions] + ); + + const [graphSessions, setGraphSessions] = useState([]); + + const handleSwitchToSession = useCallback( + (id: string) => { + setActiveSessionId(id); + onClose(); + }, + [setActiveSessionId, onClose] + ); + + const isEnabled = sessions.some((s) => s.enabled); + const [toggling, setToggling] = useState(false); + + const handleToggle = useCallback(async () => { + if (toggling) return; + setToggling(true); + try { + if (isEnabled) { + await disable(); + } else { + await enable(); + } + } finally { + setToggling(false); + } + }, [isEnabled, enable, disable, toggling]); + + // Register layer on mount + useEffect(() => { + const id = registerLayer({ + type: 'modal', + priority: MODAL_PRIORITIES.CUE_MODAL, + blocksLowerLayers: true, + capturesFocus: true, + focusTrap: 'strict', + onEscape: () => { + if (showHelpRef.current) { + setShowHelp(false); + return; + } + if (pipelineDirtyRef.current) { + const confirmed = window.confirm( + 'You have unsaved changes in the pipeline editor. Discard and close?' + ); + if (!confirmed) return; + } + onCloseRef.current(); + }, + }); + layerIdRef.current = id; + + return () => { + if (layerIdRef.current) { + unregisterLayer(layerIdRef.current); + } + }; + }, [registerLayer, unregisterLayer]); + + // Tab state + const [activeTab, setActiveTab] = useState('pipeline'); + + // Graph data fetch error state + const [graphError, setGraphError] = useState(null); + + // Fetch graph data on mount and when tab changes (needed for both dashboard and pipeline tabs) + useEffect(() => { + let cancelled = false; + setGraphError(null); + window.maestro.cue + .getGraphData() + .then((data: CueGraphSession[]) => { + if (!cancelled) setGraphSessions(data); + }) + .catch((err: unknown) => { + if (!cancelled) { + setGraphError(err instanceof Error ? err.message : 'Failed to load graph data'); + } + }); + return () => { + cancelled = true; + }; + }, [activeTab]); + + // Compute pipelines from graph sessions for dashboard pipeline info + const dashboardPipelines = useMemo(() => { + if (graphSessions.length === 0) return []; + return graphSessionsToPipelines(graphSessions, sessionInfoList); + }, [graphSessions, sessionInfoList]); + + // Build subscription-to-pipeline lookup map + const subscriptionPipelineMap = useMemo( + () => buildSubscriptionPipelineMap(dashboardPipelines), + [dashboardPipelines] + ); + + // Help modal state + const [showHelp, setShowHelp] = useState(false); + const showHelpRef = useRef(false); + showHelpRef.current = showHelp; + + // Pipeline dirty state (unsaved changes) + const [pipelineDirty, setPipelineDirty] = useState(false); + const pipelineDirtyRef = useRef(false); + pipelineDirtyRef.current = pipelineDirty; + + const handleEditYaml = useCallback((session: CueSessionStatus) => { + getModalActions().openCueYamlEditor(session.sessionId, session.projectRoot); + }, []); + + const handleViewInPipeline = useCallback((_session: CueSessionStatus) => { + setActiveTab('pipeline'); + }, []); + + const handleRemoveCue = useCallback( + async (session: CueSessionStatus) => { + const confirmed = window.confirm( + `Remove Cue configuration for "${session.sessionName}"?\n\nThis will delete the cue.yaml file from this project. This cannot be undone.` + ); + if (!confirmed) return; + await window.maestro.cue.deleteYaml(session.projectRoot); + await refresh(); + }, + [refresh] + ); + + // Close with unsaved changes confirmation + const handleCloseWithConfirm = useCallback(() => { + if (pipelineDirtyRef.current) { + const confirmed = window.confirm( + 'You have unsaved changes in the pipeline editor. Discard and close?' + ); + if (!confirmed) return; + } + onClose(); + }, [onClose]); + + // Active runs section is collapsible when empty + const [activeRunsExpanded, setActiveRunsExpanded] = useState(true); + + return ( + <> + {createPortal( +
{ + if (e.target === e.currentTarget) handleCloseWithConfirm(); + }} + > + {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+ {showHelp ? ( + <> + + +

+ Maestro Cue Guide +

+ + ) : ( + <> + +

+ Maestro Cue +

+ + {/* Tab bar */} +
+ + +
+ + )} +
+
+ {!showHelp && ( + <> + {/* Master toggle */} + + + {/* Help button */} + + + )} + + {/* Close button */} + +
+
+ + {/* Body */} + {showHelp ? ( +
+ +
+ ) : activeTab === 'dashboard' ? ( +
+ {loading ? ( +
+ Loading Cue status... +
+ ) : ( + <> + {(error || graphError) && ( +
+ + {error || graphError} + +
+ )} + + {/* Section 1: Sessions with Cue */} +
+

+ Sessions with Cue +

+ +
+ + {/* Section 2: Active Runs */} +
+ + {activeRunsExpanded && ( + + )} +
+ + {/* Section 3: Activity Log */} +
+

+ Activity Log +

+
+ +
+
+ + )} +
+ ) : ( + + )} +
+
, + document.body + )} + + ); +} diff --git a/src/renderer/components/CueModal/SessionsTable.tsx b/src/renderer/components/CueModal/SessionsTable.tsx new file mode 100644 index 0000000000..921c669be0 --- /dev/null +++ b/src/renderer/components/CueModal/SessionsTable.tsx @@ -0,0 +1,171 @@ +/** + * SessionsTable — Table of Cue-enabled sessions with status, pipeline info, and actions. + */ + +import { FileCode, GitFork, Play, Trash2 } from 'lucide-react'; +import type { Theme } from '../../types'; +import type { CueSessionStatus } from '../../hooks/useCue'; +import { + CUE_COLOR, + type CuePipeline, + type CueGraphSession, +} from '../../../shared/cue-pipeline-types'; +import { getPipelineColorForAgent } from '../CuePipelineEditor/pipelineColors'; +import { StatusDot, PipelineDot } from './StatusDot'; +import { formatRelativeTime } from './cueModalUtils'; + +interface SessionsTableProps { + sessions: CueSessionStatus[]; + theme: Theme; + onViewInPipeline: (session: CueSessionStatus) => void; + onEditYaml: (session: CueSessionStatus) => void; + onRemoveCue: (session: CueSessionStatus) => void; + onTriggerSubscription: (subscriptionName: string) => void; + queueStatus: Record; + pipelines: CuePipeline[]; + graphSessions: CueGraphSession[]; +} + +export function SessionsTable({ + sessions, + theme, + onViewInPipeline, + onEditYaml, + onRemoveCue, + onTriggerSubscription, + queueStatus, + pipelines, + graphSessions, +}: SessionsTableProps) { + if (sessions.length === 0) { + return ( +
+ No sessions have a cue config file. Create .maestro/cue.yaml in your project to get started. +
+ ); + } + + return ( + + + + + + + + + + + + + + + {sessions.map((s) => { + const status = !s.enabled ? 'paused' : s.subscriptionCount > 0 ? 'active' : 'none'; + return ( + + + + + + + + + + + ); + })} + +
SessionAgentPipelinesStatusLast TriggeredSubsQueue
+ {s.sessionName} + + {s.toolType} + + {(() => { + const colors = getPipelineColorForAgent(s.sessionId, pipelines); + if (colors.length === 0) { + return ; + } + const pipelineNames = pipelines + .filter((p) => colors.includes(p.color)) + .map((p) => p.name); + return ( + + {colors.map((color, i) => ( + + ))} + + ); + })()} + + + + + {status === 'active' ? 'Active' : status === 'paused' ? 'Paused' : 'No Config'} + + + + {formatRelativeTime(s.lastTriggered)} + + {s.subscriptionCount} + + {queueStatus[s.sessionId] ? `${queueStatus[s.sessionId]} queued` : '—'} + + + {(() => { + const gs = graphSessions.find((g) => g.sessionId === s.sessionId); + const subs = gs?.subscriptions.filter((sub) => sub.enabled !== false) ?? []; + if (subs.length === 0 || !s.enabled) return null; + return ( + + ); + })()} + + + + +
+ ); +} diff --git a/src/renderer/components/CueModal/StatusDot.tsx b/src/renderer/components/CueModal/StatusDot.tsx new file mode 100644 index 0000000000..fa5643c050 --- /dev/null +++ b/src/renderer/components/CueModal/StatusDot.tsx @@ -0,0 +1,17 @@ +/** Status indicator dot for Cue sessions (active/paused/none). */ + +export function StatusDot({ status }: { status: 'active' | 'paused' | 'none' }) { + const color = status === 'active' ? '#22c55e' : status === 'paused' ? '#eab308' : '#6b7280'; + return ; +} + +/** Colored dot representing a pipeline. */ +export function PipelineDot({ color, name }: { color: string; name: string }) { + return ( + + ); +} diff --git a/src/renderer/components/CueModal/cueModalUtils.ts b/src/renderer/components/CueModal/cueModalUtils.ts new file mode 100644 index 0000000000..5b25770029 --- /dev/null +++ b/src/renderer/components/CueModal/cueModalUtils.ts @@ -0,0 +1,73 @@ +/** + * Shared utilities for CueModal sub-components. + * + * Formatting functions, pipeline mapping helpers. + */ + +import type { CuePipeline } from '../../../shared/cue-pipeline-types'; + +export function formatRelativeTime(dateStr?: string): string { + if (!dateStr) return '—'; + const parsed = new Date(dateStr).getTime(); + if (isNaN(parsed)) return '—'; + const diff = Date.now() - parsed; + if (diff < 0) return 'just now'; + const seconds = Math.floor(diff / 1000); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +export function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainSeconds = seconds % 60; + return `${minutes}m ${remainSeconds}s`; +} + +export function formatElapsed(startedAt: string): string { + const parsed = new Date(startedAt).getTime(); + if (isNaN(parsed)) return formatDuration(0); + return formatDuration(Math.max(0, Date.now() - parsed)); +} + +/** Maps subscription names to pipeline info by checking name prefixes. */ +export function buildSubscriptionPipelineMap( + pipelines: CuePipeline[] +): Map { + const map = new Map(); + for (const pipeline of pipelines) { + // Pipeline subscriptions are named: pipelineName, pipelineName-chain-N + map.set(pipeline.name, { name: pipeline.name, color: pipeline.color }); + } + return map; +} + +/** Looks up the pipeline for a subscription name by matching the base name prefix. */ +export function getPipelineForSubscription( + subscriptionName: string, + pipelineMap: Map +): { name: string; color: string } | null { + // Strip -chain-N suffix to get base pipeline name + const baseName = subscriptionName.replace(/-chain-\d+$/, '').replace(/-fanin$/, ''); + return pipelineMap.get(baseName) ?? null; +} + +/** Formats event payload into human-readable key-value pairs, filtering out noise. */ +export function formatPayloadEntries(payload: Record): Array<[string, string]> { + const skipKeys = new Set(['outputPromptPhase', 'manual']); + const entries: Array<[string, string]> = []; + for (const [key, value] of Object.entries(payload)) { + if (skipKeys.has(key)) continue; + if (value === undefined || value === null || value === '') continue; + const strValue = typeof value === 'object' ? JSON.stringify(value) : String(value); + // Truncate very long values for display + entries.push([key, strValue.length > 500 ? strValue.slice(0, 500) + '…' : strValue]); + } + return entries; +} diff --git a/src/renderer/components/CueModal/index.ts b/src/renderer/components/CueModal/index.ts new file mode 100644 index 0000000000..3922dce574 --- /dev/null +++ b/src/renderer/components/CueModal/index.ts @@ -0,0 +1,2 @@ +export { CueModal } from './CueModal'; +export type { CueModalProps } from './CueModal'; diff --git a/src/renderer/components/CuePipelineEditor/PipelineCanvas.tsx b/src/renderer/components/CuePipelineEditor/PipelineCanvas.tsx index 550a097ea9..a2f4dd4332 100644 --- a/src/renderer/components/CuePipelineEditor/PipelineCanvas.tsx +++ b/src/renderer/components/CuePipelineEditor/PipelineCanvas.tsx @@ -26,6 +26,7 @@ import type { PipelineEdge as PipelineEdgeType, TriggerNodeData, AgentNodeData, + CuePipelineSessionInfo as SessionInfo, } from '../../../shared/cue-pipeline-types'; import type { CueSettings } from '../../../main/cue/cue-types'; import { TriggerNode, type TriggerNodeDataProps } from './nodes/TriggerNode'; @@ -36,31 +37,13 @@ import { AgentDrawer } from './drawers/AgentDrawer'; import { NodeConfigPanel, type IncomingTriggerEdgeInfo } from './panels/NodeConfigPanel'; import { EdgeConfigPanel } from './panels/EdgeConfigPanel'; import { CueSettingsPanel } from './panels/CueSettingsPanel'; +import { EVENT_COLORS } from './cueEventConstants'; const nodeTypes = { trigger: TriggerNode, agent: AgentNode, }; -/** Event type → MiniMap node color mapping (hoisted to avoid per-render allocation) */ -const EVENT_COLORS: Record = { - 'time.heartbeat': '#f59e0b', - 'time.scheduled': '#8b5cf6', - 'file.changed': '#3b82f6', - 'agent.completed': '#22c55e', - 'github.pull_request': '#a855f7', - 'github.issue': '#f97316', - 'task.pending': '#06b6d4', -}; - -interface SessionInfo { - id: string; - groupId?: string; - name: string; - toolType: string; - projectRoot?: string; -} - export interface PipelineCanvasProps { theme: Theme; // ReactFlow diff --git a/src/renderer/components/CuePipelineEditor/cueEventConstants.ts b/src/renderer/components/CuePipelineEditor/cueEventConstants.ts new file mode 100644 index 0000000000..74f5a92f1a --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/cueEventConstants.ts @@ -0,0 +1,42 @@ +/** + * Shared event-type constants for the Cue pipeline editor. + * + * Single source of truth for event icons, labels, and colors used across + * TriggerNode, TriggerDrawer, NodeConfigPanel, and PipelineCanvas. + */ + +import { Clock, FileText, Zap, GitPullRequest, CircleDot, CheckSquare } from 'lucide-react'; +import type { CueEventType } from '../../../shared/cue-pipeline-types'; + +/** Icon component for each event type */ +export const EVENT_ICONS: Record = { + 'time.heartbeat': Clock, + 'time.scheduled': Clock, + 'file.changed': FileText, + 'agent.completed': Zap, + 'github.pull_request': GitPullRequest, + 'github.issue': CircleDot, + 'task.pending': CheckSquare, +}; + +/** Display label for each event type */ +export const EVENT_LABELS: Record = { + 'time.heartbeat': 'Heartbeat Timer', + 'time.scheduled': 'Scheduled', + 'file.changed': 'File Change', + 'agent.completed': 'Agent Completed', + 'github.pull_request': 'Pull Request', + 'github.issue': 'GitHub Issue', + 'task.pending': 'Pending Task', +}; + +/** Brand color for each event type (used in nodes, drawers, minimap) */ +export const EVENT_COLORS: Record = { + 'time.heartbeat': '#f59e0b', + 'time.scheduled': '#8b5cf6', + 'file.changed': '#3b82f6', + 'agent.completed': '#22c55e', + 'github.pull_request': '#a855f7', + 'github.issue': '#f97316', + 'task.pending': '#06b6d4', +}; diff --git a/src/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.tsx b/src/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.tsx index e7ff1f3ab1..e7e51778db 100644 --- a/src/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.tsx +++ b/src/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.tsx @@ -1,7 +1,8 @@ import { memo, useState, useMemo } from 'react'; -import { Clock, FileText, GitPullRequest, GitBranch, CheckSquare, Search, X } from 'lucide-react'; +import { Search, X } from 'lucide-react'; import type { CueEventType } from '../../../../shared/cue-pipeline-types'; import type { Theme } from '../../../types'; +import { EVENT_ICONS, EVENT_COLORS } from '../cueEventConstants'; export interface TriggerDrawerProps { isOpen: boolean; @@ -13,7 +14,7 @@ interface TriggerItem { eventType: CueEventType; label: string; description: string; - icon: typeof Clock; + icon: (typeof EVENT_ICONS)[CueEventType]; color: string; } @@ -22,43 +23,43 @@ const TRIGGER_ITEMS: TriggerItem[] = [ eventType: 'time.heartbeat', label: 'Heartbeat', description: 'Run every N minutes', - icon: Clock, - color: '#f59e0b', + icon: EVENT_ICONS['time.heartbeat'], + color: EVENT_COLORS['time.heartbeat'], }, { eventType: 'time.scheduled', label: 'Scheduled', description: 'Run at specific times & days', - icon: Clock, - color: '#8b5cf6', + icon: EVENT_ICONS['time.scheduled'], + color: EVENT_COLORS['time.scheduled'], }, { eventType: 'file.changed', label: 'File Change', description: 'Watch for file modifications', - icon: FileText, - color: '#3b82f6', + icon: EVENT_ICONS['file.changed'], + color: EVENT_COLORS['file.changed'], }, { eventType: 'github.pull_request', label: 'Pull Request', description: 'GitHub PR events', - icon: GitPullRequest, - color: '#a855f7', + icon: EVENT_ICONS['github.pull_request'], + color: EVENT_COLORS['github.pull_request'], }, { eventType: 'github.issue', label: 'Issue', description: 'GitHub issue events', - icon: GitBranch, - color: '#f97316', + icon: EVENT_ICONS['github.issue'], + color: EVENT_COLORS['github.issue'], }, { eventType: 'task.pending', label: 'Pending Task', description: 'Markdown task checkboxes', - icon: CheckSquare, - color: '#06b6d4', + icon: EVENT_ICONS['task.pending'], + color: EVENT_COLORS['task.pending'], }, ]; diff --git a/src/renderer/components/CuePipelineEditor/edges/PipelineEdge.tsx b/src/renderer/components/CuePipelineEditor/edges/PipelineEdge.tsx index c7eb5683d5..c93b8c6aad 100644 --- a/src/renderer/components/CuePipelineEditor/edges/PipelineEdge.tsx +++ b/src/renderer/components/CuePipelineEditor/edges/PipelineEdge.tsx @@ -1,7 +1,7 @@ import { memo } from 'react'; import { getBezierPath, BaseEdge, EdgeLabelRenderer, type EdgeProps } from 'reactflow'; import { MessageCircle, FileText } from 'lucide-react'; -import type { EdgeMode } from '../../../../shared/cue-pipeline-types'; +import { CUE_COLOR, type EdgeMode } from '../../../../shared/cue-pipeline-types'; // Inject the pipeline dash animation once into the document head let pipelineDashInjected = false; @@ -37,7 +37,7 @@ export const PipelineEdge = memo(function PipelineEdge({ markerEnd, }: EdgeProps) { ensurePipelineDashStyle(); - const color = data?.pipelineColor ?? '#06b6d4'; + const color = data?.pipelineColor ?? CUE_COLOR; const mode = data?.mode ?? 'pass'; const isActive = data?.isActivePipeline !== false; const isRunning = data?.isRunning ?? false; diff --git a/src/renderer/components/CuePipelineEditor/nodes/TriggerNode.tsx b/src/renderer/components/CuePipelineEditor/nodes/TriggerNode.tsx index b969faefd4..46796fff7f 100644 --- a/src/renderer/components/CuePipelineEditor/nodes/TriggerNode.tsx +++ b/src/renderer/components/CuePipelineEditor/nodes/TriggerNode.tsx @@ -1,16 +1,8 @@ import { memo } from 'react'; import { Handle, Position, type NodeProps } from 'reactflow'; -import { - Clock, - FileText, - GitPullRequest, - GitBranch, - CheckSquare, - Zap, - GripVertical, - Settings, -} from 'lucide-react'; -import type { CueEventType } from '../../../../shared/cue-pipeline-types'; +import { GripVertical, Settings, Zap } from 'lucide-react'; +import { CUE_COLOR, type CueEventType } from '../../../../shared/cue-pipeline-types'; +import { EVENT_COLORS, EVENT_ICONS } from '../cueEventConstants'; export interface TriggerNodeDataProps { compositeId: string; @@ -20,31 +12,11 @@ export interface TriggerNodeDataProps { onConfigure?: (compositeId: string) => void; } -const EVENT_COLORS: Record = { - 'time.heartbeat': '#f59e0b', - 'time.scheduled': '#8b5cf6', - 'file.changed': '#3b82f6', - 'agent.completed': '#22c55e', - 'github.pull_request': '#a855f7', - 'github.issue': '#f97316', - 'task.pending': '#06b6d4', -}; - -const EVENT_ICONS: Record = { - 'time.heartbeat': Clock, - 'time.scheduled': Clock, - 'file.changed': FileText, - 'agent.completed': Zap, - 'github.pull_request': GitPullRequest, - 'github.issue': GitBranch, - 'task.pending': CheckSquare, -}; - export const TriggerNode = memo(function TriggerNode({ data, selected, }: NodeProps) { - const color = EVENT_COLORS[data.eventType] ?? '#06b6d4'; + const color = EVENT_COLORS[data.eventType] ?? CUE_COLOR; const Icon = EVENT_ICONS[data.eventType] ?? Zap; return ( diff --git a/src/renderer/components/CuePipelineEditor/panels/AgentConfigPanel.tsx b/src/renderer/components/CuePipelineEditor/panels/AgentConfigPanel.tsx new file mode 100644 index 0000000000..c037f46df1 --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/panels/AgentConfigPanel.tsx @@ -0,0 +1,291 @@ +/** + * AgentConfigPanel — Configuration panel for agent nodes in the pipeline. + * + * Handles input/output prompts, single-trigger vs multi-trigger modes, + * upstream output inclusion, and pipeline membership display. + */ + +import { useState, useEffect, useCallback } from 'react'; +import { ExternalLink } from 'lucide-react'; +import { + CUE_COLOR, + type PipelineNode, + type AgentNodeData, + type CuePipeline, +} from '../../../../shared/cue-pipeline-types'; +import { useDebouncedCallback } from '../../../hooks/utils'; +import type { IncomingTriggerEdgeInfo } from './NodeConfigPanel'; +import { EdgePromptRow } from './EdgePromptRow'; +import { inputStyle, labelStyle } from './triggers/triggerConfigStyles'; + +interface AgentConfigPanelProps { + node: PipelineNode; + pipelines: CuePipeline[]; + hasOutgoingEdge?: boolean; + hasIncomingAgentEdges?: boolean; + incomingTriggerEdges?: IncomingTriggerEdgeInfo[]; + onUpdateNode: (nodeId: string, data: Partial) => void; + onUpdateEdgePrompt?: (edgeId: string, prompt: string) => void; + onSwitchToAgent?: (sessionId: string) => void; + expanded?: boolean; +} + +export function AgentConfigPanel({ + node, + pipelines, + hasOutgoingEdge, + hasIncomingAgentEdges, + incomingTriggerEdges, + onUpdateNode, + onUpdateEdgePrompt, + onSwitchToAgent, + expanded, +}: AgentConfigPanelProps) { + const data = node.data as AgentNodeData; + const hasMultipleTriggers = (incomingTriggerEdges?.length ?? 0) > 1; + + // Single-trigger mode: use agent node's inputPrompt (existing behavior) + const [localInputPrompt, setLocalInputPrompt] = useState(data.inputPrompt ?? ''); + const [localOutputPrompt, setLocalOutputPrompt] = useState(data.outputPrompt ?? ''); + + useEffect(() => { + setLocalInputPrompt(data.inputPrompt ?? ''); + }, [data.inputPrompt]); + + useEffect(() => { + setLocalOutputPrompt(data.outputPrompt ?? ''); + }, [data.outputPrompt]); + + const { debouncedCallback: debouncedUpdateInput } = useDebouncedCallback((...args: unknown[]) => { + const inputPrompt = args[0] as string; + onUpdateNode(node.id, { inputPrompt } as Partial); + }, 300); + + const { debouncedCallback: debouncedUpdateOutput } = useDebouncedCallback( + (...args: unknown[]) => { + const outputPrompt = args[0] as string; + onUpdateNode(node.id, { outputPrompt } as Partial); + }, + 300 + ); + + const handleInputPromptChange = useCallback( + (e: React.ChangeEvent) => { + setLocalInputPrompt(e.target.value); + debouncedUpdateInput(e.target.value); + }, + [debouncedUpdateInput] + ); + + const handleOutputPromptChange = useCallback( + (e: React.ChangeEvent) => { + setLocalOutputPrompt(e.target.value); + debouncedUpdateOutput(e.target.value); + }, + [debouncedUpdateOutput] + ); + + // Find which pipelines contain this agent + const agentPipelines = pipelines.filter((p) => + p.nodes.some( + (n) => n.type === 'agent' && (n.data as AgentNodeData).sessionId === data.sessionId + ) + ); + + const outputDisabled = !hasOutgoingEdge; + + return ( +
+
+ {/* Input Prompt(s) */} + {hasMultipleTriggers && onUpdateEdgePrompt ? ( +
+ {incomingTriggerEdges!.map((edgeInfo) => ( + + ))} +
+ ) : ( +
+