diff --git a/apps/rowboat/app/projects/[projectId]/copilot/app.tsx b/apps/rowboat/app/projects/[projectId]/copilot/app.tsx index 0b214aca..20755995 100644 --- a/apps/rowboat/app/projects/[projectId]/copilot/app.tsx +++ b/apps/rowboat/app/projects/[projectId]/copilot/app.tsx @@ -52,6 +52,7 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message const workflowRef = useRef(workflow); const startRef = useRef(null); const cancelRef = useRef(null); + const [statusBar, setStatusBar] = useState(null); // Keep workflow ref up to date workflowRef.current = workflow; @@ -156,6 +157,11 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message loadingResponse={loadingResponse} workflow={workflowRef.current} dispatch={dispatch} + onStatusBarChange={status => setStatusBar({ + ...status, + context: effectiveContext, + onCloseContext: () => setDiscardContext(true) + })} />
@@ -181,40 +187,7 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
)} - {effectiveContext && ( -
-
- {/* Context icon (no background) */} - {effectiveContext.type === 'chat' && ( - - )} - {effectiveContext.type === 'agent' && ( - - )} - {effectiveContext.type === 'tool' && ( - - )} - {effectiveContext.type === 'prompt' && ( - - )} - {/* Context label */} - - {effectiveContext.type === 'chat' && "Chat"} - {effectiveContext.type === 'agent' && `Agent: ${effectiveContext.name}`} - {effectiveContext.type === 'tool' && `Tool: ${effectiveContext.name}`} - {effectiveContext.type === 'prompt' && `Prompt: ${effectiveContext.name}`} - - {/* Close button */} - -
-
- )} + {/* Remove the separate context label here */} void; handleUserMessage: (message shouldAutoFocus={isLastInteracted} onFocus={() => setIsLastInteracted(true)} onCancel={cancel} + statusBar={statusBar || { context: effectiveContext, onCloseContext: () => setDiscardContext(true) }} /> diff --git a/apps/rowboat/app/projects/[projectId]/copilot/components/actions.tsx b/apps/rowboat/app/projects/[projectId]/copilot/components/actions.tsx index cb7b0828..a59a3af3 100644 --- a/apps/rowboat/app/projects/[projectId]/copilot/components/actions.tsx +++ b/apps/rowboat/app/projects/[projectId]/copilot/components/actions.tsx @@ -25,6 +25,9 @@ export function Action({ workflow, dispatch, stale, + onApplied, + externallyApplied = false, + defaultExpanded = false, }: { msgIndex: number; actionIndex: number; @@ -32,8 +35,12 @@ export function Action({ workflow: z.infer; dispatch: (action: any) => void; stale: boolean; + onApplied?: () => void; + externallyApplied?: boolean; + defaultExpanded?: boolean; }) { - const [expanded, setExpanded] = useState(false); + const { showPreview } = usePreviewModal(); + const [expanded, setExpanded] = useState(defaultExpanded); const [appliedChanges, setAppliedChanges] = useState>({}); if (!action || typeof action !== 'object') { @@ -44,7 +51,7 @@ export function Action({ const appliedFields = Object.keys(action.config_changes).filter(key => appliedChanges[getAppliedChangeKey(msgIndex, actionIndex, key)] ); - const allApplied = Object.keys(action.config_changes).every(key => + const allApplied = externallyApplied || Object.keys(action.config_changes).every(key => appliedFields.includes(key) ); @@ -76,10 +83,24 @@ export function Action({ break; } - setAppliedChanges(prev => ({ - ...prev, - [getAppliedChangeKey(msgIndex, actionIndex, field)]: true - })); + setAppliedChanges(prev => { + const newApplied = { + ...prev, + [getAppliedChangeKey(msgIndex, actionIndex, field)]: true + }; + + // Check if all fields are now applied + const allFieldsApplied = Object.keys(action.config_changes).every(key => + newApplied[getAppliedChangeKey(msgIndex, actionIndex, key)] + ); + + // If all fields are applied, notify parent + if (allFieldsApplied) { + onApplied?.(); + } + + return newApplied; + }); }; // Handle applying all changes @@ -149,56 +170,100 @@ export function Action({ ...prev, ...appliedKeys })); + + // Notify parent that this action has been applied + onApplied?.(); }; - return
+ // Helper to get the main field for diff + function getMainDiffField() { + if (action.config_type === 'agent' && 'instructions' in action.config_changes) return 'instructions'; + if (action.config_type === 'tool' && 'description' in action.config_changes) return 'description'; + if (action.config_type === 'prompt' && 'prompt' in action.config_changes) return 'prompt'; + // fallback: first field + return Object.keys(action.config_changes)[0]; + } + + function handleViewDiff() { + const field = getMainDiffField(); + if (!field) return; + const newValue = action.config_changes[field]; + let oldValue = undefined; + if (action.action === 'edit') { + if (action.config_type === 'tool') { + const tool = workflow.tools.find(t => t.name === action.name); + if (tool) oldValue = (tool as any)[field]; + } else if (action.config_type === 'agent') { + const agent = workflow.agents.find(a => a.name === action.name); + if (agent) oldValue = (agent as any)[field]; + } else if (action.config_type === 'prompt') { + const prompt = workflow.prompts.find(p => p.name === action.name); + if (prompt) oldValue = (prompt as any)[field]; + } + } + const markdown = (action.config_type === 'agent' && field === 'instructions') || + (action.config_type === 'prompt' && field === 'prompt'); + showPreview( + oldValue ? (typeof oldValue === 'string' ? oldValue : JSON.stringify(oldValue, null, 2)) : undefined, + typeof newValue === 'string' ? newValue : JSON.stringify(newValue, null, 2), + markdown, + `${action.name} - ${field}`, + 'Review changes' + ); + } + + return
- - - {expanded && - {action.error &&
-
This configuration is invalid and cannot be applied:
-
{action.error}
-
} -
- {Object.entries(action.config_changes).map(([key, value]) => { - return - })} -
-
} -
- {action.error &&
-
- -
Error
-
-
} - {!action.error && } - + disabled={allApplied} + onClick={() => handleApplyAll()} + > + + {allApplied ? 'Applied' : 'Apply'} + + +
+
+ {/* Description of what happened */} +
+ {action.change_description || 'No description provided.'}
; @@ -251,19 +316,19 @@ export function ActionField({ // Find the tool in the workflow const tool = workflow.tools.find(t => t.name === action.name); if (tool) { - oldValue = tool[field as keyof typeof tool]; + oldValue = (tool as any)[field]; } } else if (action.config_type === 'agent') { // Find the agent in the workflow const agent = workflow.agents.find(a => a.name === action.name); if (agent) { - oldValue = agent[field as keyof typeof agent]; + oldValue = (agent as any)[field]; } } else if (action.config_type === 'prompt') { // Find the prompt in the workflow const prompt = workflow.prompts.find(p => p.name === action.name); if (prompt) { - oldValue = prompt[field as keyof typeof prompt]; + oldValue = (prompt as any)[field]; } } } @@ -333,23 +398,38 @@ export function StreamingAction({ }; loading: boolean; }) { - return
-
- {action.action == 'create_new' && } - {action.action == 'edit' && } -
- {action.config_type && `${action.action === 'create_new' ? 'Create' : 'Edit'} ${action.config_type}`} - {action.name && {action.name}} + // Use the same card container and header style as Action + return ( +
+
+ {/* Small colored icon for type */} + + {action.config_type === 'agent' ? '🧑‍💼' : action.config_type === 'tool' ? '🛠️' : '💬'} + + + {action.action === 'create_new' ? 'Add' : 'Edit'} {action.config_type}: {action.name} + +
+ {/* Loading state body */} +
+ + Loading...
-
-
- {loading && } - {!loading &&
Canceled
} -
-
-
; + ); } \ No newline at end of file diff --git a/apps/rowboat/app/projects/[projectId]/copilot/components/messages.tsx b/apps/rowboat/app/projects/[projectId]/copilot/components/messages.tsx index f646540f..c82bb302 100644 --- a/apps/rowboat/app/projects/[projectId]/copilot/components/messages.tsx +++ b/apps/rowboat/app/projects/[projectId]/copilot/components/messages.tsx @@ -2,13 +2,14 @@ import { Spinner } from "@heroui/react"; import { useEffect, useRef, useState } from "react"; import { z } from "zod"; -import { Workflow, WorkflowTool, WorkflowAgent, WorkflowPrompt } from "@/app/lib/types/workflow_types"; +import { Workflow} from "@/app/lib/types/workflow_types"; import MarkdownContent from "@/app/lib/components/markdown-content"; -import { MessageSquareIcon, EllipsisIcon, XIcon } from "lucide-react"; +import { MessageSquareIcon, EllipsisIcon, XIcon, CheckCheckIcon, ChevronDown, ChevronUp } from "lucide-react"; import { CopilotMessage, CopilotAssistantMessage, CopilotAssistantMessageActionPart } from "@/app/lib/types/copilot_types"; import { Action, StreamingAction } from './actions'; import { useParsedBlocks } from "../use-parsed-blocks"; import { validateConfigChanges } from "@/app/lib/client_utils"; +import { PreviewModalProvider } from '../../workflow/preview-modal'; const CopilotResponsePart = z.union([ z.object({ @@ -152,21 +153,45 @@ function InternalAssistantMessage({ content }: { content: string }) { ); } +type ActionPanelBlock = { + part: { + type: 'action'; + action: any; + } | { + type: 'streaming_action'; + action: any; + }; + actionIndex: number; +}; +/** + * AssistantMessage component that renders copilot responses with action cards. + * + * Features: + * - Renders text content with markdown support + * - Displays individual action cards for workflow changes + * - Shows "Apply All" button when there are action cards + * - Supports streaming responses with real-time apply all functionality + * - Action cards are in a collapsible panel with a ticker summary in collapsed state + */ function AssistantMessage({ content, workflow, dispatch, messageIndex, - loading + loading, + onStatusBarChange }: { content: z.infer['content'], workflow: z.infer, dispatch: (action: any) => void, messageIndex: number, - loading: boolean + loading: boolean, + onStatusBarChange?: (status: any) => void }) { const blocks = useParsedBlocks(content); + const [appliedActions, setAppliedActions] = useState>(new Set()); + // Remove autoApplyEnabled and useEffect for auto-apply // parse actions from parts let parsed: z.infer[] = []; @@ -181,39 +206,267 @@ function AssistantMessage({ } } - // split the content into parts + // Only render text outside the panel + const textBlocks = parsed.filter(part => part.type === 'text'); + // All cards (action and streaming_action) go inside the panel + const cardBlocks: ActionPanelBlock[] = parsed + .map((part, actionIndex) => ({ part, actionIndex })) + .filter(({ part }) => part.type === 'action' || part.type === 'streaming_action') as ActionPanelBlock[]; + const hasCards = cardBlocks.length > 0; + const totalActions = cardBlocks.filter(({ part }) => part.type === 'action').length; + const appliedCount = Array.from(appliedActions).length; + const pendingCount = Math.max(0, totalActions - appliedCount); + const allApplied = pendingCount === 0 && totalActions > 0; + + // Apply a single action + const applyAction = (action: any, actionIndex: number) => { + // Only apply, do not update appliedActions here + if (action.action === 'create_new') { + switch (action.config_type) { + case 'agent': + dispatch({ + type: 'add_agent', + agent: { + name: action.name, + ...action.config_changes + } + }); + break; + case 'tool': + dispatch({ + type: 'add_tool', + tool: { + name: action.name, + ...action.config_changes + } + }); + break; + case 'prompt': + dispatch({ + type: 'add_prompt', + prompt: { + name: action.name, + ...action.config_changes + } + }); + break; + } + } else if (action.action === 'edit') { + switch (action.config_type) { + case 'agent': + dispatch({ + type: 'update_agent', + name: action.name, + agent: action.config_changes + }); + break; + case 'tool': + dispatch({ + type: 'update_tool', + name: action.name, + tool: action.config_changes + }); + break; + case 'prompt': + dispatch({ + type: 'update_prompt', + name: action.name, + prompt: action.config_changes + }); + break; + } + } + }; + + // Apply All: batch apply all unapplied actions and update state once + const handleApplyAll = () => { + // Find all unapplied action indices + const unapplied = cardBlocks + .filter(({ part, actionIndex }) => part.type === 'action' && !appliedActions.has(actionIndex)) + .map(({ part, actionIndex }) => ({ action: part.action, actionIndex })); + + // Synchronously apply all unapplied actions + unapplied.forEach(({ action, actionIndex }) => { + applyAction(action, actionIndex); + }); + + // After all are applied, update the state in one go + setAppliedActions(prev => { + const next = new Set(prev); + unapplied.forEach(({ actionIndex }) => next.add(actionIndex)); + return next; + }); + }; + + // Manual single apply (from card) + const handleSingleApply = (action: any, actionIndex: number) => { + if (!appliedActions.has(actionIndex)) { + applyAction(action, actionIndex); + setAppliedActions(prev => new Set([...prev, actionIndex])); + } + }; + + useEffect(() => { + if (loading) { + // setAutoApplyEnabled(false); // Removed + setAppliedActions(new Set()); + // setPanelOpen(false); // Removed + } + }, [loading]); + + // Removed useEffect for auto-apply + + // Find streaming/ongoing card and extract name + const streamingBlock = cardBlocks.find(({ part }) => part.type === 'streaming_action'); + let streamingLine = ''; + if (streamingBlock && streamingBlock.part.type === 'streaming_action' && streamingBlock.part.action && streamingBlock.part.action.name) { + streamingLine = `Generating ${streamingBlock.part.action.name}...`; + } + + // Find the first card index + const firstCardIdx = parsed.findIndex(part => part.type === 'action' || part.type === 'streaming_action'); + // Group blocks into: beforePanel, cardBlocks, afterPanel + const beforePanel = firstCardIdx === -1 ? parsed : parsed.slice(0, firstCardIdx); + const panelBlocks = firstCardIdx === -1 ? [] : parsed.slice(firstCardIdx).filter(part => part.type === 'action' || part.type === 'streaming_action'); + // Find where the card blocks end (first non-card after first card) + let afterPanelStart = firstCardIdx; + if (firstCardIdx !== -1) { + for (let i = firstCardIdx; i < parsed.length; i++) { + if (parsed[i].type !== 'action' && parsed[i].type !== 'streaming_action') { + afterPanelStart = i; + break; + } + } + } + const afterPanel = (firstCardIdx !== -1 && afterPanelStart > firstCardIdx) ? parsed.slice(afterPanelStart) : []; + + // Only show Apply All button if all cards are loaded (no streaming_action cards) and streaming is finished + const allCardsLoaded = !loading && panelBlocks.length > 0 && panelBlocks.every(part => part.type === 'action'); + // When all cards are loaded, show summary of agents created/updated + let completedSummary = ''; + if (allCardsLoaded && totalActions > 0) { + // Count how many are create vs edit + const createCount = cardBlocks.filter(({ part }) => part.type === 'action' && part.action.action === 'create_new').length; + const editCount = cardBlocks.filter(({ part }) => part.type === 'action' && part.action.action === 'edit').length; + const parts = []; + if (createCount > 0) parts.push(`${createCount} agent${createCount > 1 ? 's' : ''} created`); + if (editCount > 0) parts.push(`${editCount} agent${editCount > 1 ? 's' : ''} updated`); + completedSummary = parts.join(', '); + } + + // Detect if any card has an error or is cancelled + const hasPanelWarning = cardBlocks.some( + ({ part }) => + part.type === 'action' && + part.action && + (part.action.error || ('cancelled' in part.action && part.action.cancelled)) + ); + + // Ticker summary for collapsed state (two lines) + const ticker = ( +
+ {allCardsLoaded && completedSummary ? ( + {completedSummary} + ) : streamingLine && ( + {streamingLine} + )} + {appliedCount} applied, {pendingCount} pending +
+ ); + + const applyAllButton = ( + + ); + + // Utility to filter out divider/empty markdown blocks + function isNonDividerMarkdown(content: string) { + const trimmed = content.trim(); + return ( + trimmed !== '' && + !/^(-{3,}|_{3,}|\*{3,})$/.test(trimmed) + ); + } + + // Restore panelOpen state if missing + const [panelOpen, setPanelOpen] = useState(false); // collapsed by default + + // At the end of the render, call onStatusBarChange with the current status bar props + useEffect(() => { + if (onStatusBarChange) { + onStatusBarChange({ + allCardsLoaded, + allApplied, + appliedCount, + pendingCount, + streamingLine, + completedSummary, + hasPanelWarning, + handleApplyAll, + }); + } + }, [allCardsLoaded, allApplied, appliedCount, pendingCount, streamingLine, completedSummary, hasPanelWarning]); + + // Render all cards inline, not in a panel return (
-
-
- {parsed.map((part, actionIndex) => { - if (part.type === 'text') { - return ; - } - if (part.type === 'streaming_action') { - return ; - } - if (part.type === 'action') { - return + + {/* Render markdown and cards inline in order */} + {parsed.map((part, idx) => { + if (part.type === 'text' && isNonDividerMarkdown(part.content)) { + return ; + } + if (part.type === 'action') { + return ( + ; - } - })} -
+ onApplied={() => handleSingleApply(part.action, idx)} + externallyApplied={appliedActions.has(idx)} + defaultExpanded={true} + /> + ); + } + if (part.type === 'streaming_action') { + return ( + + ); + } + return null; + })} +
@@ -245,13 +498,15 @@ export function Messages({ streamingResponse, loadingResponse, workflow, - dispatch + dispatch, + onStatusBarChange }: { messages: z.infer[]; streamingResponse: string; loadingResponse: boolean; workflow: z.infer; dispatch: (action: any) => void; + onStatusBarChange?: (status: any) => void; }) { const messagesEndRef = useRef(null); const [displayMessages, setDisplayMessages] = useState(messages); @@ -280,6 +535,9 @@ export function Messages({ return () => clearTimeout(timeoutId); }, [messages, loadingResponse]); + // Track the latest status bar info + const latestStatusBar = useRef(null); + const renderMessage = (message: z.infer, messageIndex: number) => { if (message.role === 'assistant') { return ( @@ -290,6 +548,13 @@ export function Messages({ dispatch={dispatch} messageIndex={messageIndex} loading={loadingResponse} + onStatusBarChange={status => { + // Only update for the last assistant message + if (messageIndex === displayMessages.length - 1) { + latestStatusBar.current = status; + onStatusBarChange?.(status); + } + }} /> ); } diff --git a/apps/rowboat/app/projects/[projectId]/workflow/preview-modal.tsx b/apps/rowboat/app/projects/[projectId]/workflow/preview-modal.tsx index 174d9e5e..4b30b961 100644 --- a/apps/rowboat/app/projects/[projectId]/workflow/preview-modal.tsx +++ b/apps/rowboat/app/projects/[projectId]/workflow/preview-modal.tsx @@ -3,7 +3,7 @@ import clsx from "clsx"; import MarkdownContent from "../../../lib/components/markdown-content"; import React, { PureComponent } from 'react'; import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer-continued'; -import { XIcon } from "lucide-react"; +import { XIcon, EyeIcon } from "lucide-react"; import { Button } from "@heroui/react"; // Create the context type @@ -99,43 +99,52 @@ function PreviewModal({ }) { const buttonLabel = oldValue === undefined ? 'Preview' : 'Diff'; const [view, setView] = useState<'preview' | 'markdown'>('preview'); - console.log(oldValue, newValue); - return
-
- -
-
-
{title}
- {message &&
{message}
} -
- {onApply && } -
-
-
-
- - {markdown && } + return ( +
+
+ {/* Close button */} + + {/* Header */} +
+
+ + {title}
+ {message &&
{message}
} +
-
+ {/* Tabs */} +
+ + {markdown && ( + + )} +
+ {/* Diff/Markdown content */} +
{view === 'preview' &&
{oldValue !== undefined && {newValue}}
} {view === 'markdown' &&
- {oldValue !== undefined &&
-
Old
+ {oldValue !== undefined &&
+
Old
- {oldValue !== undefined &&
New
} + {oldValue !== undefined &&
New
}
}
+ {/* Footer */} + {onApply && ( +
+ +
+ )}
-
; + ); } \ No newline at end of file diff --git a/apps/rowboat/components/common/compose-box-copilot.tsx b/apps/rowboat/components/common/compose-box-copilot.tsx index 87cbb56e..eb6c6f5e 100644 --- a/apps/rowboat/components/common/compose-box-copilot.tsx +++ b/apps/rowboat/components/common/compose-box-copilot.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Button, Spinner } from "@heroui/react"; +import { Button, Spinner, Tooltip } from "@heroui/react"; import { useRef, useState, useEffect } from "react"; import { Textarea } from "@/components/ui/textarea"; @@ -22,6 +22,7 @@ interface ComposeBoxCopilotProps { shouldAutoFocus?: boolean; onFocus?: () => void; onCancel?: () => void; + statusBar?: any; } export function ComposeBoxCopilot({ @@ -32,6 +33,7 @@ export function ComposeBoxCopilot({ shouldAutoFocus = false, onFocus, onCancel, + statusBar, }: ComposeBoxCopilotProps) { const [input, setInput] = useState(''); const [isFocused, setIsFocused] = useState(false); @@ -75,13 +77,14 @@ export function ComposeBoxCopilot({ }; return ( -
+
+ {/* Status bar above the input */} + {statusBar && } {/* Keyboard shortcut hint */}
Press ⌘ + Enter to send
- {/* Outer container with padding */}
@@ -113,7 +116,6 @@ export function ComposeBoxCopilot({ `} />
- {/* Send button */} +
+ + ); + } + // Show real button when ready + return ( + + ); + }; + return ( +
+ {/* Left: context + status/ticker, flex-1, truncate as needed */} +
+ {renderContext()} + {renderStatus() && ( +
{renderStatus()}
+ )} +
+ {/* Divider and rightmost Apply All button */} + {renderApplyAll() && ( + <> +
+
{renderApplyAll()}
+ + )} + {/* Optional: subtle shadow at the bottom for extra depth */} +
+
+ ); +}