1+ import { stripTrailingAttachmentSummary } from "@posthog/core/editor/cloud-prompt" ;
12import type { ConversationItem } from "./buildConversationItems" ;
23import { extractChannelContext } from "./session-update/channelContext" ;
34import { extractCustomInstructions } from "./session-update/customInstructions" ;
@@ -8,15 +9,22 @@ interface MergeConversationItemsArgs {
89 isCloud : boolean ;
910}
1011
12+ type UserMessageItem = Extract < ConversationItem , { type : "user_message" } > ;
13+
1114// The pinned optimistic bubble is seeded from the bare task description, but the
1215// echoed `session/prompt` that streams back from the sandbox may additionally
1316// carry the channel's CONTEXT.md and/or the user's personalization, folded into
1417// the prompt at task creation (see buildChannelContextText /
15- // buildCustomInstructionsText in @posthog /core). Dedupe and upgrade compare on the
16- // text with both blocks stripped so the echo still matches its placeholder.
18+ // buildCustomInstructionsText in @posthog /core). The description side instead
19+ // appends an `Attached files: <names>` summary line that the echo carries as
20+ // resource_link blocks, not text (see buildCloudTaskDescription). Dedupe and
21+ // upgrade compare on the text with all three stripped so the echo still matches
22+ // its placeholder.
1723function strippedUserContent ( content : string ) : string {
1824 const withoutChannel = extractChannelContext ( content ) ?. stripped ?? content ;
19- return extractCustomInstructions ( withoutChannel ) ?. stripped ?? withoutChannel ;
25+ const withoutInstructions =
26+ extractCustomInstructions ( withoutChannel ) ?. stripped ?? withoutChannel ;
27+ return stripTrailingAttachmentSummary ( withoutInstructions ) ;
2028}
2129
2230// Cloud's initial optimistic is pinned to the top so the user's prompt stays
@@ -44,43 +52,55 @@ export function mergeConversationItems({
4452 const tailOptimisticItems = optimisticItems . filter (
4553 ( item ) => item . type === "user_message" && item . pinToTop === false ,
4654 ) ;
47- const pinnedOptimisticUserContents = new Set (
48- pinnedOptimisticItems
49- . filter (
50- ( item ) : item is Extract < typeof item , { type : "user_message" } > =>
51- item . type === "user_message" ,
52- )
53- . map ( ( item ) => strippedUserContent ( item . content ) ) ,
54- ) ;
55+ const unconsumedPinnedKeyCounts = new Map < string , number > ( ) ;
56+ for ( const item of pinnedOptimisticItems ) {
57+ if ( item . type !== "user_message" ) continue ;
58+ const key = strippedUserContent ( item . content ) ;
59+ unconsumedPinnedKeyCounts . set (
60+ key ,
61+ ( unconsumedPinnedKeyCounts . get ( key ) ?? 0 ) + 1 ,
62+ ) ;
63+ }
5564
5665 // When the echoed prompt matches a pinned optimistic placeholder, drop the
57- // echo but remember its content: it may carry the channel CONTEXT.md block the
58- // placeholder lacks, so we surface the richer copy on the pinned bubble below.
59- const echoedContentByKey = new Map < string , string > ( ) ;
66+ // echo but remember it: it may carry the channel CONTEXT.md block and the
67+ // attachment chips the placeholder lacks, so we surface the richer copy on
68+ // the pinned bubble below.
69+ const echoedItemByKey = new Map < string , UserMessageItem > ( ) ;
6070 const dedupedConversation =
61- pinnedOptimisticUserContents . size === 0
71+ unconsumedPinnedKeyCounts . size === 0
6272 ? conversationItems
6373 : conversationItems . filter ( ( item ) => {
6474 if ( item . type !== "user_message" ) return true ;
6575 const key = strippedUserContent ( item . content ) ;
66- if ( ! pinnedOptimisticUserContents . has ( key ) ) return true ;
67- if ( ! echoedContentByKey . has ( key ) ) {
68- echoedContentByKey . set ( key , item . content ) ;
76+ const remaining = unconsumedPinnedKeyCounts . get ( key ) ?? 0 ;
77+ if ( remaining === 0 ) return true ;
78+ unconsumedPinnedKeyCounts . set ( key , remaining - 1 ) ;
79+ if ( ! echoedItemByKey . has ( key ) ) {
80+ echoedItemByKey . set ( key , item ) ;
6981 }
7082 return false ;
7183 } ) ;
7284
7385 const resolvedPinnedItems =
74- echoedContentByKey . size === 0
86+ echoedItemByKey . size === 0
7587 ? pinnedOptimisticItems
7688 : pinnedOptimisticItems . map ( ( item ) => {
7789 if ( item . type !== "user_message" ) return item ;
78- const echoed = echoedContentByKey . get (
79- strippedUserContent ( item . content ) ,
80- ) ;
81- return echoed !== undefined && echoed !== item . content
82- ? { ...item , content : echoed }
83- : item ;
90+ const echoed = echoedItemByKey . get ( strippedUserContent ( item . content ) ) ;
91+ if (
92+ ! echoed ||
93+ ( echoed . content === item . content && ! echoed . attachments ?. length )
94+ ) {
95+ return item ;
96+ }
97+ return {
98+ ...item ,
99+ content : echoed . content ,
100+ ...( echoed . attachments ?. length
101+ ? { attachments : echoed . attachments }
102+ : { } ) ,
103+ } ;
84104 } ) ;
85105
86106 return [
0 commit comments