Skip to content

Commit ede8639

Browse files
jahoomaclaude
andcommitted
Collapse freebuff session states into the wire shape
The CLI used to layer two client-only states (`superseded`, `ended`) on top of the server's response and translate `draining` → `ended` in a mapper. Replaces all of that with a single union the server emits directly: - GET /session now reads `X-Freebuff-Instance-Id` and returns `{ status: 'superseded' }` when the active row's id no longer matches. Previously this was inferred client-side from the chat gate's 409. - The wire's `draining` is renamed to `ended` and carries the same grace fields. The CLI's post-grace synthetic `ended` (no instanceId) reuses that shape. Also drops the zustand `driver` indirection — imperative session controls (refresh / mark superseded / mark ended) live as module-level functions on the hook, talking to a private controller ref. Combines `refreshFreebuffSession` and `rejoinFreebuffSession` into one with an optional `{ resetChat }` flag. Inlines the constant getters in `SessionDeps`/`AdmissionDeps` so tests pass plain numbers, drops the `limit` parameter from `admitFromQueue` (always 1), and consolidates the three 1-Hz UI tickers into a shared `useNow` hook. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f99d28f commit ede8639

24 files changed

Lines changed: 503 additions & 344 deletions

cli/src/chat.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1473,8 +1473,8 @@ export const Chat = ({
14731473
)}
14741474

14751475
{reviewMode ? (
1476-
// Review takes precedence over the session-ended banner: during a
1477-
// draining session the agent may still be asking to run tools, and
1476+
// Review takes precedence over the session-ended banner: during the
1477+
// grace window the agent may still be asking to run tools, and
14781478
// those approvals must be reachable for the run to finish.
14791479
<ReviewScreen
14801480
onSelectOption={handleReviewOptionSelect}

cli/src/components/freebuff-session-countdown.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import React, { useEffect, useState } from 'react'
1+
import React from 'react'
22

3+
import { useNow } from '../hooks/use-now'
34
import { useTheme } from '../hooks/use-theme'
45
import { IS_FREEBUFF } from '../utils/constants'
56

@@ -31,12 +32,7 @@ export const FreebuffSessionCountdown: React.FC<{
3132
const expiresAtMs =
3233
session?.status === 'active' ? Date.parse(session.expiresAt) : null
3334

34-
const [now, setNow] = useState(() => Date.now())
35-
useEffect(() => {
36-
if (!expiresAtMs) return
37-
const id = setInterval(() => setNow(Date.now()), 1000)
38-
return () => clearInterval(id)
39-
}, [expiresAtMs])
35+
const now = useNow(1000, expiresAtMs !== null)
4036

4137
if (!IS_FREEBUFF || !expiresAtMs) return null
4238

cli/src/components/session-ended-banner.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useKeyboard } from '@opentui/react'
33
import React, { useCallback, useState } from 'react'
44

55
import { Button } from './button'
6-
import { rejoinFreebuffSession } from '../hooks/use-freebuff-session'
6+
import { refreshFreebuffSession } from '../hooks/use-freebuff-session'
77
import { useTheme } from '../hooks/use-theme'
88
import { BORDER_CHARS } from '../utils/ui-constants'
99

@@ -38,7 +38,7 @@ export const SessionEndedBanner: React.FC<SessionEndedBannerProps> = ({
3838
// Once the POST lands, the hook flips status to 'queued' and app.tsx
3939
// swaps us into <WaitingRoomScreen>, unmounting this banner. No need to
4040
// clear `rejoining` on success — the component will be gone.
41-
rejoinFreebuffSession().catch(() => setRejoining(false))
41+
refreshFreebuffSession({ resetChat: true }).catch(() => setRejoining(false))
4242
}, [canRejoin])
4343

4444
useKeyboard(

cli/src/components/waiting-room-screen.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { TextAttributes } from '@opentui/core'
22
import { useKeyboard, useRenderer } from '@opentui/react'
3-
import React, { useCallback, useEffect, useMemo, useState } from 'react'
3+
import React, { useCallback, useMemo, useState } from 'react'
44

55
import { AdBanner } from './ad-banner'
66
import { Button } from './button'
77
import { ChoiceAdBanner } from './choice-ad-banner'
88
import { ShimmerText } from './shimmer-text'
99
import { useGravityAd } from '../hooks/use-gravity-ad'
1010
import { useLogo } from '../hooks/use-logo'
11+
import { useNow } from '../hooks/use-now'
1112
import { useSheenAnimation } from '../hooks/use-sheen-animation'
1213
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
1314
import { useTheme } from '../hooks/use-theme'
@@ -95,11 +96,7 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
9596
if (session?.status === 'queued') return Date.parse(session.queuedAt)
9697
return null
9798
}, [session])
98-
const [now, setNow] = useState(() => Date.now())
99-
useEffect(() => {
100-
const id = setInterval(() => setNow(Date.now()), 1000)
101-
return () => clearInterval(id)
102-
}, [])
99+
const now = useNow(1000, queuedAtMs !== null)
103100
const elapsedMs = queuedAtMs ? now - queuedAtMs : 0
104101

105102
const isQueued = session?.status === 'queued'

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,8 @@ function handleFreebuffGateError(
520520
updater.setError(
521521
"You're still in the waiting room. Please wait for admission before sending messages.",
522522
)
523+
// Re-sync without resetting chat — this is a "we'll wait", not a
524+
// "let's start fresh".
523525
refreshFreebuffSession().catch(() => {})
524526
return
525527
case 'session_superseded':
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { useNow } from './use-now'
2+
import { IS_FREEBUFF } from '../utils/constants'
3+
4+
import type { FreebuffSessionResponse } from '../types/freebuff-session'
5+
6+
export interface FreebuffSessionProgress {
7+
/** 0..1, fraction of the session remaining. 1 at admission, 0 at expiry. */
8+
fraction: number
9+
remainingMs: number
10+
}
11+
12+
/**
13+
* Computes a live progress value for the active freebuff session, ticking at
14+
* 1Hz. Returns null outside of active state or in non-freebuff builds, so
15+
* callers can short-circuit their rendering.
16+
*/
17+
export function useFreebuffSessionProgress(
18+
session: FreebuffSessionResponse | null,
19+
): FreebuffSessionProgress | null {
20+
const expiresAtMs =
21+
session?.status === 'active' ? Date.parse(session.expiresAt) : null
22+
const admittedAtMs =
23+
session?.status === 'active' ? Date.parse(session.admittedAt) : null
24+
25+
const nowMs = useNow(1000, expiresAtMs !== null)
26+
27+
if (!IS_FREEBUFF || !expiresAtMs || !admittedAtMs) return null
28+
29+
const totalMs = expiresAtMs - admittedAtMs
30+
if (totalMs <= 0) return null
31+
const remainingMs = Math.max(0, expiresAtMs - nowMs)
32+
const fraction = Math.max(0, Math.min(1, remainingMs / totalMs))
33+
return { fraction, remainingMs }
34+
}

0 commit comments

Comments
 (0)