diff --git a/webui/frontend/app/app.css b/webui/frontend/app/app.css index c5a2f08e6..8653a24e2 100644 --- a/webui/frontend/app/app.css +++ b/webui/frontend/app/app.css @@ -49,6 +49,8 @@ * rounded-msa-8 shadow-msa-s text-msa-sm * ================================================================ */ @theme { + --animate-msa-session-dot: msa-session-dot 1.4s ease-in-out infinite; + /* --- Background --- */ --color-msa-bg-1: var(--msa-bg-1); --color-msa-bg-2: var(--msa-bg-2); @@ -371,6 +373,18 @@ a { animation: msa-loading-reveal 150ms ease-out 250ms forwards; } +@keyframes msa-session-dot { + 0%, + 60%, + 100% { + opacity: 0.3; + } + + 30% { + opacity: 0.9; + } +} + @keyframes msa-agent-gaze-working { 0%, 8%, diff --git a/webui/frontend/app/assets/icons/sliders.svg b/webui/frontend/app/assets/icons/sliders.svg new file mode 100644 index 000000000..832836319 --- /dev/null +++ b/webui/frontend/app/assets/icons/sliders.svg @@ -0,0 +1 @@ + diff --git a/webui/frontend/app/components/common/Composer.tsx b/webui/frontend/app/components/common/Composer.tsx index 27e0f0f72..8510de4e3 100644 --- a/webui/frontend/app/components/common/Composer.tsx +++ b/webui/frontend/app/components/common/Composer.tsx @@ -49,7 +49,7 @@ import CaretDownIcon from '~/assets/icons/chevron-down.svg?react' import AddIcon from '~/assets/icons/add.svg?react' import FolderIcon from '~/assets/icons/folder.svg?react' import SendIcon from '~/assets/icons/send.svg?react' -import MoreIcon from '~/assets/icons/more.svg?react' +import SlidersIcon from '~/assets/icons/sliders.svg?react' import EditIcon from '~/assets/icons/edit.svg?react' // Sender runs PERMANENTLY in slot mode (structured input): picked skills are @@ -194,13 +194,14 @@ export function Composer({ // (container queries on the footer) so the first paint is correct at any width // with no SSR/hydration flash; `pillsExpanded` only flips after a user click. const [pillsExpanded, setPillsExpanded] = useState(false) - const pillsRef = useRef(null) + const pillsMouseDownRef = useRef(null) useEffect(() => { if (!pillsExpanded) return const handleClickOutside = (e: MouseEvent) => { - if (pillsRef.current && !pillsRef.current.contains(e.target as Node)) { + if (pillsMouseDownRef.current !== e) { setPillsExpanded(false) } + pillsMouseDownRef.current = null } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) @@ -265,25 +266,41 @@ export function Composer({ const [globalSkills, setGlobalSkills] = useState( appData?.globalSkills ?? [] ) - const [projectMcps, setProjectMcps] = useState([]) - const [projectSkills, setProjectSkills] = useState([]) + const projectScope: Scope | null = effectiveProject + ? `project:${effectiveProject.id}` + : null + const [projectMcps, setProjectMcps] = useState<{ + scope: Scope + items: Mcp[] + } | null>(null) + const [projectSkills, setProjectSkills] = useState<{ + scope: Scope + items: Skill[] + } | null>(null) + const [projectResourceRevision, setProjectResourceRevision] = useState(0) useEffect(() => { - if (effectiveProject) { - const scope: Scope = `project:${effectiveProject.id}` - api - .listMcps(scope) - .then(setProjectMcps) - .catch(() => setProjectMcps([])) - api - .listSkills(scope) - .then(setProjectSkills) - .catch(() => setProjectSkills([])) - } else { - setProjectMcps([]) - setProjectSkills([]) + if (!projectScope) return + let active = true + api + .listMcps(projectScope) + .then((items) => { + if (active) setProjectMcps({ scope: projectScope, items }) + }) + .catch(() => { + if (active) setProjectMcps({ scope: projectScope, items: [] }) + }) + api + .listSkills(projectScope) + .then((items) => { + if (active) setProjectSkills({ scope: projectScope, items }) + }) + .catch(() => { + if (active) setProjectSkills({ scope: projectScope, items: [] }) + }) + return () => { + active = false } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [effectiveProject?.id]) + }, [projectScope, projectResourceRevision]) // Re-fetch when MCP/Skill config changes on the same page (e.g. toggled in // the project-detail MCPs tab while Composer is mounted above). @@ -296,19 +313,8 @@ export function Composer({ .listSkills('global') .then(setGlobalSkills) .catch(() => setGlobalSkills([])) - if (effectiveProject) { - const scope: Scope = `project:${effectiveProject.id}` - api - .listMcps(scope) - .then(setProjectMcps) - .catch(() => setProjectMcps([])) - api - .listSkills(scope) - .then(setProjectSkills) - .catch(() => setProjectSkills([])) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [effectiveProject?.id]) + setProjectResourceRevision((revision) => revision + 1) + }, []) useOnMcpSkillChanged(refreshMcpSkill) // Re-fetch when the model catalog changes elsewhere (Settings → Models, or an @@ -356,12 +362,18 @@ export function Composer({ useOnProjectsChanged(refreshProjects) const mergedMcps = useMemo( - () => [...globalMcps, ...projectMcps], - [globalMcps, projectMcps] + () => [ + ...globalMcps, + ...(projectMcps?.scope === projectScope ? projectMcps.items : []) + ], + [globalMcps, projectMcps, projectScope] ) const mergedSkills = useMemo( - () => [...globalSkills, ...projectSkills], - [globalSkills, projectSkills] + () => [ + ...globalSkills, + ...(projectSkills?.scope === projectScope ? projectSkills.items : []) + ], + [globalSkills, projectSkills, projectScope] ) // Web-search config is GLOBAL, so it is resolved once by the app layout's @@ -1408,7 +1420,10 @@ export function Composer({ `min-w-24` floor and truncate the label instead of spilling onto a second line. */}
{ + // React capture includes portaled menus outside the DOM subtree. + pillsMouseDownRef.current = e.nativeEvent + }} className={`flex items-center gap-2.5 ${ pillsExpanded ? 'absolute inset-x-0 bottom-0 z-10 min-w-0 flex-nowrap overflow-x-auto bg-msa-bg-1 pt-3 @min-[550px]:static @min-[550px]:overflow-x-visible @min-[550px]:bg-transparent @min-[550px]:pt-0' @@ -1417,11 +1432,14 @@ export function Composer({ > {/* Toggle button: shown only while collapsed */} {!pillsExpanded && ( - } - onClick={() => setPillsExpanded(true)} - /> + + } + onClick={() => setPillsExpanded(true)} + /> + )}
+ {session.title} - {/* Spinner while the turn runs, dot once it has finished unwatched — - the same slot, never both: the dot exists precisely because the - spinner went away with nothing left to say the answer arrived. */} {isRunning ? ( - + + + + + ) : session.unread ? ( ) : null} diff --git a/webui/frontend/app/components/messages/ForkActions.tsx b/webui/frontend/app/components/messages/ForkActions.tsx index 7fbb0a337..532828632 100644 --- a/webui/frontend/app/components/messages/ForkActions.tsx +++ b/webui/frontend/app/components/messages/ForkActions.tsx @@ -9,7 +9,10 @@ import { useT } from '~/lib/i18n' import type { ForkOrigin } from '~/lib/types' import { IconButton } from '../common/IconButton' -export function ForkReplyButton({ sessionId, afterSeq }: { +export function ForkReplyButton({ + sessionId, + afterSeq +}: { sessionId: string afterSeq: number }) { @@ -32,9 +35,16 @@ export function ForkReplyButton({ sessionId, afterSeq }: { request.current = { seq: afterSeq, id: clientId() } } try { - const child = await api.forkSession(sessionId, afterSeq, request.current.id) + const child = await api.forkSession( + sessionId, + afterSeq, + request.current.id + ) revalidator.revalidate() - navigate(`/projects/${encodeURIComponent(child.project_id!)}/sessions/${encodeURIComponent(child.id)}`, { state: { focusComposer: true } }) + navigate( + `/projects/${encodeURIComponent(child.project_id!)}/sessions/${encodeURIComponent(child.id)}`, + { state: { focusComposer: true } } + ) } catch (error) { if (!(error instanceof ApiError)) message.error(t.chat.forkFailed) } finally { @@ -47,7 +57,7 @@ export function ForkReplyButton({ sessionId, afterSeq }: { } + icon={} loading={pending} disabled={pending} onClick={() => void fork()} @@ -61,15 +71,27 @@ export function ForkDivider({ origin }: { origin: ForkOrigin }) { const { t } = useT() const label = t.chat.forkFrom.replace('{title}', origin.title) return ( -
+
{origin.available ? ( {label} - ) : {label} · {t.chat.forkSourceDeleted}} + > + {label} + + ) : ( + + {label} · {t.chat.forkSourceDeleted} + + )}
) diff --git a/webui/frontend/app/components/models/ProviderLogo.tsx b/webui/frontend/app/components/models/ProviderLogo.tsx index 67e2685fd..e6b96eb17 100644 --- a/webui/frontend/app/components/models/ProviderLogo.tsx +++ b/webui/frontend/app/components/models/ProviderLogo.tsx @@ -38,7 +38,7 @@ function placeholderColor(seed: string): string { /** First letter/digit of the name, for the placeholder tile. */ function initial(text: string): string { - const c = [...text].find((ch) => /[A-Za-z0-9]/.test(ch)) + const c = [...text].find((ch) => /[\p{L}\p{N}]/u.test(ch)) return (c ?? '?').toUpperCase() } diff --git a/webui/frontend/app/lib/sessionModel.ts b/webui/frontend/app/lib/sessionModel.ts index eb5711794..e961e3765 100644 --- a/webui/frontend/app/lib/sessionModel.ts +++ b/webui/frontend/app/lib/sessionModel.ts @@ -64,8 +64,10 @@ export function useSessionModel(sessionId: string | null, initialId: string, ena if (sessionId) { try { saved.current = (await api.getSession(sessionId, { silent: true })).model_id || '' } catch { /* keep last known selection */ } } - selected.current = saved.current - setModelId(saved.current) + if (revision.current === version) { + selected.current = saved.current + setModelId(saved.current) + } } throw failure }).finally(() => { saving.current -= 1 }) diff --git a/webui/frontend/tsconfig.json b/webui/frontend/tsconfig.json index 56aee387f..cbdd92d43 100644 --- a/webui/frontend/tsconfig.json +++ b/webui/frontend/tsconfig.json @@ -5,7 +5,7 @@ "**/.client/**/*", ".react-router/types/**/*" ], - "exclude": ["node_modules", "build", "public", "backend"], + "exclude": ["node_modules", "build-runtime", "build", "public", "backend"], "compilerOptions": { "lib": ["DOM", "DOM.Iterable", "ES2022"], "types": ["node", "vite/client"],