Skip to content

Commit 670c1ac

Browse files
authored
feat(chat-thread): show hover timestamps on agent messages and tool calls (#3081)
1 parent 246b0b4 commit 670c1ac

4 files changed

Lines changed: 112 additions & 15 deletions

File tree

packages/ui/src/features/sessions/components/buildConversationItems.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,53 @@ describe("buildConversationItems", () => {
757757
);
758758
});
759759
});
760+
761+
describe("session_update timestamps", () => {
762+
const toolCallMsg = (ts: number, toolCallId: string): AcpMessage => ({
763+
type: "acp_message",
764+
ts,
765+
message: {
766+
jsonrpc: "2.0",
767+
method: "session/update",
768+
params: {
769+
update: {
770+
sessionUpdate: "tool_call",
771+
toolCallId,
772+
kind: "execute",
773+
status: "pending",
774+
title: toolCallId,
775+
},
776+
},
777+
},
778+
});
779+
780+
const firstSessionUpdate = (items: ConversationItem[]) =>
781+
items.find((i) => i.type === "session_update") as
782+
| Extract<ConversationItem, { type: "session_update" }>
783+
| undefined;
784+
785+
it("stamps an agent message with the first chunk's ts and keeps it across merges", () => {
786+
const events = [
787+
userPromptMsg(1, 1, "hi"),
788+
agentMessageMsg(5, "Hello"),
789+
agentMessageMsg(9, " there"),
790+
];
791+
const item = firstSessionUpdate(
792+
buildConversationItems(events, true).items,
793+
);
794+
expect(item?.update.sessionUpdate).toBe("agent_message_chunk");
795+
expect(item?.timestamp).toBe(5);
796+
});
797+
798+
it("stamps a tool call with its ts", () => {
799+
const events = [userPromptMsg(1, 1, "go"), toolCallMsg(4, "t1")];
800+
const item = firstSessionUpdate(
801+
buildConversationItems(events, true).items,
802+
);
803+
expect(item?.update.sessionUpdate).toBe("tool_call");
804+
expect(item?.timestamp).toBe(4);
805+
});
806+
});
760807
});
761808

762809
// Local alias kept intentionally narrow to the shape we care about in tests.

packages/ui/src/features/sessions/components/buildConversationItems.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export type ConversationItem =
5555
update: RenderItem;
5656
turnContext: TurnContext;
5757
thoughtComplete?: boolean;
58+
timestamp?: number;
5859
}
5960
| {
6061
type: "git_action_result";
@@ -188,7 +189,7 @@ export function markThoughtCompletion(items: ConversationItem[]) {
188189
}
189190
}
190191

191-
function pushItem(b: ItemBuilder, update: RenderItem) {
192+
function pushItem(b: ItemBuilder, update: RenderItem, ts?: number) {
192193
const turn = b.currentTurn;
193194
if (!turn) return;
194195
turn.itemCount++;
@@ -197,6 +198,7 @@ function pushItem(b: ItemBuilder, update: RenderItem) {
197198
id: `${turn.id}-item-${b.nextId()}`,
198199
update,
199200
turnContext: turn.context,
201+
timestamp: ts,
200202
});
201203
}
202204

@@ -460,7 +462,7 @@ function handleNotification(
460462
if (!b.currentTurn) {
461463
ensureImplicitTurn(b, ts);
462464
}
463-
processSessionUpdate(b, update);
465+
processSessionUpdate(b, update, ts);
464466
return;
465467
}
466468

@@ -783,7 +785,11 @@ function appendTextChunkToChildren(
783785
}
784786
}
785787

786-
function processSessionUpdate(b: ItemBuilder, update: SessionUpdate) {
788+
function processSessionUpdate(
789+
b: ItemBuilder,
790+
update: SessionUpdate,
791+
ts: number,
792+
) {
787793
switch (update.sessionUpdate) {
788794
case "user_message_chunk":
789795
break;
@@ -795,7 +801,7 @@ function processSessionUpdate(b: ItemBuilder, update: SessionUpdate) {
795801
if (parentId) {
796802
appendTextChunkToChildren(b, parentId, update);
797803
} else {
798-
appendTextChunk(b, update);
804+
appendTextChunk(b, update, ts);
799805
}
800806
break;
801807
}
@@ -820,7 +826,7 @@ function processSessionUpdate(b: ItemBuilder, update: SessionUpdate) {
820826
if (parentId) {
821827
pushChildItem(b, parentId, toolCall);
822828
} else {
823-
pushItem(b, toolCall);
829+
pushItem(b, toolCall, ts);
824830
}
825831
}
826832
break;
@@ -857,16 +863,20 @@ function processSessionUpdate(b: ItemBuilder, update: SessionUpdate) {
857863
};
858864
if (customUpdate.sessionUpdate === "agent_message") {
859865
if (customUpdate.content?.type === "text") {
860-
appendTextChunk(b, {
861-
sessionUpdate: "agent_message_chunk" as const,
862-
content: customUpdate.content as { type: "text"; text: string },
863-
});
866+
appendTextChunk(
867+
b,
868+
{
869+
sessionUpdate: "agent_message_chunk" as const,
870+
content: customUpdate.content as { type: "text"; text: string },
871+
},
872+
ts,
873+
);
864874
}
865875
} else if (
866876
customUpdate.sessionUpdate === "status" ||
867877
customUpdate.sessionUpdate === "error"
868878
) {
869-
pushItem(b, customUpdate as unknown as SessionUpdate);
879+
pushItem(b, customUpdate as unknown as SessionUpdate, ts);
870880
}
871881
break;
872882
}
@@ -878,6 +888,7 @@ function appendTextChunk(
878888
update: SessionUpdate & {
879889
sessionUpdate: "agent_message_chunk" | "agent_thought_chunk";
880890
},
891+
ts: number,
881892
) {
882893
if (update.content.type !== "text") return;
883894

@@ -899,6 +910,6 @@ function appendTextChunk(
899910
},
900911
};
901912
} else {
902-
pushItem(b, { ...update, content: { ...update.content } });
913+
pushItem(b, { ...update, content: { ...update.content } }, ts);
903914
}
904915
}

packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { ChatMarkdown } from "@posthog/ui/features/sessions/components/chat-thre
2929
import { ChatThreadFooter } from "@posthog/ui/features/sessions/components/chat-thread/ChatThreadFooter";
3030
import { ChatThreadChromeProvider } from "@posthog/ui/features/sessions/components/chat-thread/chatThreadChrome";
3131
import {
32+
isToolActive,
3233
ToolGroup,
3334
type ToolGroupItem,
3435
} from "@posthog/ui/features/sessions/components/chat-thread/ToolGroup";
@@ -168,6 +169,19 @@ function formatTimestamp(ts: number): string {
168169
});
169170
}
170171

172+
/**
173+
* Send-time footer revealed on hover. Sits inside a `group` container (a `ChatMessage` for prose, a
174+
* wrapper div for tool rows) so it fades in only while that row is hovered.
175+
*/
176+
function RowTimestamp({ timestamp }: { timestamp?: number }) {
177+
if (timestamp == null) return null;
178+
return (
179+
<ChatMessageFooter className="opacity-0 transition-opacity group-hover:opacity-100">
180+
{formatTimestamp(timestamp)}
181+
</ChatMessageFooter>
182+
);
183+
}
184+
171185
/**
172186
* End-aligned user bubble. The text is clamped to two lines (`max-height: 2lh` + `overflow-hidden`,
173187
* which — unlike `-webkit-line-clamp` — reliably clamps markdown's block `<p>` children); a "Show
@@ -475,7 +489,16 @@ const ThreadRow = memo(function ThreadRow({
475489
style={{ maxWidth: CHAT_CONTENT_MAX_WIDTH }}
476490
>
477491
{item.type === "tool_group" ? (
478-
<ToolGroup tools={item.tools} />
492+
<div className="group flex flex-col gap-2">
493+
<ToolGroup tools={item.tools} />
494+
<RowTimestamp
495+
timestamp={
496+
item.tools.some(isToolActive)
497+
? undefined
498+
: item.tools[0]?.timestamp
499+
}
500+
/>
501+
</div>
479502
) : item.type === "user_message" ? (
480503
<UserBubble
481504
content={item.content}
@@ -596,18 +619,23 @@ export function ChatThread({
596619
update.content.type === "text"
597620
) {
598621
return (
599-
<ChatMessage align="start">
622+
<ChatMessage align="start" className="group">
600623
<ChatMessageContent>
601624
<ChatBubble variant="ghost">
602625
<ChatBubbleContent>
603626
<ChatMarkdown content={update.content.text} />
604627
</ChatBubbleContent>
605628
</ChatBubble>
629+
<RowTimestamp
630+
timestamp={
631+
item.turnContext.turnComplete ? item.timestamp : undefined
632+
}
633+
/>
606634
</ChatMessageContent>
607635
</ChatMessage>
608636
);
609637
}
610-
return (
638+
const rendered = (
611639
<SessionUpdateView
612640
item={item.update}
613641
toolCalls={item.turnContext.toolCalls}
@@ -617,6 +645,17 @@ export function ChatThread({
617645
thoughtComplete={item.thoughtComplete}
618646
/>
619647
);
648+
if (update.sessionUpdate === "tool_call") {
649+
return (
650+
<div className="group flex flex-col gap-2">
651+
{rendered}
652+
<RowTimestamp
653+
timestamp={isToolActive(item) ? undefined : item.timestamp}
654+
/>
655+
</div>
656+
);
657+
}
658+
return rendered;
620659
}
621660
case "git_action_result":
622661
return repoPath ? (

packages/ui/src/features/sessions/components/chat-thread/ToolGroup.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ function friendlyName(key: string): string {
5757
return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase();
5858
}
5959

60-
function isToolActive(item: ToolGroupItem["tools"][number]): boolean {
60+
export function isToolActive(item: ToolGroupItem["tools"][number]): boolean {
6161
const { toolCall } = resolveTool(item);
6262
const incomplete =
6363
toolCall.status === "pending" || toolCall.status === "in_progress";

0 commit comments

Comments
 (0)