feat: scroll nav mode setting (scrollbar, buttons, none)

Adds a Nav selector in settings to choose between pixel art scrollbar,
aquatic arrow buttons, or no scroll navigation. Jump % slider only
shows in buttons mode. Persisted in localStorage.
This commit is contained in:
2026-02-20 20:02:34 -06:00
parent da26bc7b9e
commit 15731b8f69
2 changed files with 74 additions and 4 deletions

View File

@@ -162,6 +162,18 @@ function setInputMaxLines(val: number) {
localStorage.setItem('transcript-input-max-lines', String(val))
}
// Scroll nav mode: 'scrollbar' | 'buttons' | 'none'
type ScrollNavMode = 'scrollbar' | 'buttons' | 'none'
const savedScrollNav = localStorage.getItem('transcript-scroll-nav') as ScrollNavMode | null
const scrollNavMode = ref<ScrollNavMode>(
savedScrollNav && ['scrollbar', 'buttons', 'none'].includes(savedScrollNav) ? savedScrollNav : 'buttons'
)
function setScrollNavMode(val: ScrollNavMode) {
scrollNavMode.value = val
localStorage.setItem('transcript-scroll-nav', val)
}
// Scroll jump percent
const savedScrollJump = localStorage.getItem('transcript-scroll-jump')
const scrollJumpPercent = ref(savedScrollJump !== null ? parseInt(savedScrollJump) : 50)
@@ -538,7 +550,10 @@ onBeforeUnmount(() => {
resizing: isResizing,
mobile: effectiveMobile,
'chrome-visible': showChrome,
'selector-open': showSelector
'selector-open': showSelector,
'nav-scrollbar': scrollNavMode === 'scrollbar',
'nav-buttons': scrollNavMode === 'buttons',
'nav-none': scrollNavMode === 'none'
}"
:style="windowStyle"
@mouseenter="isHovered = true"
@@ -703,6 +718,7 @@ onBeforeUnmount(() => {
:overlay-opacity="overlayOpacity"
:input-max-lines="inputMaxLines"
:scroll-jump-percent="scrollJumpPercent"
:scroll-nav-mode="scrollNavMode"
:hook-permission-mode="hookMeta.permissionMode"
@send="handleSend"
@switch-agent="handleAgentSwitch"
@@ -717,6 +733,7 @@ onBeforeUnmount(() => {
@update:overlay-opacity="setOverlayOpacity"
@update:input-max-lines="setInputMaxLines"
@update:scroll-jump-percent="setScrollJumpPercent"
@update:scroll-nav-mode="setScrollNavMode"
/>
<div v-else class="empty-state">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
@@ -729,7 +746,7 @@ onBeforeUnmount(() => {
<!-- Scroll arrows (visible only in idle mode) -->
<Transition name="scroll-arrows">
<div v-if="showChrome && conversation" class="scroll-arrows">
<div v-if="showChrome && conversation && scrollNavMode === 'buttons'" class="scroll-arrows">
<!-- Top: double chevron up + wave accent -->
<button class="scroll-arrow sa-surface" @click="scrollToEdge('top')" title="Scroll to top">
<svg width="20" height="22" viewBox="0 0 20 22" shape-rendering="crispEdges">
@@ -1204,13 +1221,53 @@ onBeforeUnmount(() => {
padding-top: 5rem !important;
padding-bottom: 5rem !important;
flex: 1 !important;
}
/* Hide scrollbar for buttons and none modes */
.aero-win:not(.nav-scrollbar) .content :deep(.messages-scroll) {
scrollbar-width: none !important;
}
.content :deep(.messages-scroll)::-webkit-scrollbar {
.aero-win:not(.nav-scrollbar) .content :deep(.messages-scroll)::-webkit-scrollbar {
display: none !important;
}
/* Pixel art scrollbar (only in scrollbar mode) */
.aero-win.nav-scrollbar .content :deep(.messages-scroll) {
scrollbar-gutter: stable !important;
}
.aero-win.nav-scrollbar .content :deep(.messages-scroll)::-webkit-scrollbar {
width: 8px;
}
/* Idle: hide scrollbar visuals (gutter stays) */
.aero-win.nav-scrollbar:not(.chrome-visible) .content :deep(.messages-scroll)::-webkit-scrollbar-track {
background: transparent !important;
}
.aero-win.nav-scrollbar:not(.chrome-visible) .content :deep(.messages-scroll)::-webkit-scrollbar-thumb {
background: transparent !important;
border-color: transparent !important;
}
.aero-win.nav-scrollbar .content :deep(.messages-scroll)::-webkit-scrollbar-track {
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8' shape-rendering='crispEdges'%3E%3Crect x='0' y='0' width='8' height='8' fill='%230c2d4a' opacity='0.4'/%3E%3Crect x='2' y='2' width='2' height='2' fill='%23075985' opacity='0.15'/%3E%3Crect x='6' y='6' width='2' height='2' fill='%23075985' opacity='0.1'/%3E%3C/svg%3E") repeat;
}
.aero-win.nav-scrollbar .content :deep(.messages-scroll)::-webkit-scrollbar-thumb {
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='16' viewBox='0 0 8 16' shape-rendering='crispEdges'%3E%3Crect x='0' y='0' width='8' height='16' fill='%230ea5e9' opacity='0.3'/%3E%3Crect x='2' y='2' width='4' height='2' fill='%2322d3ee' opacity='0.25'/%3E%3Crect x='2' y='6' width='4' height='2' fill='%2367e8f9' opacity='0.2'/%3E%3Crect x='2' y='10' width='4' height='2' fill='%2322d3ee' opacity='0.25'/%3E%3Crect x='2' y='14' width='4' height='2' fill='%2367e8f9' opacity='0.15'/%3E%3C/svg%3E") repeat;
border: 1px solid rgba(14, 165, 233, 0.15);
}
.aero-win.nav-scrollbar .content :deep(.messages-scroll)::-webkit-scrollbar-thumb:hover {
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='16' viewBox='0 0 8 16' shape-rendering='crispEdges'%3E%3Crect x='0' y='0' width='8' height='16' fill='%230ea5e9' opacity='0.45'/%3E%3Crect x='2' y='2' width='4' height='2' fill='%2322d3ee' opacity='0.35'/%3E%3Crect x='2' y='6' width='4' height='2' fill='%2367e8f9' opacity='0.3'/%3E%3Crect x='2' y='10' width='4' height='2' fill='%2322d3ee' opacity='0.35'/%3E%3Crect x='2' y='14' width='4' height='2' fill='%2367e8f9' opacity='0.25'/%3E%3C/svg%3E") repeat;
border-color: rgba(14, 165, 233, 0.3);
}
.content :deep(.meta-badge) {
background: rgba(255,255,255,0.04);
color: rgba(255,255,255,0.4);

View File

@@ -40,6 +40,7 @@ const props = defineProps<{
overlayOpacity?: number
inputMaxLines?: number
scrollJumpPercent?: number
scrollNavMode?: 'scrollbar' | 'buttons' | 'none'
hookPermissionMode?: string
}>()
@@ -67,6 +68,7 @@ const emit = defineEmits<{
'update:overlayOpacity': [value: number]
'update:inputMaxLines': [value: number]
'update:scrollJumpPercent': [value: number]
'update:scrollNavMode': [value: 'scrollbar' | 'buttons' | 'none']
}>()
const scrollContainer = ref<HTMLElement | null>(null)
@@ -507,7 +509,18 @@ function formatDuration(start: string, end: string): string {
<span class="overlay-value">{{ inputMaxLines ?? 6 }}</span>
</div>
<div class="selector-row">
<label class="selector-label">Scroll</label>
<label class="selector-label">Nav</label>
<div class="agent-selector">
<button
v-for="mode in (['scrollbar', 'buttons', 'none'] as const)"
:key="mode"
:class="['agent-btn', { active: (scrollNavMode ?? 'buttons') === mode }]"
@click="emit('update:scrollNavMode', mode)"
>{{ mode }}</button>
</div>
</div>
<div v-if="(scrollNavMode ?? 'buttons') === 'buttons'" class="selector-row">
<label class="selector-label">Jump</label>
<input
type="range"
class="overlay-slider"