Skip to content

fix(posthog-ai): stop /ticket creating duplicate and blank tickets - #72932

Merged
christiaan-ph merged 12 commits into
masterfrom
christiaan-ph/fix-posthog-ai-ticket-duplicate-blank
Jul 24, 2026
Merged

fix(posthog-ai): stop /ticket creating duplicate and blank tickets#72932
christiaan-ph merged 12 commits into
masterfrom
christiaan-ph/fix-posthog-ai-ticket-duplicate-blank

Conversation

@christiaan-ph

@christiaan-ph christiaan-ph commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Problem

The /ticket command in PostHog AI could create bad tickets in the support inbox:

  • Blank tickets whose entire body was only the Conversation ID / Trace ID footer, with no actual message.
  • A duplicate ticket filed alongside the real one, or two blanks from a fast double click.

Two entry points hit the same underlying defects:

  1. Sending /ticket as the first message, then pressing enter on the empty "describe your issue" field.
  2. Using /ticket mid-conversation (summary flow), then submitting twice. The first submit files the real ticket and resets the form, so the second submit sends an empty message and produces a footer-only blank.

Root causes:

  • appendMetadataToMessage returned the ID footer on its own when the message was empty, turning "no content" into a non-empty-looking body that slipped past the form's own "please enter a message" validation.
  • Pressing enter on the empty input had no guard, even though the button below it did.
  • Nothing debounced or locked submission, and each submit calls sendMessage(..., true), which starts a fresh ticket every time.

Changes

  • appendTicketMetadata (extracted into ticketUtils.ts) returns an empty string for an empty body, so the ID footer can no longer stand in as a fake message.
  • handleSupportFormSubmit composes the body first and refuses to submit when it is empty (toast: "Please add a description before creating a ticket"), guards re-entry with a synchronous ref, and the create and submit controls lock via hasSubmitted once a ticket exists for the conversation.
  • Pressing enter on an empty input no longer opens the ticket modal.
  • New "anything to add" flow: the modal shows PostHog AI's analysis as read-only context and offers an optional note that leads the ticket body, so a ticket can carry the customer's own words instead of an AI-only summary. Implemented with two optional props on the shared SupportForm (messageLabel / messagePlaceholder); no other caller changes behavior.
  • The composed ticket body (note + analysis + ID footer) is passed directly to submitSupportTicket instead of being written back into the form first. Previously the full body was dumped into the visible note field during submission, a failed submit left it polluted (and a retry would have appended the summary and footer twice), and success was detected via the kea-forms submit promise, which resolves before the ticket request finishes, so the modal never closed. The modal now closes synchronously once the awaited submit sets the new ticket id.

How did you test this code?

  • hogli test frontend/src/scenes/max/ticketUtils.test.ts: 21 passing. I added parameterized cases for composeTicketBody and appendTicketMetadata. The empty-body case is the direct regression guard for the footer-only blank ticket that no existing test covered.
  • Lint and format are clean. The changed files type-check; the repo-wide tsgo failures are pre-existing stale-typegen and posthog-js resolution issues unrelated to this diff.
  • Manually verified locally on the Zendesk fallback variant (screenshots below): the first-message /ticket input flow, the mid-conversation summary flow with topic inference, and the create-ticket modal in both form variants (the conversations variant with product-support-side-panel enabled, and the legacy triage-field variant with it disabled), including ticket creation end to end and the modal closing with a thread confirmation and locked controls afterwards.
  • Manually verified the conversations path end to end on the local stack (product-support-side-panel at 100%, conversations extension loaded, tickets landing in the local conversations inbox with channel_detail: widget_api, matching production). Ticket and comment rows were checked in the database after every submission:
    • Blank prevention: the first-message input keeps "Create support ticket" disabled while empty (and Enter is guarded), and clearing the message inside the modal then submitting shows the "Please add a description before creating a ticket." toast with no ticket row created.
    • Body composition: an empty note produces a body of just the AI summary plus the ID footer; a filled note leads the body with the summary attached under "PostHog AI's analysis:" and the footer last. Topic inference populated the summary's Topic line.
    • Duplicate prevention: double-clicking Submit produced exactly one ticket with exactly one comment row; Cancel is disabled while the submit is in flight; after success the create and submit controls stay locked ("Ticket already created").
    • Failure retry: submitting while offline shows the error toast, stops the spinner, and preserves the note; retrying online created exactly one ticket, so the failed attempt left nothing behind.

Screenshots

1. /ticket as the first message: the describe-your-issue input (empty Enter and empty submit are now guarded)

CleanShot 2026-07-22 at 18 20 56@2x

2. /ticket after describing an issue: AI summary with inferred topic, then the create button

CleanShot 2026-07-22 at 18 21 26@2x

3. The create-ticket modal: read-only "PostHog AI's analysis" block plus the optional "Anything to add?" note

CleanShot 2026-07-22 at 18 21 46@2x

4. After a successful submit, the ticket is created and we see a confirmation

CleanShot 2026-07-22 at 18 29 31@2x

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

Docs update

No docs change. This is internal support UX, not a documented workflow.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

I (Claude, Opus 4.8, via Claude Code) investigated and wrote this at Christiaan's direction. I confirmed the failure mode against the support inbox with the PostHog MCP (conversations-tickets-*): the offending tickets are message_count: 1 with a body of only the ID footer, and at least one pair was an exact duplicate created under a second apart. Two entry points produce the footer-only body: the empty-field enter on the first-message path, and a double submit in the summary path after the first success resets the form.

Skills invoked: /writing-tests (before adding the ticketUtils cases).

Decisions: I chose an empty-body guard plus a synchronous submit lock over a debounce, since the lock also covers the summary-path double submit, not just fast double clicks. I moved body and metadata composition into pure helpers in ticketUtils.ts so the regression is covered by cheap unit tests rather than a component render. I kept the SupportForm change to two optional props to avoid touching its other callers. Manual testing caught that my first version wrote the composed body into the visible note field and never closed the modal (the kea-forms submit promise resolves too early), so the final version submits through submitSupportTicket directly. The work was built on a dedicated git worktree off origin/master because a separate branch was concurrently editing the same support files.

The /ticket flow could file blank tickets whose body was only the
conversation and trace IDs, and sometimes a duplicate alongside the real
one. Two entry points hit the same defects: sending /ticket as the first
message then pressing enter on an empty field, and a double submit in the
summary flow after the first success cleared the form.

