Skip to content
Merged
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
36 changes: 31 additions & 5 deletions echo/frontend/src/components/conversation/LiveFunnelSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
WifiSlashIcon,
} from "@phosphor-icons/react";
import { formatDistanceToNow } from "date-fns";
import posthog from "posthog-js";
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router";

Expand All @@ -31,7 +32,7 @@ import {
useConversationMonitor,
} from "@/hooks/useConversationMonitor";
import { FunnelCanvas, type NodeDatum } from "./FunnelCanvas";
import { StatePill } from "./LiveMonitorSection";
import { isProblemState, StatePill } from "./LiveMonitorSection";

const weakNetwork = (
network: { online?: boolean; effective_type?: string } | null,
Expand Down Expand Up @@ -144,9 +145,11 @@ const VisitorDrilldown = ({ visitor }: { visitor: FunnelVisitor }) => (
const ConversationDrilldown = ({
conversation,
base,
projectId,
}: {
conversation: MonitorConversation;
base: string | null;
projectId: string;
}) => {
const [name, setName] = useState(conversation.label ?? "");
const update = useUpdateConversationByIdMutation();
Expand All @@ -160,7 +163,13 @@ const ConversationDrilldown = ({
{ id: conversation.id, payload: { participant_name: name.trim() } },
{
onError: () => toast.error(t`Could not save`),
onSuccess: () => toast.success(t`Saved`),
onSuccess: () => {
posthog.capture("monitor_participant_name_edited", {
conversation_id: conversation.id,
project_id: projectId,
});
toast.success(t`Saved`);
},
},
);
};
Expand Down Expand Up @@ -198,6 +207,16 @@ const ConversationDrilldown = ({
<I18nLink
to={`${base}/conversations/${conversation.id}`}
className="no-underline"
onClick={() =>
posthog.capture("monitor_conversation_opened", {
conversation_id: conversation.id,
from_problem_state: isProblemState(conversation),
participant_state: conversation.state,
project_id: projectId,
recording_health: conversation.recording_health,
transcription_status: conversation.transcription_status,
})
}
>
<Button variant="light" size="xs">
<Trans>Open conversation</Trans>
Expand Down Expand Up @@ -314,13 +333,19 @@ export const LiveFunnelSection = ({
<FunnelCanvas
nodes={nodes}
weights={weights}
onSelect={(node) =>
onSelect={(node) => {
posthog.capture("monitor_drilldown_opened", {
entity_type: node.kind === "visitor" ? "visitor" : "recording",
project_id: projectId,
stage_or_state:
node.kind === "visitor" ? node.data.stage : node.data.state,
});
setSelected(
node.kind === "visitor"
? { kind: "visitor", visitor: node.data }
: { conversation: node.data, kind: "conversation" },
)
}
);
}}
onHover={(node) =>
onHoverConversation?.(
node?.kind === "conversation" ? node.data.id : null,
Expand Down Expand Up @@ -348,6 +373,7 @@ export const LiveFunnelSection = ({
<ConversationDrilldown
conversation={selected.conversation}
base={base}
projectId={projectId}
/>
)}
</Modal>
Expand Down
179 changes: 175 additions & 4 deletions echo/frontend/src/components/conversation/LiveMonitorSection.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,64 @@
// @vitest-environment jsdom
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { MantineProvider } from "@mantine/core";
import { render } from "@testing-library/react";
import { beforeAll, describe, expect, it } from "vitest";
import type { ParticipantState } from "@/hooks/useConversationMonitor";
import { StatePill } from "./LiveMonitorSection";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router";
import { beforeAll, describe, expect, it, vi } from "vitest";
import type {
MonitorConversation,
ParticipantState,
} from "@/hooks/useConversationMonitor";
import {
isProblemState,
LiveMonitorSection,
StatePill,
} from "./LiveMonitorSection";

const captureMock = vi.hoisted(() => vi.fn());
vi.mock("posthog-js", () => ({ default: { capture: captureMock } }));

// Set per test; read lazily by the useConversationMonitor mock at render time.
let mockConversations: MonitorConversation[] = [];

// A complete MonitorSummary so the section renders its rows.
const fullSummary = {
catch_up_eta_seconds: 0,
finished: 0,
live: 1,
not_receiving: 0,
offline: 0,
pending_transcription: 0,
total: 1,
transcribing: 0,
with_errors: 1,
};

vi.mock("@/hooks/useConversationMonitor", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/hooks/useConversationMonitor")>();
return {
...actual,
useConversationMonitor: () => ({
conversations: mockConversations,
error: null,
funnel: { summary: { total: 0 }, visitors: [] },
isLoading: false,
isStreaming: true,
summary: fullSummary,
}),
};
});

vi.mock("@/hooks/useWorkspace", () => ({
useWorkspace: () => ({
workspace: { id: "w1", role: "admin", tier: "free" },
}),
}));

i18n.load("en-US", {});
i18n.activate("en-US");

// MantineProvider reads the OS color scheme on mount; jsdom has no
// matchMedia, so stub a minimal (always non-matching) implementation.
Expand All @@ -20,6 +75,14 @@ beforeAll(() => {
removeEventListener: () => {},
removeListener: () => {},
}));
// Mantine's FloatingIndicator (in the opened UpgradeModal) needs ResizeObserver.
window.ResizeObserver =
window.ResizeObserver ||
class {
observe() {}
unobserve() {}
disconnect() {}
};
});

const renderPill = (state: ParticipantState) =>
Expand Down Expand Up @@ -55,3 +118,111 @@ describe("StatePill", () => {
expect(getByText("Idle")).toBeTruthy();
});
});

const baseConversation = (
over: Partial<MonitorConversation> = {},
): MonitorConversation =>
({
audio_level: 0.5,
battery: null,
chunk_count: 1,
created_at: null,
duration: null,
error_message: null,
has_error: false,
id: "c1",
is_finished: false,
is_live: true,
label: null,
language: null,
last_chunk_at: null,
last_seen_at: null,
latest_transcript: null,
locked: false,
mode: "voice",
network: null,
pending_transcription: 0,
recording_health: "receiving",
state: "recording",
tags: [],
transcribed_count: 1,
transcription_status: "up_to_date",
...over,
}) as MonitorConversation;

describe("isProblemState", () => {
it("is false for a healthy receiving conversation", () => {
expect(isProblemState(baseConversation())).toBe(false);
});
it("is true when audio is stalled", () => {
expect(
isProblemState(baseConversation({ recording_health: "stalled" })),
).toBe(true);
});
it("is true when the conversation has an error", () => {
expect(isProblemState(baseConversation({ has_error: true }))).toBe(true);
});
it("is true when offline", () => {
expect(isProblemState(baseConversation({ state: "offline" }))).toBe(true);
});
it("is true when transcription is failing", () => {
expect(
isProblemState(baseConversation({ transcription_status: "failing" })),
).toBe(true);
});
});

const renderSection = () =>
render(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider i18n={i18n}>
<MantineProvider>
<MemoryRouter initialEntries={["/w/w1/projects/p1/monitor"]}>
<Routes>
<Route
path="/w/:workspaceId/projects/:projectId/monitor"
element={<LiveMonitorSection projectId="p1" standalone />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</I18nProvider>
</QueryClientProvider>,
);

describe("LiveMonitorSection click-through", () => {
it("captures monitor_conversation_opened with from_problem_state when a row is clicked", () => {
captureMock.mockClear();
mockConversations = [
baseConversation({
has_error: true,
id: "c1",
recording_health: "stalled",
}),
];
const { getByText } = renderSection();
fireEvent.click(getByText("Anonymous participant"));
expect(captureMock).toHaveBeenCalledWith(
"monitor_conversation_opened",
expect.objectContaining({
conversation_id: "c1",
from_problem_state: true,
project_id: "p1",
}),
);
});

it("captures monitor_locked_row_clicked when a locked row is clicked", () => {
captureMock.mockClear();
mockConversations = [baseConversation({ id: "c1", locked: true })];
const { getByLabelText } = renderSection();
fireEvent.click(getByLabelText("Locked conversation, upgrade to view"));
expect(captureMock).toHaveBeenCalledWith(
"monitor_locked_row_clicked",
expect.objectContaining({
conversation_id: "c1",
project_id: "p1",
}),
);
});
});
36 changes: 32 additions & 4 deletions echo/frontend/src/components/conversation/LiveMonitorSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
WifiSlashIcon,
} from "@phosphor-icons/react";
import { formatDistanceToNow } from "date-fns";
import posthog from "posthog-js";
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router";
import DembraneLoadingSpinner from "@/components/common/DembraneLoadingSpinner";
Expand Down Expand Up @@ -76,6 +77,12 @@ const stateMeta = (state: ParticipantState): StateMeta => {
}
};

export const isProblemState = (conversation: MonitorConversation): boolean =>
conversation.recording_health === "stalled" ||
conversation.has_error ||
conversation.state === "offline" ||
conversation.transcription_status === "failing";

export const StatePill = ({ state }: { state: ParticipantState }) => {
const meta = stateMeta(state);
const darkTextStyles = {
Expand Down Expand Up @@ -281,11 +288,13 @@ const MonitorRow = ({
to,
highlighted,
onLockedClick,
onOpen,
}: {
conversation: MonitorConversation;
to: string | null;
highlighted?: boolean;
onLockedClick?: () => void;
onOpen?: () => void;
}) => {
const label = conversation.label?.trim() || t`Anonymous participant`;
const weakNetwork = isWeakNetwork(conversation);
Expand Down Expand Up @@ -422,7 +431,7 @@ const MonitorRow = ({

if (!to) return card;
return (
<I18nLink to={to} className="block no-underline">
<I18nLink to={to} className="block no-underline" onClick={onOpen}>
{card}
</I18nLink>
);
Expand Down Expand Up @@ -474,11 +483,13 @@ const TagGroupSection = ({
base,
highlightedConversationId,
onLockedClick,
onOpen,
}: {
group: TagGroup;
base: string | null;
highlightedConversationId?: string | null;
onLockedClick?: () => void;
onLockedClick?: (conversation: MonitorConversation) => void;
onOpen?: (conversation: MonitorConversation) => void;
}) => {
const [opened, { toggle }] = useDisclosure(true);
const [expanded, setExpanded] = useState(false);
Expand Down Expand Up @@ -531,7 +542,8 @@ const TagGroupSection = ({
conversation={conversation}
to={base ? `${base}/conversations/${conversation.id}` : null}
highlighted={conversation.id === highlightedConversationId}
onLockedClick={onLockedClick}
onLockedClick={() => onLockedClick?.(conversation)}
onOpen={() => onOpen?.(conversation)}
/>
))}
{overflow > 0 && (
Expand Down Expand Up @@ -731,7 +743,23 @@ export const LiveMonitorSection = ({
group={group}
base={base}
highlightedConversationId={highlightedConversationId}
onLockedClick={upgradeHandlers.open}
onLockedClick={(conversation) => {
posthog.capture("monitor_locked_row_clicked", {
conversation_id: conversation.id,
project_id: projectId,
});
upgradeHandlers.open();
}}
onOpen={(conversation) =>
posthog.capture("monitor_conversation_opened", {
conversation_id: conversation.id,
from_problem_state: isProblemState(conversation),
participant_state: conversation.state,
project_id: projectId,
recording_health: conversation.recording_health,
transcription_status: conversation.transcription_status,
})
}
/>
))}
</Stack>
Expand Down
Loading
Loading