diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 291f8087f5..af67ef5849 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,7 +1,8 @@ # CodeRabbit configuration # Docs: https://docs.coderabbit.ai/configuration reviews: - # Review PRs targeting both main and rc branches - base_branches: - - main - - rc + auto_review: + # Review PRs targeting both main and rc branches + base_branches: + - main + - rc diff --git a/src/__tests__/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.test.tsx b/src/__tests__/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.test.tsx index 9ab167c538..d0c1dac1c7 100644 --- a/src/__tests__/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.test.tsx +++ b/src/__tests__/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.test.tsx @@ -31,7 +31,7 @@ describe('TriggerDrawer', () => { expect(screen.getByText('Heartbeat')).toBeInTheDocument(); expect(screen.getByText('Scheduled')).toBeInTheDocument(); expect(screen.getByText('File Change')).toBeInTheDocument(); - expect(screen.getByText('Agent Done')).toBeInTheDocument(); + expect(screen.queryByText('Agent Done')).not.toBeInTheDocument(); expect(screen.getByText('Pull Request')).toBeInTheDocument(); expect(screen.getByText('Issue')).toBeInTheDocument(); expect(screen.getByText('Pending Task')).toBeInTheDocument(); @@ -43,7 +43,7 @@ describe('TriggerDrawer', () => { expect(screen.getByText('Run every N minutes')).toBeInTheDocument(); expect(screen.getByText('Run at specific times & days')).toBeInTheDocument(); expect(screen.getByText('Watch for file modifications')).toBeInTheDocument(); - expect(screen.getByText('After an agent finishes')).toBeInTheDocument(); + expect(screen.queryByText('After an agent finishes')).not.toBeInTheDocument(); }); it('should filter triggers by label', () => { @@ -112,6 +112,26 @@ describe('TriggerDrawer', () => { expect(drawer.style.transform).toBe('translateX(0)'); }); + it('should render exactly 6 trigger types (no agent.completed)', () => { + const { container } = render( + {}} theme={mockTheme} /> + ); + + // Each trigger item is a draggable div; count them + const draggableItems = container.querySelectorAll('[draggable="true"]'); + expect(draggableItems.length).toBe(6); + }); + + it('should not show agent.completed when filtering by "agent"', () => { + render( {}} theme={mockTheme} />); + + const input = screen.getByPlaceholderText('Filter triggers...'); + fireEvent.change(input, { target: { value: 'agent' } }); + + // No trigger items should match since agent.completed was removed + expect(screen.getByText('No triggers match')).toBeInTheDocument(); + }); + it('should make trigger items draggable', () => { render( {}} theme={mockTheme} />); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/nodes/TriggerNode.test.tsx b/src/__tests__/renderer/components/CuePipelineEditor/nodes/TriggerNode.test.tsx new file mode 100644 index 0000000000..6cdacca2c6 --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/nodes/TriggerNode.test.tsx @@ -0,0 +1,157 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { TriggerNode } from '../../../../../renderer/components/CuePipelineEditor/nodes/TriggerNode'; +import { ReactFlowProvider } from 'reactflow'; +import type { NodeProps } from 'reactflow'; +import type { TriggerNodeDataProps } from '../../../../../renderer/components/CuePipelineEditor/nodes/TriggerNode'; + +const defaultData: TriggerNodeDataProps = { + compositeId: 'pipeline-1:trigger-0', + eventType: 'time.heartbeat', + label: 'Heartbeat', + configSummary: 'every 5min', +}; + +function renderTriggerNode(overrides: Partial = {}, selected = false) { + const data = { ...defaultData, ...overrides }; + const props = { + id: 'test-trigger', + data, + type: 'trigger', + selected, + isConnectable: true, + xPos: 0, + yPos: 0, + zIndex: 0, + dragging: false, + } as NodeProps; + + return render( + + + + ); +} + +describe('TriggerNode', () => { + it('should render label and config summary', () => { + renderTriggerNode(); + + expect(screen.getByText('Heartbeat')).toBeInTheDocument(); + expect(screen.getByText('every 5min')).toBeInTheDocument(); + }); + + it('should render a drag handle', () => { + const { container } = renderTriggerNode(); + const dragHandle = container.querySelector('.drag-handle'); + expect(dragHandle).not.toBeNull(); + }); + + it('should render a gear icon for configuration', () => { + const { container } = renderTriggerNode(); + const gearButton = container.querySelector('[title="Configure"]'); + expect(gearButton).not.toBeNull(); + }); + + it('should show title tooltip on the label span', () => { + renderTriggerNode({ label: 'My Custom Label' }); + + const labelSpan = screen.getByText('My Custom Label'); + expect(labelSpan).toHaveAttribute('title', 'My Custom Label'); + }); + + it('should show title tooltip on the config summary span', () => { + renderTriggerNode({ configSummary: 'every 10min' }); + + const summarySpan = screen.getByText('every 10min'); + expect(summarySpan).toHaveAttribute('title', 'every 10min'); + }); + + it('should show tooltip with full text for long labels', () => { + const longLabel = 'This is a very long trigger label that will be truncated'; + renderTriggerNode({ label: longLabel }); + + const labelSpan = screen.getByText(longLabel); + expect(labelSpan).toHaveAttribute('title', longLabel); + }); + + it('should show tooltip with full text for long config summaries', () => { + const longSummary = 'Mon, Tue, Wed, Thu, Fri at 09:00, 12:00, 15:00, 18:00'; + renderTriggerNode({ configSummary: longSummary }); + + const summarySpan = screen.getByText(longSummary); + expect(summarySpan).toHaveAttribute('title', longSummary); + }); + + it('should use minWidth and maxWidth instead of fixed width', () => { + const { container } = renderTriggerNode(); + + const rootDiv = container.querySelector('div[style*="min-width: 220px"]') as HTMLElement; + expect(rootDiv).not.toBeNull(); + expect(rootDiv.style.maxWidth).toBe('320px'); + // Ensure no fixed width is set + expect(rootDiv.style.width).toBe(''); + }); + + it('should not render config summary when empty', () => { + renderTriggerNode({ configSummary: '' }); + + // The summary span should not be in the DOM + expect(screen.queryByText('every 5min')).not.toBeInTheDocument(); + }); + + it('should call onConfigure when gear icon is clicked', () => { + const onConfigure = vi.fn(); + const { container } = renderTriggerNode({ + onConfigure, + compositeId: 'pipeline-1:trigger-0', + }); + + const gearButton = container.querySelector('[title="Configure"]') as HTMLElement; + gearButton.click(); + + expect(onConfigure).toHaveBeenCalledWith('pipeline-1:trigger-0'); + }); + + it('should apply selection styling when selected', () => { + const { container: selectedContainer } = renderTriggerNode({}, true); + const { container: unselectedContainer } = renderTriggerNode({}, false); + + const selectedRoot = selectedContainer.querySelector( + 'div[style*="min-width: 220px"]' + ) as HTMLElement; + const unselectedRoot = unselectedContainer.querySelector( + 'div[style*="min-width: 220px"]' + ) as HTMLElement; + + // Selected and unselected should have different border colors + expect(selectedRoot.style.borderColor).not.toBe(unselectedRoot.style.borderColor); + // Selected should have a box shadow, unselected should not + expect(selectedRoot.style.boxShadow).toBeTruthy(); + expect(unselectedRoot.style.boxShadow).toBeFalsy(); + }); + + it('should use correct color for each event type', () => { + const eventColors: Record = { + 'time.heartbeat': '#f59e0b', + 'time.scheduled': '#8b5cf6', + 'file.changed': '#3b82f6', + 'agent.completed': '#22c55e', + 'github.pull_request': '#a855f7', + 'github.issue': '#f97316', + 'task.pending': '#06b6d4', + }; + + for (const [eventType, expectedColor] of Object.entries(eventColors)) { + const { unmount } = renderTriggerNode({ + eventType: eventType as TriggerNodeDataProps['eventType'], + label: eventType, + }); + + const labelSpan = screen.getByText(eventType); + expect(labelSpan).toHaveStyle({ color: expectedColor }); + + unmount(); + } + }); +}); diff --git a/src/__tests__/renderer/components/History/ActivityGraph.test.tsx b/src/__tests__/renderer/components/History/ActivityGraph.test.tsx index b732dc3a9b..b4c17ca159 100644 --- a/src/__tests__/renderer/components/History/ActivityGraph.test.tsx +++ b/src/__tests__/renderer/components/History/ActivityGraph.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, fireEvent, act } from '@testing-library/react'; +import { render, screen, fireEvent, act, within } from '@testing-library/react'; import { ActivityGraph } from '../../../../renderer/components/History'; import type { Theme, HistoryEntry, HistoryEntryType } from '../../../../renderer/types'; @@ -325,4 +325,162 @@ describe('ActivityGraph', () => { const graphContainer = screen.getByTitle(/1 auto, 1 user/); expect(graphContainer).toBeInTheDocument(); }); + + it('counts CUE entries in buckets', () => { + const entries = [ + createMockEntry({ type: 'CUE', timestamp: NOW - 30 * 60 * 1000 }), + createMockEntry({ type: 'CUE', timestamp: NOW - 45 * 60 * 1000 }), + createMockEntry({ type: 'AUTO', timestamp: NOW - 35 * 60 * 1000 }), + ]; + + const { container } = render( + + ); + + // Hover over the last bucket to see tooltip + const bars = container.querySelectorAll('.flex-1.min-w-0.flex.flex-col.justify-end'); + const lastBar = bars[bars.length - 1]; + fireEvent.mouseEnter(lastBar); + + // Should show Cue row in tooltip with count scoped to the Cue row + const cueLabel = screen.getByText('Cue'); + expect(cueLabel).toBeInTheDocument(); + const cueRow = cueLabel.closest('div')!; + expect(within(cueRow).getByText('2')).toBeInTheDocument(); + }); + + it('shows Cue row with zero count in tooltip when bucket has no CUE entries', () => { + const entries = [ + createMockEntry({ type: 'AUTO', timestamp: NOW - 30 * 60 * 1000 }), + createMockEntry({ type: 'USER', timestamp: NOW - 35 * 60 * 1000 }), + ]; + + const { container } = render( + + ); + + const bars = container.querySelectorAll('.flex-1.min-w-0.flex.flex-col.justify-end'); + const lastBar = bars[bars.length - 1]; + fireEvent.mouseEnter(lastBar); + + // All three rows should appear, Cue with 0 + expect(screen.getByText('Auto')).toBeInTheDocument(); + expect(screen.getByText('User')).toBeInTheDocument(); + const cueLabel = screen.getByText('Cue'); + expect(cueLabel).toBeInTheDocument(); + const cueRow = cueLabel.closest('div')!; + expect(within(cueRow).getByText('0')).toBeInTheDocument(); + }); + + it('includes CUE count in summary title when present', () => { + const entries = [ + createMockEntry({ type: 'AUTO', timestamp: NOW - 1 * 60 * 60 * 1000 }), + createMockEntry({ type: 'CUE', timestamp: NOW - 2 * 60 * 60 * 1000 }), + ]; + + render( + + ); + + const graphContainer = screen.getByTitle(/1 auto, 0 user, 1 cue/); + expect(graphContainer).toBeInTheDocument(); + }); + + it('excludes CUE count from summary title when zero', () => { + const entries = [createMockEntry({ type: 'AUTO', timestamp: NOW - 1 * 60 * 60 * 1000 })]; + + render( + + ); + + // Title should NOT contain "cue" + const graphContainer = screen.getByTitle(/1 auto, 0 user \(right-click/); + expect(graphContainer).toBeInTheDocument(); + }); + + it('makes CUE-only bars clickable', () => { + const onBarClick = vi.fn(); + const entries = [createMockEntry({ type: 'CUE', timestamp: NOW - 30 * 60 * 1000 })]; + + const { container } = render( + + ); + + const bars = container.querySelectorAll('.flex-1.min-w-0.flex.flex-col.justify-end'); + const lastBar = bars[bars.length - 1]; + fireEvent.click(lastBar); + + expect(onBarClick).toHaveBeenCalledWith(expect.any(Number), expect.any(Number)); + }); + + it('renders CUE bar segment with correct color in tooltip', () => { + const entries = [createMockEntry({ type: 'CUE', timestamp: NOW - 30 * 60 * 1000 })]; + + const { container } = render( + + ); + + // Hover to show tooltip, then verify CUE label uses the cyan color + const bars = container.querySelectorAll('.flex-1.min-w-0.flex.flex-col.justify-end'); + const lastBar = bars[bars.length - 1]; + fireEvent.mouseEnter(lastBar); + + const cueLabel = screen.getByText('Cue'); + expect(cueLabel).toHaveStyle({ color: '#06b6d4' }); + }); + + it('scales bar height correctly with mixed AUTO, USER, and CUE entries', () => { + const entries = [ + createMockEntry({ type: 'AUTO', timestamp: NOW - 30 * 60 * 1000 }), + createMockEntry({ type: 'USER', timestamp: NOW - 30 * 60 * 1000 }), + createMockEntry({ type: 'CUE', timestamp: NOW - 30 * 60 * 1000 }), + ]; + + const { container } = render( + + ); + + // The bar with all 3 entries should be at 100% height (it's the max) + const bars = container.querySelectorAll('.flex-1.min-w-0.flex.flex-col.justify-end'); + const lastBar = bars[bars.length - 1]; + const barInner = lastBar.querySelector('.w-full.rounded-t-sm') as HTMLElement; + // height should be 100% since this is the max bucket + expect(barInner.style.height).toBe('100%'); + }); }); diff --git a/src/__tests__/renderer/components/SessionItemCue.test.tsx b/src/__tests__/renderer/components/SessionItemCue.test.tsx index 6b2f5a6a23..0e0bbff2d3 100644 --- a/src/__tests__/renderer/components/SessionItemCue.test.tsx +++ b/src/__tests__/renderer/components/SessionItemCue.test.tsx @@ -137,6 +137,80 @@ describe('SessionItem Cue Indicator', () => { expect(zapIcon.style.color).toBe('rgb(45, 212, 191)'); }); + it('applies animate-pulse animation class when cueActiveRun is true', () => { + render( + + ); + + const zapIcon = screen.getByTestId('icon-zap'); + const wrapper = zapIcon.closest('span[title]'); + expect(wrapper).toHaveClass('animate-pulse'); + }); + + it('does not apply animate-pulse class when cueActiveRun is false', () => { + render( + + ); + + const zapIcon = screen.getByTestId('icon-zap'); + const wrapper = zapIcon.closest('span[title]'); + expect(wrapper).not.toHaveClass('animate-pulse'); + }); + + it('does not apply animate-pulse class when cueActiveRun is undefined', () => { + render( + + ); + + const zapIcon = screen.getByTestId('icon-zap'); + const wrapper = zapIcon.closest('span[title]'); + expect(wrapper).not.toHaveClass('animate-pulse'); + }); + + it('shows "running" in tooltip when cueActiveRun is true', () => { + render( + + ); + + const zapIcon = screen.getByTestId('icon-zap'); + expect(zapIcon.closest('span[title]')).toHaveAttribute( + 'title', + 'Maestro Cue running (2 subscriptions)' + ); + }); + + it('shows "active" in tooltip when cueActiveRun is false', () => { + render( + + ); + + const zapIcon = screen.getByTestId('icon-zap'); + expect(zapIcon.closest('span[title]')).toHaveAttribute( + 'title', + 'Maestro Cue active (2 subscriptions)' + ); + }); + it('does not show Zap icon when session is in editing mode', () => { render( @@ -124,8 +127,8 @@ export const TriggerNode = memo(function TriggerNode({ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', - maxWidth: 110, }} + title={data.label} > {data.label} @@ -139,8 +142,9 @@ export const TriggerNode = memo(function TriggerNode({ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', - maxWidth: 130, + maxWidth: '100%', }} + title={data.configSummary} > {data.configSummary} diff --git a/src/renderer/components/History/ActivityGraph.tsx b/src/renderer/components/History/ActivityGraph.tsx index fe17cb5b7d..46783c271c 100644 --- a/src/renderer/components/History/ActivityGraph.tsx +++ b/src/renderer/components/History/ActivityGraph.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef, useMemo } from 'react'; import { Check } from 'lucide-react'; import type { Theme, HistoryEntry } from '../../types'; -import { LOOKBACK_OPTIONS } from './historyConstants'; +import { LOOKBACK_OPTIONS, CUE_COLOR } from './historyConstants'; // Activity bar graph component with configurable lookback window export interface ActivityGraphProps { @@ -61,10 +61,14 @@ export const ActivityGraph: React.FC = ({ // Group entries into buckets const bucketData = useMemo(() => { - const buckets: { auto: number; user: number }[] = Array.from({ length: bucketCount }, () => ({ - auto: 0, - user: 0, - })); + const buckets: { auto: number; user: number; cue: number }[] = Array.from( + { length: bucketCount }, + () => ({ + auto: 0, + user: 0, + cue: 0, + }) + ); entries.forEach((entry) => { if (entry.timestamp >= startTime && entry.timestamp <= endTime) { @@ -77,6 +81,8 @@ export const ActivityGraph: React.FC = ({ buckets[bucketIndex].auto++; } else if (entry.type === 'USER') { buckets[bucketIndex].user++; + } else if (entry.type === 'CUE') { + buckets[bucketIndex].cue++; } } } @@ -87,12 +93,13 @@ export const ActivityGraph: React.FC = ({ // Find max value for scaling const maxValue = useMemo(() => { - return Math.max(1, ...bucketData.map((h) => h.auto + h.user)); + return Math.max(1, ...bucketData.map((h) => h.auto + h.user + h.cue)); }, [bucketData]); // Total counts for summary tooltip const totalAuto = useMemo(() => bucketData.reduce((sum, h) => sum + h.auto, 0), [bucketData]); const totalUser = useMemo(() => bucketData.reduce((sum, h) => sum + h.user, 0), [bucketData]); + const totalCue = useMemo(() => bucketData.reduce((sum, h) => sum + h.cue, 0), [bucketData]); // Get time range label for tooltip const getTimeRangeLabel = (index: number) => { @@ -131,7 +138,7 @@ export const ActivityGraph: React.FC = ({ // Handle bar click const handleBarClick = (index: number) => { - const total = bucketData[index].auto + bucketData[index].user; + const total = bucketData[index].auto + bucketData[index].user + bucketData[index].cue; if (total > 0 && onBarClick) { const { start, end } = getBucketTimeRange(index); onBarClick(start, end); @@ -216,7 +223,7 @@ export const ActivityGraph: React.FC = ({ className="flex-1 min-w-0 flex flex-col relative mt-0.5" title={ hoveredIndex === null - ? `${isHistorical ? `Viewing: ${formatReferenceTime()} • ` : ''}${lookbackConfig.label}: ${totalAuto} auto, ${totalUser} user (right-click to change)` + ? `${isHistorical ? `Viewing: ${formatReferenceTime()} • ` : ''}${lookbackConfig.label}: ${totalAuto} auto, ${totalUser} user${totalCue > 0 ? `, ${totalCue} cue` : ''} (right-click to change)` : undefined } onContextMenu={handleContextMenu} @@ -298,6 +305,12 @@ export const ActivityGraph: React.FC = ({ {bucketData[hoveredIndex].user} +
+ Cue + + {bucketData[hoveredIndex].cue} + +
)} @@ -308,9 +321,10 @@ export const ActivityGraph: React.FC = ({ style={{ borderColor: theme.colors.border }} > {bucketData.map((bucket, index) => { - const total = bucket.auto + bucket.user; + const total = bucket.auto + bucket.user + bucket.cue; const heightPercent = total > 0 ? (total / maxValue) * 100 : 0; const autoPercent = total > 0 ? (bucket.auto / total) * 100 : 0; + const cuePercent = total > 0 ? (bucket.cue / total) * 100 : 0; const userPercent = total > 0 ? (bucket.user / total) * 100 : 0; const isHovered = hoveredIndex === index; @@ -347,6 +361,16 @@ export const ActivityGraph: React.FC = ({ }} /> )} + {/* Cue portion (middle) - cyan */} + {bucket.cue > 0 && ( +
+ )} {/* User portion (top) - accent color */} {bucket.user > 0 && (
{ - switch (type) { - case 'AUTO': - return { - bg: theme.colors.warning + '20', - text: theme.colors.warning, - border: theme.colors.warning + '40', - }; - case 'USER': - return { - bg: theme.colors.accent + '20', - text: theme.colors.accent, - border: theme.colors.accent + '40', - }; - case 'CUE': - return { - bg: '#06b6d420', - text: '#06b6d4', - border: '#06b6d440', - }; - default: - return { - bg: theme.colors.bgActivity, - text: theme.colors.textDim, - border: theme.colors.border, - }; - } -}; - -// Get icon for entry type -const getEntryIcon = (type: HistoryEntryType) => { - switch (type) { - case 'AUTO': - return Bot; - case 'USER': - return User; - case 'CUE': - return Zap; - default: - return Bot; - } -}; +import { DoubleCheck, getPillColor, getEntryIcon } from './historyConstants'; // Format timestamp const formatTime = (timestamp: number) => { diff --git a/src/renderer/components/History/HistoryFilterToggle.tsx b/src/renderer/components/History/HistoryFilterToggle.tsx index 41d8ab88d2..c39896d4b0 100644 --- a/src/renderer/components/History/HistoryFilterToggle.tsx +++ b/src/renderer/components/History/HistoryFilterToggle.tsx @@ -1,6 +1,6 @@ import { memo } from 'react'; -import { Bot, User, Zap } from 'lucide-react'; import type { Theme, HistoryEntryType } from '../../types'; +import { getPillColor, getEntryIcon } from './historyConstants'; export interface HistoryFilterToggleProps { activeFilters: Set; @@ -10,50 +10,6 @@ export interface HistoryFilterToggleProps { visibleTypes?: HistoryEntryType[]; } -// Get pill color based on type -const getPillColor = (type: HistoryEntryType, theme: Theme) => { - switch (type) { - case 'AUTO': - return { - bg: theme.colors.warning + '20', - text: theme.colors.warning, - border: theme.colors.warning + '40', - }; - case 'USER': - return { - bg: theme.colors.accent + '20', - text: theme.colors.accent, - border: theme.colors.accent + '40', - }; - case 'CUE': - return { - bg: '#06b6d420', - text: '#06b6d4', - border: '#06b6d440', - }; - default: - return { - bg: theme.colors.bgActivity, - text: theme.colors.textDim, - border: theme.colors.border, - }; - } -}; - -// Get icon for entry type -const getEntryIcon = (type: HistoryEntryType) => { - switch (type) { - case 'AUTO': - return Bot; - case 'USER': - return User; - case 'CUE': - return Zap; - default: - return Bot; - } -}; - const ALL_TYPES: HistoryEntryType[] = ['AUTO', 'USER', 'CUE']; export const HistoryFilterToggle = memo(function HistoryFilterToggle({ diff --git a/src/renderer/components/History/historyConstants.tsx b/src/renderer/components/History/historyConstants.tsx index f1f739d304..7e7b2067b6 100644 --- a/src/renderer/components/History/historyConstants.tsx +++ b/src/renderer/components/History/historyConstants.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import { Bot, User, Zap } from 'lucide-react'; +import type { Theme, HistoryEntryType } from '../../types'; // Double checkmark SVG component for validated entries export const DoubleCheck = ({ @@ -41,6 +43,53 @@ export const LOOKBACK_OPTIONS: LookbackPeriod[] = [ { label: 'All time', hours: null, bucketCount: 24 }, ]; +/** Base CUE brand color — used in ActivityGraph, HistoryFilterToggle, HistoryEntryItem */ +export const CUE_COLOR = '#06b6d4'; + +/** Get pill color scheme based on entry type */ +export const getPillColor = (type: HistoryEntryType, theme: Theme) => { + switch (type) { + case 'AUTO': + return { + bg: theme.colors.warning + '20', + text: theme.colors.warning, + border: theme.colors.warning + '40', + }; + case 'USER': + return { + bg: theme.colors.accent + '20', + text: theme.colors.accent, + border: theme.colors.accent + '40', + }; + case 'CUE': + return { + bg: CUE_COLOR + '20', + text: CUE_COLOR, + border: CUE_COLOR + '40', + }; + default: + return { + bg: theme.colors.bgActivity, + text: theme.colors.textDim, + border: theme.colors.border, + }; + } +}; + +/** Get icon component for entry type */ +export const getEntryIcon = (type: HistoryEntryType) => { + switch (type) { + case 'AUTO': + return Bot; + case 'USER': + return User; + case 'CUE': + return Zap; + default: + return Bot; + } +}; + // Constants for history pagination export const MAX_HISTORY_IN_MEMORY = 500; // Maximum entries to keep in memory diff --git a/src/renderer/components/SessionItem.tsx b/src/renderer/components/SessionItem.tsx index 7af06893cc..3de8c1c31a 100644 --- a/src/renderer/components/SessionItem.tsx +++ b/src/renderer/components/SessionItem.tsx @@ -36,6 +36,7 @@ export interface SessionItemProps { isInBatch?: boolean; jumpNumber?: string | null; // Session jump shortcut number (1-9, 0) cueSubscriptionCount?: number; // Number of active Cue subscriptions (0 or undefined = no indicator) + cueActiveRun?: boolean; // Whether a Cue pipeline is currently running for this agent // Handlers onSelect: () => void; @@ -77,6 +78,7 @@ export const SessionItem = memo(function SessionItem({ isInBatch = false, jumpNumber, cueSubscriptionCount, + cueActiveRun, onSelect, onDragStart, onDragOver, @@ -160,8 +162,8 @@ export const SessionItem = memo(function SessionItem({ {cueSubscriptionCount != null && cueSubscriptionCount > 0 && ( diff --git a/src/renderer/components/SessionList/SessionList.tsx b/src/renderer/components/SessionList/SessionList.tsx index fb9d0e4ff2..505edf152e 100644 --- a/src/renderer/components/SessionList/SessionList.tsx +++ b/src/renderer/components/SessionList/SessionList.tsx @@ -132,8 +132,10 @@ function SessionListInner(props: SessionListProps) { const maestroCueEnabled = useSettingsStore((s) => s.encoreFeatures.maestroCue); const activeBatchSessionIds = useBatchStore(useShallow(selectActiveBatchSessionIds)); - // Cue session status map: sessionId → subscriptionCount (only active when Encore Feature enabled) - const [cueSessionMap, setCueSessionMap] = useState>(new Map()); + // Cue session status map: sessionId → { count, active } (only active when Encore Feature enabled) + const [cueSessionMap, setCueSessionMap] = useState< + Map + >(new Map()); useEffect(() => { if (!maestroCueEnabled) { setCueSessionMap(new Map()); @@ -146,10 +148,13 @@ function SessionListInner(props: SessionListProps) { try { const statuses = await window.maestro.cue.getStatus(); if (!mounted) return; - const map = new Map(); + const map = new Map(); for (const s of statuses) { if (s.subscriptionCount > 0) { - map.set(s.sessionId, s.subscriptionCount); + map.set(s.sessionId, { + count: s.subscriptionCount, + active: s.activeRuns > 0, + }); } } setCueSessionMap(map); @@ -520,7 +525,8 @@ function SessionListInner(props: SessionListProps) { gitFileCount={getFileCount(session.id)} isInBatch={activeBatchSessionIds.includes(session.id)} jumpNumber={getSessionJumpNumber(session.id)} - cueSubscriptionCount={cueSessionMap.get(session.id)} + cueSubscriptionCount={cueSessionMap.get(session.id)?.count} + cueActiveRun={cueSessionMap.get(session.id)?.active} onSelect={selectHandlers.get(session.id)!} onDragStart={dragStartHandlers.get(session.id)!} onDragOver={handleDragOver} @@ -586,7 +592,8 @@ function SessionListInner(props: SessionListProps) { gitFileCount={getFileCount(child.id)} isInBatch={activeBatchSessionIds.includes(child.id)} jumpNumber={getSessionJumpNumber(child.id)} - cueSubscriptionCount={cueSessionMap.get(child.id)} + cueSubscriptionCount={cueSessionMap.get(child.id)?.count} + cueActiveRun={cueSessionMap.get(child.id)?.active} onSelect={selectHandlers.get(child.id)!} onDragStart={dragStartHandlers.get(child.id)!} onContextMenu={contextMenuHandlers.get(child.id)!}