From c45e1f8dc808b0fb3b51248224fbc555e6b3e6bb Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 6 Jul 2026 08:40:07 -0700 Subject: [PATCH 1/4] fix(sessions): make stale-conversation cost notice a dismissible banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The large-idle-conversation cost warning replaced the composer with an ActionSelector, forcing staff to choose compact / continue / new session before they could type again. Turn it into a slim, dismissible banner pinned above the composer so the input box stays usable — you can fire off one more prompt or dismiss the notice and deal with compaction later. Compact stays a one-click action; the dismiss (X) acknowledges the warning for the session so it doesn't keep nagging. Generated-By: PostHog Code Task-Id: f70fc300-9167-4c69-b96b-3e34fe03588e --- .../sessions/components/SessionView.tsx | 54 +++------ .../StaleConversationCostNotice.stories.tsx | 17 +-- .../StaleConversationCostNotice.tsx | 104 ++++++++---------- 3 files changed, 68 insertions(+), 107 deletions(-) diff --git a/packages/ui/src/features/sessions/components/SessionView.tsx b/packages/ui/src/features/sessions/components/SessionView.tsx index 387b6e5804..bbaef4701f 100644 --- a/packages/ui/src/features/sessions/components/SessionView.tsx +++ b/packages/ui/src/features/sessions/components/SessionView.tsx @@ -358,12 +358,19 @@ export function SessionView({ staleGate.onContinue(); }; - const handleStaleNewSession = onNewSession - ? () => { - trackStaleGateChoice("new_session"); - onNewSession(); - } - : undefined; + // Non-blocking cost banner pinned above the composer. Rendered in both the + // pending-permission slot and the normal composer so a stale conversation is + // flagged either way; onCompact is omitted while a permission is pending (a + // queued /compact would land after answering it, paying the reload twice). + const staleNotice = (permissionPending: boolean) => + staleGate.active ? ( + + ) : null; const [isDraggingFile, setIsDraggingFile] = useState(false); const editorRef = useRef(null); @@ -666,39 +673,9 @@ export function SessionView({ )} - ) : hideInput ? null : staleGate.active ? ( - // Replaces the composer (and any pending permission — answering - // one also resumes the costly turn) until the user chooses. - isRunning ? ( - - - - ) : ( - // While reconnecting the gate still covers the composer - // slot: handoff can leave pendingPermissions set, and the - // choices must not fire into a half-connected session. - - - - ) - ) : firstPendingPermission ? ( + ) : hideInput ? null : firstPendingPermission ? ( + {staleNotice(true)} + {staleNotice(false)} {taskId && } {}, + onDismiss: () => {}, onCompact: () => {}, - onNewSession: () => {}, }, }; -export const WithoutNewSessionOrCost: Story = { +export const WithoutCost: Story = { args: { usedTokens: 128_000, - lastActivityAt: TWO_HOURS_AGO, costUsd: null, - onContinue: () => {}, + onDismiss: () => {}, onCompact: () => {}, }, }; -/** Compact is hidden while a permission is pending — two-option layout. */ +/** Compact is hidden while a permission is pending — dismiss only. */ export const PermissionPending: Story = { args: { usedTokens: 481_000, - lastActivityAt: TWO_HOURS_AGO, costUsd: 12.34, - onContinue: () => {}, - onNewSession: () => {}, + onDismiss: () => {}, }, }; diff --git a/packages/ui/src/features/sessions/components/StaleConversationCostNotice.tsx b/packages/ui/src/features/sessions/components/StaleConversationCostNotice.tsx index f22ac6f087..2776aa2ce4 100644 --- a/packages/ui/src/features/sessions/components/StaleConversationCostNotice.tsx +++ b/packages/ui/src/features/sessions/components/StaleConversationCostNotice.tsx @@ -1,82 +1,72 @@ +import { Warning, X } from "@phosphor-icons/react"; import { formatUsd } from "@posthog/core/billing/spendAnalysisFormat"; -import { formatRelativeTimeLong } from "@posthog/shared"; +import { Button } from "@posthog/quill"; import { formatTokensCompact } from "@posthog/ui/features/sessions/contextColors"; -import { ActionSelector } from "@posthog/ui/primitives/ActionSelector"; - -const COMPACT_OPTION = "compact"; -const CONTINUE_OPTION = "continue"; -const NEW_SESSION_OPTION = "new-session"; +import { Box, Flex, IconButton, Text, Tooltip } from "@radix-ui/themes"; interface StaleConversationCostNoticeProps { usedTokens: number; - lastActivityAt: number | null; /** Cumulative session cost so far, when the gateway reports it. */ costUsd: number | null; - onContinue: () => void; + /** Dismiss the notice — "continue anyway", acknowledged for the session. */ + onDismiss: () => void; /** * Compact the thread: pay the reload once, then every later turn is * smaller. Omitted while a permission is pending — a queued /compact would * land after answering it, paying the reload twice. */ onCompact?: () => void; - onNewSession?: () => void; } /** - * Composer state shown in place of the prompt input when PostHog staff return - * to a large, idle conversation whose prompt cache has likely expired. Uses - * the same ActionSelector as permission prompts, so the user must choose how - * to continue before they can type again. + * Slim, dismissible banner pinned above the composer when PostHog staff return + * to a large, idle conversation whose prompt cache has likely expired. Unlike a + * permission prompt it does not take over the input box: the user can keep + * typing and deal with (or dismiss) it whenever they like. */ export function StaleConversationCostNotice({ usedTokens, - lastActivityAt, costUsd, - onContinue, + onDismiss, onCompact, - onNewSession, }: StaleConversationCostNoticeProps) { - const activity = - lastActivityAt !== null - ? `was last active ${formatRelativeTimeLong(lastActivityAt)}` - : "has been idle"; - const spent = - costUsd !== null ? ` (≈${formatUsd(costUsd)} spent so far)` : ""; + const spent = costUsd !== null ? ` (≈${formatUsd(costUsd)} spent so far)` : ""; return ( - { - if (optionId === COMPACT_OPTION) onCompact?.(); - else if (optionId === CONTINUE_OPTION) onContinue(); - else if (optionId === NEW_SESSION_OPTION) onNewSession?.(); - }} - /> + + + + + This conversation is large (~{formatTokensCompact(usedTokens)} tokens) + and idle, so its prompt cache has likely expired — the next message + re-processes everything at full input price instead of the ~10% cached + rate{spent}. + + + {onCompact && ( + + + + )} + + + + + + + + ); } From 0bf93d95859b33a54e6d527b488a73f88336c5bb Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 6 Jul 2026 08:46:07 -0700 Subject: [PATCH 2/4] style(sessions): fix biome formatting in stale-conversation notice The `spent` assignment exceeded the line width; `biome ci` (which checks formatting) flagged it. Wrap it as the formatter expects to turn the quality check green. Generated-By: PostHog Code Task-Id: f70fc300-9167-4c69-b96b-3e34fe03588e --- .../sessions/components/StaleConversationCostNotice.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/features/sessions/components/StaleConversationCostNotice.tsx b/packages/ui/src/features/sessions/components/StaleConversationCostNotice.tsx index 2776aa2ce4..aebd5a2bcb 100644 --- a/packages/ui/src/features/sessions/components/StaleConversationCostNotice.tsx +++ b/packages/ui/src/features/sessions/components/StaleConversationCostNotice.tsx @@ -30,7 +30,8 @@ export function StaleConversationCostNotice({ onDismiss, onCompact, }: StaleConversationCostNoticeProps) { - const spent = costUsd !== null ? ` (≈${formatUsd(costUsd)} spent so far)` : ""; + const spent = + costUsd !== null ? ` (≈${formatUsd(costUsd)} spent so far)` : ""; return ( From 2ef177860ef002ad22f8181746ebc88c5d56a767 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 6 Jul 2026 09:25:35 -0700 Subject: [PATCH 3/4] refactor(sessions): address Greptile review on stale-conversation banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use the shared `@posthog/ui/primitives/Tooltip` instead of the Radix Themes one, so the banner's tooltips match the rest of the UI (dark theme, padding, shadow) like other Quill-button tooltips. - Rename the gate-choice analytics value "continue" -> "dismiss" to match the redesigned UX (the "Continue anyway" button is now a dismiss ✕), keeping the event vocabulary consistent for dashboards. Generated-By: PostHog Code Task-Id: f70fc300-9167-4c69-b96b-3e34fe03588e --- packages/shared/src/analytics-events.ts | 2 +- .../ui/src/features/sessions/components/SessionView.tsx | 6 +++--- .../sessions/components/StaleConversationCostNotice.tsx | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 62850de083..7d88919365 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -136,7 +136,7 @@ export interface PromptSentProperties { } export interface StaleConversationGateChoiceProperties { - choice: "compact" | "continue" | "new_session"; + choice: "compact" | "dismiss" | "new_session"; used_tokens: number; cost_usd: number | null; } diff --git a/packages/ui/src/features/sessions/components/SessionView.tsx b/packages/ui/src/features/sessions/components/SessionView.tsx index bbaef4701f..abd20e8fa5 100644 --- a/packages/ui/src/features/sessions/components/SessionView.tsx +++ b/packages/ui/src/features/sessions/components/SessionView.tsx @@ -353,8 +353,8 @@ export function SessionView({ onSendPrompt("/compact"); }; - const handleStaleContinue = () => { - trackStaleGateChoice("continue"); + const handleStaleDismiss = () => { + trackStaleGateChoice("dismiss"); staleGate.onContinue(); }; @@ -367,7 +367,7 @@ export function SessionView({ ) : null; diff --git a/packages/ui/src/features/sessions/components/StaleConversationCostNotice.tsx b/packages/ui/src/features/sessions/components/StaleConversationCostNotice.tsx index aebd5a2bcb..bb63242532 100644 --- a/packages/ui/src/features/sessions/components/StaleConversationCostNotice.tsx +++ b/packages/ui/src/features/sessions/components/StaleConversationCostNotice.tsx @@ -2,7 +2,8 @@ import { Warning, X } from "@phosphor-icons/react"; import { formatUsd } from "@posthog/core/billing/spendAnalysisFormat"; import { Button } from "@posthog/quill"; import { formatTokensCompact } from "@posthog/ui/features/sessions/contextColors"; -import { Box, Flex, IconButton, Text, Tooltip } from "@radix-ui/themes"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; +import { Box, Flex, IconButton, Text } from "@radix-ui/themes"; interface StaleConversationCostNoticeProps { usedTokens: number; From f9325f54aa43450a4f8aaf35472787fbaea63b2e Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 6 Jul 2026 12:11:49 -0700 Subject: [PATCH 4/4] feat(sessions): remove stale-conversation cost notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the staff-only stale-conversation gate/notice entirely. As a non-blocking banner it was just visual noise: it didn't change user behaviour, and compacting isn't the right recommendation anyway — the next message pays the same uncached input cost whether or not you compact, then warms the cache for the following turns regardless. Removes the banner component and stories, the gate hook and store (+test), the `shouldWarnStaleCostlyConversation` / `extractLastActivityAt` helpers and their tests in core, and the `STALE_CONVERSATION_GATE_CHOICE` analytics event. Generated-By: PostHog Code Task-Id: f70fc300-9167-4c69-b96b-3e34fe03588e --- .../core/src/sessions/contextUsage.test.ts | 121 +----------------- packages/core/src/sessions/contextUsage.ts | 73 ----------- packages/shared/src/analytics-events.ts | 8 -- .../sessions/components/SessionView.tsx | 55 +------- .../StaleConversationCostNotice.stories.tsx | 85 ------------ .../StaleConversationCostNotice.tsx | 74 ----------- .../staleConversationGateStore.test.ts | 72 ----------- .../sessions/staleConversationGateStore.ts | 63 --------- .../sessions/useStaleConversationGate.ts | 83 ------------ 9 files changed, 2 insertions(+), 632 deletions(-) delete mode 100644 packages/ui/src/features/sessions/components/StaleConversationCostNotice.stories.tsx delete mode 100644 packages/ui/src/features/sessions/components/StaleConversationCostNotice.tsx delete mode 100644 packages/ui/src/features/sessions/staleConversationGateStore.test.ts delete mode 100644 packages/ui/src/features/sessions/staleConversationGateStore.ts delete mode 100644 packages/ui/src/features/sessions/useStaleConversationGate.ts diff --git a/packages/core/src/sessions/contextUsage.test.ts b/packages/core/src/sessions/contextUsage.test.ts index b0a3d5cca0..b753f257a0 100644 --- a/packages/core/src/sessions/contextUsage.test.ts +++ b/packages/core/src/sessions/contextUsage.test.ts @@ -1,12 +1,6 @@ import type { AcpMessage } from "@posthog/shared"; import { describe, expect, it } from "vitest"; -import { - createContextUsageTracker, - DEFAULT_STALE_COSTLY_THRESHOLD, - extractContextUsage, - extractLastActivityAt, - shouldWarnStaleCostlyConversation, -} from "./contextUsage"; +import { createContextUsageTracker, extractContextUsage } from "./contextUsage"; function usageUpdateEvent(used: number, size: number): AcpMessage { return { @@ -196,116 +190,3 @@ describe("createContextUsageTracker", () => { expect(tracker.update(events)).toEqual(extractContextUsage(events)); }); }); - -describe("shouldWarnStaleCostlyConversation", () => { - const now = 1_000_000_000; - const threshold = { tokens: 40_000, staleMs: 5 * 60 * 1000 }; - - it.each([ - { - name: "large + stale → warn", - usedTokens: 50_000, - idleMs: 10 * 60 * 1000, - expected: true, - }, - { - name: "large + fresh → no warn", - usedTokens: 50_000, - idleMs: 60 * 1000, - expected: false, - }, - { - name: "small + stale → no warn", - usedTokens: 10_000, - idleMs: 10 * 60 * 1000, - expected: false, - }, - { - name: "small + fresh → no warn", - usedTokens: 10_000, - idleMs: 60 * 1000, - expected: false, - }, - { - name: "exactly at both thresholds → warn", - usedTokens: 40_000, - idleMs: 5 * 60 * 1000, - expected: true, - }, - { - name: "one token below the size threshold → no warn", - usedTokens: 39_999, - idleMs: 10 * 60 * 1000, - expected: false, - }, - { - name: "one ms below the stale threshold → no warn", - usedTokens: 50_000, - idleMs: 5 * 60 * 1000 - 1, - expected: false, - }, - ])("$name", ({ usedTokens, idleMs, expected }) => { - expect( - shouldWarnStaleCostlyConversation({ - usedTokens, - lastActivityAt: now - idleMs, - now, - threshold, - }), - ).toBe(expected); - }); - - it("never warns without a last-activity timestamp", () => { - expect( - shouldWarnStaleCostlyConversation({ - usedTokens: 1_000_000, - lastActivityAt: null, - now, - threshold, - }), - ).toBe(false); - }); - - it("treats a future timestamp (clock skew) as fresh", () => { - expect( - shouldWarnStaleCostlyConversation({ - usedTokens: 50_000, - lastActivityAt: now + 60_000, - now, - threshold, - }), - ).toBe(false); - }); - - it("falls back to DEFAULT_STALE_COSTLY_THRESHOLD when none is given", () => { - expect( - shouldWarnStaleCostlyConversation({ - usedTokens: DEFAULT_STALE_COSTLY_THRESHOLD.tokens, - lastActivityAt: now - DEFAULT_STALE_COSTLY_THRESHOLD.staleMs, - now, - }), - ).toBe(true); - }); -}); - -describe("extractLastActivityAt", () => { - it("returns null for an empty event list", () => { - expect(extractLastActivityAt([])).toBeNull(); - }); - - it("returns the ts of the most recent event", () => { - const events: AcpMessage[] = [ - { ...agentChunkEvent(), ts: 10 }, - { ...usageUpdateEvent(50_000, 200_000), ts: 20 }, - ]; - expect(extractLastActivityAt(events)).toBe(20); - }); - - it("returns the maximum ts even when events are out of order", () => { - const events: AcpMessage[] = [ - { ...usageUpdateEvent(50_000, 200_000), ts: 30 }, - { ...agentChunkEvent(), ts: 10 }, - ]; - expect(extractLastActivityAt(events)).toBe(30); - }); -}); diff --git a/packages/core/src/sessions/contextUsage.ts b/packages/core/src/sessions/contextUsage.ts index 1753577c2b..6d37bc156c 100644 --- a/packages/core/src/sessions/contextUsage.ts +++ b/packages/core/src/sessions/contextUsage.ts @@ -138,76 +138,3 @@ function extractBreakdown(msg: AcpMessage["message"]): ContextBreakdown | null { const params = msg.params as { breakdown?: ContextBreakdown } | undefined; return params?.breakdown ?? null; } - -/** - * Threshold controlling when {@link shouldWarnStaleCostlyConversation} fires. - */ -export interface StaleCostlyThreshold { - /** Minimum context tokens for a conversation to count as "large". */ - tokens: number; - /** - * Minimum idle time (ms) before a conversation counts as "stale". See - * {@link DEFAULT_STALE_COSTLY_THRESHOLD} for how this bound is chosen. - */ - staleMs: number; -} - -/** - * Defaults for the stale-costly conversation warning. - * - * `tokens` (100k): only conversations big enough that a cold prefix rebuild is - * a real cost — roughly 10% of the 1M context window — trip the warning. - * - * `staleMs` (60 min): Anthropic ephemeral caches default to a 5-minute TTL and - * can opt into a 1-hour one; the effective value depends on what the Agent SDK - * requests, which we don't control or observe. We deliberately use the longer - * 1-hour bound so the cache is almost certainly cold before we warn — warning - * while it is still warm would nag about a continuation that is in fact cheap, - * the worse failure. Erring long only means we occasionally skip a warning, - * never that we nag needlessly. - */ -export const DEFAULT_STALE_COSTLY_THRESHOLD: StaleCostlyThreshold = { - tokens: 100_000, - staleMs: 60 * 60 * 1000, -}; - -/** - * Decide whether to warn that continuing a conversation will be costly: true - * when it is both large (>= `threshold.tokens`) and stale (idle >= - * `threshold.staleMs`). See {@link DEFAULT_STALE_COSTLY_THRESHOLD} for the - * pricing rationale behind the defaults. - * - * Pure and time-injected (no `Date.now()`). A `null` `lastActivityAt` never - * warns, and a future timestamp (clock skew) reads as fresh. - */ -export function shouldWarnStaleCostlyConversation(args: { - usedTokens: number; - lastActivityAt: number | null; - now: number; - threshold?: StaleCostlyThreshold; -}): boolean { - const { usedTokens, lastActivityAt, now } = args; - const threshold = args.threshold ?? DEFAULT_STALE_COSTLY_THRESHOLD; - if (lastActivityAt === null) return false; - if (usedTokens < threshold.tokens) return false; - return now - lastActivityAt >= threshold.staleMs; -} - -/** - * Best-effort "time of last activity" for a session: the most recent (maximum) - * `ts` among events, or null for an empty list. Scans for the max rather than - * trusting positional order, so an out-of-order append can't report a staler - * time than reality. (Uses a loop, not the spread form of `Math.max`, which can - * overflow the stack on long event lists.) Heuristic proxy for prompt-cache - * freshness — `ts` is stamped on *any* AcpMessage (agent chunks, tool calls, - * client-side events), not only turns sent to the model. Good enough for a soft - * cost warning; not a billing signal. - */ -export function extractLastActivityAt(events: AcpMessage[]): number | null { - if (events.length === 0) return null; - let latest = events[0].ts; - for (let i = 1; i < events.length; i++) { - if (events[i].ts > latest) latest = events[i].ts; - } - return latest; -} diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 7d88919365..18ac01c6f8 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -135,12 +135,6 @@ export interface PromptSentProperties { prompt_length_chars: number; } -export interface StaleConversationGateChoiceProperties { - choice: "compact" | "dismiss" | "new_session"; - used_tokens: number; - cost_usd: number | null; -} - // Git operations export interface GitActionExecutedProperties { action_type: GitActionType; @@ -1012,7 +1006,6 @@ export const ANALYTICS_EVENTS = { TASK_RUN_COMPLETED: "Task run completed", TASK_RUN_CANCELLED: "Task run cancelled", PROMPT_SENT: "Prompt sent", - STALE_CONVERSATION_GATE_CHOICE: "Stale conversation gate choice", // Claude Code session import CLAUDE_SESSIONS_SHOWN: "Claude Code sessions shown", @@ -1167,7 +1160,6 @@ export type EventPropertyMap = { [ANALYTICS_EVENTS.TASK_RUN_COMPLETED]: TaskRunCompletedProperties; [ANALYTICS_EVENTS.TASK_RUN_CANCELLED]: TaskRunCancelledProperties; [ANALYTICS_EVENTS.PROMPT_SENT]: PromptSentProperties; - [ANALYTICS_EVENTS.STALE_CONVERSATION_GATE_CHOICE]: StaleConversationGateChoiceProperties; // Claude Code session import [ANALYTICS_EVENTS.CLAUDE_SESSIONS_SHOWN]: ClaudeSessionsShownProperties; diff --git a/packages/ui/src/features/sessions/components/SessionView.tsx b/packages/ui/src/features/sessions/components/SessionView.tsx index abd20e8fa5..6ca5b8b7e6 100644 --- a/packages/ui/src/features/sessions/components/SessionView.tsx +++ b/packages/ui/src/features/sessions/components/SessionView.tsx @@ -5,11 +5,7 @@ import { type SessionService, } from "@posthog/core/sessions/sessionService"; import { useService } from "@posthog/di/react"; -import { - type AcpMessage, - ANALYTICS_EVENTS, - type StaleConversationGateChoiceProperties, -} from "@posthog/shared"; +import type { AcpMessage } from "@posthog/shared"; import type { Task, TaskRunStatus } from "@posthog/shared/domain-types"; import { showOfflineToast } from "@posthog/ui/features/connectivity/connectivityToast"; import { @@ -33,7 +29,6 @@ import { QueuedMessagesDock } from "@posthog/ui/features/sessions/components/Que import { ReasoningLevelSelector } from "@posthog/ui/features/sessions/components/ReasoningLevelSelector"; import { RawLogsView } from "@posthog/ui/features/sessions/components/raw-logs/RawLogsView"; import { SessionResourcesBar } from "@posthog/ui/features/sessions/components/SessionResourcesBar"; -import { StaleConversationCostNotice } from "@posthog/ui/features/sessions/components/StaleConversationCostNotice"; import { SteerQueueToggle } from "@posthog/ui/features/sessions/components/SteerQueueToggle"; import { ThreadView } from "@posthog/ui/features/sessions/components/ThreadView"; import { CHAT_CONTENT_MAX_WIDTH } from "@posthog/ui/features/sessions/constants"; @@ -51,12 +46,10 @@ import { } from "@posthog/ui/features/sessions/sessionViewStore"; import type { Plan } from "@posthog/ui/features/sessions/types"; import { useSessionHandoffInProgress } from "@posthog/ui/features/sessions/useSession"; -import { useStaleConversationGate } from "@posthog/ui/features/sessions/useStaleConversationGate"; import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { useIsWorkspaceCloudRun } from "@posthog/ui/features/workspace/useWorkspace"; import { useConnectivity } from "@posthog/ui/hooks/useConnectivity"; import { toast } from "@posthog/ui/primitives/toast"; -import { track } from "@posthog/ui/shell/analytics"; import { pendingTaskPromptStoreApi, usePendingTaskPrompt, @@ -328,50 +321,6 @@ export function SessionView({ [isOnline, onBeforeSubmit], ); - // Warn PostHog staff before continuing a large, idle conversation whose - // prompt cache has likely expired (see useStaleConversationGate). - const staleGate = useStaleConversationGate(sessionId, events); - - // Plain functions, not useCallbacks: the notice isn't memoized, and the - // values they close over (usage, cost) change with every streamed event. - const trackStaleGateChoice = ( - choice: StaleConversationGateChoiceProperties["choice"], - ) => - track(ANALYTICS_EVENTS.STALE_CONVERSATION_GATE_CHOICE, { - choice, - used_tokens: staleGate.usedTokens, - cost_usd: staleGate.costUsd, - }); - - const handleStaleCompact = () => { - if (!isOnline) { - showOfflineToast(); - return; - } - trackStaleGateChoice("compact"); - staleGate.onContinue(); - onSendPrompt("/compact"); - }; - - const handleStaleDismiss = () => { - trackStaleGateChoice("dismiss"); - staleGate.onContinue(); - }; - - // Non-blocking cost banner pinned above the composer. Rendered in both the - // pending-permission slot and the normal composer so a stale conversation is - // flagged either way; onCompact is omitted while a permission is pending (a - // queued /compact would land after answering it, paying the reload twice). - const staleNotice = (permissionPending: boolean) => - staleGate.active ? ( - - ) : null; - const [isDraggingFile, setIsDraggingFile] = useState(false); const editorRef = useRef(null); const dragCounterRef = useRef(0); @@ -675,7 +624,6 @@ export function SessionView({ ) : hideInput ? null : firstPendingPermission ? ( - {staleNotice(true)} - {staleNotice(false)} {taskId && } = { - title: "Sessions/StaleConversationCostNotice", - component: StaleConversationCostNotice, - decorators: [ - (Story) => ( - - - - - - - ), - ], -}; - -export default meta; -type Story = StoryObj; - -/** - * Stand-in chat content, capped at the same width as real thread content - * (ConversationView) so the notice reads at its true relative width. - */ -function FakeConversation() { - return ( - - - {[ - "Investigate detached element memory leaks", - "Ran 3 commands, 1 subagent", - "Review posted. Now the sticky summary comment:", - "Pushed and in sync. Now the simplify pass on this HEAD:", - "1 subagent - simplify pass on 68452", - ].map((line) => ( - - - {line} - - - ))} - - - ); -} - -export const Default: Story = { - args: { - usedTokens: 481_000, - costUsd: 12.34, - onDismiss: () => {}, - onCompact: () => {}, - }, -}; - -export const WithoutCost: Story = { - args: { - usedTokens: 128_000, - costUsd: null, - onDismiss: () => {}, - onCompact: () => {}, - }, -}; - -/** Compact is hidden while a permission is pending — dismiss only. */ -export const PermissionPending: Story = { - args: { - usedTokens: 481_000, - costUsd: 12.34, - onDismiss: () => {}, - }, -}; diff --git a/packages/ui/src/features/sessions/components/StaleConversationCostNotice.tsx b/packages/ui/src/features/sessions/components/StaleConversationCostNotice.tsx deleted file mode 100644 index bb63242532..0000000000 --- a/packages/ui/src/features/sessions/components/StaleConversationCostNotice.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { Warning, X } from "@phosphor-icons/react"; -import { formatUsd } from "@posthog/core/billing/spendAnalysisFormat"; -import { Button } from "@posthog/quill"; -import { formatTokensCompact } from "@posthog/ui/features/sessions/contextColors"; -import { Tooltip } from "@posthog/ui/primitives/Tooltip"; -import { Box, Flex, IconButton, Text } from "@radix-ui/themes"; - -interface StaleConversationCostNoticeProps { - usedTokens: number; - /** Cumulative session cost so far, when the gateway reports it. */ - costUsd: number | null; - /** Dismiss the notice — "continue anyway", acknowledged for the session. */ - onDismiss: () => void; - /** - * Compact the thread: pay the reload once, then every later turn is - * smaller. Omitted while a permission is pending — a queued /compact would - * land after answering it, paying the reload twice. - */ - onCompact?: () => void; -} - -/** - * Slim, dismissible banner pinned above the composer when PostHog staff return - * to a large, idle conversation whose prompt cache has likely expired. Unlike a - * permission prompt it does not take over the input box: the user can keep - * typing and deal with (or dismiss) it whenever they like. - */ -export function StaleConversationCostNotice({ - usedTokens, - costUsd, - onDismiss, - onCompact, -}: StaleConversationCostNoticeProps) { - const spent = - costUsd !== null ? ` (≈${formatUsd(costUsd)} spent so far)` : ""; - return ( - - - - - This conversation is large (~{formatTokensCompact(usedTokens)} tokens) - and idle, so its prompt cache has likely expired — the next message - re-processes everything at full input price instead of the ~10% cached - rate{spent}. - - - {onCompact && ( - - - - )} - - - - - - - - - ); -} diff --git a/packages/ui/src/features/sessions/staleConversationGateStore.test.ts b/packages/ui/src/features/sessions/staleConversationGateStore.test.ts deleted file mode 100644 index 09db3dff3b..0000000000 --- a/packages/ui/src/features/sessions/staleConversationGateStore.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { useStaleConversationGateStore } from "./staleConversationGateStore"; - -const state = () => useStaleConversationGateStore.getState(); -const acknowledged = (id: string) => state().acknowledgedSessions.has(id); -const engaged = (id: string) => state().engagedSessions.has(id); - -describe("useStaleConversationGateStore", () => { - beforeEach(() => { - useStaleConversationGateStore.setState({ - engagedSessions: new Map(), - acknowledgedSessions: new Set(), - }); - }); - - it("starts with nothing engaged or acknowledged", () => { - expect(engaged("s1")).toBe(false); - expect(acknowledged("s1")).toBe(false); - }); - - it("engages a single session with its last-activity snapshot", () => { - state().engage("s1", 1000); - expect(state().engagedSessions.get("s1")).toBe(1000); - expect(engaged("s2")).toBe(false); - }); - - it("keeps the first engagement snapshot when engaged again", () => { - state().engage("s1", 1000); - state().engage("s1", 2000); - expect(state().engagedSessions.get("s1")).toBe(1000); - }); - - it("does not re-engage an acknowledged session", () => { - state().engage("s1", 1000); - state().acknowledge("s1"); - state().engage("s1", 2000); - expect(engaged("s1")).toBe(false); - }); - - it("acknowledges a single session and releases its engagement", () => { - state().engage("s1", 1000); - state().engage("s2", 1000); - state().acknowledge("s1"); - expect(acknowledged("s1")).toBe(true); - expect(engaged("s1")).toBe(false); - expect(acknowledged("s2")).toBe(false); - expect(engaged("s2")).toBe(true); - }); - - it("replaces the Set immutably on acknowledge", () => { - const before = state().acknowledgedSessions; - state().acknowledge("s1"); - const after = state().acknowledgedSessions; - expect(after).not.toBe(before); - expect(before.has("s1")).toBe(false); - }); - - it("is idempotent — acknowledging twice keeps the same reference", () => { - state().acknowledge("s1"); - const first = state().acknowledgedSessions; - state().acknowledge("s1"); - const second = state().acknowledgedSessions; - expect(second).toBe(first); - }); - - it("acknowledging a never-engaged session keeps the engaged Map reference", () => { - state().engage("s2", 1000); - const before = state().engagedSessions; - state().acknowledge("s1"); - expect(state().engagedSessions).toBe(before); - }); -}); diff --git a/packages/ui/src/features/sessions/staleConversationGateStore.ts b/packages/ui/src/features/sessions/staleConversationGateStore.ts deleted file mode 100644 index 9035adf6f4..0000000000 --- a/packages/ui/src/features/sessions/staleConversationGateStore.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { create } from "zustand"; - -interface StaleConversationGateState { - /** - * Sessions where the gate engaged, keyed to the last-activity time observed - * at that moment (null when no activity was observed). Latched because - * reconnecting to a stale session immediately appends freshly-stamped - * events (usage updates, handshakes) that would otherwise make the - * conversation look active again and dismiss the warning before the user - * has chosen. - */ - engagedSessions: Map; - /** Sessions where the user accepted the "large + idle = costly" warning. */ - acknowledgedSessions: Set; -} - -interface StaleConversationGateActions { - engage: (sessionId: string, lastActivityAt: number | null) => void; - acknowledge: (sessionId: string) => void; -} - -export type StaleConversationGateStore = StaleConversationGateState & - StaleConversationGateActions; - -/** - * Tracks which sessions have engaged and which have accepted the - * stale-costly-conversation cost warning. Ephemeral view state (not - * persisted): both only need to last for the current app run. - */ -export const useStaleConversationGateStore = - create()((set) => ({ - engagedSessions: new Map(), - acknowledgedSessions: new Set(), - - engage: (sessionId, lastActivityAt) => - set((state) => { - if ( - state.engagedSessions.has(sessionId) || - state.acknowledgedSessions.has(sessionId) - ) { - return state; - } - const next = new Map(state.engagedSessions); - next.set(sessionId, lastActivityAt); - return { engagedSessions: next }; - }), - - acknowledge: (sessionId) => - set((state) => { - if (state.acknowledgedSessions.has(sessionId)) return state; - const nextAcknowledged = new Set(state.acknowledgedSessions); - nextAcknowledged.add(sessionId); - if (!state.engagedSessions.has(sessionId)) { - return { acknowledgedSessions: nextAcknowledged }; - } - const nextEngaged = new Map(state.engagedSessions); - nextEngaged.delete(sessionId); - return { - acknowledgedSessions: nextAcknowledged, - engagedSessions: nextEngaged, - }; - }), - })); diff --git a/packages/ui/src/features/sessions/useStaleConversationGate.ts b/packages/ui/src/features/sessions/useStaleConversationGate.ts deleted file mode 100644 index e71e0c9d24..0000000000 --- a/packages/ui/src/features/sessions/useStaleConversationGate.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { - extractLastActivityAt, - shouldWarnStaleCostlyConversation, -} from "@posthog/core/sessions/contextUsage"; -import type { AcpMessage } from "@posthog/shared"; -import { useMeQuery } from "@posthog/ui/features/auth/useMeQuery"; -import { useContextUsage } from "@posthog/ui/features/sessions/hooks/useContextUsage"; -import { useStaleConversationGateStore } from "@posthog/ui/features/sessions/staleConversationGateStore"; -import { useCallback, useEffect } from "react"; - -export interface StaleConversationGate { - /** Gate engaged — block the conversation until the user chooses. */ - active: boolean; - usedTokens: number; - /** Last activity as observed when the gate engaged, not the live value. */ - lastActivityAt: number | null; - costUsd: number | null; - /** "Continue anyway" — permanently acknowledge the warning for this session. */ - onContinue: () => void; -} - -/** - * Gates continuation of a large, idle conversation for PostHog staff, whose - * Anthropic prompt cache has likely expired (see - * {@link shouldWarnStaleCostlyConversation}). - * - * The gate latches: opening a stale session reconnects the agent, which - * immediately emits freshly-stamped events (usage updates, handshakes), so - * the raw staleness check flips back off before the user has seen the - * warning. Once engaged, only acknowledging releases it. - */ -export function useStaleConversationGate( - sessionId: string, - events: AcpMessage[], -): StaleConversationGate { - const contextUsage = useContextUsage(events); - const { data: currentUser } = useMeQuery(); - const isStaff = currentUser?.is_staff === true; - const engaged = useStaleConversationGateStore((s) => - s.engagedSessions.has(sessionId), - ); - const engagedLastActivityAt = useStaleConversationGateStore((s) => - s.engagedSessions.get(sessionId), - ); - const acknowledged = useStaleConversationGateStore((s) => - s.acknowledgedSessions.has(sessionId), - ); - const engage = useStaleConversationGateStore((s) => s.engage); - const acknowledge = useStaleConversationGateStore((s) => s.acknowledge); - - const usedTokens = contextUsage?.used ?? 0; - const liveLastActivityAt = extractLastActivityAt(events); - const shouldEngage = - isStaff && - !acknowledged && - shouldWarnStaleCostlyConversation({ - usedTokens, - lastActivityAt: liveLastActivityAt, - now: Date.now(), - }); - - useEffect(() => { - if (shouldEngage) engage(sessionId, liveLastActivityAt); - }, [shouldEngage, engage, sessionId, liveLastActivityAt]); - - const onContinue = useCallback( - () => acknowledge(sessionId), - [acknowledge, sessionId], - ); - - return { - // shouldEngage covers the first paint before the effect latches; - // engaged covers every render after (reconnect events flip - // shouldEngage back off — see the hook doc). - active: shouldEngage || engaged, - usedTokens, - lastActivityAt: engaged - ? (engagedLastActivityAt ?? null) - : liveLastActivityAt, - costUsd: contextUsage?.cost?.amount ?? null, - onContinue, - }; -}