[Haik]: Agentic refactor 3. Composer & Mentions i.e. user input

This commit is contained in:
haikdc
2026-03-30 15:05:24 -07:00
parent 46c100dec7
commit 7d9a2fb1a3
9 changed files with 750 additions and 173 deletions
+1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@assistant-ui/react": "^0.12.21",
"@assistant-ui/react-lexical": "^0.0.3",
"@assistant-ui/react-markdown": "^0.12.7",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-json": "^6.0.2",
@@ -1,165 +0,0 @@
import React, { useState, useEffect, useMemo } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Paper from '@mui/material/Paper';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import DescriptionIcon from '@mui/icons-material/Description';
import PsychologyIcon from '@mui/icons-material/Psychology';
import { useAppSelector } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
export interface SlashItem {
id: string;
type: 'template' | 'skill';
name: string;
description: string;
command: string;
}
interface Props {
filter: string;
onSelect: (item: SlashItem) => void;
onClose: () => void;
visible: boolean;
}
const SlashCommandPicker: React.FC<Props> = ({ filter, onSelect, onClose, visible }) => {
const c = useClaudeTokens();
const templates = useAppSelector((state) => state.templates.items);
const skills = useAppSelector((state) => state.skills.items);
const [selectedIndex, setSelectedIndex] = useState(0);
const items: SlashItem[] = useMemo(() => {
const all: SlashItem[] = [
...Object.values(templates).map((t) => ({
id: t.id,
type: 'template' as const,
name: t.name,
description: t.description || `Template with ${t.fields.length} fields`,
command: t.name.toLowerCase().replace(/\s+/g, '-'),
})),
...Object.values(skills).map((s) => ({
id: s.id,
type: 'skill' as const,
name: s.name,
description: s.description || 'Skill',
command: s.command || s.id,
})),
];
if (!filter) return all;
const lower = filter.toLowerCase();
return all.filter(
(item) =>
item.name.toLowerCase().includes(lower) ||
item.command.toLowerCase().includes(lower) ||
item.description.toLowerCase().includes(lower)
);
}, [templates, skills, filter]);
useEffect(() => {
setSelectedIndex(0);
}, [filter]);
useEffect(() => {
if (!visible) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, items.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === 'Enter' && items[selectedIndex]) {
e.preventDefault();
onSelect(items[selectedIndex]);
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [visible, items, selectedIndex, onSelect, onClose]);
if (!visible || items.length === 0) return null;
return (
<Paper
sx={{
position: 'absolute',
bottom: '100%',
left: 0,
right: 0,
mb: 0.5,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderRadius: 3,
maxHeight: 280,
overflow: 'auto',
zIndex: 1000,
boxShadow: c.shadow.lg,
'&::-webkit-scrollbar': { width: 5 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': {
background: c.border.medium,
borderRadius: 3,
'&:hover': { background: c.border.strong },
},
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.medium} transparent`,
}}
>
<Box sx={{ px: 1.5, py: 1, borderBottom: `0.5px solid ${c.border.medium}` }}>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1 }}>
Commands
</Typography>
</Box>
<List sx={{ py: 0.5 }}>
{items.map((item, i) => (
<ListItemButton
key={`${item.type}-${item.id}`}
selected={i === selectedIndex}
onClick={() => onSelect(item)}
sx={{
py: 0.75,
px: 1.5,
'&.Mui-selected': { bgcolor: 'rgba(174,86,48,0.06)' },
'&:hover': { bgcolor: 'rgba(0,0,0,0.04)' },
}}
>
<ListItemIcon sx={{ minWidth: 32 }}>
{item.type === 'template' ? (
<DescriptionIcon sx={{ fontSize: 18, color: c.accent.primary }} />
) : (
<PsychologyIcon sx={{ fontSize: 18, color: c.status.success }} />
)}
</ListItemIcon>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ color: c.text.primary, fontSize: '0.8rem', fontWeight: 500 }}>
/{item.command}
</Typography>
<Typography sx={{ color: c.text.ghost, fontSize: '0.7rem' }}>
{item.type}
</Typography>
</Box>
}
secondary={
<Typography sx={{ color: c.text.tertiary, fontSize: '0.7rem', mt: 0.25 }}>
{item.description}
</Typography>
}
/>
</ListItemButton>
))}
</List>
</Paper>
);
};
export default SlashCommandPicker;
@@ -0,0 +1,108 @@
import { useEffect, type FC } from 'react';
import {
ComposerPrimitive,
unstable_useMentionContextOptional,
} from '@assistant-ui/react';
import type { Unstable_MentionItem } from '@assistant-ui/core';
import { XIcon } from 'lucide-react';
/**
* Registers a selectItemOverride callback on the nearest MentionRoot context.
* Must be rendered inside a ComposerPrimitive.Unstable_MentionRoot.
* Returns true from the callback to prevent the default mention directive insertion.
*/
export const MentionSelectOverride: FC<{
onSelect: (item: Unstable_MentionItem) => boolean;
}> = ({ onSelect }) => {
const ctx = unstable_useMentionContextOptional();
useEffect(() => {
if (!ctx) return;
return ctx.registerSelectItemOverride(onSelect);
}, [ctx, onSelect]);
return null;
};
export const MentionPopover: FC = () => (
<ComposerPrimitive.Unstable_MentionPopover className="z-50 max-h-64 min-w-56 overflow-y-auto rounded-lg border bg-popover p-1 shadow-lg">
<ComposerPrimitive.Unstable_MentionBack className="mb-1 flex w-full items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent">
Back
</ComposerPrimitive.Unstable_MentionBack>
<ComposerPrimitive.Unstable_MentionCategories>
{(categories) =>
categories.map((cat) => (
<ComposerPrimitive.Unstable_MentionCategoryItem
key={cat.id}
categoryId={cat.id}
className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent data-[highlighted]:bg-accent"
>
{cat.label}
</ComposerPrimitive.Unstable_MentionCategoryItem>
))
}
</ComposerPrimitive.Unstable_MentionCategories>
<ComposerPrimitive.Unstable_MentionItems>
{(items) =>
items.map((item) => (
<ComposerPrimitive.Unstable_MentionItem
key={item.id}
item={item}
className="flex w-full cursor-pointer flex-col gap-0.5 rounded px-2 py-1.5 hover:bg-accent data-[highlighted]:bg-accent"
>
<span className="text-sm font-medium">{item.label}</span>
{item.description && (
<span className="text-xs text-muted-foreground">{item.description}</span>
)}
</ComposerPrimitive.Unstable_MentionItem>
))
}
</ComposerPrimitive.Unstable_MentionItems>
</ComposerPrimitive.Unstable_MentionPopover>
);
const Chip: FC<{ label: string; onRemove: () => void }> = ({ label, onRemove }) => (
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs">
{label}
<button onClick={onRemove} className="ml-0.5 text-muted-foreground hover:text-foreground">
<XIcon className="h-3 w-3" />
</button>
</span>
);
export const ComposerAttachmentChips: FC<{
images: { preview: string }[];
contextPaths: { path: string; type: string }[];
forcedTools: { label: string }[];
attachedSkills: Record<string, { name: string }>;
onRemoveImage: (idx: number) => void;
onRemoveContextPath: (idx: number) => void;
onRemoveForcedTool: (idx: number) => void;
onRemoveSkill: (id: string) => void;
}> = ({
images, contextPaths, forcedTools, attachedSkills,
onRemoveImage, onRemoveContextPath, onRemoveForcedTool, onRemoveSkill,
}) => (
<div className="flex flex-wrap gap-1 px-1">
{images.map((img, i) => (
<div key={`img-${i}`} className="group relative h-10 w-10 overflow-hidden rounded border">
<img src={img.preview} alt="" className="h-full w-full object-cover" />
<button
onClick={() => onRemoveImage(i)}
className="absolute -top-1 -right-1 hidden rounded-full bg-destructive p-0.5 text-destructive-foreground group-hover:block"
>
<XIcon className="h-2.5 w-2.5" />
</button>
</div>
))}
{contextPaths.map((cp, i) => (
<Chip key={`cp-${i}`} label={cp.path.split('/').pop() || cp.path} onRemove={() => onRemoveContextPath(i)} />
))}
{forcedTools.map((ft, i) => (
<Chip key={`ft-${i}`} label={`@${ft.label}`} onRemove={() => onRemoveForcedTool(i)} />
))}
{Object.entries(attachedSkills).map(([id, s]) => (
<Chip key={`sk-${id}`} label={s.name} onRemove={() => onRemoveSkill(id)} />
))}
</div>
);
@@ -1,7 +1,178 @@
import React from 'react';
import React, { useCallback, useEffect, useRef, useState, type FC, type MutableRefObject } from 'react';
import { ComposerPrimitive, useAui } from '@assistant-ui/react';
import { LexicalComposerInput } from '@assistant-ui/react-lexical';
import type { Unstable_MentionItem } from '@assistant-ui/core';
import { useAppSelector } from '@/shared/hooks';
import type { PromptTemplate } from '@/shared/state/templatesSlice';
import type { ComposerExtras } from '../runtime/useOpenSwarmRuntime';
import { useOpenSwarmMentionAdapter, type MentionItemMetadata } from './OpenSwarmMentionAdapter';
import { useComposerAttachments } from './useComposerAttachments';
import { MentionSelectOverride, MentionPopover, ComposerAttachmentChips } from './ComposerParts';
import TemplateInvokeModal from '../TemplateInvokeModal';
import ModelModeSelector from '../ModelModeSelector';
const OpenSwarmComposer: React.FC = () => {
return <div>Composer placeholder</div>;
export interface OpenSwarmComposerProps {
composerExtrasRef: MutableRefObject<ComposerExtras>;
mode: string;
onModeChange: (mode: string) => void;
model: string;
onModelChange: (model: string) => void;
isRunning?: boolean;
onStop?: () => void;
sessionId?: string;
queueLength?: number;
contextEstimate?: { used: number; limit: number };
autoFocus?: boolean;
}
const OpenSwarmComposer: FC<OpenSwarmComposerProps> = ({
composerExtrasRef, mode, onModeChange, model, onModelChange,
isRunning, onStop, sessionId, queueLength, contextEstimate, autoFocus,
}) => {
const aui = useAui();
const mentionAdapter = useOpenSwarmMentionAdapter();
const att = useComposerAttachments();
const formRef = useRef<HTMLFormElement>(null);
const [selectedTemplate, setSelectedTemplate] = useState<PromptTemplate | null>(null);
const [hasContent, setHasContent] = useState(false);
const templates = useAppSelector((s) => s.templates.items);
const skills = useAppSelector((s) => s.skills.items);
const syncExtras = useCallback(() => {
const allForcedTools = att.forcedTools.flatMap((ft) => ft.tools);
const skillList = Object.values(att.attachedSkills);
composerExtrasRef.current = {
images: att.images.length > 0 ? att.images.map(({ data, media_type }) => ({ data, media_type })) : undefined,
contextPaths: att.contextPaths.length > 0 ? att.contextPaths : undefined,
forcedTools: allForcedTools.length > 0 ? allForcedTools : undefined,
attachedSkills: skillList.length > 0 ? skillList : undefined,
};
}, [composerExtrasRef, att.images, att.contextPaths, att.forcedTools, att.attachedSkills]);
const handleFormSubmit = useCallback(() => {
syncExtras();
setTimeout(() => att.clearAll(), 0);
}, [syncExtras, att]);
const handleSendClick = useCallback(() => {
syncExtras();
aui.composer().send();
att.clearAll();
}, [syncExtras, aui, att]);
useEffect(() => {
return aui.subscribe(() => {
const text = aui.composer().getState().text;
setHasContent(text.trim().length > 0 || att.images.length > 0 || att.contextPaths.length > 0);
});
}, [aui, att.images.length, att.contextPaths.length]);
const handleMentionSelect = useCallback(
(item: Unstable_MentionItem): boolean => {
const meta = item.metadata as unknown as MentionItemMetadata | undefined;
if (!meta) return false;
switch (meta.itemType) {
case 'template': {
const tmpl = templates[item.id];
if (!tmpl) return true;
if (tmpl.fields.length === 0) {
aui.composer().setText(aui.composer().getState().text + tmpl.template);
} else {
setSelectedTemplate(tmpl);
}
return true;
}
case 'skill': {
const skill = skills[item.id];
if (!skill) return true;
att.setAttachedSkills((prev) => ({
...prev,
[skill.id]: { id: skill.id, name: skill.name, content: skill.content },
}));
return true;
}
case 'mode':
onModeChange(item.id);
return true;
case 'file':
att.generalFileInputRef.current?.click();
return true;
case 'tool-group':
case 'output':
if (meta.toolNames && meta.toolNames.length > 0) {
att.setForcedTools((prev) => [
...prev,
{ label: item.label, tools: meta.toolNames!, iconKey: meta.iconKey },
]);
}
return true;
default:
return false;
}
},
[templates, skills, onModeChange, aui, att],
);
const handleTemplateApply = useCallback(
(rendered: string) => {
aui.composer().setText(aui.composer().getState().text + rendered);
setSelectedTemplate(null);
},
[aui],
);
const hasAttachments =
att.images.length > 0 || att.contextPaths.length > 0 ||
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">
<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"
onDragOver={att.handleDragOver} onDragLeave={att.handleDragLeave} onDrop={att.handleDrop}
data-dragging={att.isDragOver || undefined}
>
{hasAttachments && (
<ComposerAttachmentChips
images={att.images} contextPaths={att.contextPaths}
forcedTools={att.forcedTools} attachedSkills={att.attachedSkills}
onRemoveImage={att.removeImage} onRemoveContextPath={att.removeContextPath}
onRemoveForcedTool={att.removeForcedTool} onRemoveSkill={att.removeSkill}
/>
)}
<LexicalComposerInput
placeholder="Message — @ for context and commands"
className="aui-composer-input max-h-40 min-h-10 w-full resize-none bg-transparent px-2 py-1 text-sm text-foreground outline-none placeholder:text-muted-foreground/80"
autoFocus={autoFocus}
/>
<MentionPopover />
<div className="flex items-center justify-between px-1">
<ModelModeSelector
mode={mode} onModeChange={onModeChange} model={model} onModelChange={onModelChange}
contextEstimate={contextEstimate} ownerId={sessionId || 'composer'} sessionId={sessionId}
hasContent={hasContent} isRunning={isRunning} onSend={handleSendClick} onStop={onStop}
addImageFiles={att.addImageFiles} uploadAndAttachFiles={att.uploadAndAttachFiles}
generalFileInputRef={att.generalFileInputRef} queueLength={queueLength}
/>
</div>
</div>
</ComposerPrimitive.Root>
</ComposerPrimitive.Unstable_MentionRoot>
<input ref={att.generalFileInputRef} type="file" multiple className="hidden" onChange={att.handleFileInputChange} />
{selectedTemplate && (
<TemplateInvokeModal
template={selectedTemplate} open={!!selectedTemplate}
onClose={() => setSelectedTemplate(null)} onApply={handleTemplateApply}
/>
)}
</div>
);
};
export default OpenSwarmComposer;
@@ -0,0 +1,219 @@
import { useMemo, useEffect } from 'react';
import type {
Unstable_MentionAdapter,
Unstable_MentionCategory,
Unstable_MentionItem,
} from '@assistant-ui/core';
import { useAppSelector, useAppDispatch } from '@/shared/hooks';
import { fetchBuiltinTools, fetchTools } from '@/shared/state/toolsSlice';
import { fetchOutputs } from '@/shared/state/outputsSlice';
export interface MentionItemMetadata {
itemType: 'template' | 'skill' | 'mode' | 'file' | 'tool-group' | 'output';
toolNames?: string[];
iconKey?: string;
command?: string;
hasFields?: boolean;
}
function buildItem(
id: string,
type: string,
label: string,
description: string,
meta: MentionItemMetadata,
): Unstable_MentionItem {
return { id, type, label, description, metadata: meta as any };
}
function matchesQuery(item: Unstable_MentionItem, lower: string): boolean {
return (
item.label.toLowerCase().includes(lower) ||
(item.description?.toLowerCase().includes(lower) ?? false) ||
((item.metadata as any)?.command?.toLowerCase().includes(lower) ?? false)
);
}
/**
* Hook that builds an Unstable_MentionAdapter from Redux state.
* Provides categories and items for templates, skills, modes, context tools,
* MCP tool groups, and output apps.
*/
export function useOpenSwarmMentionAdapter(): Unstable_MentionAdapter {
const dispatch = useAppDispatch();
const templates = useAppSelector((s) => s.templates.items);
const skills = useAppSelector((s) => s.skills.items);
const modesMap = useAppSelector((s) => s.modes.items);
const builtinTools = useAppSelector((s) => s.tools.builtinTools);
const customTools = useAppSelector((s) => s.tools.items);
const outputItems = useAppSelector((s) => s.outputs.items);
const toolsLoaded = useAppSelector((s) => s.tools.loaded);
const builtinLoaded = useAppSelector((s) => s.tools.builtinLoaded);
const outputsLoaded = useAppSelector((s) => s.outputs.loaded);
useEffect(() => {
if (!builtinLoaded) dispatch(fetchBuiltinTools());
if (!toolsLoaded) dispatch(fetchTools());
if (!outputsLoaded) dispatch(fetchOutputs());
}, [dispatch, builtinLoaded, toolsLoaded, outputsLoaded]);
const { categories, itemsByCategory, allItems } = useMemo(() => {
const cats: Unstable_MentionCategory[] = [];
const byCategory: Record<string, Unstable_MentionItem[]> = {};
const all: Unstable_MentionItem[] = [];
const addCategory = (id: string, label: string) => {
if (!byCategory[id]) {
cats.push({ id, label });
byCategory[id] = [];
}
};
const addItem = (catId: string, item: Unstable_MentionItem) => {
byCategory[catId]?.push(item);
all.push(item);
};
// --- Templates ---
const templateValues = Object.values(templates);
if (templateValues.length > 0) {
addCategory('templates', 'Templates');
for (const t of templateValues) {
addItem(
'templates',
buildItem(t.id, 'template', t.name, t.description || `Template with ${t.fields.length} fields`, {
itemType: 'template',
command: t.name.toLowerCase().replace(/\s+/g, '-'),
hasFields: t.fields.length > 0,
}),
);
}
}
// --- Skills ---
const skillValues = Object.values(skills);
if (skillValues.length > 0) {
addCategory('skills', 'Skills');
for (const s of skillValues) {
addItem(
'skills',
buildItem(s.id, 'skill', s.name, s.description || 'Skill', {
itemType: 'skill',
command: s.command || s.id,
}),
);
}
}
// --- Modes ---
const modeValues = Object.values(modesMap);
if (modeValues.length > 0) {
addCategory('modes', 'Modes');
for (const m of modeValues) {
addItem(
'modes',
buildItem(m.id, 'mode', m.name, m.description || 'Switch to this mode', {
itemType: 'mode',
command: m.name.toLowerCase().replace(/\s+/g, '-'),
}),
);
}
}
// --- Context: File ---
addCategory('context', 'Context');
addItem(
'context',
buildItem('file', 'context', 'File', 'Attach a file or folder as context', {
itemType: 'file',
command: 'file',
}),
);
// --- Actions: Web ---
const hasWebSearch = builtinTools.some((t) => t.name === 'WebSearch' && t.deferred);
const hasWebFetch = builtinTools.some((t) => t.name === 'WebFetch' && t.deferred);
if (hasWebSearch || hasWebFetch) {
const webTools = [hasWebSearch && 'WebSearch', hasWebFetch && 'WebFetch'].filter(
Boolean,
) as string[];
addItem(
'context',
buildItem('web', 'context', 'Web', 'Search the web and fetch URLs', {
itemType: 'tool-group',
command: 'web',
toolNames: webTools,
iconKey: 'Web',
}),
);
}
// --- MCP Tool Groups ---
for (const tool of Object.values(customTools)) {
if (!tool.mcp_config || Object.keys(tool.mcp_config).length === 0) continue;
const services = tool.tool_permissions?._services as Record<string, { read: string[]; write: string[] }> | undefined;
if (!services) continue;
const perms = tool.tool_permissions as Record<string, any>;
const serviceGroups = (tool.tool_permissions?._service_groups ?? {}) as Record<string, string[]>;
const enabled: { name: string; tools: string[] }[] = [];
for (const [sn, st] of Object.entries(services)) {
const names = [...(st.read || []), ...(st.write || [])].filter((n) => perms[n] !== 'deny');
if (names.length > 0) enabled.push({ name: sn, tools: names });
}
if (enabled.length === 0) continue;
const catId = `mcp-${tool.id}`;
addCategory(catId, tool.name);
const emitted = new Set<string>();
for (const [gn, gsn] of Object.entries(serviceGroups)) {
const gc = gn.toLowerCase().replace(/\s+/g, '-');
const gs = enabled.filter((s) => gsn.includes(s.name));
if (gs.length === 0) continue;
gs.forEach((s) => emitted.add(s.name));
if (gs.length >= 2) {
addItem(catId, buildItem(`mcp-${tool.id}-group-${gn}`, 'context', gn, `Use all ${gn} actions`, { itemType: 'tool-group', command: gc, toolNames: gs.flatMap((s) => s.tools), iconKey: gn }));
}
for (const svc of gs) {
const cmd = gs.length >= 2 ? `${gc}/${svc.name.toLowerCase().replace(/\s+/g, '-')}` : svc.name.toLowerCase().replace(/\s+/g, '-');
addItem(catId, buildItem(`mcp-${tool.id}-${svc.name}`, 'context', svc.name, `Use ${svc.name} actions from ${tool.name}`, { itemType: 'tool-group', command: cmd, toolNames: svc.tools, iconKey: gn }));
}
}
for (const svc of enabled) {
if (emitted.has(svc.name)) continue;
addItem(catId, buildItem(`mcp-${tool.id}-${svc.name}`, 'context', svc.name, `Use ${svc.name} actions from ${tool.name}`, { itemType: 'tool-group', command: svc.name.toLowerCase().replace(/\s+/g, '-'), toolNames: svc.tools }));
}
}
// --- Apps / Outputs ---
const outputValues = Object.values(outputItems).filter((o) => o.permission !== 'deny');
if (outputValues.length > 0) {
addCategory('apps', 'Apps');
for (const out of outputValues) {
addItem(
'apps',
buildItem(`view-${out.id}`, 'context', out.name, out.description || `Render ${out.name} view`, {
itemType: 'output',
command: out.name.toLowerCase().replace(/\s+/g, '-'),
toolNames: ['RenderOutput'],
iconKey: 'View',
}),
);
}
}
return { categories: cats, itemsByCategory: byCategory, allItems: all };
}, [templates, skills, modesMap, builtinTools, customTools, outputItems]);
return useMemo<Unstable_MentionAdapter>(
() => ({
categories: () => categories,
categoryItems: (categoryId: string) => itemsByCategory[categoryId] ?? [],
search: (query: string) => {
if (!query) return allItems;
const lower = query.toLowerCase();
return allItems.filter((item) => matchesQuery(item, lower));
},
}),
[categories, itemsByCategory, allItems],
);
}
@@ -0,0 +1,151 @@
import { useState, useCallback, useRef } from 'react';
import { API_BASE } from '@/shared/config';
import type { ContextPath } from '@/app/components/DirectoryBrowser';
export interface AttachedImage {
data: string;
media_type: string;
preview: string;
}
export interface ForcedToolGroup {
label: string;
tools: string[];
iconKey?: string;
}
export interface AttachedSkill {
id: string;
name: string;
content: string;
}
export function useComposerAttachments() {
const [images, setImages] = useState<AttachedImage[]>([]);
const [contextPaths, setContextPaths] = useState<ContextPath[]>([]);
const [forcedTools, setForcedTools] = useState<ForcedToolGroup[]>([]);
const [attachedSkills, setAttachedSkills] = useState<Record<string, AttachedSkill>>({});
const [isUploading, setIsUploading] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const generalFileInputRef = useRef<HTMLInputElement>(null);
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 uploadAndAttachFiles = useCallback(async (files: File[]) => {
if (files.length === 0) return;
setIsUploading(true);
try {
const formData = new FormData();
files.forEach((f) => formData.append('files', f));
const resp = await fetch(`${API_BASE}/settings/upload-files`, {
method: 'POST',
body: formData,
});
if (!resp.ok) throw new Error('Upload failed');
const data = await resp.json();
const newPaths: ContextPath[] = (data.files || []).map((f: { path: string }) => ({
path: f.path,
type: 'file' as const,
}));
setContextPaths((prev) => [...prev, ...newPaths]);
} catch (err) {
console.error('File upload failed:', err);
} finally {
setIsUploading(false);
}
}, []);
const removeImage = useCallback(
(idx: number) => setImages((prev) => prev.filter((_, i) => i !== idx)),
[],
);
const removeContextPath = useCallback(
(idx: number) => setContextPaths((prev) => prev.filter((_, i) => i !== idx)),
[],
);
const removeForcedTool = useCallback(
(idx: number) => setForcedTools((prev) => prev.filter((_, i) => i !== idx)),
[],
);
const removeSkill = useCallback(
(id: string) =>
setAttachedSkills((prev) => {
const { [id]: _, ...rest } = prev;
return rest;
}),
[],
);
const clearAll = useCallback(() => {
setImages([]);
setContextPaths([]);
setForcedTools([]);
setAttachedSkills({});
}, []);
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) uploadAndAttachFiles(otherFiles);
},
[addImageFiles, uploadAndAttachFiles],
);
const handleFileInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
const allFiles = Array.from(files);
const imgFiles = allFiles.filter((f) => f.type.startsWith('image/'));
const otherFiles = allFiles.filter((f) => !f.type.startsWith('image/'));
if (imgFiles.length > 0) addImageFiles(imgFiles);
if (otherFiles.length > 0) uploadAndAttachFiles(otherFiles);
e.target.value = '';
},
[addImageFiles, uploadAndAttachFiles],
);
return {
images, contextPaths, forcedTools, attachedSkills, isUploading, isDragOver,
generalFileInputRef,
setImages, setContextPaths, setForcedTools, setAttachedSkills,
addImageFiles, uploadAndAttachFiles, removeImage, removeContextPath,
removeForcedTool, removeSkill, clearAll,
handleDragOver, handleDragLeave, handleDrop, handleFileInputChange,
};
}
@@ -0,0 +1,58 @@
import { useMemo, type MutableRefObject } from 'react';
import { useAui } from '@assistant-ui/react';
import type { ContextPath } from '@/app/components/DirectoryBrowser';
import type { ComposerExtras } from '../runtime/useOpenSwarmRuntime';
export interface ForcedToolGroup {
label: string;
tools: string[];
iconKey?: string;
}
export interface ComposerHandle {
getConfig: () => {
prompt: string;
contextPaths: ContextPath[];
forcedTools: ForcedToolGroup[];
};
setContent: (
prompt: string,
contextPaths?: ContextPath[],
forcedTools?: ForcedToolGroup[],
) => void;
}
/**
* Provides a ChatInputHandle-compatible interface backed by the assistant-ui
* ComposerRuntime and a shared ComposerExtras ref.
*
* Agent 7 can wire this into useAgentChat to replace the old chatInputRef.
*/
export function useComposerHandle(
composerExtrasRef: MutableRefObject<ComposerExtras>,
): ComposerHandle {
const aui = useAui();
return useMemo<ComposerHandle>(
() => ({
getConfig: () => {
const text = aui.composer().getState().text;
const extras = composerExtrasRef.current;
const contextPaths: ContextPath[] = (extras.contextPaths ?? []) as ContextPath[];
const forcedTools: ForcedToolGroup[] = extras.forcedTools
? extras.forcedTools.map((t) => ({ label: t, tools: [t] }))
: [];
return { prompt: text, contextPaths, forcedTools };
},
setContent: (prompt, contextPaths, forcedTools) => {
aui.composer().setText(prompt);
composerExtrasRef.current = {
...composerExtrasRef.current,
contextPaths: contextPaths as ComposerExtras['contextPaths'],
forcedTools: forcedTools?.flatMap((g) => g.tools),
};
},
}),
[aui, composerExtrasRef],
);
}
@@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, type MutableRefObject } from 'react';
import {
useExternalStoreRuntime,
type ThreadMessageLike,
@@ -13,6 +13,28 @@ import {
type StreamingMessage,
} from '@/shared/state/agentsSlice';
export interface ComposerExtras {
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[];
}
export interface DispatchableMessage {
prompt: 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[];
}
export interface RuntimeOptions {
composerExtrasRef?: MutableRefObject<ComposerExtras>;
dispatchMessage?: (msg: DispatchableMessage) => void;
}
type RawMessage = AgentMessage | (StreamingMessage & { _streaming: true });
function convertMessage(msg: RawMessage): ThreadMessageLike {
@@ -98,7 +120,10 @@ function extractText(message: AppendMessage): string {
return '';
}
export function useOpenSwarmRuntime(sessionId: string | undefined) {
export function useOpenSwarmRuntime(
sessionId: string | undefined,
options?: RuntimeOptions,
) {
const dispatch = useAppDispatch();
const session = useAppSelector((state) =>
sessionId ? state.agents.sessions[sessionId] : undefined,
@@ -120,9 +145,18 @@ export function useOpenSwarmRuntime(sessionId: string | undefined) {
if (!sessionId) return;
const text = extractText(message);
if (!text) return;
dispatch(sendMessageThunk({ sessionId, prompt: text }));
if (options?.dispatchMessage) {
const extras = options.composerExtrasRef?.current ?? {};
if (options.composerExtrasRef) {
options.composerExtrasRef.current = {};
}
options.dispatchMessage({ prompt: text, ...extras });
} else {
dispatch(sendMessageThunk({ sessionId, prompt: text }));
}
},
[sessionId, dispatch],
[sessionId, dispatch, options],
);
const onEdit = useCallback(
File diff suppressed because one or more lines are too long