mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-04 21:02:39 +02:00
Copilot apply all and status bar (#173)
* Club context with status bar copilot Club copilot cards and add apply all Remove ability to delete context and club context with status bar copilot * Show apply all disabled in copilot
This commit is contained in:
parent
bbd112f80a
commit
44a951c5d2
5 changed files with 654 additions and 179 deletions
|
|
@ -52,6 +52,7 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
|
|||
const workflowRef = useRef(workflow);
|
||||
const startRef = useRef<any>(null);
|
||||
const cancelRef = useRef<any>(null);
|
||||
const [statusBar, setStatusBar] = useState<any>(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)
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 px-1 pb-6">
|
||||
|
|
@ -181,40 +187,7 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
|
|||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{effectiveContext && (
|
||||
<div className="flex items-start mb-2">
|
||||
<div className="flex items-center gap-2 px-3 py-1 rounded-full border border-zinc-200 dark:border-zinc-700 bg-zinc-50/70 dark:bg-zinc-800/40 shadow-sm text-sm font-medium text-zinc-700 dark:text-zinc-200 transition-all">
|
||||
{/* Context icon (no background) */}
|
||||
{effectiveContext.type === 'chat' && (
|
||||
<svg className="w-4 h-4 text-blue-500 dark:text-blue-300 mr-1" fill="currentColor" viewBox="0 0 20 20"><path d="M18 10c0 3.866-3.582 7-8 7a8.96 8.96 0 01-4.39-1.11L2 17l1.11-2.61A8.96 8.96 0 012 10c0-3.866 3.582-7 8-7s8 3.134 8 7z" /></svg>
|
||||
)}
|
||||
{effectiveContext.type === 'agent' && (
|
||||
<svg className="w-4 h-4 text-green-500 dark:text-green-300 mr-1" fill="currentColor" viewBox="0 0 20 20"><path d="M10 2a6 6 0 016 6c0 2.21-1.343 4.09-3.25 5.25A4.992 4.992 0 0110 18a4.992 4.992 0 01-2.75-4.75C5.343 12.09 4 10.21 4 8a6 6 0 016-6z" /></svg>
|
||||
)}
|
||||
{effectiveContext.type === 'tool' && (
|
||||
<svg className="w-4 h-4 text-yellow-500 dark:text-yellow-300 mr-1" fill="currentColor" viewBox="0 0 20 20"><path d="M13.293 2.293a1 1 0 011.414 0l3 3a1 1 0 010 1.414l-8.5 8.5a1 1 0 01-.293.207l-4 2a1 1 0 01-1.316-1.316l2-4a1 1 0 01.207-.293l8.5-8.5z" /></svg>
|
||||
)}
|
||||
{effectiveContext.type === 'prompt' && (
|
||||
<svg className="w-4 h-4 text-purple-500 dark:text-purple-300 mr-1" fill="currentColor" viewBox="0 0 20 20"><circle cx="10" cy="10" r="8" /></svg>
|
||||
)}
|
||||
{/* Context label */}
|
||||
<span>
|
||||
{effectiveContext.type === 'chat' && "Chat"}
|
||||
{effectiveContext.type === 'agent' && `Agent: ${effectiveContext.name}`}
|
||||
{effectiveContext.type === 'tool' && `Tool: ${effectiveContext.name}`}
|
||||
{effectiveContext.type === 'prompt' && `Prompt: ${effectiveContext.name}`}
|
||||
</span>
|
||||
{/* Close button */}
|
||||
<button
|
||||
className="ml-2 text-zinc-400 hover:text-zinc-600 dark:text-zinc-400 dark:hover:text-zinc-100 transition-colors duration-150 rounded-full p-1 focus:outline-none focus:ring-2 focus:ring-blue-200"
|
||||
onClick={() => setDiscardContext(true)}
|
||||
aria-label="Close context"
|
||||
>
|
||||
<XIcon size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Remove the separate context label here */}
|
||||
<ComposeBoxCopilot
|
||||
handleUserMessage={handleUserMessage}
|
||||
messages={messages}
|
||||
|
|
@ -223,6 +196,7 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
|
|||
shouldAutoFocus={isLastInteracted}
|
||||
onFocus={() => setIsLastInteracted(true)}
|
||||
onCancel={cancel}
|
||||
statusBar={statusBar || { context: effectiveContext, onCloseContext: () => setDiscardContext(true) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<typeof Workflow>;
|
||||
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<Record<string, boolean>>({});
|
||||
|
||||
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 <div className={clsx('flex flex-col rounded-sm border border-t-4', {
|
||||
'bg-gray-50 dark:bg-gray-800/50 border-gray-400 dark:border-gray-600 border-t-blue-500 shadow': !stale && !allApplied && action.action == 'create_new',
|
||||
'bg-gray-50 dark:bg-gray-800/50 border-gray-400 dark:border-gray-600 border-t-orange-500 shadow': !stale && !allApplied && action.action == 'edit',
|
||||
'bg-gray-100 dark:bg-gray-800/30 border-gray-400 dark:border-gray-600 border-t-gray-400': stale || allApplied || action.error,
|
||||
})}>
|
||||
// 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 <div className={clsx(
|
||||
'flex flex-col rounded-md border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-xs',
|
||||
'transition-shadow duration-150',
|
||||
{
|
||||
'border-l-2 border-l-blue-500': !stale && !allApplied && action.action == 'create_new',
|
||||
'border-l-2 border-l-orange-500': !stale && !allApplied && action.action == 'edit',
|
||||
'border-l-2 border-l-gray-400': stale || allApplied || action.error,
|
||||
}
|
||||
)}>
|
||||
<ActionContext.Provider value={{ msgIndex, actionIndex, action, workflow, appliedFields, stale }}>
|
||||
<ActionHeader />
|
||||
<ActionSummary />
|
||||
{expanded && <PreviewModalProvider>
|
||||
{action.error && <div className="flex flex-col gap-1 px-1 text-xs bg-red-50 dark:bg-red-900/20 rounded-sm">
|
||||
<div className="text-red-500 dark:text-red-400 font-medium text-xs">This configuration is invalid and cannot be applied:</div>
|
||||
<div className="text-xs font-mono dark:text-gray-300">{action.error}</div>
|
||||
</div>}
|
||||
<div className="flex flex-col gap-2 px-1">
|
||||
{Object.entries(action.config_changes).map(([key, value]) => {
|
||||
return <ActionField key={key} field={key} onApply={handleFieldChange} />
|
||||
})}
|
||||
</div>
|
||||
</PreviewModalProvider>}
|
||||
<div className="flex items-center">
|
||||
{action.error && <div className="grow rounded-l-sm bg-red-100 dark:bg-red-900/20 text-red-500 dark:text-red-400 flex flex-col items-center justify-center h-8">
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
<AlertTriangleIcon size={16} />
|
||||
<div className="font-medium text-xs">Error</div>
|
||||
</div>
|
||||
</div>}
|
||||
{!action.error && <button
|
||||
className="grow rounded-l-sm bg-blue-100 dark:bg-blue-900/20 text-blue-500 dark:text-blue-400 hover:bg-blue-200 dark:hover:bg-blue-900/30 disabled:bg-gray-100 dark:disabled:bg-gray-800/30 disabled:text-gray-300 dark:disabled:text-gray-600 flex flex-col items-center justify-center h-8"
|
||||
onClick={handleApplyAll}
|
||||
disabled={stale || allApplied}
|
||||
>
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
<CheckCheckIcon size={16} />
|
||||
<div className="font-medium text-xs">{allApplied ? 'Applied' : 'Apply'}</div>
|
||||
</div>
|
||||
</button>}
|
||||
<button
|
||||
className="w-10 shrink-0 flex flex-col items-center h-8 rounded-r-sm bg-gray-100 dark:bg-gray-800/50 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 justify-center"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2 justify-center text-gray-400 dark:text-gray-500">
|
||||
{expanded ? (
|
||||
<ChevronsUpIcon size={16} />
|
||||
) : (
|
||||
<ChevronsDownIcon size={16} />
|
||||
<div className="flex items-center gap-2 px-2 py-1 border-b border-zinc-100 dark:border-zinc-800">
|
||||
{/* Small colored icon for type */}
|
||||
<span className={clsx(
|
||||
'inline-flex items-center justify-center rounded-full h-5 w-5 text-xs',
|
||||
{
|
||||
'bg-blue-100 text-blue-600': action.action == 'create_new',
|
||||
'bg-orange-100 text-orange-600': action.action == 'edit',
|
||||
'bg-gray-200 text-gray-600': stale || allApplied || action.error,
|
||||
}
|
||||
)}>
|
||||
{action.config_type === 'agent' ? '🧑💼' : action.config_type === 'tool' ? '🛠️' : '💬'}
|
||||
</span>
|
||||
<span className="font-semibold text-sm text-zinc-800 dark:text-zinc-100 truncate flex-1">
|
||||
{action.action === 'create_new' ? 'Add' : 'Edit'} {action.config_type}: {action.name}
|
||||
</span>
|
||||
{/* Action buttons - compact, icon only, show text on hover */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className={clsx(
|
||||
'flex items-center gap-1 rounded-full px-2 h-7 text-xs font-medium transition-colors bg-transparent',
|
||||
allApplied
|
||||
? 'text-zinc-400 cursor-not-allowed'
|
||||
: 'text-green-600 hover:text-green-700'
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
disabled={allApplied}
|
||||
onClick={() => handleApplyAll()}
|
||||
>
|
||||
<CheckIcon size={13} className={allApplied ? 'text-zinc-400' : 'text-green-600 group-hover:text-green-700'} />
|
||||
<span>{allApplied ? 'Applied' : 'Apply'}</span>
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1 rounded-full px-2 h-7 text-xs font-medium bg-transparent text-indigo-600 hover:text-indigo-700 transition-colors"
|
||||
onClick={handleViewDiff}
|
||||
>
|
||||
<EyeIcon size={13} className="text-indigo-600 group-hover:text-indigo-700" />
|
||||
<span>View Diff</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Description of what happened */}
|
||||
<div className="px-3 py-2 text-xs text-zinc-700 dark:text-zinc-200">
|
||||
{action.change_description || 'No description provided.'}
|
||||
</div>
|
||||
</ActionContext.Provider>
|
||||
</div>;
|
||||
|
|
@ -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 <div className={clsx('flex flex-col rounded-sm border border-t-4', {
|
||||
'bg-gray-50 dark:bg-gray-800/50 border-gray-400 dark:border-gray-600 border-t-blue-500 shadow': action.action == 'create_new',
|
||||
'bg-gray-50 dark:bg-gray-800/50 border-gray-400 dark:border-gray-600 border-t-orange-500 shadow': action.action == 'edit',
|
||||
})}>
|
||||
<div className="flex gap-2 items-center py-1 px-1">
|
||||
{action.action == 'create_new' && <PlusIcon size={16} />}
|
||||
{action.action == 'edit' && <PencilIcon size={16} />}
|
||||
<div className="text-sm truncate">
|
||||
{action.config_type && `${action.action === 'create_new' ? 'Create' : 'Edit'} ${action.config_type}`}
|
||||
{action.name && <span className="font-medium ml-1">{action.name}</span>}
|
||||
// Use the same card container and header style as Action
|
||||
return (
|
||||
<div className={clsx(
|
||||
'flex flex-col rounded-md border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-xs',
|
||||
'transition-shadow duration-150',
|
||||
{
|
||||
'border-l-2 border-l-blue-500': action.action == 'create_new',
|
||||
'border-l-2 border-l-orange-500': action.action == 'edit',
|
||||
'border-l-2 border-l-gray-400': !action.action,
|
||||
}
|
||||
)}>
|
||||
<div className="flex items-center gap-2 px-2 py-1 border-b border-zinc-100 dark:border-zinc-800">
|
||||
{/* Small colored icon for type */}
|
||||
<span className={clsx(
|
||||
'inline-flex items-center justify-center rounded-full h-5 w-5 text-xs',
|
||||
{
|
||||
'bg-blue-100 text-blue-600': action.action == 'create_new',
|
||||
'bg-orange-100 text-orange-600': action.action == 'edit',
|
||||
'bg-gray-200 text-gray-600': !action.action,
|
||||
}
|
||||
)}>
|
||||
{action.config_type === 'agent' ? '🧑💼' : action.config_type === 'tool' ? '🛠️' : '💬'}
|
||||
</span>
|
||||
<span className="font-semibold text-sm text-zinc-800 dark:text-zinc-100 truncate flex-1">
|
||||
{action.action === 'create_new' ? 'Add' : 'Edit'} {action.config_type}: {action.name}
|
||||
</span>
|
||||
</div>
|
||||
{/* Loading state body */}
|
||||
<div className="px-3 py-4 text-xs text-zinc-500 dark:text-zinc-400 flex items-center gap-2 min-h-[32px]">
|
||||
<Spinner size="sm" />
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-1 my-1">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-sm p-2 text-sm flex items-center gap-2">
|
||||
{loading && <Spinner size="sm" />}
|
||||
{!loading && <div className="text-gray-400">Canceled</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
);
|
||||
}
|
||||
|
|
@ -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<typeof CopilotAssistantMessage>['content'],
|
||||
workflow: z.infer<typeof Workflow>,
|
||||
dispatch: (action: any) => void,
|
||||
messageIndex: number,
|
||||
loading: boolean
|
||||
loading: boolean,
|
||||
onStatusBarChange?: (status: any) => void
|
||||
}) {
|
||||
const blocks = useParsedBlocks(content);
|
||||
const [appliedActions, setAppliedActions] = useState<Set<number>>(new Set());
|
||||
// Remove autoApplyEnabled and useEffect for auto-apply
|
||||
|
||||
// parse actions from parts
|
||||
let parsed: z.infer<typeof CopilotResponsePart>[] = [];
|
||||
|
|
@ -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 = (
|
||||
<div className="flex flex-col">
|
||||
{allCardsLoaded && completedSummary ? (
|
||||
<span className="font-medium text-xs sm:text-sm">{completedSummary}</span>
|
||||
) : streamingLine && (
|
||||
<span className="font-medium text-xs sm:text-sm">{streamingLine}</span>
|
||||
)}
|
||||
<span className="font-medium text-xs sm:text-sm">{appliedCount} applied, {pendingCount} pending</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const applyAllButton = (
|
||||
<button
|
||||
onClick={handleApplyAll}
|
||||
disabled={allApplied} // Changed to allApplied
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-full font-medium text-sm transition-colors duration-200
|
||||
${
|
||||
allApplied
|
||||
? 'bg-zinc-100 dark:bg-zinc-800 text-zinc-400 cursor-not-allowed border border-zinc-200 dark:border-zinc-700 shadow-none'
|
||||
: 'bg-blue-100 dark:bg-zinc-900 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-zinc-800 border border-blue-200 dark:border-zinc-800 shadow-sm'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{allApplied ? (
|
||||
<>
|
||||
<CheckCheckIcon size={16} />
|
||||
All applied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCheckIcon size={16} />
|
||||
Apply all
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
// 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 (
|
||||
<div className="w-full">
|
||||
<div className="px-4 py-2.5 text-sm leading-relaxed text-gray-700 dark:text-gray-200">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="text-left flex flex-col gap-4">
|
||||
{parsed.map((part, actionIndex) => {
|
||||
if (part.type === 'text') {
|
||||
return <MarkdownContent
|
||||
key={actionIndex}
|
||||
content={part.content}
|
||||
/>;
|
||||
}
|
||||
if (part.type === 'streaming_action') {
|
||||
return <StreamingAction
|
||||
key={actionIndex}
|
||||
action={part.action}
|
||||
loading={loading}
|
||||
/>;
|
||||
}
|
||||
if (part.type === 'action') {
|
||||
return <Action
|
||||
key={actionIndex}
|
||||
<div className="flex flex-col gap-2">
|
||||
<PreviewModalProvider>
|
||||
{/* Render markdown and cards inline in order */}
|
||||
{parsed.map((part, idx) => {
|
||||
if (part.type === 'text' && isNonDividerMarkdown(part.content)) {
|
||||
return <MarkdownContent key={`text-${idx}`} content={part.content} />;
|
||||
}
|
||||
if (part.type === 'action') {
|
||||
return (
|
||||
<Action
|
||||
key={`action-${idx}`}
|
||||
msgIndex={messageIndex}
|
||||
actionIndex={actionIndex}
|
||||
actionIndex={idx}
|
||||
action={part.action}
|
||||
workflow={workflow}
|
||||
dispatch={dispatch}
|
||||
stale={false}
|
||||
/>;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
onApplied={() => handleSingleApply(part.action, idx)}
|
||||
externallyApplied={appliedActions.has(idx)}
|
||||
defaultExpanded={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (part.type === 'streaming_action') {
|
||||
return (
|
||||
<StreamingAction
|
||||
key={`streaming-${idx}`}
|
||||
action={part.action}
|
||||
loading={loading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</PreviewModalProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -245,13 +498,15 @@ export function Messages({
|
|||
streamingResponse,
|
||||
loadingResponse,
|
||||
workflow,
|
||||
dispatch
|
||||
dispatch,
|
||||
onStatusBarChange
|
||||
}: {
|
||||
messages: z.infer<typeof CopilotMessage>[];
|
||||
streamingResponse: string;
|
||||
loadingResponse: boolean;
|
||||
workflow: z.infer<typeof Workflow>;
|
||||
dispatch: (action: any) => void;
|
||||
onStatusBarChange?: (status: any) => void;
|
||||
}) {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(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<any>(null);
|
||||
|
||||
const renderMessage = (message: z.infer<typeof CopilotMessage>, 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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <div className="fixed left-0 top-0 w-full h-full bg-gray-500/50 backdrop-blur-sm flex justify-center items-center z-50">
|
||||
<div className="bg-white rounded-md p-2 flex flex-col w-[90%] gap-4 h-[90%] max-w-7xl max-h-[800px]">
|
||||
<button className="self-end text-gray-500 hover:text-gray-700 flex items-center gap-1"
|
||||
onClick={onClose}
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-md font-semibold">{title}</div>
|
||||
{message && <div className="text-sm text-gray-600">{message}</div>}
|
||||
</div>
|
||||
{onApply && <Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onPress={() => {
|
||||
onApply();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Apply changes
|
||||
</Button>}
|
||||
</div>
|
||||
<div className="bg-gray-100 rounded-md p-2 flex flex-col overflow-auto">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<div className="flex items-center">
|
||||
<button className={clsx("text-sm text-gray-500 hover:text-gray-700 px-2 py-1 rounded-t-md", {
|
||||
'bg-white': view === 'preview',
|
||||
})} onClick={() => setView('preview')}>{buttonLabel}</button>
|
||||
{markdown && <button className={clsx("text-sm text-gray-500 hover:text-gray-700 px-2 py-1 rounded-t-md", {
|
||||
'bg-white': view === 'markdown',
|
||||
})} onClick={() => setView('markdown')}>Markdown</button>}
|
||||
return (
|
||||
<div className="fixed left-0 top-0 w-full h-full bg-gray-500/50 backdrop-blur-sm flex justify-center items-center z-50">
|
||||
<div className="relative bg-gradient-to-br from-white to-zinc-50 dark:from-zinc-900 dark:to-zinc-800 rounded-lg shadow-2xl p-6 w-[98vw] max-w-7xl max-h-[90vh] flex flex-col gap-6">
|
||||
{/* Close button */}
|
||||
<button className="absolute top-4 right-4 rounded-full p-2 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors" onClick={onClose}>
|
||||
<XIcon className="w-5 h-5 text-zinc-500" />
|
||||
</button>
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<EyeIcon className="text-indigo-500 w-5 h-5" />
|
||||
<span className="text-lg font-bold text-zinc-900 dark:text-zinc-100">{title}</span>
|
||||
</div>
|
||||
{message && <div className="text-sm text-zinc-500 dark:text-zinc-400 mb-2">{message}</div>}
|
||||
<div className="border-b border-zinc-100 dark:border-zinc-800 mb-4" />
|
||||
</div>
|
||||
<div className="bg-white rounded-md grow overflow-auto">
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-2">
|
||||
<button
|
||||
className={clsx(
|
||||
"px-4 py-1 rounded-full text-sm font-medium transition-colors",
|
||||
view === 'preview'
|
||||
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-200 shadow'
|
||||
: 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700'
|
||||
)}
|
||||
onClick={() => setView('preview')}
|
||||
>
|
||||
{buttonLabel}
|
||||
</button>
|
||||
{markdown && (
|
||||
<button
|
||||
className={clsx(
|
||||
"px-4 py-1 rounded-full text-sm font-medium transition-colors",
|
||||
view === 'markdown'
|
||||
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-200 shadow'
|
||||
: 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700'
|
||||
)}
|
||||
onClick={() => setView('markdown')}
|
||||
>
|
||||
Markdown
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Diff/Markdown content */}
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-md grow overflow-auto border border-zinc-100 dark:border-zinc-800">
|
||||
<div className="h-full flex flex-col overflow-auto">
|
||||
{view === 'preview' && <div className="flex gap-1 overflow-auto text-sm">
|
||||
{oldValue !== undefined && <ReactDiffViewer
|
||||
|
|
@ -147,8 +156,8 @@ function PreviewModal({
|
|||
{oldValue === undefined && <pre className="p-2 overflow-auto">{newValue}</pre>}
|
||||
</div>}
|
||||
{view === 'markdown' && <div className="flex gap-1">
|
||||
{oldValue !== undefined && <div className="w-1/2 flex flex-col border-r-2 border-gray-200 overflow-auto">
|
||||
<div className="text-gray-800 font-semibold italic text-sm px-2 py-1 border-b border-gray-200">Old</div>
|
||||
{oldValue !== undefined && <div className="w-1/2 flex flex-col border-r-2 border-gray-200 dark:border-zinc-800 overflow-auto">
|
||||
<div className="text-gray-800 dark:text-gray-200 font-semibold italic text-sm px-2 py-1 border-b border-gray-200 dark:border-zinc-800">Old</div>
|
||||
<div className="p-2 overflow-auto">
|
||||
<MarkdownContent
|
||||
content={oldValue}
|
||||
|
|
@ -158,7 +167,7 @@ function PreviewModal({
|
|||
<div className={clsx("flex flex-col", {
|
||||
'w-1/2': oldValue !== undefined
|
||||
})}>
|
||||
{oldValue !== undefined && <div className="text-gray-800 font-semibold italic text-sm px-2 py-1 border-b border-gray-200">New</div>}
|
||||
{oldValue !== undefined && <div className="text-gray-800 dark:text-gray-200 font-semibold italic text-sm px-2 py-1 border-b border-gray-200 dark:border-zinc-800">New</div>}
|
||||
<div className="p-2 overflow-auto">
|
||||
<MarkdownContent
|
||||
content={newValue}
|
||||
|
|
@ -168,7 +177,23 @@ function PreviewModal({
|
|||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
{/* Footer */}
|
||||
{onApply && (
|
||||
<div className="flex justify-end pt-2 border-t border-zinc-100 dark:border-zinc-800 sticky bottom-0 bg-gradient-to-t from-white/90 to-transparent dark:from-zinc-900/90">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onPress={() => {
|
||||
onApply();
|
||||
onClose();
|
||||
}}
|
||||
className="rounded-full px-6 py-2 shadow"
|
||||
>
|
||||
Apply changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="relative group">
|
||||
<div className="relative group z-10">
|
||||
{/* Status bar above the input */}
|
||||
{statusBar && <CopilotStatusBar {...statusBar} />}
|
||||
{/* Keyboard shortcut hint */}
|
||||
<div className="absolute -top-6 right-0 text-xs text-gray-500 dark:text-gray-400 opacity-0
|
||||
group-hover:opacity-100 transition-opacity">
|
||||
Press ⌘ + Enter to send
|
||||
</div>
|
||||
|
||||
{/* Outer container with padding */}
|
||||
<div className="rounded-2xl border-[1.5px] border-gray-200 dark:border-[#2a2d31] p-3 relative
|
||||
bg-white dark:bg-[#1e2023] flex items-end gap-2">
|
||||
|
|
@ -113,7 +116,6 @@ export function ComposeBoxCopilot({
|
|||
`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Send button */}
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -183,3 +185,132 @@ function StopIcon({ size, className }: { size: number, className?: string }) {
|
|||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CopilotStatusBar({
|
||||
allCardsLoaded,
|
||||
allApplied,
|
||||
appliedCount,
|
||||
pendingCount,
|
||||
streamingLine,
|
||||
completedSummary,
|
||||
hasPanelWarning,
|
||||
handleApplyAll,
|
||||
context,
|
||||
onCloseContext
|
||||
}: {
|
||||
allCardsLoaded?: boolean;
|
||||
allApplied?: boolean;
|
||||
appliedCount?: number;
|
||||
pendingCount?: number;
|
||||
streamingLine?: string;
|
||||
completedSummary?: string;
|
||||
hasPanelWarning?: boolean;
|
||||
handleApplyAll?: () => void;
|
||||
context?: any;
|
||||
onCloseContext?: () => void;
|
||||
}) {
|
||||
// Context label rendering
|
||||
const renderContext = () => {
|
||||
if (!context) return null;
|
||||
let icon = null;
|
||||
if (context.type === 'chat') icon = <svg className="w-3.5 h-3.5 text-blue-500 dark:text-blue-300 mr-1" fill="currentColor" viewBox="0 0 20 20"><path d="M18 10c0 3.866-3.582 7-8 7a8.96 8.96 0 01-4.39-1.11L2 17l1.11-2.61A8.96 8.96 0 012 10c0-3.866 3.582-7 8-7s8 3.134 8 7z" /></svg>;
|
||||
if (context.type === 'agent') icon = <svg className="w-3.5 h-3.5 text-green-500 dark:text-green-300 mr-1" fill="currentColor" viewBox="0 0 20 20"><path d="M10 2a6 6 0 016 6c0 2.21-1.343 4.09-3.25 5.25A4.992 4.992 0 0110 18a4.992 4.992 0 01-2.75-4.75C5.343 12.09 4 10.21 4 8a6 6 0 016-6z" /></svg>;
|
||||
if (context.type === 'tool') icon = <svg className="w-3.5 h-3.5 text-yellow-500 dark:text-yellow-300 mr-1" fill="currentColor" viewBox="0 0 20 20"><path d="M13.293 2.293a1 1 0 011.414 0l3 3a1 1 0 010 1.414l-8.5 8.5a1 1 0 01-.293.207l-4 2a1 1 0 01-1.316-1.316l2-4a1 1 0 01.207-.293l8.5-8.5z" /></svg>;
|
||||
if (context.type === 'prompt') icon = <svg className="w-3.5 h-3.5 text-purple-500 dark:text-purple-300 mr-1" fill="currentColor" viewBox="0 0 20 20"><circle cx="10" cy="10" r="8" /></svg>;
|
||||
let label = '';
|
||||
if (context.type === 'chat') label = 'Chat';
|
||||
if (context.type === 'agent') label = `Agent: ${context.name}`;
|
||||
if (context.type === 'tool') label = `Tool: ${context.name}`;
|
||||
if (context.type === 'prompt') label = `Prompt: ${context.name}`;
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-2 py-1 rounded-md border border-zinc-200 dark:border-zinc-700 bg-zinc-50/70 dark:bg-zinc-800/40 shadow-sm text-xs font-medium text-zinc-700 dark:text-zinc-200 max-w-[180px] truncate">
|
||||
{icon}
|
||||
<span className="truncate max-w-[110px]">{label}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// Status/ticker rendering
|
||||
const renderStatus = () => {
|
||||
if (!allCardsLoaded && !streamingLine && !hasPanelWarning && !completedSummary) return null;
|
||||
return (
|
||||
<div className="flex flex-col min-w-0">
|
||||
{hasPanelWarning && (
|
||||
<span className="text-xs text-yellow-600 dark:text-yellow-400 font-semibold flex items-center">
|
||||
<span className="mr-1">⚠️</span> Some changes could not be applied
|
||||
</span>
|
||||
)}
|
||||
{allCardsLoaded && completedSummary ? (
|
||||
<span className="font-semibold text-xs text-gray-900 dark:text-gray-100 truncate">{completedSummary}</span>
|
||||
) : streamingLine && (
|
||||
<span className="font-semibold text-xs text-gray-900 dark:text-gray-100 truncate">{streamingLine}</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{appliedCount ?? 0} applied, {pendingCount ?? 0} pending</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// Apply All button
|
||||
const renderApplyAll = () => {
|
||||
// Show disabled button with tooltip while streaming
|
||||
if (!allCardsLoaded) {
|
||||
return (
|
||||
<Tooltip content="Apply all will be available when all changes are ready" placement="top">
|
||||
<div className="inline-block">
|
||||
<button
|
||||
disabled
|
||||
className="flex items-center gap-2 px-3 py-1 rounded-full font-medium text-xs transition-colors duration-200 bg-zinc-100 dark:bg-zinc-800 text-zinc-400 cursor-not-allowed border border-zinc-200 dark:border-zinc-700 shadow-none"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path d="M9 12l2 2l4 -4" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||
Apply all
|
||||
</button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
// Show real button when ready
|
||||
return (
|
||||
<button
|
||||
onClick={handleApplyAll}
|
||||
disabled={allApplied}
|
||||
className={`flex items-center gap-2 px-3 py-1 rounded-full font-medium text-xs transition-colors duration-200
|
||||
${
|
||||
allApplied
|
||||
? 'bg-zinc-100 dark:bg-zinc-800 text-zinc-400 cursor-not-allowed border border-zinc-200 dark:border-zinc-700 shadow-none'
|
||||
: 'bg-blue-100 dark:bg-zinc-900 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-zinc-800 border border-blue-200 dark:border-zinc-800 shadow-sm'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{allApplied ? (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path d="M9 12l2 2l4 -4" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||
All applied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path d="M9 12l2 2l4 -4" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||
Apply all
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="w-auto max-w-[calc(100%-16px)] mx-auto flex items-center px-3 py-1 pt-2.5 pb-5 mt-2 -mb-3 rounded-xl bg-zinc-50 dark:bg-zinc-900/90 border border-zinc-300 dark:border-zinc-700 shadow-md dark:shadow-zinc-950/10 backdrop-blur-sm transition-all z-0 relative mx-2 overflow-visible">
|
||||
{/* Left: context + status/ticker, flex-1, truncate as needed */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-visible">
|
||||
{renderContext()}
|
||||
{renderStatus() && (
|
||||
<div className="ml-2 min-w-0 overflow-visible">{renderStatus()}</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Divider and rightmost Apply All button */}
|
||||
{renderApplyAll() && (
|
||||
<>
|
||||
<div className="mx-2 h-5 border-l border-gray-200 dark:border-gray-700 flex-shrink-0" />
|
||||
<div className="flex-shrink-0 flex items-center overflow-visible">{renderApplyAll()}</div>
|
||||
</>
|
||||
)}
|
||||
{/* Optional: subtle shadow at the bottom for extra depth */}
|
||||
<div className="absolute left-0 right-0 bottom-0 h-2 pointer-events-none rounded-b-xl shadow-[0_6px_12px_-6px_rgba(0,0,0,0.10)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue