Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
0b832c7
feat: add Actions system for user-defined background agent tasks
damianloch May 4, 2026
74d1527
feat: add action editing and scheduled trigger type
damianloch May 4, 2026
c10a9c4
fix: clean up stale action runs in background chat cleanup task
damianloch May 5, 2026
ef2deaa
fix: set RLS context in action executor and cleanup for Celery paths
damianloch May 5, 2026
56984e2
fix: resolve PUT 500 error and deprecation warnings in actions
damianloch May 5, 2026
864c4ae
prompt changes
damianloch May 5, 2026
51079ac
move actions to settings
damianloch May 5, 2026
442e5a8
feat: add /action slash command to chat with autocomplete
damianloch May 5, 2026
dc7d9d9
fix: slash command UX - arrow nav, action chip, post-select typing
damianloch May 5, 2026
1e9a172
progress
damianloch May 5, 2026
867d869
fix: action trigger UX polish and timestamp handling
damianloch May 5, 2026
de9b7f3
fix: resolve SonarQube security hotspots and reliability bug
damianloch May 5, 2026
ff47db8
fix: eliminate all regex backtracking patterns for SonarQube S5852
damianloch May 5, 2026
36a0a19
fix: resolve SonarQube vulnerabilities and code smells
damianloch May 5, 2026
47ffdb5
fix: address remaining CodeQL and SonarCloud findings
damianloch May 5, 2026
11a4c2c
fix: remove redundant conditional check in chat input
damianloch May 5, 2026
ee01c09
refactor: reduce sendMessage cognitive complexity
damianloch May 5, 2026
5bb6113
fix: address SonarCloud code smells across frontend and backend
damianloch May 5, 2026
827d84f
refactor: reduce cognitive complexity across actions code
damianloch May 5, 2026
6b399cb
fix: RLS context for incident loading, safe query params, generic err…
damianloch May 5, 2026
6710cd5
fix: validate action_id as UUID, whitelist SQL columns
damianloch May 5, 2026
9414391
fix: break CodeQL taint chains in actions routes
damianloch May 5, 2026
961322d
fix: use constant dict lookup for SQL SET clause (CodeQL)
damianloch May 5, 2026
0faa6e6
fix: remove unused _UPDATABLE_COLUMNS, use _COL_FRAGMENTS at module l…
damianloch May 5, 2026
b3ea324
refactor: reduce cognitive complexity in composer and actions routes
damianloch May 5, 2026
c040cc2
refactor: extract field validators to reduce _parse_update_fields com…
damianloch May 5, 2026
935a639
refactor: loop-based _parse_update_fields to eliminate branching comp…
damianloch May 5, 2026
18f5753
fix: compute duration before string conversion, eliminate regex backt…
damianloch May 5, 2026
2aed8cc
fix: extract nested ternary and remove negated condition in handleSelect
damianloch May 5, 2026
3358d76
fix: add error handling to handleToggle, confirmation to handleDelete
damianloch May 5, 2026
79ff113
fix: memoize clearSelectedAction to avoid unnecessary re-renders
damianloch May 5, 2026
d88af82
fix: remove redundant trim() call on already-trimmed string
damianloch May 5, 2026
6262521
fix: add click-outside handler and error toast to RunActionDropdown
damianloch May 5, 2026
8feacf8
fix: guard against undefined action in menu Enter/Tab handler
damianloch May 5, 2026
798e018
fix: use parameterized logging in build_action_mode_segment
damianloch May 5, 2026
90c40ea
fix: prevent negative duration_ms by explicitly setting started_at on…
damianloch May 5, 2026
aeefd60
feat: dispatch on_incident actions automatically when incidents are c…
damianloch May 5, 2026
6ca2580
fix: update on_incident trigger label to reflect immediate dispatch
damianloch May 5, 2026
561e847
feat: support both immediate and after_rca timing for on_incident act…
damianloch May 5, 2026
998d4af
fix: address PR #366 review feedback (20 fixes)
damianloch May 6, 2026
2535045
fix: use intervalTooLow variable in submit button disabled check
damianloch May 6, 2026
feec737
fix: handle both cache shapes for /api/actions in chat slash menu
damianloch May 7, 2026
bf60f5c
fix: persist visible message in chat when action is guardrail-blocked
damianloch May 7, 2026
3c2e333
fix: resolve 5 SonarCloud code smells
damianloch May 7, 2026
a0b8029
fix: address CodeRabbit input validation feedback
damianloch May 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
683 changes: 683 additions & 0 deletions client/src/app/actions/page.tsx

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions client/src/app/api/actions/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { NextRequest } from 'next/server';
import { forwardRequest } from '@/lib/backend-proxy';

export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return forwardRequest(request, 'GET', `/api/actions/${id}`, 'action-detail');
}

export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return forwardRequest(request, 'PUT', `/api/actions/${id}`, 'action-update');
}

export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return forwardRequest(request, 'DELETE', `/api/actions/${id}`, 'action-delete');
}
7 changes: 7 additions & 0 deletions client/src/app/api/actions/[id]/run/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { NextRequest } from 'next/server';
import { forwardRequest } from '@/lib/backend-proxy';

export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return forwardRequest(request, 'POST', `/api/actions/${id}/trigger`, 'action-trigger');
Comment thread
damianloch marked this conversation as resolved.
}
7 changes: 7 additions & 0 deletions client/src/app/api/actions/[id]/runs/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { NextRequest } from 'next/server';
import { forwardRequest } from '@/lib/backend-proxy';

export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return forwardRequest(request, 'GET', `/api/actions/${id}/runs`, 'action-runs');
Comment thread
damianloch marked this conversation as resolved.
}
10 changes: 10 additions & 0 deletions client/src/app/api/actions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { NextRequest } from 'next/server';
import { forwardRequest } from '@/lib/backend-proxy';

export async function GET(request: NextRequest) {
return forwardRequest(request, 'GET', '/api/actions', 'actions-list');
}

export async function POST(request: NextRequest) {
return forwardRequest(request, 'POST', '/api/actions', 'actions-create');
}
23 changes: 23 additions & 0 deletions client/src/app/chat/components/ChatClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { useChatCancellation } from '@/hooks/useChatCancellation';
import SessionUsagePanel from "@/components/SessionUsagePanel";
import { useSessionUsage } from '@/hooks/useSessionUsage';
import { useChatSendHandlers } from "./useChatSendHandlers";
import { useQuery } from "@/lib/query";

interface ChatClientProps {
initialSessionId?: string;
Expand Down Expand Up @@ -68,6 +69,8 @@ export default function ChatClient({ initialSessionId, shouldStartNewChat, initi
const lastLoadedSessionRef = useRef<string | null>(null);
const initialMessageSentRef = useRef<boolean>(false);
const [activeIncidentContext, setActiveIncidentContext] = useState<string | undefined>(incidentContext);
const [selectedAction, setSelectedAction] = useState<{ id: string; name: string } | null>(null);
const clearSelectedAction = useCallback(() => setSelectedAction(null), []);


// Modular streaming message handling
Expand Down Expand Up @@ -117,6 +120,17 @@ export default function ChatClient({ initialSessionId, shouldStartNewChat, initi
router.push('/chat');
}, [router]);

const { data: actionsData } = useQuery<{ id: string; name: string }[] | { actions: { id: string; name: string }[] }>(
'/api/actions',
async (key: string, signal: AbortSignal) => {
const res = await fetch(key, { credentials: 'include', signal });
if (!res.ok) return { actions: [] };
return res.json();
},
{ staleTime: 60_000, revalidateOnEvents: ['actionsStateChanged'] },
);
const availableActions = Array.isArray(actionsData) ? actionsData : (actionsData?.actions ?? []);

const {
selectedModel,
setSelectedModel,
Expand All @@ -140,6 +154,9 @@ export default function ChatClient({ initialSessionId, shouldStartNewChat, initi
justCreatedSessionRef,
onSessionCreated: refreshChatHistory,
images,
availableActions,
selectedAction,
clearSelectedAction,
});

const onSendingStateChange = useCallback((sending: boolean) => {
Expand Down Expand Up @@ -470,6 +487,9 @@ export default function ChatClient({ initialSessionId, shouldStartNewChat, initi
onRemoveContext={() => setActiveIncidentContext(undefined)}
images={images}
onImagesChange={setImages}
actions={availableActions}
selectedAction={selectedAction}
onActionSelect={setSelectedAction}
/>
)}
</div>
Expand Down Expand Up @@ -510,6 +530,9 @@ export default function ChatClient({ initialSessionId, shouldStartNewChat, initi
onRemoveContext={() => setActiveIncidentContext(undefined)}
images={images}
onImagesChange={setImages}
actions={availableActions}
selectedAction={selectedAction}
onActionSelect={setSelectedAction}
/>

<div className="w-full max-w-3xl mt-6">
Expand Down
121 changes: 86 additions & 35 deletions client/src/app/chat/components/useChatSendHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ interface ChatSendHandlerParams {
justCreatedSessionRef: MutableRefObject<string | null>;
onSessionCreated?: (sessionId: string) => void;
images?: Array<{file: File, preview: string}>;
availableActions?: { id: string; name: string }[];
selectedAction?: { id: string; name: string } | null;
clearSelectedAction?: () => void;
}

interface ChatSendHandlerResult {
Expand Down Expand Up @@ -61,6 +64,9 @@ export function useChatSendHandlers({
justCreatedSessionRef,
onSessionCreated,
images = [],
availableActions = [],
selectedAction = null,
clearSelectedAction,
}: ChatSendHandlerParams): ChatSendHandlerResult {
const { toast } = useToast();
const { providerIds, isProviderConnected } = useConnectedAccounts();
Expand Down Expand Up @@ -152,6 +158,40 @@ export function useChatSendHandlers({
};
}, [selectedMode, syncProvidersWithMode]);

// Extract action from text input (fallback when no chip-selected action)
const parseActionFromText = useCallback((text: string) => {
const lower = text.toLowerCase();
let prefix: string | null = null;
if (lower.startsWith('/actions ')) prefix = '/actions ';
else if (lower.startsWith('/action ')) prefix = '/action ';
if (!prefix) return null;
const name = text.slice(prefix.length).trim();
if (!name) return null;
return availableActions.find(a => a.name.toLowerCase() === name.toLowerCase()) || { id: '', name };
}, [availableActions]);
Comment thread
damianloch marked this conversation as resolved.

// Ensure a chat session exists, creating one if needed
const ensureSession = useCallback(async (title: string): Promise<string | undefined> => {
if (hasCreatedSession) return currentSessionId;
try {
const sessionTitle = title.length > 50 ? title.substring(0, 50).trimEnd() + '...' : title;
const newSessionId = await createSession(sessionTitle);
if (newSessionId) {
onSessionCreated?.(newSessionId);
setCurrentSessionId(newSessionId);
setHasCreatedSession(true);
justCreatedSessionRef.current = newSessionId;
startTransition(() => {
router.replace(`/chat?sessionId=${newSessionId}`);
});
return newSessionId;
}
} catch (error) {
console.error('Error creating session:', error);
}
return currentSessionId;
}, [createSession, currentSessionId, hasCreatedSession, justCreatedSessionRef, onSessionCreated, router, setCurrentSessionId, setHasCreatedSession]);

const sendMessage = useCallback(async (
messageText: string,
socket: ChatWebSocket,
Expand All @@ -160,18 +200,51 @@ export function useChatSendHandlers({
options?: { triggerRca?: boolean },
) => {
const trimmed = messageText.trim();
if (!trimmed || isSending || !userId) return false;
if ((!trimmed && !selectedAction) || isSending || !userId) return false;

// Action trigger: chip-selected action takes priority, text fallback second
const actionToTrigger = selectedAction || parseActionFromText(trimmed);

if (actionToTrigger) {
if (!actionToTrigger.id) {
toast({ description: `Action "${actionToTrigger.name}" not found. Type /action to see suggestions.`, variant: 'destructive' });
return false;
}
if (!socket.isReady) {
toast({ description: 'Connection not ready. Please wait and try again.', variant: 'destructive' });
return false;
}

setIsSending(true);
const displayText = trimmed || `Run action: ${actionToTrigger.name}`;
onNewMessage({ id: Date.now(), sender: 'user', text: displayText });
const actualSessionId = await ensureSession(displayText);

socket.send({
type: 'message',
query: displayText,
user_id: userId,
session_id: actualSessionId || undefined,
model: selectedModel,
mode: 'agent',
trigger_action: actionToTrigger.id,
});
clearSelectedAction?.();
return true;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const bareCmd = trimmed.toLowerCase();
if (bareCmd === '/action' || bareCmd === '/actions') {
toast({ description: 'Usage: /action <name>. Type /action and see suggestions.' });
return false;
}

const effectiveMode = normalizeMode(modeOverride || selectedMode);
const connectedProviders = getConnectedProviders();
const providersForMode = providersOverride ?? selectedProviders;
const finalProviders = syncProvidersWithMode(effectiveMode, providersForMode);

if (!socket.isReady) {
toast({
description: "Connection not ready. Please wait a moment and try again.",
variant: "destructive"
});
toast({ description: "Connection not ready. Please wait a moment and try again.", variant: "destructive" });
return false;
}

Expand All @@ -189,34 +262,13 @@ export function useChatSendHandlers({
})) : undefined
};

let actualSessionId = currentSessionId;
if (!hasCreatedSession) {
try {
const title = trimmed.length > 50 ? trimmed.substring(0, 50).trimEnd() + '...' : trimmed;
const newSessionId = await createSession(title);
if (newSessionId) {
actualSessionId = newSessionId;
onSessionCreated?.(newSessionId);

setCurrentSessionId(newSessionId);
setHasCreatedSession(true);
justCreatedSessionRef.current = newSessionId;

startTransition(() => {
router.replace(`/chat?sessionId=${newSessionId}`);
});
}
} catch (error) {
console.error('Error creating session:', error);
}
}

const actualSessionId = await ensureSession(trimmed);
onNewMessage(userMessage);

// Convert images to attachments
const attachments = await Promise.all(
images.map(async (img) => {
const base64 = img.preview.split(',')[1]; // Remove data URL prefix
const base64 = img.preview.split(',')[1];
return {
file_type: img.file.type,
file_data: base64,
Expand Down Expand Up @@ -244,15 +296,14 @@ export function useChatSendHandlers({

return true;
}, [
createSession, currentSessionId, hasCreatedSession, justCreatedSessionRef,
onNewMessage, router, selectedMode, selectedModel, selectedProviders,
syncProvidersWithMode, toast, userId, setCurrentSessionId,
setHasCreatedSession, isSending, getConnectedProviders, images
ensureSession, parseActionFromText, onNewMessage, selectedMode, selectedModel,
selectedProviders, syncProvidersWithMode, toast, userId, isSending,
images, selectedAction, clearSelectedAction
]);

const initiateSend = useCallback(async (messageText: string, socket: ChatWebSocket, modeOverride?: string, options?: { triggerRca?: boolean }) => {
const trimmed = messageText.trim();
if (!trimmed) return false;
if (!trimmed && !selectedAction) return false;

const targetMode = modeOverride || selectedMode;
// Show warning if no providers connected
Expand All @@ -265,7 +316,7 @@ export function useChatSendHandlers({

// Let sendMessage handle provider enforcement and normalization
return await sendMessage(messageText, socket, targetMode, undefined, options);
}, [anyProviderConnected, selectedMode, sendMessage, toast]);
}, [anyProviderConnected, selectedMode, sendMessage, toast, selectedAction]);

const handleSend = useCallback(async (messageText: string, socket: ChatWebSocket, overrideMode?: string, options?: { triggerRca?: boolean }) => {
return await initiateSend(messageText, socket, overrideMode, options);
Expand Down
Loading
Loading