From 4264a07a8bf70f0b230efb3ddeed7de7c96e5099 Mon Sep 17 00:00:00 2001 From: Christiaan Hendriksen Date: Fri, 28 Aug 2026 11:32:06 +0100 Subject: [PATCH 1/5] feat(conversations): make widget ticket recovery text customizable Co-Authored-By: Claude Fable 5 --- frontend/src/types.ts | 2 + posthog/models/remote_config.py | 3 + posthog/models/test/test_remote_config.py | 6 ++ .../scenes/settings/WidgetSection.tsx | 58 +++++++++++++++++++ .../scenes/settings/supportSettingsLogic.ts | 56 ++++++++++++++++++ 5 files changed, 125 insertions(+) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 79f4e965b7ab..e4c4c10c6f01 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -775,6 +775,8 @@ export interface ConversationsSettings { widget_identification_form_title?: string widget_identification_form_description?: string widget_placeholder_text?: string + widget_ticket_recovery_text?: string + widget_ticket_recovery_link_text?: string widget_position?: 'bottom_left' | 'bottom_right' | 'top_left' | 'top_right' slack_enabled?: boolean slack_team_id?: string | null diff --git a/posthog/models/remote_config.py b/posthog/models/remote_config.py index 658ecd00ef99..d13c03ad8cdc 100644 --- a/posthog/models/remote_config.py +++ b/posthog/models/remote_config.py @@ -301,6 +301,9 @@ def build_config(self, bypass_recordings_quota_cache: bool = False) -> dict[str, "identificationFormDescription": conv_settings.get("widget_identification_form_description") or "Please provide your details so we can help you better.", "placeholderText": conv_settings.get("widget_placeholder_text") or "Type your message...", + "ticketRecoveryText": conv_settings.get("widget_ticket_recovery_text") + or "Don't see your previous tickets?", + "ticketRecoveryLinkText": conv_settings.get("widget_ticket_recovery_link_text") or "Recover them here", "widgetPosition": conv_settings.get("widget_position") or "bottom_right", } else: diff --git a/posthog/models/test/test_remote_config.py b/posthog/models/test/test_remote_config.py index b5d63ee945a4..b729a509bd2f 100644 --- a/posthog/models/test/test_remote_config.py +++ b/posthog/models/test/test_remote_config.py @@ -194,6 +194,8 @@ def test_conversations_enabled_with_defaults(self): == "Please provide your details so we can help you better." ) assert self.remote_config.config["conversations"]["placeholderText"] == "Type your message..." + assert self.remote_config.config["conversations"]["ticketRecoveryText"] == "Don't see your previous tickets?" + assert self.remote_config.config["conversations"]["ticketRecoveryLinkText"] == "Recover them here" def test_conversations_enabled_with_custom_config(self): self.team.conversations_enabled = True @@ -209,6 +211,8 @@ def test_conversations_enabled_with_custom_config(self): "widget_identification_form_title": "Let's get started", "widget_identification_form_description": "Tell us about yourself", "widget_placeholder_text": "Ask away...", + "widget_ticket_recovery_text": "Missing your tickets?", + "widget_ticket_recovery_link_text": "Restore access", } self.team.save() self.sync_remote_config() @@ -223,6 +227,8 @@ def test_conversations_enabled_with_custom_config(self): assert self.remote_config.config["conversations"]["identificationFormTitle"] == "Let's get started" assert self.remote_config.config["conversations"]["identificationFormDescription"] == "Tell us about yourself" assert self.remote_config.config["conversations"]["placeholderText"] == "Ask away..." + assert self.remote_config.config["conversations"]["ticketRecoveryText"] == "Missing your tickets?" + assert self.remote_config.config["conversations"]["ticketRecoveryLinkText"] == "Restore access" def test_conversations_disabled_returns_false(self): self.team.conversations_enabled = False diff --git a/products/conversations/frontend/scenes/settings/WidgetSection.tsx b/products/conversations/frontend/scenes/settings/WidgetSection.tsx index 544fff940880..faa82f172d9f 100644 --- a/products/conversations/frontend/scenes/settings/WidgetSection.tsx +++ b/products/conversations/frontend/scenes/settings/WidgetSection.tsx @@ -32,6 +32,10 @@ export function WidgetSection(): JSX.Element { saveIdentificationFormDescription, setPlaceholderTextValue, savePlaceholderText, + setTicketRecoveryTextValue, + saveTicketRecoveryText, + setTicketRecoveryLinkTextValue, + saveTicketRecoveryLinkText, } = useActions(supportSettingsLogic) const { widgetEnabledLoading, @@ -39,6 +43,8 @@ export function WidgetSection(): JSX.Element { identificationFormTitleValue, identificationFormDescriptionValue, placeholderTextValue, + ticketRecoveryTextValue, + ticketRecoveryLinkTextValue, } = useValues(supportSettingsLogic) return ( @@ -190,6 +196,58 @@ export function WidgetSection(): JSX.Element { + +
+ +
+ + + Save + +
+
+ +
+ +
+ + + Save + +
+
diff --git a/products/conversations/frontend/scenes/settings/supportSettingsLogic.ts b/products/conversations/frontend/scenes/settings/supportSettingsLogic.ts index 76acf4bbe81c..2b3a63ee8df6 100644 --- a/products/conversations/frontend/scenes/settings/supportSettingsLogic.ts +++ b/products/conversations/frontend/scenes/settings/supportSettingsLogic.ts @@ -138,6 +138,8 @@ export interface supportSettingsLogicValues { name: string }[] teamsTeamsLoading: boolean + ticketRecoveryLinkTextValue: string | null + ticketRecoveryTextValue: string | null widgetEnabledLoading: boolean } @@ -382,6 +384,12 @@ export interface supportSettingsLogicActions { saveSlackTicketEmoji: () => { value: true } + saveTicketRecoveryLinkText: () => { + value: true + } + saveTicketRecoveryText: () => { + value: true + } sendTestEmail: (configId: string) => { configId: string } @@ -494,6 +502,12 @@ export interface supportSettingsLogicActions { status: 'error' | 'idle' | 'installed' | 'installing' | 'needs_org_catalog' teamId: string | null } + setTicketRecoveryLinkTextValue: (value: string | null) => { + value: string | null + } + setTicketRecoveryTextValue: (value: string | null) => { + value: string | null + } setWidgetEnabledLoading: (loading: boolean) => { loading: boolean } @@ -600,6 +614,10 @@ export const supportSettingsLogic = kea([ saveIdentificationFormDescription: true, setPlaceholderTextValue: (value: string | null) => ({ value }), savePlaceholderText: true, + setTicketRecoveryTextValue: (value: string | null) => ({ value }), + saveTicketRecoveryText: true, + setTicketRecoveryLinkTextValue: (value: string | null) => ({ value }), + saveTicketRecoveryLinkText: true, // Notification recipients setNotificationRecipients: (users: UserBasicType[]) => ({ users }), // Slack channel settings (SupportHog) @@ -738,6 +756,18 @@ export const supportSettingsLogic = kea([ setPlaceholderTextValue: (_, { value }) => value, }, ], + ticketRecoveryTextValue: [ + null as string | null, + { + setTicketRecoveryTextValue: (_, { value }) => value, + }, + ], + ticketRecoveryLinkTextValue: [ + null as string | null, + { + setTicketRecoveryLinkTextValue: (_, { value }) => value, + }, + ], // Email multi-config state emailConfigs: [ [] as EmailConfigStatus[], @@ -1277,6 +1307,30 @@ export const supportSettingsLogic = kea([ }, }) }, + saveTicketRecoveryText: () => { + const trimmedValue = values.ticketRecoveryTextValue?.trim() + if (!trimmedValue) { + return + } + actions.updateCurrentTeam({ + conversations_settings: { + ...values.currentTeam?.conversations_settings, + widget_ticket_recovery_text: trimmedValue, + }, + }) + }, + saveTicketRecoveryLinkText: () => { + const trimmedValue = values.ticketRecoveryLinkTextValue?.trim() + if (!trimmedValue) { + return + } + actions.updateCurrentTeam({ + conversations_settings: { + ...values.currentTeam?.conversations_settings, + widget_ticket_recovery_link_text: trimmedValue, + }, + }) + }, setNotificationRecipients: ({ users }) => { actions.updateCurrentTeam({ conversations_settings: { @@ -1647,6 +1701,8 @@ export const supportSettingsLogic = kea([ actions.setIdentificationFormTitleValue(null) actions.setIdentificationFormDescriptionValue(null) actions.setPlaceholderTextValue(null) + actions.setTicketRecoveryTextValue(null) + actions.setTicketRecoveryLinkTextValue(null) actions.setSlackTicketEmojiValue(null) actions.setSlackBotIconUrlValue(null) actions.setSlackBotDisplayNameValue(null) From 7db63ff8af5ebcfed00a67f78d71d30b1bcdd38d Mon Sep 17 00:00:00 2001 From: Christiaan Hendriksen Date: Fri, 28 Aug 2026 12:47:16 +0100 Subject: [PATCH 2/5] fix(conversations): improve widget settings copy and accessibility Rename the "Ticket recovery link" label to "Ticket recovery link text" so it reads as the link's wording rather than a URL, and explain under both ticket recovery fields when the footer is shown. Associate every widget settings label with its input via htmlFor/id and give each Save button a distinct accessible name. Co-Authored-By: Claude Fable 5 --- .../scenes/settings/WidgetSection.tsx | 57 ++++++++++++++++--- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/products/conversations/frontend/scenes/settings/WidgetSection.tsx b/products/conversations/frontend/scenes/settings/WidgetSection.tsx index faa82f172d9f..57ca3111c52a 100644 --- a/products/conversations/frontend/scenes/settings/WidgetSection.tsx +++ b/products/conversations/frontend/scenes/settings/WidgetSection.tsx @@ -148,9 +148,12 @@ export function WidgetSection(): JSX.Element {
- +
- +
- +
+ +

+ Only shown to visitors who aren't identified. +

+
- +
+ +

+ The clickable part of the ticket recovery message. +

+
- +
- +
- +

Automatically generated token used to authenticate widget requests.

Date: Fri, 28 Aug 2026 12:50:46 +0100 Subject: [PATCH 3/5] fix(conversations): scope widget settings changes to ticket recovery copy Remove the htmlFor/id label association and Save button aria-labels so the form keeps one labeling pattern throughout. Widget form accessibility is tracked as team follow-up work. Co-Authored-By: Claude Fable 5 --- .../scenes/settings/WidgetSection.tsx | 47 +++---------------- 1 file changed, 7 insertions(+), 40 deletions(-) diff --git a/products/conversations/frontend/scenes/settings/WidgetSection.tsx b/products/conversations/frontend/scenes/settings/WidgetSection.tsx index 57ca3111c52a..e22cb2d1708d 100644 --- a/products/conversations/frontend/scenes/settings/WidgetSection.tsx +++ b/products/conversations/frontend/scenes/settings/WidgetSection.tsx @@ -148,12 +148,9 @@ export function WidgetSection(): JSX.Element {
- +
- +
- +

Only shown to visitors who aren't identified.

- +

The clickable part of the ticket recovery message.

- +
- +
- +

Automatically generated token used to authenticate widget requests.

Date: Fri, 28 Aug 2026 12:31:22 +0000 Subject: [PATCH 4/5] fix(conversations): guard ticket recovery saves against concurrent overwrite The two recovery Save buttons stayed clickable during an in-flight team update. Because each save spreads the same conversations_settings snapshot and the server merge is client-wins, a second concurrent save reverted the first, losing the value silently. Set loading={currentTeamLoading} on both recovery Save buttons so an in-flight save disables them, matching the double-submission guard in SlackSection and the repo convention. Generated-By: PostHog Desktop Task-Id: 0a532af1-05d4-4511-9f72-2b09847186d8 --- .../conversations/frontend/scenes/settings/WidgetSection.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/products/conversations/frontend/scenes/settings/WidgetSection.tsx b/products/conversations/frontend/scenes/settings/WidgetSection.tsx index e22cb2d1708d..4aabc167fd82 100644 --- a/products/conversations/frontend/scenes/settings/WidgetSection.tsx +++ b/products/conversations/frontend/scenes/settings/WidgetSection.tsx @@ -19,7 +19,7 @@ import { SceneSection } from '~/layout/scenes/components/SceneSection' import { supportSettingsLogic } from './supportSettingsLogic' export function WidgetSection(): JSX.Element { - const { currentTeam } = useValues(teamLogic) + const { currentTeam, currentTeamLoading } = useValues(teamLogic) const { updateCurrentTeam } = useActions(teamLogic) const { generateNewToken, @@ -218,6 +218,7 @@ export function WidgetSection(): JSX.Element { Date: Fri, 28 Aug 2026 12:33:17 +0000 Subject: [PATCH 5/5] fix(conversations): correct ticket recovery visibility description The description said the recovery footer is hidden from "identified" visitors. In PostHog "identified" means posthog.identify(), which does not hide the footer. The footer is hidden only for visitors using identity verification (a signed identity_distinct_id + identity_hash), matching the sibling in-app footer gate and the wording in the Identity verification settings section. Reword to "Only shown to visitors who aren't using identity verification" so a team running a logged-in product does not wrongly skip translating it. Generated-By: PostHog Desktop Task-Id: 0a532af1-05d4-4511-9f72-2b09847186d8 --- .../conversations/frontend/scenes/settings/WidgetSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/products/conversations/frontend/scenes/settings/WidgetSection.tsx b/products/conversations/frontend/scenes/settings/WidgetSection.tsx index 4aabc167fd82..bcba8ce2e2d3 100644 --- a/products/conversations/frontend/scenes/settings/WidgetSection.tsx +++ b/products/conversations/frontend/scenes/settings/WidgetSection.tsx @@ -201,7 +201,7 @@ export function WidgetSection(): JSX.Element {

- Only shown to visitors who aren't identified. + Only shown to visitors who aren't using identity verification.