mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 10:23:07 +00:00
Improve auto-loop event emission and add ntfy notifications (#821)
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { Bell, Check, Trash2 } from 'lucide-react';
|
||||
import { Bell, Check, Trash2, AlertCircle } from 'lucide-react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useNotificationsStore } from '@/store/notifications-store';
|
||||
import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notification-events';
|
||||
@@ -11,25 +11,7 @@ import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import type { Notification } from '@automaker/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Format a date as relative time (e.g., "2 minutes ago", "3 hours ago")
|
||||
*/
|
||||
function formatRelativeTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) return 'just now';
|
||||
if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
|
||||
if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
|
||||
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
import { cn, formatRelativeTime } from '@/lib/utils';
|
||||
|
||||
interface NotificationBellProps {
|
||||
projectPath: string | null;
|
||||
@@ -86,7 +68,7 @@ export function NotificationBell({ projectPath }: NotificationBellProps) {
|
||||
|
||||
// Navigate to the relevant view based on notification type
|
||||
if (notification.featureId) {
|
||||
navigate({ to: '/board' });
|
||||
navigate({ to: '/board', search: { featureId: notification.featureId } });
|
||||
}
|
||||
},
|
||||
[handleMarkAsRead, setPopoverOpen, navigate]
|
||||
@@ -105,6 +87,10 @@ export function NotificationBell({ projectPath }: NotificationBellProps) {
|
||||
return <Check className="h-4 w-4 text-green-500" />;
|
||||
case 'spec_regeneration_complete':
|
||||
return <Check className="h-4 w-4 text-blue-500" />;
|
||||
case 'feature_error':
|
||||
return <AlertCircle className="h-4 w-4 text-red-500" />;
|
||||
case 'auto_mode_error':
|
||||
return <AlertCircle className="h-4 w-4 text-red-500" />;
|
||||
default:
|
||||
return <Bell className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
@@ -195,8 +195,10 @@ export function SessionManager({
|
||||
if (result.success && result.session?.id) {
|
||||
setNewSessionName('');
|
||||
setIsCreating(false);
|
||||
await invalidateSessions();
|
||||
// Select the new session immediately before invalidating the cache to avoid
|
||||
// a race condition where the cache re-render resets the selected session.
|
||||
onSelectSession(result.session.id);
|
||||
await invalidateSessions();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -210,8 +212,10 @@ export function SessionManager({
|
||||
const result = await api.sessions.create(sessionName, projectPath, effectiveWorkingDirectory);
|
||||
|
||||
if (result.success && result.session?.id) {
|
||||
await invalidateSessions();
|
||||
// Select the new session immediately before invalidating the cache to avoid
|
||||
// a race condition where the cache re-render resets the selected session.
|
||||
onSelectSession(result.session.id);
|
||||
await invalidateSessions();
|
||||
}
|
||||
}, [effectiveWorkingDirectory, projectPath, invalidateSessions, onSelectSession]);
|
||||
|
||||
|
||||
@@ -114,7 +114,12 @@ const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWo
|
||||
|
||||
const logger = createLogger('Board');
|
||||
|
||||
export function BoardView() {
|
||||
interface BoardViewProps {
|
||||
/** Feature ID from URL parameter - if provided, opens output modal for this feature on load */
|
||||
initialFeatureId?: string;
|
||||
}
|
||||
|
||||
export function BoardView({ initialFeatureId }: BoardViewProps) {
|
||||
const {
|
||||
currentProject,
|
||||
defaultSkipTests,
|
||||
@@ -300,6 +305,93 @@ export function BoardView() {
|
||||
setFeaturesWithContext,
|
||||
});
|
||||
|
||||
// Handle initial feature ID from URL - switch to the correct worktree and open output modal
|
||||
// Uses a ref to track which featureId has been handled to prevent re-opening
|
||||
// when the component re-renders but initialFeatureId hasn't changed.
|
||||
// We read worktrees from the store reactively so this effect re-runs once worktrees load.
|
||||
const handledFeatureIdRef = useRef<string | undefined>(undefined);
|
||||
|
||||
// Reset the handled ref whenever initialFeatureId changes (including to undefined),
|
||||
// so navigating to the same featureId again after clearing works correctly.
|
||||
useEffect(() => {
|
||||
handledFeatureIdRef.current = undefined;
|
||||
}, [initialFeatureId]);
|
||||
const deepLinkWorktrees = useAppStore(
|
||||
useCallback(
|
||||
(s) =>
|
||||
currentProject?.path
|
||||
? (s.worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES)
|
||||
: EMPTY_WORKTREES,
|
||||
[currentProject?.path]
|
||||
)
|
||||
);
|
||||
useEffect(() => {
|
||||
if (
|
||||
!initialFeatureId ||
|
||||
handledFeatureIdRef.current === initialFeatureId ||
|
||||
isLoading ||
|
||||
!hookFeatures.length ||
|
||||
!currentProject?.path
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const feature = hookFeatures.find((f) => f.id === initialFeatureId);
|
||||
if (!feature) return;
|
||||
|
||||
// If the feature has a branch, wait for worktrees to load so we can switch
|
||||
if (feature.branchName && deepLinkWorktrees.length === 0) {
|
||||
return; // Worktrees not loaded yet - effect will re-run when they load
|
||||
}
|
||||
|
||||
// Switch to the correct worktree based on the feature's branchName
|
||||
if (feature.branchName && deepLinkWorktrees.length > 0) {
|
||||
const targetWorktree = deepLinkWorktrees.find((w) => w.branch === feature.branchName);
|
||||
if (targetWorktree) {
|
||||
const currentWt = useAppStore.getState().getCurrentWorktree(currentProject.path);
|
||||
const isAlreadySelected = targetWorktree.isMain
|
||||
? currentWt?.path === null
|
||||
: currentWt?.path === targetWorktree.path;
|
||||
if (!isAlreadySelected) {
|
||||
logger.info(
|
||||
`Deep link: switching to worktree "${targetWorktree.branch}" for feature ${initialFeatureId}`
|
||||
);
|
||||
setCurrentWorktree(
|
||||
currentProject.path,
|
||||
targetWorktree.isMain ? null : targetWorktree.path,
|
||||
targetWorktree.branch
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (!feature.branchName && deepLinkWorktrees.length > 0) {
|
||||
// Feature has no branch - should be on the main worktree
|
||||
const currentWt = useAppStore.getState().getCurrentWorktree(currentProject.path);
|
||||
if (currentWt?.path !== null && currentWt !== null) {
|
||||
const mainWorktree = deepLinkWorktrees.find((w) => w.isMain);
|
||||
if (mainWorktree) {
|
||||
logger.info(
|
||||
`Deep link: switching to main worktree for unassigned feature ${initialFeatureId}`
|
||||
);
|
||||
setCurrentWorktree(currentProject.path, null, mainWorktree.branch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Opening output modal for feature from URL: ${initialFeatureId}`);
|
||||
setOutputFeature(feature);
|
||||
setShowOutputModal(true);
|
||||
handledFeatureIdRef.current = initialFeatureId;
|
||||
}, [
|
||||
initialFeatureId,
|
||||
isLoading,
|
||||
hookFeatures,
|
||||
currentProject?.path,
|
||||
deepLinkWorktrees,
|
||||
setCurrentWorktree,
|
||||
setOutputFeature,
|
||||
setShowOutputModal,
|
||||
]);
|
||||
|
||||
// Load pipeline config when project changes
|
||||
useEffect(() => {
|
||||
if (!currentProject?.path) return;
|
||||
@@ -1988,7 +2080,10 @@ export function BoardView() {
|
||||
{/* Agent Output Modal */}
|
||||
<AgentOutputModal
|
||||
open={showOutputModal}
|
||||
onClose={() => setShowOutputModal(false)}
|
||||
onClose={() => {
|
||||
setShowOutputModal(false);
|
||||
handledFeatureIdRef.current = undefined;
|
||||
}}
|
||||
featureDescription={outputFeature?.description || ''}
|
||||
featureId={outputFeature?.id || ''}
|
||||
featureStatus={outputFeature?.status}
|
||||
|
||||
@@ -85,7 +85,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
Map<string, 'pending' | 'in_progress' | 'completed'>
|
||||
>(new Map());
|
||||
// Track real-time task summary updates from WebSocket events
|
||||
const [taskSummaryMap, setTaskSummaryMap] = useState<Map<string, string>>(new Map());
|
||||
const [taskSummaryMap, setTaskSummaryMap] = useState<Map<string, string | null>>(new Map());
|
||||
// Track last WebSocket event timestamp to know if we're receiving real-time updates
|
||||
const [lastWsEventTimestamp, setLastWsEventTimestamp] = useState<number | null>(null);
|
||||
|
||||
@@ -200,7 +200,11 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
// Derive effective todos from planSpec.tasks when available, fallback to agentInfo.todos
|
||||
// Uses freshPlanSpec (from API) for accurate progress, with taskStatusMap for real-time updates
|
||||
const isFeatureFinished = feature.status === 'waiting_approval' || feature.status === 'verified';
|
||||
const effectiveTodos = useMemo(() => {
|
||||
const effectiveTodos = useMemo((): {
|
||||
content: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
summary?: string | null;
|
||||
}[] => {
|
||||
// Use freshPlanSpec if available (fetched from API), fallback to store's feature.planSpec
|
||||
const planSpec = freshPlanSpec?.tasks?.length ? freshPlanSpec : feature.planSpec;
|
||||
|
||||
@@ -250,7 +254,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
return {
|
||||
content: task.description,
|
||||
status: effectiveStatus,
|
||||
summary: realtimeSummary ?? task.summary,
|
||||
summary: taskSummaryMap.has(task.id) ? realtimeSummary : task.summary,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -240,6 +240,12 @@ export const ListView = memo(function ListView({
|
||||
const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts);
|
||||
const addFeatureShortcut = keyboardShortcuts.addFeature || 'N';
|
||||
|
||||
// Effective sort config: when sortNewestCardOnTop is enabled, sort by createdAt desc
|
||||
const effectiveSortConfig: SortConfig = useMemo(
|
||||
() => (sortNewestCardOnTop ? { column: 'createdAt', direction: 'desc' } : sortConfig),
|
||||
[sortNewestCardOnTop, sortConfig]
|
||||
);
|
||||
|
||||
// Generate status groups from columnFeaturesMap
|
||||
const statusGroups = useMemo<StatusGroup[]>(() => {
|
||||
// Effective sort config: when sortNewestCardOnTop is enabled, sort by createdAt desc
|
||||
@@ -454,7 +460,7 @@ export const ListView = memo(function ListView({
|
||||
>
|
||||
{/* Table header */}
|
||||
<ListHeader
|
||||
sortConfig={sortConfig}
|
||||
sortConfig={effectiveSortConfig}
|
||||
onSortChange={onSortChange}
|
||||
showCheckbox={isSelectionMode}
|
||||
allSelected={selectionState.allSelected}
|
||||
|
||||
@@ -20,7 +20,14 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { GitPullRequest, ExternalLink, Sparkles, RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
GitPullRequest,
|
||||
ExternalLink,
|
||||
Sparkles,
|
||||
RefreshCw,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
@@ -93,6 +100,7 @@ export function CreatePRDialog({
|
||||
|
||||
// Generate description state
|
||||
const [isGeneratingDescription, setIsGeneratingDescription] = useState(false);
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||
|
||||
// PR description model override
|
||||
const prDescriptionModelOverride = useModelOverride({ phase: 'prDescriptionModel' });
|
||||
@@ -286,6 +294,7 @@ export function CreatePRDialog({
|
||||
setSelectedRemote('');
|
||||
setSelectedTargetRemote('');
|
||||
setIsGeneratingDescription(false);
|
||||
setIsDescriptionExpanded(false);
|
||||
operationCompletedRef.current = false;
|
||||
}, [defaultBaseBranch]);
|
||||
|
||||
@@ -642,13 +651,28 @@ export function CreatePRDialog({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="pr-body">Description</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="pr-body">Description</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsDescriptionExpanded(!isDescriptionExpanded)}
|
||||
className="h-6 px-2 text-xs"
|
||||
title={isDescriptionExpanded ? 'Collapse description' : 'Expand description'}
|
||||
>
|
||||
{isDescriptionExpanded ? (
|
||||
<Minimize2 className="w-3 h-3" />
|
||||
) : (
|
||||
<Maximize2 className="w-3 h-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
id="pr-body"
|
||||
placeholder="Describe the changes in this PR..."
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
className="min-h-[80px]"
|
||||
className={isDescriptionExpanded ? 'min-h-[300px]' : 'min-h-[80px]'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -32,6 +32,18 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
/**
|
||||
* Qualify a branch name with a remote prefix when appropriate.
|
||||
* Returns undefined when branch is empty, and avoids double-prefixing.
|
||||
*/
|
||||
function qualifyRemoteBranch(remote: string, branch?: string): string | undefined {
|
||||
const trimmed = branch?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (remote === 'local') return trimmed;
|
||||
if (trimmed.startsWith(`${remote}/`)) return trimmed;
|
||||
return `${remote}/${trimmed}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse git/worktree error messages and return user-friendly versions
|
||||
*/
|
||||
@@ -264,19 +276,21 @@ export function CreateWorktreeDialog({
|
||||
return availableBranches.filter((b) => !b.isRemote).map((b) => b.name);
|
||||
}
|
||||
|
||||
// If a specific remote is selected, show only branches from that remote
|
||||
// If a specific remote is selected, show only branches from that remote (without remote prefix)
|
||||
const remoteBranchList = remoteBranches.get(selectedRemote);
|
||||
if (remoteBranchList) {
|
||||
return remoteBranchList.map((b) => b.fullRef);
|
||||
return remoteBranchList.map((b) => b.name);
|
||||
}
|
||||
|
||||
// Fallback: filter from available branches by remote prefix
|
||||
// Fallback: filter from available branches by remote prefix, stripping the prefix for display
|
||||
const prefix = `${selectedRemote}/`;
|
||||
return availableBranches
|
||||
.filter((b) => b.isRemote && b.name.startsWith(`${selectedRemote}/`))
|
||||
.map((b) => b.name);
|
||||
.filter((b) => b.isRemote && b.name.startsWith(prefix))
|
||||
.map((b) => b.name.substring(prefix.length));
|
||||
}, [availableBranches, selectedRemote, remoteBranches]);
|
||||
|
||||
// Determine if the selected base branch is a remote branch.
|
||||
// When a remote is selected in the source dropdown, the branch is always remote.
|
||||
// Also detect manually entered remote-style names (e.g. "origin/feature")
|
||||
// so the UI shows the "Remote branch — will fetch latest" hint even when
|
||||
// the branch isn't in the fetched availableBranches list.
|
||||
@@ -285,6 +299,8 @@ export function CreateWorktreeDialog({
|
||||
// If the branch list couldn't be fetched, availableBranches is a fallback
|
||||
// and may not reflect reality — suppress the remote hint to avoid misleading the user.
|
||||
if (branchFetchError) return false;
|
||||
// If a remote is explicitly selected, the branch is remote
|
||||
if (selectedRemote !== 'local') return true;
|
||||
// Check fetched branch list first
|
||||
const knownRemote = availableBranches.some((b) => b.name === baseBranch && b.isRemote);
|
||||
if (knownRemote) return true;
|
||||
@@ -295,7 +311,7 @@ export function CreateWorktreeDialog({
|
||||
return !isKnownLocal;
|
||||
}
|
||||
return false;
|
||||
}, [baseBranch, availableBranches, branchFetchError]);
|
||||
}, [baseBranch, availableBranches, branchFetchError, selectedRemote]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!branchName.trim()) {
|
||||
@@ -334,8 +350,10 @@ export function CreateWorktreeDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass the validated baseBranch if one was selected (otherwise defaults to HEAD)
|
||||
const effectiveBaseBranch = trimmedBaseBranch || undefined;
|
||||
// Pass the validated baseBranch if one was selected (otherwise defaults to HEAD).
|
||||
// When a remote is selected, prepend the remote name to form the full ref
|
||||
// (e.g. "main" with remote "origin" becomes "origin/main").
|
||||
const effectiveBaseBranch = qualifyRemoteBranch(selectedRemote, trimmedBaseBranch);
|
||||
const result = await api.worktree.create(projectPath, branchName, effectiveBaseBranch);
|
||||
|
||||
if (result.success && result.worktree) {
|
||||
@@ -435,7 +453,7 @@ export function CreateWorktreeDialog({
|
||||
<span>Base Branch</span>
|
||||
{baseBranch && !showBaseBranch && (
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono ml-1">
|
||||
{baseBranch}
|
||||
{qualifyRemoteBranch(selectedRemote, baseBranch) ?? baseBranch}
|
||||
</code>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -163,7 +163,7 @@ export function WorktreeDropdownItem({
|
||||
className="inline-flex items-center justify-center h-4 w-4 text-amber-500"
|
||||
title="Dev server starting..."
|
||||
>
|
||||
<Spinner size="xs" variant="current" />
|
||||
<Spinner size="xs" variant="primary" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
||||
@@ -372,7 +372,7 @@ export function WorktreeDropdown({
|
||||
className="inline-flex items-center justify-center h-4 w-4 text-amber-500 shrink-0"
|
||||
title="Dev server starting..."
|
||||
>
|
||||
<Spinner size="xs" variant="current" />
|
||||
<Spinner size="xs" variant="primary" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -561,7 +561,7 @@ export function WorktreeDropdown({
|
||||
}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingAnyDevServer}
|
||||
isStartingAnyDevServer={isStartingAnyDevServer}
|
||||
isDevServerStarting={isDevServerStarting(selectedWorktree)}
|
||||
isDevServerRunning={isDevServerRunning(selectedWorktree)}
|
||||
devServerInfo={getDevServerInfo(selectedWorktree)}
|
||||
|
||||
@@ -533,7 +533,7 @@ export function WorktreeTab({
|
||||
trackingRemote={trackingRemote}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingAnyDevServer}
|
||||
isStartingAnyDevServer={isStartingAnyDevServer}
|
||||
isDevServerStarting={isDevServerStarting}
|
||||
isDevServerRunning={isDevServerRunning}
|
||||
devServerInfo={devServerInfo}
|
||||
|
||||
@@ -9,28 +9,11 @@ import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notific
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card';
|
||||
import { Bell, Check, CheckCheck, Trash2, ExternalLink } from 'lucide-react';
|
||||
import { Bell, Check, CheckCheck, Trash2, ExternalLink, AlertCircle } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import type { Notification } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Format a date as relative time (e.g., "2 minutes ago", "3 hours ago")
|
||||
*/
|
||||
function formatRelativeTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) return 'just now';
|
||||
if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
|
||||
if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
|
||||
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
|
||||
export function NotificationsView() {
|
||||
const { currentProject } = useAppStore();
|
||||
@@ -111,8 +94,8 @@ export function NotificationsView() {
|
||||
|
||||
// Navigate to the relevant view based on notification type
|
||||
if (notification.featureId) {
|
||||
// Navigate to board view - feature will be selected
|
||||
navigate({ to: '/board' });
|
||||
// Navigate to board view with feature ID to show output
|
||||
navigate({ to: '/board', search: { featureId: notification.featureId } });
|
||||
}
|
||||
},
|
||||
[handleMarkAsRead, navigate]
|
||||
@@ -128,6 +111,10 @@ export function NotificationsView() {
|
||||
return <Check className="h-5 w-5 text-blue-500" />;
|
||||
case 'agent_complete':
|
||||
return <Check className="h-5 w-5 text-purple-500" />;
|
||||
case 'feature_error':
|
||||
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
||||
case 'auto_mode_error':
|
||||
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
||||
default:
|
||||
return <Bell className="h-5 w-5" />;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ const PHASE_LABELS: Record<PhaseModelKey, string> = {
|
||||
projectAnalysisModel: 'Project Analysis',
|
||||
ideationModel: 'Ideation',
|
||||
memoryExtractionModel: 'Memory Extraction',
|
||||
prDescriptionModel: 'PR Description',
|
||||
};
|
||||
|
||||
const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[];
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { Bot, Folder, RefreshCw, Square, Activity, FileText } from 'lucide-react';
|
||||
import { Bot, Folder, RefreshCw, Square, Activity, FileText, Cpu } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI, type RunningAgent } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
@@ -16,6 +16,16 @@ import { useNavigate } from '@tanstack/react-router';
|
||||
import { AgentOutputModal } from './board-view/dialogs/agent-output-modal';
|
||||
import { useRunningAgents } from '@/hooks/queries';
|
||||
import { useStopFeature } from '@/hooks/mutations';
|
||||
import { getModelDisplayName } from '@/lib/utils';
|
||||
|
||||
function formatFeatureId(featureId: string): string {
|
||||
// Strip 'feature-' prefix and timestamp for readability
|
||||
// e.g. 'feature-1772305345138-epit9shpdxl' → 'epit9shpdxl'
|
||||
const match = featureId.match(/^feature-\d+-(.+)$/);
|
||||
if (match) return match[1];
|
||||
// For other patterns like 'backlog-plan:...' or 'spec-generation:...', show as-is
|
||||
return featureId;
|
||||
}
|
||||
|
||||
export function RunningAgentsView() {
|
||||
const [selectedAgent, setSelectedAgent] = useState<RunningAgent | null>(null);
|
||||
@@ -156,15 +166,21 @@ export function RunningAgentsView() {
|
||||
|
||||
{/* Agent info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium truncate" title={agent.title || agent.featureId}>
|
||||
{agent.title || agent.featureId}
|
||||
{agent.title || formatFeatureId(agent.featureId)}
|
||||
</span>
|
||||
{agent.isAutoMode && (
|
||||
<span className="px-2 py-0.5 text-[10px] font-medium rounded-full bg-brand-500/10 text-brand-500 border border-brand-500/30">
|
||||
AUTO
|
||||
</span>
|
||||
)}
|
||||
{agent.model && (
|
||||
<span className="px-2 py-0.5 text-[10px] font-medium rounded-full bg-purple-500/10 text-purple-500 border border-purple-500/30 flex items-center gap-1">
|
||||
<Cpu className="h-3 w-3" />
|
||||
{getModelDisplayName(agent.model)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{agent.description && (
|
||||
<p
|
||||
|
||||
@@ -19,16 +19,19 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Terminal, Globe } from 'lucide-react';
|
||||
import { Terminal, Globe, Bell } from 'lucide-react';
|
||||
import type {
|
||||
EventHook,
|
||||
EventHookTrigger,
|
||||
EventHookHttpMethod,
|
||||
EventHookShellAction,
|
||||
EventHookHttpAction,
|
||||
EventHookNtfyAction,
|
||||
} from '@automaker/types';
|
||||
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface EventHookDialogProps {
|
||||
open: boolean;
|
||||
@@ -37,7 +40,7 @@ interface EventHookDialogProps {
|
||||
onSave: (hook: EventHook) => void;
|
||||
}
|
||||
|
||||
type ActionType = 'shell' | 'http';
|
||||
type ActionType = 'shell' | 'http' | 'ntfy';
|
||||
|
||||
const TRIGGER_OPTIONS: EventHookTrigger[] = [
|
||||
'feature_created',
|
||||
@@ -49,7 +52,17 @@ const TRIGGER_OPTIONS: EventHookTrigger[] = [
|
||||
|
||||
const HTTP_METHODS: EventHookHttpMethod[] = ['POST', 'GET', 'PUT', 'PATCH'];
|
||||
|
||||
const PRIORITY_OPTIONS = [
|
||||
{ value: 1, label: 'Min (no sound/vibration)' },
|
||||
{ value: 2, label: 'Low' },
|
||||
{ value: 3, label: 'Default' },
|
||||
{ value: 4, label: 'High' },
|
||||
{ value: 5, label: 'Urgent (max)' },
|
||||
];
|
||||
|
||||
export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: EventHookDialogProps) {
|
||||
const ntfyEndpoints = useAppStore((state) => state.ntfyEndpoints);
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState('');
|
||||
const [trigger, setTrigger] = useState<EventHookTrigger>('feature_success');
|
||||
@@ -65,6 +78,15 @@ export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: Eve
|
||||
const [headers, setHeaders] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
|
||||
// Ntfy action state
|
||||
const [ntfyEndpointId, setNtfyEndpointId] = useState('');
|
||||
const [ntfyTitle, setNtfyTitle] = useState('');
|
||||
const [ntfyBody, setNtfyBody] = useState('');
|
||||
const [ntfyTags, setNtfyTags] = useState('');
|
||||
const [ntfyEmoji, setNtfyEmoji] = useState('');
|
||||
const [ntfyClickUrl, setNtfyClickUrl] = useState('');
|
||||
const [ntfyPriority, setNtfyPriority] = useState<1 | 2 | 3 | 4 | 5>(3);
|
||||
|
||||
// Reset form when dialog opens/closes or editingHook changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -72,68 +94,131 @@ export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: Eve
|
||||
// Populate form with existing hook data
|
||||
setName(editingHook.name || '');
|
||||
setTrigger(editingHook.trigger);
|
||||
setActionType(editingHook.action.type);
|
||||
setActionType(editingHook.action.type as ActionType);
|
||||
|
||||
if (editingHook.action.type === 'shell') {
|
||||
const shellAction = editingHook.action as EventHookShellAction;
|
||||
setCommand(shellAction.command);
|
||||
setTimeout(String(shellAction.timeout || 30000));
|
||||
// Reset HTTP fields
|
||||
setUrl('');
|
||||
setMethod('POST');
|
||||
setHeaders('');
|
||||
setBody('');
|
||||
} else {
|
||||
// Reset other fields
|
||||
resetHttpFields();
|
||||
resetNtfyFields();
|
||||
} else if (editingHook.action.type === 'http') {
|
||||
const httpAction = editingHook.action as EventHookHttpAction;
|
||||
setUrl(httpAction.url);
|
||||
setMethod(httpAction.method);
|
||||
setHeaders(httpAction.headers ? JSON.stringify(httpAction.headers, null, 2) : '');
|
||||
setBody(httpAction.body || '');
|
||||
// Reset shell fields
|
||||
setCommand('');
|
||||
setTimeout('30000');
|
||||
// Reset other fields
|
||||
resetShellFields();
|
||||
resetNtfyFields();
|
||||
} else if (editingHook.action.type === 'ntfy') {
|
||||
const ntfyAction = editingHook.action as EventHookNtfyAction;
|
||||
setNtfyEndpointId(ntfyAction.endpointId);
|
||||
setNtfyTitle(ntfyAction.title || '');
|
||||
setNtfyBody(ntfyAction.body || '');
|
||||
setNtfyTags(ntfyAction.tags || '');
|
||||
setNtfyEmoji(ntfyAction.emoji || '');
|
||||
setNtfyClickUrl(ntfyAction.clickUrl || '');
|
||||
setNtfyPriority(ntfyAction.priority || 3);
|
||||
// Reset other fields
|
||||
resetShellFields();
|
||||
resetHttpFields();
|
||||
}
|
||||
} else {
|
||||
// Reset to defaults for new hook
|
||||
setName('');
|
||||
setTrigger('feature_success');
|
||||
setActionType('shell');
|
||||
setCommand('');
|
||||
setTimeout('30000');
|
||||
setUrl('');
|
||||
setMethod('POST');
|
||||
setHeaders('');
|
||||
setBody('');
|
||||
resetShellFields();
|
||||
resetHttpFields();
|
||||
resetNtfyFields();
|
||||
}
|
||||
}
|
||||
}, [open, editingHook]);
|
||||
|
||||
const resetShellFields = () => {
|
||||
setCommand('');
|
||||
setTimeout('30000');
|
||||
};
|
||||
|
||||
const resetHttpFields = () => {
|
||||
setUrl('');
|
||||
setMethod('POST');
|
||||
setHeaders('');
|
||||
setBody('');
|
||||
};
|
||||
|
||||
const resetNtfyFields = () => {
|
||||
setNtfyEndpointId('');
|
||||
setNtfyTitle('');
|
||||
setNtfyBody('');
|
||||
setNtfyTags('');
|
||||
setNtfyEmoji('');
|
||||
setNtfyClickUrl('');
|
||||
setNtfyPriority(3);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
let action: EventHook['action'];
|
||||
|
||||
if (actionType === 'shell') {
|
||||
action = {
|
||||
type: 'shell',
|
||||
command,
|
||||
timeout: parseInt(timeout, 10) || 30000,
|
||||
};
|
||||
} else if (actionType === 'http') {
|
||||
// Parse headers JSON with error handling
|
||||
let parsedHeaders: Record<string, string> | undefined;
|
||||
if (headers.trim()) {
|
||||
try {
|
||||
parsedHeaders = JSON.parse(headers);
|
||||
} catch {
|
||||
// If JSON is invalid, show error and don't save
|
||||
toast.error('Invalid JSON in Headers field');
|
||||
return;
|
||||
}
|
||||
}
|
||||
action = {
|
||||
type: 'http',
|
||||
url,
|
||||
method,
|
||||
headers: parsedHeaders,
|
||||
body: body.trim() || undefined,
|
||||
};
|
||||
} else {
|
||||
action = {
|
||||
type: 'ntfy',
|
||||
endpointId: ntfyEndpointId,
|
||||
title: ntfyTitle.trim() || undefined,
|
||||
body: ntfyBody.trim() || undefined,
|
||||
tags: ntfyTags.trim() || undefined,
|
||||
emoji: ntfyEmoji.trim() || undefined,
|
||||
clickUrl: ntfyClickUrl.trim() || undefined,
|
||||
priority: ntfyPriority,
|
||||
};
|
||||
}
|
||||
|
||||
const hook: EventHook = {
|
||||
id: editingHook?.id || generateUUID(),
|
||||
name: name.trim() || undefined,
|
||||
trigger,
|
||||
enabled: editingHook?.enabled ?? true,
|
||||
action:
|
||||
actionType === 'shell'
|
||||
? {
|
||||
type: 'shell',
|
||||
command,
|
||||
timeout: parseInt(timeout, 10) || 30000,
|
||||
}
|
||||
: {
|
||||
type: 'http',
|
||||
url,
|
||||
method,
|
||||
headers: headers.trim() ? JSON.parse(headers) : undefined,
|
||||
body: body.trim() || undefined,
|
||||
},
|
||||
action,
|
||||
};
|
||||
|
||||
onSave(hook);
|
||||
};
|
||||
|
||||
const isValid = actionType === 'shell' ? command.trim().length > 0 : url.trim().length > 0;
|
||||
const selectedEndpoint = ntfyEndpoints.find((e) => e.id === ntfyEndpointId);
|
||||
|
||||
const isValid = (() => {
|
||||
if (actionType === 'shell') return command.trim().length > 0;
|
||||
if (actionType === 'http') return url.trim().length > 0;
|
||||
if (actionType === 'ntfy') return Boolean(selectedEndpoint);
|
||||
return false;
|
||||
})();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -179,13 +264,17 @@ export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: Eve
|
||||
<Label>Action Type</Label>
|
||||
<Tabs value={actionType} onValueChange={(v) => setActionType(v as ActionType)}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="shell" className="flex-1 gap-2">
|
||||
<TabsTrigger value="shell" className="flex-1 gap-1">
|
||||
<Terminal className="w-4 h-4" />
|
||||
Shell Command
|
||||
<span className="sr-only sm:inline">Shell</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="http" className="flex-1 gap-2">
|
||||
<TabsTrigger value="http" className="flex-1 gap-1">
|
||||
<Globe className="w-4 h-4" />
|
||||
HTTP Request
|
||||
<span className="sr-only sm:inline">HTTP</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="ntfy" className="flex-1 gap-1">
|
||||
<Bell className="w-4 h-4" />
|
||||
<span className="sr-only sm:inline">Ntfy</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -273,6 +362,139 @@ export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: Eve
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Ntfy notification form */}
|
||||
<TabsContent value="ntfy" className="space-y-4 mt-4">
|
||||
{ntfyEndpoints.length === 0 ? (
|
||||
<div className="rounded-lg bg-muted/50 p-4 text-center">
|
||||
<Bell className="w-8 h-8 mx-auto mb-2 text-muted-foreground opacity-50" />
|
||||
<p className="text-sm text-muted-foreground">No ntfy endpoints configured.</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Add an endpoint in the "Endpoints" tab first.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-endpoint">Endpoint *</Label>
|
||||
<Select value={ntfyEndpointId} onValueChange={setNtfyEndpointId}>
|
||||
<SelectTrigger id="ntfy-endpoint">
|
||||
<SelectValue placeholder="Select an endpoint" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ntfyEndpoints
|
||||
.filter((e) => e.enabled)
|
||||
.map((endpoint) => (
|
||||
<SelectItem key={endpoint.id} value={endpoint.id}>
|
||||
{endpoint.name} ({endpoint.topic})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedEndpoint && (
|
||||
<div className="rounded-lg bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
<p>
|
||||
<strong>Server:</strong> {selectedEndpoint.serverUrl}
|
||||
</p>
|
||||
{selectedEndpoint.defaultTags && (
|
||||
<p>
|
||||
<strong>Default Tags:</strong> {selectedEndpoint.defaultTags}
|
||||
</p>
|
||||
)}
|
||||
{selectedEndpoint.defaultEmoji && (
|
||||
<p>
|
||||
<strong>Default Emoji:</strong> {selectedEndpoint.defaultEmoji}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-title">Title (optional)</Label>
|
||||
<Input
|
||||
id="ntfy-title"
|
||||
value={ntfyTitle}
|
||||
onChange={(e) => setNtfyTitle(e.target.value)}
|
||||
placeholder="Feature {{featureName}} completed"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Defaults to event name. Use {'{{variable}}'} for dynamic values.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-body">Message (optional)</Label>
|
||||
<Textarea
|
||||
id="ntfy-body"
|
||||
value={ntfyBody}
|
||||
onChange={(e) => setNtfyBody(e.target.value)}
|
||||
placeholder="Feature {{featureId}} completed at {{timestamp}}"
|
||||
className="font-mono text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Defaults to event details. Leave empty for auto-generated message.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-tags">Tags (optional)</Label>
|
||||
<Input
|
||||
id="ntfy-tags"
|
||||
value={ntfyTags}
|
||||
onChange={(e) => setNtfyTags(e.target.value)}
|
||||
placeholder="warning,skull"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-emoji">Emoji</Label>
|
||||
<Input
|
||||
id="ntfy-emoji"
|
||||
value={ntfyEmoji}
|
||||
onChange={(e) => setNtfyEmoji(e.target.value)}
|
||||
placeholder="tada"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-click">Click URL (optional)</Label>
|
||||
<Input
|
||||
id="ntfy-click"
|
||||
value={ntfyClickUrl}
|
||||
onChange={(e) => setNtfyClickUrl(e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
URL to open when notification is clicked. Defaults to endpoint setting.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-priority">Priority</Label>
|
||||
<Select
|
||||
value={String(ntfyPriority)}
|
||||
onValueChange={(v) => setNtfyPriority(Number(v) as 1 | 2 | 3 | 4 | 5)}
|
||||
>
|
||||
<SelectTrigger id="ntfy-priority">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRIORITY_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={String(opt.value)}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,63 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Webhook, Plus, Trash2, Pencil, Terminal, Globe, History } from 'lucide-react';
|
||||
import {
|
||||
Webhook,
|
||||
Plus,
|
||||
Trash2,
|
||||
Pencil,
|
||||
Terminal,
|
||||
Globe,
|
||||
History,
|
||||
Bell,
|
||||
Server,
|
||||
} from 'lucide-react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type { EventHook, EventHookTrigger } from '@automaker/types';
|
||||
import type {
|
||||
EventHook,
|
||||
EventHookTrigger,
|
||||
NtfyEndpointConfig,
|
||||
NtfyAuthenticationType,
|
||||
} from '@automaker/types';
|
||||
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
|
||||
import { EventHookDialog } from './event-hook-dialog';
|
||||
import { EventHistoryView } from './event-history-view';
|
||||
import { toast } from 'sonner';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
|
||||
const logger = createLogger('EventHooks');
|
||||
|
||||
type TabType = 'hooks' | 'endpoints' | 'history';
|
||||
|
||||
export function EventHooksSection() {
|
||||
const { eventHooks, setEventHooks } = useAppStore();
|
||||
const { eventHooks, setEventHooks, ntfyEndpoints, setNtfyEndpoints } = useAppStore();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingHook, setEditingHook] = useState<EventHook | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'hooks' | 'history'>('hooks');
|
||||
const [activeTab, setActiveTab] = useState<TabType>('hooks');
|
||||
|
||||
// Ntfy endpoint dialog state
|
||||
const [endpointDialogOpen, setEndpointDialogOpen] = useState(false);
|
||||
const [editingEndpoint, setEditingEndpoint] = useState<NtfyEndpointConfig | null>(null);
|
||||
|
||||
const handleAddHook = () => {
|
||||
setEditingHook(null);
|
||||
@@ -65,6 +104,57 @@ export function EventHooksSection() {
|
||||
}
|
||||
};
|
||||
|
||||
// Ntfy endpoint handlers
|
||||
const handleAddEndpoint = () => {
|
||||
setEditingEndpoint(null);
|
||||
setEndpointDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditEndpoint = (endpoint: NtfyEndpointConfig) => {
|
||||
setEditingEndpoint(endpoint);
|
||||
setEndpointDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteEndpoint = async (endpointId: string) => {
|
||||
try {
|
||||
await setNtfyEndpoints(ntfyEndpoints.filter((e) => e.id !== endpointId));
|
||||
toast.success('Endpoint deleted');
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete ntfy endpoint:', error);
|
||||
toast.error('Failed to delete endpoint');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEndpoint = async (endpointId: string, enabled: boolean) => {
|
||||
try {
|
||||
await setNtfyEndpoints(
|
||||
ntfyEndpoints.map((e) => (e.id === endpointId ? { ...e, enabled } : e))
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle ntfy endpoint:', error);
|
||||
toast.error('Failed to update endpoint');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEndpoint = async (endpoint: NtfyEndpointConfig) => {
|
||||
try {
|
||||
if (editingEndpoint) {
|
||||
// Update existing
|
||||
await setNtfyEndpoints(ntfyEndpoints.map((e) => (e.id === endpoint.id ? endpoint : e)));
|
||||
toast.success('Endpoint updated');
|
||||
} else {
|
||||
// Add new
|
||||
await setNtfyEndpoints([...ntfyEndpoints, endpoint]);
|
||||
toast.success('Endpoint added');
|
||||
}
|
||||
setEndpointDialogOpen(false);
|
||||
setEditingEndpoint(null);
|
||||
} catch (error) {
|
||||
logger.error('Failed to save ntfy endpoint:', error);
|
||||
toast.error('Failed to save endpoint');
|
||||
}
|
||||
};
|
||||
|
||||
// Group hooks by trigger type for better organization
|
||||
const hooksByTrigger = eventHooks.reduce(
|
||||
(acc, hook) => {
|
||||
@@ -96,7 +186,7 @@ export function EventHooksSection() {
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Event Hooks</h2>
|
||||
<p className="text-sm text-muted-foreground/80">
|
||||
Run custom commands or webhooks when events occur
|
||||
Run custom commands or send notifications when events occur
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -106,17 +196,27 @@ export function EventHooksSection() {
|
||||
Add Hook
|
||||
</Button>
|
||||
)}
|
||||
{activeTab === 'endpoints' && (
|
||||
<Button onClick={handleAddEndpoint} size="sm" className="gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Endpoint
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'hooks' | 'history')}>
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabType)}>
|
||||
<div className="px-6 pt-4">
|
||||
<TabsList className="grid w-full max-w-xs grid-cols-2">
|
||||
<TabsList className="grid w-full max-w-sm grid-cols-3">
|
||||
<TabsTrigger value="hooks" className="gap-2">
|
||||
<Webhook className="w-4 h-4" />
|
||||
Hooks
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="endpoints" className="gap-2">
|
||||
<Bell className="w-4 h-4" />
|
||||
Endpoints
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history" className="gap-2">
|
||||
<History className="w-4 h-4" />
|
||||
History
|
||||
@@ -148,6 +248,7 @@ export function EventHooksSection() {
|
||||
<HookCard
|
||||
key={hook.id}
|
||||
hook={hook}
|
||||
ntfyEndpoints={ntfyEndpoints}
|
||||
onEdit={() => handleEditHook(hook)}
|
||||
onDelete={() => handleDeleteHook(hook.id)}
|
||||
onToggle={(enabled) => handleToggleHook(hook.id, enabled)}
|
||||
@@ -166,12 +267,56 @@ export function EventHooksSection() {
|
||||
<p className="font-medium mb-2">Available variables:</p>
|
||||
<code className="text-[10px] leading-relaxed">
|
||||
{'{{featureId}}'} {'{{featureName}}'} {'{{projectPath}}'} {'{{projectName}}'}{' '}
|
||||
{'{{error}}'} {'{{timestamp}}'} {'{{eventType}}'}
|
||||
{'{{error}}'} {'{{errorType}}'} {'{{timestamp}}'} {'{{eventType}}'}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Endpoints Tab */}
|
||||
<TabsContent value="endpoints" className="m-0">
|
||||
<div className="p-6 pt-4">
|
||||
{ntfyEndpoints.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Bell className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">No ntfy endpoints configured</p>
|
||||
<p className="text-xs mt-1">Add endpoints to send push notifications via ntfy.sh</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{ntfyEndpoints.map((endpoint) => (
|
||||
<EndpointCard
|
||||
key={endpoint.id}
|
||||
endpoint={endpoint}
|
||||
onEdit={() => handleEditEndpoint(endpoint)}
|
||||
onDelete={() => handleDeleteEndpoint(endpoint.id)}
|
||||
onToggle={(enabled) => handleToggleEndpoint(endpoint.id, enabled)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Help text */}
|
||||
<div className="px-6 pb-6">
|
||||
<div className="rounded-lg bg-muted/30 p-4 text-xs text-muted-foreground">
|
||||
<p className="font-medium mb-2">About ntfy.sh:</p>
|
||||
<p className="mb-2">
|
||||
ntfy.sh is a simple pub-sub notification service. Create a topic and subscribe via
|
||||
web, mobile app, or API.
|
||||
</p>
|
||||
<a
|
||||
href="https://ntfy.sh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-brand-500 hover:underline"
|
||||
>
|
||||
https://ntfy.sh
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* History Tab */}
|
||||
<TabsContent value="history" className="m-0">
|
||||
<div className="p-6 pt-4">
|
||||
@@ -180,26 +325,51 @@ export function EventHooksSection() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Dialog */}
|
||||
{/* Hook Dialog */}
|
||||
<EventHookDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
editingHook={editingHook}
|
||||
onSave={handleSaveHook}
|
||||
/>
|
||||
|
||||
{/* Endpoint Dialog */}
|
||||
<NtfyEndpointDialog
|
||||
open={endpointDialogOpen}
|
||||
onOpenChange={setEndpointDialogOpen}
|
||||
editingEndpoint={editingEndpoint}
|
||||
onSave={handleSaveEndpoint}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface HookCardProps {
|
||||
hook: EventHook;
|
||||
ntfyEndpoints: NtfyEndpointConfig[];
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
function HookCard({ hook, onEdit, onDelete, onToggle }: HookCardProps) {
|
||||
function HookCard({ hook, ntfyEndpoints, onEdit, onDelete, onToggle }: HookCardProps) {
|
||||
const isShell = hook.action.type === 'shell';
|
||||
const isHttp = hook.action.type === 'http';
|
||||
const isNtfy = hook.action.type === 'ntfy';
|
||||
|
||||
// Get ntfy endpoint name if this is an ntfy hook
|
||||
const ntfyEndpointName = isNtfy
|
||||
? ntfyEndpoints.find(
|
||||
(e) => e.id === (hook.action as { type: 'ntfy'; endpointId: string }).endpointId
|
||||
)?.name || 'Unknown endpoint'
|
||||
: null;
|
||||
|
||||
// Get icon background and color
|
||||
const iconStyle = isShell
|
||||
? 'bg-amber-500/10 text-amber-500'
|
||||
: isHttp
|
||||
? 'bg-blue-500/10 text-blue-500'
|
||||
: 'bg-purple-500/10 text-purple-500';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -210,24 +380,27 @@ function HookCard({ hook, onEdit, onDelete, onToggle }: HookCardProps) {
|
||||
)}
|
||||
>
|
||||
{/* Type icon */}
|
||||
<div
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-lg flex items-center justify-center',
|
||||
isShell ? 'bg-amber-500/10 text-amber-500' : 'bg-blue-500/10 text-blue-500'
|
||||
<div className={cn('w-8 h-8 rounded-lg flex items-center justify-center', iconStyle)}>
|
||||
{isShell ? (
|
||||
<Terminal className="w-4 h-4" />
|
||||
) : isHttp ? (
|
||||
<Globe className="w-4 h-4" />
|
||||
) : (
|
||||
<Bell className="w-4 h-4" />
|
||||
)}
|
||||
>
|
||||
{isShell ? <Terminal className="w-4 h-4" /> : <Globe className="w-4 h-4" />}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{hook.name || (isShell ? 'Shell Command' : 'HTTP Webhook')}
|
||||
{hook.name || (isShell ? 'Shell Command' : isHttp ? 'HTTP Webhook' : 'Ntfy Notification')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{isShell
|
||||
? (hook.action as { type: 'shell'; command: string }).command
|
||||
: (hook.action as { type: 'http'; url: string }).url}
|
||||
: isHttp
|
||||
? (hook.action as { type: 'http'; url: string }).url
|
||||
: ntfyEndpointName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -249,3 +422,341 @@ function HookCard({ hook, onEdit, onDelete, onToggle }: HookCardProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EndpointCardProps {
|
||||
endpoint: NtfyEndpointConfig;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
function EndpointCard({ endpoint, onEdit, onDelete, onToggle }: EndpointCardProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="endpoint-card"
|
||||
className={cn(
|
||||
'flex items-center gap-3 p-3 rounded-lg border',
|
||||
'bg-background/50 hover:bg-background/80 transition-colors',
|
||||
!endpoint.enabled && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-purple-500/10 text-purple-500">
|
||||
<Server className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{endpoint.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{endpoint.topic} • {endpoint.serverUrl.replace(/^https?:\/\//, '')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auth badge */}
|
||||
{endpoint.authType !== 'none' && (
|
||||
<div className="text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{endpoint.authType === 'basic' ? 'Basic Auth' : 'Token'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={endpoint.enabled}
|
||||
onCheckedChange={onToggle}
|
||||
aria-label={`${endpoint.enabled ? 'Disable' : 'Enable'} endpoint ${endpoint.name}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={onEdit}
|
||||
aria-label={`Edit endpoint ${endpoint.name}`}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
aria-label={`Delete endpoint ${endpoint.name}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Ntfy Endpoint Dialog Component
|
||||
interface NtfyEndpointDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editingEndpoint: NtfyEndpointConfig | null;
|
||||
onSave: (endpoint: NtfyEndpointConfig) => void;
|
||||
}
|
||||
|
||||
function NtfyEndpointDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
editingEndpoint,
|
||||
onSave,
|
||||
}: NtfyEndpointDialogProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [serverUrl, setServerUrl] = useState('https://ntfy.sh');
|
||||
const [topic, setTopic] = useState('');
|
||||
const [authType, setAuthType] = useState<NtfyAuthenticationType>('none');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [token, setToken] = useState('');
|
||||
const [defaultTags, setDefaultTags] = useState('');
|
||||
const [defaultEmoji, setDefaultEmoji] = useState('');
|
||||
const [defaultClickUrl, setDefaultClickUrl] = useState('');
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
|
||||
// Reset form when dialog opens/closes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (editingEndpoint) {
|
||||
setName(editingEndpoint.name);
|
||||
setServerUrl(editingEndpoint.serverUrl);
|
||||
setTopic(editingEndpoint.topic);
|
||||
setAuthType(editingEndpoint.authType);
|
||||
setUsername(editingEndpoint.username || '');
|
||||
setPassword(''); // Don't populate password for security
|
||||
setToken(''); // Don't populate token for security
|
||||
setDefaultTags(editingEndpoint.defaultTags || '');
|
||||
setDefaultEmoji(editingEndpoint.defaultEmoji || '');
|
||||
setDefaultClickUrl(editingEndpoint.defaultClickUrl || '');
|
||||
setEnabled(editingEndpoint.enabled);
|
||||
} else {
|
||||
setName('');
|
||||
setServerUrl('https://ntfy.sh');
|
||||
setTopic('');
|
||||
setAuthType('none');
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
setToken('');
|
||||
setDefaultTags('');
|
||||
setDefaultEmoji('');
|
||||
setDefaultClickUrl('');
|
||||
setEnabled(true);
|
||||
}
|
||||
}
|
||||
}, [open, editingEndpoint]);
|
||||
|
||||
const handleSave = () => {
|
||||
const trimmedPassword = password.trim();
|
||||
const trimmedToken = token.trim();
|
||||
const endpoint: NtfyEndpointConfig = {
|
||||
id: editingEndpoint?.id || generateUUID(),
|
||||
name: name.trim(),
|
||||
serverUrl: serverUrl.trim(),
|
||||
topic: topic.trim(),
|
||||
authType,
|
||||
username: authType === 'basic' ? username.trim() : undefined,
|
||||
// Preserve existing secret if input was left blank when editing
|
||||
password:
|
||||
authType === 'basic'
|
||||
? trimmedPassword || (editingEndpoint ? editingEndpoint.password : undefined)
|
||||
: undefined,
|
||||
token:
|
||||
authType === 'token'
|
||||
? trimmedToken || (editingEndpoint ? editingEndpoint.token : undefined)
|
||||
: undefined,
|
||||
defaultTags: defaultTags.trim() || undefined,
|
||||
defaultEmoji: defaultEmoji.trim() || undefined,
|
||||
defaultClickUrl: defaultClickUrl.trim() || undefined,
|
||||
enabled,
|
||||
};
|
||||
|
||||
onSave(endpoint);
|
||||
};
|
||||
|
||||
// Validate form
|
||||
const isServerUrlValid = (() => {
|
||||
const trimmed = serverUrl.trim();
|
||||
if (!trimmed) return false;
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
const isValid =
|
||||
name.trim().length > 0 &&
|
||||
isServerUrlValid &&
|
||||
topic.trim().length > 0 &&
|
||||
!topic.includes(' ') &&
|
||||
(authType !== 'basic' ||
|
||||
(username.trim().length > 0 &&
|
||||
(password.trim().length > 0 || Boolean(editingEndpoint?.password)))) &&
|
||||
(authType !== 'token' || token.trim().length > 0 || Boolean(editingEndpoint?.token));
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingEndpoint ? 'Edit Ntfy Endpoint' : 'Add Ntfy Endpoint'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure an ntfy.sh server to receive push notifications.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint-name">Name *</Label>
|
||||
<Input
|
||||
id="endpoint-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Personal Phone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Server URL */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint-server">Server URL</Label>
|
||||
<Input
|
||||
id="endpoint-server"
|
||||
value={serverUrl}
|
||||
onChange={(e) => setServerUrl(e.target.value)}
|
||||
placeholder="https://ntfy.sh"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Default is ntfy.sh. Use custom URL for self-hosted servers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Topic */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint-topic">Topic *</Label>
|
||||
<Input
|
||||
id="endpoint-topic"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
placeholder="my-automaker-notifications"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Topic name (no spaces). This acts like a channel for your notifications.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Authentication */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint-auth">Authentication</Label>
|
||||
<Select
|
||||
value={authType}
|
||||
onValueChange={(v) => setAuthType(v as NtfyAuthenticationType)}
|
||||
>
|
||||
<SelectTrigger id="endpoint-auth">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None (public topic)</SelectItem>
|
||||
<SelectItem value="basic">Username & Password</SelectItem>
|
||||
<SelectItem value="token">Access Token</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Conditional auth fields */}
|
||||
{authType === 'basic' && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint-username">Username</Label>
|
||||
<Input
|
||||
id="endpoint-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="username"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint-password">Password</Label>
|
||||
<Input
|
||||
id="endpoint-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authType === 'token' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint-token">Access Token</Label>
|
||||
<Input
|
||||
id="endpoint-token"
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="tk_xxxxxxxxxxxxx"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Default Tags */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint-tags">Default Tags (optional)</Label>
|
||||
<Input
|
||||
id="endpoint-tags"
|
||||
value={defaultTags}
|
||||
onChange={(e) => setDefaultTags(e.target.value)}
|
||||
placeholder="warning,skull"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Comma-separated tags or emoji shortcodes (e.g., warning, partypopper)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Default Emoji */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint-emoji">Default Emoji (optional)</Label>
|
||||
<Input
|
||||
id="endpoint-emoji"
|
||||
value={defaultEmoji}
|
||||
onChange={(e) => setDefaultEmoji(e.target.value)}
|
||||
placeholder="tada"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Default Click URL */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint-click">Default Click URL (optional)</Label>
|
||||
<Input
|
||||
id="endpoint-click"
|
||||
value={defaultClickUrl}
|
||||
onChange={(e) => setDefaultClickUrl(e.target.value)}
|
||||
placeholder="http://localhost:3007"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
URL to open when notification is clicked. Auto-linked to project/feature if available.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Enabled toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="endpoint-enabled">Enabled</Label>
|
||||
<Switch id="endpoint-enabled" checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!isValid}>
|
||||
{editingEndpoint ? 'Save Changes' : 'Add Endpoint'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ const PHASE_LABELS: Record<PhaseModelKey, string> = {
|
||||
projectAnalysisModel: 'Project Analysis',
|
||||
ideationModel: 'Ideation',
|
||||
memoryExtractionModel: 'Memory Extraction',
|
||||
prDescriptionModel: 'PR Description',
|
||||
};
|
||||
|
||||
const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[];
|
||||
|
||||
@@ -580,7 +580,7 @@ export function PhaseModelSelector({
|
||||
id: model.id,
|
||||
label: model.name,
|
||||
description: model.description,
|
||||
badge: model.tier === 'premium' ? 'Premium' : model.tier === 'basic' ? 'Free' : undefined,
|
||||
badge: model.tier === 'premium' ? 'Premium' : undefined,
|
||||
provider: 'opencode' as const,
|
||||
}));
|
||||
|
||||
|
||||
@@ -248,7 +248,7 @@ export function useWorktreeBranches(worktreePath: string | undefined, includeRem
|
||||
isGitRepo: true,
|
||||
hasCommits: true,
|
||||
trackingRemote: result.result?.trackingRemote,
|
||||
remotesWithBranch: result.result?.remotesWithBranch,
|
||||
remotesWithBranch: (result.result as { remotesWithBranch?: string[] })?.remotesWithBranch,
|
||||
};
|
||||
},
|
||||
enabled: !!worktreePath,
|
||||
|
||||
@@ -30,7 +30,7 @@ export function useAgentOutputWebSocket({
|
||||
onFeatureComplete,
|
||||
}: UseAgentOutputWebSocketProps) {
|
||||
const [streamedContent, setStreamedContent] = useState('');
|
||||
const closeTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
// Use React Query for initial output loading
|
||||
const { data: initialOutput = '', isLoading } = useAgentOutput(projectPath, featureId, {
|
||||
@@ -98,7 +98,16 @@ export function useAgentOutputWebSocket({
|
||||
if (isBacklogPlan) {
|
||||
// Handle backlog plan events
|
||||
if (api.backlogPlan) {
|
||||
unsubscribe = api.backlogPlan.onEvent(handleBacklogPlanEvent);
|
||||
unsubscribe = api.backlogPlan.onEvent((data: unknown) => {
|
||||
if (
|
||||
data !== null &&
|
||||
typeof data === 'object' &&
|
||||
'type' in data &&
|
||||
typeof (data as { type: unknown }).type === 'string'
|
||||
) {
|
||||
handleBacklogPlanEvent(data as BacklogPlanEvent);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Handle auto mode events
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
migratePhaseModelEntry,
|
||||
type GlobalSettings,
|
||||
type CursorModelId,
|
||||
type PhaseModelEntry,
|
||||
} from '@automaker/types';
|
||||
|
||||
const logger = createLogger('SettingsMigration');
|
||||
@@ -198,6 +199,8 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
|
||||
mcpServers: state.mcpServers as GlobalSettings['mcpServers'],
|
||||
promptCustomization: state.promptCustomization as GlobalSettings['promptCustomization'],
|
||||
eventHooks: state.eventHooks as GlobalSettings['eventHooks'],
|
||||
ntfyEndpoints: state.ntfyEndpoints as GlobalSettings['ntfyEndpoints'],
|
||||
featureTemplates: state.featureTemplates as GlobalSettings['featureTemplates'],
|
||||
projects: state.projects as GlobalSettings['projects'],
|
||||
trashedProjects: state.trashedProjects as GlobalSettings['trashedProjects'],
|
||||
currentProjectId: (state.currentProject as { id?: string } | null)?.id ?? null,
|
||||
@@ -809,6 +812,8 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
mcpServers: settings.mcpServers ?? [],
|
||||
promptCustomization: settings.promptCustomization ?? {},
|
||||
eventHooks: settings.eventHooks ?? [],
|
||||
ntfyEndpoints: settings.ntfyEndpoints ?? [],
|
||||
featureTemplates: settings.featureTemplates ?? [],
|
||||
claudeCompatibleProviders: settings.claudeCompatibleProviders ?? [],
|
||||
claudeApiProfiles: settings.claudeApiProfiles ?? [],
|
||||
activeClaudeApiProfileId: settings.activeClaudeApiProfileId ?? null,
|
||||
@@ -821,7 +826,10 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
agentModelBySession: settings.agentModelBySession
|
||||
? Object.fromEntries(
|
||||
Object.entries(settings.agentModelBySession as Record<string, unknown>).map(
|
||||
([sessionId, entry]) => [sessionId, migratePhaseModelEntry(entry)]
|
||||
([sessionId, entry]) => [
|
||||
sessionId,
|
||||
migratePhaseModelEntry(entry as string | PhaseModelEntry | null | undefined),
|
||||
]
|
||||
)
|
||||
)
|
||||
: current.agentModelBySession,
|
||||
@@ -945,6 +953,8 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
||||
mcpServers: state.mcpServers,
|
||||
promptCustomization: state.promptCustomization,
|
||||
eventHooks: state.eventHooks,
|
||||
ntfyEndpoints: state.ntfyEndpoints,
|
||||
featureTemplates: state.featureTemplates,
|
||||
claudeCompatibleProviders: state.claudeCompatibleProviders,
|
||||
claudeApiProfiles: state.claudeApiProfiles,
|
||||
activeClaudeApiProfileId: state.activeClaudeApiProfileId,
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
type CursorModelId,
|
||||
type GeminiModelId,
|
||||
type CopilotModelId,
|
||||
type PhaseModelEntry,
|
||||
} from '@automaker/types';
|
||||
|
||||
const logger = createLogger('SettingsSync');
|
||||
@@ -106,6 +107,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'subagentsSources',
|
||||
'promptCustomization',
|
||||
'eventHooks',
|
||||
'ntfyEndpoints',
|
||||
'featureTemplates',
|
||||
'claudeCompatibleProviders', // Claude-compatible provider configs - must persist to server
|
||||
'claudeApiProfiles',
|
||||
@@ -855,7 +857,10 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
agentModelBySession: serverSettings.agentModelBySession
|
||||
? Object.fromEntries(
|
||||
Object.entries(serverSettings.agentModelBySession as Record<string, unknown>).map(
|
||||
([sessionId, entry]) => [sessionId, migratePhaseModelEntry(entry)]
|
||||
([sessionId, entry]) => [
|
||||
sessionId,
|
||||
migratePhaseModelEntry(entry as string | PhaseModelEntry | null | undefined),
|
||||
]
|
||||
)
|
||||
)
|
||||
: currentAppState.agentModelBySession,
|
||||
@@ -870,6 +875,8 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
recentFolders: serverSettings.recentFolders ?? [],
|
||||
// Event hooks
|
||||
eventHooks: serverSettings.eventHooks ?? [],
|
||||
// Ntfy endpoints
|
||||
ntfyEndpoints: serverSettings.ntfyEndpoints ?? [],
|
||||
// Feature templates
|
||||
featureTemplates: serverSettings.featureTemplates ?? [],
|
||||
// Codex CLI Settings
|
||||
|
||||
@@ -239,6 +239,8 @@ export interface RunningAgent {
|
||||
projectPath: string;
|
||||
projectName: string;
|
||||
isAutoMode: boolean;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
branchName?: string;
|
||||
|
||||
@@ -1384,7 +1384,7 @@ export function isAccumulatedSummary(summary: string | undefined): boolean {
|
||||
|
||||
// Check for the presence of phase headers with separator
|
||||
const hasMultiplePhases =
|
||||
summary.includes(PHASE_SEPARATOR) && summary.match(/###\s+.+/g)?.length > 0;
|
||||
summary.includes(PHASE_SEPARATOR) && (summary.match(/###\s+.+/g)?.length ?? 0) > 0;
|
||||
|
||||
return hasMultiplePhases;
|
||||
}
|
||||
|
||||
@@ -101,12 +101,22 @@ export function getProviderFromModel(model?: string): ModelProvider {
|
||||
|
||||
/**
|
||||
* Get display name for a model
|
||||
* Handles both aliases (e.g., "sonnet") and full model IDs (e.g., "claude-sonnet-4-20250514")
|
||||
*/
|
||||
export function getModelDisplayName(model: ModelAlias | string): string {
|
||||
const displayNames: Record<string, string> = {
|
||||
// Claude aliases
|
||||
haiku: 'Claude Haiku',
|
||||
sonnet: 'Claude Sonnet',
|
||||
opus: 'Claude Opus',
|
||||
// Claude canonical IDs (without version suffix)
|
||||
'claude-haiku': 'Claude Haiku',
|
||||
'claude-sonnet': 'Claude Sonnet',
|
||||
'claude-opus': 'Claude Opus',
|
||||
// Claude full model IDs (returned by server)
|
||||
'claude-haiku-4-5': 'Claude Haiku',
|
||||
'claude-sonnet-4-20250514': 'Claude Sonnet',
|
||||
'claude-opus-4-6': 'Claude Opus',
|
||||
// Codex models
|
||||
'codex-gpt-5.2': 'GPT-5.2',
|
||||
'codex-gpt-5.1-codex-max': 'GPT-5.1 Codex Max',
|
||||
@@ -211,3 +221,24 @@ export function generateUUID(): string {
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date as relative time (e.g., "2 minutes ago", "3 hours ago")
|
||||
*/
|
||||
export function formatRelativeTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
|
||||
if (diffSec < 0) return date.toLocaleDateString();
|
||||
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) return 'just now';
|
||||
if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
|
||||
if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
|
||||
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
@@ -53,10 +53,6 @@ if (isDev) {
|
||||
// Must be set before app.whenReady() — has no effect on macOS/Windows.
|
||||
if (process.platform === 'linux') {
|
||||
app.commandLine.appendSwitch('ozone-platform-hint', 'auto');
|
||||
// Link the running process to its .desktop file so GNOME/KDE uses the
|
||||
// desktop entry's Icon for the taskbar instead of Electron's default.
|
||||
// Must be called before any window is created.
|
||||
app.setDesktopName('automaker.desktop');
|
||||
}
|
||||
|
||||
// Register IPC handlers
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { createLazyFileRoute } from '@tanstack/react-router';
|
||||
import { createLazyFileRoute, useSearch } from '@tanstack/react-router';
|
||||
import { BoardView } from '@/components/views/board-view';
|
||||
|
||||
export const Route = createLazyFileRoute('/board')({
|
||||
component: BoardView,
|
||||
component: BoardRouteComponent,
|
||||
});
|
||||
|
||||
function BoardRouteComponent() {
|
||||
const { featureId } = useSearch({ from: '/board' });
|
||||
return <BoardView initialFeatureId={featureId} />;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Search params schema for board route
|
||||
const boardSearchSchema = z.object({
|
||||
featureId: z.string().optional(),
|
||||
});
|
||||
|
||||
// Component is lazy-loaded via board.lazy.tsx for code splitting.
|
||||
// Board is the most-visited landing route, but lazy loading still benefits
|
||||
@@ -6,4 +12,6 @@ import { createFileRoute } from '@tanstack/react-router';
|
||||
// downloaded when the user actually navigates to /board (vs being bundled
|
||||
// into the entry chunk). TanStack Router's autoCodeSplitting handles the
|
||||
// dynamic import automatically when a .lazy.tsx file exists.
|
||||
export const Route = createFileRoute('/board')({});
|
||||
export const Route = createFileRoute('/board')({
|
||||
validateSearch: boardSearchSchema,
|
||||
});
|
||||
|
||||
@@ -361,6 +361,7 @@ const initialState: AppState = {
|
||||
subagentsSources: ['user', 'project'] as Array<'user' | 'project'>,
|
||||
promptCustomization: {},
|
||||
eventHooks: [],
|
||||
ntfyEndpoints: [],
|
||||
featureTemplates: DEFAULT_GLOBAL_SETTINGS.featureTemplates ?? [],
|
||||
claudeCompatibleProviders: [],
|
||||
claudeApiProfiles: [],
|
||||
@@ -1501,6 +1502,17 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// Ntfy Endpoint actions
|
||||
setNtfyEndpoints: async (endpoints) => {
|
||||
set({ ntfyEndpoints: endpoints });
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.settings.updateGlobal({ ntfyEndpoints: endpoints });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync ntfy endpoints:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Feature Template actions
|
||||
setFeatureTemplates: async (templates) => {
|
||||
set({ featureTemplates: templates });
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
ModelDefinition,
|
||||
ServerLogLevel,
|
||||
EventHook,
|
||||
NtfyEndpointConfig,
|
||||
ClaudeApiProfile,
|
||||
ClaudeCompatibleProvider,
|
||||
SidebarStyle,
|
||||
@@ -275,6 +276,9 @@ export interface AppState {
|
||||
// Event Hooks
|
||||
eventHooks: EventHook[]; // Event hooks for custom commands or webhooks
|
||||
|
||||
// Ntfy.sh Notification Endpoints
|
||||
ntfyEndpoints: NtfyEndpointConfig[]; // Configured ntfy.sh endpoints for push notifications
|
||||
|
||||
// Feature Templates
|
||||
featureTemplates: FeatureTemplate[]; // Feature templates for quick task creation
|
||||
|
||||
@@ -675,6 +679,9 @@ export interface AppActions {
|
||||
// Event Hook actions
|
||||
setEventHooks: (hooks: EventHook[]) => Promise<void>;
|
||||
|
||||
// Ntfy Endpoint actions
|
||||
setNtfyEndpoints: (endpoints: NtfyEndpointConfig[]) => Promise<void>;
|
||||
|
||||
// Feature Template actions
|
||||
setFeatureTemplates: (templates: FeatureTemplate[]) => Promise<void>;
|
||||
addFeatureTemplate: (template: FeatureTemplate) => Promise<void>;
|
||||
|
||||
6
apps/ui/src/types/electron.d.ts
vendored
6
apps/ui/src/types/electron.d.ts
vendored
@@ -1433,10 +1433,14 @@ export interface WorktreeAPI {
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Subscribe to dev server log events (started, output, stopped, url-detected)
|
||||
// Subscribe to dev server log events (starting, started, output, stopped, url-detected)
|
||||
onDevServerLogEvent: (
|
||||
callback: (
|
||||
event:
|
||||
| {
|
||||
type: 'dev-server:starting';
|
||||
payload: { worktreePath: string; timestamp: string };
|
||||
}
|
||||
| {
|
||||
type: 'dev-server:started';
|
||||
payload: { worktreePath: string; port: number; url: string; timestamp: string };
|
||||
|
||||
Reference in New Issue
Block a user