Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 56 additions & 21 deletions cli/src/components/session-ended-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { useKeyboard } from '@opentui/react'
import React, { useCallback, useState } from 'react'

import { Button } from './button'
import { returnToFreebuffLanding } from '../hooks/use-freebuff-session'
import {
refreshFreebuffSession,
returnToFreebuffLanding,
} from '../hooks/use-freebuff-session'
import { useTheme } from '../hooks/use-theme'
import { BORDER_CHARS } from '../utils/ui-constants'

Expand All @@ -25,36 +28,46 @@ export const SessionEndedBanner: React.FC<SessionEndedBannerProps> = ({
isStreaming,
}) => {
const theme = useTheme()
const [rejoining, setRejoining] = useState(false)
const [pendingAction, setPendingAction] = useState<
'waiting-room' | 'same-chat' | null
>(null)

// While a request is still streaming, rejoin is disabled: it would
// While a request is still streaming, restart is disabled: it would
// unmount <Chat> and abort the in-flight agent run. The promise is "we
// let the agent finish" — honoring that means Enter does nothing until
// the stream ends or the user hits Esc.
const canRejoin = !isStreaming && !rejoining
const rejoin = useCallback(() => {
if (!canRejoin) return
setRejoining(true)
const canRestart = !isStreaming && pendingAction === null
const pickNewModel = useCallback(() => {
if (!canRestart) return
setPendingAction('waiting-room')
// Drop back to the landing picker (status: 'none') so the user picks a
// model and hits Enter again to commit, instead of being silently
// re-queued. app.tsx swaps us into <WaitingRoomScreen> on the
// transition, unmounting this banner — no need to clear `rejoining` on
// transition, unmounting this banner — no need to clear the pending state on
// success.
returnToFreebuffLanding({ resetChat: true }).catch(() =>
setRejoining(false),
setPendingAction(null),
)
}, [canRejoin])
}, [canRestart])

const startSameChatSession = useCallback(() => {
if (!canRestart) return
setPendingAction('same-chat')
// Re-POST with the currently selected model and keep the chat/run state
// intact so the next prompt continues the same conversation.
refreshFreebuffSession().catch(() => setPendingAction(null))
}, [canRestart])

useKeyboard(
useCallback(
(key: KeyEvent) => {
if (!canRejoin) return
if (!canRestart) return
if (key.name === 'return' || key.name === 'enter') {
key.preventDefault?.()
rejoin()
startSameChatSession()
}
},
[rejoin, canRejoin],
[startSameChatSession, canRestart],
),
)

Expand Down Expand Up @@ -83,14 +96,36 @@ export const SessionEndedBanner: React.FC<SessionEndedBannerProps> = ({
Agent is wrapping up. Rejoin the wait room after it's finished.
</text>
) : (
<Button onClick={rejoin}>
<text
style={{ fg: rejoining ? theme.muted : theme.primary }}
attributes={TextAttributes.BOLD}
>
{rejoining ? 'Rejoining…' : 'Press Enter to rejoin waiting room'}
</text>
</Button>
<box style={{ flexDirection: 'row', gap: 2, flexWrap: 'wrap' }}>
<Button onClick={startSameChatSession}>
<text
style={{
fg:
pendingAction === 'same-chat'
? theme.muted
: theme.primary,
}}
attributes={TextAttributes.BOLD}
>
{pendingAction === 'same-chat'
? 'Starting…'
: 'Press Enter to start new session (same model + chat)'}
</text>
</Button>
<Button onClick={pickNewModel}>
<text
style={{
fg:
pendingAction === 'waiting-room' ? theme.muted : theme.info,
}}
attributes={TextAttributes.BOLD}
>
{pendingAction === 'waiting-room'
? 'Opening model picker…'
: 'Pick a new model'}
</text>
</Button>
</box>
)}
</box>
)
Expand Down
5 changes: 4 additions & 1 deletion cli/src/hooks/use-freebuff-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,10 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
useFreebuffModelStore
.getState()
.setSelectedModel(FALLBACK_FREEBUFF_MODEL_ID)
nextMethod = 'GET'
// The unavailable response came from a POST attempt. Re-POST with
// the fallback model; a GET would only redisplay the old ended row
// and leave the restart banner stuck in its pending state.
nextMethod = 'POST'
schedule(0)
return
}
Expand Down
5 changes: 4 additions & 1 deletion cli/src/hooks/use-send-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,9 @@ export const useSendMessage = ({
setRunState,
setIsRetrying,
} = useChatStore.getState()
const previousRunStateRef = useRef<RunState | null>(null)
const previousRunStateRef = useRef<RunState | null>(
useChatStore.getState().runState,
)
// Memoize stream controller to maintain referential stability across renders
const streamRefsRef = useRef<ReturnType<
typeof createStreamController
Expand Down Expand Up @@ -198,6 +200,7 @@ export const useSendMessage = ({

function clearMessages() {
previousRunStateRef.current = null
setRunState(null)
}

const prepareUserMessage = useCallback(
Expand Down
Loading