mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-22 00:53:34 +00:00
fix(tui): isolate tab scroll state (#42456)
This commit is contained in:
parent
82b96eb495
commit
71caa7d406
3 changed files with 138 additions and 32 deletions
|
|
@ -38,6 +38,11 @@ type PersistedState = {
|
|||
cwd: Record<string, TabsState>
|
||||
}
|
||||
|
||||
type ScrollAnchor = {
|
||||
messageID: string
|
||||
screenY: number
|
||||
}
|
||||
|
||||
const empty = (): TabsState => ({ tabs: [], unread: {} })
|
||||
|
||||
// Deliberately after connect settles: the visible session's mount syncs win the first slots.
|
||||
|
|
@ -71,7 +76,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||
let history: SessionTabHistory = { entries: [], index: -1 }
|
||||
// User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
|
||||
let closedTabs: ClosedSessionTab[] = []
|
||||
const scrollPositions = new Map<string, number>()
|
||||
const scrollAnchors = new Map<string, ScrollAnchor>()
|
||||
|
||||
const onFocus = () => setFocused(true)
|
||||
const onBlur = () => setFocused(false)
|
||||
|
|
@ -84,7 +89,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||
|
||||
createEffect(() => {
|
||||
if (config.experimental?.tab_scroll === true) return
|
||||
scrollPositions.clear()
|
||||
scrollAnchors.clear()
|
||||
})
|
||||
|
||||
function state() {
|
||||
|
|
@ -260,7 +265,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||
|
||||
function remove(sessionID: string, navigate: boolean) {
|
||||
const target = root(sessionID)
|
||||
scrollPositions.delete(target)
|
||||
scrollAnchors.delete(target)
|
||||
const closed = closeSessionTab(state().tabs, target)
|
||||
const selected = navigate && current() === target
|
||||
if (closed.tabs === state().tabs && !selected) return
|
||||
|
|
@ -292,18 +297,20 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||
},
|
||||
current,
|
||||
status,
|
||||
scrollPosition(sessionID: string) {
|
||||
scrollAnchor(sessionID: string) {
|
||||
const target = root(sessionID)
|
||||
if (!state().tabs.some((tab) => tab.sessionID === target)) return
|
||||
return scrollPositions.get(target)
|
||||
return scrollAnchors.get(target)
|
||||
},
|
||||
setScrollPosition(sessionID: string, position: number | undefined) {
|
||||
setScrollAnchor(sessionID: string, anchor: ScrollAnchor | undefined) {
|
||||
const target = root(sessionID)
|
||||
if (position === undefined || !state().tabs.some((tab) => tab.sessionID === target)) {
|
||||
scrollPositions.delete(target)
|
||||
if (anchor === undefined || !state().tabs.some((tab) => tab.sessionID === target)) {
|
||||
scrollAnchors.delete(target)
|
||||
return
|
||||
}
|
||||
scrollPositions.set(target, position)
|
||||
const current = scrollAnchors.get(target)
|
||||
if (current?.messageID === anchor.messageID && current.screenY === anchor.screenY) return
|
||||
scrollAnchors.set(target, anchor)
|
||||
},
|
||||
select(sessionID: string) {
|
||||
if (!enabled()) return
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@ export function Session() {
|
|||
await writeFile(file, content)
|
||||
}
|
||||
const route = useRouteData("session")
|
||||
const sessionID = route.sessionID
|
||||
const { navigate } = useRoute()
|
||||
const data = useData()
|
||||
const local = useLocal()
|
||||
|
|
@ -266,13 +267,21 @@ export function Session() {
|
|||
})
|
||||
})
|
||||
const editor = useEditorContext()
|
||||
const rows = createSessionRows(() => route.sessionID)
|
||||
const [rowsSynced, setRowsSynced] = createSignal(false)
|
||||
const rows = createSessionRows(
|
||||
() => route.sessionID,
|
||||
(id) => {
|
||||
if (id === sessionID) setRowsSynced(true)
|
||||
},
|
||||
)
|
||||
const boundaries = createMemo(() => messageBoundaryIDs(rows, messages()))
|
||||
const boundaryIDs = createMemo(() => new Set(boundaries().filter((id) => id !== undefined)))
|
||||
const [navigationMessage, setNavigationMessage] = createSignal<string>()
|
||||
const [navigationSlack, setNavigationSlack] = createSignal(0)
|
||||
const [synced, setSynced] = createSignal(false)
|
||||
const sessionTabs = useSessionTabs()
|
||||
const [awayFromBottom, setAwayFromBottom] = createSignal(false)
|
||||
const [latestHovered, setLatestHovered] = createSignal(false)
|
||||
|
||||
const clearMessageNavigation = () => {
|
||||
setNavigationSlack(0)
|
||||
|
|
@ -318,7 +327,6 @@ export function Session() {
|
|||
return
|
||||
}
|
||||
editor.reconnect(info.location.directory)
|
||||
if (route.sessionID === sessionID && scroll) restoreScrollPosition(sessionID)
|
||||
setSynced(true)
|
||||
})().catch((error) => {
|
||||
if (route.sessionID !== sessionID) return
|
||||
|
|
@ -333,13 +341,18 @@ export function Session() {
|
|||
|
||||
let seeded = false
|
||||
let sent = false
|
||||
let restored = false
|
||||
let scroll: ScrollBoxRenderable
|
||||
createEffect(() => {
|
||||
if (restored || !synced() || !rowsSynced() || !scroll || scroll.isDestroyed) return
|
||||
restored = true
|
||||
restoreScrollPosition()
|
||||
})
|
||||
let awayTimer: ReturnType<typeof setTimeout> | undefined
|
||||
onCleanup(() => {
|
||||
if (awayTimer) clearTimeout(awayTimer)
|
||||
if (!scroll || scroll.isDestroyed) return
|
||||
sessionTabs.setScrollPosition(
|
||||
route.sessionID,
|
||||
config.experimental?.tab_scroll === true && isAwayFromBottom() ? scroll.scrollTop : undefined,
|
||||
)
|
||||
saveScrollAnchor()
|
||||
})
|
||||
const [prompt, setPrompt] = createSignal<PromptRef>()
|
||||
const bind = (r: PromptRef | undefined) => {
|
||||
|
|
@ -364,8 +377,10 @@ export function Session() {
|
|||
// mount on demand near the top, keeping inactive tabs cheap to tear down. Until the first chunk
|
||||
// pins the count, the hidden span derives from the row count, so streaming appends remain visible.
|
||||
const [hiddenRows, setHiddenRows] = createSignal<number>()
|
||||
const [visibleRowsEnd, setVisibleRowsEnd] = createSignal<number>()
|
||||
const hidden = createMemo(() => Math.max(0, Math.min(hiddenRows() ?? Infinity, rows.length - TRANSCRIPT_TAIL_ROWS)))
|
||||
const visibleRows = createMemo(() => (hidden() === 0 ? rows : rows.slice(hidden())))
|
||||
const visibleEnd = createMemo(() => Math.max(hidden(), Math.min(visibleRowsEnd() ?? rows.length, rows.length)))
|
||||
const visibleRows = createMemo(() => rows.slice(hidden(), visibleEnd()))
|
||||
let revealingOlderRows = false
|
||||
const revealOlderRows = (scrollBy = 0) => {
|
||||
const current = hidden()
|
||||
|
|
@ -387,36 +402,102 @@ export function Session() {
|
|||
})
|
||||
return true
|
||||
}
|
||||
let revealingNewerRows = false
|
||||
const revealNewerRows = (scrollBy = 0) => {
|
||||
const current = visibleEnd()
|
||||
if (
|
||||
revealingNewerRows ||
|
||||
current === rows.length ||
|
||||
!scroll ||
|
||||
scroll.isDestroyed ||
|
||||
scroll.scrollTop + scroll.viewport.height < scroll.scrollHeight - scroll.viewport.height
|
||||
)
|
||||
return false
|
||||
revealingNewerRows = true
|
||||
const next = Math.min(rows.length, current + TRANSCRIPT_BACKFILL_CHUNK)
|
||||
setVisibleRowsEnd(next === rows.length ? undefined : next)
|
||||
afterLayout(() => {
|
||||
revealingNewerRows = false
|
||||
scroll.scrollBy(scrollBy)
|
||||
updateAwayFromBottom()
|
||||
})
|
||||
return true
|
||||
}
|
||||
/** Message navigation needs the full transcript mounted before walking or jumping. */
|
||||
const ensureAllRows = (continuation: () => void) => {
|
||||
if (hidden() === 0) return continuation()
|
||||
if (hidden() === 0 && visibleEnd() === rows.length) return continuation()
|
||||
setHiddenRows(0)
|
||||
setVisibleRowsEnd(undefined)
|
||||
afterLayout(continuation)
|
||||
}
|
||||
|
||||
function isAwayFromBottom() {
|
||||
if (visibleEnd() < rows.length) return true
|
||||
return scroll.scrollTop < Math.max(0, scroll.scrollHeight - scroll.viewport.height) - 1
|
||||
}
|
||||
function updateAwayFromBottom() {
|
||||
if (config.experimental?.tab_scroll !== true) return
|
||||
setTimeout(() => {
|
||||
if (awayTimer) clearTimeout(awayTimer)
|
||||
awayTimer = setTimeout(() => {
|
||||
awayTimer = undefined
|
||||
if (!scroll || scroll.isDestroyed) return
|
||||
const away = isAwayFromBottom()
|
||||
setAwayFromBottom(away)
|
||||
if (!away) sessionTabs.setScrollPosition(route.sessionID, undefined)
|
||||
saveScrollAnchor()
|
||||
})
|
||||
}
|
||||
function restoreScrollPosition(sessionID: string) {
|
||||
const position = config.experimental?.tab_scroll === true ? sessionTabs.scrollPosition(sessionID) : undefined
|
||||
if (position === undefined) {
|
||||
function saveScrollAnchor() {
|
||||
if (config.experimental?.tab_scroll !== true || !isAwayFromBottom()) {
|
||||
sessionTabs.setScrollAnchor(sessionID, undefined)
|
||||
return
|
||||
}
|
||||
let first: { messageID: string; screenY: number } | undefined
|
||||
let anchor: { messageID: string; screenY: number } | undefined
|
||||
for (const child of scroll.getChildren()) {
|
||||
if (!child.id || !boundaryIDs().has(child.id)) continue
|
||||
const item = { messageID: child.id, screenY: child.y - scroll.viewport.y }
|
||||
first ??= item
|
||||
if (item.screenY <= 0) anchor = item
|
||||
}
|
||||
anchor ??= first
|
||||
if (anchor) sessionTabs.setScrollAnchor(sessionID, anchor)
|
||||
else sessionTabs.setScrollAnchor(sessionID, undefined)
|
||||
}
|
||||
function restoreScrollPosition() {
|
||||
const anchor = config.experimental?.tab_scroll === true ? sessionTabs.scrollAnchor(sessionID) : undefined
|
||||
const index = anchor ? boundaries().indexOf(anchor.messageID) : -1
|
||||
if (!anchor || index === -1) {
|
||||
scroll.scrollTo(scroll.scrollHeight)
|
||||
setAwayFromBottom(false)
|
||||
return
|
||||
}
|
||||
ensureAllRows(() => {
|
||||
scroll.scrollTo(position)
|
||||
updateAwayFromBottom()
|
||||
})
|
||||
setHiddenRows(Math.max(0, index - TRANSCRIPT_BACKFILL_CHUNK))
|
||||
const end = Math.min(rows.length, index + TRANSCRIPT_BACKFILL_CHUNK)
|
||||
setVisibleRowsEnd(end === rows.length ? undefined : end)
|
||||
scroll.stickyScroll = false
|
||||
const restore = () =>
|
||||
afterLayout(() => {
|
||||
const boundary = scroll.getRenderable(anchor.messageID)
|
||||
if (!boundary) {
|
||||
sessionTabs.setScrollAnchor(sessionID, undefined)
|
||||
scroll.stickyScroll = true
|
||||
scroll.scrollTo(scroll.scrollHeight)
|
||||
setAwayFromBottom(false)
|
||||
return
|
||||
}
|
||||
const contentY = scroll.scrollTop + boundary.y - scroll.viewport.y
|
||||
const target = contentY - anchor.screenY
|
||||
const maximum = Math.max(0, scroll.scrollHeight - scroll.viewport.height)
|
||||
if (target > maximum && visibleEnd() < rows.length) {
|
||||
const next = Math.min(rows.length, visibleEnd() + TRANSCRIPT_BACKFILL_CHUNK)
|
||||
setVisibleRowsEnd(next === rows.length ? undefined : next)
|
||||
restore()
|
||||
return
|
||||
}
|
||||
scroll.scrollTo(target)
|
||||
updateAwayFromBottom()
|
||||
})
|
||||
restore()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
|
|
@ -527,20 +608,31 @@ export function Session() {
|
|||
|
||||
function toBottom() {
|
||||
clearMessageNavigation()
|
||||
if (awayTimer) clearTimeout(awayTimer)
|
||||
awayTimer = undefined
|
||||
setAwayFromBottom(false)
|
||||
sessionTabs.setScrollPosition(route.sessionID, undefined)
|
||||
sessionTabs.setScrollAnchor(route.sessionID, undefined)
|
||||
setHiddenRows(undefined)
|
||||
setVisibleRowsEnd(undefined)
|
||||
setTimeout(() => {
|
||||
if (!scroll || scroll.isDestroyed) return
|
||||
scroll.stickyScroll = true
|
||||
scroll.scrollTo(scroll.scrollHeight)
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function moveTranscript(delta: number) {
|
||||
clearMessageNavigation()
|
||||
if (delta >= 0 || !revealOlderRows(delta)) {
|
||||
scroll.scrollBy(delta)
|
||||
updateAwayFromBottom()
|
||||
if (delta < 0 && revealOlderRows(delta)) {
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
if (delta > 0 && revealNewerRows(delta)) {
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
scroll.scrollBy(delta)
|
||||
updateAwayFromBottom()
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
|
|
@ -1084,6 +1176,7 @@ export function Session() {
|
|||
scrollAcceleration={scrollAcceleration()}
|
||||
onMouseScroll={(event) => {
|
||||
if (event.scroll?.direction === "up" && revealOlderRows()) return
|
||||
if (event.scroll?.direction === "down" && revealNewerRows()) return
|
||||
updateAwayFromBottom()
|
||||
}}
|
||||
>
|
||||
|
|
@ -1110,7 +1203,12 @@ export function Session() {
|
|||
</box>
|
||||
<box height={1} flexShrink={0} flexDirection="row" justifyContent="flex-end">
|
||||
<Show when={config.experimental?.tab_scroll === true && awayFromBottom()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={toBottom}>
|
||||
<text
|
||||
fg={latestHovered() ? theme.text.default : theme.text.subdued}
|
||||
onMouseOver={() => setLatestHovered(true)}
|
||||
onMouseOut={() => setLatestHovered(false)}
|
||||
onMouseUp={toBottom}
|
||||
>
|
||||
Latest ↓
|
||||
</text>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export type SessionRow =
|
|||
| { type: "assistant-footer"; messageID: string }
|
||||
| { type: "turn-usage"; messageIDs: string[]; previousCache?: CacheUsage }
|
||||
|
||||
export function createSessionRows(sessionID: Accessor<string>) {
|
||||
export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessionID: string) => void) {
|
||||
const data = useData()
|
||||
const client = useClient()
|
||||
const config = useConfig()
|
||||
|
|
@ -95,6 +95,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||
() => {
|
||||
if (sessionID() !== id) return
|
||||
setRows(reconcile(reduce()))
|
||||
onSynced?.(id)
|
||||
},
|
||||
() => undefined,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue