@flamingo-stack/openframe-frontend-core 0.0.665 → 0.0.666-snapshot.20260925092147

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.
Files changed (115) hide show
  1. package/dist/{chunk-MNUR6PAJ.cjs → chunk-23XIOIGS.cjs} +392 -370
  2. package/dist/chunk-23XIOIGS.cjs.map +1 -0
  3. package/dist/{chunk-ZMZUF6FX.cjs → chunk-2FUSTKIV.cjs} +3 -3
  4. package/dist/{chunk-ZMZUF6FX.cjs.map → chunk-2FUSTKIV.cjs.map} +1 -1
  5. package/dist/{chunk-EMJAT6IK.cjs → chunk-3A6MM3CK.cjs} +15 -15
  6. package/dist/{chunk-EMJAT6IK.cjs.map → chunk-3A6MM3CK.cjs.map} +1 -1
  7. package/dist/{chunk-CQJXOBWK.js → chunk-4FHL6C5E.js} +3 -3
  8. package/dist/{chunk-Z2MJPTIB.js → chunk-57BIWOGD.js} +3 -3
  9. package/dist/{chunk-YRMKUDOE.js → chunk-5ZHLPC32.js} +5 -5
  10. package/dist/{chunk-JHH7NETU.cjs → chunk-6M5NRLFI.cjs} +5 -5
  11. package/dist/{chunk-JHH7NETU.cjs.map → chunk-6M5NRLFI.cjs.map} +1 -1
  12. package/dist/{chunk-GDTFJ5QN.js → chunk-7ZIOUUWU.js} +2 -2
  13. package/dist/{chunk-RT5JNTCN.js → chunk-AAZLZBR5.js} +3 -3
  14. package/dist/{chunk-XCACXPCM.cjs → chunk-AUHHSY5H.cjs} +22 -22
  15. package/dist/{chunk-XCACXPCM.cjs.map → chunk-AUHHSY5H.cjs.map} +1 -1
  16. package/dist/{chunk-FGQSSFP4.cjs → chunk-B76ZH6HA.cjs} +10 -10
  17. package/dist/{chunk-FGQSSFP4.cjs.map → chunk-B76ZH6HA.cjs.map} +1 -1
  18. package/dist/{chunk-OLCI3UA5.js → chunk-BO2ZXX4Z.js} +6 -6
  19. package/dist/{chunk-77WMNQTE.js → chunk-CEPI3A3T.js} +3 -3
  20. package/dist/{chunk-X32X52VN.cjs → chunk-CIB3QB2T.cjs} +71 -71
  21. package/dist/{chunk-X32X52VN.cjs.map → chunk-CIB3QB2T.cjs.map} +1 -1
  22. package/dist/{chunk-LTW2GWJ2.cjs → chunk-CYBRUQ2Z.cjs} +22 -22
  23. package/dist/{chunk-LTW2GWJ2.cjs.map → chunk-CYBRUQ2Z.cjs.map} +1 -1
  24. package/dist/{chunk-MBLACUCE.js → chunk-DG763XMY.js} +5 -5
  25. package/dist/{chunk-LXL3PZE2.cjs → chunk-DKBXW3EI.cjs} +10 -10
  26. package/dist/{chunk-LXL3PZE2.cjs.map → chunk-DKBXW3EI.cjs.map} +1 -1
  27. package/dist/{chunk-BPKEDLGR.js → chunk-DYFQCHUY.js} +5 -5
  28. package/dist/{chunk-QR3NUFA5.js → chunk-GKAZC2JI.js} +2 -2
  29. package/dist/{chunk-OMTLCMQB.js → chunk-IWCYTHNG.js} +31 -9
  30. package/dist/chunk-IWCYTHNG.js.map +1 -0
  31. package/dist/{chunk-6FD7ZEBY.cjs → chunk-JMPZUL2X.cjs} +197 -170
  32. package/dist/chunk-JMPZUL2X.cjs.map +1 -0
  33. package/dist/{chunk-RM4S53GB.js → chunk-KS2PRUHN.js} +2 -2
  34. package/dist/{chunk-O3GB7JFL.js → chunk-M6PDHXI5.js} +3 -3
  35. package/dist/{chunk-LCYYTWRF.cjs → chunk-QHJ4GEDA.cjs} +4 -4
  36. package/dist/{chunk-LCYYTWRF.cjs.map → chunk-QHJ4GEDA.cjs.map} +1 -1
  37. package/dist/{chunk-3SS37SNM.cjs → chunk-SFMFRUY2.cjs} +10 -10
  38. package/dist/{chunk-3SS37SNM.cjs.map → chunk-SFMFRUY2.cjs.map} +1 -1
  39. package/dist/{chunk-JHAS6Y7P.js → chunk-TN33GNFP.js} +4 -4
  40. package/dist/{chunk-DNFC6RYF.js → chunk-TSEYXPXZ.js} +55 -28
  41. package/dist/{chunk-DNFC6RYF.js.map → chunk-TSEYXPXZ.js.map} +1 -1
  42. package/dist/{chunk-ZGPST26T.cjs → chunk-VUQWJUW7.cjs} +45 -45
  43. package/dist/{chunk-ZGPST26T.cjs.map → chunk-VUQWJUW7.cjs.map} +1 -1
  44. package/dist/{chunk-L4ZNA3NX.cjs → chunk-WD73QMY3.cjs} +3 -3
  45. package/dist/{chunk-L4ZNA3NX.cjs.map → chunk-WD73QMY3.cjs.map} +1 -1
  46. package/dist/{chunk-KBUYG5N6.cjs → chunk-YW4LA55U.cjs} +33 -33
  47. package/dist/{chunk-KBUYG5N6.cjs.map → chunk-YW4LA55U.cjs.map} +1 -1
  48. package/dist/{chunk-6W5N23LC.cjs → chunk-Z5FOWR7O.cjs} +26 -26
  49. package/dist/{chunk-6W5N23LC.cjs.map → chunk-Z5FOWR7O.cjs.map} +1 -1
  50. package/dist/{chunk-BKCPXDOE.js → chunk-ZRD3YAPM.js} +2 -2
  51. package/dist/components/case-studies/index.cjs +14 -14
  52. package/dist/components/case-studies/index.js +3 -3
  53. package/dist/components/chat/chat-composer.d.ts +10 -1
  54. package/dist/components/chat/chat-composer.d.ts.map +1 -1
  55. package/dist/components/chat/chat-input.d.ts.map +1 -1
  56. package/dist/components/chat/embeddable-chat.d.ts +10 -0
  57. package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
  58. package/dist/components/chat/index.cjs +2 -2
  59. package/dist/components/chat/index.js +1 -1
  60. package/dist/components/chat/types/component.types.d.ts +7 -0
  61. package/dist/components/chat/types/component.types.d.ts.map +1 -1
  62. package/dist/components/contact/index.cjs +4 -4
  63. package/dist/components/contact/index.js +3 -3
  64. package/dist/components/docs/index.cjs +7 -7
  65. package/dist/components/docs/index.js +6 -6
  66. package/dist/components/embeds/index.cjs +4 -4
  67. package/dist/components/embeds/index.js +3 -3
  68. package/dist/components/faq/index.cjs +4 -4
  69. package/dist/components/faq/index.js +3 -3
  70. package/dist/components/features/board/ticket-card.d.ts +13 -3
  71. package/dist/components/features/board/ticket-card.d.ts.map +1 -1
  72. package/dist/components/features/index.cjs +3 -3
  73. package/dist/components/features/index.js +2 -2
  74. package/dist/components/help-center-pages/index.cjs +23 -23
  75. package/dist/components/help-center-pages/index.js +14 -14
  76. package/dist/components/index.cjs +39 -39
  77. package/dist/components/index.js +13 -13
  78. package/dist/components/meeting-scheduler/index.cjs +24 -24
  79. package/dist/components/meeting-scheduler/index.js +2 -2
  80. package/dist/components/navigation/index.cjs +6 -6
  81. package/dist/components/navigation/index.js +5 -5
  82. package/dist/components/onboarding-guides/index.cjs +6 -6
  83. package/dist/components/onboarding-guides/index.js +5 -5
  84. package/dist/components/tickets/index.cjs +7 -7
  85. package/dist/components/tickets/index.js +6 -6
  86. package/dist/components/ui/index.cjs +3 -3
  87. package/dist/components/ui/index.js +2 -2
  88. package/dist/index.cjs +7 -7
  89. package/dist/index.js +6 -6
  90. package/package.json +1 -1
  91. package/src/__tests__/ticket-card-meta.test.tsx +39 -1
  92. package/src/components/chat/chat-composer.tsx +21 -5
  93. package/src/components/chat/chat-input.tsx +15 -3
  94. package/src/components/chat/embeddable-chat.tsx +16 -2
  95. package/src/components/chat/types/component.types.ts +7 -0
  96. package/src/components/features/board/.ticket-card.md +2 -2
  97. package/src/components/features/board/ticket-card.tsx +49 -10
  98. package/src/stories/EmbeddableChat.stories.tsx +21 -0
  99. package/dist/chunk-6FD7ZEBY.cjs.map +0 -1
  100. package/dist/chunk-MNUR6PAJ.cjs.map +0 -1
  101. package/dist/chunk-OMTLCMQB.js.map +0 -1
  102. /package/dist/{chunk-CQJXOBWK.js.map → chunk-4FHL6C5E.js.map} +0 -0
  103. /package/dist/{chunk-Z2MJPTIB.js.map → chunk-57BIWOGD.js.map} +0 -0
  104. /package/dist/{chunk-YRMKUDOE.js.map → chunk-5ZHLPC32.js.map} +0 -0
  105. /package/dist/{chunk-GDTFJ5QN.js.map → chunk-7ZIOUUWU.js.map} +0 -0
  106. /package/dist/{chunk-RT5JNTCN.js.map → chunk-AAZLZBR5.js.map} +0 -0
  107. /package/dist/{chunk-OLCI3UA5.js.map → chunk-BO2ZXX4Z.js.map} +0 -0
  108. /package/dist/{chunk-77WMNQTE.js.map → chunk-CEPI3A3T.js.map} +0 -0
  109. /package/dist/{chunk-MBLACUCE.js.map → chunk-DG763XMY.js.map} +0 -0
  110. /package/dist/{chunk-BPKEDLGR.js.map → chunk-DYFQCHUY.js.map} +0 -0
  111. /package/dist/{chunk-QR3NUFA5.js.map → chunk-GKAZC2JI.js.map} +0 -0
  112. /package/dist/{chunk-RM4S53GB.js.map → chunk-KS2PRUHN.js.map} +0 -0
  113. /package/dist/{chunk-O3GB7JFL.js.map → chunk-M6PDHXI5.js.map} +0 -0
  114. /package/dist/{chunk-JHAS6Y7P.js.map → chunk-TN33GNFP.js.map} +0 -0
  115. /package/dist/{chunk-BKCPXDOE.js.map → chunk-ZRD3YAPM.js.map} +0 -0
