mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[aidan] fix/lazyload-chat-lag: virtualize large user messages, add hard-cut fallback
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { test, expect, ElectronApplication, Page } from '@playwright/test';
|
||||
import { launchApp, waitForMainWindow, hasAnyProviderKey } from '../helpers/launch';
|
||||
|
||||
const MARKER = 'E2E_LAZYLOAD_MARKER';
|
||||
const BIG_LINE = 'The quick brown fox jumps over the lazy dog, repeated to build bulk. ';
|
||||
const BIG_TEXT = `${MARKER}\n` + Array.from({ length: 400 }, (_, i) => `paragraph ${i}: ${BIG_LINE.repeat(6)}`).join('\n\n');
|
||||
const SHORT_TEXT = `${MARKER}_SHORT short control message`;
|
||||
|
||||
async function sendAndGetBubble(page: Page, text: string) {
|
||||
const editor = page.locator('[data-onboarding="chat-input"]').first();
|
||||
await editor.click();
|
||||
await page.keyboard.insertText(text);
|
||||
const sendBtn = page.locator('[data-onboarding="chat-send-button"]');
|
||||
await expect(sendBtn, 'send button never enabled; provider likely unconfigured').toBeVisible({ timeout: 10_000 });
|
||||
await sendBtn.click();
|
||||
const bubble = page.locator(`[data-select-type="message"][data-select-meta*="${MARKER}"]`).last();
|
||||
await expect(bubble).toBeVisible({ timeout: 15_000 });
|
||||
return bubble;
|
||||
}
|
||||
|
||||
async function scrollableAncestor(page: Page, bubble: ReturnType<Page['locator']>) {
|
||||
return bubble.evaluateHandle((el) => {
|
||||
let node: HTMLElement | null = el.parentElement;
|
||||
while (node && node.scrollHeight <= node.clientHeight + 4) node = node.parentElement;
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('user message lazy-load', () => {
|
||||
let app: ElectronApplication;
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
test.skip(!hasAnyProviderKey(), 'no provider env key set; pass ANTHROPIC_API_KEY or OPENAI_API_KEY etc. to enable');
|
||||
test.skip(process.env.CI !== 'true' && process.env.OPENSWARM_E2E_SEED !== '1', 'seed gate not enabled; set OPENSWARM_E2E_SEED=1 for local runs');
|
||||
app = await launchApp();
|
||||
page = await waitForMainWindow(app);
|
||||
const newAgentBtn = page.locator('[data-onboarding="new-agent-button"]');
|
||||
await expect(newAgentBtn).toBeVisible({ timeout: 15_000 });
|
||||
await newAgentBtn.click();
|
||||
await expect(page.locator('[data-onboarding="chat-input"]').first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await app?.close().catch(() => {});
|
||||
});
|
||||
|
||||
test('short message renders in full (no regression)', async () => {
|
||||
const bubble = await sendAndGetBubble(page, SHORT_TEXT);
|
||||
const rendered = (await bubble.innerText()).trim();
|
||||
expect(rendered).toContain(SHORT_TEXT);
|
||||
});
|
||||
|
||||
test('large pasted message does not render fully at once', async () => {
|
||||
const t0 = Date.now();
|
||||
const bubble = await sendAndGetBubble(page, BIG_TEXT);
|
||||
expect(Date.now() - t0, 'send-to-visible took too long; likely un-windowed full render').toBeLessThan(5_000);
|
||||
|
||||
const renderedLen = (await bubble.innerText()).length;
|
||||
expect(renderedLen, 'bubble rendered its entire text at once; windowing did not engage').toBeLessThan(BIG_TEXT.length * 0.5);
|
||||
|
||||
// Scroll the transcript away from and back to the message; the visible slice
|
||||
// should change but stay bounded both times, proving blocks mount/unmount
|
||||
// rather than the whole message staying resident once rendered.
|
||||
const scrollEl = await scrollableAncestor(page, bubble);
|
||||
await page.evaluate((el) => { if (el) (el as HTMLElement).scrollTop = 0; }, scrollEl);
|
||||
await page.waitForTimeout(500);
|
||||
await page.evaluate((el) => { if (el) (el as HTMLElement).scrollTop = (el as HTMLElement).scrollHeight; }, scrollEl);
|
||||
await page.waitForTimeout(500);
|
||||
const rescannedLen = (await bubble.innerText()).length;
|
||||
expect(rescannedLen, 'bubble rendered its entire text after scrolling back').toBeLessThan(BIG_TEXT.length * 0.5);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,8 @@ import BuildOutlinedIcon from '@mui/icons-material/BuildOutlined';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import WindowedMarkdown from './WindowedMarkdown';
|
||||
import WindowedPlainText from './WindowedPlainText';
|
||||
import { renderUserTextWithPills } from './renderUserTextWithPills';
|
||||
import { estimateRenderedTextHeight, oversizedCharThreshold, RECHECK_VISIBILITY_EVENT } from './markdownMeasure';
|
||||
import { THINKING_LABELS } from '../thinkingLabels';
|
||||
import { extractPlatformNote } from '../parsing/toolResultParsing';
|
||||
@@ -280,44 +282,6 @@ function parseElementContext(text: string): { userMessage: string; elements: Par
|
||||
return { userMessage, elements };
|
||||
}
|
||||
|
||||
const SKILL_PILL_RE = /\{\{skill:([^}]+)\}\}/g;
|
||||
|
||||
function renderUserTextWithPills(text: string, c: ReturnType<typeof useClaudeTokens>): React.ReactNode[] {
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
const re = new RegExp(SKILL_PILL_RE.source, 'g');
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
const skillName = match[1];
|
||||
parts.push(
|
||||
<Chip
|
||||
key={`skill-${match.index}`}
|
||||
icon={<PsychologyOutlinedIcon sx={{ fontSize: 12 }} />}
|
||||
label={skillName}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: `${SKILL_COLOR}18`,
|
||||
color: SKILL_COLOR,
|
||||
fontSize: '0.72rem',
|
||||
fontFamily: c.font.mono,
|
||||
height: 20,
|
||||
mx: 0.25,
|
||||
verticalAlign: 'baseline',
|
||||
'& .MuiChip-icon': { color: SKILL_COLOR },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
lastIndex = re.lastIndex;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
interface ContextGroup {
|
||||
key: string;
|
||||
icon: React.ReactNode;
|
||||
@@ -937,10 +901,10 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
|
||||
? parseElementContext(rawText)
|
||||
: { userMessage: rawText, elements: [] };
|
||||
// A message longer than ~2 screens of text gets the placeholder + block virtualization treatment (full render in view, reserved-height placeholder off).
|
||||
const isOversizedAssistant = !isUser && !isStreaming
|
||||
&& rawText.length > oversizedCharThreshold(viewportHeight, viewportWidth);
|
||||
const isOversized = !isStreaming
|
||||
&& displayText.length > oversizedCharThreshold(viewportHeight, viewportWidth);
|
||||
const [isOversizedInViewport, setIsOversizedInViewport] = useState(false);
|
||||
const shouldRenderMarkdown = !isOversizedAssistant || isOversizedInViewport;
|
||||
const shouldRenderMarkdown = !isOversized || isOversizedInViewport;
|
||||
const markdownWindow = useMemo(() => {
|
||||
if (!shouldRenderMarkdown) {
|
||||
return { text: '', start: rawText.length, end: rawText.length, windowed: true };
|
||||
@@ -961,8 +925,8 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
|
||||
|
||||
// Height to reserve for this message's off-screen placeholder before it has ever been measured. Estimated from the FULL text length (we render in full when in view) with the same model as AgentChat's spacer estimate, so the placeholder and the spacer reserve the same space. Once rendered, oversizedContentHeights wins over this.
|
||||
const placeholderFallbackHeight = useMemo(
|
||||
() => estimateRenderedTextHeight(rawText, viewportWidth),
|
||||
[rawText, viewportWidth],
|
||||
() => estimateRenderedTextHeight(displayText, viewportWidth),
|
||||
[displayText, viewportWidth],
|
||||
);
|
||||
|
||||
const overflowCtx = useAppSelector((state) => {
|
||||
@@ -985,7 +949,7 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
|
||||
|
||||
// Reports asynchronously, bc without this an oversized message that mounts in view (e.g. scrolling up into the agent's reply) would paint the blank placeholder box for a frame and then pop in the real markdown.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!isOversizedAssistant) {
|
||||
if (!isOversized) {
|
||||
setIsOversizedInViewport(false);
|
||||
return;
|
||||
}
|
||||
@@ -1021,16 +985,16 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
|
||||
observer.disconnect();
|
||||
scrollRoot?.removeEventListener(RECHECK_VISIBILITY_EVENT, evaluate);
|
||||
};
|
||||
}, [isOversizedAssistant, message.id, scrollRoot, viewportHeight]);
|
||||
}, [isOversized, message.id, scrollRoot, viewportHeight]);
|
||||
|
||||
// Remember the full-render height of an oversized message while it is on-screen, so its off-screen placeholder can reserve exactly that height (see the module-level oversizedContentHeights cache). Measured on the content box only, which excludes the action bar (rendered by the parent) to avoid a feedback loop.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!isOversizedAssistant || !shouldRenderMarkdown) return;
|
||||
if (!isOversized || !shouldRenderMarkdown) return;
|
||||
const node = contentRef.current;
|
||||
if (!node) return;
|
||||
const h = node.offsetHeight;
|
||||
if (h > 0) oversizedContentHeights.set(message.id, h);
|
||||
}, [isOversizedAssistant, shouldRenderMarkdown, message.id, markdownWindow.text]);
|
||||
}, [isOversized, shouldRenderMarkdown, message.id, markdownWindow.text]);
|
||||
|
||||
// (message.id, kind) keys so cap card analytics fire once, not on edits.
|
||||
React.useEffect(() => {
|
||||
@@ -1091,7 +1055,7 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
|
||||
maxWidth: '85%',
|
||||
minWidth: 0,
|
||||
// Oversized messages are block-virtualized, so the set of rendered blocks (and thus the widest visible content) changes as you scroll. Pin them to a stable width so the bubble doesn't shrink-to-fit and resize horizontally frame to frame. Normal messages keep shrink-to-fit.
|
||||
...(isOversizedAssistant ? { width: '85%' } : {}),
|
||||
...(isOversized ? { width: '85%' } : {}),
|
||||
bgcolor: isUser ? c.user.bubble : c.bg.surface,
|
||||
border: isUser ? (isFailed ? `1px solid ${c.status.error}` : 'none') : `1px solid ${c.border.subtle}`,
|
||||
borderRadius: isUser ? '16px 16px 4px 16px' : '16px 16px 16px 4px',
|
||||
@@ -1167,9 +1131,34 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
|
||||
{message.images && message.images.length > 0 && (
|
||||
<MessageImageThumbnails images={message.images} c={c} />
|
||||
)}
|
||||
<Typography sx={{ color: c.text.primary, fontSize: '0.875rem', lineHeight: 1.6, overflowWrap: 'anywhere', wordBreak: 'break-word', whiteSpace: 'pre-wrap' }}>
|
||||
{renderUserTextWithPills(displayText, c)}
|
||||
</Typography>
|
||||
<Box ref={contentRef}>
|
||||
{isOversized && !isOversizedInViewport ? (
|
||||
<Box
|
||||
sx={{
|
||||
// Reserve the height this message had when last rendered full (cached across unmount) so the box doesn't collapse and the scrollbar stays put. Falls back to a content-aware estimate until it's been measured.
|
||||
minHeight: oversizedContentHeights.get(message.id)
|
||||
|| placeholderFallbackHeight
|
||||
|| Math.min(420, Math.max(180, viewportHeight || 240)),
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : isOversized ? (
|
||||
// In view, but virtualize WITHIN the message: only blocks near the viewport render, the rest are reserved-height placeholders, so a huge pasted message never mounts more than the on-screen portion plus a buffer.
|
||||
<WindowedPlainText
|
||||
messageId={message.id}
|
||||
text={displayText}
|
||||
scrollRoot={scrollRoot}
|
||||
viewportHeight={viewportHeight}
|
||||
viewportWidth={viewportWidth}
|
||||
/>
|
||||
) : (
|
||||
<Typography sx={{ color: c.text.primary, fontSize: '0.875rem', lineHeight: 1.6, overflowWrap: 'anywhere', wordBreak: 'break-word', whiteSpace: 'pre-wrap' }}>
|
||||
{renderUserTextWithPills(displayText, c)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<AttachedContextSection elements={selectedElements} message={message} c={c} />
|
||||
</Box>
|
||||
)
|
||||
@@ -1311,7 +1300,7 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
|
||||
Re-parse is memoized on the (smoothed) text and cheap at chat sizes.
|
||||
While streaming, useSmoothText appends pending chars into the reveal
|
||||
subtree between parses, so the re-parse runs per commit, not per frame. */}
|
||||
{isOversizedAssistant && !isOversizedInViewport ? (
|
||||
{isOversized && !isOversizedInViewport ? (
|
||||
<Box
|
||||
sx={{
|
||||
// Reserve the height this message had when last rendered full (cached across unmount) so the box doesn't collapse and the scrollbar stays put. Falls back to a content-aware estimate (matched to the spacer estimate) until it's been measured.
|
||||
@@ -1323,7 +1312,7 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : isOversizedAssistant ? (
|
||||
) : isOversized ? (
|
||||
// In view, but virtualize WITHIN the message: only blocks near the viewport render their markdown, the rest are reserved-height placeholders, so an extremely long message never parses/mounts more than the on-screen portion plus a buffer.
|
||||
<WindowedMarkdown
|
||||
messageId={message.id}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { estimateRenderedTextHeight, RECHECK_VISIBILITY_EVENT } from './markdown
|
||||
|
||||
// Intra-message virtualization for very long assistant messages. The text is split into FIXED blocks (each block always covers the same character range), so unlike the old growing-tail chunking nothing shifts as you scroll, and no scroll correction is needed. Only blocks within a screen of the viewport actually render their markdown; the rest are height-reserved placeholders, so an extremely long message never parses or mounts more than the on-screen portion plus a buffer.
|
||||
|
||||
const BLOCK_TARGET_CHARS = 4_000;
|
||||
export const BLOCK_TARGET_CHARS = 4_000;
|
||||
// Remembered measured height per block (`${messageId}#${index}`). Module-scoped so it survives the block unmounting/remounting as you scroll, keeping the reserved placeholder heights (and thus scroll position) stable.
|
||||
const blockHeights = new Map<string, number>();
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { estimateRenderedTextHeight, RECHECK_VISIBILITY_EVENT } from './markdownMeasure';
|
||||
import { renderUserTextWithPills } from './renderUserTextWithPills';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { BLOCK_TARGET_CHARS } from './WindowedMarkdown';
|
||||
|
||||
// Intra-message virtualization for very long user (plain-text) messages, mirroring WindowedMarkdown's approach minus fence-awareness (plain text has no code fences to protect).
|
||||
|
||||
const plainBlockHeights = new Map<string, number>();
|
||||
|
||||
// Split at blank lines so each block is a self-contained chunk of lines. Blocks grow to ~targetChars then break at the next blank-line boundary.
|
||||
// Plain-text pastes (logs, book/PDF-extracted text) often have no blank lines at all, so a hard ceiling force-cuts at 2x target to guarantee progress.
|
||||
function splitPlainTextIntoBlocks(text: string, targetChars: number): string[] {
|
||||
if (text.length <= targetChars) return [text];
|
||||
const lines = text.split('\n');
|
||||
const blocks: string[] = [];
|
||||
let cur: string[] = [];
|
||||
let curLen = 0;
|
||||
for (const line of lines) {
|
||||
cur.push(line);
|
||||
curLen += line.length + 1;
|
||||
if (curLen >= targetChars && (line.trim() === '' || curLen >= targetChars * 2)) {
|
||||
blocks.push(cur.join('\n'));
|
||||
cur = [];
|
||||
curLen = 0;
|
||||
}
|
||||
}
|
||||
if (cur.length) blocks.push(cur.join('\n'));
|
||||
return blocks.length ? blocks : [text];
|
||||
}
|
||||
|
||||
const PlainTextBlock: React.FC<{
|
||||
blockId: string;
|
||||
text: string;
|
||||
scrollRoot: Element | null;
|
||||
viewportHeight: number;
|
||||
viewportWidth: number;
|
||||
}> = React.memo(({ blockId, text, scrollRoot, viewportHeight, viewportWidth }) => {
|
||||
const c = useClaudeTokens();
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const [inView, setInView] = useState(false);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const node = ref.current;
|
||||
if (!node) return;
|
||||
const bufferPx = Math.max(180, Math.round(viewportHeight || 240));
|
||||
// Resolve visibility synchronously (on mount and on demand) so an on-screen block paints its text without waiting on the observer's async callback.
|
||||
const rootEl: Element = (scrollRoot as Element) ?? document.scrollingElement ?? document.documentElement;
|
||||
const evaluate = () => {
|
||||
const rootRect = rootEl.getBoundingClientRect();
|
||||
const nodeRect = node.getBoundingClientRect();
|
||||
setInView(nodeRect.bottom >= rootRect.top - bufferPx && nodeRect.top <= rootRect.bottom + bufferPx);
|
||||
};
|
||||
evaluate();
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) setInView(entry.isIntersecting);
|
||||
}, {
|
||||
root: scrollRoot ?? null,
|
||||
rootMargin: `${bufferPx}px 0px ${bufferPx}px 0px`,
|
||||
threshold: 0,
|
||||
});
|
||||
observer.observe(node);
|
||||
// Re-evaluate when a programmatic jump settles (see RECHECK_VISIBILITY_EVENT).
|
||||
scrollRoot?.addEventListener(RECHECK_VISIBILITY_EVENT, evaluate);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
scrollRoot?.removeEventListener(RECHECK_VISIBILITY_EVENT, evaluate);
|
||||
};
|
||||
}, [scrollRoot, viewportHeight]);
|
||||
|
||||
// Remember the rendered height so the placeholder reserves exactly that space.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!inView) return;
|
||||
const node = ref.current;
|
||||
if (!node) return;
|
||||
const h = node.offsetHeight;
|
||||
if (h > 0) plainBlockHeights.set(blockId, h);
|
||||
}, [inView, blockId, text]);
|
||||
|
||||
// chrome=8: block wrappers have minimal padding vs a full bubble.
|
||||
const reserved = plainBlockHeights.get(blockId) ?? estimateRenderedTextHeight(text, viewportWidth, 8);
|
||||
|
||||
return (
|
||||
<Box ref={ref}>
|
||||
{inView ? (
|
||||
<Typography sx={{ color: c.text.primary, fontSize: '0.875rem', lineHeight: 1.6, overflowWrap: 'anywhere', wordBreak: 'break-word', whiteSpace: 'pre-wrap' }}>
|
||||
{renderUserTextWithPills(text, c)}
|
||||
</Typography>
|
||||
) : (
|
||||
<Box aria-hidden sx={{ height: reserved }} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
const WindowedPlainText: React.FC<{
|
||||
messageId: string;
|
||||
text: string;
|
||||
scrollRoot: Element | null;
|
||||
viewportHeight: number;
|
||||
viewportWidth: number;
|
||||
}> = ({ messageId, text, scrollRoot, viewportHeight, viewportWidth }) => {
|
||||
const c = useClaudeTokens();
|
||||
const blocks = useMemo(() => splitPlainTextIntoBlocks(text, BLOCK_TARGET_CHARS), [text]);
|
||||
if (blocks.length === 1) {
|
||||
// Short enough that virtualizing would only add overhead.
|
||||
return (
|
||||
<Typography sx={{ color: c.text.primary, fontSize: '0.875rem', lineHeight: 1.6, overflowWrap: 'anywhere', wordBreak: 'break-word', whiteSpace: 'pre-wrap' }}>
|
||||
{renderUserTextWithPills(text, c)}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{blocks.map((block, i) => (
|
||||
<PlainTextBlock
|
||||
key={i}
|
||||
blockId={`${messageId}#${i}`}
|
||||
text={block}
|
||||
scrollRoot={scrollRoot}
|
||||
viewportHeight={viewportHeight}
|
||||
viewportWidth={viewportWidth}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WindowedPlainText;
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import PsychologyOutlinedIcon from '@mui/icons-material/PsychologyOutlined';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { SKILL_COLOR } from '@/app/components/editor/richEditorUtils';
|
||||
|
||||
const SKILL_PILL_RE = /\{\{skill:([^}]+)\}\}/g;
|
||||
|
||||
export function renderUserTextWithPills(text: string, c: ReturnType<typeof useClaudeTokens>): React.ReactNode[] {
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
const re = new RegExp(SKILL_PILL_RE.source, 'g');
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
const skillName = match[1];
|
||||
parts.push(
|
||||
<Chip
|
||||
key={`skill-${match.index}`}
|
||||
icon={<PsychologyOutlinedIcon sx={{ fontSize: 12 }} />}
|
||||
label={skillName}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: `${SKILL_COLOR}18`,
|
||||
color: SKILL_COLOR,
|
||||
fontSize: '0.72rem',
|
||||
fontFamily: c.font.mono,
|
||||
height: 20,
|
||||
mx: 0.25,
|
||||
verticalAlign: 'baseline',
|
||||
'& .MuiChip-icon': { color: SKILL_COLOR },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
lastIndex = re.lastIndex;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
Reference in New Issue
Block a user