diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8ce843ba2a..ff03602e7e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -290,6 +290,7 @@ export type { TaskCreationOutput, } from "./task-creation-domain"; export { + formatClockTime, formatRelativeTimeLong, formatRelativeTimeShort, getLocalDayDiff, diff --git a/packages/shared/src/time.test.ts b/packages/shared/src/time.test.ts index 827afc5c77..d0a69f92ec 100644 --- a/packages/shared/src/time.test.ts +++ b/packages/shared/src/time.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + formatClockTime, formatRelativeTimeLong, formatRelativeTimeShort, getLocalDayDiff, @@ -11,6 +12,17 @@ const MINUTE = 60_000; const HOUR = 3_600_000; const DAY = 86_400_000; +describe("formatClockTime", () => { + it.each([ + ["00:00", "12:00 AM"], + ["08:15", "8:15 AM"], + ["11:00", "11:00 AM"], + ["17:30", "5:30 PM"], + ])("formats %s as %s", (time, expected) => { + expect(formatClockTime(time)).toBe(expected); + }); +}); + beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(NOW); diff --git a/packages/shared/src/time.ts b/packages/shared/src/time.ts index dcbb4090da..3cfd21ba65 100644 --- a/packages/shared/src/time.ts +++ b/packages/shared/src/time.ts @@ -1,3 +1,12 @@ +export function formatClockTime(time: string): string { + const [hour, minute] = time.split(":").map(Number); + return new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "2-digit", + timeZone: "UTC", + }).format(new Date(Date.UTC(2000, 0, 1, hour, minute))); +} + /** * Format a timestamp as a short relative string (e.g. "3m", "2h", "5d"). * Accepts either a Unix ms timestamp or an ISO date string. diff --git a/packages/ui/src/features/loops/components/LoopDetailView.tsx b/packages/ui/src/features/loops/components/LoopDetailView.tsx index f16b8b85bf..97a6ab4cff 100644 --- a/packages/ui/src/features/loops/components/LoopDetailView.tsx +++ b/packages/ui/src/features/loops/components/LoopDetailView.tsx @@ -12,6 +12,7 @@ import { import { AlertDialog, Flex, Text } from "@radix-ui/themes"; import { useState } from "react"; import { useLoop } from "../hooks/useLoop"; +import { useLoopDisplayModel } from "../hooks/useLoopDisplayModel"; import { useDeleteLoop, useRunLoop, @@ -237,6 +238,8 @@ export function LoopDetailView({ loopId }: { loopId: string }) { } function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) { + const displayModel = useLoopDisplayModel(loop.runtime_adapter, loop.model); + return ( @@ -251,7 +254,7 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) { {[ loop.runtime_adapter, - loop.model, + displayModel, loop.reasoning_effort ? `${loop.reasoning_effort} reasoning` : null, ] .filter(Boolean) diff --git a/packages/ui/src/features/loops/hooks/useLoopDisplayModel.ts b/packages/ui/src/features/loops/hooks/useLoopDisplayModel.ts new file mode 100644 index 0000000000..a63d7660dc --- /dev/null +++ b/packages/ui/src/features/loops/hooks/useLoopDisplayModel.ts @@ -0,0 +1,31 @@ +import type { LoopSchemas } from "@posthog/api-client/loops"; +import { + REPORT_MODEL_RESOLVER, + type ReportModelResolver, +} from "@posthog/core/inbox/identifiers"; +import { useService } from "@posthog/di/react"; +import { getCloudUrlFromRegion } from "@posthog/shared"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { useQuery } from "@tanstack/react-query"; + +export function useLoopDisplayModel( + adapter: LoopSchemas.LoopRuntimeAdapterEnum, + configuredModel: string, +): string { + const cloudRegion = useAuthStateValue((state) => state.cloudRegion); + const modelResolver = useService(REPORT_MODEL_RESOLVER); + const { data } = useQuery({ + queryKey: ["loops", "default-model", cloudRegion, adapter], + queryFn: () => { + if (!cloudRegion) return undefined; + return modelResolver.resolveDefaultModel( + getCloudUrlFromRegion(cloudRegion), + adapter, + ); + }, + enabled: !configuredModel && !!cloudRegion, + staleTime: 5 * 60_000, + }); + + return configuredModel || data || "Default model"; +} diff --git a/packages/ui/src/features/loops/loopDisplay.test.ts b/packages/ui/src/features/loops/loopDisplay.test.ts new file mode 100644 index 0000000000..03e585b7a7 --- /dev/null +++ b/packages/ui/src/features/loops/loopDisplay.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { describeTrigger } from "./loopDisplay"; + +describe("describeTrigger", () => { + it.each([ + ["0 * * * *", "Every hour (UTC)"], + ["30 9 * * *", "Daily at 9:30 AM (UTC)"], + ["0 11 * * 1-5", "Weekdays at 11:00 AM (UTC)"], + ["15 8 * * 3", "Wednesdays at 8:15 AM (UTC)"], + ])("formats %s as a readable schedule", (cronExpression, expected) => { + expect( + describeTrigger({ + type: "schedule", + config: { cron_expression: cronExpression, timezone: "UTC" }, + }), + ).toBe(`Schedule · ${expected}`); + }); + + it("keeps custom cron expressions visible", () => { + expect( + describeTrigger({ + type: "schedule", + config: { cron_expression: "*/15 * * * *", timezone: "UTC" }, + }), + ).toBe("Schedule · */15 * * * * (UTC)"); + }); +}); diff --git a/packages/ui/src/features/loops/loopDisplay.ts b/packages/ui/src/features/loops/loopDisplay.ts index 382f5fbec5..beacd6958f 100644 --- a/packages/ui/src/features/loops/loopDisplay.ts +++ b/packages/ui/src/features/loops/loopDisplay.ts @@ -1,4 +1,32 @@ import type { LoopSchemas } from "@posthog/api-client/loops"; +import { formatClockTime } from "@posthog/shared"; +import { parseCronSchedule } from "./loopCron"; + +const WEEKDAY_NAMES: Record = { + "0": "Sunday", + "1": "Monday", + "2": "Tuesday", + "3": "Wednesday", + "4": "Thursday", + "5": "Friday", + "6": "Saturday", +}; + +function describeSchedule( + config: LoopSchemas.LoopScheduleTriggerConfig, +): string { + const cron = config.cron_expression; + const parsed = parseCronSchedule(cron); + const timezone = config.timezone ?? "UTC"; + if (!parsed) return `${cron ?? "?"} (${timezone})`; + if (parsed.frequency === "hourly") return `Every hour (${timezone})`; + + const time = formatClockTime(parsed.time); + if (parsed.frequency === "daily") return `Daily at ${time} (${timezone})`; + if (parsed.frequency === "weekdays") + return `Weekdays at ${time} (${timezone})`; + return `${WEEKDAY_NAMES[parsed.weekday]}s at ${time} (${timezone})`; +} export function loopStatusColor( loop: LoopSchemas.Loop, @@ -39,7 +67,7 @@ export function describeTrigger(trigger: TriggerLike): string { const config = trigger.config as LoopSchemas.LoopScheduleTriggerConfig; if (config.run_at) return `One-time · ${new Date(config.run_at).toLocaleString()}`; - return `Schedule · ${config.cron_expression ?? "?"} (${config.timezone ?? "UTC"})`; + return `Schedule · ${describeSchedule(config)}`; } if (trigger.type === "github") { const config = trigger.config as LoopSchemas.LoopGithubTriggerConfig; diff --git a/packages/ui/src/features/sessions/components/buildConversationItems.test.ts b/packages/ui/src/features/sessions/components/buildConversationItems.test.ts index 50dba8b580..69fb1d2f0b 100644 --- a/packages/ui/src/features/sessions/components/buildConversationItems.test.ts +++ b/packages/ui/src/features/sessions/components/buildConversationItems.test.ts @@ -238,6 +238,36 @@ describe("buildConversationItems", () => { expect(result.isCompacting).toBe(false); }); + it("builds a compact boundary without optional metadata", () => { + const result = buildConversationItems( + [ + userPromptMsg(1, 1, "hi"), + statusMsg(2, "compacting"), + { + type: "acp_message", + ts: 3, + message: { + jsonrpc: "2.0", + method: "_posthog/compact_boundary", + params: { sessionId: "session-1" }, + }, + }, + ], + null, + ); + + const boundary = result.items.find( + (item) => + item.type === "session_update" && + item.update.sessionUpdate === "compact_boundary", + ); + expect(boundary).toMatchObject({ + type: "session_update", + update: { sessionUpdate: "compact_boundary" }, + }); + expect(result.isCompacting).toBe(false); + }); + it("renders a failed compaction as a compacting_failed status row and clears the spinner", () => { // A failed compaction emits no compact_boundary, so the agent sends a // structured `compacting_failed` status: it clears the spinner (the original diff --git a/packages/ui/src/features/sessions/components/buildConversationItems.ts b/packages/ui/src/features/sessions/components/buildConversationItems.ts index 17abfe38e2..83d33c41c1 100644 --- a/packages/ui/src/features/sessions/components/buildConversationItems.ts +++ b/packages/ui/src/features/sessions/components/buildConversationItems.ts @@ -22,6 +22,7 @@ import { } from "@posthog/ui/features/sessions/components/GitActionMessage"; import type { UserShellExecute } from "@posthog/ui/features/sessions/components/session-update/UserShellExecuteView"; import type { + CompactBoundaryMetadata, ConversationSessionUpdate, ToolCall, } from "@posthog/ui/features/sessions/types"; @@ -680,11 +681,7 @@ function handleNotification( if (isNotification(msg.method, POSTHOG_NOTIFICATIONS.COMPACT_BOUNDARY)) { ensureImplicitTurn(b, ts); - const params = msg.params as { - trigger: "manual" | "auto"; - preTokens: number; - contextSize?: number; - }; + const params = msg.params as CompactBoundaryMetadata; markRuntimeStatusComplete(b, "compacting"); pushItem(b, { sessionUpdate: "compact_boundary", diff --git a/packages/ui/src/features/sessions/components/session-update/CompactBoundaryView.test.ts b/packages/ui/src/features/sessions/components/session-update/CompactBoundaryView.test.ts new file mode 100644 index 0000000000..70fbbbaf6a --- /dev/null +++ b/packages/ui/src/features/sessions/components/session-update/CompactBoundaryView.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + formatCompactBoundaryLabel, + formatLegacyCompactBoundaryDetails, +} from "./CompactBoundaryView"; + +describe("formatCompactBoundaryLabel", () => { + it.each([ + { + name: "missing metadata", + props: {}, + expected: "Conversation compacted", + }, + { + name: "token count without context size", + props: { trigger: "auto" as const, preTokens: 12_400 }, + expected: "Conversation compacted · auto · ~12K tokens", + }, + { + name: "percentage with context size", + props: { + trigger: "manual" as const, + preTokens: 75_000, + contextSize: 100_000, + }, + expected: "Conversation compacted · manual · 75% of context", + }, + ])("formats $name", ({ props, expected }) => { + expect(formatCompactBoundaryLabel(props)).toBe(expected); + }); +}); + +describe("formatLegacyCompactBoundaryDetails", () => { + it.each([ + { + name: "missing metadata", + props: {}, + expected: null, + }, + { + name: "token count without context size", + props: { preTokens: 12_400 }, + expected: "~12K tokens summarized", + }, + { + name: "percentage and token count", + props: { preTokens: 75_000, contextSize: 100_000 }, + expected: "75% of context · ~75K tokens summarized", + }, + ])("formats $name", ({ props, expected }) => { + expect(formatLegacyCompactBoundaryDetails(props)).toBe(expected); + }); +}); diff --git a/packages/ui/src/features/sessions/components/session-update/CompactBoundaryView.tsx b/packages/ui/src/features/sessions/components/session-update/CompactBoundaryView.tsx index 6629be9134..4481d64cae 100644 --- a/packages/ui/src/features/sessions/components/session-update/CompactBoundaryView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/CompactBoundaryView.tsx @@ -1,24 +1,78 @@ import { Lightning } from "@phosphor-icons/react"; import { ChatMarker, ChatMarkerContent } from "@posthog/quill"; +import type { CompactBoundaryMetadata } from "@posthog/ui/features/sessions/types"; import { Badge, Box, Flex, Text } from "@radix-ui/themes"; import { useChatThreadChrome } from "../chat-thread/chatThreadChrome"; -interface CompactBoundaryViewProps { - trigger: "manual" | "auto"; - preTokens: number; - contextSize?: number; +interface CompactBoundaryDisplayMetadata { + trigger?: "manual" | "auto"; + tokensK?: number; + percent?: number; +} + +function getCompactBoundaryMetadata({ + trigger, + preTokens, + contextSize, +}: CompactBoundaryMetadata): CompactBoundaryDisplayMetadata { + const metadata: CompactBoundaryDisplayMetadata = { + trigger, + }; + + if (preTokens === undefined) { + return metadata; + } + + metadata.tokensK = Math.round(preTokens / 1000); + if (contextSize) { + metadata.percent = Math.round((preTokens / contextSize) * 100); + } + + return metadata; +} + +export function formatCompactBoundaryLabel( + props: CompactBoundaryMetadata, +): string { + const metadata = getCompactBoundaryMetadata(props); + const details: string[] = []; + if (metadata.trigger) { + details.push(metadata.trigger); + } + if (metadata.percent !== undefined) { + details.push(`${metadata.percent}% of context`); + } else if (metadata.tokensK !== undefined) { + details.push(`~${metadata.tokensK}K tokens`); + } + return ["Conversation compacted", ...details].join(" · "); +} + +export function formatLegacyCompactBoundaryDetails( + props: CompactBoundaryMetadata, +): string | null { + const metadata = getCompactBoundaryMetadata(props); + if (metadata.tokensK === undefined) return null; + if (metadata.percent !== undefined) { + return `${metadata.percent}% of context · ~${metadata.tokensK}K tokens summarized`; + } + return `~${metadata.tokensK}K tokens summarized`; } export function CompactBoundaryView({ trigger, preTokens, contextSize, -}: CompactBoundaryViewProps) { - const tokensK = Math.round(preTokens / 1000); - const percent = - contextSize && contextSize > 0 - ? Math.round((preTokens / contextSize) * 100) - : null; +}: CompactBoundaryMetadata) { + const metadata = getCompactBoundaryMetadata({ + trigger, + preTokens, + contextSize, + }); + const legacyDetails = formatLegacyCompactBoundaryDetails({ + trigger, + preTokens, + contextSize, + }); // New thread renders the boundary as a centered separator marker; the legacy thread keeps its // bordered badge row so ConversationView is unchanged when the chat thread is off. const chatChrome = useChatThreadChrome(); @@ -27,9 +81,7 @@ export function CompactBoundaryView({ return ( - {`Conversation compacted · ${trigger} · ${ - percent !== null ? `${percent}% of context` : `~${tokensK}K tokens` - }`} + {formatCompactBoundaryLabel({ trigger, preTokens, contextSize })} ); @@ -40,18 +92,18 @@ export function CompactBoundaryView({ Conversation compacted - - {trigger} - - - {percent !== null - ? `(${percent}% of context · ~${tokensK}K tokens summarized)` - : `(~${tokensK}K tokens summarized)`} - + {metadata.trigger && ( + + {metadata.trigger} + + )} + {legacyDetails && ( + ({legacyDetails}) + )} ); diff --git a/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx b/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx index dc49fd9abe..43fbe742b6 100644 --- a/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx @@ -7,6 +7,7 @@ import { StatusNotificationView } from "@posthog/ui/features/sessions/components import { TaskNotificationView } from "@posthog/ui/features/sessions/components/session-update/TaskNotificationView"; import { ThoughtView } from "@posthog/ui/features/sessions/components/session-update/ThoughtView"; import type { + CompactBoundaryUpdate, ConversationSessionUpdate, ToolCall, } from "@posthog/ui/features/sessions/types"; @@ -23,12 +24,7 @@ export type RenderItem = message: string; timestamp?: string; } - | { - sessionUpdate: "compact_boundary"; - trigger: "manual" | "auto"; - preTokens: number; - contextSize?: number; - } + | CompactBoundaryUpdate | { sessionUpdate: "status"; status: string; diff --git a/packages/ui/src/features/sessions/types.ts b/packages/ui/src/features/sessions/types.ts index cf4f45f570..99a56d1403 100644 --- a/packages/ui/src/features/sessions/types.ts +++ b/packages/ui/src/features/sessions/types.ts @@ -62,3 +62,13 @@ export type ConfigOptionUpdate = Extract< SessionUpdate, { sessionUpdate: "config_option_update" } >; + +export interface CompactBoundaryMetadata { + trigger?: "manual" | "auto"; + preTokens?: number; + contextSize?: number; +} + +export interface CompactBoundaryUpdate extends CompactBoundaryMetadata { + sessionUpdate: "compact_boundary"; +}