@flamingo-stack/openframe-frontend-core 0.0.537-snapshot.20260814102953 → 0.0.537
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chat-protocol/events.d.ts +1 -15
- package/dist/chat-protocol/events.d.ts.map +1 -1
- package/dist/chat-protocol/index.cjs +0 -15
- package/dist/chat-protocol/index.cjs.map +1 -1
- package/dist/chat-protocol/index.js +0 -15
- package/dist/chat-protocol/index.js.map +1 -1
- package/dist/chat-protocol/nats-decoder.d.ts.map +1 -1
- package/dist/{chunk-KUKHUECE.js → chunk-3GXMS4OP.js} +2 -2
- package/dist/{chunk-ZHW5VLHY.js → chunk-5JPSQOZQ.js} +2 -2
- package/dist/{chunk-RBBWKSRC.js → chunk-7EHGP4QK.js} +4367 -4638
- package/dist/chunk-7EHGP4QK.js.map +1 -0
- package/dist/{chunk-4BK5Z763.js → chunk-7FXZHI5D.js} +2 -2
- package/dist/{chunk-YO5BWF4L.cjs → chunk-7WNJ7RFO.cjs} +14 -14
- package/dist/{chunk-YO5BWF4L.cjs.map → chunk-7WNJ7RFO.cjs.map} +1 -1
- package/dist/{chunk-56QT3JBZ.js → chunk-C4LEH3VE.js} +2 -2
- package/dist/{chunk-BUKQ6B65.cjs → chunk-CATY4DVR.cjs} +87 -87
- package/dist/{chunk-BUKQ6B65.cjs.map → chunk-CATY4DVR.cjs.map} +1 -1
- package/dist/{chunk-UBEVU6LS.js → chunk-CQQXB25K.js} +2 -2
- package/dist/{chunk-73W4L656.js → chunk-CZHCXCWL.js} +2 -2
- package/dist/{chunk-LJW7B6GY.cjs → chunk-DHSVBGOX.cjs} +9 -9
- package/dist/{chunk-LJW7B6GY.cjs.map → chunk-DHSVBGOX.cjs.map} +1 -1
- package/dist/{chunk-EH6LNTOT.js → chunk-HRC56BQW.js} +5 -5
- package/dist/{chunk-C6776X43.cjs → chunk-KKZ2QMQD.cjs} +26 -26
- package/dist/{chunk-C6776X43.cjs.map → chunk-KKZ2QMQD.cjs.map} +1 -1
- package/dist/{chunk-67CKAX7Z.js → chunk-LFMIAPBP.js} +2 -2
- package/dist/{chunk-PFGRHRBO.cjs → chunk-MDP3YKFI.cjs} +5 -5
- package/dist/{chunk-PFGRHRBO.cjs.map → chunk-MDP3YKFI.cjs.map} +1 -1
- package/dist/{chunk-C6NKOHFW.js → chunk-MOIHWYSA.js} +2 -2
- package/dist/{chunk-NE67LT3B.cjs → chunk-OYTM3YXO.cjs} +7 -7
- package/dist/{chunk-NE67LT3B.cjs.map → chunk-OYTM3YXO.cjs.map} +1 -1
- package/dist/{chunk-QU6P2U2Q.cjs → chunk-QPPG3KHW.cjs} +3 -3
- package/dist/{chunk-QU6P2U2Q.cjs.map → chunk-QPPG3KHW.cjs.map} +1 -1
- package/dist/{chunk-QK4SQAYG.js → chunk-SGNWSUL3.js} +6 -6
- package/dist/{chunk-NUHDE7CB.cjs → chunk-SKIM263K.cjs} +37 -37
- package/dist/{chunk-NUHDE7CB.cjs.map → chunk-SKIM263K.cjs.map} +1 -1
- package/dist/{chunk-7CBQWZAH.js → chunk-TCSSYTDN.js} +4 -4
- package/dist/{chunk-R3XO2KO4.cjs → chunk-V4D5MPB7.cjs} +4 -4
- package/dist/{chunk-R3XO2KO4.cjs.map → chunk-V4D5MPB7.cjs.map} +1 -1
- package/dist/{chunk-TWVRCPWY.cjs → chunk-VSIHO7DJ.cjs} +61 -61
- package/dist/{chunk-TWVRCPWY.cjs.map → chunk-VSIHO7DJ.cjs.map} +1 -1
- package/dist/{chunk-ZBRKTF6S.js → chunk-WHUEUACV.js} +2 -2
- package/dist/{chunk-MGYNZ7SA.cjs → chunk-WJIBB3KT.cjs} +31 -31
- package/dist/{chunk-MGYNZ7SA.cjs.map → chunk-WJIBB3KT.cjs.map} +1 -1
- package/dist/{chunk-ZNWW4WAT.cjs → chunk-YEKXTTAC.cjs} +26 -26
- package/dist/{chunk-ZNWW4WAT.cjs.map → chunk-YEKXTTAC.cjs.map} +1 -1
- package/dist/{chunk-BNF6FZLP.js → chunk-YIDGB5KP.js} +4 -4
- package/dist/{chunk-QQ3HUSDJ.cjs → chunk-YMPRMZDD.cjs} +328 -599
- package/dist/chunk-YMPRMZDD.cjs.map +1 -0
- package/dist/{chunk-F4QOOHZL.cjs → chunk-YPI3JIR6.cjs} +11 -11
- package/dist/{chunk-F4QOOHZL.cjs.map → chunk-YPI3JIR6.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +2 -8
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.d.ts +0 -2
- package/dist/components/chat/index.d.ts.map +1 -1
- package/dist/components/chat/index.js +1 -7
- package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -1
- package/dist/components/chat/stream/message-mutations.d.ts.map +1 -1
- package/dist/components/chat/ticket-escalated-message.d.ts +8 -2
- package/dist/components/chat/ticket-escalated-message.d.ts.map +1 -1
- package/dist/components/chat/types/message.types.d.ts +2 -51
- package/dist/components/chat/types/message.types.d.ts.map +1 -1
- package/dist/components/chat/utils/message-segment-accumulator.d.ts +1 -13
- package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
- package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/docs/index.cjs +5 -5
- package/dist/components/docs/index.js +4 -4
- package/dist/components/embeds/index.cjs +3 -3
- package/dist/components/embeds/index.js +2 -2
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/help-center-pages/index.cjs +22 -22
- package/dist/components/help-center-pages/index.js +13 -13
- package/dist/components/index.cjs +132 -144
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +11 -23
- package/dist/components/index.js.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +34 -34
- package/dist/components/meeting-scheduler/index.js +3 -3
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +4 -4
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/index.cjs +2 -14
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.d.ts +0 -1
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +1 -13
- package/dist/components/ui/take-over-ticket-modal.d.ts +0 -5
- package/dist/components/ui/take-over-ticket-modal.d.ts.map +1 -1
- package/dist/index.cjs +2 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1 -13
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +0 -44
- package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +0 -32
- package/src/chat-protocol/events.ts +0 -16
- package/src/chat-protocol/nats-decoder.ts +0 -27
- package/src/components/chat/chat-message-enhanced.tsx +0 -3
- package/src/components/chat/index.ts +0 -2
- package/src/components/chat/stream/chat-stream-reducer.ts +0 -19
- package/src/components/chat/stream/message-mutations.ts +0 -5
- package/src/components/chat/ticket-escalated-message.tsx +32 -10
- package/src/components/chat/types/message.types.ts +1 -60
- package/src/components/chat/utils/message-segment-accumulator.ts +0 -41
- package/src/components/chat/utils/process-historical-messages.ts +0 -31
- package/src/components/ui/index.ts +0 -1
- package/src/components/ui/take-over-ticket-modal.tsx +4 -7
- package/dist/chunk-QQ3HUSDJ.cjs.map +0 -1
- package/dist/chunk-RBBWKSRC.js.map +0 -1
- package/dist/components/chat/ai-assistant-info.d.ts +0 -31
- package/dist/components/chat/ai-assistant-info.d.ts.map +0 -1
- package/dist/components/chat/ticket-event-message.d.ts +0 -24
- package/dist/components/chat/ticket-event-message.d.ts.map +0 -1
- package/dist/components/ui/reopen-ticket-modal.d.ts +0 -42
- package/dist/components/ui/reopen-ticket-modal.d.ts.map +0 -1
- package/src/components/chat/__tests__/ticket-event.test.ts +0 -194
- package/src/components/chat/ai-assistant-info.tsx +0 -71
- package/src/components/chat/ticket-event-message.tsx +0 -79
- package/src/components/ui/reopen-ticket-modal.tsx +0 -174
- package/src/stories/ReopenTicketModal.stories.tsx +0 -114
- package/src/stories/TicketEventMessage.stories.tsx +0 -100
- /package/dist/{chunk-KUKHUECE.js.map → chunk-3GXMS4OP.js.map} +0 -0
- /package/dist/{chunk-ZHW5VLHY.js.map → chunk-5JPSQOZQ.js.map} +0 -0
- /package/dist/{chunk-4BK5Z763.js.map → chunk-7FXZHI5D.js.map} +0 -0
- /package/dist/{chunk-56QT3JBZ.js.map → chunk-C4LEH3VE.js.map} +0 -0
- /package/dist/{chunk-UBEVU6LS.js.map → chunk-CQQXB25K.js.map} +0 -0
- /package/dist/{chunk-73W4L656.js.map → chunk-CZHCXCWL.js.map} +0 -0
- /package/dist/{chunk-EH6LNTOT.js.map → chunk-HRC56BQW.js.map} +0 -0
- /package/dist/{chunk-67CKAX7Z.js.map → chunk-LFMIAPBP.js.map} +0 -0
- /package/dist/{chunk-C6NKOHFW.js.map → chunk-MOIHWYSA.js.map} +0 -0
- /package/dist/{chunk-QK4SQAYG.js.map → chunk-SGNWSUL3.js.map} +0 -0
- /package/dist/{chunk-7CBQWZAH.js.map → chunk-TCSSYTDN.js.map} +0 -0
- /package/dist/{chunk-ZBRKTF6S.js.map → chunk-WHUEUACV.js.map} +0 -0
- /package/dist/{chunk-BNF6FZLP.js.map → chunk-YIDGB5KP.js.map} +0 -0
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { type HTMLAttributes, type ReactNode } from "react";
|
|
2
|
-
export interface AiAssistantInfoProps extends HTMLAttributes<HTMLDivElement> {
|
|
3
|
-
/** Rendered inside the standard 48px framed box (typically a 24px icon). */
|
|
4
|
-
icon?: ReactNode;
|
|
5
|
-
/** Replaces the framed icon box entirely (e.g. a round 48px avatar for the
|
|
6
|
-
* `direct-chat` variant). Wins over `icon` when both are set. */
|
|
7
|
-
leading?: ReactNode;
|
|
8
|
-
title: string;
|
|
9
|
-
/** Secondary line under the title. Omitted → the row is title-only. */
|
|
10
|
-
body?: ReactNode;
|
|
11
|
-
/** Timestamp of the message row this block came in on. */
|
|
12
|
-
timestamp?: Date;
|
|
13
|
-
}
|
|
14
|
-
/**
|
|
15
|
-
* Figma `ai-assistant-info` — the shared in-thread status card: a framed
|
|
16
|
-
* icon (or avatar), a title with a right-aligned time, and a secondary line.
|
|
17
|
-
* One presentational shell for every lifecycle receipt in a chat thread
|
|
18
|
-
* (handoff, technician joined, ticket resolved/reopened, …); the per-event
|
|
19
|
-
* components (`TicketEscalatedMessage`, `TicketEventMessage`) own the copy
|
|
20
|
-
* and icon choice and delegate the chrome here.
|
|
21
|
-
*
|
|
22
|
-
* The body WRAPS, deliberately diverging from the mock's single-line
|
|
23
|
-
* ellipsis: the mock's 72px comes from its placeholder copy fitting one
|
|
24
|
-
* line, while real wire text is a full sentence, and clipping the only
|
|
25
|
-
* explanation of a lifecycle change is not recoverable on touch. The title
|
|
26
|
-
* still truncates — it holds one line so the timestamp is never pushed out
|
|
27
|
-
* of the row at narrow widths.
|
|
28
|
-
*/
|
|
29
|
-
declare const AiAssistantInfo: import("react").ForwardRefExoticComponent<AiAssistantInfoProps & import("react").RefAttributes<HTMLDivElement>>;
|
|
30
|
-
export { AiAssistantInfo };
|
|
31
|
-
//# sourceMappingURL=ai-assistant-info.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"ai-assistant-info.d.ts","sourceRoot":"","sources":["../../../src/components/chat/ai-assistant-info.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAc,KAAK,cAAc,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAIvE,MAAM,WAAW,oBAAqB,SAAQ,cAAc,CAAC,cAAc,CAAC;IAC1E,4EAA4E;IAC5E,IAAI,CAAC,EAAE,SAAS,CAAA;IAChB;sEACkE;IAClE,OAAO,CAAC,EAAE,SAAS,CAAA;IACnB,KAAK,EAAE,MAAM,CAAA;IACb,uEAAuE;IACvE,IAAI,CAAC,EAAE,SAAS,CAAA;IAChB,0DAA0D;IAC1D,SAAS,CAAC,EAAE,IAAI,CAAA;CACjB;AAED;;;;;;;;;;;;;;GAcG;AACH,QAAA,MAAM,eAAe,iHAgCpB,CAAA;AAID,OAAO,EAAE,eAAe,EAAE,CAAA"}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import { type HTMLAttributes } from "react";
|
|
2
|
-
import { type TicketEventSegment } from "./types/message.types";
|
|
3
|
-
export interface TicketEventMessageProps extends HTMLAttributes<HTMLDivElement> {
|
|
4
|
-
data: TicketEventSegment['data'];
|
|
5
|
-
/** Timestamp of the message row this block came in on. */
|
|
6
|
-
timestamp?: Date;
|
|
7
|
-
}
|
|
8
|
-
/**
|
|
9
|
-
* Ticket lifecycle receipt — resolved / reopened / an unknown future kind.
|
|
10
|
-
* Figma `ai-assistant-info` (type=resolved-fae | resolved-tech |
|
|
11
|
-
* reopened-fae | reopened-tech | reopened-reason).
|
|
12
|
-
*
|
|
13
|
-
* Copy is composed client-side from the event's fields (unlike
|
|
14
|
-
* `ticket_escalated`, whose body the backend authors):
|
|
15
|
-
* RESOLVED → "Resolved by {actorName}." under a green check;
|
|
16
|
-
* REOPENED → the reason when the wire carries one, otherwise who picks the
|
|
17
|
-
* conversation up next, inferred from `actorType` (an AI actor keeps
|
|
18
|
-
* assisting; anything else means a technician will reply);
|
|
19
|
-
* unknown kind → a neutral info line: humanized kind as the title, the
|
|
20
|
-
* reason or actor as the body. Never dropped — the vocabulary is open.
|
|
21
|
-
*/
|
|
22
|
-
declare const TicketEventMessage: import("react").ForwardRefExoticComponent<TicketEventMessageProps & import("react").RefAttributes<HTMLDivElement>>;
|
|
23
|
-
export { TicketEventMessage };
|
|
24
|
-
//# sourceMappingURL=ticket-event-message.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"ticket-event-message.d.ts","sourceRoot":"","sources":["../../../src/components/chat/ticket-event-message.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,OAAO,CAAA;AAGvD,OAAO,EAAqB,KAAK,kBAAkB,EAAE,MAAM,uBAAuB,CAAA;AAoBlF,MAAM,WAAW,uBAAwB,SAAQ,cAAc,CAAC,cAAc,CAAC;IAC7E,IAAI,EAAE,kBAAkB,CAAC,MAAM,CAAC,CAAA;IAChC,0DAA0D;IAC1D,SAAS,CAAC,EAAE,IAAI,CAAA;CACjB;AAED;;;;;;;;;;;;;GAaG;AACH,QAAA,MAAM,kBAAkB,oHA6BvB,CAAA;AAID,OAAO,EAAE,kBAAkB,EAAE,CAAA"}
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import * as React from 'react';
|
|
2
|
-
import { type TakeOverAssigneeOption, type TakeOverStatusOption } from './take-over-ticket-modal';
|
|
3
|
-
export interface ReopenTicketSelection {
|
|
4
|
-
statusId: string;
|
|
5
|
-
/** `null` — reopen without an assignee (the ticket stays unassigned). */
|
|
6
|
-
assigneeId: string | null;
|
|
7
|
-
/** Trimmed; `null` when the field was left blank. */
|
|
8
|
-
reason: string | null;
|
|
9
|
-
}
|
|
10
|
-
export interface ReopenTicketModalProps {
|
|
11
|
-
isOpen: boolean;
|
|
12
|
-
onClose: () => void;
|
|
13
|
-
/** Highlighted ticket reference in the description, e.g. "1003: Email client synchronization issues". */
|
|
14
|
-
ticketRef: string;
|
|
15
|
-
/** Allowed target statuses, in display order. Per design, the consumer
|
|
16
|
-
* pre-selects Tech Required by default (annotation on the mock: "tech
|
|
17
|
-
* required by default, but may be changed to custom status"). */
|
|
18
|
-
statusOptions: TakeOverStatusOption[];
|
|
19
|
-
/** Assignable users, in display order (consumer decides ordering, e.g. signed-in user first). */
|
|
20
|
-
assigneeOptions: TakeOverAssigneeOption[];
|
|
21
|
-
assigneesLoading?: boolean;
|
|
22
|
-
/** Pre-selected status; falls back to the first status option. */
|
|
23
|
-
initialStatusId?: string | null;
|
|
24
|
-
/** Pre-selected assignee. Unlike Take Over, the assignee is OPTIONAL —
|
|
25
|
-
* a reopened ticket may stay unassigned (it is flagged for triage). */
|
|
26
|
-
initialAssigneeId?: string | null;
|
|
27
|
-
/** Confirm in-flight: buttons lock and the modal refuses to close. */
|
|
28
|
-
isPending?: boolean;
|
|
29
|
-
onConfirm: (selection: ReopenTicketSelection) => void;
|
|
30
|
-
}
|
|
31
|
-
/**
|
|
32
|
-
* Reopen Ticket dialog (Figma openframe---tickets 8456-17581): shown when a
|
|
33
|
-
* technician reopens a Resolved/Archived ticket. Same Status + Assigned pair
|
|
34
|
-
* as {@link TakeOverTicketModal} plus an optional free-text reason.
|
|
35
|
-
* Presentational — the consumer supplies the option lists, their ordering and
|
|
36
|
-
* default selections, and performs the actual reopen in `onConfirm`.
|
|
37
|
-
*
|
|
38
|
-
* Desktop footer shows only the accent Reopen button (X closes); mobile
|
|
39
|
-
* bottom-sheet adds a Cancel button, matching Take Over.
|
|
40
|
-
*/
|
|
41
|
-
export declare function ReopenTicketModal({ isOpen, onClose, ticketRef, statusOptions, assigneeOptions, assigneesLoading, initialStatusId, initialAssigneeId, isPending, onConfirm, }: ReopenTicketModalProps): React.JSX.Element;
|
|
42
|
-
//# sourceMappingURL=reopen-ticket-modal.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"reopen-ticket-modal.d.ts","sourceRoot":"","sources":["../../../src/components/ui/reopen-ticket-modal.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAM/B,OAAO,EAGL,KAAK,sBAAsB,EAC3B,KAAK,oBAAoB,EAC1B,MAAM,0BAA0B,CAAC;AAMlC,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,MAAM,CAAC;IACjB,yEAAyE;IACzE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,qDAAqD;IACrD,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,yGAAyG;IACzG,SAAS,EAAE,MAAM,CAAC;IAClB;;sEAEkE;IAClE,aAAa,EAAE,oBAAoB,EAAE,CAAC;IACtC,iGAAiG;IACjG,eAAe,EAAE,sBAAsB,EAAE,CAAC;IAC1C,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,kEAAkE;IAClE,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC;4EACwE;IACxE,iBAAiB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,sEAAsE;IACtE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,SAAS,EAAE,CAAC,SAAS,EAAE,qBAAqB,KAAK,IAAI,CAAC;CACvD;AAED;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,EAChC,MAAM,EACN,OAAO,EACP,SAAS,EACT,aAAa,EACb,eAAe,EACf,gBAAgB,EAChB,eAAe,EACf,iBAAiB,EACjB,SAAiB,EACjB,SAAS,GACV,EAAE,sBAAsB,qBAuGxB"}
|
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TICKET_EVENT — the ticket lifecycle receipt (resolved / reopened / open
|
|
3
|
-
* vocabulary), across both the live NATS path and history replay.
|
|
4
|
-
*
|
|
5
|
-
* The behaviours worth pinning:
|
|
6
|
-
* - the chunk arrives STANDALONE (no MESSAGE_START at all — resolution can
|
|
7
|
-
* fire from a PSA-side action with no turn open), so it must mint/append
|
|
8
|
-
* to the trailing bubble like `ticket-escalated` does;
|
|
9
|
-
* - dedupe identity is the stream sequence, with payload-equality as the
|
|
10
|
-
* fallback for the hydrate-then-catch-up overlap where the hydrated twin
|
|
11
|
-
* is seq-less (the persisted row's seq lives on the message, not in
|
|
12
|
-
* `messageData`);
|
|
13
|
-
* - `kind` is OPEN: an unknown kind decodes and renders, only a missing
|
|
14
|
-
* kind is malformed.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { describe, expect, it } from 'vitest'
|
|
18
|
-
import { decodeNatsChunk } from '../../../chat-protocol/nats-decoder'
|
|
19
|
-
import { createChatStreamReducer } from '../stream/chat-stream-reducer'
|
|
20
|
-
import { MessageSegmentAccumulator } from '../utils/message-segment-accumulator'
|
|
21
|
-
import { processHistoricalMessages } from '../utils/process-historical-messages'
|
|
22
|
-
import type { HistoricalMessage, MessageSegment, TicketEventSegment } from '../types'
|
|
23
|
-
|
|
24
|
-
const resolvedChunk = (streamSeq: number, over: Record<string, unknown> = {}) => ({
|
|
25
|
-
type: 'TICKET_EVENT',
|
|
26
|
-
kind: 'RESOLVED',
|
|
27
|
-
actorId: 'fae',
|
|
28
|
-
actorName: 'Fae',
|
|
29
|
-
actorType: 'AI',
|
|
30
|
-
streamSeq,
|
|
31
|
-
...over,
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
const events = (segments: MessageSegment[] | string | undefined): TicketEventSegment[] =>
|
|
35
|
-
Array.isArray(segments)
|
|
36
|
-
? (segments.filter((s) => s.type === 'ticket_event') as TicketEventSegment[])
|
|
37
|
-
: []
|
|
38
|
-
|
|
39
|
-
describe('decodeNatsChunk — TICKET_EVENT', () => {
|
|
40
|
-
it('decodes the resolution receipt with its actor fields', () => {
|
|
41
|
-
expect(decodeNatsChunk(resolvedChunk(7))).toEqual({
|
|
42
|
-
type: 'ticket-event',
|
|
43
|
-
kind: 'RESOLVED',
|
|
44
|
-
actorId: 'fae',
|
|
45
|
-
actorName: 'Fae',
|
|
46
|
-
actorType: 'AI',
|
|
47
|
-
reason: undefined,
|
|
48
|
-
seq: 7,
|
|
49
|
-
})
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
it('falls back to the payload `sequenceId` when the transport did not stamp `streamSeq`', () => {
|
|
53
|
-
const event = decodeNatsChunk({ type: 'TICKET_EVENT', kind: 'REOPENED', sequenceId: 42 })
|
|
54
|
-
expect(event).toMatchObject({ type: 'ticket-event', seq: 42 })
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
it('prefers the transport-stamped `streamSeq` over the payload copy', () => {
|
|
58
|
-
expect(decodeNatsChunk(resolvedChunk(500, { sequenceId: 499 }))).toMatchObject({ seq: 500 })
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
it('keeps unknown kinds — the vocabulary is open', () => {
|
|
62
|
-
expect(decodeNatsChunk({ type: 'TICKET_EVENT', kind: 'ON_HOLD', streamSeq: 1 })).toMatchObject({
|
|
63
|
-
type: 'ticket-event',
|
|
64
|
-
kind: 'ON_HOLD',
|
|
65
|
-
})
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
it('drops only a chunk with no kind at all', () => {
|
|
69
|
-
expect(decodeNatsChunk({ type: 'TICKET_EVENT', streamSeq: 1 })).toBeNull()
|
|
70
|
-
expect(decodeNatsChunk({ type: 'TICKET_EVENT', kind: ' ', streamSeq: 1 })).toBeNull()
|
|
71
|
-
})
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
describe('chat stream reducer — ticket event', () => {
|
|
75
|
-
const trailingSegments = (reducer: ReturnType<typeof createChatStreamReducer>): MessageSegment[] => {
|
|
76
|
-
const { messages } = reducer.state
|
|
77
|
-
return messages[messages.length - 1]?.segments ?? []
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const feed = (reducer: ReturnType<typeof createChatStreamReducer>, chunks: unknown[]) => {
|
|
81
|
-
for (const chunk of chunks) {
|
|
82
|
-
const event = decodeNatsChunk(chunk)
|
|
83
|
-
if (event) reducer.apply(event)
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
it('renders a receipt arriving with no turn open at all', () => {
|
|
88
|
-
const reducer = createChatStreamReducer({ transport: 'nats' })
|
|
89
|
-
feed(reducer, [resolvedChunk(1)])
|
|
90
|
-
expect(events(trailingSegments(reducer))).toHaveLength(1)
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
it('appends to the trailing bubble without wiping the reply the user is reading', () => {
|
|
94
|
-
const reducer = createChatStreamReducer({ transport: 'nats' })
|
|
95
|
-
feed(reducer, [
|
|
96
|
-
{ type: 'MESSAGE_START', streamSeq: 1 },
|
|
97
|
-
{ type: 'TEXT', text: 'Excellent! Feel free to reach out.', streamSeq: 2 },
|
|
98
|
-
{ type: 'MESSAGE_END', streamSeq: 3 },
|
|
99
|
-
resolvedChunk(4),
|
|
100
|
-
])
|
|
101
|
-
const segments = trailingSegments(reducer)
|
|
102
|
-
expect(segments.find((s: MessageSegment) => s.type === 'text')).toMatchObject({
|
|
103
|
-
text: 'Excellent! Feel free to reach out.',
|
|
104
|
-
})
|
|
105
|
-
expect(events(segments)).toHaveLength(1)
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
it('is idempotent across a redelivered chunk (JetStream catch-up)', () => {
|
|
109
|
-
const reducer = createChatStreamReducer({ transport: 'nats' })
|
|
110
|
-
feed(reducer, [resolvedChunk(2), { type: 'MESSAGE_END', streamSeq: 3 }])
|
|
111
|
-
// A replay re-decodes the same chunk; the seq gate drops it, but the
|
|
112
|
-
// upsert must hold even if the gate ever moves.
|
|
113
|
-
const event = decodeNatsChunk(resolvedChunk(2))
|
|
114
|
-
if (event) reducer.apply(event)
|
|
115
|
-
expect(events(trailingSegments(reducer))).toHaveLength(1)
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
it('keeps distinct lifecycle events distinct (resolve → reopen → resolve)', () => {
|
|
119
|
-
const reducer = createChatStreamReducer({ transport: 'nats' })
|
|
120
|
-
feed(reducer, [
|
|
121
|
-
resolvedChunk(1),
|
|
122
|
-
{ type: 'TICKET_EVENT', kind: 'REOPENED', reason: 'Still broken', streamSeq: 2 },
|
|
123
|
-
resolvedChunk(3, { actorName: 'Roman Smith', actorType: 'TECHNICIAN', actorId: 'u-1' }),
|
|
124
|
-
])
|
|
125
|
-
expect(events(trailingSegments(reducer))).toHaveLength(3)
|
|
126
|
-
})
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
describe('accumulator — hydrate/catch-up overlap', () => {
|
|
130
|
-
it('matches a seq-less hydrated twin by payload equality instead of stacking', () => {
|
|
131
|
-
const accumulator = new MessageSegmentAccumulator()
|
|
132
|
-
// Hydrated from history: the persisted row's seq lives on the message,
|
|
133
|
-
// so the restored segment carries none.
|
|
134
|
-
accumulator.addTicketEvent({ kind: 'RESOLVED', actorId: 'fae', actorName: 'Fae', actorType: 'AI' })
|
|
135
|
-
// The same event redelivered live, now with its stream sequence.
|
|
136
|
-
const segments = accumulator.addTicketEvent(
|
|
137
|
-
{ kind: 'RESOLVED', actorId: 'fae', actorName: 'Fae', actorType: 'AI' },
|
|
138
|
-
412,
|
|
139
|
-
)
|
|
140
|
-
const rendered = events(segments)
|
|
141
|
-
expect(rendered).toHaveLength(1)
|
|
142
|
-
expect(rendered[0].streamSeq).toBe(412)
|
|
143
|
-
})
|
|
144
|
-
|
|
145
|
-
it('does NOT collapse two different events of the same kind', () => {
|
|
146
|
-
const accumulator = new MessageSegmentAccumulator()
|
|
147
|
-
accumulator.addTicketEvent({ kind: 'RESOLVED', actorName: 'Fae', actorType: 'AI' }, 1)
|
|
148
|
-
const segments = accumulator.addTicketEvent(
|
|
149
|
-
{ kind: 'RESOLVED', actorName: 'Roman Smith', actorType: 'TECHNICIAN' },
|
|
150
|
-
9,
|
|
151
|
-
)
|
|
152
|
-
expect(events(segments)).toHaveLength(2)
|
|
153
|
-
})
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
describe('processHistoricalMessages — ticket event', () => {
|
|
157
|
-
const historyRow = (id: string, data: Record<string, unknown>): HistoricalMessage => ({
|
|
158
|
-
id,
|
|
159
|
-
createdAt: '2026-08-06T12:00:00Z',
|
|
160
|
-
owner: { type: 'ASSISTANT' },
|
|
161
|
-
messageData: [data as never],
|
|
162
|
-
})
|
|
163
|
-
|
|
164
|
-
it('replays the persisted row through the same mapper as the live path', () => {
|
|
165
|
-
const { messages } = processHistoricalMessages([
|
|
166
|
-
historyRow('m1', {
|
|
167
|
-
type: 'TICKET_EVENT',
|
|
168
|
-
kind: 'REOPENED',
|
|
169
|
-
actorId: 'user-42',
|
|
170
|
-
actorName: 'John Smith',
|
|
171
|
-
actorType: 'CLIENT',
|
|
172
|
-
reason: 'The printer stopped working again',
|
|
173
|
-
}),
|
|
174
|
-
])
|
|
175
|
-
expect(messages).toHaveLength(1)
|
|
176
|
-
expect(events(messages[0].content)[0]).toMatchObject({
|
|
177
|
-
data: { kind: 'REOPENED', reason: 'The printer stopped working again' },
|
|
178
|
-
})
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
it('keeps an unknown persisted kind renderable', () => {
|
|
182
|
-
const { messages } = processHistoricalMessages([
|
|
183
|
-
historyRow('m1', { type: 'TICKET_EVENT', kind: 'ON_HOLD' }),
|
|
184
|
-
])
|
|
185
|
-
expect(events(messages[0]?.content ?? [])).toHaveLength(1)
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
it('skips a malformed row with no kind', () => {
|
|
189
|
-
const { messages } = processHistoricalMessages([
|
|
190
|
-
historyRow('m1', { type: 'TICKET_EVENT', actorName: 'Fae' }),
|
|
191
|
-
])
|
|
192
|
-
expect(messages.flatMap((m) => events(m.content))).toHaveLength(0)
|
|
193
|
-
})
|
|
194
|
-
})
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import { forwardRef, type HTMLAttributes, type ReactNode } from "react"
|
|
4
|
-
import { cn } from "../../utils/cn"
|
|
5
|
-
import { formatTime } from "../../utils/format-date"
|
|
6
|
-
|
|
7
|
-
export interface AiAssistantInfoProps extends HTMLAttributes<HTMLDivElement> {
|
|
8
|
-
/** Rendered inside the standard 48px framed box (typically a 24px icon). */
|
|
9
|
-
icon?: ReactNode
|
|
10
|
-
/** Replaces the framed icon box entirely (e.g. a round 48px avatar for the
|
|
11
|
-
* `direct-chat` variant). Wins over `icon` when both are set. */
|
|
12
|
-
leading?: ReactNode
|
|
13
|
-
title: string
|
|
14
|
-
/** Secondary line under the title. Omitted → the row is title-only. */
|
|
15
|
-
body?: ReactNode
|
|
16
|
-
/** Timestamp of the message row this block came in on. */
|
|
17
|
-
timestamp?: Date
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Figma `ai-assistant-info` — the shared in-thread status card: a framed
|
|
22
|
-
* icon (or avatar), a title with a right-aligned time, and a secondary line.
|
|
23
|
-
* One presentational shell for every lifecycle receipt in a chat thread
|
|
24
|
-
* (handoff, technician joined, ticket resolved/reopened, …); the per-event
|
|
25
|
-
* components (`TicketEscalatedMessage`, `TicketEventMessage`) own the copy
|
|
26
|
-
* and icon choice and delegate the chrome here.
|
|
27
|
-
*
|
|
28
|
-
* The body WRAPS, deliberately diverging from the mock's single-line
|
|
29
|
-
* ellipsis: the mock's 72px comes from its placeholder copy fitting one
|
|
30
|
-
* line, while real wire text is a full sentence, and clipping the only
|
|
31
|
-
* explanation of a lifecycle change is not recoverable on touch. The title
|
|
32
|
-
* still truncates — it holds one line so the timestamp is never pushed out
|
|
33
|
-
* of the row at narrow widths.
|
|
34
|
-
*/
|
|
35
|
-
const AiAssistantInfo = forwardRef<HTMLDivElement, AiAssistantInfoProps>(
|
|
36
|
-
({ className, icon, leading, title, body, timestamp, ...props }, ref) => {
|
|
37
|
-
return (
|
|
38
|
-
<div
|
|
39
|
-
ref={ref}
|
|
40
|
-
className={cn(
|
|
41
|
-
"mb-[var(--spacing-system-xsf)] flex items-start gap-[var(--spacing-system-s)] rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-s)]",
|
|
42
|
-
className,
|
|
43
|
-
)}
|
|
44
|
-
{...props}
|
|
45
|
-
>
|
|
46
|
-
{leading ?? (
|
|
47
|
-
<div className="flex size-12 shrink-0 items-center justify-center rounded-md border border-ods-border bg-ods-bg">
|
|
48
|
-
{icon}
|
|
49
|
-
</div>
|
|
50
|
-
)}
|
|
51
|
-
<div className="flex min-w-0 flex-1 flex-col">
|
|
52
|
-
<div className="flex w-full items-start">
|
|
53
|
-
<p className="text-h4 min-w-0 flex-1 truncate text-ods-text-primary" title={title}>
|
|
54
|
-
{title}
|
|
55
|
-
</p>
|
|
56
|
-
{timestamp && (
|
|
57
|
-
<span className="text-h6 shrink-0 text-right text-ods-text-secondary">{formatTime(timestamp)}</span>
|
|
58
|
-
)}
|
|
59
|
-
</div>
|
|
60
|
-
{body != null && body !== "" && (
|
|
61
|
-
<p className="text-h4 w-full break-words text-ods-text-secondary">{body}</p>
|
|
62
|
-
)}
|
|
63
|
-
</div>
|
|
64
|
-
</div>
|
|
65
|
-
)
|
|
66
|
-
},
|
|
67
|
-
)
|
|
68
|
-
|
|
69
|
-
AiAssistantInfo.displayName = "AiAssistantInfo"
|
|
70
|
-
|
|
71
|
-
export { AiAssistantInfo }
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import { forwardRef, type HTMLAttributes } from "react"
|
|
4
|
-
import { CheckCircleIcon, InfoCircleIcon } from "../icons-v2-generated"
|
|
5
|
-
import { AiAssistantInfo } from "./ai-assistant-info"
|
|
6
|
-
import { TICKET_EVENT_KIND, type TicketEventSegment } from "./types/message.types"
|
|
7
|
-
|
|
8
|
-
/** Actor types the backend uses for the AI agent. Open set on the wire —
|
|
9
|
-
* compared case-insensitively; anything else is treated as a human. */
|
|
10
|
-
const AI_ACTOR_TYPES = new Set(["AI", "AGENT", "ASSISTANT", "BOT"])
|
|
11
|
-
|
|
12
|
-
function isAiActor(actorType?: string): boolean {
|
|
13
|
-
return !!actorType && AI_ACTOR_TYPES.has(actorType.toUpperCase())
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** `TICKET_ON_HOLD` → `Ticket On Hold` — neutral title for kinds this build
|
|
17
|
-
* does not know, which must still render (the vocabulary is open). */
|
|
18
|
-
function humanizeKind(kind: string): string {
|
|
19
|
-
return kind
|
|
20
|
-
.split(/[_\s-]+/)
|
|
21
|
-
.filter(Boolean)
|
|
22
|
-
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
23
|
-
.join(" ")
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface TicketEventMessageProps extends HTMLAttributes<HTMLDivElement> {
|
|
27
|
-
data: TicketEventSegment['data']
|
|
28
|
-
/** Timestamp of the message row this block came in on. */
|
|
29
|
-
timestamp?: Date
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Ticket lifecycle receipt — resolved / reopened / an unknown future kind.
|
|
34
|
-
* Figma `ai-assistant-info` (type=resolved-fae | resolved-tech |
|
|
35
|
-
* reopened-fae | reopened-tech | reopened-reason).
|
|
36
|
-
*
|
|
37
|
-
* Copy is composed client-side from the event's fields (unlike
|
|
38
|
-
* `ticket_escalated`, whose body the backend authors):
|
|
39
|
-
* RESOLVED → "Resolved by {actorName}." under a green check;
|
|
40
|
-
* REOPENED → the reason when the wire carries one, otherwise who picks the
|
|
41
|
-
* conversation up next, inferred from `actorType` (an AI actor keeps
|
|
42
|
-
* assisting; anything else means a technician will reply);
|
|
43
|
-
* unknown kind → a neutral info line: humanized kind as the title, the
|
|
44
|
-
* reason or actor as the body. Never dropped — the vocabulary is open.
|
|
45
|
-
*/
|
|
46
|
-
const TicketEventMessage = forwardRef<HTMLDivElement, TicketEventMessageProps>(
|
|
47
|
-
({ data, timestamp, ...props }, ref) => {
|
|
48
|
-
const { kind, actorName, actorType, reason } = data
|
|
49
|
-
const resolved = kind === TICKET_EVENT_KIND.RESOLVED
|
|
50
|
-
const reopened = kind === TICKET_EVENT_KIND.REOPENED
|
|
51
|
-
|
|
52
|
-
const title = resolved ? "Ticket Resolved" : reopened ? "Ticket Reopened" : humanizeKind(kind)
|
|
53
|
-
|
|
54
|
-
let body: string | undefined
|
|
55
|
-
if (resolved) {
|
|
56
|
-
body = actorName ? `Resolved by ${actorName}.` : "The ticket has been resolved."
|
|
57
|
-
} else if (reopened) {
|
|
58
|
-
body = reason
|
|
59
|
-
? `Reason: ${reason}`
|
|
60
|
-
: isAiActor(actorType)
|
|
61
|
-
? `${actorName || "Fae"} will continue assisting in this conversation.`
|
|
62
|
-
: "A technician will reply when available."
|
|
63
|
-
} else {
|
|
64
|
-
body = reason || (actorName ? `By ${actorName}.` : undefined)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const icon = resolved ? (
|
|
68
|
-
<CheckCircleIcon className="size-6 text-ods-attention-green-success" />
|
|
69
|
-
) : (
|
|
70
|
-
<InfoCircleIcon className="size-6 text-ods-flamingo-pink" />
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
return <AiAssistantInfo ref={ref} icon={icon} title={title} body={body} timestamp={timestamp} {...props} />
|
|
74
|
-
},
|
|
75
|
-
)
|
|
76
|
-
|
|
77
|
-
TicketEventMessage.displayName = "TicketEventMessage"
|
|
78
|
-
|
|
79
|
-
export { TicketEventMessage }
|
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import * as React from 'react';
|
|
4
|
-
import { Autocomplete } from './autocomplete';
|
|
5
|
-
import { Button } from './button';
|
|
6
|
-
import { ColorSwatch } from './color-swatch';
|
|
7
|
-
import { ModalV2, ModalV2Footer, ModalV2Header, ModalV2Title } from './modal-v2';
|
|
8
|
-
import { SquareAvatar } from './square-avatar';
|
|
9
|
-
import {
|
|
10
|
-
renderTicketAssigneeOption,
|
|
11
|
-
renderTicketStatusOption,
|
|
12
|
-
type TakeOverAssigneeOption,
|
|
13
|
-
type TakeOverStatusOption,
|
|
14
|
-
} from './take-over-ticket-modal';
|
|
15
|
-
import { Textarea } from './textarea';
|
|
16
|
-
|
|
17
|
-
/** Backend trims and caps the reason at 1000 chars; cap it client-side too. */
|
|
18
|
-
const REASON_MAX_LENGTH = 1000;
|
|
19
|
-
|
|
20
|
-
export interface ReopenTicketSelection {
|
|
21
|
-
statusId: string;
|
|
22
|
-
/** `null` — reopen without an assignee (the ticket stays unassigned). */
|
|
23
|
-
assigneeId: string | null;
|
|
24
|
-
/** Trimmed; `null` when the field was left blank. */
|
|
25
|
-
reason: string | null;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface ReopenTicketModalProps {
|
|
29
|
-
isOpen: boolean;
|
|
30
|
-
onClose: () => void;
|
|
31
|
-
/** Highlighted ticket reference in the description, e.g. "1003: Email client synchronization issues". */
|
|
32
|
-
ticketRef: string;
|
|
33
|
-
/** Allowed target statuses, in display order. Per design, the consumer
|
|
34
|
-
* pre-selects Tech Required by default (annotation on the mock: "tech
|
|
35
|
-
* required by default, but may be changed to custom status"). */
|
|
36
|
-
statusOptions: TakeOverStatusOption[];
|
|
37
|
-
/** Assignable users, in display order (consumer decides ordering, e.g. signed-in user first). */
|
|
38
|
-
assigneeOptions: TakeOverAssigneeOption[];
|
|
39
|
-
assigneesLoading?: boolean;
|
|
40
|
-
/** Pre-selected status; falls back to the first status option. */
|
|
41
|
-
initialStatusId?: string | null;
|
|
42
|
-
/** Pre-selected assignee. Unlike Take Over, the assignee is OPTIONAL —
|
|
43
|
-
* a reopened ticket may stay unassigned (it is flagged for triage). */
|
|
44
|
-
initialAssigneeId?: string | null;
|
|
45
|
-
/** Confirm in-flight: buttons lock and the modal refuses to close. */
|
|
46
|
-
isPending?: boolean;
|
|
47
|
-
onConfirm: (selection: ReopenTicketSelection) => void;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Reopen Ticket dialog (Figma openframe---tickets 8456-17581): shown when a
|
|
52
|
-
* technician reopens a Resolved/Archived ticket. Same Status + Assigned pair
|
|
53
|
-
* as {@link TakeOverTicketModal} plus an optional free-text reason.
|
|
54
|
-
* Presentational — the consumer supplies the option lists, their ordering and
|
|
55
|
-
* default selections, and performs the actual reopen in `onConfirm`.
|
|
56
|
-
*
|
|
57
|
-
* Desktop footer shows only the accent Reopen button (X closes); mobile
|
|
58
|
-
* bottom-sheet adds a Cancel button, matching Take Over.
|
|
59
|
-
*/
|
|
60
|
-
export function ReopenTicketModal({
|
|
61
|
-
isOpen,
|
|
62
|
-
onClose,
|
|
63
|
-
ticketRef,
|
|
64
|
-
statusOptions,
|
|
65
|
-
assigneeOptions,
|
|
66
|
-
assigneesLoading,
|
|
67
|
-
initialStatusId,
|
|
68
|
-
initialAssigneeId,
|
|
69
|
-
isPending = false,
|
|
70
|
-
onConfirm,
|
|
71
|
-
}: ReopenTicketModalProps) {
|
|
72
|
-
const [statusId, setStatusId] = React.useState<string | null>(null);
|
|
73
|
-
const [assigneeId, setAssigneeId] = React.useState<string | null>(null);
|
|
74
|
-
const [reason, setReason] = React.useState('');
|
|
75
|
-
|
|
76
|
-
// Re-seed the selections every time the modal opens.
|
|
77
|
-
React.useEffect(() => {
|
|
78
|
-
if (!isOpen) return;
|
|
79
|
-
setStatusId(initialStatusId ?? null);
|
|
80
|
-
setAssigneeId(initialAssigneeId ?? null);
|
|
81
|
-
setReason('');
|
|
82
|
-
}, [isOpen, initialStatusId, initialAssigneeId]);
|
|
83
|
-
|
|
84
|
-
const selectedStatusId = statusId ?? statusOptions[0]?.value ?? null;
|
|
85
|
-
const selectedStatus = statusOptions.find(o => o.value === selectedStatusId);
|
|
86
|
-
const selectedAssignee = assigneeOptions.find(o => o.value === assigneeId);
|
|
87
|
-
|
|
88
|
-
const handleConfirm = () => {
|
|
89
|
-
if (!selectedStatusId || isPending) return;
|
|
90
|
-
const trimmedReason = reason.trim();
|
|
91
|
-
onConfirm({
|
|
92
|
-
statusId: selectedStatusId,
|
|
93
|
-
assigneeId: assigneeId ?? null,
|
|
94
|
-
reason: trimmedReason ? trimmedReason.slice(0, REASON_MAX_LENGTH) : null,
|
|
95
|
-
});
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
return (
|
|
99
|
-
<ModalV2 isOpen={isOpen} onClose={isPending ? () => {} : onClose} className="text-left md:max-w-[600px]">
|
|
100
|
-
<ModalV2Header>
|
|
101
|
-
<ModalV2Title>Reopen Ticket</ModalV2Title>
|
|
102
|
-
</ModalV2Header>
|
|
103
|
-
|
|
104
|
-
<p className="text-h4 text-ods-text-primary">
|
|
105
|
-
The ticket <span className="text-ods-accent">{ticketRef}</span> will return to the selected status and appear on
|
|
106
|
-
the board. Assigned admins and the user will be notified.
|
|
107
|
-
</p>
|
|
108
|
-
|
|
109
|
-
<div className="flex flex-col md:flex-row gap-[var(--spacing-system-l)] w-full">
|
|
110
|
-
<div className="flex-1 min-w-0">
|
|
111
|
-
<Autocomplete
|
|
112
|
-
label="Status"
|
|
113
|
-
options={statusOptions}
|
|
114
|
-
value={selectedStatusId}
|
|
115
|
-
onChange={setStatusId}
|
|
116
|
-
placeholder="Select Status"
|
|
117
|
-
startAdornment={selectedStatus ? <ColorSwatch color={selectedStatus.color} /> : undefined}
|
|
118
|
-
renderOption={renderTicketStatusOption}
|
|
119
|
-
/>
|
|
120
|
-
</div>
|
|
121
|
-
<div className="flex-1 min-w-0">
|
|
122
|
-
<Autocomplete
|
|
123
|
-
label="Assigned"
|
|
124
|
-
options={assigneeOptions}
|
|
125
|
-
value={assigneeId}
|
|
126
|
-
onChange={setAssigneeId}
|
|
127
|
-
placeholder="Select Technician"
|
|
128
|
-
loading={assigneesLoading}
|
|
129
|
-
startAdornment={
|
|
130
|
-
selectedAssignee ? (
|
|
131
|
-
<SquareAvatar
|
|
132
|
-
src={selectedAssignee.imageUrl}
|
|
133
|
-
alt={selectedAssignee.label}
|
|
134
|
-
fallback={selectedAssignee.label}
|
|
135
|
-
size="sm"
|
|
136
|
-
variant="round"
|
|
137
|
-
/>
|
|
138
|
-
) : undefined
|
|
139
|
-
}
|
|
140
|
-
renderOption={renderTicketAssigneeOption}
|
|
141
|
-
/>
|
|
142
|
-
</div>
|
|
143
|
-
</div>
|
|
144
|
-
|
|
145
|
-
<div className="w-full">
|
|
146
|
-
<Textarea
|
|
147
|
-
label="Reason"
|
|
148
|
-
value={reason}
|
|
149
|
-
onChange={event => setReason(event.target.value)}
|
|
150
|
-
placeholder="Describe what's still not working (optional)"
|
|
151
|
-
maxLength={REASON_MAX_LENGTH}
|
|
152
|
-
disabled={isPending}
|
|
153
|
-
/>
|
|
154
|
-
</div>
|
|
155
|
-
|
|
156
|
-
<ModalV2Footer>
|
|
157
|
-
<Button type="button" variant="outline" onClick={onClose} disabled={isPending} className="flex-1 md:hidden">
|
|
158
|
-
Cancel
|
|
159
|
-
</Button>
|
|
160
|
-
<div className="hidden md:block flex-1" />
|
|
161
|
-
<Button
|
|
162
|
-
type="button"
|
|
163
|
-
variant="accent"
|
|
164
|
-
onClick={handleConfirm}
|
|
165
|
-
loading={isPending}
|
|
166
|
-
disabled={!selectedStatusId}
|
|
167
|
-
className="flex-1"
|
|
168
|
-
>
|
|
169
|
-
Reopen Ticket
|
|
170
|
-
</Button>
|
|
171
|
-
</ModalV2Footer>
|
|
172
|
-
</ModalV2>
|
|
173
|
-
);
|
|
174
|
-
}
|