From 2278f06d691152e5834f97a18fc1490e257219dc Mon Sep 17 00:00:00 2001 From: Roman Korvatskyi Date: Mon, 24 Aug 2026 15:53:16 +0200 Subject: [PATCH 1/5] feat(tickets): lock status changes while an approval request is pending A Tech Required ticket with a pending tool-approval request cannot change status - the server rejects every transition (409, openframe-saas-tenant#2764) and returns empty availableTransitions. Mirror the lock in the UI instead of letting users hit the error: - detail page: the inline status changer renders as a locked tag with the reason in a tooltip; handleTransition guards the programmatic path; the cached ticket refetches right after approve/reject so the lock releases - board: dragging a locked card out of its lane toasts the reason and snaps back; the Take Over trigger on the assignee avatar toasts instead of opening the modal (the board fragment now selects statusDefinition.kind) - take-over: openTakeOver has a safety-net guard, and both Start Direct Chat buttons disable with the tooltip while the AI still works a locked ticket - edit form: the status field disables with the tooltip The lock signal is Ticket.pendingApproval (now selected by GET_TICKET_QUERY), which uses the same pending semantics as the server-side lock. Requires @flamingo-stack/openframe-frontend-core with TicketStatusTag disabled/disabledReason (openframe-oss-lib#1898) - version bump follows the lib release. Co-Authored-By: Claude Fable 5 --- .../create-edit/ticket-form-fields.tsx | 36 +++++++- .../components/ticket-details-view.tsx | 92 +++++++++++++++---- .../tickets/components/tickets-board.tsx | 50 +++++++--- .../(app)/tickets/queries/ticket-queries.ts | 5 + src/app/(app)/tickets/types/ticket.types.ts | 2 + src/app/(app)/tickets/utils/status-lock.ts | 13 +++ 6 files changed, 162 insertions(+), 36 deletions(-) create mode 100644 src/app/(app)/tickets/utils/status-lock.ts diff --git a/src/app/(app)/tickets/components/create-edit/ticket-form-fields.tsx b/src/app/(app)/tickets/components/create-edit/ticket-form-fields.tsx index 1e7cea9e..cb2d0b2d 100644 --- a/src/app/(app)/tickets/components/create-edit/ticket-form-fields.tsx +++ b/src/app/(app)/tickets/components/create-edit/ticket-form-fields.tsx @@ -1,6 +1,15 @@ 'use client'; -import { Autocomplete, FileUpload, Input, Label } from '@flamingo-stack/openframe-frontend-core/components/ui'; +import { + Autocomplete, + FileUpload, + Input, + Label, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@flamingo-stack/openframe-frontend-core/components/ui'; import { useDebounce } from '@flamingo-stack/openframe-frontend-core/hooks'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { Controller, type UseFormReturn } from 'react-hook-form'; @@ -12,6 +21,7 @@ import { useTicketStatusesQuery } from '../../statuses/hooks/use-ticket-statuses import type { CreateTicketFormData } from '../../types/create-ticket.types'; import type { Ticket } from '../../types/ticket.types'; import { resolveCurrentStatus } from '../../utils/resolve-current-status'; +import { isStatusLockedByPendingApproval, STATUS_LOCKED_BY_APPROVAL_REASON } from '../../utils/status-lock'; import { TICKET_STATUS_KIND } from '../../utils/ticket-statistics'; import { avatarStartAdornment, renderAvatarOption } from '../avatar-autocomplete'; import { renderStatusOption, type StatusOption, statusStartAdornment } from '../status-autocomplete'; @@ -77,6 +87,15 @@ export function TicketFormFields({ .map(s => ({ label: s.name, value: s.id, color: s.color })); }, [isEditMode, ticket, statusesQuery.data]); + // Tech Required + pending approval: the server rejects any transition, so + // the status field is locked with the reason in a tooltip. + const isStatusLocked = + isEditMode && + isStatusLockedByPendingApproval({ + statusKind: ticket?.statusDefinition?.kind, + pendingApproval: ticket?.pendingApproval, + }); + const selectedStatusId = watch('statusId'); // New ticket: pre-select the first CUSTOM status once options load (Tech Required is // selectable but must not become the default). @@ -215,7 +234,7 @@ export function TicketFormFields({ control={control} render={({ field, fieldState }) => { const selectedStatus = statusOptions.find(o => o.value === field.value); - return ( + const statusField = ( field.onChange(val)} placeholder="Select Status" loading={!isEditMode && statusesQuery.isLoading} - disabled={isFaeForm} + disabled={isFaeForm || isStatusLocked} error={fieldState.error?.message} invalid={!!fieldState.error} startAdornment={statusStartAdornment(selectedStatus)} renderOption={renderStatusOption} /> ); + if (!isStatusLocked) return statusField; + return ( + + + +
{statusField}
+
+ {STATUS_LOCKED_BY_APPROVAL_REASON} +
+
+ ); }} /> diff --git a/src/app/(app)/tickets/components/ticket-details-view.tsx b/src/app/(app)/tickets/components/ticket-details-view.tsx index 179814d8..4e3b45cc 100644 --- a/src/app/(app)/tickets/components/ticket-details-view.tsx +++ b/src/app/(app)/tickets/components/ticket-details-view.tsx @@ -39,6 +39,10 @@ import { type TabItem, TabNavigation, TicketInfoSection, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, } from '@flamingo-stack/openframe-frontend-core/components/ui'; import { useToast } from '@flamingo-stack/openframe-frontend-core/hooks'; import { cn } from '@flamingo-stack/openframe-frontend-core/utils'; @@ -99,6 +103,7 @@ import { hasActiveAiDialog } from '../utils/ai-dialog'; import { isResolvedStatusId } from '../utils/is-resolved-status'; import { latestAssistantModel } from '../utils/latest-assistant-model'; import { ticketsQueryKeys } from '../utils/query-keys'; +import { isStatusLockedByPendingApproval, STATUS_LOCKED_BY_APPROVAL_REASON } from '../utils/status-lock'; import { TICKET_STATUS_KIND } from '../utils/ticket-statistics'; import { ReopenTicketModal, type ReopenTicketTarget } from './reopen-ticket-modal'; import { TakeOverTicketModal, type TakeOverTicketTarget } from './take-over-ticket-modal'; @@ -286,9 +291,20 @@ function TicketDetailsContent({ ticketId, technicianChatEnabled: isTechnicianCha const openTakeOver = useCallback( (prefill?: Omit) => { if (!dialog) return; + // Take-over is a status transition, so the pending-approval lock blocks + // it too (server-enforced). Safety net for every entry point. + if (isStatusLockedByPendingApproval(dialog)) { + toast({ + title: 'Status Locked', + description: STATUS_LOCKED_BY_APPROVAL_REASON, + variant: 'destructive', + duration: 5000, + }); + return; + } setTakeOverTarget({ ticket: dialog, ...prefill }); }, - [dialog], + [dialog, toast], ); const handleAssign = useCallback( @@ -536,6 +552,9 @@ function TicketDetailsContent({ ticketId, technicianChatEnabled: isTechnicianCha const handleTransition = useCallback( (toStatusId: string) => { if (!dialog || transitionTicket.isPending) return; + // Server-enforced lock (Tech Required + pending approval): the dropdown + // is disabled in this state, but guard the programmatic path too. + if (isStatusLockedByPendingApproval(dialog)) return; // Leaving a terminal status for a WORKING one is a REOPEN, not a plain // move: it goes through the confirmation modal (target status + assignee // + reason) instead of firing the transition directly. Gated on @@ -585,6 +604,9 @@ function TicketDetailsContent({ ticketId, technicianChatEnabled: isTechnicianCha updateApprovalStatusInMessages('admin', requestId, status); try { await mutate(requestId); + // Resolving the approval releases the status lock and changes the + // available transitions - refresh the cached ticket right away. + refetchDialog(); } catch (error) { toast({ title: approving ? 'Approval Failed' : 'Rejection Failed', @@ -599,7 +621,7 @@ function TicketDetailsContent({ ticketId, technicianChatEnabled: isTechnicianCha }); } }, - [handleApproveRequest, handleRejectRequest, toast, updateApprovalStatusInMessages], + [handleApproveRequest, handleRejectRequest, toast, updateApprovalStatusInMessages, refetchDialog], ); const handleApprove = useCallback( @@ -787,6 +809,12 @@ function TicketDetailsContent({ ticketId, technicianChatEnabled: isTechnicianCha statusName: dialog.statusName, statusColor: dialog.statusColor, }); + // Tech Required + pending approval: the server rejects any transition, so + // the inline changer renders as a locked tag with the reason in a tooltip. + const isStatusLocked = isStatusLockedByPendingApproval(dialog); + // Take-over transitions the status, so the same lock blocks starting a + // direct chat while the AI still works the ticket. + const isTakeOverBlocked = isStatusLocked && hasActiveAiDialog(dialog); const statusInfoProps = { status: statusTag.status, statusLabel: statusTag.label, @@ -794,6 +822,8 @@ function TicketDetailsContent({ ticketId, technicianChatEnabled: isTechnicianCha statusOptions: dialog.availableTransitions, onStatusSelect: handleTransition, isStatusPending: transitionTicket.isPending, + isStatusDisabled: isStatusLocked, + statusDisabledReason: isStatusLocked ? STATUS_LOCKED_BY_APPROVAL_REASON : undefined, }; const hasClientChat = !isAdminOwner; @@ -871,6 +901,8 @@ function TicketDetailsContent({ ticketId, technicianChatEnabled: isTechnicianCha options: dialog.availableTransitions, onSelect: handleTransition, isPending: transitionTicket.isPending, + disabled: isStatusLocked, + disabledReason: isStatusLocked ? STATUS_LOCKED_BY_APPROVAL_REASON : undefined, }, }, ]; @@ -962,15 +994,24 @@ function TicketDetailsContent({ ticketId, technicianChatEnabled: isTechnicianCha

The AI assistant will be stopped and you will be able to communicate with the user directly.

- + + + + + + + + {isTakeOverBlocked && {STATUS_LOCKED_BY_APPROVAL_REASON}} + + )} {!isClosed && isDirectMode && ( @@ -1318,15 +1359,26 @@ function TicketDetailsContent({ ticketId, technicianChatEnabled: isTechnicianCha {/* Direct Chat: Start button or ChatInput */} {!isClosed && !isDirectMode && ( - + + + + + + + + {isTakeOverBlocked && {STATUS_LOCKED_BY_APPROVAL_REASON}} + + )} {!isClosed && isDirectMode && ( { - if (aiOwnedTicketIdsRef.current.has(ticket.id)) return null; - const dialog = dialogByIdRef.current.get(ticket.id); - const aiActive = !!dialog && hasActiveAiDialog(dialog); - return ( - setTakeOverTarget({ ticket: dialog }) : undefined} - /> - ); - }, []); + const renderAssignSlot = useCallback( + (ticket: BoardTicket) => { + if (aiOwnedTicketIdsRef.current.has(ticket.id)) return null; + const dialog = dialogByIdRef.current.get(ticket.id); + const aiActive = !!dialog && hasActiveAiDialog(dialog); + if (!aiActive) return ; + // Assigning an AI-worked ticket is a take-over, which the pending-approval + // lock blocks (server-enforced) - explain instead of opening the modal. + const handleTakeOver = isStatusLockedByPendingApproval(dialog) + ? () => + toast({ + title: 'Status Locked', + description: STATUS_LOCKED_BY_APPROVAL_REASON, + variant: 'destructive', + duration: 5000, + }) + : () => setTakeOverTarget({ ticket: dialog }); + return ; + }, + [toast], + ); const handleChange = useCallback( (change: BoardChange) => { if (change.fromColumnId !== change.toColumnId) { + // Tech Required + pending approval: the server rejects the transition, + // so block the drop up front - toast the reason and snap the card back. + const sourceKind = statuses.find(s => s.id === change.fromColumnId)?.kind; + if (sourceKind === TICKET_STATUS_KIND.TECH_REQUIRED && dialogById.get(change.ticketId)?.pendingApproval) { + toast({ + title: 'Status Locked', + description: STATUS_LOCKED_BY_APPROVAL_REASON, + variant: 'destructive', + duration: 5000, + }); + setBoardResetNonce(nonce => nonce + 1); + return; + } // Dragging OUT of the Resolved lane is a REOPEN, not a plain move: it // goes through the confirmation modal (target status + assignee + // reason) instead of committing the drop. The optimistic move never // runs, so the card snaps back until the modal confirms. Gated on // `ai-resolution` — with the flag off the drop commits directly (legacy). if (featureFlags.aiResolution.enabled()) { - const sourceKind = statuses.find(s => s.id === change.fromColumnId)?.kind; if (sourceKind === 'RESOLVED') { setReopenTarget({ ticketId: change.ticketId, initialStatusId: change.toColumnId }); return; @@ -444,7 +468,7 @@ export function TicketsBoard({ beforeTicketId: change.beforeTicketId, }); }, - [moveTicket, statuses, dialogById], + [moveTicket, statuses, dialogById, toast], ); const showEmptyState = diff --git a/src/app/(app)/tickets/queries/ticket-queries.ts b/src/app/(app)/tickets/queries/ticket-queries.ts index dbe49236..6acef4af 100644 --- a/src/app/(app)/tickets/queries/ticket-queries.ts +++ b/src/app/(app)/tickets/queries/ticket-queries.ts @@ -112,6 +112,10 @@ export const GET_TICKET_QUERY = ` name color } + pendingApproval { + id + approvalType + } creationSource owner { ... on ClientTicketOwner { @@ -291,6 +295,7 @@ const boardCardTicketFragment = () => ` id name color + kind } availableTransitions { id diff --git a/src/app/(app)/tickets/types/ticket.types.ts b/src/app/(app)/tickets/types/ticket.types.ts index b4a7ab39..67af1104 100644 --- a/src/app/(app)/tickets/types/ticket.types.ts +++ b/src/app/(app)/tickets/types/ticket.types.ts @@ -74,6 +74,8 @@ export interface Ticket { status: TicketStatus; statusDefinition?: TicketStatusRef & { kind: string }; availableTransitions?: TicketStatusRef[]; + // Latest pending tool-approval request (locks Tech Required status changes). + pendingApproval?: { id: string; approvalType?: string } | null; creationSource?: string; owner: TicketOwner; deviceId?: string; diff --git a/src/app/(app)/tickets/utils/status-lock.ts b/src/app/(app)/tickets/utils/status-lock.ts new file mode 100644 index 00000000..b8b516de --- /dev/null +++ b/src/app/(app)/tickets/utils/status-lock.ts @@ -0,0 +1,13 @@ +import { TICKET_STATUS_KIND } from './ticket-statistics'; + +// Mirrors the server rule (TicketTransitionPolicyValidator): a Tech Required +// ticket with a pending tool-approval request cannot change status until the +// approval is resolved. `pendingApproval` already excludes escalate-to-human +// offers on the backend, so its presence matches the lock semantics exactly. +export const STATUS_LOCKED_BY_APPROVAL_REASON = 'Status is locked while an approval request is pending.'; + +export function isStatusLockedByPendingApproval( + ticket: { statusKind?: string; pendingApproval?: unknown } | null | undefined, +): boolean { + return !!ticket && ticket.statusKind === TICKET_STATUS_KIND.TECH_REQUIRED && !!ticket.pendingApproval; +} From 134b16038d8fcf9679e637f87c69fdb91beb736a Mon Sep 17 00:00:00 2001 From: Roman Korvatskyi Date: Mon, 24 Aug 2026 16:30:11 +0200 Subject: [PATCH 2/5] chore(deps): bump @flamingo-stack/openframe-frontend-core to 0.0.564 Brings TicketStatusTag disabled/disabledReason and the TicketInfoSection pass-through for the pending-approval status lock (openframe-oss-lib#1898). Co-Authored-By: Claude Fable 5 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 27c6970b..d5651c22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@flamingo-stack/openframe-frontend-core": "^0.0.563", + "@flamingo-stack/openframe-frontend-core": "^0.0.564", "@hookform/resolvers": "^5.2.2", "@lezer/highlight": "^1.2.3", "@tanstack/react-query": "^5.90.16", @@ -898,9 +898,9 @@ } }, "node_modules/@flamingo-stack/openframe-frontend-core": { - "version": "0.0.563", - "resolved": "https://registry.npmjs.org/@flamingo-stack/openframe-frontend-core/-/openframe-frontend-core-0.0.563.tgz", - "integrity": "sha512-3zVbqxeOEilEjwrqAmcMNRJRW3JDJ6SSRMYfjAxooDLOD++RcAoxsDetlB8+1kg2ps/cKZz96z1Lvx2RE8vs0g==", + "version": "0.0.564", + "resolved": "https://registry.npmjs.org/@flamingo-stack/openframe-frontend-core/-/openframe-frontend-core-0.0.564.tgz", + "integrity": "sha512-o2n0OW7EQvuYXVZuEfX9sRJv2OxUh1NxSmqzQA9nb/lXxGre6W0PEMX8VEYLttfyEdFEN4rKEhc7ZpdCb0eibQ==", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^3.0.0", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^3.0.1", diff --git a/package.json b/package.json index 40b17b8c..3575fbf8 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@flamingo-stack/openframe-frontend-core": "^0.0.563", + "@flamingo-stack/openframe-frontend-core": "^0.0.564", "@hookform/resolvers": "^5.2.2", "@lezer/highlight": "^1.2.3", "@tanstack/react-query": "^5.90.16", From 06b135ee0c68424de38259e59b703e2de7b69e16 Mon Sep 17 00:00:00 2001 From: Roman Korvatskyi Date: Mon, 24 Aug 2026 18:15:41 +0200 Subject: [PATCH 3/5] refactor(tickets): board drag guard reuses the shared status-lock predicate The drop interception checked statusDefinition kind + pendingApproval directly; isStatusLockedByPendingApproval is the single definition of the lock and works on board dialogs since the fragment selects the kind. Co-Authored-By: Claude Fable 5 --- src/app/(app)/tickets/components/tickets-board.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/(app)/tickets/components/tickets-board.tsx b/src/app/(app)/tickets/components/tickets-board.tsx index 0764f12c..e935cf4e 100644 --- a/src/app/(app)/tickets/components/tickets-board.tsx +++ b/src/app/(app)/tickets/components/tickets-board.tsx @@ -32,7 +32,6 @@ import type { Dialog } from '../types/dialog.types'; import { hasActiveAiDialog } from '../utils/ai-dialog'; import { dialogsQueryKeys, ticketsQueryKeys } from '../utils/query-keys'; import { isStatusLockedByPendingApproval, STATUS_LOCKED_BY_APPROVAL_REASON } from '../utils/status-lock'; -import { TICKET_STATUS_KIND } from '../utils/ticket-statistics'; import { AssigneeFilter } from './assignee-filter'; import { BoardAssigneePicker } from './board-assignee-picker'; import { BoardColumnSubscriber, type BoardColumnUpdate } from './board-column-subscriber'; @@ -429,7 +428,7 @@ export function TicketsBoard({ // Tech Required + pending approval: the server rejects the transition, // so block the drop up front - toast the reason and snap the card back. const sourceKind = statuses.find(s => s.id === change.fromColumnId)?.kind; - if (sourceKind === TICKET_STATUS_KIND.TECH_REQUIRED && dialogById.get(change.ticketId)?.pendingApproval) { + if (isStatusLockedByPendingApproval(dialogById.get(change.ticketId))) { toast({ title: 'Status Locked', description: STATUS_LOCKED_BY_APPROVAL_REASON, From 9d8aac2a476b3fd3b9204fcbb0b0a410b149767b Mon Sep 17 00:00:00 2001 From: Roman Korvatskyi Date: Mon, 24 Aug 2026 19:53:04 +0200 Subject: [PATCH 4/5] chore(deps): bump @flamingo-stack/openframe-frontend-core to 0.0.565 Co-Authored-By: Claude Fable 5 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index d5651c22..f0413621 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@flamingo-stack/openframe-frontend-core": "^0.0.564", + "@flamingo-stack/openframe-frontend-core": "^0.0.565", "@hookform/resolvers": "^5.2.2", "@lezer/highlight": "^1.2.3", "@tanstack/react-query": "^5.90.16", @@ -898,9 +898,9 @@ } }, "node_modules/@flamingo-stack/openframe-frontend-core": { - "version": "0.0.564", - "resolved": "https://registry.npmjs.org/@flamingo-stack/openframe-frontend-core/-/openframe-frontend-core-0.0.564.tgz", - "integrity": "sha512-o2n0OW7EQvuYXVZuEfX9sRJv2OxUh1NxSmqzQA9nb/lXxGre6W0PEMX8VEYLttfyEdFEN4rKEhc7ZpdCb0eibQ==", + "version": "0.0.565", + "resolved": "https://registry.npmjs.org/@flamingo-stack/openframe-frontend-core/-/openframe-frontend-core-0.0.565.tgz", + "integrity": "sha512-hp7dyUIMFdKQ4UaI4M9poNXfjm/+J0f0eeQFkWXOdyuepGYRzRuS9WeqpCmf9DKkikeCXlPYVOSKloJmMehjPg==", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^3.0.0", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^3.0.1", diff --git a/package.json b/package.json index 3575fbf8..d6e63409 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@flamingo-stack/openframe-frontend-core": "^0.0.564", + "@flamingo-stack/openframe-frontend-core": "^0.0.565", "@hookform/resolvers": "^5.2.2", "@lezer/highlight": "^1.2.3", "@tanstack/react-query": "^5.90.16", From fb7144b5de730101993093a2baf7ab16033a1e66 Mon Sep 17 00:00:00 2001 From: Roman Korvatskyi Date: Thu, 24 Sep 2026 15:18:53 +0200 Subject: [PATCH 5/5] fix(tickets): drop the dead take-over lock guards after the AI-active redesign hasActiveAiDialog now means statusKind AI_ASSISTANCE only (86ak573q9), and a locked ticket is always Tech Required - the openTakeOver guard and the Start Direct Chat disable could never fire. Starting a direct chat on a locked ticket is correct anyway: it changes no status, and the handoff cancels the pending approval server-side, which unlocks the ticket by design. Co-Authored-By: Claude Fable 5 --- .../components/ticket-details-view.tsx | 49 +++++-------------- 1 file changed, 12 insertions(+), 37 deletions(-) diff --git a/src/app/(app)/tickets/components/ticket-details-view.tsx b/src/app/(app)/tickets/components/ticket-details-view.tsx index ea20b8f4..81a40ef5 100644 --- a/src/app/(app)/tickets/components/ticket-details-view.tsx +++ b/src/app/(app)/tickets/components/ticket-details-view.tsx @@ -31,10 +31,6 @@ import { SimpleMarkdownRenderer, type TabItem, TabNavigation, - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, } from '@flamingo-stack/openframe-frontend-core/components/ui'; import { useLgUp, useToast } from '@flamingo-stack/openframe-frontend-core/hooks'; import { useQueryClient } from '@tanstack/react-query'; @@ -232,20 +228,9 @@ export function TicketDetailsView({ ticketId }: TicketDetailsViewProps) { const openTakeOver = useCallback( (prefill?: Omit) => { if (!dialog) return; - // Take-over is a status transition, so the pending-approval lock blocks - // it too (server-enforced). Safety net for every entry point. - if (isStatusLockedByPendingApproval(dialog)) { - toast({ - title: 'Status Locked', - description: STATUS_LOCKED_BY_APPROVAL_REASON, - variant: 'destructive', - duration: 5000, - }); - return; - } setTakeOverTarget({ ticket: dialog, ...prefill }); }, - [dialog, toast], + [dialog], ); const handleAssign = useCallback( @@ -643,10 +628,9 @@ export function TicketDetailsView({ ticketId }: TicketDetailsViewProps) { }); // Tech Required + pending approval: the server rejects any transition, so // the inline changer renders as a locked tag with the reason in a tooltip. + // Starting a direct chat stays available - it changes no status, and the + // handoff cancels the pending approval on the backend, which unlocks. const isStatusLocked = isStatusLockedByPendingApproval(dialog); - // Take-over transitions the status, so the same lock blocks starting a - // direct chat while the AI still works the ticket. - const isTakeOverBlocked = isStatusLocked && hasActiveAiDialog(dialog); const hasClientChat = !isAdminOwner; const hasDescription = !!dialog.description?.trim(); @@ -826,24 +810,15 @@ export function TicketDetailsView({ ticketId }: TicketDetailsViewProps) {

The AI assistant will be stopped and you will be able to communicate with the user directly.

- - - - - - - - {isTakeOverBlocked && {STATUS_LOCKED_BY_APPROVAL_REASON}} - - + )} {!isClosed && isDirectMode && (