- appendTicketMetadata returns an empty string for an empty body, so the
  ID footer can no longer stand in as a fake message that slips past the
  "please enter a message" validation
- handleSupportFormSubmit guards the composed body and blocks re-entry
  with a synchronous ref, and the create and submit controls lock once a
  ticket exists for the conversation
- pressing enter on an empty field no longer opens the ticket modal
- customers get an optional "anything to add" note that leads the ticket
  with the AI summary attached as context

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@christiaan-ph christiaan-ph self-assigned this Jul 22, 2026
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

🤖 CI report

⚠️ Bundle size — 🔺 +78 B (+0.0%)

Uncompressed size of every built .js bundle, compared against the base branch.

Total: 64.42 MiB · 🔺 +78 B (+0.0%)

No file changed by more than 1000 B.

Posted automatically by build-bundle-size-report · uncompressed bytes from dist-report

Eager graph — within budget

How much code each root ships on the eager path — downloaded and parsed before the surface is interactive. Measured from the esbuild output chunks (post-tree-shake, static imports only); lazy import() / React.lazy chunks are not counted.

Root Eager (shipped) Δ vs base Budget
entry (logged-out pages, app bootstrap)
src/index.tsx
1.24 MiB · 22 files no change ███░░░░░░░ 27.6% of 4.51 MiB
authenticated shell (every logged-in page)
src/scenes/AuthenticatedShell.tsx
8.07 MiB · 3,011 files 🔺 +78 B (+0.0%) ████████░░ 83.1% of 9.71 MiB

🟢 node_modules/monaco-editor/ stays out of src/index.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/index.tsx
🟢 [object Object] stays out of src/index.tsx
🟢 [object Object] stays out of src/index.tsx
🟢 node_modules/monaco-editor/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx

Largest files eagerly shipped from src/index.tsx
Size File
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
24.6 KiB ../node_modules/.pnpm/buffer@6.0.3/node_modules/buffer/index.js
6.3 KiB ../node_modules/.pnpm/react@18.3.1/node_modules/react/cjs/react.production.min.js
4.5 KiB ../node_modules/.pnpm/@jspm+core@2.1.0/node_modules/@jspm/core/nodelibs/browser/process.js
3.9 KiB ../node_modules/.pnpm/scheduler@0.23.2/node_modules/scheduler/cjs/scheduler.production.min.js
1.4 KiB ../node_modules/.pnpm/base64-js@1.5.1/node_modules/base64-js/index.js
1.3 KiB src/RootErrorBoundary.tsx
912 B ../node_modules/.pnpm/ieee754@1.2.1/node_modules/ieee754/index.js
789 B src/scenes/ChunkLoadErrorBoundary.tsx
762 B src/index.tsx
Largest files eagerly shipped from src/scenes/AuthenticatedShell.tsx
Size File
281.5 KiB ../node_modules/.pnpm/posthog-js@1.407.2/node_modules/posthog-js/dist/rrweb.js
267.7 KiB ../node_modules/.pnpm/@posthog+icons@0.38.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
236.0 KiB src/taxonomy/core-filter-definitions-by-group.json
226.1 KiB ../node_modules/.pnpm/posthog-js@1.407.2/node_modules/posthog-js/dist/module.js
154.3 KiB ../node_modules/.pnpm/re2js@0.4.1/node_modules/re2js/build/index.esm.js
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
106.2 KiB src/lib/api.ts
94.0 KiB ../packages/quill/packages/quill/dist/index.js
93.3 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js
90.6 KiB ../node_modules/.pnpm/@tiptap+core@3.20.6_@tiptap+pm@3.20.6/node_modules/@tiptap/core/dist/index.js

Posted automatically by check-eager-graph · sizes are eager output bytes (shipped, post-tree-shake) from the esbuild metafile · part of #32479

Toolbar bundle — eager 2.18 MiB within budget

What the toolbar ships to customer pages, measured from the esbuild output (minified, post-tree-shake). The eager set is the entry plus everything statically imported from it — fetched before any feature runs; deferred chunks load lazily. The eager guardrail is 5.72 MiB. Each output file must also stay below 10 MB, where CloudFront stops compressing it. The module boundary is enforced separately by check-toolbar-graph.

Metric Size Δ vs base Budget
Eager (shipped)
entry + static imports
2.18 MiB · 17 files no change ████░░░░░░ 38.1% of 5.72 MiB
Deferred (lazy) 2.07 MiB · 33 files no change n/a — loads on demand
Loader dist/toolbar.js 1.1 KiB no change █░░░░░░░░░ 5.8% of 19.5 KiB
Largest eagerly-shipped chunks
Size File
716.0 KiB dist/toolbar/toolbar-app-UT2TAV4U.css
545.1 KiB dist/toolbar/chunk-chunk-R67YSTOI.js
484.3 KiB dist/toolbar/chunk-chunk-PY4X2WR2.js
133.6 KiB dist/toolbar/chunk-chunk-P36RTWFE.js
131.8 KiB dist/toolbar/chunk-chunk-T5KY5WYR.js
71.0 KiB dist/toolbar/toolbar-app-KIASA47E.js
69.0 KiB dist/toolbar/chunk-chunk-27JL52RE.js
35.6 KiB dist/toolbar/chunk-chunk-NZ4IQ3HA.js
20.9 KiB dist/toolbar/chunk-chunk-DKDYNNXI.js
12.2 KiB dist/toolbar/chunk-chunk-PIK3PADE.js

Posted automatically by check-toolbar-size · sizes are toolbar output bytes (shipped, post-tree-shake) from the esbuild metafile

Dist folder size — 🔺 +8.6 KiB (+0.0%)

Total size of the built frontend/dist folder (all assets), compared against the base branch.

Total: 1353.98 MiB · 🔺 +8.6 KiB (+0.0%)

Playwright — all passed

All tests passed.

View test results →

@christiaan-ph
christiaan-ph marked this pull request as ready for review July 22, 2026 17:30
@christiaan-ph
christiaan-ph requested a review from slshults July 22, 2026 17:30
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

🦔 Hogbox preview · ✅ ready

▶ Open the preview

🔑 Login test@posthog.com / 12345678 (demo data)
🧩 Running this PR's backend and frontend, on the PostHog :master base
🔗 Link stable across rebuilds — a re-push swaps the box underneath, the URL stays
🔒 Access tailnet only (PostHog VPN)
🛠️ Admin inspect & debug state in hogland
💤 Idle sleeps after ~30 min idle (snapshot to S3, zero node cost) and wakes on your next visit in ~30s, behind a brief "waking up" screen

commit f24b1f8 · box box-d6e6a7d2c1ee · ready in 809s (push → usable) · build log · rebuilds on every push, torn down on close

@christiaan-ph
christiaan-ph requested review from a team and abigailbramble July 22, 2026 17:31
@github-actions
github-actions Bot requested a deployment to preview-pr-72932 July 22, 2026 17:31 In progress
Writing the composed message (summary + ID footer) back into the form
before submitting dumped the whole ticket body into the visible
"anything to add" textarea, left it polluted on failure, and a retry
would have composed the summary and footer in twice. Success was also
detected via the kea-forms submit promise plus an effect watching
lastSubmittedTicketId, which resolves before the ticket request
finishes, so the modal never closed.

Compose the final message locally and pass it straight to
submitSupportTicket (replicating the form wrapper's name/email
defaulting), then close the modal synchronously once the awaited
listener has set the new ticket id. The pending-submission state and
effect are no longer needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
frontend/src/scenes/max/TicketPrompt.tsx:5
**Toast Import Breaks Compilation**

`lemonToast` is provided by the frontend LemonToast module, not the `@posthog/lemon-ui` package barrel. This named import makes the changed module fail TypeScript compilation with a missing-export error.

```suggestion
import { LemonButton, LemonInput, LemonModal } from '@posthog/lemon-ui'

import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast'
```

### Issue 2 of 2
frontend/src/scenes/max/TicketPrompt.tsx:110
**Retry Reprocesses Final Payload**

When submission fails, this stores the composed body and metadata in the shared message field before the retry lock is released. The next attempt treats that stored payload as the user's note and composes it again, producing duplicate metadata without a summary or duplicate summary and metadata with one.

