mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Merge chat scroll recovery into main
Bring the detached worktree fix into the local main line while preserving the newer main behavior that passes session context into interactive chat message blocks. Constraint: Local main already contains six unpublished commits on top of origin/main Constraint: Merge conflict only affected MessageList message rendering around sessionId propagation Rejected: Overwrite main's MessageBlock call shape | would regress AskUserQuestion and permission interactions that need sessionId Confidence: high Scope-risk: moderate Directive: Keep MessageBlock sessionId propagation when editing chat rendering; AskUserQuestion depends on it Tested: cd desktop && bun run test -- MessageList.test.tsx Not-tested: Full verify after merge; previous verify remains blocked by agent-utils global coverage baseline
This commit is contained in:
commit
50d4679f51
@ -537,6 +537,191 @@ describe('MessageList nested tool calls', () => {
|
||||
expect(scrollIntoView).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores a session scroll position when switching back to a tab', async () => {
|
||||
const scrollIntoView = vi.fn()
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: scrollIntoView,
|
||||
})
|
||||
|
||||
useTabStore.setState({
|
||||
activeTabId: 'session-a',
|
||||
tabs: [
|
||||
{ sessionId: 'session-a', title: 'A', type: 'session' as const, status: 'idle' },
|
||||
{ sessionId: 'session-b', title: 'B', type: 'session' as const, status: 'idle' },
|
||||
],
|
||||
})
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
'session-a': makeSessionState({
|
||||
messages: [
|
||||
{ id: 'a-user', type: 'user_text', content: 'A prompt', timestamp: 1 },
|
||||
{ id: 'a-assistant', type: 'assistant_text', content: 'A response', timestamp: 2 },
|
||||
],
|
||||
}),
|
||||
'session-b': makeSessionState({
|
||||
messages: [
|
||||
{ id: 'b-user', type: 'user_text', content: 'B prompt', timestamp: 1 },
|
||||
{ id: 'b-assistant', type: 'assistant_text', content: 'B response', timestamp: 2 },
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<MessageList />)
|
||||
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
|
||||
let scrollTop = 180
|
||||
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1200 })
|
||||
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 })
|
||||
Object.defineProperty(scroller, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value) => {
|
||||
scrollTop = value
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.scroll(scroller)
|
||||
expect(screen.getByRole('button', { name: 'Latest' })).toBeTruthy()
|
||||
|
||||
act(() => {
|
||||
useTabStore.setState({ activeTabId: 'session-b' })
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('B response')).toBeTruthy()
|
||||
})
|
||||
|
||||
scrollTop = 760
|
||||
fireEvent.scroll(scroller)
|
||||
|
||||
act(() => {
|
||||
useTabStore.setState({ activeTabId: 'session-a' })
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('A response')).toBeTruthy()
|
||||
})
|
||||
|
||||
expect(scrollTop).toBe(180)
|
||||
expect(screen.getByRole('button', { name: 'Latest' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('scrolls new sessions to the latest message instead of inheriting another tab position', async () => {
|
||||
const scrollIntoView = vi.fn()
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: scrollIntoView,
|
||||
})
|
||||
|
||||
useTabStore.setState({
|
||||
activeTabId: 'session-a',
|
||||
tabs: [
|
||||
{ sessionId: 'session-a', title: 'A', type: 'session' as const, status: 'idle' },
|
||||
{ sessionId: 'session-fresh', title: 'Fresh', type: 'session' as const, status: 'idle' },
|
||||
],
|
||||
})
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
'session-a': makeSessionState({
|
||||
messages: [
|
||||
{ id: 'a-user', type: 'user_text', content: 'A prompt', timestamp: 1 },
|
||||
{ id: 'a-assistant', type: 'assistant_text', content: 'A response', timestamp: 2 },
|
||||
],
|
||||
}),
|
||||
'session-fresh': makeSessionState({
|
||||
messages: [
|
||||
{ id: 'fresh-user', type: 'user_text', content: 'Fresh prompt', timestamp: 1 },
|
||||
{ id: 'fresh-assistant', type: 'assistant_text', content: 'Fresh latest response', timestamp: 2 },
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<MessageList />)
|
||||
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
|
||||
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1200 })
|
||||
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 })
|
||||
Object.defineProperty(scroller, 'scrollTop', {
|
||||
configurable: true,
|
||||
value: 150,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
fireEvent.scroll(scroller)
|
||||
scrollIntoView.mockClear()
|
||||
|
||||
act(() => {
|
||||
useTabStore.setState({ activeTabId: 'session-fresh' })
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Fresh latest response')).toBeTruthy()
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'auto', block: 'end' })
|
||||
})
|
||||
})
|
||||
|
||||
it('shows a latest button when reading history and resumes following after clicking it', async () => {
|
||||
const scrollIntoView = vi.fn()
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: scrollIntoView,
|
||||
})
|
||||
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
chatState: 'streaming',
|
||||
messages: [
|
||||
{
|
||||
id: 'user-1',
|
||||
type: 'user_text',
|
||||
content: '历史消息',
|
||||
timestamp: 1,
|
||||
},
|
||||
],
|
||||
streamingText: 'streaming',
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<MessageList />)
|
||||
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
|
||||
let scrollTop = 120
|
||||
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1000 })
|
||||
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 })
|
||||
Object.defineProperty(scroller, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value) => {
|
||||
scrollTop = value
|
||||
},
|
||||
})
|
||||
|
||||
scrollIntoView.mockClear()
|
||||
fireEvent.scroll(scroller)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Latest' }))
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'end' })
|
||||
expect(screen.queryByRole('button', { name: 'Latest' })).toBeNull()
|
||||
|
||||
scrollIntoView.mockClear()
|
||||
act(() => {
|
||||
useChatStore.setState((state) => ({
|
||||
sessions: {
|
||||
...state.sessions,
|
||||
[ACTIVE_TAB]: {
|
||||
...state.sessions[ACTIVE_TAB]!,
|
||||
streamingText: 'streaming after jump',
|
||||
},
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('streaming after jump')).toBeTruthy()
|
||||
})
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'end' })
|
||||
})
|
||||
|
||||
it('keeps user actions anchored to the right bubble and assistant actions to the left bubble', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useRef, useEffect, useMemo, memo, useState, useCallback } from 'react'
|
||||
import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect } from 'react'
|
||||
import { ArrowDown } from 'lucide-react'
|
||||
import { ApiError } from '../../api/client'
|
||||
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
@ -246,6 +247,28 @@ type MessageListProps = {
|
||||
}
|
||||
|
||||
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 48
|
||||
const MAX_SCROLL_SNAPSHOTS = 100
|
||||
const CHAT_SCROLL_AREA_CLASS = [
|
||||
'chat-scroll-area',
|
||||
'[scrollbar-width:auto]',
|
||||
'[scrollbar-color:color-mix(in_srgb,var(--color-outline)_72%,transparent)_transparent]',
|
||||
'[&::-webkit-scrollbar]:w-2.5',
|
||||
'[&::-webkit-scrollbar-track]:bg-transparent',
|
||||
'[&::-webkit-scrollbar-thumb]:rounded-full',
|
||||
'[&::-webkit-scrollbar-thumb]:border-[3px]',
|
||||
'[&::-webkit-scrollbar-thumb]:border-transparent',
|
||||
'[&::-webkit-scrollbar-thumb]:bg-[color-mix(in_srgb,var(--color-outline)_74%,transparent)]',
|
||||
'[&::-webkit-scrollbar-thumb]:bg-clip-content',
|
||||
'[&::-webkit-scrollbar-thumb:hover]:border-2',
|
||||
'[&::-webkit-scrollbar-thumb:hover]:bg-[color-mix(in_srgb,var(--color-outline)_90%,transparent)]',
|
||||
].join(' ')
|
||||
|
||||
type SessionScrollSnapshot = {
|
||||
scrollTop: number
|
||||
wasAtBottom: boolean
|
||||
}
|
||||
|
||||
const sessionScrollSnapshots = new Map<string, SessionScrollSnapshot>()
|
||||
|
||||
function isNearScrollBottom(element: HTMLElement) {
|
||||
return (
|
||||
@ -254,6 +277,24 @@ function isNearScrollBottom(element: HTMLElement) {
|
||||
)
|
||||
}
|
||||
|
||||
function rememberSessionScroll(sessionId: string, element: HTMLElement) {
|
||||
if (sessionScrollSnapshots.size >= MAX_SCROLL_SNAPSHOTS && !sessionScrollSnapshots.has(sessionId)) {
|
||||
const oldestSessionId = sessionScrollSnapshots.keys().next().value
|
||||
if (oldestSessionId) {
|
||||
sessionScrollSnapshots.delete(oldestSessionId)
|
||||
}
|
||||
}
|
||||
|
||||
sessionScrollSnapshots.set(sessionId, {
|
||||
scrollTop: element.scrollTop,
|
||||
wasAtBottom: isNearScrollBottom(element),
|
||||
})
|
||||
}
|
||||
|
||||
function clampScrollTop(element: HTMLElement, scrollTop: number) {
|
||||
return Math.max(0, Math.min(scrollTop, element.scrollHeight - element.clientHeight))
|
||||
}
|
||||
|
||||
export function MessageList({ sessionId, compact = false }: MessageListProps = {}) {
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const resolvedSessionId = sessionId ?? activeTabId
|
||||
@ -283,23 +324,61 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
const [isLoadingTurnChangeCards, setIsLoadingTurnChangeCards] = useState(false)
|
||||
const [rewindingTurnId, setRewindingTurnId] = useState<string | null>(null)
|
||||
const [turnUndoConfirmTargetId, setTurnUndoConfirmTargetId] = useState<string | null>(null)
|
||||
const [showJumpToLatest, setShowJumpToLatest] = useState(false)
|
||||
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior) => {
|
||||
shouldAutoScrollRef.current = true
|
||||
bottomRef.current?.scrollIntoView?.({ behavior, block: 'end' })
|
||||
const container = scrollContainerRef.current
|
||||
if (container && resolvedSessionId) {
|
||||
sessionScrollSnapshots.set(resolvedSessionId, {
|
||||
scrollTop: Math.max(0, container.scrollHeight - container.clientHeight),
|
||||
wasAtBottom: true,
|
||||
})
|
||||
}
|
||||
setShowJumpToLatest(false)
|
||||
}, [resolvedSessionId])
|
||||
|
||||
const updateAutoScrollState = useCallback(() => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container) return
|
||||
shouldAutoScrollRef.current = isNearScrollBottom(container)
|
||||
}, [])
|
||||
const isAtBottom = isNearScrollBottom(container)
|
||||
shouldAutoScrollRef.current = isAtBottom
|
||||
setShowJumpToLatest(!isAtBottom)
|
||||
|
||||
if (resolvedSessionId) {
|
||||
rememberSessionScroll(resolvedSessionId, container)
|
||||
}
|
||||
}, [resolvedSessionId])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (lastSessionIdRef.current !== resolvedSessionId) {
|
||||
const snapshot = resolvedSessionId ? sessionScrollSnapshots.get(resolvedSessionId) : undefined
|
||||
shouldAutoScrollRef.current = snapshot?.wasAtBottom ?? true
|
||||
lastSessionIdRef.current = resolvedSessionId
|
||||
|
||||
const container = scrollContainerRef.current
|
||||
if (container && snapshot && !snapshot.wasAtBottom) {
|
||||
container.scrollTop = clampScrollTop(container, snapshot.scrollTop)
|
||||
setShowJumpToLatest(true)
|
||||
} else {
|
||||
scrollToBottom('auto')
|
||||
}
|
||||
}
|
||||
}, [resolvedSessionId, scrollToBottom])
|
||||
|
||||
useEffect(() => {
|
||||
if (lastSessionIdRef.current !== resolvedSessionId) {
|
||||
shouldAutoScrollRef.current = true
|
||||
lastSessionIdRef.current = resolvedSessionId
|
||||
if (!shouldAutoScrollRef.current) {
|
||||
setShowJumpToLatest(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (!shouldAutoScrollRef.current) return
|
||||
scrollToBottom('smooth')
|
||||
}, [messages.length, resolvedSessionId, scrollToBottom, streamingText])
|
||||
|
||||
bottomRef.current?.scrollIntoView?.({ behavior: 'smooth' })
|
||||
}, [messages.length, resolvedSessionId, streamingText])
|
||||
const handleJumpToLatest = useCallback(() => {
|
||||
scrollToBottom('smooth')
|
||||
}, [scrollToBottom])
|
||||
|
||||
const { toolResultMap, childToolCallsByParent, renderItems } = useMemo(
|
||||
() => buildRenderModel(messages),
|
||||
@ -446,85 +525,100 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onScroll={updateAutoScrollState}
|
||||
className={`flex-1 overflow-y-auto ${compact ? 'px-3 py-3 pb-5' : 'px-4 py-4'}`}
|
||||
>
|
||||
<div className={compact ? 'mx-auto max-w-full' : 'mx-auto max-w-[860px]'}>
|
||||
{renderItems.map((item, index) => {
|
||||
const cardsForItem = turnCardsByRenderIndex.get(index) ?? []
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onScroll={updateAutoScrollState}
|
||||
className={`${CHAT_SCROLL_AREA_CLASS} h-full overflow-y-auto ${compact ? 'px-3 py-3 pb-5' : 'px-4 py-4'}`}
|
||||
>
|
||||
<div className={compact ? 'mx-auto max-w-full' : 'mx-auto max-w-[860px]'}>
|
||||
{renderItems.map((item, index) => {
|
||||
const cardsForItem = turnCardsByRenderIndex.get(index) ?? []
|
||||
|
||||
return (
|
||||
<div key={item.kind === 'tool_group' ? item.id : item.message.id}>
|
||||
{item.kind === 'tool_group' ? (
|
||||
<ToolCallGroup
|
||||
toolCalls={item.toolCalls}
|
||||
resultMap={toolResultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
agentTaskNotifications={agentTaskNotifications}
|
||||
isStreaming={
|
||||
chatState === 'tool_executing' &&
|
||||
item.toolCalls.some((tc) => !toolResultMap.has(tc.toolUseId))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<MessageBlock
|
||||
sessionId={resolvedSessionId}
|
||||
message={item.message}
|
||||
activeThinkingId={activeThinkingId}
|
||||
agentTaskNotifications={agentTaskNotifications}
|
||||
toolResult={
|
||||
item.message.type === 'tool_use'
|
||||
? (() => {
|
||||
const result = toolResultMap.get(item.message.toolUseId)
|
||||
return result ? { content: result.content, isError: result.isError } : null
|
||||
})()
|
||||
: null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
return (
|
||||
<div key={item.kind === 'tool_group' ? item.id : item.message.id}>
|
||||
{item.kind === 'tool_group' ? (
|
||||
<ToolCallGroup
|
||||
toolCalls={item.toolCalls}
|
||||
resultMap={toolResultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
agentTaskNotifications={agentTaskNotifications}
|
||||
isStreaming={
|
||||
chatState === 'tool_executing' &&
|
||||
item.toolCalls.some((tc) => !toolResultMap.has(tc.toolUseId))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<MessageBlock
|
||||
sessionId={resolvedSessionId}
|
||||
message={item.message}
|
||||
activeThinkingId={activeThinkingId}
|
||||
agentTaskNotifications={agentTaskNotifications}
|
||||
toolResult={
|
||||
item.message.type === 'tool_use'
|
||||
? (() => {
|
||||
const result = toolResultMap.get(item.message.toolUseId)
|
||||
return result ? { content: result.content, isError: result.isError } : null
|
||||
})()
|
||||
: null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{resolvedSessionId && cardsForItem.map((card) => (
|
||||
<CurrentTurnChangeCard
|
||||
key={`turn-change-${card.target.messageId}`}
|
||||
sessionId={resolvedSessionId}
|
||||
targetUserMessageId={card.checkpoint.target.targetUserMessageId}
|
||||
checkpoint={card.checkpoint}
|
||||
workDir={card.workDir}
|
||||
error={turnActionErrors[card.target.messageId] ?? null}
|
||||
isUndoing={rewindingTurnId === card.target.messageId}
|
||||
isLatest={card.isLatest}
|
||||
onUndo={() => {
|
||||
setTurnUndoConfirmTargetId(card.target.messageId)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{resolvedSessionId && cardsForItem.map((card) => (
|
||||
<CurrentTurnChangeCard
|
||||
key={`turn-change-${card.target.messageId}`}
|
||||
sessionId={resolvedSessionId}
|
||||
targetUserMessageId={card.checkpoint.target.targetUserMessageId}
|
||||
checkpoint={card.checkpoint}
|
||||
workDir={card.workDir}
|
||||
error={turnActionErrors[card.target.messageId] ?? null}
|
||||
isUndoing={rewindingTurnId === card.target.messageId}
|
||||
isLatest={card.isLatest}
|
||||
onUndo={() => {
|
||||
setTurnUndoConfirmTargetId(card.target.messageId)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{streamingText.trim() && (
|
||||
<AssistantMessage content={streamingText} isStreaming={chatState === 'streaming'} />
|
||||
)}
|
||||
|
||||
{/* Show StreamingIndicator when:
|
||||
- tool_executing: tool is running
|
||||
- thinking but no active ThinkingBlock yet: the gap between
|
||||
sending a message and receiving the first thinking delta */}
|
||||
{(chatState === 'tool_executing' || (chatState === 'thinking' && !activeThinkingId)) && (
|
||||
<StreamingIndicator />
|
||||
)}
|
||||
|
||||
{!isLoadingTurnChangeCards && turnChangeCards.length === 0 && turnChangeLoadError && (
|
||||
<div className="mx-auto mb-5 w-full max-w-[860px] rounded-[var(--radius-lg)] border border-[var(--color-error)]/25 bg-[var(--color-error-container)]/18 px-4 py-3 text-xs text-[var(--color-error)]">
|
||||
{turnChangeLoadError}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
)}
|
||||
|
||||
{streamingText.trim() && (
|
||||
<AssistantMessage content={streamingText} isStreaming={chatState === 'streaming'} />
|
||||
)}
|
||||
|
||||
{/* Show StreamingIndicator when:
|
||||
- tool_executing: tool is running
|
||||
- thinking but no active ThinkingBlock yet: the gap between
|
||||
sending a message and receiving the first thinking delta */}
|
||||
{(chatState === 'tool_executing' || (chatState === 'thinking' && !activeThinkingId)) && (
|
||||
<StreamingIndicator />
|
||||
)}
|
||||
|
||||
{!isLoadingTurnChangeCards && turnChangeCards.length === 0 && turnChangeLoadError && (
|
||||
<div className="mx-auto mb-5 w-full max-w-[860px] rounded-[var(--radius-lg)] border border-[var(--color-error)]/25 bg-[var(--color-error-container)]/18 px-4 py-3 text-xs text-[var(--color-error)]">
|
||||
{turnChangeLoadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showJumpToLatest && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleJumpToLatest}
|
||||
title={t('chat.jumpToLatest')}
|
||||
aria-label={t('chat.jumpToLatest')}
|
||||
className="absolute bottom-4 right-5 z-20 flex h-9 items-center gap-2 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 text-xs font-medium text-[var(--color-text-primary)] shadow-[var(--shadow-dropdown)] transition-colors hover:border-[var(--color-brand)]/50 hover:bg-[var(--color-surface-container-low)]"
|
||||
>
|
||||
<ArrowDown size={15} aria-hidden="true" />
|
||||
<span>{t('chat.jumpToLatest')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmTurnCard)}
|
||||
onClose={() => {
|
||||
|
||||
@ -834,6 +834,7 @@ export const en = {
|
||||
'chat.select': 'select',
|
||||
'chat.dismiss': 'dismiss',
|
||||
'chat.stopTitle': 'Stop generation (Cmd+.)',
|
||||
'chat.jumpToLatest': 'Latest',
|
||||
'chat.rewindSuccessWithCode': 'Rewound {count} messages and restored tracked files.',
|
||||
'chat.rewindSuccessConversationOnly': 'Rewound {count} messages. No file checkpoint was available for this turn.',
|
||||
'chat.turnChangesTitle': '{count} files changed',
|
||||
|
||||
@ -836,6 +836,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.select': '选择',
|
||||
'chat.dismiss': '关闭',
|
||||
'chat.stopTitle': '停止生成 (Cmd+.)',
|
||||
'chat.jumpToLatest': '回到最新',
|
||||
'chat.rewindSuccessWithCode': '已回滚 {count} 条消息,并恢复相关文件。',
|
||||
'chat.rewindSuccessConversationOnly': '已回滚 {count} 条消息。这一轮没有可用的文件检查点。',
|
||||
'chat.turnChangesTitle': '{count} 个文件已更改',
|
||||
|
||||
@ -296,6 +296,7 @@ describe('cron scheduler launcher resolution', () => {
|
||||
)
|
||||
expect(env.ANTHROPIC_API_KEY).toBe('proxy-managed')
|
||||
expect(env.ANTHROPIC_MODEL).toBe('provider-fast')
|
||||
expect(env.ANTHROPIC_MODEL).not.toBe('stale-parent-model')
|
||||
expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe('1')
|
||||
expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('sdk-cli')
|
||||
})
|
||||
|
||||
@ -667,7 +667,10 @@ export class CronScheduler {
|
||||
PWD: workDir,
|
||||
CC_HAHA_SKIP_DOTENV: '1',
|
||||
...(explicitProviderEnv
|
||||
? { CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: '1' }
|
||||
? {
|
||||
CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: '1',
|
||||
CLAUDE_CODE_ENTRYPOINT: 'sdk-cli',
|
||||
}
|
||||
: {}),
|
||||
...(explicitProviderEnv ?? {}),
|
||||
...(this.shouldMarkManagedOAuth(task.providerId)
|
||||
|
||||
42
src/utils/__tests__/themeWords.test.ts
Normal file
42
src/utils/__tests__/themeWords.test.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import {
|
||||
getTheme,
|
||||
THEME_NAMES,
|
||||
themeColorToAnsi,
|
||||
type ThemeName,
|
||||
} from '../theme.js'
|
||||
import { generateShortWordSlug, generateWordSlug } from '../words.js'
|
||||
|
||||
describe('theme utilities', () => {
|
||||
test('resolves every supported theme to a complete palette', () => {
|
||||
for (const themeName of THEME_NAMES) {
|
||||
const theme = getTheme(themeName)
|
||||
|
||||
expect(theme.text).toBeTruthy()
|
||||
expect(theme.inverseText).toBeTruthy()
|
||||
expect(theme.success).toBeTruthy()
|
||||
expect(theme.error).toBeTruthy()
|
||||
expect(theme.claude).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
test('falls back to the dark theme for unknown concrete theme names', () => {
|
||||
const darkTheme = getTheme('dark')
|
||||
const unknownTheme = getTheme('unknown' as ThemeName)
|
||||
|
||||
expect(unknownTheme).toBe(darkTheme)
|
||||
})
|
||||
|
||||
test('converts rgb theme colors to ansi escapes', () => {
|
||||
expect(themeColorToAnsi('rgb(12, 34, 56)')).toEqual(expect.any(String))
|
||||
expect(themeColorToAnsi('not-a-color')).toBe('\x1b[35m')
|
||||
})
|
||||
})
|
||||
|
||||
describe('word slug utilities', () => {
|
||||
test('generates hyphenated slugs with the expected part count', () => {
|
||||
expect(generateWordSlug().split('-')).toHaveLength(3)
|
||||
expect(generateShortWordSlug().split('-')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user