Skip to content

Commit 92a819f

Browse files
committed
fix: address qa-swarm review on the stale-conversation gate
- suppress "Compact and continue" while a permission is pending (a queued /compact would land after the permission resumes the costly turn, paying the reload twice) - guard the compact choice on connectivity so the gate is not released when the send cannot happen - wait for isRunning before showing the notice so choices cannot fire into a session that is still reconnecting - track which of the three options staff pick (typed analytics event) - let Escape bubble out of ActionSelector when there is no onCancel - explicit new-session branch in the notice dispatch, cost parenthetical moved next to the pricing sentence, comment on the active derivation Generated-By: PostHog Code Task-Id: 1ec8e82e-b126-48b8-8fd4-54edcaf041d5
1 parent f05d8c5 commit 92a819f

5 files changed

Lines changed: 85 additions & 18 deletions

File tree

packages/shared/src/analytics-events.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,12 @@ export interface PromptSentProperties {
135135
prompt_length_chars: number;
136136
}
137137

138+
export interface StaleConversationGateChoiceProperties {
139+
choice: "compact" | "continue" | "new_session";
140+
used_tokens: number;
141+
cost_usd: number | null;
142+
}
143+
138144
// Git operations
139145
export interface GitActionExecutedProperties {
140146
action_type: GitActionType;
@@ -1005,6 +1011,7 @@ export const ANALYTICS_EVENTS = {
10051011
TASK_RUN_COMPLETED: "Task run completed",
10061012
TASK_RUN_CANCELLED: "Task run cancelled",
10071013
PROMPT_SENT: "Prompt sent",
1014+
STALE_CONVERSATION_GATE_CHOICE: "Stale conversation gate choice",
10081015

10091016
// Claude Code session import
10101017
CLAUDE_SESSIONS_SHOWN: "Claude Code sessions shown",
@@ -1159,6 +1166,7 @@ export type EventPropertyMap = {
11591166
[ANALYTICS_EVENTS.TASK_RUN_COMPLETED]: TaskRunCompletedProperties;
11601167
[ANALYTICS_EVENTS.TASK_RUN_CANCELLED]: TaskRunCancelledProperties;
11611168
[ANALYTICS_EVENTS.PROMPT_SENT]: PromptSentProperties;
1169+
[ANALYTICS_EVENTS.STALE_CONVERSATION_GATE_CHOICE]: StaleConversationGateChoiceProperties;
11621170

11631171
// Claude Code session import
11641172
[ANALYTICS_EVENTS.CLAUDE_SESSIONS_SHOWN]: ClaudeSessionsShownProperties;

packages/ui/src/features/sessions/components/SessionView.tsx

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
type SessionService,
66
} from "@posthog/core/sessions/sessionService";
77
import { useService } from "@posthog/di/react";
8-
import type { AcpMessage } from "@posthog/shared";
8+
import { type AcpMessage, ANALYTICS_EVENTS } from "@posthog/shared";
99
import type { Task, TaskRunStatus } from "@posthog/shared/domain-types";
1010
import { showOfflineToast } from "@posthog/ui/features/connectivity/connectivityToast";
1111
import {
@@ -52,6 +52,7 @@ import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore";
5252
import { useIsWorkspaceCloudRun } from "@posthog/ui/features/workspace/useWorkspace";
5353
import { useConnectivity } from "@posthog/ui/hooks/useConnectivity";
5454
import { toast } from "@posthog/ui/primitives/toast";
55+
import { track } from "@posthog/ui/shell/analytics";
5556
import {
5657
pendingTaskPromptStoreApi,
5758
usePendingTaskPrompt,
@@ -271,10 +272,38 @@ export function SessionView({
271272
// prompt cache has likely expired (see useStaleConversationGate).
272273
const staleGate = useStaleConversationGate(sessionId, events);
273274

275+
const trackStaleGateChoice = useCallback(
276+
(choice: "compact" | "continue" | "new_session") =>
277+
track(ANALYTICS_EVENTS.STALE_CONVERSATION_GATE_CHOICE, {
278+
choice,
279+
used_tokens: staleGate.usedTokens,
280+
cost_usd: staleGate.costUsd,
281+
}),
282+
[staleGate.usedTokens, staleGate.costUsd],
283+
);
284+
274285
const handleStaleCompact = useCallback(() => {
286+
if (!isOnline) {
287+
showOfflineToast();
288+
return;
289+
}
290+
trackStaleGateChoice("compact");
275291
staleGate.onContinue();
276292
onSendPrompt("/compact");
277-
}, [staleGate.onContinue, onSendPrompt]);
293+
}, [isOnline, trackStaleGateChoice, staleGate.onContinue, onSendPrompt]);
294+
295+
const handleStaleContinue = useCallback(() => {
296+
trackStaleGateChoice("continue");
297+
staleGate.onContinue();
298+
}, [trackStaleGateChoice, staleGate.onContinue]);
299+
300+
const handleStaleNewSession = useMemo(() => {
301+
if (!onNewSession) return undefined;
302+
return () => {
303+
trackStaleGateChoice("new_session");
304+
onNewSession();
305+
};
306+
}, [trackStaleGateChoice, onNewSession]);
278307

279308
const [isDraggingFile, setIsDraggingFile] = useState(false);
280309
const editorRef = useRef<PromptInputHandle>(null);
@@ -577,9 +606,11 @@ export function SessionView({
577606
)}
578607
</Flex>
579608
</Flex>
580-
) : hideInput ? null : staleGate.active ? (
609+
) : hideInput ? null : staleGate.active && isRunning ? (
581610
// Replaces the composer (and any pending permission — answering
582611
// one also resumes the costly turn) until the user chooses.
612+
// Waits for isRunning so the choices can't fire into a session
613+
// that is still reconnecting.
583614
<Box className="min-h-0 shrink-0 overflow-y-auto">
584615
<Box
585616
className={compact ? "p-1" : "mx-auto px-2 pb-3"}
@@ -593,9 +624,16 @@ export function SessionView({
593624
usedTokens={staleGate.usedTokens}
594625
lastActivityAt={staleGate.lastActivityAt}
595626
costUsd={staleGate.costUsd}
596-
onContinue={staleGate.onContinue}
597-
onCompact={handleStaleCompact}
598-
onNewSession={onNewSession}
627+
onContinue={handleStaleContinue}
628+
// A queued /compact would land after the pending
629+
// permission resumes the costly turn — paying the
630+
// reload twice — so the option hides until then.
631+
onCompact={
632+
firstPendingPermission
633+
? undefined
634+
: handleStaleCompact
635+
}
636+
onNewSession={handleStaleNewSession}
599637
/>
600638
</Box>
601639
</Box>

packages/ui/src/features/sessions/components/StaleConversationCostNotice.tsx

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,12 @@ interface StaleConversationCostNoticeProps {
1212
/** Cumulative session cost so far, when the gateway reports it. */
1313
costUsd: number | null;
1414
onContinue: () => void;
15-
/** Compact the thread: pay the reload once, then every later turn is smaller. */
16-
onCompact: () => void;
15+
/**
16+
* Compact the thread: pay the reload once, then every later turn is
17+
* smaller. Omitted while a permission is pending — a queued /compact would
18+
* land after answering it, paying the reload twice.
19+
*/
20+
onCompact?: () => void;
1721
onNewSession?: () => void;
1822
}
1923

@@ -40,13 +44,18 @@ export function StaleConversationCostNotice({
4044
return (
4145
<ActionSelector
4246
title="Continue this large, idle conversation?"
43-
question={`This conversation holds about ${formatTokensCompact(usedTokens)} tokens and ${activity}${spent}. Its prompt cache has likely expired, so the next message re-processes everything at full input price instead of the ~10% cached rate. How do you want to continue?`}
47+
question={`This conversation holds about ${formatTokensCompact(usedTokens)} tokens and ${activity}. Its prompt cache has likely expired, so the next message re-processes everything at full input price instead of the ~10% cached rate${spent}. How do you want to continue?`}
4448
options={[
45-
{
46-
id: COMPACT_OPTION,
47-
label: "Compact and continue",
48-
description: "Pays the reload once, then every later turn is cheaper",
49-
},
49+
...(onCompact
50+
? [
51+
{
52+
id: COMPACT_OPTION,
53+
label: "Compact and continue",
54+
description:
55+
"Pays the reload once, then every later turn is cheaper",
56+
},
57+
]
58+
: []),
5059
{
5160
id: CONTINUE_OPTION,
5261
label: "Continue anyway",
@@ -63,9 +72,9 @@ export function StaleConversationCostNotice({
6372
: []),
6473
]}
6574
onSelect={(optionId) => {
66-
if (optionId === COMPACT_OPTION) onCompact();
75+
if (optionId === COMPACT_OPTION) onCompact?.();
6776
else if (optionId === CONTINUE_OPTION) onContinue();
68-
else onNewSession?.();
77+
else if (optionId === NEW_SESSION_OPTION) onNewSession?.();
6978
}}
7079
/>
7180
);

packages/ui/src/features/sessions/useStaleConversationGate.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ export function useStaleConversationGate(
6666
);
6767

6868
return {
69+
// shouldEngage covers the first paint before the effect latches;
70+
// engagement covers every render after (reconnect events flip
71+
// shouldEngage back off — see the hook doc).
6972
active: shouldEngage || engagement !== undefined,
7073
usedTokens,
7174
lastActivityAt: engagement ? engagement.lastActivityAt : liveLastActivityAt,

packages/ui/src/primitives/action-selector/ActionSelector.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,18 +102,25 @@ export function ActionSelector({
102102
hasSteps,
103103
showSubmitButton,
104104
multiSelect,
105+
hasCancel: onCancel !== undefined,
105106
});
106107
stateRef.current = {
107108
showInlineEdit,
108109
hasSteps,
109110
showSubmitButton,
110111
multiSelect,
112+
hasCancel: onCancel !== undefined,
111113
};
112114

113115
useEffect(() => {
114116
const handler = (e: KeyboardEvent) => {
115-
const { showInlineEdit, hasSteps, showSubmitButton, multiSelect } =
116-
stateRef.current;
117+
const {
118+
showInlineEdit,
119+
hasSteps,
120+
showSubmitButton,
121+
multiSelect,
122+
hasCancel,
123+
} = stateRef.current;
117124
const h = handlersRef.current;
118125

119126
if (showInlineEdit || document.activeElement?.tagName === "TEXTAREA")
@@ -179,6 +186,8 @@ export function ActionSelector({
179186
}
180187
break;
181188
case "Escape":
189+
// Nothing to cancel — let Escape bubble instead of swallowing it.
190+
if (!hasCancel) break;
182191
e.preventDefault();
183192
e.stopPropagation();
184193
h.handleCancel();

0 commit comments

Comments
 (0)