Reviews (1): Last reviewed commit: "fix(posthog-ai): submit ticket directly ..." | Re-trigger Greptile

})
} catch {
// Failure is detected below via the unchanged ticket id
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Retry Reprocesses Final Payload

When submission fails, this stores the composed body and metadata in the shared message field before the retry lock is released. The next attempt treats that stored payload as the user's note and composes it again, producing duplicate metadata without a summary or duplicate summary and metadata with one.

Rule Used: If data has already been processed, avoid re-proce... (source)

Learned From
PostHog/posthog#32520

Prompt To Fix With AI
This is a comment left during a code review.
Path: frontend/src/scenes/max/TicketPrompt.tsx
Line: 110

Comment:
**Retry Reprocesses Final Payload**

When submission fails, this stores the composed body and metadata in the shared message field before the retry lock is released. The next attempt treats that stored payload as the user's note and composes it again, producing duplicate metadata without a summary or duplicate summary and metadata with one.

**Rule Used:** If data has already been processed, avoid re-proce... ([source](https://app.greptile.com/posthog-org-19734/-/custom-context?memory=951766f2-db79-4e5b-b8ee-ae0fa0c13119))

**Learned From**
[PostHog/posthog#32520](https://github.com/PostHog/posthog/pull/32520)

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked both findings against the PR head (e1f31a5):

  • Retry reprocessing: this described the previous revision, which wrote the composed message back into the form via setSendSupportRequestValue before releasing the retry lock. The current code composes the final message locally and passes it straight to submitSupportTicket, so the shared message field only ever holds the user's note. Nothing is stored on failure, and a retry recomposes from the untouched note.
  • lemonToast import (from the summary comment): the @posthog/lemon-ui barrel does export lemonToast. There are 146 existing barrel imports of it on master, including SupportForm.tsx in this same diff, and the Frontend typechecking CI job passes on this commit.

@slshults slshults added team/conversations Conversations team feature/conversations Conversations (support inbox + widget) reviewhog ($$$) Reviews pull requests before humans do labels Jul 22, 2026
@posthog

posthog Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

🦔 ReviewHog reviewed this pull request

Found 1 must fix, 0 should fix, 3 consider.

Published 4 findings (view the review).

@posthog

posthog Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReviewHog Report

Changes

Issues: 4 issues

Files (3)
  • frontend/src/lib/components/Support/SupportForm.tsx
  • frontend/src/scenes/max/TicketPrompt.tsx
  • frontend/src/scenes/max/ticketUtils.ts

Comment on lines +196 to +200
onPressEnter={() => {
if (issueText.trim()) {
openSupportModal()
}
}}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enter key on the issue-text input doesn't check hasSubmitted, unlike the adjacent button

consider bug

Why we think it's a valid issue
  • Checked: Working tree is at base commit 527d71b6, not the PR head, so I pulled the real PR-head content via gh pr diff 72932. Confirmed the final render block (PATCH 2/2 stops before it, so PATCH 1/2's version stands) and traced the downstream submit guards.
  • Found: At the flagged lines the Enter handler is onPressEnter={() => { if (issueText.trim()) { openSupportModal() } }} — only issueText.trim(). The sibling button in the same block uses disabledReason={hasSubmitted ? 'Ticket already created' : !issueText.trim() ? 'Describe your issue first' : undefined}. hasSubmitted is set true in handleTicketCreated after a successful ticket, and issueText is never cleared — so once a ticket exists the button is disabled but pressing Enter in the still-populated input still calls openSupportModal() and reopens the modal. The guard mismatch is genuine and sits on lines this PR authored.
  • Impact: Real but bounded. Resubmission is fully blocked — handleSupportFormSubmit early-returns on if (submitInFlightRef.current || hasSubmitted) and the modal's Submit button is disabled via disabledReason={hasSubmitted ? ...} — so no duplicate or blank ticket can result. The only observable effect is a confusing dead-end modal reopening after a ticket already exists, which contradicts the PR's own stated goal of locking the controls once a ticket exists. Minor cosmetic UX inconsistency, not a data/correctness defect, fixed by adding !hasSubmitted && to the existing condition. The reviewer's consider (lowest) priority is the right bucket, so no adjustment.
Issue description

The 'Create support ticket' button in the no-summary flow disables via disabledReason={hasSubmitted ? 'Ticket already created' : !issueText.trim() ? 'Describe your issue first' : undefined}, but the input's onPressEnter handler only checks issueText.trim() and omits the hasSubmitted check. issueText is never cleared after a successful submission, so once a ticket has been created for this TicketPrompt instance, pressing Enter in the still-populated input reopens the create-ticket modal even though the button right below it is disabled — an inconsistent guard between the two entry points for the same action (actual resubmission is still blocked by handleSupportFormSubmit's own hasSubmitted check and the Submit button's disabledReason, but the modal can confusingly reopen after a ticket already exists).

Suggested fix

Mirror the button's guard in the Enter handler: onPressEnter={() => { if (!hasSubmitted && issueText.trim()) { openSupportModal() } }}.

Prompt to fix with AI (copy-paste)
## Context
@frontend/src/scenes/max/TicketPrompt.tsx#L196-200

<issue_description>
The 'Create support ticket' button in the no-summary flow disables via `disabledReason={hasSubmitted ? 'Ticket already created' : !issueText.trim() ? 'Describe your issue first' : undefined}`, but the input's `onPressEnter` handler only checks `issueText.trim()` and omits the `hasSubmitted` check. `issueText` is never cleared after a successful submission, so once a ticket has been created for this `TicketPrompt` instance, pressing Enter in the still-populated input reopens the create-ticket modal even though the button right below it is disabled — an inconsistent guard between the two entry points for the same action (actual resubmission is still blocked by `handleSupportFormSubmit`'s own `hasSubmitted` check and the Submit button's `disabledReason`, but the modal can confusingly reopen after a ticket already exists).
</issue_description>

<issue_validation>
- **Checked:** Working tree is at base commit `527d71b6`, not the PR head, so I pulled the real PR-head content via `gh pr diff 72932`. Confirmed the final render block (PATCH 2/2 stops before it, so PATCH 1/2's version stands) and traced the downstream submit guards.
- **Found:** At the flagged lines the Enter handler is `onPressEnter={() => { if (issueText.trim()) { openSupportModal() } }}` — only `issueText.trim()`. The sibling button in the same block uses `disabledReason={hasSubmitted ? 'Ticket already created' : !issueText.trim() ? 'Describe your issue first' : undefined}`. `hasSubmitted` is set true in `handleTicketCreated` after a successful ticket, and `issueText` is never cleared — so once a ticket exists the button is disabled but pressing Enter in the still-populated input still calls `openSupportModal()` and reopens the modal. The guard mismatch is genuine and sits on lines this PR authored.
- **Impact:** Real but bounded. Resubmission is fully blocked — `handleSupportFormSubmit` early-returns on `if (submitInFlightRef.current || hasSubmitted)` and the modal's Submit button is disabled via `disabledReason={hasSubmitted ? ...}` — so no duplicate or blank ticket can result. The only observable effect is a confusing dead-end modal reopening after a ticket already exists, which contradicts the PR's own stated goal of locking the controls once a ticket exists. Minor cosmetic UX inconsistency, not a data/correctness defect, fixed by adding `!hasSubmitted &&` to the existing condition. The reviewer's `consider` (lowest) priority is the right bucket, so no adjustment.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Mirror the button's guard in the Enter handler: `onPressEnter={() => { if (!hasSubmitted && issueText.trim()) { openSupportModal() } }}`.
</potential_solution>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 04fa82e. openSupportModal now early-returns when hasSubmitted, which covers the Enter handler and any other caller, so the dead modal cannot be reopened after a ticket is created.

Comment on lines +96 to +107
}

const finalMessage = appendMetadataToMessage(sendSupportRequest.message)
setSendSupportRequestValue('message', finalMessage)
submitInFlightRef.current = true
setIsSubmitting(true)
const ticketIdBefore = supportLogic.values.lastSubmittedTicketId
setTicketIdBeforeSubmission(ticketIdBefore)
setPendingTicketSubmission(true)
try {
await supportLogic.asyncActions.submitSendSupportRequest()
await supportLogic.asyncActions.submitSupportTicket({
...sendSupportRequest,
name: user?.first_name ?? sendSupportRequest.name ?? 'name not set',
email: user?.email ?? sendSupportRequest.email ?? '',
message: finalMessage,
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direct call to submitSupportTicket bypasses the form's required-field validation for the legacy (non-conversations) ticket path

consider bug

Why we think it's a valid issue
  • Checked: PR-head handleSupportFormSubmit (via gh pr diff), the kea-forms forms() block and submitSupportTicket listener in supportLogic.ts, the target_area field in SupportForm.tsx, openSupportModal's pre-fill in TicketPrompt.tsx, and the featurePreviewsLogic.tsx precedent.
  • Found: PATCH 2/2 replaced submitSendSupportRequest() with a direct submitSupportTicket({...sendSupportRequest, ...}) call. The validator lives on the kea-forms wrapper (supportLogic.ts:684-696, requiring kind/severity_level/target_area when !conversationsFlagEnabled); submitSupportTicket (supportLogic.ts:780) is the submit target and runs no validation. SupportForm.tsx:145-168 renders a clearable target_area select (onChange(newValue ?? null) at :163) only on the !conversationsFlagEnabled branch, and the Zendesk fallback coerces null via target_area ?? 'General' (supportLogic.ts:953). So clearing the topic on the legacy path yields a ticket routed to 'General' with no client-side block — a genuine regression, since the base code's submitSendSupportRequest() ran the validator. Diagnosis and the featurePreviewsLogic.tsx:193-199 hardcoded-fields precedent are accurate.
  • Impact: Real but narrow. Trigger requires the deprecated Zendesk path (conversationsFlagEnabled false — code labels it the temporary fallback being retired) AND a user deliberately clearing the pre-filled Topic (openSupportModal seeds target_area: targetArea ?? 'analytics', always non-null at open; kind/severity_level are pre-set and not user-nullable). Consequence is a soft routing downgrade to 'General' — the ticket is still created and delivered; no data loss, no duplicate/blank ticket, no security dimension.
  • Priority: Lowering should_fixconsider: the finding is accurate and a legitimate completeness gap (this PR guarded message but not the other required fields), but the deprecated path plus deliberate-clear trigger and the benign 'General'-bucket outcome put it below the should-fix bar.
Issue description

handleSupportFormSubmit now calls supportLogic.asyncActions.submitSupportTicket({...sendSupportRequest, name, email, message: finalMessage}) directly instead of the kea-forms-generated submitSendSupportRequest(). That generated action runs the errors validator declared in supportLogic.ts (forms(...), requiring kind, severity_level, and target_area whenever !conversationsFlagEnabled) before calling submit; calling submitSupportTicket directly skips that validator entirely. SupportForm renders those triage fields (including a LemonInputSelect for target_area that can be cleared to null via its onChange={([newValue]) => onChange(newValue ?? null)}) whenever conversationsFlagEnabled is false — the exact 'legacy triage-field variant' the PR description says was manually tested. A user who clears the topic can now submit a ticket with no client-side error and no target_area, silently degrading to the 'General' bucket server-side, whereas the same action on the standard support modal (via submitSendSupportRequest) would have blocked the submission with a 'Please choose' validation error. The codebase's existing precedent for calling submitSupportTicket directly (featurePreviewsLogic.tsx) works around this by hardcoding non-null kind/severity_level/target_area values itself — this call site doesn't provide the same guarantee since it spreads live, user-editable sendSupportRequest state.

Suggested fix

Either route back through the validated submitSendSupportRequest() action (letting the form report the missing-field errors), or, if bypassing kea-forms validation is intentional for this flow, replicate the same required-field guarantees this PR already added for message — e.g. also guard on sendSupportRequest.target_area/kind/severity_level before calling submitSupportTicket, matching the pattern featurePreviewsLogic.tsx uses of always supplying non-null values for the legacy triage fields.

Prompt to fix with AI (copy-paste)
## Context
@frontend/src/scenes/max/TicketPrompt.tsx#L96-107

<issue_description>
`handleSupportFormSubmit` now calls `supportLogic.asyncActions.submitSupportTicket({...sendSupportRequest, name, email, message: finalMessage})` directly instead of the kea-forms-generated `submitSendSupportRequest()`. That generated action runs the `errors` validator declared in `supportLogic.ts` (`forms(...)`, requiring `kind`, `severity_level`, and `target_area` whenever `!conversationsFlagEnabled`) before calling `submit`; calling `submitSupportTicket` directly skips that validator entirely. `SupportForm` renders those triage fields (including a `LemonInputSelect` for `target_area` that can be cleared to `null` via its `onChange={([newValue]) => onChange(newValue ?? null)}`) whenever `conversationsFlagEnabled` is false — the exact 'legacy triage-field variant' the PR description says was manually tested. A user who clears the topic can now submit a ticket with no client-side error and no `target_area`, silently degrading to the 'General' bucket server-side, whereas the same action on the standard support modal (via `submitSendSupportRequest`) would have blocked the submission with a 'Please choose' validation error. The codebase's existing precedent for calling `submitSupportTicket` directly (`featurePreviewsLogic.tsx`) works around this by hardcoding non-null `kind`/`severity_level`/`target_area` values itself — this call site doesn't provide the same guarantee since it spreads live, user-editable `sendSupportRequest` state.
</issue_description>

<issue_validation>
- **Checked:** PR-head `handleSupportFormSubmit` (via `gh pr diff`), the kea-forms `forms()` block and `submitSupportTicket` listener in `supportLogic.ts`, the `target_area` field in `SupportForm.tsx`, `openSupportModal`'s pre-fill in `TicketPrompt.tsx`, and the `featurePreviewsLogic.tsx` precedent.
- **Found:** PATCH 2/2 replaced `submitSendSupportRequest()` with a direct `submitSupportTicket({...sendSupportRequest, ...})` call. The validator lives on the kea-forms wrapper (`supportLogic.ts:684-696`, requiring `kind`/`severity_level`/`target_area` when `!conversationsFlagEnabled`); `submitSupportTicket` (`supportLogic.ts:780`) is the `submit` target and runs no validation. `SupportForm.tsx:145-168` renders a clearable `target_area` select (`onChange(newValue ?? null)` at :163) only on the `!conversationsFlagEnabled` branch, and the Zendesk fallback coerces null via `target_area ?? 'General'` (`supportLogic.ts:953`). So clearing the topic on the legacy path yields a ticket routed to 'General' with no client-side block — a genuine regression, since the base code's `submitSendSupportRequest()` ran the validator. Diagnosis and the `featurePreviewsLogic.tsx:193-199` hardcoded-fields precedent are accurate.
- **Impact:** Real but narrow. Trigger requires the deprecated Zendesk path (`conversationsFlagEnabled` false — code labels it the temporary fallback being retired) AND a user deliberately clearing the pre-filled Topic (`openSupportModal` seeds `target_area: targetArea ?? 'analytics'`, always non-null at open; `kind`/`severity_level` are pre-set and not user-nullable). Consequence is a soft routing downgrade to 'General' — the ticket is still created and delivered; no data loss, no duplicate/blank ticket, no security dimension.
- **Priority:** Lowering `should_fix` → `consider`: the finding is accurate and a legitimate completeness gap (this PR guarded `message` but not the other required fields), but the deprecated path plus deliberate-clear trigger and the benign 'General'-bucket outcome put it below the should-fix bar.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Either route back through the validated `submitSendSupportRequest()` action (letting the form report the missing-field errors), or, if bypassing kea-forms validation is intentional for this flow, replicate the same required-field guarantees this PR already added for `message` — e.g. also guard on `sendSupportRequest.target_area`/`kind`/`severity_level` before calling `submitSupportTicket`, matching the pattern `featurePreviewsLogic.tsx` uses of always supplying non-null values for the legacy triage fields.
</potential_solution>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not addressing this one: the clearable triage fields (message type, topic, severity) only render on the legacy Zendesk form variant, which is being retired now that support runs on conversations. When the conversations extension falls back to Zendesk at submit time, the rendered form was the conversations variant, where these fields are prefilled by openSupportModal and not clearable, so the cleared-topic scenario cannot occur there. Accepting the target_area ?? 'General' coercion for the remaining lifetime of the fallback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: the Zendesk fallback is staying in service for now, so this is fixed in 9961928 after all. The triage fields (kind, topic, severity) are validated before the direct submit on the non-conversations variant, restoring the block the kea-forms validator used to provide.

Comment on lines 120 to 126

function handleSupportModalCancel(): void {
setIsSupportModalOpen(false)
setPendingTicketSubmission(false)
submitInFlightRef.current = false
setIsSubmitting(false)
closeSupportForm()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cancelling the modal during an in-flight submission resets the duplicate-submit guard, enabling a real double ticket

must_fix bug

Why we think it's a valid issue
  • Checked: Final PR-head handleSupportModalCancel and handleSupportFormSubmit (via gh pr diff), the submitInFlightRef/hasSubmitted guard, LemonModal's close defaults, submitSupportTicket's listener in supportLogic.ts, and what closeSupportForm resets.
  • Found: handleSupportModalCancel clears submitInFlightRef.current = false unconditionally, with no isSubmitting guard. LemonModal defaults closable = true (LemonModal.tsx:87) and routes the Cancel button, X (:136), Escape (shouldCloseOnEsc, :192) and overlay click (:186-188) to onClosehandleSupportModalCancel, so the modal can close while await submitSupportTicket(...) is still pending. submitSupportTicket (supportLogic.ts:780) has no AbortController; the conversations path awaits waitForConversations() + sendMessage, a real multi-second window. closeSupportForm is only a reducer flag (supportLogic.ts:525) — it does not reset the local hasSubmitted, which stays false until the first request resolves. After cancel, the outer 'Create support ticket' button (gated only on hasSubmitted) re-enables, so reopening and re-submitting passes the submitInFlightRef.current || hasSubmitted guard and fires a second submitSupportTicket.
  • Impact: Two tickets from one /ticket invocation if the first (cancelled) request still succeeds — precisely the duplicate-ticket defect this PR exists to eliminate, produced by clearing the PR's own synchronous lock from the cancel path. Trigger is a plausible real action: cancelling a submit that feels slow and retrying (single-click double-submit is already blocked by the loading Submit button + ref, so this is the remaining hole). The suggested fix — don't clear the ref on cancel, and/or closable={!isSubmitting} — closes it without side effects.
Issue description

submitInFlightRef is the PR's new synchronous lock against double submission, but handleSupportModalCancel unconditionally sets submitInFlightRef.current = false even while a submission is genuinely in flight. LemonModal defaults closable to true, and onClose is wired straight to handleSupportModalCancel with no isSubmitting guard, so the user can hit Cancel, click the X, click outside the modal, or press Escape at any point while await supportLogic.asyncActions.submitSupportTicket(...) is still pending. submitSupportTicket's listener is fire-and-forget (there is no AbortController), so the original request keeps running server-side. Once the ref is cleared, the outer 'Create support ticket' button (gated only on hasSubmitted, still false) is clickable again, and a fresh handleSupportFormSubmit() call passes the submitInFlightRef.current || hasSubmitted guard and fires a second submitSupportTicket. If the first ("cancelled") request still succeeds, two tickets get filed from one /ticket invocation — the exact defect this PR is meant to fix.

Suggested fix

Don't clear submitInFlightRef.current from the cancel path. Only reset isSupportModalOpen/isSubmitting/closeSupportForm() there, and let the ref get cleared solely by the in-flight handleSupportFormSubmit once it observes the real outcome (success or failure). That way a stale in-flight request still blocks a resubmission even if the user reopens the modal before it settles. Alternatively (or additionally), disable the modal's close affordances (closable={!isSubmitting} on LemonModal) while isSubmitting is true.

Prompt to fix with AI (copy-paste)
## Context
@frontend/src/scenes/max/TicketPrompt.tsx#L120-126
@frontend/src/scenes/max/TicketPrompt.tsx#L86-100

<issue_description>
`submitInFlightRef` is the PR's new synchronous lock against double submission, but `handleSupportModalCancel` unconditionally sets `submitInFlightRef.current = false` even while a submission is genuinely in flight. `LemonModal` defaults `closable` to `true`, and `onClose` is wired straight to `handleSupportModalCancel` with no `isSubmitting` guard, so the user can hit Cancel, click the X, click outside the modal, or press Escape at any point while `await supportLogic.asyncActions.submitSupportTicket(...)` is still pending. `submitSupportTicket`'s listener is fire-and-forget (there is no `AbortController`), so the original request keeps running server-side. Once the ref is cleared, the outer 'Create support ticket' button (gated only on `hasSubmitted`, still false) is clickable again, and a fresh `handleSupportFormSubmit()` call passes the `submitInFlightRef.current || hasSubmitted` guard and fires a second `submitSupportTicket`. If the first ("cancelled") request still succeeds, two tickets get filed from one /ticket invocation — the exact defect this PR is meant to fix.
</issue_description>

<issue_validation>
- **Checked:** Final PR-head `handleSupportModalCancel` and `handleSupportFormSubmit` (via `gh pr diff`), the `submitInFlightRef`/`hasSubmitted` guard, `LemonModal`'s close defaults, `submitSupportTicket`'s listener in `supportLogic.ts`, and what `closeSupportForm` resets.
- **Found:** `handleSupportModalCancel` clears `submitInFlightRef.current = false` unconditionally, with no `isSubmitting` guard. `LemonModal` defaults `closable = true` (`LemonModal.tsx:87`) and routes the Cancel button, X (`:136`), Escape (`shouldCloseOnEsc`, `:192`) and overlay click (`:186-188`) to `onClose` → `handleSupportModalCancel`, so the modal can close while `await submitSupportTicket(...)` is still pending. `submitSupportTicket` (`supportLogic.ts:780`) has no `AbortController`; the conversations path awaits `waitForConversations()` + `sendMessage`, a real multi-second window. `closeSupportForm` is only a reducer flag (`supportLogic.ts:525`) — it does not reset the local `hasSubmitted`, which stays false until the first request resolves. After cancel, the outer 'Create support ticket' button (gated only on `hasSubmitted`) re-enables, so reopening and re-submitting passes the `submitInFlightRef.current || hasSubmitted` guard and fires a second `submitSupportTicket`.
- **Impact:** Two tickets from one /ticket invocation if the first (cancelled) request still succeeds — precisely the duplicate-ticket defect this PR exists to eliminate, produced by clearing the PR's own synchronous lock from the cancel path. Trigger is a plausible real action: cancelling a submit that feels slow and retrying (single-click double-submit is already blocked by the `loading` Submit button + ref, so this is the remaining hole). The suggested fix — don't clear the ref on cancel, and/or `closable={!isSubmitting}` — closes it without side effects.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Don't clear `submitInFlightRef.current` from the cancel path. Only reset `isSupportModalOpen`/`isSubmitting`/`closeSupportForm()` there, and let the ref get cleared solely by the in-flight `handleSupportFormSubmit` once it observes the real outcome (success or failure). That way a stale in-flight request still blocks a resubmission even if the user reopens the modal before it settles. Alternatively (or additionally), disable the modal's close affordances (`closable={!isSubmitting}` on `LemonModal`) while `isSubmitting` is true.
</potential_solution>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 04fa82e. Cancelling (esc, X, overlay, or the button) is now a no-op while the submit is in flight, since the request cannot be aborted, and the Cancel button shows a disabled reason while submitting. If the submit fails, the guard clears and cancel works again, so the modal cannot get stuck.

Comment on lines 100 to 118
const ticketIdBefore = supportLogic.values.lastSubmittedTicketId
setTicketIdBeforeSubmission(ticketIdBefore)
setPendingTicketSubmission(true)
try {
await supportLogic.asyncActions.submitSendSupportRequest()
await supportLogic.asyncActions.submitSupportTicket({
...sendSupportRequest,
name: user?.first_name ?? sendSupportRequest.name ?? 'name not set',
email: user?.email ?? sendSupportRequest.email ?? '',
message: finalMessage,
})
} catch {
// Failure is detected below via the unchanged ticket id
}
// Success is handled by the effect watching lastSubmittedTicketId. If no ticket was created,
// the submit failed — stop the spinner so the user can retry (the error toast already showed).
if (supportLogic.values.lastSubmittedTicketId === ticketIdBefore) {
const ticketIdAfter = supportLogic.values.lastSubmittedTicketId
if (ticketIdAfter && ticketIdAfter !== ticketIdBefore) {
handleTicketCreated(ticketIdAfter)
} else {
// Submit failed (the error toast already showed) — allow a retry with the note untouched
submitInFlightRef.current = false
setIsSubmitting(false)
setPendingTicketSubmission(false)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Success detection depends on lastSubmittedTicketId changing, but the Zendesk sendBeacon fallback never sets it

consider bug

Why we think it's a valid issue
  • Checked: All three success branches of submitSupportTicket in supportLogic.ts, the PR-head success-detection in handleSupportFormSubmit, and the base (pre-PR) success-detection to determine regression status.
  • Found: The Beacon-success branch (supportLogic.ts:1000-1018) calls capture/lemonToast.success/closeSupportForm()/resetSendSupportRequest() and returns but never calls setLastSubmittedTicketId, whereas the fetch-success (:1062) and conversations-success (:795) branches do. PR-head handleSupportFormSubmit infers success solely from ticketIdAfter !== ticketIdBefore, so a beacon-success reads as failure (hasSubmitted stays false, modal stays open, no confirmation appended, retry invited) → possible duplicate. Diagnosis confirmed accurate.
  • Impact: Real but a rare edge within a deprecated path — reached only on the Zendesk fallback (conversations flag off or extension not loaded) AND when fetch returns non-ok but navigator.sendBeacon succeeds (the Firefox strict-privacy case). The duplicate is also probabilistic: sendBeacon true only means the request was queued, not accepted, so a duplicate only results when the beacon ticket actually lands and the user resubmits.
  • Priority: Lowering should_fixconsider: (1) not a regression — the beacon branch has never set the id and the base TicketPrompt already detected success via the same lastSubmittedTicketId-diff, so the PR inherits a pre-existing latent gap rather than introducing one; (2) the trigger is a rare browser/network edge inside a Zendesk fallback the codebase describes as being retired; (3) the fix lands in unchanged supportLogic.ts, beyond this diff's scope. Real and in the PR's domain, so kept on record, but below the should-fix bar.
Issue description

handleSupportFormSubmit treats the ticket as created only when lastSubmittedTicketId changes (ticketIdAfter !== ticketIdBefore); otherwise it falls into the 'submit failed, allow a retry' branch. In supportLogic.ts's submitSupportTicket listener, the Zendesk fallback's sendBeacon success branch (hit when fetch resolves with a non-ok response but navigator.sendBeacon still succeeds — the code's own comment references Firefox's strict privacy mode) shows a success toast and calls closeSupportForm()/resetSendSupportRequest(), but never calls actions.setLastSubmittedTicketId(...) (unlike the fetch-success and conversations-success branches). From TicketPrompt's point of view this looks identical to a real failure: hasSubmitted stays false, the modal stays open, no 'I've created a support ticket for you' confirmation is appended to the thread (so ticketUtils.getTicketPromptData/getTicketSummaryData will still say a ticket is needed on a later /ticket invocation too), and the user is invited to resubmit — filing a genuine duplicate ticket for a submission that already succeeded via Beacon.

Suggested fix

Have the Beacon-success branch in supportLogic.ts also call actions.setLastSubmittedTicketId(...) (a synthetic/local id works since the real Zendesk id isn't available from a fire-and-forget beacon), or replace the ticket-id diff with an explicit success boolean returned/set by submitSupportTicket so TicketPrompt doesn't have to infer success from an id it can't rely on for every code path.

Prompt to fix with AI (copy-paste)
## Context
@frontend/src/scenes/max/TicketPrompt.tsx#L100-118

<issue_description>
`handleSupportFormSubmit` treats the ticket as created only when `lastSubmittedTicketId` changes (`ticketIdAfter !== ticketIdBefore`); otherwise it falls into the 'submit failed, allow a retry' branch. In `supportLogic.ts`'s `submitSupportTicket` listener, the Zendesk fallback's `sendBeacon` success branch (hit when `fetch` resolves with a non-ok response but `navigator.sendBeacon` still succeeds — the code's own comment references Firefox's strict privacy mode) shows a success toast and calls `closeSupportForm()`/`resetSendSupportRequest()`, but never calls `actions.setLastSubmittedTicketId(...)` (unlike the `fetch`-success and conversations-success branches). From `TicketPrompt`'s point of view this looks identical to a real failure: `hasSubmitted` stays `false`, the modal stays open, no 'I've created a support ticket for you' confirmation is appended to the thread (so `ticketUtils.getTicketPromptData`/`getTicketSummaryData` will still say a ticket is needed on a later /ticket invocation too), and the user is invited to resubmit — filing a genuine duplicate ticket for a submission that already succeeded via Beacon.
</issue_description>

<issue_validation>
- **Checked:** All three success branches of `submitSupportTicket` in `supportLogic.ts`, the PR-head success-detection in `handleSupportFormSubmit`, and the base (pre-PR) success-detection to determine regression status.
- **Found:** The Beacon-success branch (`supportLogic.ts:1000-1018`) calls `capture`/`lemonToast.success`/`closeSupportForm()`/`resetSendSupportRequest()` and returns but never calls `setLastSubmittedTicketId`, whereas the fetch-success (`:1062`) and conversations-success (`:795`) branches do. PR-head `handleSupportFormSubmit` infers success solely from `ticketIdAfter !== ticketIdBefore`, so a beacon-success reads as failure (`hasSubmitted` stays false, modal stays open, no confirmation appended, retry invited) → possible duplicate. Diagnosis confirmed accurate.
- **Impact:** Real but a rare edge within a deprecated path — reached only on the Zendesk fallback (conversations flag off or extension not loaded) AND when `fetch` returns non-ok but `navigator.sendBeacon` succeeds (the Firefox strict-privacy case). The duplicate is also probabilistic: `sendBeacon` true only means the request was queued, not accepted, so a duplicate only results when the beacon ticket actually lands and the user resubmits.
- **Priority:** Lowering `should_fix` → `consider`: (1) not a regression — the beacon branch has never set the id and the base `TicketPrompt` already detected success via the same `lastSubmittedTicketId`-diff, so the PR inherits a pre-existing latent gap rather than introducing one; (2) the trigger is a rare browser/network edge inside a Zendesk fallback the codebase describes as being retired; (3) the fix lands in unchanged `supportLogic.ts`, beyond this diff's scope. Real and in the PR's domain, so kept on record, but below the should-fix bar.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Have the Beacon-success branch in `supportLogic.ts` also call `actions.setLastSubmittedTicketId(...)` (a synthetic/local id works since the real Zendesk id isn't available from a fire-and-forget beacon), or replace the ticket-id diff with an explicit success boolean returned/set by `submitSupportTicket` so `TicketPrompt` doesn't have to infer success from an id it can't rely on for every code path.
</potential_solution>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not addressing this one: the sendBeacon branch exists only inside the retired Zendesk fallback, and only fires when fetch returns non-ok and the beacon is queued (the Firefox strict-privacy case). Support now runs on conversations, so this edge is confined to a path that is going away. Behavior matches the base branch here (the pre-PR success detection also keyed off lastSubmittedTicketId, which this branch never set).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: the Zendesk fallback is staying in service for now, so this is fixed in 9961928 after all. The beacon-success branch now calls setLastSubmittedTicketId with the client-generated uuid (the same id already embedded in the ticket subject), so a beacon-delivered ticket reads as success, the modal closes, and no retry is invited.

@slshults slshults left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@christiaan-ph A couple of the things spotted by ReviewHog look like they're worth addressing (at this point, I guess the Zendesk fallbacks are becoming less important though.)

Address review findings on the /ticket modal:

- cancelling the modal (esc, X, overlay, or the button) while a submit
  was in flight cleared the re-entry guard, so reopening and submitting
  again could file a duplicate ticket; cancel is now a no-op until the
  request settles and the button shows why
- pressing enter on the issue input after a ticket was created reopened
  the dead modal; openSupportModal now respects hasSubmitted

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@christiaan-ph

Copy link
Copy Markdown
Contributor Author

@slshults Fixed the two that touch the conversations path (04fa82e), skipped the two Zendesk-only ones. Replied on each comment with details.

@christiaan-ph
christiaan-ph requested a review from slshults July 23, 2026 09:26
The Zendesk fallback is still in service, so address the two findings
previously deferred as legacy-only:

- the direct submitSupportTicket call skipped the kea-forms validator,
  so clearing the topic on the Zendesk form variant silently routed the
  ticket to General; the triage fields are now checked before submit
- the sendBeacon success branch never set lastSubmittedTicketId, so a
  beacon-delivered ticket read as a failure to the /ticket modal,
  leaving it open and inviting a duplicate; the client-generated uuid
  now marks success, matching the id already embedded in the subject

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@christiaan-ph

Copy link
Copy Markdown
Contributor Author

@slshults it turns out that actually we are keeping Zendesk for now as a fallback, so I've made these fixes too!

@skoob13 skoob13 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

@christiaan-ph

Copy link
Copy Markdown
Contributor Author

@slshults do you mind re-reviewing? I can't merge until you approve!

@posthog-bot-comment-resolver

Copy link
Copy Markdown

🔀 Tried to auto-resolve conflicts with master but this one needs a human.

I won't retry until the branch or master moves.

Resolve conflict in TicketPrompt.tsx: keep this branch's rewritten submit
flow (submitInFlightRef/hasSubmitted guards, synchronous handleTicketCreated,
ticketUtils) and drop the local posthog.capture for
posthog_ai_support_ticket_created. Master's #73525 moved that capture into
supportLogic.submitSupportTicket, which this flow already calls, so keeping
the local capture would double-fire the event. The ai_conversation_id and
ai_trace_id fields master added to resetSendSupportRequest auto-merged and
feed the supportLogic capture.
@github-actions
github-actions Bot requested a deployment to preview-pr-72932 July 24, 2026 15:45 In progress
@christiaan-ph
christiaan-ph merged commit be1c5e6 into master Jul 24, 2026
198 checks passed
@christiaan-ph
christiaan-ph deleted the christiaan-ph/fix-posthog-ai-ticket-duplicate-blank branch July 24, 2026 16:05
@deployment-status-posthog

deployment-status-posthog Bot commented Jul 24, 2026

Copy link
Copy Markdown

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-07-24 16:43 UTC Run
prod-us ✅ Deployed 2026-07-24 17:00 UTC Run
prod-eu ✅ Deployed 2026-07-24 17:00 UTC Run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature/conversations Conversations (support inbox + widget) reviewhog ($$$) Reviews pull requests before humans do team/conversations Conversations team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants