Improve auto-loop event emission and add ntfy notifications (#821)

This commit is contained in:
gsxdsm
2026-03-01 00:12:22 -08:00
committed by GitHub
parent 63b0a4fb38
commit 57bcb2802d
53 changed files with 4620 additions and 255 deletions

View File

@@ -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}

View File

@@ -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,
};
});
}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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)}

View File

@@ -533,7 +533,7 @@ export function WorktreeTab({
trackingRemote={trackingRemote}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingAnyDevServer}
isStartingAnyDevServer={isStartingAnyDevServer}
isDevServerStarting={isDevServerStarting}
isDevServerRunning={isDevServerRunning}
devServerInfo={devServerInfo}

View File

@@ -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" />;
}

View File

@@ -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[];

View File

@@ -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

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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[];

View File

@@ -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,
}));