@@ -12,10 +12,20 @@ import { ModelDisplay } from './model-display';
12
12
  import type { ChatInputRef, ModelDisplayProps } from './types/component.types';
13
13
  import type { ChatContextItem, ChatContextPickerConfig } from './types/context-item.types';
14
14
 
15
+ /** Why the composer takes no input, in the user's words — shown where the prompt would be. */
16
+ export interface ChatComposerLock {
17
+ placeholder: string;
18
+ }
19
+
15
20
  export interface ChatComposerProps {
16
21
  /** Read-only archived chat → render the unarchive placeholder instead of the
17
22
  * input (Figma node 7361:426949). */
18
23
  archived?: boolean;
24
+ /** The host cannot take a message right now — e.g. the AI balance is spent
25
+ * and the agents are paused (Figma 954:28455). The composer keeps its place
26
+ * but takes no input: the editor is disabled with the lock's copy in the
27
+ * prompt's place, and the `+` menu, the chips and Send are inert. */
28
+ lock?: ChatComposerLock | null;
19
29
  inputRef: Ref<ChatInputRef>;
20
30
  onSend: (text: string) => void;
21
31
  onStop: () => void;
@@ -80,6 +90,7 @@ export interface ChatComposerProps {
80
90
  */
81
91
  export function ChatComposer({
82
92
  archived = false,
93
+ lock = null,
83
94
  inputRef,
84
95
  onSend,
85
96
  onStop,
@@ -107,6 +118,7 @@ export function ChatComposer({
107
118
  previewText,
108
119
  }: ChatComposerProps) {
109
120
  const contextEnabled = !!contextPicker && !archived;
121
+ const locked = lock != null;
110
122
  const selected = selectedContextItems ?? [];
111
123
  const memory = contextMemoryItems ?? [];
112
124
 
@@ -131,11 +143,15 @@ export function ChatComposer({
131
143
  onStop={onStop}
132
144
  sending={sending}
133
145
  placeholder={placeholder}
146
+ // Locked: the editor is disabled and the lock's copy stands where the
147
+ // prompt would; there is nothing to focus, so autofocus stands down too.
148
+ disabled={locked}
149
+ disabledPlaceholder={lock?.placeholder}
134
150
  previewText={previewText}
135
151
  fullWidth
136
152
  className="px-0"
137
153
  reserveAvatarOffset={false}
138
- autoFocus={autoFocus}
154
+ autoFocus={locked ? false : autoFocus}
139
155
  slashCommands={slashCommands}
140
156
  onMentionQueryChange={contextEnabled ? onMentionQueryChange : undefined}
141
157
  onValueChange={contextEnabled ? onValueChange : undefined}
@@ -148,7 +164,7 @@ export function ChatComposer({
148
164
  <ChatComposerPlusMenu
149
165
  onToggle={() => (contextPickerOpen ? onCloseContextPicker?.() : onOpenContextPicker?.())}
150
166
  open={contextPickerOpen}
151
- disabled={attachmentsDisabled}
167
+ disabled={attachmentsDisabled || locked}
152
168
  dropdown={
153
169
  contextPicker ? (
154
170
  <ChatContextPicker
@@ -202,7 +218,7 @@ export function ChatComposer({
202
218
  items={memory}
203
219
  onRemove={onRemoveContextMemoryItem}
204
220
  resolveIcon={resolveContextIcon}
205
- disabled={sending}
221
+ disabled={sending || locked}
206
222
  className="rounded-t-md"
207
223
  />
208
224
  {selected.length > 0 && (
@@ -210,7 +226,7 @@ export function ChatComposer({
210
226
  items={selected}
211
227
  onRemove={onRemoveContextItem}
212
228
  resolveIcon={resolveContextIcon}
213
- disabled={sending}
229
+ disabled={sending || locked}
214
230
  className={cn(
215
231
  'border-b border-ods-border bg-ods-bg p-2',
216
232
  // Only the TOP strip rounds into the card corner.
@@ -236,7 +252,7 @@ export function ChatComposer({
236
252
  attachmentsEnabled
237
253
  attachmentsCount={attachmentsCount}
238
254
  onAddFiles={onAddFiles}
239
- disabled={attachmentsDisabled}
255
+ disabled={attachmentsDisabled || locked}
240
256
  />
241
257
  )}
242
258
  <div className="min-w-0 flex-1">
@@ -167,6 +167,7 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
167
167
  placeholder = 'Enter your Request...',
168
168
  reserveAvatarOffset: _reserveAvatarOffset,
169
169
  disabled = false,
170
+ disabledPlaceholder = 'Connection lost. Waiting to reconnect...',
170
171
  autoFocus = false,
171
172
  fullWidth = false,
172
173
  allowEmptySend = false,
@@ -607,8 +608,18 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
607
608
  the draft is EMPTY, so the editor is always on its plain line
608
609
  box here. */}
609
610
  {isEmpty && !showPreview && (
610
- <span className="pointer-events-none absolute left-0 top-0 select-none text-ods-text-secondary text-h4">
611
- {disabled ? 'Connection lost. Waiting to reconnect...' : placeholder}
611
+ <span
612
+ className={cn(
613
+ // One line, clipped with an ellipsis (Figma 954:28455): the
614
+ // disabled copy is a sentence, and the panel's narrowest
615
+ // width does not fit it — wrapped, it ran under the row.
616
+ 'pointer-events-none absolute left-0 top-0 w-full select-none truncate text-h4',
617
+ // Disabled reads as disabled (Figma 954:28455): the copy says
618
+ // why, and the colour says it is not an invitation to type.
619
+ disabled ? 'text-ods-text-disabled' : 'text-ods-text-secondary',
620
+ )}
621
+ >
622
+ {disabled ? disabledPlaceholder : placeholder}
612
623
  </span>
613
624
  )}
614
625
  {/* Ghost preview of a hovered quick-action's prompt. Overlaid like
@@ -642,7 +653,8 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
642
653
  data-editor
643
654
  role="textbox"
644
655
  aria-multiline="true"
645
- aria-label={typeof placeholder === 'string' ? placeholder : 'Message'}
656
+ // Named by what it shows: locked, the lock's copy is the name.
657
+ aria-label={disabled ? disabledPlaceholder : typeof placeholder === 'string' ? placeholder : 'Message'}
646
658
  // `sending` blocks SENDING, never typing. Taking
647
659
  // `contentEditable` away under a focused editor blurs it, and
648
660
  // iOS dismisses the keyboard on blur; restoring it re-establishes
@@ -62,7 +62,7 @@ import { CollisionBoundaryContext, PortalContainerContext } from '../ui/portal-c
62
62
  import { SquareAvatar } from '../ui/square-avatar';
63
63
  import { ChatArchivePage } from './chat-archive-page';
64
64
  import { ChatAttachmentChipStrip } from './chat-attachment-bar';
65
- import { ChatComposer } from './chat-composer';
65
+ import { ChatComposer, type ChatComposerLock } from './chat-composer';
66
66
  import { chatDialogMenuItems } from './chat-dialog-menu-items';
67
67
  import { ChatHeaderIconButton } from './chat-header-icon-button';
68
68
  import { ChatHeaderSearchField } from './chat-header-search-field';
@@ -185,6 +185,15 @@ export interface EmbeddableChatProps {
185
185
  * cursor. Scrolling is intentionally disabled too. Defaults to `false`.
186
186
  */
187
187
  previewMode?: boolean;
188
+ /**
189
+ * The host cannot serve a message right now — e.g. the tenant's AI balance
190
+ * is spent and the agents are paused (Figma 954:28455). The composer stays
191
+ * in place but takes no input: the editor is disabled with `placeholder`
192
+ * where the prompt would be, and the `+` menu and Send are inert. Sends that
193
+ * bypass the composer (quick-action chips, imperative prompts) are the
194
+ * host's to hold back — it owns their callbacks. `null`/unset = live.
195
+ */
196
+ composerLock?: ChatComposerLock | null;
188
197
  /** Optional builders for chat-card types whose props live in hub-land
189
198
  * (programs + product_release). Forwarded straight to
190
199
  * `renderChatInlineEntityCard`. */
@@ -943,6 +952,7 @@ function EmbeddableChatInner({
943
952
  defaultOpen,
944
953
  showInternalTrigger = true,
945
954
  previewMode = false,
955
+ composerLock = null,
946
956
  extras,
947
957
  tableIdForDocumentType,
948
958
  modes,
@@ -1571,6 +1581,9 @@ function EmbeddableChatInner({
1571
1581
 
1572
1582
  const handleSend = useCallback(
1573
1583
  (text: string) => {
1584
+ // Locked: the disabled editor cannot submit, but a queued Enter or a
1585
+ // stale ref call still lands here — refused at the seam, not by luck.
1586
+ if (composerLock) return;
1574
1587
  // Append chat-attachment markdown lines to the user's bubble.
1575
1588
  let augmentedText = text;
1576
1589
  if (readyAttachments.length > 0) {
@@ -1598,7 +1611,7 @@ function EmbeddableChatInner({
1598
1611
  setContextItems([]);
1599
1612
  }
1600
1613
  },
1601
- [sendMessage, readyAttachments, viewUrlPrefix, clearAttachments, contextItems],
1614
+ [composerLock, sendMessage, readyAttachments, viewUrlPrefix, clearAttachments, contextItems],
1602
1615
  );
1603
1616
 
1604
1617
  /**
@@ -2649,6 +2662,7 @@ function EmbeddableChatInner({
2649
2662
 
2650
2663
  <ChatComposer
2651
2664
  archived={isViewingArchived}
2665
+ lock={composerLock}
2652
2666
  inputRef={chatInputRef}
2653
2667
  onSend={handleSend}
2654
2668
  onStop={stopMessage}
@@ -453,6 +453,13 @@ export interface ChatInputProps extends Omit<TextareaHTMLAttributes<HTMLTextArea
453
453
  */
454
454
  reserveAvatarOffset?: boolean;
455
455
  disabled?: boolean;
456
+ /**
457
+ * Placeholder shown while `disabled`, in the prompt's place — what the user
458
+ * cannot do right now. Defaults to the connection-lost copy, which is what
459
+ * `disabled` has meant so far (the ticket reply composer); a host that
460
+ * disables the input for another reason says so here.
461
+ */
462
+ disabledPlaceholder?: string;
456
463
  maxRows?: number;
457
464
  showSendButton?: boolean;
458
465
  sendButtonLabel?: string;
@@ -5,10 +5,10 @@ Draggable and static board ticket card components for a kanban-style board view,
5
5
 
6
6
  | Export | Type | Description |
7
7
  |---|---|---|
8
- | `TicketCardBody` | Component | Pure presentational inner content — title, device/org row, priority flag, assignees, tags, the `<ticketNumber> • <relative time>` line (either half alone when the other is missing), new message badge, and approval row |
8
+ | `TicketCardBody` | Component | Pure presentational inner content — title, device/org row, priority flag, assignees, tags, the `<ticketNumber> • <relative time>` line (either half alone when the other is missing; with `href` it is a link to the ticket, since it takes pointer events for its tooltip and would otherwise swallow the card's click), new message badge, and approval row |
9
9
  | `TicketCardView` | Component | Static, non-draggable card using the same shell and body as the board card; suitable for embeds, previews, and marketing |
10
10
  | `TicketCard` | Component | Full board card: registers itself as both draggable and drop target, fades in place while carried, draws the insertion line for the shared aim, and supports an optional `href` link plus drag/drop-disabled state |
11
- | `TicketCardBodyProps` | Interface | Props for the shared body: `ticket`, `columnColor`, `renderAssignSlot`, `onApprove`, `onReject` |
11
+ | `TicketCardBodyProps` | Interface | Props for the shared body: `ticket`, `columnColor`, `renderAssignSlot`, `onApprove`, `onReject`, and optional `href` / `onLinkClick` for the number/time line |
12
12
  | `TicketCardProps` | Interface | Extends body props with `columnId`, `href`, `dragDisabled`, `dropDisabled`, `allowedFromColumns`, and board-aware approve/reject signatures |
13
13
 
14
14
  **Nothing about the lane moves during a drag.** The card being carried stays exactly where it is,
@@ -7,6 +7,7 @@ import { preserveOffsetOnSource } from '@atlaskit/pragmatic-drag-and-drop/elemen
7
7
  import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
8
8
  import {
9
9
  memo,
10
+ useCallback,
10
11
  useEffect,
11
12
  useMemo,
12
13
  useRef,
@@ -82,11 +83,27 @@ export interface TicketCardBodyProps {
82
83
  * `TicketCard` adapts its own `(ticketId, requestId)` signature onto these). */
83
84
  onApprove?: (requestId?: string) => void | Promise<void>;
84
85
  onReject?: (requestId?: string) => void | Promise<void>;
86
+ /** Where the card opens. The body sits above the card's own link with pointer
87
+ * events off, but the number/time line has to take them back for its tooltip -
88
+ * so with an `href` that line is a link of its own, and a click on it opens the
89
+ * ticket like a click anywhere else on the card. That link is out of the tab
90
+ * order, so pass `href` only from a card that already renders its own focusable
91
+ * link to the same place, as `TicketCard` does. */
92
+ href?: string;
93
+ onLinkClick?: (e: MouseEvent) => void;
85
94
  }
86
95
 
87
96
  /** The card's inner content: title + device/org, priority + assignees, tags,
88
97
  * timestamp, "New Message", and the approval row. Pure — driven only by props. */
89
- export function TicketCardBody({ ticket, columnColor, renderAssignSlot, onApprove, onReject }: TicketCardBodyProps) {
98
+ export function TicketCardBody({
99
+ ticket,
100
+ columnColor,
101
+ renderAssignSlot,
102
+ onApprove,
103
+ onReject,
104
+ href,
105
+ onLinkClick,
106
+ }: TicketCardBodyProps) {
90
107
  const showNewMessage = !!ticket.hasNewMessage && !!columnColor;
91
108
  const newMessageTextColor = columnColor ? getReadableTextColor(columnColor) : undefined;
92
109
 
@@ -143,6 +160,22 @@ export function TicketCardBody({ ticket, columnColor, renderAssignSlot, onApprov
143
160
  // may be empty - the hub hero card and tickets without one send '' - so either
144
161
  // half stands on its own and the line disappears with both missing.
145
162
  const metaLabel = [ticket.ticketNumber || null, timestampLabel].filter(Boolean).join(' \u2022 ');
163
+ const metaClass = 'truncate text-ods-text-secondary text-h6';
164
+ // Out of the tab order: the card's own link is the one stop for the ticket.
165
+ const meta = href ? (
166
+ <Link
167
+ href={href}
168
+ draggable={false}
169
+ prefetch={false}
170
+ tabIndex={-1}
171
+ onClick={onLinkClick}
172
+ className={cn('pointer-events-auto block', metaClass)}
173
+ >
174
+ {metaLabel}
175
+ </Link>
176
+ ) : (
177
+ <p className={cn(tooltipLabel && 'pointer-events-auto', metaClass)}>{metaLabel}</p>
178
+ );
146
179
 
147
180
  return (
148
181
  <>
@@ -165,14 +198,12 @@ export function TicketCardBody({ ticket, columnColor, renderAssignSlot, onApprov
165
198
  (tooltipLabel ? (
166
199
  <TooltipProvider>
167
200
  <Tooltip>
168
- <TooltipTrigger asChild>
169
- <p className="pointer-events-auto truncate text-ods-text-secondary text-h6">{metaLabel}</p>
170
- </TooltipTrigger>
201
+ <TooltipTrigger asChild>{meta}</TooltipTrigger>
171
202
  <TooltipContent>{tooltipLabel}</TooltipContent>
172
203
  </Tooltip>
173
204
  </TooltipProvider>
174
205
  ) : (
175
- <p className="truncate text-ods-text-secondary text-h6">{metaLabel}</p>
206
+ meta
176
207
  ))}
177
208
  {ticket.escalatedByUser && (
178
209
  <div className="flex items-center gap-[var(--spacing-system-xxs)] text-ods-open-yellow text-h6">
@@ -215,7 +246,9 @@ export function TicketCardBody({ ticket, columnColor, renderAssignSlot, onApprov
215
246
  // drag-and-drop / board context (for embeds, marketing heroes, previews).
216
247
  // =============================================================================
217
248
 
218
- export interface TicketCardViewProps extends TicketCardBodyProps {
249
+ /** No `href`: the static card has no card-wide link for keyboard users, so the
250
+ * number/time line must not become a pointer-only one. */
251
+ export interface TicketCardViewProps extends Omit<TicketCardBodyProps, 'href' | 'onLinkClick'> {
219
252
  className?: string;
220
253
  /** Merged over the shell's own — the board uses it to fade the copy it leaves
221
254
  * behind and to place the one that follows the pointer. */
@@ -412,9 +445,13 @@ export const TicketCard = memo(function TicketCardImpl({
412
445
  const style: CSSProperties = {};
413
446
  if (showNewMessage) style.borderColor = columnColor;
414
447
 
415
- const handleClick = (e: MouseEvent) => {
416
- if (isDragging) e.preventDefault();
417
- };
448
+ // Stable between drags: it is handed into the memoized body below.
449
+ const handleClick = useCallback(
450
+ (e: MouseEvent) => {
451
+ if (isDragging) e.preventDefault();
452
+ },
453
+ [isDragging],
454
+ );
418
455
 
419
456
  // Held as one memoized element, not re-created per render. A card being
420
457
  // dragged over re-renders on every edge flip — it owns the hover state that
@@ -430,9 +467,11 @@ export const TicketCard = memo(function TicketCardImpl({
430
467
  renderAssignSlot={renderAssignSlot}
431
468
  onApprove={onApprove ? requestId => onApprove(ticket.id, requestId) : undefined}
432
469
  onReject={onReject ? requestId => onReject(ticket.id, requestId) : undefined}
470
+ href={href}
471
+ onLinkClick={handleClick}
433
472
  />
434
473
  ),
435
- [ticket, columnColor, renderAssignSlot, onApprove, onReject],
474
+ [ticket, columnColor, renderAssignSlot, onApprove, onReject, href, handleClick],
436
475
  );
437
476
 
438
477
  // No transition on the margins below, and that is not a style choice: room is
@@ -535,6 +535,27 @@ export const MingoOnly: Story = {
535
535
  args: {},
536
536
  };
537
537
 
538
+ /**
539
+ * The host cannot serve a message — the tenant's AI balance is spent and the
540
+ * agents are paused (Figma 954:28455). The panel reads as usual; the composer
541
+ * keeps its place, says why it takes nothing, and its `+` and Send are inert.
542
+ */
543
+ export const ComposerLocked: Story = {
544
+ render: args => (
545
+ <EmbeddableChat
546
+ {...args}
547
+ modes={{
548
+ mingo: createMockMingoConfig(),
549
+ }}
550
+ defaultActiveMode="mingo"
551
+ defaultOpen
552
+ showInternalTrigger={false}
553
+ composerLock={{ placeholder: 'Mingo chat unavailable. Top up your balance.' }}
554
+ />
555
+ ),
556
+ args: {},
557
+ };
558
+
538
559
  // =============================================================================
539
560
  // 3. Both modes (toggle visible — openframe-frontend target)
540
561
  // =============================================================================