diff --git a/enterprise/migrations/versions/081_add_parent_conversation_id.py b/enterprise/migrations/versions/081_add_parent_conversation_id.py
new file mode 100644
index 0000000000..b27c444632
--- /dev/null
+++ b/enterprise/migrations/versions/081_add_parent_conversation_id.py
@@ -0,0 +1,41 @@
+"""add parent_conversation_id to conversation_metadata
+
+Revision ID: 081
+Revises: 080
+Create Date: 2025-11-06 00:00:00.000000
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = '081'
+down_revision: Union[str, None] = '080'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ op.add_column(
+ 'conversation_metadata',
+ sa.Column('parent_conversation_id', sa.String(), nullable=True),
+ )
+ op.create_index(
+ op.f('ix_conversation_metadata_parent_conversation_id'),
+ 'conversation_metadata',
+ ['parent_conversation_id'],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ op.drop_index(
+ op.f('ix_conversation_metadata_parent_conversation_id'),
+ table_name='conversation_metadata',
+ )
+ op.drop_column('conversation_metadata', 'parent_conversation_id')
diff --git a/enterprise/storage/saas_conversation_store.py b/enterprise/storage/saas_conversation_store.py
index 916c4009ad..d1dfc2a2d6 100644
--- a/enterprise/storage/saas_conversation_store.py
+++ b/enterprise/storage/saas_conversation_store.py
@@ -82,6 +82,7 @@ class SaasConversationStore(ConversationStore):
kwargs.pop('reasoning_tokens', None)
kwargs.pop('context_window', None)
kwargs.pop('per_turn_token', None)
+ kwargs.pop('parent_conversation_id', None)
return ConversationMetadata(**kwargs)
diff --git a/frontend/__tests__/posthog-tracking.test.tsx b/frontend/__tests__/posthog-tracking.test.tsx
new file mode 100644
index 0000000000..5d76649013
--- /dev/null
+++ b/frontend/__tests__/posthog-tracking.test.tsx
@@ -0,0 +1,233 @@
+import {
+ describe,
+ it,
+ expect,
+ beforeAll,
+ afterAll,
+ afterEach,
+ vi,
+} from "vitest";
+import { screen, waitFor, render, cleanup } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { createMockAgentErrorEvent } from "#/mocks/mock-ws-helpers";
+import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context";
+import { conversationWebSocketTestSetup } from "./helpers/msw-websocket-setup";
+import { ConnectionStatusComponent } from "./helpers/websocket-test-components";
+
+// Mock the tracking function
+const mockTrackCreditLimitReached = vi.fn();
+
+// Mock useTracking hook
+vi.mock("#/hooks/use-tracking", () => ({
+ useTracking: () => ({
+ trackCreditLimitReached: mockTrackCreditLimitReached,
+ trackLoginButtonClick: vi.fn(),
+ trackConversationCreated: vi.fn(),
+ trackPushButtonClick: vi.fn(),
+ trackPullButtonClick: vi.fn(),
+ trackCreatePrButtonClick: vi.fn(),
+ trackGitProviderConnected: vi.fn(),
+ trackUserSignupCompleted: vi.fn(),
+ trackCreditsPurchased: vi.fn(),
+ }),
+}));
+
+// Mock useActiveConversation hook
+vi.mock("#/hooks/query/use-active-conversation", () => ({
+ useActiveConversation: () => ({
+ data: null,
+ isLoading: false,
+ error: null,
+ }),
+}));
+
+// MSW WebSocket mock setup
+const { wsLink, server: mswServer } = conversationWebSocketTestSetup();
+
+beforeAll(() => {
+ // The global MSW server from vitest.setup.ts is already running
+ // We just need to start our WebSocket-specific server
+ mswServer.listen({ onUnhandledRequest: "bypass" });
+});
+
+afterEach(() => {
+ // Clear all mocks before each test
+ mockTrackCreditLimitReached.mockClear();
+ mswServer.resetHandlers();
+ // Clean up any React components
+ cleanup();
+});
+
+afterAll(async () => {
+ // Close the WebSocket MSW server
+ mswServer.close();
+
+ // Give time for any pending WebSocket connections to close. This is very important to prevent serious memory leaks
+ await new Promise((resolve) => {
+ setTimeout(resolve, 500);
+ });
+});
+
+// Helper function to render components with all necessary providers
+function renderWithProviders(
+ children: React.ReactNode,
+ conversationId = "test-conversation-123",
+ conversationUrl = "http://localhost:3000/api/conversations/test-conversation-123",
+) {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+
+ return render(
+
+
+ {children}
+
+ ,
+ );
+}
+
+describe("PostHog Analytics Tracking", () => {
+ describe("Credit Limit Tracking", () => {
+ it("should track credit_limit_reached when AgentErrorEvent contains budget error", async () => {
+ // Create a mock AgentErrorEvent with budget-related error message
+ const mockBudgetErrorEvent = createMockAgentErrorEvent({
+ error: "ExceededBudget: Task exceeded maximum budget of $10.00",
+ });
+
+ // Set up MSW to send the budget error event when connection is established
+ mswServer.use(
+ wsLink.addEventListener("connection", ({ client, server }) => {
+ server.connect();
+ // Send the mock budget error event after connection
+ client.send(JSON.stringify(mockBudgetErrorEvent));
+ }),
+ );
+
+ // Render with all providers
+ renderWithProviders();
+
+ // Wait for connection to be established
+ await waitFor(() => {
+ expect(screen.getByTestId("connection-state")).toHaveTextContent(
+ "OPEN",
+ );
+ });
+
+ // Wait for the tracking event to be captured
+ await waitFor(() => {
+ expect(mockTrackCreditLimitReached).toHaveBeenCalledWith(
+ expect.objectContaining({
+ conversationId: "test-conversation-123",
+ }),
+ );
+ });
+ });
+
+ it("should track credit_limit_reached when AgentErrorEvent contains 'credit' keyword", async () => {
+ // Create error with "credit" keyword (case-insensitive)
+ const mockCreditErrorEvent = createMockAgentErrorEvent({
+ error: "Insufficient CREDIT to complete this operation",
+ });
+
+ mswServer.use(
+ wsLink.addEventListener("connection", ({ client, server }) => {
+ server.connect();
+ client.send(JSON.stringify(mockCreditErrorEvent));
+ }),
+ );
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("connection-state")).toHaveTextContent(
+ "OPEN",
+ );
+ });
+
+ await waitFor(() => {
+ expect(mockTrackCreditLimitReached).toHaveBeenCalledWith(
+ expect.objectContaining({
+ conversationId: "test-conversation-123",
+ }),
+ );
+ });
+ });
+
+ it("should NOT track credit_limit_reached for non-budget errors", async () => {
+ // Create a regular error without budget/credit keywords
+ const mockRegularErrorEvent = createMockAgentErrorEvent({
+ error: "Failed to execute command: Permission denied",
+ });
+
+ mswServer.use(
+ wsLink.addEventListener("connection", ({ client, server }) => {
+ server.connect();
+ client.send(JSON.stringify(mockRegularErrorEvent));
+ }),
+ );
+
+ renderWithProviders();
+
+ // Wait for connection and error to be processed
+ await waitFor(() => {
+ expect(screen.getByTestId("connection-state")).toHaveTextContent(
+ "OPEN",
+ );
+ });
+
+ // Verify that credit_limit_reached was NOT tracked
+ expect(mockTrackCreditLimitReached).not.toHaveBeenCalled();
+ });
+
+ it("should only track credit_limit_reached once per error event", async () => {
+ const mockBudgetErrorEvent = createMockAgentErrorEvent({
+ error: "Budget exceeded: $10.00 limit reached",
+ });
+
+ mswServer.use(
+ wsLink.addEventListener("connection", ({ client, server }) => {
+ server.connect();
+ // Send the same error event twice
+ client.send(JSON.stringify(mockBudgetErrorEvent));
+ client.send(
+ JSON.stringify({ ...mockBudgetErrorEvent, id: "different-id" }),
+ );
+ }),
+ );
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("connection-state")).toHaveTextContent(
+ "OPEN",
+ );
+ });
+
+ await waitFor(() => {
+ expect(mockTrackCreditLimitReached).toHaveBeenCalledTimes(2);
+ });
+
+ // Both calls should be for credit_limit_reached (once per event)
+ expect(mockTrackCreditLimitReached).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ conversationId: "test-conversation-123",
+ }),
+ );
+ expect(mockTrackCreditLimitReached).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ conversationId: "test-conversation-123",
+ }),
+ );
+ });
+ });
+});
diff --git a/frontend/public/android-chrome-192x192.png b/frontend/public/android-chrome-192x192.png
index 23f4f4fd24..31d5801adb 100644
Binary files a/frontend/public/android-chrome-192x192.png and b/frontend/public/android-chrome-192x192.png differ
diff --git a/frontend/public/android-chrome-512x512.png b/frontend/public/android-chrome-512x512.png
index 1fe76e4196..57e1544c5f 100644
Binary files a/frontend/public/android-chrome-512x512.png and b/frontend/public/android-chrome-512x512.png differ
diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png
index d6146fed32..31d5801adb 100644
Binary files a/frontend/public/apple-touch-icon.png and b/frontend/public/apple-touch-icon.png differ
diff --git a/frontend/public/favicon-16x16.png b/frontend/public/favicon-16x16.png
index 5db772fa15..4f230c5981 100644
Binary files a/frontend/public/favicon-16x16.png and b/frontend/public/favicon-16x16.png differ
diff --git a/frontend/public/favicon-32x32.png b/frontend/public/favicon-32x32.png
index bb75b8b65f..1f874a817d 100644
Binary files a/frontend/public/favicon-32x32.png and b/frontend/public/favicon-32x32.png differ
diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico
index 680e72b56f..502a6b37cd 100644
Binary files a/frontend/public/favicon.ico and b/frontend/public/favicon.ico differ
diff --git a/frontend/public/safari-pinned-tab.svg b/frontend/public/safari-pinned-tab.svg
index fb271c3449..daa0090f0f 100644
--- a/frontend/public/safari-pinned-tab.svg
+++ b/frontend/public/safari-pinned-tab.svg
@@ -1,32 +1,7 @@
-