diff --git a/.env.example b/.env.example index be2f9ef..9b5fd8c 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,12 @@ LAZYBOY_COMPUTER_DRIVER=cua # Linux only (optional): point this at the host's LXCFS root to make htop/free # report the per-Agent cgroup quota. Leave the default empty directory on macOS. LAZYBOY_LXCFS_ROOT=./data/lxcfs +# 群組聊天「誰該回應」:沒被 @ 的時候,由一次短短的模型問從成員的工作內容與簡介裡 +# 挑出最多三個人回覆。留空(預設)=沿用空間的預設模型,多數人不用理這行; +# 想省 token 就填一個便宜快速的 model id(要跟空間預設來自同一家 provider)。 +# 挑不到人、模型不能用、或超過 1.2 秒,一律由主持人(群組第一位成員)收場。 +LAZYBOY_ROUTER_MODEL= + # 任務長度政策:不再用固定輪數掐掉任務。正常任務一路做到驗證完成,只有 # 真的鬼打牆(同一個動作重複、同一個錯誤一直失敗、很久沒有新的成功)才會被 # 提示、接著暫停等你決定;最後兩個是防迴圈失控烧 token 的保險絲,不是額度。 diff --git a/README.md b/README.md index 45e189a..77ebf26 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ This is an early `0.1.0` release with desktop and phone browser UIs. You bring y - **A lasting workspace**: each agent has its own chats, run history, and optional long-term memory. - **A real computer**: open pages, use the terminal, organize files, drive the GUI — and watch it live. - **Take over any time**: sign in, pass a check, or nudge things by hand on the same desktop, then hand it back. -- **Several agents and groups**: shared Team computers or private dedicated desktops. +- **Several agents and groups**: shared Team computers or private dedicated desktops; `@name` decides who answers, so a message wakes the one agent it is for instead of all of them. - **Teach by demo, then schedule**: turn a walkthrough into a skill; use cron for repeat work. - **Your models and tools**: xAI, OpenCode Go, OpenAI-compatible endpoints, MCP, and file skills. - **Voice calls**: after you enable a voice provider, you can talk to the agent on a call. diff --git a/README.zh-TW.md b/README.zh-TW.md index b79e244..7a2aa7d 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -25,7 +25,7 @@ LazyBoy 讓 Agent 在 Docker 裡使用自己的 Linux 桌面,操作瀏覽器 - **持續的工作空間**:每個 Agent 有自己的對話、工作紀錄與可設定的長期記憶。 - **真的能操作電腦**:開網頁、使用終端、整理檔案、操作圖形介面,過程可即時觀看。 - **隨時人工接管**:在同一個桌面完成登入、驗證或手動調整,再交回 Agent。 -- **多 Agent 與群組**:支援 Team 共用電腦與 Private 獨立電腦模式。 +- **多 Agent 與群組**:支援 Team 共用電腦與 Private 獨立電腦模式;群組裡 @誰就由誰回,沒點名時只叫醒工作內容相關的那個,不會全部出動。 - **示範教學與排程**:把操作示範整理成技能,使用 cron 安排重複工作。 - **自選模型與工具**:支援 xAI、OpenCode Go、OpenAI 相容端點,以及 MCP 與檔案技能。 - **語音通話**:設定語音服務後,可以透過通話與 Agent 互動。 diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index fadaa74..bdba7f1 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,4 +1,4 @@ -import { FormEvent, KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { FormEvent, Fragment, KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { BotIcon, Brain, ChevronDown, ChevronsRight, CircleHelp, ClipboardPaste, Computer, Download, Ellipsis, Info, LogOut, Megaphone, Paperclip, Pencil, Pin, Plug, Plus, RefreshCw, Settings, Smartphone, Sparkle, Square, Upload, Users, X } from "./animated-icons"; import UseAnimations from "./use-animations"; import loading from "react-useanimations/lib/loading"; @@ -17,6 +17,8 @@ import searchToX from "react-useanimations/lib/searchToX"; import { api, ApiError } from "./api"; import { createCoalescer, subscribeToSession } from "./live"; import { applyReplyEvent, type ReplyDrafts } from "./reply-stream"; +import { acceptMention, mentionChoices, mentionToken } from "./mentions"; +import { clockTime, dayLabel, sameDay } from "./chat-time"; import { HANDOFF_MS, VEIL_FADE_MS, handoffRemaining, keepScreenUrl, nextVeil, viewOnlyFor, viewerPath, type Veil } from "./handoff"; import { Avatar, AvatarLookProvider, AvatarStack, BLOBATAR_BACKGROUNDS, BLOBATAR_EXPRESSIONS, BLOBATAR_SHAPES, DEFAULT_LOOK, persistBlobatarShape, readAvatarLooks, resolveBlobatarShape, writeAvatarLook, type AvatarBackground, type AvatarExpression, type AvatarLook } from "./avatar"; import { dateLocale, getLocale, listJoin, setLocale, t, useLocale, type MessageKey } from "./i18n"; @@ -55,10 +57,17 @@ type WorkspacePrefs={name:string;showHidden:boolean}; function isDefaultWorkspaceName(name?:string|null){return !name||name==="Local workspace"||name==="本機工作區"} function readWorkspace():WorkspacePrefs{try{const raw=localStorage.getItem(WORKSPACE_STORE);if(!raw)return{name:t("localWorkspace"),showHidden:false};const value=JSON.parse(raw) as {name?:string;showHidden?:boolean};const name=value.name?.trim();return{name:isDefaultWorkspaceName(name)?t("localWorkspace"):name||t("localWorkspace"),showHidden:Boolean(value.showHidden)}}catch{return{name:t("localWorkspace"),showHidden:false}}} +// A message shows only its clock time; the day it belongs to is written once, +// in the divider above the first message of that day, the way chat apps do. function MessageTime({value}:{value:string}){ const date=new Date(value); if(!Number.isFinite(date.getTime()))return null; - return ; + return ; +} +function DayDivider({value}:{value:string}){ + const date=new Date(value); + if(!Number.isFinite(date.getTime()))return null; + return
; } function WorkspaceAvatar({name}:{name:string}){const parts=name.trim().split(/\s+/).filter(Boolean);const initials=(parts.length>1?parts.map(part=>part[0]).join(""):parts[0]?.slice(0,2)||"LB").slice(0,2).toUpperCase();return } function modeLabel(mode:ComputerMode){return mode==="team"?t("sharedComputer"):t("privateComputer")} @@ -217,6 +226,20 @@ export function App(){ const [slashIndex,setSlashIndex]=useState(0); const [slashDismissed,setSlashDismissed]=useState(false); useEffect(()=>{setSlashIndex(0);setSlashDismissed(false)},[draft]); + // Who answers a group message is decided by the server; here it is only + // typed. The `@` list opens on `@`, closes on the first space, and remembers + // nothing, because choosing who should talk must never feel like setup. + const [mentionIndex,setMentionIndex]=useState(0); + const [mentionDismissed,setMentionDismissed]=useState(false); + const [caretAt,setCaretAt]=useState(0); + const composerRef=useRef(null); + const [hostMenuOpen,setHostMenuOpen]=useState(false); + useEffect(()=>{setMentionIndex(0);setMentionDismissed(false);setHostMenuOpen(false)},[draft,activeRoomId]); + useEffect(()=>{setCaretAt(current=>current>draft.length?draft.length:current)},[draft]); + const mentionQuery=activeRoom&&!mentionDismissed?mentionToken(draft,caretAt):null; + const mentionList=mentionQuery===null||!activeRoom?[]:mentionChoices(mentionQuery,activeRoom.members,t("everyoneMention")); + const roomHostId=activeRoom?activeRoom.hostBotId||activeRoom.members[0]?.id||null:null; + const roomHost=activeRoom?.members.find(member=>member.id===roomHostId)||null; const slashToken=draft.trimStart().split(/\s/,1)[0].slice(1).toLowerCase(); const slashSuggestions=!slashDismissed&&/^\/[^\s]*$/.test(draft.trimStart()) ? [{name:"goal",description:t("goalCommandHint"),kind:t("slashMode")},...fileSkills.map(skill=>({...skill,kind:t("slashFileSkill")}))].filter(skill=>!slashToken||skill.name.startsWith(slashToken)).slice(0,8) @@ -290,7 +313,12 @@ export function App(){ useEffect(()=>{if(!handingOff)return;handoffAtRef.current=Date.now();const timer=setTimeout(()=>{expectedHolderRef.current=null;setHandingOff(false)},HANDOFF_MS);return()=>clearTimeout(timer)},[handingOff]); useEffect(()=>{const frame=desktopFrameRef.current;if(!frame?.contentWindow||!screenUrl)return;frame.contentWindow.postMessage({type:"lazyboy-view-only",viewOnly:!desktopInteractive},location.origin)},[desktopInteractive,screenUrl,desktopReady]); useEffect(()=>{const listener=(event:MessageEvent)=>{if(event.origin!==location.origin||event.source!==desktopFrameRef.current?.contentWindow||event.data?.type!=="lazyboy-request-control"||!paneBotId)return;if(computer.sharedInput){pushViewOnly(!desktopInteractive);return}void setControl("user")};window.addEventListener("message",listener);return()=>window.removeEventListener("message",listener)},[paneBotId,computer.sharedInput,desktopInteractive]); - useEffect(()=>{const close=(event:MouseEvent)=>{const target=event.target;if(target instanceof Element&&target.closest(".create-menu-wrap,.account-wrap,.session-picker,.context-menu,.plus-menu-wrap"))return;setContext(null);setRoomContext(null);setSessionMenuOpen(false);setAccountOpen(false);setCreateMenuOpen(false);setPlusOpen(false)};window.addEventListener("click",close);return()=>window.removeEventListener("click",close)},[]); + async function setRoomHost(botId:string){ + setHostMenuOpen(false); + if(!activeRoom||botId===roomHostId)return; + try{await api(`/api/rooms/${activeRoom.id}`,{method:"PATCH",body:JSON.stringify({hostBotId:botId})});await loadBots()}catch(error){setError(localizeError(error instanceof Error?error.message:t("operationFailed")))} + } + useEffect(()=>{const close=(event:MouseEvent)=>{const target=event.target;if(target instanceof Element&&target.closest(".create-menu-wrap,.account-wrap,.session-picker,.context-menu,.plus-menu-wrap,.host-chip-wrap"))return;setContext(null);setRoomContext(null);setSessionMenuOpen(false);setAccountOpen(false);setCreateMenuOpen(false);setPlusOpen(false);setHostMenuOpen(false)};window.addEventListener("click",close);return()=>window.removeEventListener("click",close)},[]); useEffect(()=>{const onKey=(event:KeyboardEvent)=>{if(event.key!=="Escape")return;setAccountOpen(false);setAccountDialog(null);setCreateMenuOpen(false);setPlusOpen(false);setTeachOpen(false);setEditingSkillId(null);setSessionMenuOpen(false);setContext(null);setRoomContext(null)};window.addEventListener("keydown",onKey);return()=>window.removeEventListener("keydown",onKey)},[]); useEffect(()=>{setWorkspaceName(name=>isDefaultWorkspaceName(name)?t("localWorkspace"):name)},[locale]); useEffect(()=>{localStorage.setItem(WORKSPACE_STORE,JSON.stringify({name:workspaceName,showHidden}))},[workspaceName,showHidden]); @@ -365,7 +393,8 @@ export function App(){ })}finally{sendingRef.current=false;setSendingMessage(false)} } - function composerKeyDown(event:ReactKeyboardEvent){if(event.nativeEvent.isComposing||event.key==="Process")return;if(slashSuggestions.length){if(event.key==="Escape"){event.preventDefault();setSlashDismissed(true);return}if(event.key==="ArrowDown"||event.key==="ArrowUp"){event.preventDefault();setSlashIndex(index=>(index+(event.key==="ArrowDown"?1:slashSuggestions.length-1))%slashSuggestions.length);return}if(event.key==="Tab"||(event.key==="Enter"&&!event.shiftKey)){event.preventDefault();setDraft(`/${slashSuggestions[slashIndex%slashSuggestions.length].name} `);return}}const history=sentHistoryRef.current;if(event.key==="ArrowUp"&&history.length>0&&(!event.currentTarget.value.includes("\n")||event.currentTarget.selectionStart===0)){event.preventDefault();if(historyIndexRef.current===null){historyDraftRef.current=draft;historyIndexRef.current=history.length-1}else historyIndexRef.current=Math.max(0,historyIndexRef.current-1);setDraft(history[historyIndexRef.current]);return}if(event.key==="ArrowDown"&&historyIndexRef.current!==null&&(!event.currentTarget.value.includes("\n")||event.currentTarget.selectionEnd===event.currentTarget.value.length)){event.preventDefault();if(historyIndexRef.currentcomposerRef.current?.focus())} + function composerKeyDown(event:ReactKeyboardEvent){if(event.nativeEvent.isComposing||event.key==="Process")return;if(mentionList.length){if(event.key==="Escape"){event.preventDefault();setMentionDismissed(true);return}if(event.key==="ArrowDown"||event.key==="ArrowUp"){event.preventDefault();setMentionIndex(index=>(index+(event.key==="ArrowDown"?1:mentionList.length-1))%mentionList.length);return}if(event.key==="Tab"||(event.key==="Enter"&&!event.shiftKey)){event.preventDefault();pickMention(mentionList[mentionIndex%mentionList.length]);return}}if(slashSuggestions.length){if(event.key==="Escape"){event.preventDefault();setSlashDismissed(true);return}if(event.key==="ArrowDown"||event.key==="ArrowUp"){event.preventDefault();setSlashIndex(index=>(index+(event.key==="ArrowDown"?1:slashSuggestions.length-1))%slashSuggestions.length);return}if(event.key==="Tab"||(event.key==="Enter"&&!event.shiftKey)){event.preventDefault();setDraft(`/${slashSuggestions[slashIndex%slashSuggestions.length].name} `);return}}const history=sentHistoryRef.current;if(event.key==="ArrowUp"&&history.length>0&&(!event.currentTarget.value.includes("\n")||event.currentTarget.selectionStart===0)){event.preventDefault();if(historyIndexRef.current===null){historyDraftRef.current=draft;historyIndexRef.current=history.length-1}else historyIndexRef.current=Math.max(0,historyIndexRef.current-1);setDraft(history[historyIndexRef.current]);return}if(event.key==="ArrowDown"&&historyIndexRef.current!==null&&(!event.currentTarget.value.includes("\n")||event.currentTarget.selectionEnd===event.currentTarget.value.length)){event.preventDefault();if(historyIndexRef.current(sessionsPath,{method:"POST",body:JSON.stringify({title:t("newConversation")})});writeSessionStore(sessionStoreKey,session.id);const next=await api(sessionsPath);setSessions(next);setActiveSessionId(session.id);setMessages([])}catch(e){setError(e instanceof Error?localizeError(e.message):t("operationFailed"))}finally{setBusy(false)}} // A run that stopped to ask keeps its harness state, so the answer is just a @@ -525,14 +554,14 @@ export function App(){
-
{activeRoom?<>member.id)}/>{activeRoom.name}{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/>{topTools}:active?<>{active.name}{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/>{topTools}:<>{t("chooseBot")}{topTools}}
+
{activeRoom?<>member.id)}/>{activeRoom.name}event.stopPropagation()}>{hostMenuOpen&&
{t("hostMenuLabel")}{activeRoom.members.map(member=>)}
}
{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/>{topTools}:active?<>{active.name}{setSessionMenuOpen(false);setSessionToDelete(id)}} onClear={()=>{setSessionMenuOpen(false);setClearOpen(true)}}/>{topTools}:<>{t("chooseBot")}{topTools}}
{callOpen&&voiceSettings?.enabled&&active&&activeSessionId&&!activeRoomId?setCallOpen(false)} onTakeOver={()=>{setRightPart("computer");setRightCollapsed(false);setComputerOpen(true);if(active&&!computer.sharedInput)void setControl("user",active.id)}}/>:null} -
{(activeRoom||active)&&messages.length===0?
{activeRoom?:}

{activeRoom?t("startRoomDiscussion",{name:activeRoom.name}):t("startBotWork",{name:active!.name})}

{activeRoom?t("roomWillReply",{names:listJoin(activeRoom.members.map(member=>member.name))}):active!.description||t("botWelcome")}

:messages.map(message=>{const spoken=message.role!=="user"&&Boolean(activeRoom);const speakerName=message.speakerName||(spoken?paneBot?.name:undefined);const speakerShape=(message.speakerShape||paneBot?.avatarShape||"blob") as AvatarShape;const files=messageFiles(message.blocks);const chips=chipBlocks(message.blocks);const hideBody=isAutoAttachCaption(message.body,files)||isAutoScheduleCaption(message.body,chips);return
{spoken&&}{spoken&&{speakerName}}{files.length>0&&
{files.map(file=>)}
}{chips.map((chip,index)=>chip.kind==="error"?
{errorTitle(chip.code)}
{errorActions(chip.code).map(next=>next==="retry"?:next==="screen"?:)}
:chip.kind==="resume"?
{resumeTitle(chip.reason)}
{(chip.limit||0)>0?
{t("resumeProgress",{turns:chip.turns||0,limit:chip.limit||0})}
:null}{resumedChips[message.id]||(message.seq??0)
}
:chip.kind==="login"?
{t("loginNeedsYou")}
{chip.site||message.body}
{chip.why?
{t("loginWhy",{why:chip.why})}
:null}
:chip.kind==="schedule"?
{t("scheduleChip")}
{chip.name}{scheduleWhen(chip.cron,chip.human)}
:
{t("scheduleRunChip")}
{chip.name}{scheduleWhen(chip.cron,chip.human)}
)}{!hideBody&&(message.role==="assistant"?
{message.body}
{message.body.trim()?:null}{message.runId&&{t("messageRunDetails")}}
:{message.body})}{!hideBody&&message.body.trim()&&}
})}{pausedForUser&&paneBot&&
{computer.sharedInput?t("sharedNeedsUser",{name:paneBot.name}):computer.controlHolder==="user"?t("pausedUserControl",{name:paneBot.name}):t("pausedNeedsUser",{name:paneBot.name})}{(computer.queuedRuns||0)>0&&<> {t("queuedMessages",{count:computer.queuedRuns||0})}}{computer.sharedInput||computer.controlHolder==="user"?:}
}{teaching&&active&&
{t("teachingLive",{goal:teaching.goal})}{t("teachingHint")}{teaching.eventCount>0&&<> · {t("teachingCaptured",{count:teaching.eventCount})}}
}{drafting&&
{t("distilling",{goal:drafting.goal})}
}{skillDraft&&active&&void saveSkill(skillDraft,name,playbook)} onTest={(name,playbook)=>void testSkill(skillDraft,name,playbook)} onDiscard={()=>void discardSkill(skillDraft)} onEdit={()=>setEditingSkillId(skillDraft.id)} onExport={(name,playbook)=>downloadSkill(name,skillDraft.goal,playbook)}/>}{Object.values(replyDrafts).filter(reply=>reply.text&&(!reply.messageId||!messages.some(message=>message.id===reply.messageId))).map(reply=>{const speaker=bots.find(bot=>bot.id===reply.botId);return
{activeRoom&&speaker&&<>{speaker.name}}
{reply.text}
})} +
{(activeRoom||active)&&messages.length===0?
{activeRoom?:}

{activeRoom?t("startRoomDiscussion",{name:activeRoom.name}):t("startBotWork",{name:active!.name})}

{activeRoom?t("roomWillReply",{names:listJoin(activeRoom.members.map(member=>member.name))}):active!.description||t("botWelcome")}

:messages.map((message,index)=>{const spoken=message.role!=="user"&&Boolean(activeRoom);const speakerName=message.speakerName||(spoken?paneBot?.name:undefined);const speakerShape=(message.speakerShape||paneBot?.avatarShape||"blob") as AvatarShape;const files=messageFiles(message.blocks);const chips=chipBlocks(message.blocks);const hideBody=isAutoAttachCaption(message.body,files)||isAutoScheduleCaption(message.body,chips);const previous=index>0?messages[index-1]:null;const newDay=!previous||!sameDay(previous.createdAt,message.createdAt);return {newDay&&}
{spoken&&}{spoken&&{speakerName}}{files.length>0&&
{files.map(file=>)}
}{chips.map((chip,index)=>chip.kind==="error"?
{errorTitle(chip.code)}
{errorActions(chip.code).map(next=>next==="retry"?:next==="screen"?:)}
:chip.kind==="resume"?
{resumeTitle(chip.reason)}
{(chip.limit||0)>0?
{t("resumeProgress",{turns:chip.turns||0,limit:chip.limit||0})}
:null}{resumedChips[message.id]||(message.seq??0)
}
:chip.kind==="login"?
{t("loginNeedsYou")}
{chip.site||message.body}
{chip.why?
{t("loginWhy",{why:chip.why})}
:null}
:chip.kind==="schedule"?
{t("scheduleChip")}
{chip.name}{scheduleWhen(chip.cron,chip.human)}
:
{t("scheduleRunChip")}
{chip.name}{scheduleWhen(chip.cron,chip.human)}
)}{!hideBody&&(message.role==="assistant"?
{message.body}
{message.body.trim()?:null}{message.runId&&{t("messageRunDetails")}}
:{message.body})}{!hideBody&&message.body.trim()&&}{message.replyBots&&message.replyBots.length>0&&
{message.role==="user"?t("routedTo",{names:listJoin(message.replyBots.map(reply=>reply.name))}):t("handedTo",{name:message.replyBots[0].name})}
}
})}{pausedForUser&&paneBot&&
{computer.sharedInput?t("sharedNeedsUser",{name:paneBot.name}):computer.controlHolder==="user"?t("pausedUserControl",{name:paneBot.name}):t("pausedNeedsUser",{name:paneBot.name})}{(computer.queuedRuns||0)>0&&<> {t("queuedMessages",{count:computer.queuedRuns||0})}}{computer.sharedInput||computer.controlHolder==="user"?:}
}{teaching&&active&&
{t("teachingLive",{goal:teaching.goal})}{t("teachingHint")}{teaching.eventCount>0&&<> · {t("teachingCaptured",{count:teaching.eventCount})}}
}{drafting&&
{t("distilling",{goal:drafting.goal})}
}{skillDraft&&active&&void saveSkill(skillDraft,name,playbook)} onTest={(name,playbook)=>void testSkill(skillDraft,name,playbook)} onDiscard={()=>void discardSkill(skillDraft)} onEdit={()=>setEditingSkillId(skillDraft.id)} onExport={(name,playbook)=>downloadSkill(name,skillDraft.goal,playbook)}/>}{Object.values(replyDrafts).filter(reply=>reply.text&&(!reply.messageId||!messages.some(message=>message.id===reply.messageId))).map(reply=>{const speaker=bots.find(bot=>bot.id===reply.botId);return
{activeRoom&&speaker&&<>{speaker.name}}
{reply.text}
})}
{error&&
{error}
} {otherSessionBusy&&
{t("anotherConversationQueued")}
} {statusMembers.map(member=>{const step=computer.busyStep&&member.id===computer.botId?computer.busyStep:null;const transition=isTransitionStep(step);const label=t("working",{name:member.name});return
{label}{!transition&&step?{localizeStep(step)}:null}
})} -
{event.preventDefault()}} onDrop={event=>{event.preventDefault();if(event.dataTransfer.files.length)addPendingFiles(event.dataTransfer.files)}}>
event.stopPropagation()}>{plusOpen&&
{savedSkills.length>0&&<>
{t("taughtSkills")}{savedSkills.length>5?` · ${savedSkills.length}`:""}{savedSkills.length>=6&&setSkillQuery(e.target.value)} placeholder={t("searchSkills")} aria-label={t("searchSkills")} onClick={e=>e.stopPropagation()}/>}
{listedSkills.map(skill=>
)}{listedSkills.length===0&&{t("noMatchingSkills")}}
}
}
{const file=event.target.files?.[0];event.currentTarget.value="";if(file)void importSkillFile(file)}}/>{const files=[...event.target.files||[]];event.currentTarget.value="";if(files.length)addPendingFiles(files)}}/>{pendingFiles.length>0&&
{pendingFiles.map(item=>removePendingFile(item.id)}/>)}
}{slashSuggestions.length>0&&
{slashSuggestions.map((skill,index)=>)}
}