- {showMobileScrollButton ? (
- <>
-
-
- Latest
-
- {(isSessionActive || hasBackgroundActivity) && (
-
-
-
- )}
-
- >
- ) : (
+ {showMobileKeys ? renderMobileKeys() : (
<>
>
) : null}
- {(isSessionActive || hasBackgroundActivity) && (
-
-
-
- )}
- {!isSessionActive && !hideSecondaryButtons && (
-
- {displayModelName || 'Select model'}
- {hasVariants && currentVariant && (
- {currentVariant}
- )}
-
- )}
+
+ {displayModelName || 'Select model'}
+ {hasVariants && currentVariant && (
+ {currentVariant}
+ )}
+
>
)}
-
+
{!isMobile && (
@@ -1652,10 +1671,21 @@ return (
>
- {showMobileVoiceButton && (
+ {canUseMobileVoice && (
renderVoiceButton('mobile')
)}
- {isMobile && directory && (
+ {showMobileScrollButton ? (
+
e.preventDefault()}
+ onClick={onScrollToBottom}
+ className={cn(getIconToggleButtonClassName(false), 'flex items-center justify-center size-10 p-0')}
+ >
+
+
+ ) : isMobile && directory && (
void startVoiceRecording() : undefined}
/>
)}
{!isMobile && sttEnabled && sttSupported && (
renderVoiceButton('desktop')
)}
- {!showMobileVoiceButton && (
- setShowDialog(true) : handleSubmit}
- disabled={hasPendingPermissionForSession ? false : (!isModelReady || !hasPromptContent || (isPromptSubmitPending && !isStreamingResponse))}
- className={`px-4 md:px-5 py-1.5 md:py-2 rounded-lg text-sm font-medium transition-colors dark:border flex-shrink-0 min-w-[52px] flex items-center justify-center min-h-[44px] md:min-h-0 ${
- hasPendingPermissionForSession
- ? 'bg-highlight hover:bg-highlight/90 border-highlight text-highlight-foreground ring-highlight/20'
- : 'bg-primary hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed text-primary-foreground border-border/30'
- }`}
- title={hasPendingPermissionForSession ? 'View pending permission' : (isStreamingResponse ? 'Queue message' : 'Send')}
- >
- {hasPendingPermissionForSession ? 'View' : (isStreamingResponse ? 'Queue' : 'Send')}
-
- )}
- {isMobile && isPromptFocused && !hasPendingPermissionForSession && (
-
- {KEYBAR_KEYS.map((key) => {
- const isActive = key.id === 'command'
- ? showSuggestions
- : key.id === 'mention'
- ? showMentionSuggestions
- : isBashMode
- const isDisabled = key.id === 'bash' ? !isBashKeyEnabled : isBashMode
- return (
- e.preventDefault()}
- onTouchStart={keybarTouch.onTouchStart}
- onTouchMove={keybarTouch.onTouchMove}
- onTouchEnd={(e) => keybarTouch.onTouchEnd(e, key.id)}
- onClick={() => keybarTouch.onClick(key.id)}
- className={`flex items-center gap-1 min-h-[44px] px-3 rounded-lg border font-mono text-base font-bold transition-colors disabled:opacity-30 ${
- isActive ? COMPOSER_MODE_STYLES[key.id].key : 'bg-muted border-border text-foreground active:bg-muted-foreground/20'
- }`}
- >
- {key.symbol}
- {key.label}
-
- )
- })}
-
- )}
-
)}
-
+
{hasPermission(item.session.id) && (
)}
diff --git a/frontend/src/components/repo/MultiRunCard.tsx b/frontend/src/components/repo/MultiRunCard.tsx
index 2df5404ce..ebc18bcbd 100644
--- a/frontend/src/components/repo/MultiRunCard.tsx
+++ b/frontend/src/components/repo/MultiRunCard.tsx
@@ -329,7 +329,7 @@ function EntryRow({
{entry.model}
- {readiness.label === 'Running' && sessionId ? : null}
+ {readiness.label === 'Running' && sessionId ? : null}
{isolationLabel}
{detail ? (
@@ -389,7 +389,7 @@ function FusionRow({ fusion, onOpenSession, onWalkthrough }: FusionRowProps) {
{fusion.model}
- {fusion.status === 'started' && sessionId ? : null}
+ {fusion.status === 'started' && sessionId ? : null}
from {fusion.sources.length} source{fusion.sources.length === 1 ? '' : 's'}
diff --git a/frontend/src/components/session/SessionCard.tsx b/frontend/src/components/session/SessionCard.tsx
index e8c4ff07d..8ec24f89c 100644
--- a/frontend/src/components/session/SessionCard.tsx
+++ b/frontend/src/components/session/SessionCard.tsx
@@ -95,7 +95,7 @@ export const SessionCard = ({
}}
className="w-5 h-5 flex-shrink-0"
/>
-
+
@@ -130,7 +130,7 @@ export const SessionCard = ({
addSuffix: true,
})}
-
+
{onTogglePin && (
diff --git a/frontend/src/components/session/SessionPickerDialog.tsx b/frontend/src/components/session/SessionPickerDialog.tsx
index fb4fb849c..9f772d7d0 100644
--- a/frontend/src/components/session/SessionPickerDialog.tsx
+++ b/frontend/src/components/session/SessionPickerDialog.tsx
@@ -181,7 +181,7 @@ function SessionPickerGutter({ sessionID, isActive }: { sessionID: string; isAct
const status = useSessionStatusForSession(sessionID)
if (status.type !== 'idle') {
return (
-
+
)
}
if (isActive) {
diff --git a/frontend/src/components/ui/scanner-bars.tsx b/frontend/src/components/ui/scanner-bars.tsx
new file mode 100644
index 000000000..c297df342
--- /dev/null
+++ b/frontend/src/components/ui/scanner-bars.tsx
@@ -0,0 +1,37 @@
+import { cn } from '@/lib/utils'
+
+export type ScannerTone = 'info' | 'primary' | 'warning' | 'muted-foreground'
+
+const SCANNER_TONE_CLASS: Record = {
+ info: 'text-info',
+ primary: 'text-primary',
+ warning: 'text-warning',
+ 'muted-foreground': 'text-muted-foreground',
+}
+
+interface ScannerBarsProps {
+ tone: ScannerTone
+ className?: string
+}
+
+/**
+ * Segmented scanner: a masked track of 4x8px cells with a 2px gap where only a lit window is
+ * visible. The window is brightest at its center and fades out cell by cell toward both edges as it
+ * sweeps from edge to edge and back. The mask, gradient and sweep live in `index.css`, so the
+ * animation advances in the browser with no JavaScript and no React render per frame. Like the
+ * braille activity spinner it animates regardless of `prefers-reduced-motion`, because a still
+ * indicator is indistinguishable from a stalled one.
+ */
+export function ScannerBars({ tone, className }: ScannerBarsProps) {
+ return (
+
+ )
+}
diff --git a/frontend/src/components/ui/session-status-indicator.test.tsx b/frontend/src/components/ui/session-status-indicator.test.tsx
index b506b2853..43b79b112 100644
--- a/frontend/src/components/ui/session-status-indicator.test.tsx
+++ b/frontend/src/components/ui/session-status-indicator.test.tsx
@@ -26,13 +26,13 @@ describe('SessionStatusIndicator background activity', () => {
it('shows the background tasks label when idle with a running shell', () => {
useSessionStatus.getState().setShellRunning('shell-1', 'session-a', '/repo')
- render( )
+ render( )
expect(screen.getByLabelText('Background tasks running')).toBeInTheDocument()
})
it('renders nothing when idle without background activity', () => {
- const { container } = render( )
+ const { container } = render( )
expect(container).toBeEmptyDOMElement()
})
@@ -40,7 +40,7 @@ describe('SessionStatusIndicator background activity', () => {
it('renders the braille spinner with a running label for a busy session', () => {
useSessionStatus.getState().setStatus('session-a', { type: 'busy' })
- const { container } = render( )
+ const { container } = render( )
expect(screen.getByLabelText('Running')).toBeInTheDocument()
const litDots = container.querySelectorAll('svg[aria-hidden="true"] circle[fill-opacity="1"]')
diff --git a/frontend/src/components/ui/session-status-indicator.tsx b/frontend/src/components/ui/session-status-indicator.tsx
index fc6c3960e..4ea40a602 100644
--- a/frontend/src/components/ui/session-status-indicator.tsx
+++ b/frontend/src/components/ui/session-status-indicator.tsx
@@ -1,169 +1,51 @@
-import { memo, useEffect, useState } from 'react'
+import { memo } from 'react'
import { ActivitySpinner } from '@/components/ui/activity-spinner'
-import { useHasBackgroundActivity, useSessionStatusForSession, type SessionStatusType } from '@/stores/sessionStatusStore'
+import { useHasBackgroundActivity, useSessionStatusForSession } from '@/stores/sessionStatusStore'
interface SessionStatusIndicatorProps {
sessionID: string
className?: string
- size?: 'sm' | 'md'
- showLabel?: boolean
}
-const SCANNER_WIDTH = 6
-const SCANNER_SEGMENTS = 12
-
/**
- * Shows a session's activity. `sm` renders the braille spinner sized for list rows;
- * `md` renders the animated scanner used by the session composer.
+ * Shows a session's activity as the braille spinner sized for list rows.
+ * Renders nothing while the session is idle and has no background activity.
*/
-export const SessionStatusIndicator = memo(function SessionStatusIndicator({
- sessionID,
+export const SessionStatusIndicator = memo(function SessionStatusIndicator({
+ sessionID,
className = '',
- size = 'md',
- showLabel = false
}: SessionStatusIndicatorProps) {
const status = useSessionStatusForSession(sessionID)
const hasBackground = useHasBackgroundActivity(sessionID)
- const [position, setPosition] = useState(0)
- const [direction, setDirection] = useState(1)
- const [retryCountdown, setRetryCountdown] = useState(0)
const backgroundOnly = status.type === 'idle' && hasBackground
-
- useEffect(() => {
- if (size !== 'md') {
- return
- }
- if (status.type === 'idle' && !hasBackground) {
- return
- }
-
- const interval = setInterval(() => {
- setPosition(prev => {
- const next = prev + direction
- if (next >= SCANNER_SEGMENTS - SCANNER_WIDTH) {
- setDirection(-1)
- return SCANNER_SEGMENTS - SCANNER_WIDTH
- }
- if (next <= 0) {
- setDirection(1)
- return 0
- }
- return next
- })
- }, 60)
-
- return () => clearInterval(interval)
- }, [size, status.type, hasBackground, direction])
-
- useEffect(() => {
- if (!showLabel || status.type !== 'retry') {
- setRetryCountdown(0)
- return
- }
-
- const remaining = Math.max(0, Math.ceil((status.next - Date.now()) / 1000))
- setRetryCountdown(remaining)
-
- const interval = setInterval(() => {
- const newRemaining = Math.max(0, Math.ceil((status.next - Date.now()) / 1000))
- setRetryCountdown(newRemaining)
- }, 1000)
-
- return () => clearInterval(interval)
- }, [status, showLabel])
-
+
if (status.type === 'idle' && !hasBackground) {
return null
}
-
- if (size === 'sm') {
- const colorClass = backgroundOnly
- ? 'text-muted-foreground'
- : status.type === 'retry'
- ? 'text-warning'
- : status.type === 'compact'
- ? 'text-primary'
- : 'text-info'
- const label = backgroundOnly
- ? 'Background tasks running'
- : status.type === 'retry'
- ? 'Retrying'
- : status.type === 'compact'
- ? 'Compacting'
- : 'Running'
- return (
-
-
-
- )
- }
-
- const getSegmentColor = (index: number, statusType: SessionStatusType['type'], background: boolean) => {
- const distance = Math.abs(index - (position + SCANNER_WIDTH / 2))
- const maxDistance = SCANNER_WIDTH / 2
-
- if (distance > maxDistance + 1) {
- return 'bg-muted/20'
- }
-
- const intensity = Math.max(0, 1 - distance / (maxDistance + 2))
-
- if (background) {
- if (intensity > 0.8) return 'bg-muted-foreground'
- if (intensity > 0.5) return 'bg-muted-foreground/70'
- if (intensity > 0.2) return 'bg-muted-foreground/40'
- return 'bg-muted-foreground/20'
- }
+ const colorClass = backgroundOnly
+ ? 'text-muted-foreground'
+ : status.type === 'retry'
+ ? 'text-warning'
+ : status.type === 'compact'
+ ? 'text-primary'
+ : 'text-info'
+ const label = backgroundOnly
+ ? 'Background tasks running'
+ : status.type === 'retry'
+ ? 'Retrying'
+ : status.type === 'compact'
+ ? 'Compacting'
+ : 'Running'
- if (statusType === 'retry') {
- if (intensity > 0.8) return 'bg-warning'
- if (intensity > 0.5) return 'bg-warning/70'
- if (intensity > 0.2) return 'bg-warning/40'
- return 'bg-warning/20'
- }
-
- if (statusType === 'compact') {
- if (intensity > 0.8) return 'bg-primary'
- if (intensity > 0.5) return 'bg-primary/70'
- if (intensity > 0.2) return 'bg-primary/40'
- return 'bg-primary/20'
- }
-
- if (intensity > 0.8) return 'bg-info'
- if (intensity > 0.5) return 'bg-info/70'
- if (intensity > 0.2) return 'bg-info/40'
- return 'bg-info/20'
- }
-
- const segmentClass = 'w-1 h-4'
- const containerGap = showLabel ? 'gap-2' : ''
-
return (
-
-
- {Array.from({ length: SCANNER_SEGMENTS }).map((_, i) => (
-
- ))}
-
- {showLabel && status.type === 'retry' && (
-
- Retry #{status.attempt}
- {retryCountdown > 0 && ({retryCountdown}s) }
-
- )}
-
+
+
)
})
diff --git a/frontend/src/index.css b/frontend/src/index.css
index bd95ed182..28a2b0ec2 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -545,6 +545,40 @@ input:-webkit-autofill:active {
animation: progress 2s ease-in-out infinite;
}
+.scanner-track {
+ mask-image: repeating-linear-gradient(
+ 90deg,
+ #000 0 4px,
+ transparent 4px 6px
+ );
+ -webkit-mask-image: repeating-linear-gradient(
+ 90deg,
+ #000 0 4px,
+ transparent 4px 6px
+ );
+}
+
+@keyframes scanner-sweep {
+ from { left: -15%; }
+ to { left: 85%; }
+}
+
+.scanner-sweep {
+ left: -15%;
+ width: 30%;
+ background-image: linear-gradient(
+ 90deg,
+ transparent,
+ color-mix(in oklab, currentColor 12%, transparent) 20%,
+ color-mix(in oklab, currentColor 40%, transparent) 35%,
+ currentColor 50%,
+ color-mix(in oklab, currentColor 40%, transparent) 65%,
+ color-mix(in oklab, currentColor 12%, transparent) 80%,
+ transparent
+ );
+ animation: scanner-sweep 2.8s ease-in-out infinite alternate;
+}
+
:root [data-sonner-toaster][data-sonner-theme] {
--normal-bg: var(--color-popover);
--normal-bg-hover: var(--color-accent);
diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx
index 16c8f9b50..7cc5eb483 100644
--- a/frontend/src/pages/SessionDetail.tsx
+++ b/frontend/src/pages/SessionDetail.tsx
@@ -896,11 +896,11 @@ export function SessionDetail() {
"relative pointer-events-auto",
isFormSheetOpen
? "flex w-full flex-1 min-h-0 flex-col"
- : cn("w-[94%] md:max-w-4xl", isFormOpen && "flex max-h-full min-h-0 flex-col"),
+ : cn("w-full md:w-[94%] md:max-w-4xl", isFormOpen && "flex max-h-full min-h-0 flex-col"),
)}
>
{!isFormSheetOpen && (
-
+
{ttsEnabled && !hasPromptContent && !isSessionActive && latestPlayableAssistant && (
)}
-
-
-
+
+
+
+
+
{
})
})
- it('does NOT pass scroll button state when prompt has content', async () => {
+ it('passes scroll button state even when the prompt has content', async () => {
renderWith({ mobile: true, showScrollButton: true, promptContent: true })
- await waitFor(() => expect(screen.getByText('MockedPromptInput')).toBeInTheDocument())
- expect(screen.queryByLabelText('Scroll to bottom')).not.toBeInTheDocument()
+ await waitFor(() => {
+ expect(screen.getByLabelText('Scroll to bottom')).toBeInTheDocument()
+ })
})
it('shows clear button for prompt content only when session is idle', async () => {