@@ -5,7 +5,11 @@ import {
55 type SessionService ,
66} from "@posthog/core/sessions/sessionService" ;
77import { 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" ;
913import type { Task , TaskRunStatus } from "@posthog/shared/domain-types" ;
1014import { showOfflineToast } from "@posthog/ui/features/connectivity/connectivityToast" ;
1115import {
@@ -29,7 +33,7 @@ import { QueuedMessagesDock } from "@posthog/ui/features/sessions/components/Que
2933import { ReasoningLevelSelector } from "@posthog/ui/features/sessions/components/ReasoningLevelSelector" ;
3034import { RawLogsView } from "@posthog/ui/features/sessions/components/raw-logs/RawLogsView" ;
3135import { 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 " ;
3337import { SteerQueueToggle } from "@posthog/ui/features/sessions/components/SteerQueueToggle" ;
3438import { ThreadView } from "@posthog/ui/features/sessions/components/ThreadView" ;
3539import { CHAT_CONTENT_MAX_WIDTH } from "@posthog/ui/features/sessions/constants" ;
@@ -52,6 +56,7 @@ import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore";
5256import { useIsWorkspaceCloudRun } from "@posthog/ui/features/workspace/useWorkspace" ;
5357import { useConnectivity } from "@posthog/ui/hooks/useConnectivity" ;
5458import { toast } from "@posthog/ui/primitives/toast" ;
59+ import { track } from "@posthog/ui/shell/analytics" ;
5560import {
5661 pendingTaskPromptStoreApi ,
5762 usePendingTaskPrompt ,
@@ -94,6 +99,54 @@ interface SessionViewProps {
9499const 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+
97150interface 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 ) }
0 commit comments