diff --git a/packages/tui/src/component/dialog-experiments.tsx b/packages/tui/src/component/dialog-experiments.tsx index 04b9a4f1713..3c5f0e18b46 100644 --- a/packages/tui/src/component/dialog-experiments.tsx +++ b/packages/tui/src/component/dialog-experiments.tsx @@ -13,7 +13,13 @@ type Experiment = { // In-flight features anyone can opt into. Each entry is temporary: an // experiment either graduates (delete the entry, make the behavior // unconditional) or dies (delete the entry and the branch it gated). -export const experiments: Experiment[] = [] +export const experiments: Experiment[] = [ + { + id: "tab_scroll", + title: "Remember tab scroll", + description: "Keep each open tab's reading position and show a shortcut back to the bottom.", + }, +] export function DialogExperiments() { const config = useConfig() @@ -27,7 +33,6 @@ export function DialogExperiments() { const options = createMemo(() => experiments.map((experiment) => ({ title: experiment.title, - category: "Experiments", searchText: experiment.description, footer: enabled(experiment) ? "on" : "off", value: experiment, diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 3503030addb..b29205d2b2e 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -66,6 +66,12 @@ 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() + + createEffect(() => { + if (config.experimental?.tab_scroll === true) return + scrollPositions.clear() + }) function state() { if (config.tabs.scope === "cwd") return store.cwd[paths.cwd] ?? fallback @@ -231,6 +237,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp function remove(sessionID: string, navigate: boolean) { const target = root(sessionID) + scrollPositions.delete(target) const closed = closeSessionTab(state().tabs, target) const selected = navigate && current() === target if (closed.tabs === state().tabs && !selected) return @@ -262,6 +269,19 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp }, current, status, + scrollPosition(sessionID: string) { + const target = root(sessionID) + if (!state().tabs.some((tab) => tab.sessionID === target)) return + return scrollPositions.get(target) + }, + setScrollPosition(sessionID: string, position: number | undefined) { + const target = root(sessionID) + if (position === undefined || !state().tabs.some((tab) => tab.sessionID === target)) { + scrollPositions.delete(target) + return + } + scrollPositions.set(target, position) + }, select(sessionID: string) { if (!enabled()) return route.navigate({ type: "session", sessionID: root(sessionID) }) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 5f5df94e774..397a46cf035 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -274,6 +274,7 @@ export function Session() { const [navigationSlack, setNavigationSlack] = createSignal(0) const [synced, setSynced] = createSignal(false) const sessionTabs = useSessionTabs() + const [awayFromBottom, setAwayFromBottom] = createSignal(false) const clearMessageNavigation = () => { setNavigationSlack(0) @@ -319,7 +320,7 @@ export function Session() { return } editor.reconnect(info.location.directory) - if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) + if (route.sessionID === sessionID && scroll) restoreScrollPosition(sessionID) setSynced(true) })().catch((error) => { if (route.sessionID !== sessionID) return @@ -335,6 +336,13 @@ export function Session() { let seeded = false let sent = false let scroll: ScrollBoxRenderable + onCleanup(() => { + if (!scroll || scroll.isDestroyed) return + sessionTabs.setScrollPosition( + route.sessionID, + config.experimental?.tab_scroll === true && isAwayFromBottom() ? scroll.scrollTop : undefined, + ) + }) const [prompt, setPrompt] = createSignal() const bind = (r: PromptRef | undefined) => { setPrompt(r) @@ -387,6 +395,31 @@ export function Session() { afterLayout(continuation) } + function isAwayFromBottom() { + return scroll.scrollTop < Math.max(0, scroll.scrollHeight - scroll.viewport.height) - 1 + } + function updateAwayFromBottom() { + if (config.experimental?.tab_scroll !== true) return + setTimeout(() => { + if (!scroll || scroll.isDestroyed) return + const away = isAwayFromBottom() + setAwayFromBottom(away) + if (!away) sessionTabs.setScrollPosition(route.sessionID, undefined) + }) + } + function restoreScrollPosition(sessionID: string) { + const position = config.experimental?.tab_scroll === true ? sessionTabs.scrollPosition(sessionID) : undefined + if (position === undefined) { + scroll.scrollTo(scroll.scrollHeight) + setAwayFromBottom(false) + return + } + ensureAllRows(() => { + scroll.scrollTo(position) + updateAwayFromBottom() + }) + } + createEffect(() => { const current = prompt() if (sent || !current || !synced() || !local.model.ready || !local.model.catalogReady) return @@ -495,6 +528,8 @@ export function Session() { function toBottom() { clearMessageNavigation() + setAwayFromBottom(false) + sessionTabs.setScrollPosition(route.sessionID, undefined) setTimeout(() => { if (!scroll || scroll.isDestroyed) return scroll.scrollTo(scroll.scrollHeight) @@ -510,6 +545,7 @@ export function Session() { run: () => { clearMessageNavigation() scroll.scrollBy(-scroll.height / 2) + updateAwayFromBottom() dialog.clear() }, }, @@ -521,6 +557,7 @@ export function Session() { run: () => { clearMessageNavigation() scroll.scrollBy(scroll.height / 2) + updateAwayFromBottom() dialog.clear() }, }, @@ -532,6 +569,7 @@ export function Session() { run: () => { clearMessageNavigation() scroll.scrollBy(-1) + updateAwayFromBottom() dialog.clear() }, }, @@ -543,6 +581,7 @@ export function Session() { run: () => { clearMessageNavigation() scroll.scrollBy(1) + updateAwayFromBottom() dialog.clear() }, }, @@ -554,6 +593,7 @@ export function Session() { run: () => { clearMessageNavigation() scroll.scrollBy(-scroll.height / 4) + updateAwayFromBottom() dialog.clear() }, }, @@ -565,6 +605,7 @@ export function Session() { run: () => { clearMessageNavigation() scroll.scrollBy(scroll.height / 4) + updateAwayFromBottom() dialog.clear() }, }, @@ -579,6 +620,7 @@ export function Session() { run: () => { clearMessageNavigation() scroll.scrollTo(0) + updateAwayFromBottom() dialog.clear() }, }, @@ -588,8 +630,7 @@ export function Session() { group: "Session", palette: undefined, run: () => { - clearMessageNavigation() - scroll.scrollTo(scroll.scrollHeight) + toBottom() dialog.clear() }, }, @@ -1006,8 +1047,6 @@ export function Session() { bindings: [...baseAndUnfocusedCommands, ...baseCommands()].map((command) => command.id), })) - // snap to bottom when session changes - createEffect(on(() => route.sessionID, toBottom)) createEffect( on( () => route.sessionID, @@ -1043,47 +1082,56 @@ export function Session() { paddingBottom={1} paddingLeft={dimensions().width < 44 ? 1 : 2} paddingRight={dimensions().width < 44 ? 1 : 2} - gap={1} > - (scroll = r)} - viewportOptions={{ - paddingRight: showScrollbar() ? 1 : 0, - }} - verticalScrollbarOptions={{ - paddingLeft: 1, - visible: showScrollbar(), - trackOptions: { - backgroundColor: theme.raise(theme.background.surface.offset), - foregroundColor: theme.border.default, - }, - }} - stickyScroll={!navigationMessage()} - stickyStart="bottom" - flexGrow={1} - scrollAcceleration={scrollAcceleration()} - > - - {(row, index) => ( - data.session.message.get(route.sessionID, messageID)} - boundaryID={boundaries()[index() + hidden()]} + + (scroll = r)} + viewportOptions={{ + paddingRight: showScrollbar() ? 1 : 0, + }} + verticalScrollbarOptions={{ + paddingLeft: 1, + visible: showScrollbar(), + trackOptions: { + backgroundColor: theme.raise(theme.background.surface.offset), + foregroundColor: theme.border.default, + }, + }} + stickyScroll={!navigationMessage()} + stickyStart="bottom" + flexGrow={1} + scrollAcceleration={scrollAcceleration()} + onMouseScroll={updateAwayFromBottom} + > + + {(row, index) => ( + data.session.message.get(route.sessionID, messageID)} + boundaryID={boundaries()[index() + hidden()]} + /> + )} + + + + message.type === "user").length} + files={session()!.revert!.files ?? []} /> - )} - - - - message.type === "user").length} - files={session()!.revert!.files ?? []} - /> + + + {(height) => } + + + + + + + Latest ↓ + - - {(height) => } - - + 0}>