Skip to content

Commit 542b9a9

Browse files
authored
fix(sessions): stale-conversation cost warning is a blocking prompt-input state (#3152)
1 parent cab5b24 commit 542b9a9

10 files changed

Lines changed: 448 additions & 199 deletions

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: 132 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ 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 {
9+
type AcpMessage,
10+
ANALYTICS_EVENTS,
11+
type StaleConversationGateChoiceProperties,
12+
} from "@posthog/shared";
913
import type { Task, TaskRunStatus } from "@posthog/shared/domain-types";
1014
import { showOfflineToast } from "@posthog/ui/features/connectivity/connectivityToast";
1115
import {
@@ -29,7 +33,7 @@ import { QueuedMessagesDock } from "@posthog/ui/features/sessions/components/Que
2933
import { ReasoningLevelSelector } from "@posthog/ui/features/sessions/components/ReasoningLevelSelector";
3034
import { RawLogsView } from "@posthog/ui/features/sessions/components/raw-logs/RawLogsView";
3135
import { SessionResourcesBar } from "@posthog/ui/features/sessions/components/SessionResourcesBar";
32-
import { StaleConversationCostDialog } from "@posthog/ui/features/sessions/components/StaleConversationCostDialog";
36+
import { StaleConversationCostNotice } from "@posthog/ui/features/sessions/components/StaleConversationCostNotice";
3337
import { SteerQueueToggle } from "@posthog/ui/features/sessions/components/SteerQueueToggle";
3438
import { ThreadView } from "@posthog/ui/features/sessions/components/ThreadView";
3539
import { CHAT_CONTENT_MAX_WIDTH } from "@posthog/ui/features/sessions/constants";
@@ -52,6 +56,7 @@ import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore";
5256
import { useIsWorkspaceCloudRun } from "@posthog/ui/features/workspace/useWorkspace";
5357
import { useConnectivity } from "@posthog/ui/hooks/useConnectivity";
5458
import { toast } from "@posthog/ui/primitives/toast";
59+
import { track } from "@posthog/ui/shell/analytics";
5560
import {
5661
pendingTaskPromptStoreApi,
5762
usePendingTaskPrompt,
@@ -94,6 +99,54 @@ interface SessionViewProps {
9499
const DEFAULT_ERROR_MESSAGE =
95100
"Failed to resume this session. The working directory may have been deleted. Please start a new session.";
96101

102+
function ConnectingToAgent() {
103+
return (
104+
<>
105+
<Spinner size={28} className="animate-spin text-gray-9" />
106+
<Text color="gray" className="text-base">
107+
Connecting to agent...
108+
</Text>
109+
</>
110+
);
111+
}
112+
113+
/** Centers composer-slot content at the chat width (or compact padding). */
114+
function ComposerWidth({
115+
compact,
116+
children,
117+
}: {
118+
compact: boolean;
119+
children: React.ReactNode;
120+
}) {
121+
return (
122+
<Box
123+
className={compact ? "p-1" : "mx-auto px-2 pb-3"}
124+
style={compact ? undefined : { maxWidth: CHAT_CONTENT_MAX_WIDTH }}
125+
>
126+
{children}
127+
</Box>
128+
);
129+
}
130+
131+
/**
132+
* Input region replacing the composer: `shrink-0` keeps it from being
133+
* compressed by the scroller above, and `min-h-0 overflow-y-auto` lets tall
134+
* content scroll inside itself.
135+
*/
136+
function ComposerSlot({
137+
compact,
138+
children,
139+
}: {
140+
compact: boolean;
141+
children: React.ReactNode;
142+
}) {
143+
return (
144+
<Box className="min-h-0 shrink-0 overflow-y-auto">
145+
<ComposerWidth compact={compact}>{children}</ComposerWidth>
146+
</Box>
147+
);
148+
}
149+
97150
interface CloudStreamDisconnectedBannerProps {
98151
errorTitle?: string;
99152
errorMessage?: string;
@@ -271,6 +324,39 @@ export function SessionView({
271324
// prompt cache has likely expired (see useStaleConversationGate).
272325
const staleGate = useStaleConversationGate(sessionId, events);
273326

327+
// Plain functions, not useCallbacks: the notice isn't memoized, and the
328+
// values they close over (usage, cost) change with every streamed event.
329+
const trackStaleGateChoice = (
330+
choice: StaleConversationGateChoiceProperties["choice"],
331+
) =>
332+
track(ANALYTICS_EVENTS.STALE_CONVERSATION_GATE_CHOICE, {
333+
choice,
334+
used_tokens: staleGate.usedTokens,
335+
cost_usd: staleGate.costUsd,
336+
});
337+
338+
const handleStaleCompact = () => {
339+
if (!isOnline) {
340+
showOfflineToast();
341+
return;
342+
}
343+
trackStaleGateChoice("compact");
344+
staleGate.onContinue();
345+
onSendPrompt("/compact");
346+
};
347+
348+
const handleStaleContinue = () => {
349+
trackStaleGateChoice("continue");
350+
staleGate.onContinue();
351+
};
352+
353+
const handleStaleNewSession = onNewSession
354+
? () => {
355+
trackStaleGateChoice("new_session");
356+
onNewSession();
357+
}
358+
: undefined;
359+
274360
const [isDraggingFile, setIsDraggingFile] = useState(false);
275361
const editorRef = useRef<PromptInputHandle>(null);
276362
const dragCounterRef = useRef(0);
@@ -572,27 +658,46 @@ export function SessionView({
572658
)}
573659
</Flex>
574660
</Flex>
575-
) : hideInput ? null : firstPendingPermission ? (
576-
// This box replaces the composer while a permission is pending, so it's an input
577-
// region: `shrink-0` keeps it from being compressed by the scroller above, and
578-
// `min-h-0 overflow-y-auto` lets a tall permission prompt scroll inside itself.
579-
<Box className="min-h-0 shrink-0 overflow-y-auto">
580-
<Box
581-
className={compact ? "p-1" : "mx-auto px-2 pb-3"}
582-
style={
583-
compact
584-
? undefined
585-
: { maxWidth: CHAT_CONTENT_MAX_WIDTH }
586-
}
587-
>
588-
<PermissionSelector
589-
toolCall={firstPendingPermission.toolCall}
590-
options={firstPendingPermission.options}
591-
onSelect={handlePermissionSelect}
592-
onCancel={handlePermissionCancel}
661+
) : hideInput ? null : staleGate.active ? (
662+
// Replaces the composer (and any pending permission — answering
663+
// one also resumes the costly turn) until the user chooses.
664+
isRunning ? (
665+
<ComposerSlot compact={compact}>
666+
<StaleConversationCostNotice
667+
usedTokens={staleGate.usedTokens}
668+
lastActivityAt={staleGate.lastActivityAt}
669+
costUsd={staleGate.costUsd}
670+
onContinue={handleStaleContinue}
671+
onCompact={
672+
firstPendingPermission
673+
? undefined
674+
: handleStaleCompact
675+
}
676+
onNewSession={handleStaleNewSession}
593677
/>
594-
</Box>
595-
</Box>
678+
</ComposerSlot>
679+
) : (
680+
// While reconnecting the gate still covers the composer
681+
// slot: handoff can leave pendingPermissions set, and the
682+
// choices must not fire into a half-connected session.
683+
<Flex
684+
align="center"
685+
justify="center"
686+
gap="2"
687+
className="min-h-[66px]"
688+
>
689+
<ConnectingToAgent />
690+
</Flex>
691+
)
692+
) : firstPendingPermission ? (
693+
<ComposerSlot compact={compact}>
694+
<PermissionSelector
695+
toolCall={firstPendingPermission.toolCall}
696+
options={firstPendingPermission.options}
697+
onSelect={handlePermissionSelect}
698+
onCancel={handlePermissionCancel}
699+
/>
700+
</ComposerSlot>
596701
) : (
597702
<Box className="relative">
598703
<Box
@@ -602,10 +707,7 @@ export function SessionView({
602707
: "opacity-100"
603708
}`}
604709
>
605-
<Spinner size={28} className="animate-spin text-gray-9" />
606-
<Text color="gray" className="text-base">
607-
Connecting to agent...
608-
</Text>
710+
<ConnectingToAgent />
609711
</Box>
610712
<Box
611713
className={`transition-all duration-300 ease-out ${
@@ -614,60 +716,18 @@ export function SessionView({
614716
: "pointer-events-none translate-y-4 opacity-0"
615717
}`}
616718
>
617-
<Box
618-
className={compact ? "p-1" : "mx-auto px-2 pb-3"}
619-
style={
620-
compact
621-
? undefined
622-
: { maxWidth: CHAT_CONTENT_MAX_WIDTH }
623-
}
624-
>
719+
<ComposerWidth compact={compact}>
625720
{taskId && <QueuedMessagesDock taskId={taskId} />}
626-
{staleGate.dismissed && (
627-
<Flex justify="center" mb="2">
628-
<Button
629-
variant="soft"
630-
color="amber"
631-
size="1"
632-
onClick={staleGate.onReopen}
633-
>
634-
<Warning size={14} weight="fill" />
635-
Conversation paused to avoid a costly reload —
636-
review
637-
</Button>
638-
</Flex>
639-
)}
640-
<StaleConversationCostDialog
641-
open={staleGate.dialogOpen}
642-
usedTokens={staleGate.usedTokens}
643-
lastActivityAt={staleGate.lastActivityAt}
644-
costUsd={staleGate.costUsd}
645-
onContinue={staleGate.onContinue}
646-
onCompact={() => {
647-
// Acknowledge so the gate clears and the dialog
648-
// closes, then trigger the agent's manual compaction.
649-
staleGate.onContinue();
650-
onSendPrompt("/compact");
651-
}}
652-
onOpenChange={staleGate.onDialogOpenChange}
653-
/>
654721
<PromptInput
655722
ref={editorRef}
656723
sessionId={sessionId}
657724
placeholder="Type a message... @ to mention files, ! for bash mode, / for skills"
658-
disabled={
659-
(!isRunning && !handoffInProgress) ||
660-
staleGate.active
661-
}
725+
disabled={!isRunning && !handoffInProgress}
662726
submitDisabledExternal={
663727
handoffInProgress || !isOnline
664728
}
665729
submitTooltipOverride={
666-
staleGate.active
667-
? "Large idle conversation — review the cost notice to continue"
668-
: !isOnline
669-
? "No internet connection"
670-
: undefined
730+
!isOnline ? "No internet connection" : undefined
671731
}
672732
isLoading={!!isPromptPending}
673733
isActiveSession={isActiveSession}
@@ -708,7 +768,7 @@ export function SessionView({
708768
onBashCommand={onBashCommand}
709769
onCancel={onCancelPrompt}
710770
/>
711-
</Box>
771+
</ComposerWidth>
712772
</Box>
713773
</Box>
714774
)}

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

Lines changed: 0 additions & 71 deletions
This file was deleted.

0 commit comments

Comments
 (0)