This document outlines the implementation plan for Hive Phase 8, focusing on streaming UX quality (smart auto-scroll, adaptive flush), message integrity (echo fix), and keyboard shortcut reliability (Cmd+T).
The implementation is divided into 6 focused sessions, each with:
- Clear objectives
- Definition of done
- Testing criteria for verification
Phase 8 builds upon Phase 7 — all Phase 7 infrastructure is assumed to be in place.
test/
├── phase-8/
│ ├── session-1/
│ │ └── message-echo-fix.test.ts
│ ├── session-2/
│ │ └── cmd-t-shortcut.test.ts
│ ├── session-3/
│ │ └── adaptive-flush.test.ts
│ ├── session-4/
│ │ └── smart-auto-scroll.test.ts
│ ├── session-5/
│ │ └── scroll-fab.test.ts
│ └── session-6/
│ └── integration-verification.test.ts
// No new dependencies requiredAll features use existing packages: React, Zustand, Electron, lucide-react.
- Fix the bug where user messages sent to the OpenCode SDK are echoed back and persisted as assistant messages in the database
- Invert role guards in
persistStreamEventso only confirmed assistant messages are persisted - Extend
extractEventMessageRolewith additional payload paths for robustness
In src/main/services/opencode-service.ts, locate the persistStreamEvent function's message.part.updated handler (around line 250):
// BEFORE (line ~250):
if (role === 'user') return
// AFTER:
if (role !== 'assistant') returnThen locate the message.updated handler (around line 280):
// BEFORE (lines ~280-284):
if (role === 'user') return
if (!messageId) return
if (role && role !== 'assistant') return
// AFTER:
if (role !== 'assistant') return
if (!messageId) returnThe second handler's three separate guards collapse into a single if (role !== 'assistant') return. This handles all cases: undefined role (extraction failed), 'user' role (SDK echo), and any other unexpected role value.
In the same file, locate extractEventMessageRole (around lines 177–186). Add additional payload paths:
function extractEventMessageRole(eventData: Record<string, unknown>): string | undefined {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = eventData as any
const paths = [
d?.message?.role,
d?.info?.role,
d?.part?.role,
d?.role,
d?.properties?.message?.role,
d?.properties?.info?.role,
d?.properties?.part?.role,
d?.properties?.role,
d?.metadata?.role,
d?.content?.role
]
for (const val of paths) {
if (typeof val === 'string') return val
}
return undefined
}Confirm that SessionView.tsx still has user-echo skips at:
- Line ~647:
if (eventRole === 'user') return(inmessage.part.updatedhandler) - Line ~745:
if (eventRole === 'user') return(inmessage.updatedhandler)
These are defense-in-depth — the main process fix is the primary fix, the renderer guards prevent any visual flicker.
src/main/services/opencode-service.ts— role guard inversion + enhanced role extraction
-
persistStreamEventonly persists events with confirmedrole === 'assistant' - Events with
undefinedrole are silently dropped (not persisted) - Events with
role === 'user'are silently dropped (not persisted) -
extractEventMessageRolechecks 10+ payload paths for the role field - Sending a user message does NOT produce a duplicate assistant-role message in the database
-
finalizeResponseFromDatabaseloads only legitimate messages (user messages + real assistant responses) - Renderer-side
eventRole === 'user'guards remain in place as defense-in-depth -
pnpm lintpasses -
pnpm testpasses
- Start the app, select a worktree, open a session
- Send a message to the AI
- Wait for the response to complete
- Open the SQLite database (
~/.hive/hive.db) and query:SELECT id, role, content FROM session_messages WHERE session_id = '<id>' ORDER BY created_at - Verify: exactly one
userrow with your message, exactly oneassistantrow with the AI response — no duplicates - Send multiple messages in succession — verify no echoes accumulate
- Refresh the session (switch away and back) — verify message history is clean
// test/phase-8/session-1/message-echo-fix.test.ts
describe('Session 1: Message Echo Fix', () => {
describe('extractEventMessageRole', () => {
test('extracts role from message.role path', () => {
const role = extractEventMessageRole({ message: { role: 'user' } })
expect(role).toBe('user')
})
test('extracts role from info.role path', () => {
const role = extractEventMessageRole({ info: { role: 'assistant' } })
expect(role).toBe('assistant')
})
test('extracts role from part.role path', () => {
const role = extractEventMessageRole({ part: { role: 'user' } })
expect(role).toBe('user')
})
test('extracts role from nested properties path', () => {
const role = extractEventMessageRole({
properties: { message: { role: 'assistant' } }
})
expect(role).toBe('assistant')
})
test('returns undefined when role not found', () => {
const role = extractEventMessageRole({ foo: 'bar' })
expect(role).toBeUndefined()
})
test('returns undefined for empty object', () => {
const role = extractEventMessageRole({})
expect(role).toBeUndefined()
})
test('first valid path wins', () => {
const role = extractEventMessageRole({
message: { role: 'user' },
info: { role: 'assistant' }
})
expect(role).toBe('user')
})
})
describe('persistStreamEvent role guards', () => {
test('message.part.updated with role=assistant is persisted', () => {
// Mock event with role='assistant', valid part and messageId
// Verify upsertSessionMessageByOpenCodeId called
})
test('message.part.updated with role=user is NOT persisted', () => {
// Mock event with role='user'
// Verify upsertSessionMessageByOpenCodeId NOT called
})
test('message.part.updated with undefined role is NOT persisted', () => {
// Mock event with no role field anywhere
// Verify upsertSessionMessageByOpenCodeId NOT called
})
test('message.updated with role=assistant is persisted', () => {
// Mock event with role='assistant', valid messageId
// Verify upsertSessionMessageByOpenCodeId called
})
test('message.updated with role=user is NOT persisted', () => {
// Mock event with role='user'
// Verify upsertSessionMessageByOpenCodeId NOT called
})
test('message.updated with undefined role is NOT persisted', () => {
// Mock event with no role field
// Verify upsertSessionMessageByOpenCodeId NOT called
})
})
})- Intercept Cmd+T at the Electron main process level before Chromium consumes it
- Forward the shortcut to the renderer via IPC to trigger new session creation
- Ensure Cmd+T works even when the chat textarea is focused
In src/main/index.ts, after the mainWindow is created (inside createWindow or wherever the BrowserWindow is set up), add:
mainWindow.webContents.on('before-input-event', (event, input) => {
// Intercept Cmd+T (macOS) / Ctrl+T (Windows/Linux) before Chromium handles it
if (
input.key.toLowerCase() === 't' &&
(input.meta || input.control) &&
!input.alt &&
!input.shift &&
input.type === 'keyDown'
) {
event.preventDefault()
mainWindow.webContents.send('shortcut:new-session')
}
})Note the input.type === 'keyDown' check to avoid firing on keyUp events.
In src/preload/index.ts, add to the systemOps namespace:
onNewSessionShortcut: (callback: () => void) => {
const handler = (): void => {
callback()
}
ipcRenderer.on('shortcut:new-session', handler)
return () => {
ipcRenderer.removeListener('shortcut:new-session', handler)
}
}In src/preload/index.d.ts, add to the SystemOps interface:
onNewSessionShortcut: (callback: () => void) => () => voidIn src/renderer/src/hooks/useKeyboardShortcuts.ts, add a useEffect inside the useKeyboardShortcuts hook to listen for the main-process Cmd+T forwarding:
useEffect(() => {
if (!window.systemOps?.onNewSessionShortcut) return
const cleanup = window.systemOps.onNewSessionShortcut(() => {
const { selectedWorktreeId, worktreesByProject } = useWorktreeStore.getState()
if (!selectedWorktreeId) {
toast.error('Please select a worktree first')
return
}
let projectId: string | null = null
for (const [pid, worktrees] of worktreesByProject) {
if (worktrees.find((w) => w.id === selectedWorktreeId)) {
projectId = pid
break
}
}
if (!projectId) {
toast.error('Please select a worktree first')
return
}
useSessionStore
.getState()
.createSession(selectedWorktreeId, projectId)
.then((result) => {
if (result.success) {
toast.success('New session created')
} else {
toast.error(result.error || 'Failed to create session')
}
})
})
return cleanup
}, [])This duplicates the existing session:new handler logic. To avoid duplication, extract the handler into a shared function:
function createNewSession(): void {
const { selectedWorktreeId, worktreesByProject } = useWorktreeStore.getState()
if (!selectedWorktreeId) {
toast.error('Please select a worktree first')
return
}
let projectId: string | null = null
for (const [pid, worktrees] of worktreesByProject) {
if (worktrees.find((w) => w.id === selectedWorktreeId)) {
projectId = pid
break
}
}
if (!projectId) {
toast.error('Please select a worktree first')
return
}
useSessionStore
.getState()
.createSession(selectedWorktreeId, projectId)
.then((result) => {
if (result.success) {
toast.success('New session created')
} else {
toast.error(result.error || 'Failed to create session')
}
})
}Use this in both the session:new keyboard shortcut handler and the IPC listener.
In the getShortcutHandlers function in useKeyboardShortcuts.ts, find the session:new entry and change:
// BEFORE:
allowInInput: false
// AFTER:
allowInInput: trueThis ensures that even if the event does reach the DOM (some platforms/configurations), it fires while the textarea is focused.
src/main/index.ts—before-input-eventlistenersrc/preload/index.ts— exposeonNewSessionShortcutsrc/preload/index.d.ts— type declarationsrc/renderer/src/hooks/useKeyboardShortcuts.ts— IPC listener +allowInInput: true
- Cmd+T on macOS creates a new session tab
- Ctrl+T on all platforms creates a new session tab (existing behavior preserved)
- Cmd+T works when the chat textarea is focused
- Cmd+T works when no input element is focused
- Cmd+T does not open a Chromium "new tab" (Electron default behavior suppressed)
- When no worktree is selected, Cmd+T shows an error toast
- Success toast shown when session is created
-
before-input-eventonly interceptskeyDown(notkeyUp) - IPC listener properly cleaned up on unmount
-
pnpm lintpasses -
pnpm testpasses
- Start the app, select a worktree with at least one session open
- Click into the session textarea (focus it)
- Press Cmd+T → verify a new session tab appears and "New session created" toast shown
- Press Cmd+T again with no textarea focus → verify another session created
- Press Ctrl+T → verify it also works (backward compatibility)
- Deselect all worktrees → press Cmd+T → verify "Please select a worktree first" error toast
- Verify no Chromium "new tab" behavior occurs on Cmd+T
// test/phase-8/session-2/cmd-t-shortcut.test.ts
describe('Session 2: Cmd+T Shortcut Fix', () => {
describe('before-input-event handler', () => {
test('Cmd+T keyDown sends IPC event', () => {
// Simulate before-input-event with meta=true, key='t', type='keyDown'
// Verify event.preventDefault called
// Verify webContents.send called with 'shortcut:new-session'
})
test('Ctrl+T keyDown sends IPC event', () => {
// Simulate before-input-event with control=true, key='t', type='keyDown'
// Verify event.preventDefault called
// Verify webContents.send called with 'shortcut:new-session'
})
test('Cmd+T keyUp does NOT send IPC event', () => {
// Simulate before-input-event with meta=true, key='t', type='keyUp'
// Verify event.preventDefault NOT called
})
test('Cmd+Shift+T does NOT trigger', () => {
// Simulate with meta=true, shift=true, key='t'
// Verify NOT intercepted
})
test('Alt+T does NOT trigger', () => {
// Simulate with alt=true, key='t'
// Verify NOT intercepted
})
})
describe('Renderer IPC listener', () => {
test('IPC callback triggers session creation', () => {
// Mock window.systemOps.onNewSessionShortcut
// Mock useWorktreeStore with selectedWorktreeId
// Trigger the callback
// Verify createSession called
})
test('IPC callback shows error when no worktree selected', () => {
// Mock useWorktreeStore with selectedWorktreeId = null
// Trigger the callback
// Verify toast.error called
})
test('cleanup function removes listener', () => {
// Get cleanup function from useEffect
// Call cleanup
// Verify ipcRenderer.removeListener called
})
})
describe('allowInInput', () => {
test('session:new shortcut fires when textarea focused', () => {
// Focus a textarea
// Dispatch Cmd+T keydown
// Verify handler called (allowInInput: true)
})
})
})- Replace the fixed 100ms
setTimeoutthrottle withrequestAnimationFrame-based flushing - Ensure streamed text appears within one frame (~16ms) of arriving
- Maintain immediate flush behavior for tool card updates
In src/renderer/src/components/sessions/SessionView.tsx, locate the throttle machinery (around lines 351, 448–464):
Replace the ref declaration (line ~351):
// BEFORE:
const throttleRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// AFTER:
const rafRef = useRef<number | null>(null)Replace scheduleFlush (lines ~448–455):
// BEFORE:
const scheduleFlush = useCallback(() => {
if (throttleRef.current === null) {
throttleRef.current = setTimeout(() => {
throttleRef.current = null
flushStreamingState()
}, 100)
}
}, [flushStreamingState])
// AFTER:
const scheduleFlush = useCallback(() => {
if (rafRef.current === null) {
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null
flushStreamingState()
})
}
}, [flushStreamingState])Replace immediateFlush (lines ~458–464):
// BEFORE:
const immediateFlush = useCallback(() => {
if (throttleRef.current !== null) {
clearTimeout(throttleRef.current)
throttleRef.current = null
}
flushStreamingState()
}, [flushStreamingState])
// AFTER:
const immediateFlush = useCallback(() => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
flushStreamingState()
}, [flushStreamingState])Add a cleanup useEffect near the other effect hooks:
useEffect(() => {
return () => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current)
}
}
}, [])Confirm these callers still work correctly without modification:
appendTextDelta(line ~489) callsscheduleFlush()— works the same, just faster- Tool event handlers (lines ~538+) call
immediateFlush()— works the same, now cancels rAF instead of timeout message.updatedcompletion (line ~773) callsimmediateFlush()— works the samesession.idlehandler (line ~778) callsimmediateFlush()— works the same
src/renderer/src/components/sessions/SessionView.tsx— throttle → rAF replacement
-
throttleRefreplaced withrafRefthroughoutSessionView.tsx -
scheduleFlushusesrequestAnimationFrameinstead ofsetTimeout(100) -
immediateFlushusescancelAnimationFrameinstead ofclearTimeout - Cleanup
useEffectcancels pending rAF on component unmount - Streamed text appears within one frame (~16ms) of arriving — no 100ms delay
- Text streaming looks smooth and continuous (no visible 100ms chunking)
- Tool card updates still appear immediately via
immediateFlush - Stream completion (
message.updated) still flushes immediately -
session.idlestill flushes immediately - No memory leaks (rAF properly canceled on unmount)
- No
setTimeoutorclearTimeoutreferences remain in the streaming machinery -
pnpm lintpasses -
pnpm testpasses
- Start the app, open a session, send a message to the AI
- Watch the streaming text appear — it should flow smoothly without visible "jumps" every 100ms
- With a fast model (e.g., Haiku), verify text appears nearly character-by-character at frame rate
- With a slow model (e.g., Opus), verify behavior is unchanged (just less frequent updates)
- During streaming, verify tool cards (Read, Edit, Bash, etc.) still appear instantly when emitted
- Verify stream completion still works — final message loads from DB correctly
- Switch sessions mid-stream → verify no errors or leaked rAF callbacks
// test/phase-8/session-3/adaptive-flush.test.ts
describe('Session 3: Adaptive Streaming Flush', () => {
describe('scheduleFlush', () => {
test('uses requestAnimationFrame not setTimeout', () => {
// Spy on requestAnimationFrame
// Call scheduleFlush
// Verify requestAnimationFrame called
// Verify setTimeout NOT called
})
test('batches multiple calls within same frame', () => {
// Call scheduleFlush 5 times rapidly
// Verify requestAnimationFrame called only once
// Verify flushStreamingState called only once after frame
})
test('flushes streaming state on animation frame', () => {
// Call scheduleFlush
// Fire the rAF callback
// Verify flushStreamingState called
// Verify rafRef reset to null
})
})
describe('immediateFlush', () => {
test('cancels pending rAF', () => {
// Schedule a flush (sets rafRef)
// Call immediateFlush
// Verify cancelAnimationFrame called
// Verify flushStreamingState called synchronously
})
test('works when no pending rAF', () => {
// rafRef is null
// Call immediateFlush
// Verify flushStreamingState called
// Verify cancelAnimationFrame NOT called
})
})
describe('cleanup', () => {
test('cancels pending rAF on unmount', () => {
// Schedule a flush
// Unmount the component
// Verify cancelAnimationFrame called
})
test('no error when unmounting with no pending rAF', () => {
// rafRef is null
// Unmount
// Verify no error
})
})
describe('streaming behavior', () => {
test('appendTextDelta triggers scheduleFlush', () => {
// Call appendTextDelta with a text delta
// Verify scheduleFlush called (which uses rAF)
})
test('tool events trigger immediateFlush', () => {
// Simulate a tool_use event
// Verify immediateFlush called (not scheduleFlush)
})
})
})- Add scroll position detection to the message list container
- Track whether the user has scrolled up away from the bottom
- Make auto-scroll conditional — only scroll to bottom when the user is near the bottom
- Force-resume auto-scroll when the user sends a new message
In src/renderer/src/components/sessions/SessionView.tsx, add a ref for the scroll container:
const scrollContainerRef = useRef<HTMLDivElement>(null)Attach it to the message list container div (the <div className="flex-1 overflow-y-auto"> around line 1228):
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto"
onScroll={handleScroll}
>const isAutoScrollEnabledRef = useRef(true)
const [showScrollFab, setShowScrollFab] = useState(false)Use a useRef for isAutoScrollEnabled (not state) because:
onScrollfires very frequently during streaming (every rAF flush triggers a scroll)- Using state would cause unnecessary re-renders
- The auto-scroll
useEffectreads the ref synchronously — no stale closure issue
const handleScroll = useCallback(() => {
const el = scrollContainerRef.current
if (!el) return
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
const isNearBottom = distanceFromBottom < 80
if (isNearBottom) {
isAutoScrollEnabledRef.current = true
setShowScrollFab(false)
} else {
// Only show FAB and disable auto-scroll if we're actively streaming
// or have just finished streaming (isSending covers both)
if (isSending || isStreaming) {
isAutoScrollEnabledRef.current = false
setShowScrollFab(true)
}
}
}, [isSending, isStreaming])The 80px threshold accounts for:
- Small rendering differences between browsers
- The sentinel div height
- A margin of error so the user doesn't have to be pixel-perfect at the bottom
// BEFORE (lines 401-403):
useEffect(() => {
scrollToBottom()
}, [messages, streamingContent, streamingParts, scrollToBottom])
// AFTER:
useEffect(() => {
if (isAutoScrollEnabledRef.current) {
scrollToBottom()
}
}, [messages, streamingContent, streamingParts, scrollToBottom])In the handleSend function (around line 1013), add before the prompt is sent:
// User just sent a message — they want to see the response
isAutoScrollEnabledRef.current = true
setShowScrollFab(false)const handleScrollToBottomClick = useCallback(() => {
isAutoScrollEnabledRef.current = true
setShowScrollFab(false)
scrollToBottom()
}, [scrollToBottom])When the active session changes, reset to auto-scroll enabled:
useEffect(() => {
isAutoScrollEnabledRef.current = true
setShowScrollFab(false)
}, [sessionId])src/renderer/src/components/sessions/SessionView.tsx— scroll tracking, conditional auto-scroll, FAB state
- Scroll container has
refandonScrollhandler attached -
isAutoScrollEnabledReftracks whether auto-scroll is active - Scrolling up more than 80px from bottom during streaming disables auto-scroll
- Scrolling back within 80px of bottom re-enables auto-scroll
-
showScrollFabstate istruewhen auto-scroll is paused during/after streaming - Auto-scroll
useEffectchecksisAutoScrollEnabledRefbefore scrolling -
handleSendforce-resumes auto-scroll - Session switch resets auto-scroll to enabled
-
handleScrollToBottomClickre-enables auto-scroll and scrolls to bottom - No re-render loops from the
onScrollhandler (ref-based tracking) -
pnpm lintpasses -
pnpm testpasses
- Start the app, open a session, send a long prompt that generates a multi-paragraph response
- While the AI is streaming, scroll up — verify the viewport stays where you scrolled (no snapping to bottom)
- Continue scrolling while streaming continues — verify viewport remains stable
- Scroll back down to the bottom — verify auto-scroll resumes (new content pushes scroll down)
- Send a new message — verify auto-scroll resumes immediately (viewport follows the new response)
- Switch to a different session and back — verify auto-scroll is reset to enabled
// test/phase-8/session-4/smart-auto-scroll.test.ts
describe('Session 4: Smart Auto-Scroll', () => {
describe('handleScroll', () => {
test('near bottom enables auto-scroll', () => {
// Set scrollHeight=1000, scrollTop=920, clientHeight=80 (distance=0)
// Call handleScroll
// Verify isAutoScrollEnabledRef.current === true
// Verify showScrollFab === false
})
test('scrolled up during streaming disables auto-scroll', () => {
// Set scrollHeight=1000, scrollTop=500, clientHeight=80 (distance=420)
// isStreaming=true
// Call handleScroll
// Verify isAutoScrollEnabledRef.current === false
// Verify showScrollFab === true
})
test('scrolled up when NOT streaming does NOT show FAB', () => {
// Set scrollHeight=1000, scrollTop=500, clientHeight=80
// isStreaming=false, isSending=false
// Call handleScroll
// Verify showScrollFab === false
})
test('80px threshold respected', () => {
// Distance = 79px → isNearBottom = true
// Distance = 81px → isNearBottom = false
})
})
describe('conditional auto-scroll', () => {
test('scrollToBottom called when auto-scroll enabled', () => {
// isAutoScrollEnabledRef.current = true
// Trigger useEffect (new streaming content)
// Verify scrollToBottom called
})
test('scrollToBottom NOT called when auto-scroll disabled', () => {
// isAutoScrollEnabledRef.current = false
// Trigger useEffect (new streaming content)
// Verify scrollToBottom NOT called
})
})
describe('handleSend force-resume', () => {
test('sending a message re-enables auto-scroll', () => {
// isAutoScrollEnabledRef.current = false (user scrolled up)
// Call handleSend
// Verify isAutoScrollEnabledRef.current === true
// Verify showScrollFab === false
})
})
describe('session switch reset', () => {
test('auto-scroll reset on session change', () => {
// isAutoScrollEnabledRef.current = false
// Change sessionId prop
// Verify isAutoScrollEnabledRef.current === true
// Verify showScrollFab === false
})
})
describe('handleScrollToBottomClick', () => {
test('re-enables auto-scroll and scrolls', () => {
// isAutoScrollEnabledRef.current = false
// Call handleScrollToBottomClick
// Verify isAutoScrollEnabledRef.current === true
// Verify showScrollFab === false
// Verify scrollToBottom called
})
})
})- Create the
ScrollToBottomFabcomponent with fade animation - Render it inside the scroll container with absolute positioning
- Wire it to the scroll tracking state from Session 4
Create src/renderer/src/components/sessions/ScrollToBottomFab.tsx:
import { ArrowDown } from 'lucide-react'
import { cn } from '@/lib/utils'
interface ScrollToBottomFabProps {
onClick: () => void
visible: boolean
}
export function ScrollToBottomFab({
onClick,
visible
}: ScrollToBottomFabProps): React.JSX.Element {
return (
<button
onClick={onClick}
className={cn(
'absolute bottom-4 right-4 z-10',
'h-8 w-8 rounded-full',
'bg-muted/80 backdrop-blur-sm border border-border',
'flex items-center justify-center',
'shadow-md hover:bg-muted transition-all duration-200',
'cursor-pointer',
visible
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-2 pointer-events-none'
)}
aria-label="Scroll to bottom"
data-testid="scroll-to-bottom-fab"
>
<ArrowDown className="h-4 w-4" />
</button>
)
}Key design decisions:
absolutepositioning within the scroll container (container needsrelative)backdrop-blur-smfor a frosted glass effect over contenttransition-all duration-200for smooth show/hidepointer-events-nonewhen hidden to prevent blocking clicks on content underneathtranslate-y-2when hidden for a subtle slide-up animation on appear
In src/renderer/src/components/sessions/SessionView.tsx, wrap the scroll container in a relative positioned div (or add relative to the existing container):
<div className="relative flex-1">
<div
ref={scrollContainerRef}
className="absolute inset-0 overflow-y-auto"
onScroll={handleScroll}
>
{/* ... message list ... */}
<div ref={messagesEndRef} />
</div>
<ScrollToBottomFab
onClick={handleScrollToBottomClick}
visible={showScrollFab}
/>
</div>Note: The scroll container may need restructuring. The current layout uses flex-1 overflow-y-auto directly. Wrapping in a relative div with absolute inset-0 child preserves the flex sizing while allowing absolute positioning of the FAB.
Alternatively, if the scroll container already has or can receive relative class:
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto relative"
onScroll={handleScroll}
>
{/* ... message list ... */}
<div ref={messagesEndRef} />
<ScrollToBottomFab
onClick={handleScrollToBottomClick}
visible={showScrollFab}
/>
</div>But this would make the FAB scroll with the content. The FAB needs to be sticky or in a sibling wrapper. Evaluate the actual DOM structure and pick the approach that keeps the FAB fixed at the bottom-right of the visible viewport.
The safest approach is the wrapper pattern:
<div className="relative flex-1 min-h-0">
<div
ref={scrollContainerRef}
className="h-full overflow-y-auto"
onScroll={handleScroll}
>
{/* messages */}
</div>
<ScrollToBottomFab
onClick={handleScrollToBottomClick}
visible={showScrollFab}
/>
</div>min-h-0 prevents the flex child from overflowing. The FAB is positioned absolute within the wrapper, outside the scroll container, so it stays fixed.
import { ScrollToBottomFab } from './ScrollToBottomFab'src/renderer/src/components/sessions/ScrollToBottomFab.tsx— NEWsrc/renderer/src/components/sessions/SessionView.tsx— render FAB, layout adjustments
-
ScrollToBottomFab.tsxcreated with proper styling and props - FAB renders at bottom-right of the message area
- FAB is visible (opacity-100) when
showScrollFabis true - FAB is hidden (opacity-0, pointer-events-none) when
showScrollFabis false - FAB has a 200ms fade transition for show/hide
- FAB does not scroll with the content — stays fixed in the viewport
- Clicking FAB triggers
handleScrollToBottomClick(scrolls to bottom + resumes auto-scroll) - FAB does not obscure important content (bottom-right corner with proper z-index)
- FAB has hover state (bg-muted → slightly more opaque)
- Layout of the message area is unchanged (no visual regressions)
-
pnpm lintpasses -
pnpm testpasses
- Start the app, open a session, send a prompt
- While streaming, scroll up — verify the FAB appears at the bottom-right with a fade-in animation
- Verify the FAB has a down-arrow icon and semi-transparent background
- Hover over the FAB — verify hover state (slightly darker)
- Click the FAB — verify:
- Viewport scrolls smoothly to the bottom
- FAB fades out
- Auto-scroll resumes (new streaming content pushes viewport down)
- Scroll up again, then manually scroll all the way to the bottom — verify FAB disappears
- Verify the FAB does not appear when just browsing static message history (no active streaming)
- Verify the FAB does not block clicking on content underneath when hidden
// test/phase-8/session-5/scroll-fab.test.ts
describe('Session 5: ScrollToBottomFab', () => {
describe('ScrollToBottomFab component', () => {
test('renders with ArrowDown icon', () => {
// Render ScrollToBottomFab with visible=true
// Verify button element with aria-label="Scroll to bottom"
// Verify ArrowDown icon present
})
test('visible when visible=true', () => {
// Render with visible=true
// Verify opacity-100 class present
// Verify pointer-events-none NOT present
})
test('hidden when visible=false', () => {
// Render with visible=false
// Verify opacity-0 class present
// Verify pointer-events-none present
})
test('calls onClick when clicked', () => {
// Render with visible=true
// Click the button
// Verify onClick called
})
test('not clickable when hidden', () => {
// Render with visible=false
// Attempt to click
// Verify onClick NOT called (pointer-events-none)
})
})
describe('FAB integration in SessionView', () => {
test('FAB rendered in message area', () => {
// Render SessionView
// Verify scroll-to-bottom-fab element exists in DOM
})
test('FAB hidden by default', () => {
// Render SessionView (initial state)
// Verify FAB has opacity-0
})
test('FAB appears when user scrolls up during streaming', () => {
// Set isStreaming=true
// Simulate scroll event with distance > 80px from bottom
// Verify FAB has opacity-100
})
test('FAB disappears when user scrolls to bottom', () => {
// FAB is visible
// Simulate scroll to bottom (distance < 80px)
// Verify FAB has opacity-0
})
test('FAB click scrolls to bottom and hides FAB', () => {
// FAB is visible
// Click FAB
// Verify scrollToBottom called
// Verify FAB hidden
})
test('FAB position is fixed (does not scroll with content)', () => {
// Verify FAB parent has position: relative
// Verify FAB has position: absolute
// Verify FAB is outside the scroll container
})
})
})- Verify all Phase 8 features work correctly together
- Test cross-feature interactions
- Run lint and tests
- Fix any edge cases or regressions
- Send a message → verify no echo during streaming (rAF flush doesn't introduce echo timing issues)
- Send multiple rapid messages → verify no echo accumulation
- Verify
finalizeResponseFromDatabaseloads clean messages after rAF-based streaming
- During streaming with rAF flush, scroll up → verify auto-scroll pauses (FAB appears)
- With faster flushing (~16ms), verify
onScrollhandler doesn't cause performance issues (no jank) - Verify the auto-scroll
useEffectfires correctly with rAF-speed state updates
- While auto-scroll is paused (FAB visible), press Cmd+T → verify new session created
- Verify the new session starts with auto-scroll enabled
- Verify Cmd+T works while the FAB is visible
- Create a new session via Cmd+T, send a message immediately → verify no echo
- Rapidly create sessions and send messages → verify message integrity across all sessions
- Send a message and immediately scroll up before any response arrives → verify FAB appears once streaming starts
- Send a message, scroll up, then send another message (queued) → verify auto-scroll resumes for the new message
- Switch sessions mid-stream → verify no leaked rAF callbacks, no scroll state bleed
- Close a session tab during streaming → verify cleanup (rAF canceled, no errors)
- Very long response (10,000+ tokens) → verify scroll and rAF performance remain smooth
- Empty session (no messages) → verify no FAB, no scroll issues
pnpm lint
pnpm testFix any failures.
Run through the full happy path:
- Open app → select worktree → Cmd+T to create session → send message → verify smooth streaming → scroll up → FAB appears → click FAB → auto-scroll resumes → response completes → verify no echoes in history
- All files modified in sessions 1–5
- Focus on cross-cutting concerns between the four features
- All 4 features from sessions 1–5 work correctly in isolation
- Cross-feature interactions work correctly (echo fix + flush, scroll + flush, Cmd+T + scroll, etc.)
- No regressions in Phase 7 features
- No console errors during normal operation
- No leaked rAF callbacks or timers
- Performance remains smooth with rAF-speed updates + scroll tracking
-
pnpm lintpasses -
pnpm testpasses - Full happy path smoke test passes
Run through each integration scenario listed in Tasks above. Focus on timing-sensitive interactions — the rAF flush, scroll events, and IPC forwarding all involve async timing that could interact unexpectedly.
// test/phase-8/session-6/integration-verification.test.ts
describe('Session 6: Integration & Verification', () => {
test('streaming with rAF produces no echoes', () => {
// Send message, stream response via rAF flush
// Verify messages array has exactly 1 user + 1 assistant
})
test('scroll tracking works with rAF-speed updates', () => {
// Stream content at rAF speed
// Scroll up mid-stream
// Verify auto-scroll paused, FAB visible
// Verify no performance degradation
})
test('Cmd+T works during active streaming', () => {
// Start streaming in session-A
// Press Cmd+T
// Verify new session-B created
// Verify session-A streaming continues
})
test('new session from Cmd+T starts with auto-scroll enabled', () => {
// Pause auto-scroll in session-A (scroll up)
// Cmd+T to create session-B
// Switch to session-B
// Verify auto-scroll enabled in session-B
})
test('rapid message sending produces no echoes', () => {
// Send 5 messages in rapid succession
// Wait for all responses
// Verify exactly 5 user + 5 assistant messages
})
test('session switch during streaming cleans up properly', () => {
// Start streaming in session-A
// Switch to session-B
// Verify no errors, no leaked callbacks
// Switch back to session-A
// Verify messages loaded from DB correctly
})
test('lint passes', () => {
// pnpm lint exit code 0
})
test('tests pass', () => {
// pnpm test exit code 0
})
})Session 1 (Echo Fix) ── independent, no UI changes
Session 2 (Cmd+T Fix) ── independent, main+preload+renderer
Session 3 (Adaptive Flush) ── independent, SessionView only
|
└──► Session 4 (Scroll Tracking) ── depends on Session 3 (same file, flush affects scroll timing)
|
└──► Session 5 (Scroll FAB) ── depends on Session 4 (needs scroll state)
|
Session 6 (Integration) ── requires sessions 1-5
Sessions 1, 2, and 3 are fully independent and can run in parallel:
- Track A: Session 1 — Main process only (
opencode-service.ts) - Track B: Session 2 — Main + preload + renderer (
index.ts,index.d.ts,useKeyboardShortcuts.ts) - Track C: Session 3 — Renderer only (
SessionView.tsx— throttle replacement)
Sessions 4 and 5 must run sequentially after Session 3 (they build on the same file and depend on the flush timing).
┌──────────────────────────────────────────────────┐
│ Time → │
│ │
│ Track A: [Session 1: Echo Fix ] │
│ Track B: [Session 2: Cmd+T Fix ] │
│ Track C: [Session 3: Flush] → [Session 4: Scroll] → [Session 5: FAB] │
│ │
│ All ───────────────────────────► [Session 6: Integration] │
└──────────────────────────────────────────────────┘
Minimum critical path: Session 3 → Session 4 → Session 5 → Session 6
Maximum parallelism: Sessions 1, 2, 3 all in parallel. Sessions 4, 5 sequential after 3.
Session 6 requires all previous sessions complete.
- Project filter with subsequence matching
- Branch duplication with versioning
- Code review button
- Inline diff viewer
- Pulse animation for running worktrees
- Auto-focus session textarea
- Clear button in run pane
- Model variant selection with Alt+T
Per PRD Phase 8:
- Scroll position persistence across session switches
- Unread message count on the scroll FAB
- Scroll-to-top button
- Per-message scroll anchoring
- Typewriter / character animation effects
- Configurable flush interval settings
- Retroactive database cleanup of echoed messages
- Cmd+T interception for other Chromium-consumed shortcuts
- Customizable keybinding for new session
| Operation | Target |
|---|---|
| Scroll position check (onScroll) | < 1ms |
| Auto-scroll suppression | Immediate (next flush cycle) |
| FAB show/hide transition | 200ms CSS |
| Streaming text flush latency | ≤ 16.6ms (1 frame) |
| Text delta to screen | ≤ 1 frame after arrival |
| Role guard evaluation | < 0.1ms per event |
| Cmd+T interception → IPC | < 5ms |
| New session from Cmd+T | < 500ms end-to-end |
- Role guard inversion (
!== 'assistant'instead of=== 'user'): Fail-safe — if role extraction fails, the event is dropped rather than persisted as the wrong role. Defense-in-depth: renderer-side guards remain as a second layer. requestAnimationFrameinstead of reduced timeout: rAF is purpose-built for visual updates. It's frame-synced, auto-paused in background tabs, and naturally batches multiple calls per frame.setTimeout(16)would be a poor imitation subject to timer throttling.useReffor auto-scroll tracking (not state):onScrollfires at frame rate during streaming. Using state would trigger re-renders on every scroll event. A ref is read synchronously by the auto-scrolluseEffectwithout causing renders.before-input-eventfor Cmd+T (notglobalShortcut):before-input-eventfires in the context of the specific BrowserWindow, before Chromium's built-in handlers.globalShortcutis system-wide and would intercept Cmd+T in all apps.before-input-eventis scoped correctly.- 80px scroll threshold: Accounts for rendering variance, sentinel div height, and provides a comfortable buffer. Too small (e.g., 10px) would cause flickering between paused/resumed states. Too large (e.g., 200px) would prevent auto-scroll from resuming until the user is very close to the bottom.
- FAB outside scroll container: Positioned in a sibling wrapper with
position: absolute. If placed inside the scroll container, it would scroll with the content. The wrapper pattern keeps it fixed relative to the viewport area.