Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"changes": [
"The main chat window now blocks a send before it fires when your quota is exhausted, matching the bar's behavior — you see the upgrade prompt immediately instead of after the server replies."
]
}
Original file line number Diff line number Diff line change
@@ -1,29 +1,20 @@
import { useEffect, useRef } from 'react'
import { useAppState } from '../../../state/appState'
import { maybeTriggerChatQuotaPopup } from '../../../lib/usageLimit'
import { fetchChatQuota } from '../../../lib/billing'
import { mainChatQuotaGate } from '../../../hooks/useChat'

/**
* Raises the usage-limit popup when a chat send finishes against an exhausted
* quota. It observes the ONE app-wide chat engine's `sending` flag (via
* useAppState) rather than touching the chat send path itself — on the
* busy→idle edge (a reply just completed) it probes the chat quota once. The
* probe is cheap, silent on error, and shows the popup at most once per session
* (guards live in lib/usageLimit). Mounted once at the app root, main window
* only.
* Refreshes the main-window chat quota snapshot after each completed reply so
* the next pre-send gate check in useChat.send() reads a fresh verdict without
* a network round trip. Mounted once at the app root, main window only.
*/
export function UsageLimitTriggerHost(): null {
// TODO(#10240 stream-1 chat integration — see docs/mac-parity-audit/PARALLEL-PLAN.md
// §Stream 1): replace this spinner-flag inference with an explicit
// quota-exceeded signal from the chat engine once fix/windows-wiring-criticals'
// useChat changes merge.
const { chat } = useAppState()
const wasSending = useRef(chat.sending)

useEffect(() => {
const finishedReply = wasSending.current && !chat.sending
wasSending.current = chat.sending
if (finishedReply) void maybeTriggerChatQuotaPopup(fetchChatQuota)
if (finishedReply) void mainChatQuotaGate.sync()
}, [chat.sending])

return null
Expand Down
48 changes: 48 additions & 0 deletions desktop/windows/src/renderer/src/hooks/useChat.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// @vitest-environment jsdom

Check warning on line 1 in desktop/windows/src/renderer/src/hooks/useChat.test.tsx

View workflow job for this annotation

GitHub Actions / PR Metadata Preflight

Large changed file

desktop/windows/src/renderer/src/hooks/useChat.test.tsx is 1159 lines; consider splitting files over 800 lines.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act, cleanup } from '@testing-library/react'
import type { ChatMessage, CodingAgentEvent } from '../../../shared/types'
Expand Down Expand Up @@ -70,6 +70,25 @@
saveDesktopMessage: (r: unknown) => saveDesktopMessageSpy(r)
}))

// Chat quota gate — default allow (blocked: false) so existing tests are unaffected.
const gateMocks = vi.hoisted(() => ({
check: vi.fn<() => Promise<{ blocked: false } | { blocked: true; message: string }>>().mockResolvedValue({
blocked: false
}),
recordQuery: vi.fn(),
sync: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
checkSync: vi.fn().mockReturnValue({ blocked: false }),
applyQuota: vi.fn(),
isLimitReached: vi.fn().mockReturnValue(false)
}))
vi.mock('../lib/chatQuotaGate', () => ({ createChatQuotaGate: () => gateMocks }))
const showUsageLimitSpy = vi.hoisted(() => vi.fn())
vi.mock('../lib/usageLimit', () => ({
showUsageLimit: showUsageLimitSpy,
dismissUsageLimit: vi.fn(),
onUsageLimit: vi.fn(() => () => {})
}))

import {
useChat,
CHAT_STREAM_TIMEOUT_MS,
Expand Down Expand Up @@ -1109,3 +1128,32 @@
])
})
})

describe('useChat — chat quota gate (Mac AgentBridge.quotaExceeded parity)', () => {
it('blocks a send when the quota is exhausted — popup shown, no fetch, no history entry', async () => {
gateMocks.check.mockResolvedValueOnce({ blocked: true, message: "You've reached your limit." })
const { result } = renderHook(() => useChat())
await act(async () => {
await result.current.send('hello')
})
expect(showUsageLimitSpy).toHaveBeenCalledWith('chat')
expect(global.fetch).not.toHaveBeenCalled()
expect(result.current.history).toHaveLength(0)
expect(result.current.sending).toBe(false)
})

it('lets an in-quota send through and records the query optimistically', async () => {
const { result } = renderHook(() => useChat())
void act(async () => {
await result.current.send('hello')
})
await waitForStream(0)
streams[0].close()
await act(async () => {
await flush()
})
expect(global.fetch).toHaveBeenCalledTimes(1)
expect(gateMocks.recordQuery).toHaveBeenCalledTimes(1)
expect(showUsageLimitSpy).not.toHaveBeenCalled()
})
})
17 changes: 17 additions & 0 deletions desktop/windows/src/renderer/src/hooks/useChat.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'

Check warning on line 1 in desktop/windows/src/renderer/src/hooks/useChat.ts

View workflow job for this annotation

GitHub Actions / PR Metadata Preflight

Large changed file

desktop/windows/src/renderer/src/hooks/useChat.ts is 1696 lines; consider splitting files over 800 lines.
import { auth } from '../lib/firebase'
import { invalidateConversationsCache } from '../lib/pageCache'
import { gatherLocalContext } from '../lib/localAgent'
Expand Down Expand Up @@ -41,6 +41,13 @@
import { trackEvent } from '../lib/analytics'
import { mergeAgentCards } from '../lib/chat/agentThreadCards'
import type { ChatContentBlock } from '../../../shared/chatContent'
import { createChatQuotaGate, type ChatQuotaGate } from '../lib/chatQuotaGate'
import { showUsageLimit } from '../lib/usageLimit'

