mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix: keep chat tabs from losing scroll context
Chat sessions need predictable navigation when users switch tabs or read history during streaming. This preserves each session's scroll position, defaults fresh sessions to the latest message, and adds a compact jump-to-latest affordance when auto-follow is paused. The PR gate also exposed that provider-scoped scheduled tasks must force the sdk-cli entrypoint when launched through the sidecar, so the same change records that runtime contract and its regression assertion. Constraint: Desktop chat should not force-scroll while the user is reading older messages Constraint: Scheduled task provider env must not inherit stale parent model runtime values Rejected: Persist scroll positions in localStorage | session scroll is transient UI state and should not survive app restarts Confidence: high Scope-risk: moderate Directive: Do not remove the sdk-cli entrypoint marker from provider-scoped cron tasks without rerunning the sidecar launcher regression Tested: cd desktop && bun run test -- MessageList.test.tsx Tested: cd desktop && bun run lint Tested: bun test src/server/__tests__/cron-scheduler-launcher.test.ts src/utils/__tests__/themeWords.test.ts Tested: bun run check:desktop Tested: bun run check:server Tested: bun run check:native Not-tested: bun run verify still fails on pre-existing agent-utils global coverage baseline below threshold Not-tested: Chrome extension E2E blocked by Codex Chrome Extension communication timeout after plugin diagnostics passed
This commit is contained in:
parent
3193e741c7
commit
6382115de9
@ -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,84 +525,99 @@ 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
|
||||
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
|
||||
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={() => {
|
||||
|
||||
@ -823,6 +823,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',
|
||||
|
||||
@ -825,6 +825,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} 个文件已更改',
|
||||
|
||||
@ -295,6 +295,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')
|
||||
})
|
||||
|
||||
@ -666,7 +666,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