mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8d6ef17ee | ||
|
|
1d5ad04d3c | ||
|
|
6a951f2fc6 | ||
|
|
58189f45f6 |
No files matched your search
@@ -443,6 +443,8 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.newChat': 'New Chat',
|
||||
'ai.chat.allSessions': 'All Sessions',
|
||||
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
|
||||
'ai.chat.jumpNav': 'Jump to message',
|
||||
'ai.chat.jumpUntitled': '(empty message)',
|
||||
'ai.chat.usedTools': 'Tools used: {n}',
|
||||
'ai.chat.loadMoreSessions': 'Load more sessions ({n} more)',
|
||||
'ai.chat.noSessions': 'No previous sessions',
|
||||
|
||||
@@ -395,6 +395,8 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.newChat': 'Новый чат',
|
||||
'ai.chat.allSessions': 'Все сессии',
|
||||
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
|
||||
'ai.chat.jumpNav': 'Перейти к сообщению',
|
||||
'ai.chat.jumpUntitled': '(пустое сообщение)',
|
||||
'ai.chat.usedTools': 'Использовано инструментов: {n}',
|
||||
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
|
||||
'ai.chat.noSessions': 'Предыдущих сессий нет',
|
||||
|
||||
@@ -443,6 +443,8 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.newChat': '新对话',
|
||||
'ai.chat.allSessions': '所有会话',
|
||||
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
|
||||
'ai.chat.jumpNav': '跳转到消息',
|
||||
'ai.chat.jumpUntitled': '(空消息)',
|
||||
'ai.chat.usedTools': '已使用 {n} 个工具',
|
||||
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
|
||||
'ai.chat.noSessions': '没有历史会话',
|
||||
|
||||
@@ -442,6 +442,8 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.newChat': '新對話',
|
||||
'ai.chat.allSessions': '所有工作階段',
|
||||
'ai.chat.loadEarlierMessages': '載入更早的訊息(還有 {n} 條)',
|
||||
'ai.chat.jumpNav': '跳轉到訊息',
|
||||
'ai.chat.jumpUntitled': '(空訊息)',
|
||||
'ai.chat.usedTools': '已使用 {n} 個工具',
|
||||
'ai.chat.loadMoreSessions': '載入更多工作階段(還有 {n} 條)',
|
||||
'ai.chat.noSessions': '沒有歷史工作階段',
|
||||
|
||||
@@ -25,12 +25,17 @@ export const ConversationContent = ({ className, ...props }: ConversationContent
|
||||
/>
|
||||
);
|
||||
|
||||
export const ConversationScrollButton = ({ className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||
export const ConversationScrollButton = ({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event);
|
||||
scrollToBottom();
|
||||
}, [scrollToBottom]);
|
||||
}, [onClick, scrollToBottom]);
|
||||
|
||||
if (isAtBottom) return null;
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Floating jump list for long AI chat sessions (user-turn TOC).
|
||||
*/
|
||||
|
||||
import { ListTree, X } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useStickToBottomContext } from 'use-stick-to-bottom';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { ChatJumpEntry } from '../../domain/chatJumpNav';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export interface ChatJumpNavProps {
|
||||
entries: ChatJumpEntry[];
|
||||
activeMessageId: string | null;
|
||||
/** When true, ignore transient isAtBottom flips from streaming resize. */
|
||||
isStreaming?: boolean;
|
||||
onSelect: (messageId: string) => void;
|
||||
/** Fired after the user leaves the jump target and returns to the bottom. */
|
||||
onReleasePin?: () => void;
|
||||
}
|
||||
|
||||
const ChatJumpNav: React.FC<ChatJumpNavProps> = ({
|
||||
entries,
|
||||
activeMessageId,
|
||||
isStreaming = false,
|
||||
onSelect,
|
||||
onReleasePin,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { stopScroll, isAtBottom } = useStickToBottomContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
// Only release after the viewport has left the bottom; avoids clearing a pin
|
||||
// on the same tick as select while isAtBottom is still true.
|
||||
const leftBottomWhilePinnedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
if (target && rootRef.current && !rootRef.current.contains(target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
return () => document.removeEventListener('pointerdown', onPointerDown);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeMessageId) {
|
||||
leftBottomWhilePinnedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!isAtBottom) {
|
||||
leftBottomWhilePinnedRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (leftBottomWhilePinnedRef.current) {
|
||||
// Streaming content growth / smooth resize can flip isAtBottom without the
|
||||
// user intending to leave the jump target; keep the pin until streaming ends
|
||||
// or they explicitly scroll to bottom via the scroll button.
|
||||
if (isStreaming) return;
|
||||
leftBottomWhilePinnedRef.current = false;
|
||||
onReleasePin?.();
|
||||
return;
|
||||
}
|
||||
// Jump target was already in the bottom window, so the viewport never left
|
||||
// isAtBottom. Clear the pin after scrollIntoView has had a chance to run.
|
||||
if (isStreaming) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
onReleasePin?.();
|
||||
}, 100);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [activeMessageId, isAtBottom, isStreaming, onReleasePin]);
|
||||
|
||||
const handleSelect = useCallback((messageId: string) => {
|
||||
stopScroll();
|
||||
onSelect(messageId);
|
||||
setOpen(false);
|
||||
}, [onSelect, stopScroll]);
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="absolute top-3 right-3 z-20 flex flex-col items-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'h-7 w-7 rounded-full border border-border/40 bg-background/90 backdrop-blur-sm',
|
||||
'flex items-center justify-center shadow-sm',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-pointer',
|
||||
open && 'text-foreground bg-muted',
|
||||
)}
|
||||
aria-label={t('ai.chat.jumpNav')}
|
||||
aria-expanded={open}
|
||||
title={t('ai.chat.jumpNav')}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
{open ? <X size={14} /> : <ListTree size={14} />}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className={cn(
|
||||
'w-[min(220px,calc(100vw-2rem))] max-h-[min(320px,50vh)] overflow-y-auto',
|
||||
'rounded-md border border-border/50 bg-background/95 backdrop-blur-sm shadow-md',
|
||||
'py-1',
|
||||
)}
|
||||
role="listbox"
|
||||
aria-label={t('ai.chat.jumpNav')}
|
||||
>
|
||||
{entries.map((entry) => {
|
||||
const selected = entry.messageId === activeMessageId;
|
||||
return (
|
||||
<button
|
||||
key={entry.messageId}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-2 px-2.5 py-1.5 text-left text-[12px] leading-snug',
|
||||
'hover:bg-muted/70 transition-colors cursor-pointer',
|
||||
selected
|
||||
? 'bg-muted text-foreground'
|
||||
: 'text-foreground/80',
|
||||
)}
|
||||
onClick={() => handleSelect(entry.messageId)}
|
||||
>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground/70 w-4 text-right">
|
||||
{entry.index}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{entry.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatJumpNav;
|
||||
@@ -132,6 +132,63 @@ test("ChatMessageList only renders the recent message batch by default", () => {
|
||||
assert.match(markup, /message-59/);
|
||||
});
|
||||
|
||||
test("ChatMessageList exposes jump navigation once there are enough user turns", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{ id: "u1", role: "user", content: "first turn", timestamp: 1 },
|
||||
{ id: "a1", role: "assistant", content: "ok", timestamp: 2 },
|
||||
{ id: "u2", role: "user", content: "second turn", timestamp: 3 },
|
||||
{ id: "a2", role: "assistant", content: "ok", timestamp: 4 },
|
||||
{ id: "u3", role: "user", content: "third turn", timestamp: 5 },
|
||||
{ id: "a3", role: "assistant", content: "ok", timestamp: 6 },
|
||||
];
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(ChatMessageList, { messages }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(markup, /aria-label="Jump to message"/);
|
||||
assert.match(markup, /id="ai-chat-msg-u1"/);
|
||||
assert.match(markup, /id="ai-chat-msg-u3"/);
|
||||
});
|
||||
|
||||
test("jump pin release does not reset the loaded message tail", () => {
|
||||
const source = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
|
||||
const releaseHandler = source.match(
|
||||
/const handleReleaseJumpPin = useCallback\(\(\) => \{[\s\S]*?\}, \[\]\);/,
|
||||
)?.[0] ?? "";
|
||||
|
||||
assert.match(releaseHandler, /setActiveJumpMessageId\(null\)/);
|
||||
assert.match(releaseHandler, /setPendingJumpMessageId\(null\)/);
|
||||
assert.doesNotMatch(releaseHandler, /setRenderedTailCount/);
|
||||
});
|
||||
|
||||
test("load earlier advances from the effective pinned tail", () => {
|
||||
const source = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
|
||||
assert.match(
|
||||
source,
|
||||
/setRenderedTailCount\(\(count\) =>\s*Math\.max\(count, effectiveTailCount\) \+ MESSAGE_RENDER_STEP\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("jump pin ignores streaming isAtBottom flips and releases on scroll button", () => {
|
||||
const jumpSource = readFileSync(new URL("./ChatJumpNav.tsx", import.meta.url), "utf8");
|
||||
const listSource = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(jumpSource, /isStreaming\?: boolean/);
|
||||
assert.match(jumpSource, /if \(isStreaming\) return;/);
|
||||
assert.match(jumpSource, /window\.setTimeout/);
|
||||
assert.match(listSource, /isStreaming=\{\!\!isStreaming\}/);
|
||||
assert.match(listSource, /<ConversationScrollButton onClick=\{handleReleaseJumpPin\} \/>/);
|
||||
});
|
||||
|
||||
test("ChatMessageList renders Codex activities and actual usage", () => {
|
||||
const messages: ChatMessage[] = [{
|
||||
id: "assistant-activity",
|
||||
|
||||
@@ -73,6 +73,12 @@ import {
|
||||
getAIPanelProfilerProps,
|
||||
isAIPanelDiagnosticPartHidden,
|
||||
} from './aiPanelDiagnostics';
|
||||
import {
|
||||
buildChatJumpEntries,
|
||||
chatMessageDomId,
|
||||
resolveTailCountForJumpTarget,
|
||||
} from '../../domain/chatJumpNav';
|
||||
import ChatJumpNav from './ChatJumpNav';
|
||||
|
||||
interface ChatMessageListProps {
|
||||
messages: ChatMessage[];
|
||||
@@ -354,9 +360,13 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
const hideMarkdown = isAIPanelDiagnosticPartHidden('markdown', hiddenParts);
|
||||
const hideToolCalls = isAIPanelDiagnosticPartHidden('toolcalls', hiddenParts);
|
||||
const [renderedTailCount, setRenderedTailCount] = useState(MESSAGE_RENDER_BATCH);
|
||||
const [activeJumpMessageId, setActiveJumpMessageId] = useState<string | null>(null);
|
||||
const [pendingJumpMessageId, setPendingJumpMessageId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setRenderedTailCount(MESSAGE_RENDER_BATCH);
|
||||
setActiveJumpMessageId(null);
|
||||
setPendingJumpMessageId(null);
|
||||
}, [activeSessionId]);
|
||||
|
||||
const visibleMessages = useMemo(
|
||||
@@ -364,11 +374,47 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
[messages],
|
||||
);
|
||||
|
||||
const hiddenMessageCount = Math.max(0, visibleMessages.length - renderedTailCount);
|
||||
// While a jump target is active, re-resolve the tail against the current list
|
||||
// so streaming appends cannot slide the window past the selected message.
|
||||
const effectiveTailCount = activeJumpMessageId
|
||||
? resolveTailCountForJumpTarget(visibleMessages, activeJumpMessageId, renderedTailCount)
|
||||
: renderedTailCount;
|
||||
|
||||
const hiddenMessageCount = Math.max(0, visibleMessages.length - effectiveTailCount);
|
||||
const displayedMessages = hiddenMessageCount > 0
|
||||
? visibleMessages.slice(-renderedTailCount)
|
||||
? visibleMessages.slice(-effectiveTailCount)
|
||||
: visibleMessages;
|
||||
|
||||
const jumpEntries = useMemo(
|
||||
() => buildChatJumpEntries(visibleMessages, {
|
||||
emptyLabel: t('ai.chat.jumpUntitled'),
|
||||
}),
|
||||
[t, visibleMessages],
|
||||
);
|
||||
|
||||
const handleJumpToMessage = useCallback((messageId: string) => {
|
||||
setActiveJumpMessageId(messageId);
|
||||
// Persist the expanded window so releasing the pin (or a spurious isAtBottom
|
||||
// flip) cannot unmount the jump target. Load-earlier progress is preserved
|
||||
// because we never reset renderedTailCount on pin release.
|
||||
setRenderedTailCount((count) =>
|
||||
resolveTailCountForJumpTarget(visibleMessages, messageId, count));
|
||||
setPendingJumpMessageId(messageId);
|
||||
}, [visibleMessages]);
|
||||
|
||||
const handleReleaseJumpPin = useCallback(() => {
|
||||
setActiveJumpMessageId(null);
|
||||
setPendingJumpMessageId(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingJumpMessageId) return;
|
||||
const target = document.getElementById(chatMessageDomId(pendingJumpMessageId));
|
||||
if (!target) return;
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
setPendingJumpMessageId(null);
|
||||
}, [displayedMessages, pendingJumpMessageId]);
|
||||
|
||||
const resolvedToolCallIds = new Set(
|
||||
displayedMessages
|
||||
.filter((m) => m.role === 'tool')
|
||||
@@ -483,7 +529,8 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
{hiddenMessageCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRenderedTailCount((count) => count + MESSAGE_RENDER_STEP)}
|
||||
onClick={() => setRenderedTailCount((count) =>
|
||||
Math.max(count, effectiveTailCount) + MESSAGE_RENDER_STEP)}
|
||||
className="w-full py-2 text-center text-[12px] text-muted-foreground/50 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
{t('ai.chat.loadEarlierMessages').replace('{n}', String(hiddenMessageCount))}
|
||||
@@ -591,7 +638,11 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
const isThisStreaming = isStreaming && isLastAssistant;
|
||||
|
||||
return (
|
||||
<Message key={message.id} from={message.role}>
|
||||
<Message
|
||||
key={message.id}
|
||||
id={chatMessageDomId(message.id)}
|
||||
from={message.role}
|
||||
>
|
||||
<MessageContent from={message.role}>
|
||||
{/* Thinking block */}
|
||||
{!isUser && message.thinking && (
|
||||
@@ -833,7 +884,14 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
<ChatJumpNav
|
||||
entries={jumpEntries}
|
||||
activeMessageId={activeJumpMessageId}
|
||||
isStreaming={!!isStreaming}
|
||||
onSelect={handleJumpToMessage}
|
||||
onReleasePin={handleReleaseJumpPin}
|
||||
/>
|
||||
<ConversationScrollButton onClick={handleReleaseJumpPin} />
|
||||
</Conversation>
|
||||
|
||||
{/* Image preview lightbox */}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildChatJumpEntries,
|
||||
chatMessageDomId,
|
||||
resolveTailCountForJumpTarget,
|
||||
truncateChatJumpLabel,
|
||||
} from './chatJumpNav.ts';
|
||||
|
||||
test('truncateChatJumpLabel collapses whitespace and ellipsizes', () => {
|
||||
assert.equal(truncateChatJumpLabel(' hello world '), 'hello world');
|
||||
assert.equal(
|
||||
truncateChatJumpLabel('a'.repeat(40), 10),
|
||||
`${'a'.repeat(7)}...`,
|
||||
);
|
||||
assert.equal(truncateChatJumpLabel(' '), '');
|
||||
});
|
||||
|
||||
test('buildChatJumpEntries returns empty until the minimum user-turn threshold', () => {
|
||||
const messages = [
|
||||
{ id: 'u1', role: 'user', content: 'one' },
|
||||
{ id: 'a1', role: 'assistant', content: 'ok' },
|
||||
{ id: 'u2', role: 'user', content: 'two' },
|
||||
];
|
||||
assert.deepEqual(buildChatJumpEntries(messages), []);
|
||||
assert.equal(
|
||||
buildChatJumpEntries([
|
||||
...messages,
|
||||
{ id: 'u3', role: 'user', content: 'three' },
|
||||
]).length,
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
test('buildChatJumpEntries uses empty label fallback and skips non-user roles', () => {
|
||||
const entries = buildChatJumpEntries(
|
||||
[
|
||||
{ id: 's', role: 'system', content: 'ignore' },
|
||||
{ id: 'u1', role: 'user', content: ' ' },
|
||||
{ id: 't', role: 'tool', content: 'tool' },
|
||||
{ id: 'u2', role: 'user', content: 'second' },
|
||||
{ id: 'u3', role: 'user', content: 'third' },
|
||||
],
|
||||
{ emptyLabel: '(empty)' },
|
||||
);
|
||||
assert.deepEqual(entries, [
|
||||
{ messageId: 'u1', label: '(empty)', index: 1 },
|
||||
{ messageId: 'u2', label: 'second', index: 2 },
|
||||
{ messageId: 'u3', label: 'third', index: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolveTailCountForJumpTarget expands only when the target is outside the window', () => {
|
||||
const visible = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }, { id: 'e' }];
|
||||
assert.equal(resolveTailCountForJumpTarget(visible, 'd', 2), 2);
|
||||
assert.equal(resolveTailCountForJumpTarget(visible, 'b', 2), 4);
|
||||
assert.equal(resolveTailCountForJumpTarget(visible, 'missing', 2), 2);
|
||||
});
|
||||
|
||||
test('resolveTailCountForJumpTarget grows with appends so a pinned target stays mounted', () => {
|
||||
const before = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }, { id: 'e' }];
|
||||
const afterJump = resolveTailCountForJumpTarget(before, 'b', 2);
|
||||
assert.equal(afterJump, 4);
|
||||
// slice(-4) on a longer list would drop 'b' unless the count is re-resolved.
|
||||
const afterAppend = [...before, { id: 'f' }, { id: 'g' }];
|
||||
assert.equal(resolveTailCountForJumpTarget(afterAppend, 'b', afterJump), 6);
|
||||
});
|
||||
|
||||
test('chatMessageDomId prefixes message ids for DOM anchors', () => {
|
||||
assert.equal(chatMessageDomId('msg-12'), 'ai-chat-msg-msg-12');
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Pure helpers for AI chat in-session jump navigation (user-turn TOC).
|
||||
*/
|
||||
|
||||
export const CHAT_JUMP_MIN_ENTRIES = 3;
|
||||
export const CHAT_JUMP_LABEL_MAX = 36;
|
||||
|
||||
export type ChatJumpMessage = {
|
||||
id: string;
|
||||
role: string;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
export type ChatJumpEntry = {
|
||||
messageId: string;
|
||||
label: string;
|
||||
/** 1-based index among user turns */
|
||||
index: number;
|
||||
};
|
||||
|
||||
export function chatMessageDomId(messageId: string): string {
|
||||
return `ai-chat-msg-${messageId}`;
|
||||
}
|
||||
|
||||
export function truncateChatJumpLabel(text: string, maxLen = CHAT_JUMP_LABEL_MAX): string {
|
||||
const normalized = text.replace(/\s+/g, ' ').trim();
|
||||
if (!normalized) return '';
|
||||
if (normalized.length <= maxLen) return normalized;
|
||||
const ellipsis = '...';
|
||||
return `${normalized.slice(0, Math.max(1, maxLen - ellipsis.length)).trimEnd()}${ellipsis}`;
|
||||
}
|
||||
|
||||
export function buildChatJumpEntries(
|
||||
messages: ReadonlyArray<ChatJumpMessage>,
|
||||
options?: {
|
||||
minEntries?: number;
|
||||
maxLabelLen?: number;
|
||||
emptyLabel?: string;
|
||||
},
|
||||
): ChatJumpEntry[] {
|
||||
const minEntries = options?.minEntries ?? CHAT_JUMP_MIN_ENTRIES;
|
||||
const maxLabelLen = options?.maxLabelLen ?? CHAT_JUMP_LABEL_MAX;
|
||||
const emptyLabel = options?.emptyLabel ?? '...';
|
||||
const entries: ChatJumpEntry[] = [];
|
||||
for (const message of messages) {
|
||||
if (message.role !== 'user') continue;
|
||||
const label = truncateChatJumpLabel(message.content ?? '', maxLabelLen) || emptyLabel;
|
||||
entries.push({
|
||||
messageId: message.id,
|
||||
label,
|
||||
index: entries.length + 1,
|
||||
});
|
||||
}
|
||||
return entries.length >= minEntries ? entries : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand the rendered tail window so a jump target is mounted in the DOM.
|
||||
* Call again whenever the message list grows while the jump target remains
|
||||
* active; a one-shot expand at jump time is not enough if the tail is
|
||||
* `slice(-count)` and later appends would otherwise slide the window forward.
|
||||
*/
|
||||
export function resolveTailCountForJumpTarget(
|
||||
visibleMessages: ReadonlyArray<{ id: string }>,
|
||||
targetMessageId: string,
|
||||
currentTailCount: number,
|
||||
): number {
|
||||
const targetIndex = visibleMessages.findIndex((message) => message.id === targetMessageId);
|
||||
if (targetIndex < 0) return currentTailCount;
|
||||
const requiredTail = visibleMessages.length - targetIndex;
|
||||
return Math.max(currentTailCount, requiredTail);
|
||||
}
|
||||
Reference in new issue
Block a user