// Main-window pre-send quota gate (Mac AgentBridge.quotaExceeded parity).
// Exported so UsageLimitTriggerHost can call sync() after each completed reply
// to refresh the snapshot for the next send's check().
export const mainChatQuotaGate: ChatQuotaGate = createChatQuotaGate()

export type ChatMsg = {
id?: string
Expand Down Expand Up @@ -172,7 +179,7 @@
* Conversations list immediately. One conversation per hook lifetime (i.e. per
* Home mount / app launch).
*/
export function useChat(): UseChat {

Check warning on line 182 in desktop/windows/src/renderer/src/hooks/useChat.ts

View workflow job for this annotation

GitHub Actions / PR Metadata Preflight

Long function

export function useChat(): UseChat is 1515 lines; consider extracting focused helpers over 150 lines.
const mode = getPreferences().chatHistoryMode

const [history, setHistory] = useState<ChatMsg[]>([])
Expand Down Expand Up @@ -982,6 +989,16 @@
// attachment-only sends); only a truly empty send is dropped. The file_ids
// are drained from the pending list below.
if ((!text.trim() && getPendingAttachments().length === 0) || sendingRef.current) return
// Pre-send quota gate (Mac AgentBridge.quotaExceeded parity). A signed-out
// user has no quota to gate; fail-open so a forced-reauth can't lock the send.
if (auth.currentUser) {
const verdict = await mainChatQuotaGate.check()
if (verdict.blocked) {
showUsageLimit('chat')
return
}
mainChatQuotaGate.recordQuery()
}
const fromVoice = !!opts?.fromVoice
setBusy(true)
// Open a new generation. reset()/dismiss bumps genRef, so `isCurrent()` goes
Expand Down
35 changes: 0 additions & 35 deletions desktop/windows/src/renderer/src/lib/usageLimit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,10 @@ import {
onUsageLimit,
showUsageLimit,
dismissUsageLimit,
maybeTriggerChatQuotaPopup,
maybeTriggerTranscriptionQuotaPopup,
__resetUsageLimitSession,
type UsageLimitReason
} from './usageLimit'
import type { ChatUsageQuota } from './omiApi.generated'

const quota = (p: Partial<ChatUsageQuota>): ChatUsageQuota => ({
plan: 'basic',
plan_type: 'free',
unit: 'questions',
used: 0,
...p
})

beforeEach(() => {
__resetUsageLimitSession()
Expand All @@ -41,31 +31,6 @@ describe('usage-limit pub/sub', () => {
})
})

describe('maybeTriggerChatQuotaPopup', () => {
it('shows the popup once when the quota is exhausted', async () => {
const seen: (UsageLimitReason | null)[] = []
onUsageLimit((r) => seen.push(r))
const fetchQuota = vi.fn().mockResolvedValue(quota({ used: 30, limit: 30, allowed: false }))

expect(await maybeTriggerChatQuotaPopup(fetchQuota)).toBe(true)
expect(seen.at(-1)).toBe('chat')

// Second call in the same session is a no-op (no nagging).
expect(await maybeTriggerChatQuotaPopup(fetchQuota)).toBe(false)
expect(fetchQuota).toHaveBeenCalledTimes(1)
})

it('does nothing when the quota still allows sending', async () => {
const fetchQuota = vi.fn().mockResolvedValue(quota({ used: 5, limit: 30, allowed: true }))
expect(await maybeTriggerChatQuotaPopup(fetchQuota)).toBe(false)
})

it('stays silent when the quota probe fails', async () => {
const fetchQuota = vi.fn().mockRejectedValue(new Error('network'))
expect(await maybeTriggerChatQuotaPopup(fetchQuota)).toBe(false)
})
})

describe('maybeTriggerTranscriptionQuotaPopup', () => {
// The message the capture window surfaces on a 1008 free-quota close.
const QUOTA_ERR =
Expand Down
34 changes: 0 additions & 34 deletions desktop/windows/src/renderer/src/lib/usageLimit.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { ChatUsageQuota } from './omiApi.generated'
import type { LiveStatus } from './liveConversation'
import { isQuotaExhaustedMessage } from './transcriptionClient'
import { isByokActiveCached } from './byokKeys'
Expand All @@ -23,45 +22,12 @@ export function dismissUsageLimit(): void {
signal.set(null)
}

// ── Chat-quota trigger ──────────────────────────────────────────────────────
// The chat send path lives on another branch, so rather than rewire it we watch
// the quota from the outside: after a send settles, a cheap GET usage-quota that
// reports allowed=false raises the popup. Fired at most once per app session so
// a user who keeps trying isn't nagged repeatedly.

let chatQuotaPopupShown = false

/** Test-only: reset the once-per-session guards. */
export function __resetUsageLimitSession(): void {
chatQuotaPopupShown = false
transcriptionQuotaPopupShown = false
signal.set(null)
}

/**
* Check the chat quota and, if it is exhausted, raise the 'chat' popup — but
* only the first time in a session. Returns true iff the popup was shown by this
* call. `fetchQuota` is injected so callers/tests control the network.
*/
export async function maybeTriggerChatQuotaPopup(
fetchQuota: () => Promise<ChatUsageQuota>
): Promise<boolean> {
if (chatQuotaPopupShown) return false
let quota: ChatUsageQuota
try {
quota = await fetchQuota()
} catch {
// A quota probe must never surface an error to the user — stay silent.
return false
}
if (quota.allowed === false) {
chatQuotaPopupShown = true
showUsageLimit('chat')
return true
}
return false
}

// ── Transcription-quota trigger ─────────────────────────────────────────────
// The always-on mic session runs in the hidden capture window, a SEPARATE
// renderer from the one that mounts UsageLimitPopup — so it can't raise the
Expand Down
Loading