Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
61d2a5f
fork: apply OpenCodeUI customizations on top of upstream v0.6.33
SsparKluo Jul 19, 2026
0898df2
merge: sync upstream v0.6.34 into dev
SsparKluo Jul 20, 2026
4a897f2
refactor(chat): input-event-driven scroll-follow redesign
SsparKluo Jul 21, 2026
261faf5
merge: feat/auto-scroll-input-driven into dev
SsparKluo Jul 21, 2026
bf61aef
feat(chat): replace queue placeholder messages with QueuedMessagesBar…
SsparKluo Jul 21, 2026
c56183f
feat(chat): selection only affects follow state during active streaming
SsparKluo Jul 22, 2026
7cfa63d
fix(reasoning): keep capsule scroll when scrolled up during streaming
SsparKluo Jul 22, 2026
51a0ec6
feat(text-selection-popup): Quote + Copy actions on chat text selection
SsparKluo Jul 22, 2026
60ea05f
Merge branch 'feat/auto-scroll-input-driven' into dev
SsparKluo Jul 22, 2026
790192b
Merge branch 'feat/text-selection-floating-popup' into dev
SsparKluo Jul 22, 2026
ef80216
Merge branch 'fix/reasoning-capsule-smart-scroll' into dev
SsparKluo Jul 22, 2026
9325ad6
Merge branch 'feat/queue-message-input-bar' into dev
SsparKluo Jul 22, 2026
f0d2310
Merge branch 'fix/collapsed-input-dock-pointer-events' into dev
SsparKluo Jul 22, 2026
c6898e6
Merge branch 'fix/entry-grow-strictmode-leak' into dev
SsparKluo Jul 22, 2026
e65195f
docs: update fork customization record for auto-scroll v2, text-selec…
SsparKluo Jul 22, 2026
da7b6b0
fix(chat): touch tap no longer stops follow, only real scroll gesture…
SsparKluo Jul 23, 2026
68659e4
revert(sidebar): restore upstream project selector in SidePanel
SsparKluo Jul 23, 2026
590c852
merge: touch-tap fix from feat/auto-scroll-input-driven
SsparKluo Jul 23, 2026
9842183
merge: upstream v0.6.35 into dev
SsparKluo Jul 26, 2026
e933815
fix(chat): drive InputBox fold from userScrolled, not geometric dist
SsparKluo Jul 26, 2026
946ae4f
feat(text-selection-popup): Quote + Copy actions on chat text selection
SsparKluo Jul 22, 2026
f713fd1
fix(text-selection-popup): wrap quote with blank lines on both sides
SsparKluo Jul 29, 2026
36440e2
merge: text-selection-popup quote-spacing fix into dev
SsparKluo Jul 29, 2026
0117b66
Merge branch 'main' into feat/queue-message-input-bar
SsparKluo Jul 30, 2026
f810f56
feat(chat): extend queued messages bar with revert and reorder
SsparKluo Jul 30, 2026
0270522
Merge branch 'feat/queue-message-input-bar' into dev
SsparKluo Jul 30, 2026
e64fe6b
merge: upstream v0.6.36-0.6.45 into dev
SsparKluo Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
366 changes: 366 additions & 0 deletions docs/fork-customizations.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { invoke } from '@tauri-apps/api/core'
import { Sidebar } from './features/chat'
import { ChatPane } from './features/chat/ChatPane'
import { SplitContainer } from './features/chat/SplitContainer'
import { TextSelectionPopup } from './features/text-selection-popup'
import type { CommandItem } from './components/CommandPalette'
import { ToastContainer } from './components/ToastContainer'
import { RightPanel } from './components/RightPanel'
Expand Down Expand Up @@ -927,7 +928,6 @@ function App() {
onNewSession={handleNewSession}
onOpen={handleOpenSidebar}
onClose={handleCloseSidebar}
contextLimit={focusedController?.contextLimit}
onOpenSettings={openSettings}
projectDialogOpen={projectDialogOpen}
onProjectDialogClose={closeProjectDialog}
Expand Down Expand Up @@ -1013,7 +1013,6 @@ function App() {
onNewSession={handleNewSession}
onOpen={handleOpenSidebar}
onClose={handleCloseSidebar}
contextLimit={focusedController?.contextLimit}
onOpenSettings={openSettings}
projectDialogOpen={projectDialogOpen}
onProjectDialogClose={closeProjectDialog}
Expand Down Expand Up @@ -1045,6 +1044,7 @@ function App() {
</>
)}
<ToastContainer onOpenAbout={openAboutSettings} />
<TextSelectionPopup />
</div>

<Suspense fallback={null}>
Expand Down
4 changes: 2 additions & 2 deletions src/components/CodeBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ export const CodeBlock = memo(function CodeBlock({
className={`rounded-sm overflow-hidden bg-bg-200/25 contain-content ${className}`}
style={containerStyle}
>
<div className={scrollClasses} style={maxHeight ? { maxHeight } : undefined}>
<div data-scrollable className={scrollClasses} style={maxHeight ? { maxHeight } : undefined}>
{content}
</div>
</div>
Expand Down Expand Up @@ -275,7 +275,7 @@ export const CodeBlock = memo(function CodeBlock({
)}

{/* Scrollable content */}
<div className={scrollClasses} style={maxHeight ? { maxHeight } : undefined}>
<div data-scrollable className={scrollClasses} style={maxHeight ? { maxHeight } : undefined}>
{content}
</div>
</div>
Expand Down
4 changes: 4 additions & 0 deletions src/components/DiffViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,7 @@ const WrappedSplitDiffView = memo(function WrappedSplitDiffView({
return (
<div
ref={containerRef}
data-scrollable
className="overflow-y-auto overflow-x-hidden custom-scrollbar font-mono text-[length:var(--fs-code)] h-full"
onScroll={handleScroll}
style={maxHeight !== undefined ? { maxHeight } : undefined}
Expand Down Expand Up @@ -1115,6 +1116,7 @@ const SplitDiffView = memo(function SplitDiffView({
return (
<div
ref={containerRef}
data-scrollable
className="overflow-y-auto overflow-x-hidden custom-scrollbar font-mono text-[length:var(--fs-code)] h-full"
style={maxHeight !== undefined ? { maxHeight } : undefined}
onScroll={handleScroll}
Expand Down Expand Up @@ -1418,6 +1420,7 @@ const UnifiedDiffView = memo(function UnifiedDiffView({
return (
<div
ref={containerRef}
data-scrollable
className="overflow-y-auto overflow-x-hidden custom-scrollbar font-mono text-[length:var(--fs-code)] h-full"
style={maxHeight !== undefined ? { maxHeight } : undefined}
onScroll={handleScroll}
Expand Down Expand Up @@ -1586,6 +1589,7 @@ const WrappedUnifiedDiffView = memo(function WrappedUnifiedDiffView({
return (
<div
ref={containerRef}
data-scrollable
className="overflow-y-auto overflow-x-hidden custom-scrollbar font-mono text-[length:var(--fs-code)] h-full"
onScroll={handleScroll}
style={maxHeight !== undefined ? { maxHeight } : undefined}
Expand Down
2 changes: 2 additions & 0 deletions src/components/Icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
Check,
Send,
Plus,
Quote,
GraduationCap,
Settings,
Sun,
Expand Down Expand Up @@ -123,6 +124,7 @@ export const KeyboardIcon = wrap(Keyboard)
export const CheckIcon = wrap(Check)
export const SendIcon = wrap(Send)
export const PlusIcon = wrap(Plus)
export const QuoteIcon = wrap(Quote)
export const TeachIcon = wrap(GraduationCap)
export const SettingsIcon = wrap(Settings)
export const SunIcon = wrap(Sun)
Expand Down
2 changes: 1 addition & 1 deletion src/components/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1581,7 +1581,7 @@ export const HighlightedCode = memo(function HighlightedCode({
}, [filePath, language])

return (
<div className={`overflow-auto ${className}`} style={maxHeight ? { maxHeight } : undefined}>
<div data-scrollable className={`overflow-auto ${className}`} style={maxHeight ? { maxHeight } : undefined}>
<CodeBlock code={code} language={lang} />
</div>
)
Expand Down
1 change: 1 addition & 0 deletions src/components/ui/ScrollArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
return (
<div
ref={ref}
data-scrollable
className={`overflow-y-auto overflow-x-hidden ${className}`}
style={{
maxHeight: typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight,
Expand Down
140 changes: 70 additions & 70 deletions src/features/chat/ChatArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
import { useTheme } from '../../hooks/useTheme'
import { getStreamingHotIndexes, getTimelineRowYClass, mergeVirtualRangeIndexes } from './chatAreaUtils'
import { useAutoScroll } from './virtual/useAutoScroll'
import { AutoScrollProvider } from '../../hooks/AutoScrollContext'
import { useEmptyWorkingShellGate } from './virtual/useEmptyWorkingShellGate'

const NOOP = () => {}
Expand Down Expand Up @@ -98,6 +99,8 @@
bottomPadding?: number
onVisibleMessageIdsChange?: (ids: string[]) => void
onAtBottomChange?: (atBottom: boolean) => void
/** 用户跟随状态变化时调用:true=正在贴底跟随,false=用户主动停止跟随 */
onFollowingChange?: (following: boolean) => void
}

export type ChatAreaHandle = {
Expand Down Expand Up @@ -342,6 +345,7 @@
bottomPadding = 0,
onVisibleMessageIdsChange,
onAtBottomChange,
onFollowingChange,
},
ref,
) => {
Expand Down Expand Up @@ -441,6 +445,8 @@
onVisibleIdsRef.current = onVisibleMessageIdsChange
const onAtBottomRef = useRef(onAtBottomChange)
onAtBottomRef.current = onAtBottomChange
const onFollowingRef = useRef(onFollowingChange)
onFollowingRef.current = onFollowingChange
const hasMoreRef = useRef(hasMoreHistory)
hasMoreRef.current = hasMoreHistory
const loadStateRef = useRef(loadState)
Expand All @@ -459,18 +465,24 @@
const autoSetScrollRef = auto.setScrollRef
const autoSetContentRef = auto.setContentRef
const autoHandleScroll = auto.handleScroll
const autoHandleWheel = auto.handleWheel
const autoHandleInteraction = auto.handleInteraction
const autoForceScroll = auto.forceScrollToBottom
const autoScrollBottom = auto.scrollToBottom
const autoPause = auto.pause
const autoMarkAuto = auto.markAuto
const autoSetStreaming = auto.setStreaming
const userScrolledRef = auto.userScrolledRef
const autoSetPinToBottom = auto.setPinToBottom
const spacerHeight = bottomSpacerHeight(bottomPadding)
// 贴底判断必须读 ref:wheel→stop 后 state 还没 re-render,
// 若仍用 state,同一帧的 ResizeObserver 会误判仍可贴底。
const shouldAnchorBottom = () => !userScrolledRef.current

// 给 message tree 里的 disclosure widget 用:用户主动操作(展开/折叠)
// 时通知这里停止贴底跟随。值 stable(pause 是 useCallback),不会引起消费方 re-render。
const autoScrollCtxValue = useMemo(() => ({ pause: autoPause }), [autoPause])

// 同步 isStreaming 到 useAutoScroll —— selection 只在流式时才影响跟随
useEffect(() => { autoSetStreaming(isStreaming) }, [autoSetStreaming, isStreaming])

// ── 滚动状态(同步计算,不使用 rAF) ──
// prevState.bottom 仍是几何贴底,给 scrollToBottomIfAtBottom 用。
// onAtBottomChange 给输入区 dock / 回底按钮:只反映 !userScrolled(用户意图),
Expand Down Expand Up @@ -536,7 +548,6 @@
// 预写 total height,避免浏览器把新 offset clamp 到旧高度(oc 同款)
scrollToFn: (offset, options, instance) => {
if (contentRef.current) contentRef.current.style.height = `${instance.getTotalSize()}px`
autoMarkAuto(scrollRef.current)
elementScroll(offset, options, instance)
},
anchorTo: 'end',
Expand All @@ -555,7 +566,7 @@
if (!overridesApplied.current) {
const origResize = virtualizer.resizeItem
virtualizer.resizeItem = (index: number, size: number) => {
const item = (virtualizer as any).measurementsCache[index]

Check warning on line 569 in src/features/chat/ChatArea.tsx

View workflow job for this annotation

GitHub Actions / validate

Unexpected any. Specify a different type
const prev = item ? ((virtualizer as any).itemSizeCache.get(item.key) ?? item.size) : undefined
const root = scrollRef.current
if (root && prev !== undefined && Math.abs(size - prev) > root.clientHeight) {
Expand All @@ -574,13 +585,13 @@
})
})
}
// 核心修复:用户已上滚(userScrolledRef)时,临时关掉 anchorTo:'end',
// 用户已上滚(userScrolledRef)时,临时关掉 anchorTo:'end',
// 阻止 virtual-core resizeItem 内部的 wasAtEnd 路径(applyScrollAdjustment 拉回)。
// wasAtEnd 用 getVirtualDistanceFromEnd()(基于内部 scrollOffset),
// 但 React commit 阶段 ref 回调触发 measureElement 时 scroll 事件还没 fire,
// scrollOffset 是陈旧的(仍指向底部),wasAtEnd 误判为 true → 拉回。
// userScrolledRef 由 handleWheel 上滚设 true,只由 handleWheel 下滚回底设 false
// handleScroll 不清它(避免流式增长推回时误清)
// userScrolledRef 现在只由输入事件设置(useAutoScroll 的 wheel/touch/...)
// handleScroll 在用户回到底部阈值内时清掉
if (userScrolledRef.current) {
const opts = (virtualizer as any).options
const origAnchor = opts.anchorTo
Expand All @@ -598,7 +609,6 @@
if (!(virtualizer as any).isAtEnd?.(80)) return
const el = scrollRef.current
if (!el) return
autoMarkAuto(el)
const max = Math.max(0, el.scrollHeight - el.clientHeight)
if (max - el.scrollTop >= 2) el.scrollTop = max
})
Expand Down Expand Up @@ -731,10 +741,14 @@
// 必须滚到整页底(含 retry/error + 输入框 spacer),不能只 scrollToEnd 虚拟消息区
const el = scrollRef.current
if (!el) return
autoMarkAuto(el)
const max = Math.max(0, el.scrollHeight - el.clientHeight)
if (max - el.scrollTop >= 2) el.scrollTop = max
}, [autoMarkAuto])
}, [])

// 把 pinToBottom 注入 useAutoScroll,让 drift 自愈路径能调到
useEffect(() => {
autoSetPinToBottom(pinToBottom)
}, [autoSetPinToBottom, pinToBottom])

// ── 事件处理 ──
const onScroll = useCallback(() => {
Expand All @@ -752,13 +766,9 @@
autoHandleScroll()
}, [updatePrependAnchor, computeScrollState, autoHandleScroll, loadMore, userScrolledRef])

const onWheel = useCallback(
(e: React.WheelEvent<HTMLDivElement>) => {
if (!prependLoading.current) clearPrepend()
autoHandleWheel(e.nativeEvent)
},
[autoHandleWheel, clearPrepend],
)
const onWheel = useCallback(() => {
if (!prependLoading.current) clearPrepend()
}, [clearPrepend])

const onTouchStart = useCallback(() => {
if (!prependLoading.current) clearPrepend()
Expand Down Expand Up @@ -825,6 +835,12 @@
return () => cancelAnimationFrame(frame)
}, [auto.userScrolled, autoScrollBottom, pinToBottom])

// 通知父组件跟随状态变化(toBottom 按钮的显隐依据)
// useLayoutEffect 避免新 session remount 时按钮闪烁
useLayoutEffect(() => {
onFollowingRef.current?.(!auto.userScrolled)
}, [auto.userScrolled])

// fill effect
useEffect(() => {
if (!sessionId || loadState !== 'loaded' || isLoadingMore || auto.userScrolled || !hasMoreHistory) return
Expand Down Expand Up @@ -888,59 +904,43 @@
}, [mountedMessageIdsKey])

// ── 命令式接口 ──
useImperativeHandle(
ref,
() => ({
scrollToBottom: () => {
autoForceScroll()
pinToBottom()
},
scrollToBottomIfAtBottom: () => {
// userScrolled 守卫:用户上滚后 userScrolled=true,此函数由 onScrollRequest
//(每个 SSE chunk)调用。autoForceScroll() 的 force=true 会清掉 userScrolled,
// 导致 resizeItem 的 anchorTo toggle 失效 → wasAtEnd 恢复拉回。
// 不在此处清 userScrolled——用户主动下滚回底时 handleWheel 会清。
// 正常贴底跟随(userScrolled=false 时)不受影响。
if (userScrolledRef.current) return
if (!prevState.current.bottom) return
autoForceScroll()
pinToBottom()
},
scrollToLastMessage: () => {
if (timeline.length === 0) return
autoMarkAuto(scrollRef.current)
virtualizer.scrollToIndex(timeline.length - 1, { align: 'end' })
},
scrollToMessageIndex: (index: number) => {
// index 仍按 visibleMessages 语义;过程折叠时映射到 timeline 行
if (index < 0 || index >= visibleMessages.length) return
const messageId = visibleMessages[index]?.info.id
const timelineIndex = messageId != null ? (messageIdToTimelineIndex.get(messageId) ?? index) : index
if (timelineIndex < 0 || timelineIndex >= timeline.length) return
autoPause()
virtualizer.scrollToIndex(timelineIndex, { align: 'center' })
},
scrollToMessageId: (messageId: string) => {
const timelineIndex = messageIdToTimelineIndex.get(messageId)
if (timelineIndex == null) return
autoPause()
virtualizer.scrollToIndex(timelineIndex, { align: 'center' })
},
}),
[
autoForceScroll,
autoPause,
autoMarkAuto,
pinToBottom,
virtualizer,
timeline,
visibleMessages,
messageIdToTimelineIndex,
],
)
useImperativeHandle(ref, () => ({
scrollToBottom: () => {
autoForceScroll()
},
scrollToBottomIfAtBottom: () => {
// 每个 SSE chunk 调用一次。userScrolled=true(用户主动操作过)时直接 return,
// 否则贴底。不再用 prevState.bottom 做二次门控——
// 用户手势是唯一停止信号,drift 之类不应该让 chunk 停止 pin。
if (userScrolledRef.current) return
pinToBottom()
},
scrollToLastMessage: () => {
if (timeline.length === 0) return
virtualizer.scrollToIndex(timeline.length - 1, { align: 'end' })
},
scrollToMessageIndex: (index: number) => {
// index 仍按 visibleMessages 语义;过程折叠时映射到 timeline 行
if (index < 0 || index >= visibleMessages.length) return
const messageId = visibleMessages[index]?.info.id
const timelineIndex = messageId != null
? (messageIdToTimelineIndex.get(messageId) ?? index)
: index
if (timelineIndex < 0 || timelineIndex >= timeline.length) return
autoPause()
virtualizer.scrollToIndex(timelineIndex, { align: 'center' })
},
scrollToMessageId: (messageId: string) => {
const timelineIndex = messageIdToTimelineIndex.get(messageId)
if (timelineIndex == null) return
autoPause()
virtualizer.scrollToIndex(timelineIndex, { align: 'center' })
},
}), [autoForceScroll, autoPause, pinToBottom, virtualizer, timeline, visibleMessages, messageIdToTimelineIndex])

return (
<div className="h-full overflow-hidden contain-strict relative">
<AutoScrollProvider value={autoScrollCtxValue}>
<div className="h-full overflow-hidden contain-strict relative">
{loadState === 'loading' && visibleMessages.length === 0 && (
<div className="absolute inset-0 z-10 flex items-center justify-center">
<div className="flex flex-col items-center gap-3 text-text-400 session-loading-indicator">
Expand All @@ -961,7 +961,6 @@
onWheel={onWheel}
onTouchStart={onTouchStart}
onScroll={onScroll}
onClick={autoHandleInteraction}
>
{visibleMessages.length > 0 && isLoadingMore && (
<div className="flex justify-center py-3" aria-live="polite">
Expand Down Expand Up @@ -1032,7 +1031,8 @@

<div style={{ height: spacerHeight }} aria-hidden="true" />
</div>
</div>
</div>
</AutoScrollProvider>
)
},
),
Expand Down
Loading
Loading