mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 00:48:10 +00:00
fix(chat): scroll-to-latest no longer cancels smooth scroll to bottom (#1112)
This commit is contained in:
@@ -81,11 +81,20 @@ export function useStickToBottomInstant(resetKey?: string, active = false) {
|
||||
|
||||
if (!element) return;
|
||||
|
||||
let lastScrollTop = element.scrollTop;
|
||||
const handleScroll = () => {
|
||||
const distanceFromBottom = element.scrollHeight - element.clientHeight - element.scrollTop;
|
||||
if (distanceFromBottom > ESCAPE_FROM_LOCK_OFFSET_PX) {
|
||||
// Only relevant while a run is actively pinning to the bottom.
|
||||
if (!activeRef.current) return;
|
||||
|
||||
const scrollTop = element.scrollTop;
|
||||
const distanceFromBottom = element.scrollHeight - element.clientHeight - scrollTop;
|
||||
// Match the library's escape semantics: upward manual scroll only.
|
||||
// Calling stopScroll while scrolling *down* (e.g. scroll-to-latest smooth
|
||||
// animation) would cancel the animation before it reaches the bottom.
|
||||
if (distanceFromBottom > ESCAPE_FROM_LOCK_OFFSET_PX && scrollTop < lastScrollTop) {
|
||||
stopScroll();
|
||||
}
|
||||
lastScrollTop = scrollTop;
|
||||
};
|
||||
element.addEventListener("scroll", handleScroll, { passive: true });
|
||||
scrollEscapeCleanupRef.current = () => element.removeEventListener("scroll", handleScroll);
|
||||
|
||||
+37
-10
@@ -18,7 +18,7 @@ import { ChatMessage } from './ChatMessage';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { ExecutionGraphCard } from './ExecutionGraphCard';
|
||||
import { ChatToolbar } from './ChatToolbar';
|
||||
import { extractImages, extractText, extractThinking, extractToolUse, isInternalAssistantReplyText, isInternalProcessNarration, normalizeMessageRole, stripProcessMessagePrefix } from './message-utils';
|
||||
import { extractImages, extractText, extractThinking, extractToolUse, isInternalAssistantReplyText, isInternalProcessNarration, normalizeMessageRole, sanitizeAssistantReplyText, stripProcessMessagePrefix } from './message-utils';
|
||||
import {
|
||||
buildRunSegmentMessageIndices,
|
||||
deriveRuntimeTaskSteps,
|
||||
@@ -576,7 +576,9 @@ export function Chat() {
|
||||
: sanitizeGraphSteps(buildSteps(rawStreamingReplyCandidate));
|
||||
let streamingReplyText: string | null = null;
|
||||
if (rawStreamingReplyCandidate) {
|
||||
const trimmedReplyText = stripProcessMessagePrefix(streamText, getPrimaryMessageStepTexts(steps));
|
||||
const trimmedReplyText = sanitizeAssistantReplyText(
|
||||
stripProcessMessagePrefix(streamText, getPrimaryMessageStepTexts(steps)),
|
||||
);
|
||||
const hasReplyText = trimmedReplyText.trim().length > 0
|
||||
&& !isInternalAssistantReplyText(trimmedReplyText);
|
||||
if (hasReplyText || hasStreamImages) {
|
||||
@@ -775,7 +777,9 @@ export function Chat() {
|
||||
const replyMessage = messages[card.replyIndex];
|
||||
if (!replyMessage || replyMessage.role !== 'assistant') continue;
|
||||
const fullReplyText = extractText(replyMessage);
|
||||
const trimmedReplyText = stripProcessMessagePrefix(fullReplyText, card.messageStepTexts);
|
||||
const trimmedReplyText = sanitizeAssistantReplyText(
|
||||
stripProcessMessagePrefix(fullReplyText, card.messageStepTexts),
|
||||
);
|
||||
if (trimmedReplyText !== fullReplyText) {
|
||||
map.set(card.replyIndex, trimmedReplyText);
|
||||
}
|
||||
@@ -1203,14 +1207,37 @@ export function Chat() {
|
||||
function QuestionDirectory({ items }: { items: QuestionDirectoryItem[] }) {
|
||||
const { t } = useTranslation('chat');
|
||||
const scrollRef = useRef<HTMLElement | null>(null);
|
||||
const visibleItems = items.slice(0, QUESTION_DIRECTORY_RENDER_LIMIT);
|
||||
const visibleItems =
|
||||
items.length > QUESTION_DIRECTORY_RENDER_LIMIT
|
||||
? items.slice(-QUESTION_DIRECTORY_RENDER_LIMIT)
|
||||
: items;
|
||||
const hiddenCount = Math.max(0, items.length - visibleItems.length);
|
||||
const lastItemKey = visibleItems.at(-1)?.index ?? -1;
|
||||
|
||||
useEffect(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!scrollEl) return;
|
||||
scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||
}, [visibleItems.length]);
|
||||
|
||||
const scrollToEnd = () => {
|
||||
scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||
};
|
||||
|
||||
scrollToEnd();
|
||||
const frame = requestAnimationFrame(() => {
|
||||
requestAnimationFrame(scrollToEnd);
|
||||
});
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(scrollToEnd);
|
||||
observer.observe(scrollEl);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [lastItemKey, visibleItems.length]);
|
||||
|
||||
const handleJumpToMessage = (index: number) => {
|
||||
document.getElementById(`chat-message-${index}`)?.scrollIntoView({
|
||||
@@ -1222,11 +1249,11 @@ function QuestionDirectory({ items }: { items: QuestionDirectoryItem[] }) {
|
||||
return (
|
||||
<aside
|
||||
data-testid="chat-question-directory"
|
||||
className="w-full shrink-0 lg:w-64 xl:w-72"
|
||||
className="flex min-h-0 w-full shrink-0 self-stretch lg:w-64 xl:w-72"
|
||||
aria-label={t('questionDirectory.title')}
|
||||
>
|
||||
<div className="sticky top-2 max-h-full overflow-hidden rounded-2xl border border-black/5 bg-black/[0.02] p-3 shadow-sm dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div className="mb-2 flex items-center justify-between gap-2 px-1">
|
||||
<div className="sticky top-2 flex min-h-0 w-full flex-1 flex-col rounded-2xl border border-black/5 bg-black/[0.02] p-3 shadow-sm dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div className="mb-2 flex shrink-0 items-center justify-between gap-2 px-1">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('questionDirectory.title')}
|
||||
</h2>
|
||||
@@ -1234,7 +1261,7 @@ function QuestionDirectory({ items }: { items: QuestionDirectoryItem[] }) {
|
||||
{items.length}
|
||||
</span>
|
||||
</div>
|
||||
<nav ref={scrollRef} className="max-h-[calc(100vh-13rem)] space-y-1 overflow-y-auto pr-1">
|
||||
<nav ref={scrollRef} className="min-h-0 flex-1 space-y-1 overflow-y-auto overscroll-contain pr-1">
|
||||
{visibleItems.map((item) => (
|
||||
<button
|
||||
key={item.index}
|
||||
|
||||
@@ -77,12 +77,14 @@ function stripAssistantMediaTags(text: string): string {
|
||||
// are also stripped from the visible bubble. Without this, the bubble
|
||||
// would still leak the literal `MEDIA:/.../截屏 2026-05-06 17.46.51.png`
|
||||
// to the user when the underlying path detection succeeds.
|
||||
const tagged = new RegExp(`(^|[\\s(\\[{>])(?:MEDIA|media):(?:\\/|~\\/|[A-Za-z]:\\\\)[^\\n"'()\\[\\],<>]*?\\.(?:${exts})(?=$|[\\s\\n"'()\\[\\],<>]|[,。;;,.!?])`, 'g');
|
||||
// Allow MEDIA: after punctuation/emojis (e.g. `必备~MEDIA:/path`) — only reject
|
||||
// when immediately preceded by word/path characters so `someMEDIA:` stays literal.
|
||||
const tagged = new RegExp(`(?<![A-Za-z0-9/\\\\])(?:MEDIA|media):(?:\\/|~\\/|[A-Za-z]:\\\\)[^\\n"'()\\[\\],<>]*?\\.(?:${exts})(?=$|[\\s\\n"'()\\[\\],<>]|[,。;;,.!?])`, 'g');
|
||||
// Bare OpenClaw artifact paths emitted alongside `_attachedFiles` cards.
|
||||
// Scope to `.openclaw/media/` so normal absolute paths in prose stay visible.
|
||||
const bareOpenClawMedia = new RegExp(`(^|[\\s(\\[{>])(?:(?:\\/|~\\/|[A-Za-z]:\\\\)[^\\n"'()\\[\\],<>]*?\\.openclaw[\\\\/]media[\\\\/][^\\n"'()\\[\\],<>]*?\\.(?:${exts}))(?=$|[\\s\\n"'()\\[\\],<>]|[,。;;,.!?])`, 'g');
|
||||
return text
|
||||
.replace(tagged, (_, lead: string) => lead)
|
||||
.replace(tagged, '')
|
||||
.replace(bareOpenClawMedia, (_, lead: string) => lead)
|
||||
.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
|
||||
// Collapse the empty lines / orphan whitespace the strip leaves behind.
|
||||
@@ -213,6 +215,40 @@ export function isOpenClawRuntimeEventPrompt(text: string): boolean {
|
||||
return trimmed.split(/\n+/).some((line) => /^Continue the OpenClaw runtime event\.?\s*$/i.test(line.trim()));
|
||||
}
|
||||
|
||||
/** Model-side planning about message-tool vs MEDIA delivery — never user-facing. */
|
||||
function isInternalDeliveryPlanningParagraph(text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/message tool isn't suitable/i.test(trimmed)) return true;
|
||||
if (/visible-reply contract/i.test(trimmed)) return true;
|
||||
if (/final-reply MEDIA lines/i.test(trimmed)) return true;
|
||||
if (/writing the normal final reply with MEDIA directives/i.test(trimmed)) return true;
|
||||
if (/webchat isn't a valid channel for the message tool/i.test(trimmed)) return true;
|
||||
if (/fall back to writing the normal final reply/i.test(trimmed)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Remove internal delivery-planning paragraphs the model sometimes prepends to replies. */
|
||||
export function stripInternalDeliveryPlanning(text: string): string {
|
||||
if (!text) return text;
|
||||
const paragraphs = text.split(/\n{2,}/);
|
||||
const kept = paragraphs.filter((paragraph) => !isInternalDeliveryPlanningParagraph(paragraph));
|
||||
if (kept.length === paragraphs.length) return text;
|
||||
return kept
|
||||
.join('\n\n')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Normalize assistant reply text for chat bubbles and stream overrides. */
|
||||
export function sanitizeAssistantReplyText(text: string): string {
|
||||
if (!text) return text;
|
||||
return stripInternalSentinelLines(
|
||||
stripAssistantMediaTags(stripInternalDeliveryPlanning(text)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Process narration that should never appear in the execution graph or chat stream. */
|
||||
export function isInternalProcessNarration(text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
@@ -220,6 +256,7 @@ export function isInternalProcessNarration(text: string): boolean {
|
||||
if (isInternalAssistantReplyText(trimmed)) return true;
|
||||
if (isGeneratingStatusNarration(trimmed)) return true;
|
||||
if (isOpenClawRuntimeEventPrompt(trimmed)) return true;
|
||||
if (isInternalDeliveryPlanningParagraph(trimmed)) return true;
|
||||
if (/^\[Inter-session message\]/i.test(trimmed)) return true;
|
||||
if (/OpenClaw runtime event/i.test(trimmed)) return true;
|
||||
if (/Handle the result internally/i.test(trimmed) && /Do not relay it to the user/i.test(trimmed)) {
|
||||
@@ -278,14 +315,9 @@ export function extractText(message: RawMessage | unknown): string {
|
||||
} else if (!isUser && result) {
|
||||
if (isInternalAssistantReplyText(result)) return '';
|
||||
if (isGeneratingStatusNarration(result)) return '';
|
||||
// Assistant-side cleanup: keep the bubble free of `MEDIA:/path` tags
|
||||
// that the runtime emits to point at produced artifacts. The same
|
||||
// path is surfaced as a clickable file card via `_attachedFiles`,
|
||||
// so leaving it inline would duplicate the artifact.
|
||||
result = stripAssistantMediaTags(result);
|
||||
// Drop a trailing `NO_REPLY` / `HEARTBEAT_OK` the model may append after
|
||||
// an otherwise-real answer.
|
||||
result = stripInternalSentinelLines(result);
|
||||
// Assistant-side cleanup: drop internal delivery planning, `MEDIA:/path`
|
||||
// tags, and trailing sentinels. Artifact paths surface via `_attachedFiles`.
|
||||
result = sanitizeAssistantReplyText(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
+1
-1
@@ -1089,7 +1089,7 @@ function extractRawFilePaths(text: string): Array<{ filePath: string; mimeType:
|
||||
// and other space-containing paths the agent emits with the explicit
|
||||
// `MEDIA:` marker still resolve. Newline and quote characters remain
|
||||
// path terminators so we don't accidentally swallow trailing prose.
|
||||
const taggedRegex = new RegExp(`(?:^|[\\s(\\[{>])(?:MEDIA|media):((?:\\/|~\\/|[A-Za-z]:\\\\)[^\\n"'()\\[\\],<>` + '`' + `]*?\\.(?:${exts}))(?=$|[\\s\\n"'()\\[\\],<>` + '`' + `]|[,。;;,.!?])`, 'g');
|
||||
const taggedRegex = new RegExp(`(?<![A-Za-z0-9/\\\\])(?:MEDIA|media):((?:\\/|~\\/|[A-Za-z]:\\\\)[^\\n"'()\\[\\],<>` + '`' + `]*?\\.(?:${exts}))(?=$|[\\s\\n"'()\\[\\],<>` + '`' + `]|[,。;;,.!?])`, 'g');
|
||||
let workingText = text;
|
||||
let taggedMatch: RegExpExecArray | null;
|
||||
while ((taggedMatch = taggedRegex.exec(text)) !== null) {
|
||||
|
||||
@@ -789,7 +789,7 @@ function extractRawFilePaths(text: string): Array<{ filePath: string; mimeType:
|
||||
// path terminators so we don't accidentally swallow trailing prose.
|
||||
// The non-greedy `*?` anchored to `\.<ext>` keeps the match minimal so
|
||||
// multiple `MEDIA:` markers in one paragraph still match independently.
|
||||
const taggedRegex = new RegExp(`(?:^|[\\s(\\[{>])(?:MEDIA|media):((?:\\/|~\\/|[A-Za-z]:\\\\)[^\\n"'()\\[\\],<>` + '`' + `]*?\\.(?:${exts}))(?=$|[\\s\\n"'()\\[\\],<>` + '`' + `]|[,。;;,.!?])`, 'g');
|
||||
const taggedRegex = new RegExp(`(?<![A-Za-z0-9/\\\\])(?:MEDIA|media):((?:\\/|~\\/|[A-Za-z]:\\\\)[^\\n"'()\\[\\],<>` + '`' + `]*?\\.(?:${exts}))(?=$|[\\s\\n"'()\\[\\],<>` + '`' + `]|[,。;;,.!?])`, 'g');
|
||||
let workingText = text;
|
||||
let taggedMatch: RegExpExecArray | null;
|
||||
while ((taggedMatch = taggedRegex.exec(text)) !== null) {
|
||||
|
||||
@@ -28,51 +28,69 @@ const seededHistory = [
|
||||
{ role: 'assistant', content: 'Here is the final action plan.', timestamp: 1007 },
|
||||
];
|
||||
|
||||
const latestQuestion = '给我生成一只哈密瓜';
|
||||
|
||||
const longQuestionDirectoryHistory = [
|
||||
...Array.from({ length: 14 }, (_, idx) => ([
|
||||
{ role: 'user', content: `Question ${idx + 1}: generate an image.`, timestamp: 2000 + idx * 2 },
|
||||
{ role: 'assistant', content: `Answer ${idx + 1}.`, timestamp: 2001 + idx * 2 },
|
||||
])).flat(),
|
||||
{ role: 'user', content: latestQuestion, timestamp: 3000 },
|
||||
{ role: 'assistant', content: 'Here is the cantaloupe image.', timestamp: 3001 },
|
||||
];
|
||||
|
||||
async function installQuestionDirectoryMocks(
|
||||
app: Awaited<ReturnType<typeof import('./fixtures/electron').launchElectronApp>>,
|
||||
messages: Array<{ role: string; content: string; timestamp: number }>,
|
||||
) {
|
||||
await installIpcMocks(app, {
|
||||
gatewayStatus: { state: 'running', port: 18789, pid: 12345 },
|
||||
gatewayRpc: {
|
||||
[stableStringify(['sessions.list', {}])]: {
|
||||
success: true,
|
||||
result: {
|
||||
sessions: [{ key: SESSION_KEY, displayName: 'main' }],
|
||||
},
|
||||
},
|
||||
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
|
||||
success: true,
|
||||
result: { messages },
|
||||
},
|
||||
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000, maxChars: 500000 }])]: {
|
||||
success: true,
|
||||
result: { messages },
|
||||
},
|
||||
},
|
||||
hostApi: {
|
||||
[stableStringify(['/api/gateway/status', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: { state: 'running', port: 18789, pid: 12345 },
|
||||
},
|
||||
},
|
||||
[stableStringify(['/api/agents', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: {
|
||||
success: true,
|
||||
agents: [{ id: 'main', name: 'main' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('ClawX chat question directory', () => {
|
||||
test('shows a toolbar button that opens a clickable in-conversation question directory', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
|
||||
try {
|
||||
await installIpcMocks(app, {
|
||||
gatewayStatus: { state: 'running', port: 18789, pid: 12345 },
|
||||
gatewayRpc: {
|
||||
[stableStringify(['sessions.list', {}])]: {
|
||||
success: true,
|
||||
result: {
|
||||
sessions: [{ key: SESSION_KEY, displayName: 'main' }],
|
||||
},
|
||||
},
|
||||
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
|
||||
success: true,
|
||||
result: { messages: seededHistory },
|
||||
},
|
||||
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000, maxChars: 500000 }])]: {
|
||||
success: true,
|
||||
result: { messages: seededHistory },
|
||||
},
|
||||
},
|
||||
hostApi: {
|
||||
[stableStringify(['/api/gateway/status', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: { state: 'running', port: 18789, pid: 12345 },
|
||||
},
|
||||
},
|
||||
[stableStringify(['/api/agents', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: {
|
||||
success: true,
|
||||
agents: [{ id: 'main', name: 'main' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await installQuestionDirectoryMocks(app, seededHistory);
|
||||
|
||||
const page = await getStableWindow(app);
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
@@ -103,4 +121,36 @@ test.describe('ClawX chat question directory', () => {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('scrolls the question directory to show the latest question', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
|
||||
try {
|
||||
await installQuestionDirectoryMocks(app, longQuestionDirectoryHistory);
|
||||
|
||||
const page = await getStableWindow(app);
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
try {
|
||||
await page.reload();
|
||||
} catch (error) {
|
||||
if (!String(error).includes('ERR_FILE_NOT_FOUND')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('main-layout')).toBeVisible();
|
||||
await page.getByTestId('chat-question-directory-toggle').click();
|
||||
|
||||
const directory = page.getByTestId('chat-question-directory');
|
||||
await expect(directory).toBeVisible({ timeout: 30_000 });
|
||||
await expect(directory).toContainText('15');
|
||||
|
||||
const lastItem = page.getByTestId(`chat-question-directory-item-${longQuestionDirectoryHistory.length - 2}`);
|
||||
await expect(lastItem).toBeVisible();
|
||||
await expect(lastItem).toContainText(latestQuestion);
|
||||
await expect(lastItem).toBeInViewport();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractText } from '@/pages/Chat/message-utils';
|
||||
import { extractText, sanitizeAssistantReplyText } from '@/pages/Chat/message-utils';
|
||||
|
||||
describe('assistant media path display cleanup', () => {
|
||||
it('strips bare OpenClaw media paths when the image is shown as an attachment card', () => {
|
||||
@@ -37,4 +37,22 @@ C:\Users\alice\.openclaw\media\outbound\cat---abc.png`;
|
||||
|
||||
expect(extractText({ role: 'assistant', content: text })).toBe('宇航员图片完成啦 🧑🚀✨');
|
||||
});
|
||||
|
||||
it('strips internal delivery-planning narration before the user-facing caption', () => {
|
||||
const text = [
|
||||
"The message tool isn't suitable here since I'm in a webchat session with no proper routing target. The runtime context says 'Use the current visible-reply contract... Otherwise, write the normal final reply and attach every generated media path with final-reply MEDIA lines.' Since webchat isn't a valid channel for the message tool, I should fall back to writing the normal final reply with MEDIA directives.",
|
||||
'西瓜切片来了 🍉 红透多汁,夏日续命必备~',
|
||||
'MEDIA:/Users/zhonghaolu/.openclaw/media/tool-image-generation/clawx-image-1---4d2c1ef7-0d16-451c-9c09-9b58c4c99846.png',
|
||||
].join('\n\n');
|
||||
|
||||
expect(extractText({ role: 'assistant', content: text })).toBe('西瓜切片来了 🍉 红透多汁,夏日续命必备~');
|
||||
expect(sanitizeAssistantReplyText(text)).toBe('西瓜切片来了 🍉 红透多汁,夏日续命必备~');
|
||||
});
|
||||
|
||||
it('strips inline MEDIA: markers glued to caption punctuation', () => {
|
||||
const text =
|
||||
'橘子来了 🍊 一筐新鲜砂糖橘,酸甜爆汁~MEDIA:/Users/zhonghaolu/.openclaw/media/tool-image-generation/clawx-image-1---03c6fed9-836c-49ed-87df-09969d5d6fe1.png';
|
||||
|
||||
expect(extractText({ role: 'assistant', content: text })).toBe('橘子来了 🍊 一筐新鲜砂糖橘,酸甜爆汁~');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,6 +84,18 @@ MEDIA:C:\Users\Administrator\.openclaw\workspace\japan-kansai-4d3n-plan.svg`);
|
||||
]);
|
||||
});
|
||||
|
||||
it('captures inline MEDIA: markers glued to caption punctuation', () => {
|
||||
const sample =
|
||||
'橘子来了 🍊 一筐新鲜砂糖橘,酸甜爆汁~MEDIA:/Users/zhonghaolu/.openclaw/media/tool-image-generation/clawx-image-1---03c6fed9-836c-49ed-87df-09969d5d6fe1.png';
|
||||
const refs = extractRawFilePaths(sample);
|
||||
expect(refs).toEqual([
|
||||
{
|
||||
filePath: '/Users/zhonghaolu/.openclaw/media/tool-image-generation/clawx-image-1---03c6fed9-836c-49ed-87df-09969d5d6fe1.png',
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps non-MEDIA prose after a space-bearing path out of the captured filename', () => {
|
||||
// The lookahead must terminate the match at the first non-path character
|
||||
// (newline, quote, paren, comma, full-stop, ...). Otherwise a long line
|
||||
|
||||
@@ -119,4 +119,32 @@ describe('Chat question directory', () => {
|
||||
expect(directory).toBeInTheDocument();
|
||||
expect(directory.querySelectorAll('button')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('includes the latest question in the directory list', async () => {
|
||||
const latestQuestion = '给我生成一只哈密瓜';
|
||||
const originalMessages = chatState.messages;
|
||||
chatState.messages = [
|
||||
...Array.from({ length: 13 }, (_, idx) => ([
|
||||
{ role: 'user', content: `question ${idx + 1}` },
|
||||
{ role: 'assistant', content: `reply ${idx + 1}` },
|
||||
])).flat(),
|
||||
{ role: 'user', content: latestQuestion },
|
||||
{ role: 'assistant', content: 'generated image' },
|
||||
];
|
||||
|
||||
try {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<Chat />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByTestId('chat-question-directory-toggle'));
|
||||
|
||||
const lastUserIndex = chatState.messages.length - 2;
|
||||
expect(screen.getByTestId(`chat-question-directory-item-${lastUserIndex}`)).toHaveTextContent(latestQuestion);
|
||||
} finally {
|
||||
chatState.messages = originalMessages;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockStopScroll = vi.fn();
|
||||
const mockScrollToBottom = vi.fn();
|
||||
let mockEscapedFromLock = false;
|
||||
|
||||
vi.mock('use-stick-to-bottom', () => ({
|
||||
useStickToBottom: () => ({
|
||||
contentRef: vi.fn(),
|
||||
scrollRef: vi.fn(),
|
||||
scrollToBottom: mockScrollToBottom,
|
||||
stopScroll: mockStopScroll,
|
||||
isAtBottom: false,
|
||||
isNearBottom: false,
|
||||
escapedFromLock: mockEscapedFromLock,
|
||||
state: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('useStickToBottomInstant', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockEscapedFromLock = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does not call stopScroll while scrolling down toward the bottom during an active run', async () => {
|
||||
const { useStickToBottomInstant } = await import('@/hooks/use-stick-to-bottom-instant');
|
||||
|
||||
let scrollElement: HTMLDivElement | null = null;
|
||||
const { result, rerender } = renderHook(
|
||||
({ active }) => useStickToBottomInstant('session-1', active),
|
||||
{ initialProps: { active: true } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
scrollElement = document.createElement('div');
|
||||
Object.defineProperty(scrollElement, 'scrollHeight', { value: 2000, configurable: true });
|
||||
Object.defineProperty(scrollElement, 'clientHeight', { value: 400, configurable: true });
|
||||
scrollElement.scrollTop = 0;
|
||||
result.current.scrollRef(scrollElement);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
scrollElement!.scrollTop = 500;
|
||||
scrollElement!.dispatchEvent(new Event('scroll'));
|
||||
});
|
||||
|
||||
expect(mockStopScroll).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ active: false });
|
||||
|
||||
act(() => {
|
||||
scrollElement!.scrollTop = 0;
|
||||
scrollElement!.dispatchEvent(new Event('scroll'));
|
||||
});
|
||||
|
||||
expect(mockStopScroll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls stopScroll when the user scrolls up away from the bottom during an active run', async () => {
|
||||
const { useStickToBottomInstant } = await import('@/hooks/use-stick-to-bottom-instant');
|
||||
|
||||
let scrollElement: HTMLDivElement | null = null;
|
||||
const { result } = renderHook(() => useStickToBottomInstant('session-1', true));
|
||||
|
||||
act(() => {
|
||||
scrollElement = document.createElement('div');
|
||||
Object.defineProperty(scrollElement, 'scrollHeight', { value: 2000, configurable: true });
|
||||
Object.defineProperty(scrollElement, 'clientHeight', { value: 400, configurable: true });
|
||||
scrollElement.scrollTop = 1500;
|
||||
result.current.scrollRef(scrollElement);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
scrollElement!.scrollTop = 1500;
|
||||
scrollElement!.dispatchEvent(new Event('scroll'));
|
||||
});
|
||||
expect(mockStopScroll).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
scrollElement!.scrollTop = 1000;
|
||||
scrollElement!.dispatchEvent(new Event('scroll'));
|
||||
});
|
||||
|
||||
expect(mockStopScroll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user