Skip to content

Commit febb263

Browse files
jahoomaclaude
andcommitted
Add freebuff session-end banner and drain-window handling
When the server flips the seat into `draining` (past `expires_at`) or past the hard cutoff, the chat input is replaced by a "Session ended" banner. While an agent is still streaming under the grace window, Enter is disabled and the banner shows "Agent is wrapping up. Rejoin the wait room after it's finished." — Esc still interrupts. Once idle, Enter re-POSTs /session to drop back into the waiting room. Adds a small countdown to the far right of the status bar (muted, turning soft warning in the final minute — no red) and schedules the next poll just after expires_at / gracePeriodEndsAt so the draining transition shows up promptly instead of stalling at 0 for a full interval. Moves getFreebuffInstanceId onto the session hook's module handle and deletes the now-vestigial freebuff-session-store. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5ddb102 commit febb263

File tree

12 files changed

+337
-117
lines changed

12 files changed

+337
-117
lines changed

cli/src/app.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,10 @@ const AuthedSurface = ({
375375
// 'none' → server lost our row; hook is about to re-POST
376376
// Falling through to <Chat> on 'none' would leave the user unable to send
377377
// any free-mode request until the next poll cycle.
378+
//
379+
// 'draining' and 'ended' deliberately fall through to <Chat>: the agent
380+
// may still be finishing work under the server-side grace period, and the
381+
// chat surface itself swaps the input box for the session-ended banner.
378382
if (
379383
IS_FREEBUFF &&
380384
(session === null ||
@@ -401,6 +405,7 @@ const AuthedSurface = ({
401405
initialMode={initialMode}
402406
gitRoot={gitRoot}
403407
onSwitchToGitRoot={onSwitchToGitRoot}
408+
freebuffSession={session}
404409
/>
405410
)
406411
}

cli/src/chat.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { ReviewScreen } from './components/review-screen'
2121
import { MessageWithAgents } from './components/message-with-agents'
2222
import { areCreditsRestored } from './components/out-of-credits-banner'
2323
import { PendingBashMessage } from './components/pending-bash-message'
24-
import { useHasActiveFreebuffSession } from './components/freebuff-session-countdown'
24+
import { SessionEndedBanner } from './components/session-ended-banner'
2525
import { StatusBar } from './components/status-bar'
2626
import { TopBanner } from './components/top-banner'
2727
import { getSlashCommandsWithSkills } from './data/slash-commands'
@@ -84,6 +84,7 @@ import { computeInputLayoutMetrics } from './utils/text-layout'
8484
import type { CommandResult } from './commands/command-registry'
8585
import type { MultilineInputHandle } from './components/multiline-input'
8686
import type { MatchedSlashCommand } from './hooks/use-suggestion-engine'
87+
import type { FreebuffSessionResponse } from './types/freebuff-session'
8788
import type { User } from './utils/auth'
8889
import type { AgentMode } from './utils/constants'
8990
import type { FileTreeNode } from '@codebuff/common/util/file'
@@ -106,6 +107,7 @@ export const Chat = ({
106107
initialMode,
107108
gitRoot,
108109
onSwitchToGitRoot,
110+
freebuffSession,
109111
}: {
110112
headerContent: React.ReactNode
111113
initialPrompt: string | null
@@ -121,6 +123,7 @@ export const Chat = ({
121123
initialMode?: AgentMode
122124
gitRoot?: string | null
123125
onSwitchToGitRoot?: () => void
126+
freebuffSession: FreebuffSessionResponse | null
124127
}) => {
125128
const [forceFileOnlyMentions, setForceFileOnlyMentions] = useState(false)
126129

@@ -1338,7 +1341,12 @@ export const Chat = ({
13381341
return ` ${segments.join(' ')} `
13391342
}, [queuePreviewTitle, pausedQueueText])
13401343

1341-
const hasActiveFreebuffSession = useHasActiveFreebuffSession()
1344+
const hasActiveFreebuffSession =
1345+
IS_FREEBUFF && freebuffSession?.status === 'active'
1346+
const isFreebuffSessionOver =
1347+
IS_FREEBUFF &&
1348+
(freebuffSession?.status === 'draining' ||
1349+
freebuffSession?.status === 'ended')
13421350
const shouldShowStatusLine =
13431351
!feedbackMode &&
13441352
(hasStatusIndicatorContent ||
@@ -1447,6 +1455,7 @@ export const Chat = ({
14471455
scrollToLatest={scrollToLatest}
14481456
statusIndicatorState={statusIndicatorState}
14491457
onStop={chatKeyboardHandlers.onInterruptStream}
1458+
freebuffSession={freebuffSession}
14501459
/>
14511460
)}
14521461

@@ -1466,11 +1475,18 @@ export const Chat = ({
14661475
)}
14671476

14681477
{reviewMode ? (
1478+
// Review takes precedence over the session-ended banner: during a
1479+
// draining session the agent may still be asking to run tools, and
1480+
// those approvals must be reachable for the run to finish.
14691481
<ReviewScreen
14701482
onSelectOption={handleReviewOptionSelect}
14711483
onCustom={handleReviewCustom}
14721484
onCancel={handleCloseReviewScreen}
14731485
/>
1486+
) : isFreebuffSessionOver ? (
1487+
<SessionEndedBanner
1488+
isStreaming={isStreaming || isWaitingForResponse}
1489+
/>
14741490
) : (
14751491
<ChatInputBar
14761492
inputValue={inputValue}
Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import React, { useEffect, useState } from 'react'
22

33
import { useTheme } from '../hooks/use-theme'
4-
import { useFreebuffSessionStore } from '../state/freebuff-session-store'
54
import { IS_FREEBUFF } from '../utils/constants'
65

7-
const LOW_THRESHOLD_MS = 5 * 60_000
8-
const CRITICAL_THRESHOLD_MS = 60_000
6+
import type { FreebuffSessionResponse } from '../types/freebuff-session'
7+
8+
const LOW_THRESHOLD_MS = 60_000
99

1010
const formatRemaining = (ms: number): string => {
1111
if (ms <= 0) return 'expiring…'
@@ -24,9 +24,10 @@ const formatRemaining = (ms: number): string => {
2424
* surprised when their seat is released. Returns null in non-freebuff
2525
* builds or when no active session exists — safe to always mount.
2626
*/
27-
export const FreebuffSessionCountdown: React.FC = () => {
27+
export const FreebuffSessionCountdown: React.FC<{
28+
session: FreebuffSessionResponse | null
29+
}> = ({ session }) => {
2830
const theme = useTheme()
29-
const session = useFreebuffSessionStore((s) => s.session)
3031
const expiresAtMs =
3132
session?.status === 'active' ? Date.parse(session.expiresAt) : null
3233

@@ -40,21 +41,9 @@ export const FreebuffSessionCountdown: React.FC = () => {
4041
if (!IS_FREEBUFF || !expiresAtMs) return null
4142

4243
const remainingMs = expiresAtMs - now
43-
const color =
44-
remainingMs < CRITICAL_THRESHOLD_MS
45-
? theme.error
46-
: remainingMs < LOW_THRESHOLD_MS
47-
? theme.warning
48-
: theme.muted
44+
// Muted until the final minute, then a soft warning — deliberately not
45+
// `theme.error` so the countdown reads informational, not alarming.
46+
const color = remainingMs < LOW_THRESHOLD_MS ? theme.warning : theme.muted
4947

5048
return <span fg={color}>{formatRemaining(remainingMs)}</span>
5149
}
52-
53-
/** True when the freebuff session countdown will render non-null content.
54-
* Used by the chat surface to keep the status bar visible while a
55-
* session is active, even when there's no streaming/queue activity. */
56-
export const useHasActiveFreebuffSession = (): boolean => {
57-
return useFreebuffSessionStore(
58-
(s) => IS_FREEBUFF && s.session?.status === 'active',
59-
)
60-
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { TextAttributes } from '@opentui/core'
2+
import { useKeyboard } from '@opentui/react'
3+
import React, { useCallback, useState } from 'react'
4+
5+
import { Button } from './button'
6+
import { refreshFreebuffSession } from '../hooks/use-freebuff-session'
7+
import { useTheme } from '../hooks/use-theme'
8+
import { useChatStore } from '../state/chat-store'
9+
import { BORDER_CHARS } from '../utils/ui-constants'
10+
11+
import type { KeyEvent } from '@opentui/core'
12+
13+
interface SessionEndedBannerProps {
14+
/** True while an agent request is still streaming under the server-side
15+
* grace window. Swaps the Enter-to-rejoin affordance for a "let it
16+
* finish" hint so the user doesn't abort their in-flight work. */
17+
isStreaming: boolean
18+
}
19+
20+
/**
21+
* Replaces the chat input when the freebuff session has ended (client state
22+
* `draining` or `ended`). Captures Enter to re-queue the user; Esc keeps
23+
* falling through to the global stream-interrupt handler so in-flight work
24+
* can be cancelled.
25+
*/
26+
export const SessionEndedBanner: React.FC<SessionEndedBannerProps> = ({
27+
isStreaming,
28+
}) => {
29+
const theme = useTheme()
30+
const [rejoining, setRejoining] = useState(false)
31+
32+
// While a request is still streaming, rejoin is disabled: it would
33+
// unmount <Chat> and abort the in-flight agent run. The promise is "we
34+
// let the agent finish" — honoring that means Enter does nothing until
35+
// the stream ends or the user hits Esc.
36+
const canRejoin = !isStreaming && !rejoining
37+
const rejoin = useCallback(() => {
38+
if (!canRejoin) return
39+
setRejoining(true)
40+
// Once the POST lands, the hook flips status to 'queued' and app.tsx
41+
// swaps us into <WaitingRoomScreen>, unmounting this banner. No need to
42+
// clear `rejoining` on success — the component will be gone.
43+
refreshFreebuffSession()
44+
.then(() => {
45+
// Wipe the prior conversation so the next admitted session starts
46+
// with empty history instead of continuing the one that just ended.
47+
useChatStore.getState().reset()
48+
})
49+
.catch(() => setRejoining(false))
50+
}, [canRejoin])
51+
52+
useKeyboard(
53+
useCallback(
54+
(key: KeyEvent) => {
55+
if (!canRejoin) return
56+
if (key.name === 'return' || key.name === 'enter') {
57+
key.preventDefault?.()
58+
rejoin()
59+
}
60+
},
61+
[rejoin, canRejoin],
62+
),
63+
)
64+
65+
return (
66+
<box
67+
title="Session ended"
68+
titleAlignment="center"
69+
style={{
70+
width: '100%',
71+
borderStyle: 'single',
72+
borderColor: theme.muted,
73+
customBorderChars: BORDER_CHARS,
74+
paddingLeft: 1,
75+
paddingRight: 1,
76+
paddingTop: 0,
77+
paddingBottom: 0,
78+
flexDirection: 'column',
79+
gap: 0,
80+
}}
81+
>
82+
<text style={{ fg: theme.foreground, wrapMode: 'word' }}>
83+
Your freebuff session has ended.
84+
</text>
85+
{isStreaming ? (
86+
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
87+
Agent is wrapping up. Rejoin the wait room after it's finished.
88+
</text>
89+
) : (
90+
<Button onClick={rejoin}>
91+
<text
92+
style={{ fg: rejoining ? theme.muted : theme.primary }}
93+
attributes={TextAttributes.BOLD}
94+
>
95+
{rejoining ? 'Rejoining…' : '[Enter] Rejoin waiting room'}
96+
</text>
97+
</Button>
98+
)}
99+
</box>
100+
)
101+
}

cli/src/components/status-bar.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { StopButton } from './stop-button'
77
import { useTheme } from '../hooks/use-theme'
88
import { formatElapsedTime } from '../utils/format-elapsed-time'
99

10+
import type { FreebuffSessionResponse } from '../types/freebuff-session'
1011
import type { StatusIndicatorState } from '../utils/status-indicator-state'
1112

1213

@@ -18,6 +19,7 @@ interface StatusBarProps {
1819
scrollToLatest: () => void
1920
statusIndicatorState: StatusIndicatorState
2021
onStop?: () => void
22+
freebuffSession: FreebuffSessionResponse | null
2123
}
2224

2325
export const StatusBar = ({
@@ -26,6 +28,7 @@ export const StatusBar = ({
2628
scrollToLatest,
2729
statusIndicatorState,
2830
onStop,
31+
freebuffSession,
2932
}: StatusBarProps) => {
3033
const theme = useTheme()
3134
const [elapsedSeconds, setElapsedSeconds] = useState(0)
@@ -170,12 +173,12 @@ export const StatusBar = ({
170173
}}
171174
>
172175
<text style={{ wrapMode: 'none' }}>{elapsedTimeContent}</text>
173-
<text style={{ wrapMode: 'none' }}>
174-
<FreebuffSessionCountdown />
175-
</text>
176176
{onStop && (statusIndicatorState.kind === 'waiting' || statusIndicatorState.kind === 'streaming') && (
177177
<StopButton onClick={onStop} />
178178
)}
179+
<text style={{ wrapMode: 'none' }}>
180+
<FreebuffSessionCountdown session={freebuffSession} />
181+
</text>
179182
</box>
180183
</box>
181184
)

cli/src/hooks/helpers/__tests__/send-message.test.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1581,7 +1581,7 @@ describe('freebuff gate errors', () => {
15811581
expect(messages[0].userError).toContain('Another freebuff CLI took over')
15821582
})
15831583

1584-
test('handleRunError maps 410 session_expired to the rejoining message', () => {
1584+
test('handleRunError suppresses the inline error for 410 session_expired (ended banner takes over)', () => {
15851585
const messages = baseMessage()
15861586
const updater = makeUpdater(messages)
15871587
handleRunError({
@@ -1594,10 +1594,13 @@ describe('freebuff gate errors', () => {
15941594
updateChainInProgress: () => {},
15951595
})
15961596
updater.flush()
1597-
expect(messages[0].userError).toContain('no longer active')
1597+
// New contract: the gate handler flips the session store into `ended`
1598+
// and the session-ended banner is the user-facing signal, so we do NOT
1599+
// also surface an inline userError inside the chat transcript.
1600+
expect(messages[0].userError).toBeUndefined()
15981601
})
15991602

1600-
test('handleRunError maps 428 waiting_room_required to the rejoining message', () => {
1603+
test('handleRunError suppresses the inline error for 428 waiting_room_required (ended banner takes over)', () => {
16011604
const messages = baseMessage()
16021605
const updater = makeUpdater(messages)
16031606
handleRunError({
@@ -1610,7 +1613,7 @@ describe('freebuff gate errors', () => {
16101613
updateChainInProgress: () => {},
16111614
})
16121615
updater.flush()
1613-
expect(messages[0].userError).toContain('no longer active')
1616+
expect(messages[0].userError).toBeUndefined()
16141617
})
16151618

16161619
test('handleRunError maps 429 waiting_room_queued to the still-queued message', () => {
@@ -1679,6 +1682,10 @@ describe('freebuff gate errors', () => {
16791682
setHasReceivedPlanResponse: () => {},
16801683
})
16811684
updater.flush()
1682-
expect(messages[0].userError).toContain('no longer active')
1685+
// 410 is now handled by the ended banner, not an inline error. The
1686+
// assertion here just confirms routing happened via the gate handler
1687+
// (which swallows the userError) rather than the generic error path
1688+
// (which would set a userError from the message).
1689+
expect(messages[0].userError).toBeUndefined()
16831690
})
16841691
})

cli/src/hooks/helpers/send-message.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { getErrorObject } from '@codebuff/common/util/error'
22

33
import {
4+
markFreebuffSessionEnded,
45
markFreebuffSessionSuperseded,
56
refreshFreebuffSession,
67
} from '../use-freebuff-session'
@@ -507,14 +508,14 @@ function handleFreebuffGateError(
507508
updater: BatchedMessageUpdater,
508509
) {
509510
switch (kind) {
510-
case 'waiting_room_required':
511511
case 'session_expired':
512-
updater.setError(
513-
'Your freebuff session is no longer active. Rejoining the waiting room…',
514-
)
515-
// Re-POST asynchronously; UI flips back to the waiting room as soon as
516-
// the store picks up status: 'queued'.
517-
refreshFreebuffSession().catch(() => {})
512+
case 'waiting_room_required':
513+
// Our seat is gone mid-chat. Flip to the client-only `ended` state
514+
// instead of auto re-queuing: the Chat surface stays mounted so any
515+
// in-flight agent work can finish under the server-side grace period,
516+
// and the session-ended banner prompts the user to press Enter when
517+
// they're ready to rejoin the waiting room.
518+
markFreebuffSessionEnded()
518519
return
519520
case 'waiting_room_queued':
520521
updater.setError(

0 commit comments

Comments
 (0)