[Haik]: Remove legacy ChatInput component tree (ChatInput, AttachmentChips, ImageAttachments, useChatSubmit) and replace DashboardToolbar usage with OpenSwarmComposer via a new ToolbarComposer wrapper backed by useStandaloneComposerRuntime and AssistantRuntimeProvider; relocate ModelModeSelector and ContextRing into OpenSwarmComposer/components; move TabLocalState from shared Dashboard types to BrowserCard directory; add embedded prop to OpenSwarmComposer for borderless toolbar rendering

This commit is contained in:
haikdc
2026-04-18 13:23:57 -07:00
parent 5566eaa69d
commit b8ca2535b9
14 changed files with 101 additions and 634 deletions
@@ -1,170 +0,0 @@
import React, { useState, useRef, useEffect, useId, forwardRef, useImperativeHandle } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import AttachFileIcon from '@mui/icons-material/AttachFile';
import CommandPicker from '@/app/components/CommandPicker';
import { useElementSelection } from '@/app/components/ElementSelectionContext';
import type { ContextPath } from '@/shared/state/agentsTypes';
import { type AttachedSkill, type TriggerState, EMPTY_TRIGGER, serializeEditorContent } from '@/app/components/richEditorUtils';
import { useAppSelector } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import type { AttachedImage } from './components/ImageAttachments';
import ImageAttachments from './components/ImageAttachments';
import type { ForcedToolGroup } from './components/AttachmentChips';
import AttachmentChips from './components/AttachmentChips';
import ModelModeSelector from '@/app/pages/AgentChat/ModelModeSelector/ModelModeSelector';
import { useChatSubmit } from './components/useChatSubmit';
interface Props {
onSend: (message: string, images?: Array<{ data: string; media_type: string }>, contextPaths?: ContextPath[], forcedTools?: string[], attachedSkills?: Array<{ id: string; name: string; content: string }>, selectedBrowserIds?: string[]) => void;
disabled?: boolean;
mode: string; onModeChange: (mode: string) => void;
model: string; onModelChange: (model: string) => void;
provider?: string; onProviderChange?: (provider: string) => void;
isRunning?: boolean; onStop?: () => void;
autoRunMode?: boolean;
contextEstimate?: { used: number; limit: number };
embedded?: boolean; autoFocus?: boolean;
sessionId?: string; queueLength?: number;
}
export interface ChatInputHandle {
getConfig: () => { prompt: string; contextPaths: ContextPath[]; forcedTools: ForcedToolGroup[] };
setContent: (prompt: string, contextPaths?: ContextPath[], forcedTools?: ForcedToolGroup[]) => void;
}
const ChatInput = forwardRef<ChatInputHandle, Props>(({
onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange,
isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0,
}, ref) => {
const c = useClaudeTokens();
const editorRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const elementSelection = useElementSelection();
const fallbackOwnerId = useId();
const ownerId = sessionId || fallbackOwnerId;
useEffect(() => { if (autoFocus) editorRef.current?.focus(); }, [autoFocus]);
const [hasContent, setHasContent] = useState(false);
const [attachedSkills, setAttachedSkills] = useState<Record<string, AttachedSkill>>({});
const attachedSkillsRef = useRef(attachedSkills);
useEffect(() => { attachedSkillsRef.current = attachedSkills; });
const [picker, setPicker] = useState<TriggerState>(EMPTY_TRIGGER);
const skills = useAppSelector((state) => state.skills.items);
const modesMap = useAppSelector((state) => state.modes.items);
const [images, setImages] = useState<AttachedImage[]>([]);
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
const [isDragOver, setIsDragOver] = useState(false);
const [contextPaths, setContextPaths] = useState<ContextPath[]>([]);
const [forcedTools, setForcedTools] = useState<ForcedToolGroup[]>([]);
const [copiedPathIdx, setCopiedPathIdx] = useState<number | null>(null);
useImperativeHandle(ref, () => ({
getConfig: () => {
const editor = editorRef.current;
const prompt = editor ? serializeEditorContent(editor, attachedSkillsRef.current).trim() : '';
return { prompt, contextPaths, forcedTools };
},
setContent: (prompt: string, newContextPaths?: ContextPath[], newForcedTools?: ForcedToolGroup[]) => {
const editor = editorRef.current;
if (editor) { editor.textContent = prompt; setHasContent(!!prompt); }
if (newContextPaths) setContextPaths(newContextPaths);
if (newForcedTools) setForcedTools(newForcedTools);
},
}), [contextPaths, forcedTools]);
const {
handleSend, handlePickerSelect, handlePaste, handleKeyDown,
handleInput, handleEditorClick, handleDragOver, handleDragLeave, handleDrop,
addImageFiles, browseAndAttachFiles, removeImage,
} = useChatSubmit({
editorRef, attachedSkillsRef, disabled, autoRunMode,
images, contextPaths, forcedTools, picker, skills, ownerId,
elementSelection, onSend, onModeChange, setImages, setContextPaths,
setForcedTools, setPicker, setHasContent, setAttachedSkills,
setIsDragOver, c,
});
const handleCopyPath = (idx: number) => {
navigator.clipboard.writeText(contextPaths[idx].path);
setCopiedPathIdx(idx);
setTimeout(() => setCopiedPathIdx((cur) => cur === idx ? null : cur), 1200);
};
const selectedElements = elementSelection?.elementsByOwner?.[ownerId] ?? [];
const hasAttachments = images.length > 0 || contextPaths.length > 0 || forcedTools.length > 0 || selectedElements.length > 0;
const modeLabel = modesMap[mode]?.name || 'Agent';
return (
<Box ref={containerRef} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop}
sx={{
position: 'relative',
...(embedded ? {} : {
mx: 1.5, mb: 1.5, borderRadius: '16px',
border: isDragOver ? `1px solid ${c.accent.primary}` : `1px solid ${c.border.subtle}`,
bgcolor: c.bg.surface, boxShadow: c.shadow.md, transition: 'border-color 0.15s',
}),
}}>
{isDragOver && (
<Box sx={{ position: 'absolute', inset: 0, bgcolor: 'rgba(174,86,48,0.04)', zIndex: 10,
display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '16px', pointerEvents: 'none' }}>
<AttachFileIcon sx={{ fontSize: 16, color: c.accent.primary, mr: 0.5 }} />
<Typography sx={{ color: c.accent.primary, fontSize: '0.85rem', fontWeight: 500 }}>Drop files here</Typography>
</Box>
)}
<CommandPicker trigger={picker.trigger} filter={picker.filter}
onSelect={handlePickerSelect}
onClose={() => setPicker((prev) => ({ ...prev, visible: false }))}
visible={picker.visible} />
<ImageAttachments images={images} onRemoveImage={removeImage}
lightboxSrc={lightboxSrc} onOpenLightbox={setLightboxSrc} onCloseLightbox={() => setLightboxSrc(null)} c={c} />
<AttachmentChips contextPaths={contextPaths}
onRemoveContextPath={(idx) => setContextPaths((prev) => prev.filter((_, i) => i !== idx))}
copiedPathIdx={copiedPathIdx} onCopyPath={handleCopyPath}
forcedTools={forcedTools}
onRemoveForcedTool={(idx) => setForcedTools((prev) => prev.filter((_, i) => i !== idx))}
selectedElements={selectedElements}
onRemoveElement={(id) => elementSelection?.removeOwnerElement(ownerId, id)}
hasImages={images.length > 0} c={c} />
<Box sx={{ px: 1.5, pt: hasAttachments ? 0.5 : 1.25, pb: 0.25, position: 'relative' }}>
<div ref={editorRef} contentEditable={!disabled} suppressContentEditableWarning
onInput={handleInput} onClick={handleEditorClick} onKeyDown={handleKeyDown} onPaste={handlePaste}
style={{
width: '100%', minHeight: '1.5em', maxHeight: 200, overflowY: 'auto',
background: 'transparent', border: 'none', outline: 'none', color: c.text.primary,
fontSize: '0.875rem', lineHeight: '1.5', fontFamily: 'inherit',
wordBreak: 'break-word', whiteSpace: 'pre-wrap',
}} />
{!hasContent && (
<div style={{
position: 'absolute', top: 0, left: 0, right: 0,
padding: `${hasAttachments ? 4 : 10}px 12px`,
color: c.text.tertiary, fontSize: '0.875rem', lineHeight: '1.5',
fontFamily: 'inherit', pointerEvents: 'none', userSelect: 'none',
}}>
{disabled ? 'Agent is working...' : autoRunMode ? 'Describe what data to generate…' : isRunning ? (queueLength > 0 ? `${queueLength} queued — type another or wait…` : 'Agent is working — messages will queue…') : `${modeLabel}, @ for context, / for commands`}
</div>
)}
</Box>
<ModelModeSelector mode={mode} onModeChange={onModeChange} model={model} onModelChange={onModelChange}
provider={provider} onProviderChange={onProviderChange} contextEstimate={contextEstimate}
ownerId={ownerId} sessionId={sessionId} autoRunMode={autoRunMode} hasContent={hasContent}
isRunning={isRunning} disabled={disabled} onSend={handleSend} onStop={onStop}
browseAndAttachFiles={browseAndAttachFiles}
queueLength={queueLength} />
</Box>
);
});
ChatInput.displayName = 'ChatInput';
export default ChatInput;
@@ -1,118 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Tooltip from '@mui/material/Tooltip';
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined';
import AdsClickIcon from '@mui/icons-material/AdsClick';
import { getToolGroupIcon } from '@/app/components/CommandPicker';
import type { SelectedElement } from '@/app/components/ElementSelectionContext';
import type { ContextPath } from '@/shared/state/agentsTypes';
export interface ForcedToolGroup {
label: string;
tools: string[];
icon?: React.ReactNode;
iconKey?: string;
}
interface Props {
contextPaths: ContextPath[];
onRemoveContextPath: (idx: number) => void;
copiedPathIdx: number | null;
onCopyPath: (idx: number) => void;
forcedTools: ForcedToolGroup[];
onRemoveForcedTool: (idx: number) => void;
selectedElements: SelectedElement[];
onRemoveElement: (id: string) => void;
hasImages: boolean;
c: {
accent: { primary: string };
font: { mono: string };
status: { error: string; info: string };
};
}
const AttachmentChips: React.FC<Props> = ({
contextPaths, onRemoveContextPath, copiedPathIdx, onCopyPath,
forcedTools, onRemoveForcedTool,
selectedElements, onRemoveElement,
hasImages, c,
}) => (
<>
{contextPaths.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, px: 1.5, pt: hasImages ? 0.25 : 1, pb: 0 }}>
{contextPaths.map((cp, idx) => {
const label = cp.path.split('/').filter(Boolean).slice(-2).join('/');
return (
<Tooltip key={`${cp.path}-${idx}`} title={copiedPathIdx === idx ? 'Copied!' : cp.path}
arrow placement="top"
slotProps={{ tooltip: { sx: { fontFamily: c.font.mono, fontSize: '0.7rem', maxWidth: 420, wordBreak: 'break-all' } } }}>
<Chip
icon={cp.type === 'directory' ? <FolderOpenIcon sx={{ fontSize: 14 }} /> : <InsertDriveFileOutlinedIcon sx={{ fontSize: 14 }} />}
label={label} size="small"
onClick={() => onCopyPath(idx)}
onDelete={() => onRemoveContextPath(idx)}
sx={{
bgcolor: `${c.accent.primary}12`, color: c.accent.primary,
fontSize: '0.72rem', fontFamily: c.font.mono, height: 26, maxWidth: 220, cursor: 'pointer',
'& .MuiChip-label': { overflow: 'hidden', textOverflow: 'ellipsis' },
'& .MuiChip-deleteIcon': { color: c.accent.primary, fontSize: 16, '&:hover': { color: c.status.error } },
}}
/>
</Tooltip>
);
})}
</Box>
)}
{forcedTools.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, px: 1.5, pt: (hasImages || contextPaths.length > 0) ? 0.25 : 1, pb: 0 }}>
{forcedTools.map((ft, idx) => (
<Chip key={`ft-${ft.label}-${idx}`}
icon={<>{ft.icon || getToolGroupIcon(ft.iconKey || ft.label, 14)}</>}
label={`@${ft.label.toLowerCase()}`} size="small"
onDelete={() => onRemoveForcedTool(idx)}
sx={{
bgcolor: `${c.status.info}15`, color: c.status.info,
fontSize: '0.72rem', fontFamily: c.font.mono, height: 26, maxWidth: 220,
'& .MuiChip-label': { overflow: 'hidden', textOverflow: 'ellipsis' },
'& .MuiChip-deleteIcon': { color: c.status.info, fontSize: 16, '&:hover': { color: c.status.error } },
}}
/>
))}
</Box>
)}
{selectedElements.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, px: 1.5,
pt: (hasImages || contextPaths.length > 0 || forcedTools.length > 0) ? 0.25 : 1, pb: 0 }}>
{selectedElements.map((el) => {
const chipLabel = el.semanticLabel ? el.semanticLabel
: el.className ? `${el.tagName.toLowerCase()}.${el.className.split(' ')[0]}`
: el.tagName.toLowerCase();
const tooltipText = el.semanticType
? `${el.semanticType}: ${el.semanticLabel || el.selectorPath}`
: el.selectorPath;
return (
<Tooltip key={el.id} title={tooltipText} arrow placement="top"
slotProps={{ tooltip: { sx: { fontFamily: c.font.mono, fontSize: '0.7rem', maxWidth: 420, wordBreak: 'break-all' } } }}>
<Chip icon={<AdsClickIcon sx={{ fontSize: 14 }} />} label={chipLabel} size="small"
onDelete={() => onRemoveElement(el.id)}
sx={{
bgcolor: 'rgba(59, 130, 246, 0.1)', color: '#3b82f6',
fontSize: '0.72rem', fontFamily: c.font.mono, height: 26, maxWidth: 220,
'& .MuiChip-label': { overflow: 'hidden', textOverflow: 'ellipsis' },
'& .MuiChip-deleteIcon': { color: '#3b82f6', fontSize: 16, '&:hover': { color: c.status.error } },
'& .MuiChip-icon': { color: '#3b82f6' },
}}
/>
</Tooltip>
);
})}
</Box>
)}
</>
);
export default AttachmentChips;
@@ -1,85 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import Modal from '@mui/material/Modal';
import CloseIcon from '@mui/icons-material/Close';
export interface AttachedImage {
data: string;
media_type: string;
preview: string;
}
interface Props {
images: AttachedImage[];
onRemoveImage: (idx: number) => void;
lightboxSrc: string | null;
onOpenLightbox: (src: string) => void;
onCloseLightbox: () => void;
c: {
border: { subtle: string; medium: string };
bg: { surface: string; secondary: string };
text: { secondary: string; tertiary: string; primary: string };
shadow: { md: string };
};
}
const ImageAttachments: React.FC<Props> = ({
images, onRemoveImage, lightboxSrc, onOpenLightbox, onCloseLightbox, c,
}) => (
<>
{images.length > 0 && (
<Box sx={{
display: 'flex', gap: 0.75, px: 1.5, pt: 1, pb: 0.5, overflowX: 'auto',
'&::-webkit-scrollbar': { height: 4 },
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 2 },
}}>
{images.map((img, idx) => (
<Box key={idx} sx={{
position: 'relative', width: 56, height: 56, flexShrink: 0, borderRadius: '8px',
overflow: 'hidden', border: `1px solid ${c.border.subtle}`, cursor: 'pointer',
transition: 'opacity 0.15s, transform 0.15s',
'&:hover': { opacity: 0.85, transform: 'scale(1.04)' },
}} onClick={() => onOpenLightbox(img.preview)}>
<img src={img.preview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<IconButton size="small" onClick={(e) => { e.stopPropagation(); onRemoveImage(idx); }} sx={{
position: 'absolute', top: -2, right: -2, width: 18, height: 18,
bgcolor: c.bg.surface, border: `1px solid ${c.border.medium}`,
color: c.text.tertiary,
'&:hover': { bgcolor: c.bg.secondary, color: c.text.primary },
}}>
<CloseIcon sx={{ fontSize: 10 }} />
</IconButton>
</Box>
))}
</Box>
)}
<Modal
open={!!lightboxSrc}
onClose={onCloseLightbox}
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<Box onClick={onCloseLightbox} sx={{ position: 'relative', outline: 'none', maxWidth: '90vw', maxHeight: '90vh' }}>
<IconButton onClick={onCloseLightbox} sx={{
position: 'absolute', top: -16, right: -16, bgcolor: c.bg.surface,
border: `1px solid ${c.border.medium}`, color: c.text.secondary,
width: 32, height: 32, zIndex: 1, '&:hover': { bgcolor: c.bg.secondary },
boxShadow: c.shadow.md,
}}>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
<img
src={lightboxSrc || ''} alt=""
onClick={(e) => e.stopPropagation()}
style={{
maxWidth: '90vw', maxHeight: '90vh', borderRadius: 8,
boxShadow: '0 8px 32px rgba(0,0,0,0.4)', display: 'block',
}}
/>
</Box>
</Modal>
</>
);
export default ImageAttachments;
@@ -1,241 +0,0 @@
import React, { useCallback } from 'react';
import type { CommandPickerItem } from '@/app/components/CommandPicker';
import { useElementSelection, type SelectedElement } from '@/app/components/ElementSelectionContext';
import { getClipboardCards, clearClipboard } from '@/shared/dashboardClipboard';
import { getWebview } from '@/shared/browserRegistry';
import type { ContextPath } from '@/shared/state/agentsTypes';
import {
SKILL_PILL_ATTR, type AttachedSkill, createSkillPillElement,
serializeEditorContent, type TriggerState, detectEditorTrigger,
} from '@/app/components/richEditorUtils';
import type { AttachedImage } from '../ImageAttachments';
import type { ForcedToolGroup } from '../AttachmentChips';
interface ChatSubmitParams {
editorRef: React.RefObject<HTMLDivElement | null>; attachedSkillsRef: React.MutableRefObject<Record<string, AttachedSkill>>;
disabled?: boolean; autoRunMode?: boolean; images: AttachedImage[]; contextPaths: ContextPath[];
forcedTools: ForcedToolGroup[]; picker: TriggerState;
skills: Record<string, { id: string; name: string; content: string }>; ownerId: string;
elementSelection: ReturnType<typeof useElementSelection>;
onSend: (msg: string, imgs?: Array<{ data: string; media_type: string }>, ctx?: ContextPath[], tools?: string[], skills?: Array<{ id: string; name: string; content: string }>, browserIds?: string[]) => void;
onModeChange: (mode: string) => void;
setImages: React.Dispatch<React.SetStateAction<AttachedImage[]>>; setContextPaths: React.Dispatch<React.SetStateAction<ContextPath[]>>;
setForcedTools: React.Dispatch<React.SetStateAction<ForcedToolGroup[]>>; setPicker: React.Dispatch<React.SetStateAction<TriggerState>>;
setHasContent: React.Dispatch<React.SetStateAction<boolean>>; setAttachedSkills: React.Dispatch<React.SetStateAction<Record<string, AttachedSkill>>>;
setIsDragOver: React.Dispatch<React.SetStateAction<boolean>>;
c: { font: { mono: string }; status: { error: string } };
}
export function useChatSubmit(p: ChatSubmitParams) {
const {
editorRef, attachedSkillsRef, disabled, autoRunMode,
images, contextPaths, forcedTools, picker, skills, ownerId,
elementSelection, onSend, onModeChange, setImages, setContextPaths,
setForcedTools, setPicker, setHasContent, setAttachedSkills,
setIsDragOver, c,
} = p;
const updateHasContent = useCallback(() => {
const editor = editorRef.current;
if (!editor) return;
const text = (editor.textContent || '').replace(/\u200B/g, '');
setHasContent(text.trim().length > 0 || editor.querySelector(`[${SKILL_PILL_ATTR}]`) !== null);
}, []);
const syncAttachedSkills = useCallback(() => {
const editor = editorRef.current;
if (!editor) return;
const pillIds = new Set(
Array.from(editor.querySelectorAll(`[${SKILL_PILL_ATTR}]`))
.map((el) => el.getAttribute(SKILL_PILL_ATTR)).filter(Boolean) as string[],
);
setAttachedSkills((prev) => {
const prevKeys = Object.keys(prev);
if (prevKeys.length === pillIds.size && prevKeys.every((k) => pillIds.has(k))) return prev;
const next: Record<string, AttachedSkill> = {};
for (const [id, skill] of Object.entries(prev)) { if (pillIds.has(id)) next[id] = skill; }
return next;
});
}, []);
const removeSkillPill = useCallback((skillId: string) => {
const editor = editorRef.current;
if (!editor) return;
const pill = editor.querySelector(`[${SKILL_PILL_ATTR}="${skillId}"]`);
if (pill) pill.remove();
setAttachedSkills((prev) => { const { [skillId]: _, ...rest } = prev; return rest; });
const text = (editor.textContent || '').replace(/\u200B/g, '');
setHasContent(text.trim().length > 0 || editor.querySelector(`[${SKILL_PILL_ATTR}]`) !== null);
editor.focus();
}, []);
const detectTrigger = useCallback(() => { const r = detectEditorTrigger(); r ? setPicker(r) : setPicker((prev) => ({ ...prev, visible: false })); }, []);
const handleInput = useCallback(() => { updateHasContent(); detectTrigger(); syncAttachedSkills(); }, [updateHasContent, detectTrigger, syncAttachedSkills]);
const handleEditorClick = useCallback(() => { detectTrigger(); }, [detectTrigger]);
const addImageFiles = useCallback((files: FileList | File[]) => {
Array.from(files).forEach((file) => {
if (!file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
setImages((prev) => [...prev, { data: result.split(',')[1], media_type: file.type, preview: result }]);
};
reader.readAsDataURL(file);
});
}, []);
const attachFiles = useCallback((files: File[]) => {
if (files.length === 0) return;
const newPaths: ContextPath[] = files
.map((f) => (f as File & { path?: string }).path)
.filter((p): p is string => Boolean(p))
.map((p) => ({ path: p, type: 'file' as const }));
if (newPaths.length > 0) setContextPaths((prev) => [...prev, ...newPaths]);
}, []);
const browseAndAttachFiles = useCallback(async () => {
const result = await window.openswarm.showOpenDialog({
properties: ['openFile', 'multiSelections'],
});
if (result.canceled || !result.filePaths?.length) return;
const newPaths: ContextPath[] = result.filePaths.map((p) => ({ path: p, type: 'file' as const }));
setContextPaths((prev) => [...prev, ...newPaths]);
}, []);
const handleSend = useCallback(async () => {
const editor = editorRef.current;
if (!editor || disabled) return;
let trimmed = serializeEditorContent(editor, attachedSkillsRef.current).trim();
if (!trimmed) return;
const selectedEls = elementSelection?.elementsByOwner?.[ownerId] ?? [];
let allImages = images.length > 0 ? images.map(({ data, media_type }) => ({ data, media_type })) : [];
if (selectedEls.length > 0) {
const lines: string[] = ['\n\n---\nSelected UI Elements:\n'];
for (let i = 0; i < selectedEls.length; i++) {
const el = selectedEls[i];
if (el.semanticType === 'browser-card' && el.semanticData?.selectId) {
const wv = getWebview(el.semanticData.selectId as string);
const url = wv ? (el.semanticData.url || wv.getURL()) : (el.semanticData.url || '');
const title = wv ? (el.semanticData.name || wv.getTitle()) : (el.semanticLabel || '');
lines.push(`${i + 1}. [Browser Card] ${title}`, ` browser_id: ${el.semanticData.selectId}`);
if (url) lines.push(` URL: ${url}`);
lines.push(' (Use BrowserAgent with this browser_id to interact with it, or CreateBrowserAgent for a new browser)');
} else if (el.semanticType && el.semanticData) {
const typeLabel = { 'agent-card': 'Agent Card', message: 'Message', 'tool-call': 'Tool Call', 'tool-group': 'Tool Group', 'view-card': 'App Card', 'browser-card': 'Browser Card', 'dom-element': 'Element' }[el.semanticType] || el.semanticType;
lines.push(`${i + 1}. [${typeLabel}] ${el.semanticLabel || ''}`);
const { selectId, ...rest } = el.semanticData;
if (selectId) lines.push(` ID: ${selectId}`);
const metaStr = Object.entries(rest).filter(([, v]) => v != null).map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`).join(', ');
if (metaStr) lines.push(` ${metaStr}`);
if (el.semanticType === 'agent-card' && selectId) lines.push(` (Use InvokeAgent with session_id "${selectId}" to query this agent with full conversation context)`);
} else {
const styleStr = Object.entries(el.computedStyles).map(([k, v]) => `${k}: ${v}`).join('; ');
lines.push(`${i + 1}. \`${el.selectorPath}\` (${el.tagName.toLowerCase()})`, ` Selector: ${el.selectorPath}`);
lines.push(` HTML: ${el.outerHTML.length > 500 ? el.outerHTML.slice(0, 500) + '...' : el.outerHTML}`);
if (styleStr) lines.push(` Key styles: ${styleStr}`);
}
lines.push('');
if (el.screenshot) allImages.push({ data: el.screenshot.replace(/^data:image\/\w+;base64,/, ''), media_type: 'image/png' });
}
trimmed += lines.join('\n');
}
const allForcedToolNames = forcedTools.flatMap((ft) => ft.tools);
const currentSkills = Object.values(attachedSkillsRef.current);
const sendSkills = currentSkills.length > 0 ? currentSkills.map((s) => ({ id: s.id, name: s.name, content: s.content })) : undefined;
const browserIds = selectedEls.filter((el) => el.semanticType === 'browser-card' && el.semanticData?.selectId).map((el) => el.semanticData!.selectId as string);
onSend(trimmed, allImages.length > 0 ? allImages : undefined, contextPaths.length > 0 ? contextPaths : undefined,
allForcedToolNames.length > 0 ? allForcedToolNames : undefined, sendSkills, browserIds.length > 0 ? browserIds : undefined);
editor.innerHTML = '';
setImages([]); setContextPaths([]); setForcedTools([]); setAttachedSkills({}); setHasContent(false);
elementSelection?.clearOwnerElements(ownerId);
}, [disabled, images, contextPaths, forcedTools, onSend, elementSelection, ownerId]);
const handlePickerSelect = (item: CommandPickerItem) => {
setPicker((prev) => ({ ...prev, visible: false }));
const editor = editorRef.current;
if (!editor) return;
editor.focus();
const { triggerNode, triggerOffset, filter } = picker;
if (triggerNode && triggerNode.parentNode && editor.contains(triggerNode)) {
const endOffset = Math.min(triggerOffset + 1 + filter.length, triggerNode.length);
const range = document.createRange();
range.setStart(triggerNode, triggerOffset);
range.setEnd(triggerNode, endOffset);
range.deleteContents();
const sel = window.getSelection();
if (sel) { sel.removeAllRanges(); sel.addRange(range); }
}
if (item.type === 'skill') {
const skill = skills[item.id];
if (!skill || editor.querySelector(`[${SKILL_PILL_ATTR}="${skill.id}"]`)) return;
const pill = createSkillPillElement({ id: skill.id, name: skill.name, content: skill.content }, removeSkillPill, c.font.mono, c.status.error);
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
range.collapse(false);
range.insertNode(pill);
const spacer = document.createTextNode('\u200B');
pill.after(spacer);
const newRange = document.createRange();
newRange.setStartAfter(spacer);
newRange.collapse(true);
sel.removeAllRanges();
sel.addRange(newRange);
}
setAttachedSkills((prev) => ({ ...prev, [skill.id]: { id: skill.id, name: skill.name, content: skill.content } }));
} else if (item.type === 'mode') {
onModeChange(item.id);
} else if (item.type === 'context') {
if (item.command === 'file') { browseAndAttachFiles(); return; }
else if (item.toolNames && item.toolNames.length > 0) setForcedTools((prev) => [...prev, { label: item.name, tools: item.toolNames!, icon: item.icon, iconKey: item.iconKey }]);
else document.execCommand('insertText', false, `@${item.command} `);
}
updateHasContent();
setTimeout(() => editor.focus(), 0);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (picker.visible && ['ArrowDown', 'ArrowUp', 'Escape', 'Tab', 'Enter'].includes(e.key)) { e.preventDefault(); return; }
if ((e.ctrlKey || e.metaKey) && ['b', 'i', 'u'].includes(e.key.toLowerCase())) { e.preventDefault(); return; }
if (e.key === 'Enter' && !e.shiftKey && !autoRunMode) { e.preventDefault(); handleSend(); }
};
const handlePaste = useCallback((e: React.ClipboardEvent) => {
const copied = getClipboardCards();
if (copied.length > 0 && elementSelection) {
e.preventDefault();
for (const card of copied) {
const semanticTypeMap: Record<string, SelectedElement['semanticType']> = { agent: 'agent-card', view: 'view-card', browser: 'browser-card' };
const semanticType = semanticTypeMap[card.type];
if (!semanticType) continue;
const labelMap: Record<string, string> = { 'agent-card': 'Agent', 'view-card': 'View', 'browser-card': 'Browser' };
const el: SelectedElement = {
id: `sel-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
selectorPath: `[data-select-type="${semanticType}"][data-select-id="${card.id}"]`,
tagName: 'DIV', className: '', outerHTML: '', computedStyles: {},
boundingRect: { x: 0, y: 0, width: 0, height: 0 },
semanticType, semanticLabel: (labelMap[semanticType] || semanticType) + ': ' + card.name,
semanticData: { ...card.meta, selectId: card.id },
};
elementSelection.addElementForOwner(ownerId, el);
}
clearClipboard();
return;
}
const items = e.clipboardData?.items;
if (items) {
const imageFiles: File[] = [];
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) { const file = items[i].getAsFile(); if (file) imageFiles.push(file); }
}
if (imageFiles.length > 0) { e.preventDefault(); addImageFiles(imageFiles); return; }
}
e.preventDefault();
const plain = e.clipboardData?.getData('text/plain');
if (plain) document.execCommand('insertText', false, plain);
}, [addImageFiles, elementSelection, ownerId]);
const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes('Files')) setIsDragOver(true); }, []);
const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(false); }, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.stopPropagation(); setIsDragOver(false);
if (e.dataTransfer.files.length === 0) return;
const allFiles = Array.from(e.dataTransfer.files);
const imageFiles = allFiles.filter((f) => f.type.startsWith('image/'));
const otherFiles = allFiles.filter((f) => !f.type.startsWith('image/'));
if (imageFiles.length > 0) addImageFiles(imageFiles);
if (otherFiles.length > 0) attachFiles(otherFiles);
}, [addImageFiles, attachFiles]);
const removeImage = useCallback((idx: number) => setImages((prev) => prev.filter((_, i) => i !== idx)), []);
return { handleSend, handlePickerSelect, handlePaste, handleKeyDown, handleInput, handleEditorClick, handleDragOver, handleDragLeave, handleDrop, addImageFiles, attachFiles, browseAndAttachFiles, removeImage };
}
@@ -8,7 +8,7 @@ import type { ContextPath } from '@/shared/state/agentsTypes';
import { useOpenSwarmMentionAdapter, type MentionItemMetadata } from './components/OpenSwarmMentionAdapter';
import { useComposerAttachments } from './components/useComposerAttachments';
import { MentionSelectOverride, MentionPopover, ComposerAttachmentChips } from './components/ComposerParts';
import ModelModeSelector from '../ModelModeSelector/ModelModeSelector';
import ModelModeSelector from './components/ModelModeSelector/ModelModeSelector';
interface OpenSwarmComposerProps {
composerExtrasRef: MutableRefObject<ComposerExtras>;
@@ -23,12 +23,13 @@ interface OpenSwarmComposerProps {
contextEstimate?: { used: number; limit: number };
autoFocus?: boolean;
initialContextPaths?: ContextPath[];
embedded?: boolean;
}
const OpenSwarmComposer: FC<OpenSwarmComposerProps> = ({
composerExtrasRef, mode, onModeChange, model, onModelChange,
isRunning, onStop, sessionId, queueLength, contextEstimate, autoFocus,
initialContextPaths,
initialContextPaths, embedded,
}) => {
const aui = useAui();
const mentionAdapter = useOpenSwarmMentionAdapter();
@@ -115,12 +116,15 @@ const OpenSwarmComposer: FC<OpenSwarmComposerProps> = ({
att.forcedTools.length > 0 || Object.keys(att.attachedSkills).length > 0;
return (
<div className="mx-auto flex w-full max-w-(--thread-max-width) flex-col">
<div className={embedded ? 'flex w-full flex-col' : 'mx-auto flex w-full max-w-(--thread-max-width) flex-col'}>
<ComposerPrimitive.Unstable_MentionRoot trigger="@" adapter={mentionAdapter}>
<ComposerPrimitive.Root ref={formRef} onSubmit={handleFormSubmit} className="aui-composer-root relative flex w-full flex-col">
<MentionSelectOverride onSelect={handleMentionSelect} />
<div
className="flex w-full flex-col gap-1 rounded-2xl border bg-background p-2 transition-shadow focus-within:border-ring/75 focus-within:ring-2 focus-within:ring-ring/20"
className={embedded
? 'flex w-full flex-col gap-1 bg-transparent p-1'
: 'flex w-full flex-col gap-1 rounded-2xl border bg-background p-2 transition-shadow focus-within:border-ring/75 focus-within:ring-2 focus-within:ring-ring/20'
}
onDragOver={att.handleDragOver} onDragLeave={att.handleDragLeave} onDrop={att.handleDrop}
data-dragging={att.isDragOver || undefined}
>
@@ -0,0 +1,39 @@
import { useCallback } from 'react';
import {
useExternalStoreRuntime,
type ThreadMessageLike,
type AppendMessage,
} from '@assistant-ui/react';
import type { MutableRefObject } from 'react';
import type { ComposerExtras, DispatchableMessage } from './useOpenSwarmRuntime';
const EMPTY_MESSAGES: ThreadMessageLike[] = [];
function extractText(message: AppendMessage): string {
for (const part of message.content) {
if (part.type === 'text') return part.text;
}
return '';
}
export function useStandaloneComposerRuntime(
composerExtrasRef: MutableRefObject<ComposerExtras>,
dispatchMessage: (msg: DispatchableMessage) => void,
) {
const onNew = useCallback(
async (message: AppendMessage) => {
const text = extractText(message);
if (!text) return;
const extras = composerExtrasRef.current;
composerExtrasRef.current = {};
dispatchMessage({ prompt: text, ...extras });
},
[composerExtrasRef, dispatchMessage],
);
return useExternalStoreRuntime({
messages: EMPTY_MESSAGES,
isRunning: false,
onNew,
});
}
@@ -11,7 +11,7 @@ import { useOverlayScrollPassthrough } from '../shared/useOverlayScrollPassthrou
import { useElementSelection } from '@/app/components/ElementSelectionContext';
import { type ResizeDir, CURSOR_MAP, HANDLE_DEFS, DRAG_THRESHOLD } from '../shared/cardLayoutConstants';
import { useWebviewLifecycle, isElectron, chromeUserAgent, webviewPreloadPath, type WebviewElement } from './hooks/useWebviewLifecycle';
import type { TabLocalState } from '@/app/pages/Dashboard/types/types';
import type { TabLocalState } from './TabLocalState';
import BrowserTabBar from './components/BrowserTabBar';
import BrowserNavBar from './components/BrowserNavBar';
import BrowserActionOverlay from './components/BrowserActionOverlay';
@@ -0,0 +1,6 @@
export interface TabLocalState {
loading: boolean;
canGoBack: boolean;
canGoForward: boolean;
}
@@ -13,7 +13,7 @@ import {
reorderBrowserTab, setActiveBrowserTab, addBrowserTab,
removeBrowserTab, removeBrowserCard, type BrowserTab,
} from '@/shared/state/dashboardLayoutSlice';
import type { TabLocalState } from '@/app/pages/Dashboard/types/types';
import type { TabLocalState } from '../TabLocalState';
interface BrowserTabBarProps {
tabs: BrowserTab[];
@@ -12,7 +12,7 @@ import {
updateBrowserTabFavicon,
type BrowserTab,
} from '@/shared/state/dashboardLayoutSlice';
import type { TabLocalState } from '@/app/pages/Dashboard/types/types';
import type { TabLocalState } from '../TabLocalState';
export type WebviewElement = BrowserWebview;
@@ -1,11 +1,52 @@
import React from 'react';
import React, { useCallback, useRef } from 'react';
import { motion } from 'framer-motion';
import ChatInput from '@/app/pages/AgentChat/ChatInput/ChatInput';
import { AssistantRuntimeProvider } from '@assistant-ui/react';
import OpenSwarmComposer from '@/app/pages/AgentChat/OpenSwarmComposer/OpenSwarmComposer';
import { useStandaloneComposerRuntime } from '@/app/pages/AgentChat/runtime/useStandaloneComposerRuntime';
import type { ComposerExtras, DispatchableMessage } from '@/app/pages/AgentChat/runtime/useOpenSwarmRuntime';
import { useDashboardToolbar, TOOLBAR_OWNER_ID, ToolbarProps } from './useDashboardToolbar';
import HistoryPanel from './components/HistoryPanel';
import ViewPickerPanel from './components/ViewPickerPanel';
import ToolbarButtons from './components/ToolbarButtons/ToolbarButtons';
// TODO: pull this out into a separate file or idk do smthn else but not this
const ToolbarComposer: React.FC<{
mode: string; onModeChange: (mode: string) => void;
model: string; onModelChange: (model: string) => void;
onSend: (
message: string,
images?: Array<{ data: string; media_type: string }>,
contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>,
forcedTools?: string[],
attachedSkills?: Array<{ id: string; name: string; content: string }>,
selectedBrowserIds?: string[],
) => void;
}> = ({ mode, onModeChange, model, onModelChange, onSend }) => {
const composerExtrasRef = useRef<ComposerExtras>({});
const dispatch = useCallback(
(msg: DispatchableMessage) => {
onSend(msg.prompt, msg.images, msg.contextPaths, msg.forcedTools, msg.attachedSkills, msg.selectedBrowserIds);
},
[onSend],
);
const runtime = useStandaloneComposerRuntime(composerExtrasRef, dispatch);
return (
<AssistantRuntimeProvider runtime={runtime}>
<OpenSwarmComposer
composerExtrasRef={composerExtrasRef}
mode={mode}
onModeChange={onModeChange}
model={model}
onModelChange={onModelChange}
embedded
autoFocus
sessionId={TOOLBAR_OWNER_ID}
/>
</AssistantRuntimeProvider>
);
};
const DashboardToolbar = React.forwardRef<HTMLDivElement, ToolbarProps>(
({ inputOpen, onNewAgent, onCancel, onSend, onAddView, onHistoryResume, onAddBrowser, dashboardId }, ref) => {
const {
@@ -44,15 +85,12 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, ToolbarProps>(
>
{inputOpen ? (
<div style={{ width: '100%', minHeight: 44, paddingBottom: 0, marginBottom: -4 }}>
<ChatInput
onSend={handleSend}
<ToolbarComposer
mode={mode}
onModeChange={setMode}
model={model}
onModelChange={setModel}
embedded
autoFocus
sessionId={TOOLBAR_OWNER_ID}
onSend={handleSend}
/>
</div>
) : historyOpen ? (
@@ -1,9 +1,3 @@
export interface TabLocalState {
loading: boolean;
canGoBack: boolean;
canGoForward: boolean;
}
export interface TetherInfo {
key: string;
path: string;