Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ export type {
TaskCreationOutput,
} from "./task-creation-domain";
export {
formatClockTime,
formatRelativeTimeLong,
formatRelativeTimeShort,
getLocalDayDiff,
Expand Down
12 changes: 12 additions & 0 deletions packages/shared/src/time.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
formatClockTime,
formatRelativeTimeLong,
formatRelativeTimeShort,
getLocalDayDiff,
Expand All @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions packages/shared/src/time.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
5 changes: 4 additions & 1 deletion packages/ui/src/features/loops/components/LoopDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<Flex direction="column" gap="3">
<Text className="font-medium text-[13px] text-gray-12">
Expand All @@ -251,7 +254,7 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
<SummaryRow label="Model">
{[
loop.runtime_adapter,
loop.model,
displayModel,
loop.reasoning_effort ? `${loop.reasoning_effort} reasoning` : null,
]
.filter(Boolean)
Expand Down
31 changes: 31 additions & 0 deletions packages/ui/src/features/loops/hooks/useLoopDisplayModel.ts
Original file line number Diff line number Diff line change
@@ -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<ReportModelResolver>(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";
}
27 changes: 27 additions & 0 deletions packages/ui/src/features/loops/loopDisplay.test.ts
Original file line number Diff line number Diff line change
@@ -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)");
});
});
30 changes: 29 additions & 1 deletion packages/ui/src/features/loops/loopDisplay.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
"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,
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading