@flamingo-stack/openframe-frontend-core 0.0.717 → 0.0.719

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 (106) hide show
  1. package/dist/{chunk-B5XU6KOA.js → chunk-2JLP2X2M.js} +4836 -5424
  2. package/dist/{chunk-47FISYO5.js → chunk-4D6NMPOO.js} +2 -2
  3. package/dist/{chunk-U3TP4UGA.cjs → chunk-4JIGEK7C.cjs} +8 -8
  4. package/dist/{chunk-2X2P3VHF.js → chunk-5U3KWQXP.js} +4 -4
  5. package/dist/{chunk-TYVS64YN.js → chunk-5YAGX5DY.js} +1 -1
  6. package/dist/{chunk-GEDY7HQI.cjs → chunk-6X3O67OL.cjs} +4 -0
  7. package/dist/{chunk-2EK7PA73.js → chunk-7BCY77W6.js} +5 -5
  8. package/dist/{chunk-X3RVDBXU.cjs → chunk-7BREFV7G.cjs} +19 -19
  9. package/dist/chunk-A6P6DHEZ.cjs +130 -0
  10. package/dist/{chunk-3MJPCKB6.js → chunk-AJHSQ5XK.js} +2 -2
  11. package/dist/{chunk-E6FLX4TX.js → chunk-CU6NWO2M.js} +6 -6
  12. package/dist/{chunk-6MZI5SQ4.js → chunk-D6PMVWDI.js} +3 -3
  13. package/dist/{chunk-2RKFMUNW.cjs → chunk-DFEPUYTF.cjs} +24 -21
  14. package/dist/{chunk-M2A5SC6H.cjs → chunk-E2V2EH2G.cjs} +2 -2
  15. package/dist/{chunk-WH5GJSID.js → chunk-EAHOB64X.js} +4 -4
  16. package/dist/{chunk-PD4W2VMH.js → chunk-FVQJBII6.js} +1 -1
  17. package/dist/{chunk-APJZBDQN.js → chunk-H5V7GRN5.js} +24 -21
  18. package/dist/{chunk-3RKBJT6K.cjs → chunk-H5VIMBRN.cjs} +2 -2
  19. package/dist/{chunk-NB6EWUDE.cjs → chunk-HCFNEKO3.cjs} +13 -13
  20. package/dist/{chunk-6T3BTD6Q.js → chunk-IWCPXJEE.js} +201 -327
  21. package/dist/{chunk-PPXR22YC.cjs → chunk-LCLDFMDF.cjs} +773 -61
  22. package/dist/{chunk-LANNTTKP.cjs → chunk-LOZLHG2U.cjs} +6 -6
  23. package/dist/{chunk-5MF3NMYC.cjs → chunk-NQVS4G7G.cjs} +581 -1169
  24. package/dist/{chunk-J3Q53TWW.cjs → chunk-NYWESHOQ.cjs} +22 -22
  25. package/dist/{chunk-FROXS52D.cjs → chunk-QBCDSTLE.cjs} +2 -2
  26. package/dist/{chunk-YBQQNDC7.js → chunk-RR6PY34K.js} +4 -0
  27. package/dist/{chunk-2AI2E3O2.cjs → chunk-SWAVO7BB.cjs} +27 -27
  28. package/dist/{chunk-3QAMMWV2.cjs → chunk-TDZ76NCB.cjs} +20 -20
  29. package/dist/{chunk-NQWJAOHO.js → chunk-TELAANY6.js} +1 -1
  30. package/dist/{chunk-FGKSAQR3.js → chunk-TTEFN6VO.js} +4 -4
  31. package/dist/{chunk-VYKFM2NB.cjs → chunk-WS7WRR5Z.cjs} +36 -36
  32. package/dist/{chunk-VZI5BA4L.js → chunk-XGHGGBM3.js} +1 -1
  33. package/dist/{chunk-NPAQSMRY.js → chunk-Y4MB25KW.js} +1103 -391
  34. package/dist/chunk-YYYVS7CX.js +130 -0
  35. package/dist/{chunk-OHLBO4LM.cjs → chunk-ZKNZ2CXW.cjs} +3 -3
  36. package/dist/{chunk-JO5TLJPM.cjs → chunk-ZXCZYMN4.cjs} +37 -163
  37. package/dist/{cloud-tenants-LNC26WY2.js → cloud-tenants-DNBPNQNP.js} +4 -3
  38. package/dist/{cloud-tenants-KDWTVHSI.cjs → cloud-tenants-FBUO66GW.cjs} +5 -4
  39. package/dist/components/case-studies/index.cjs +13 -12
  40. package/dist/components/case-studies/index.js +5 -4
  41. package/dist/components/contact/index.cjs +6 -5
  42. package/dist/components/contact/index.js +5 -4
  43. package/dist/components/docs/index.cjs +8 -7
  44. package/dist/components/docs/index.js +7 -6
  45. package/dist/components/embeds/index.cjs +6 -5
  46. package/dist/components/embeds/index.js +5 -4
  47. package/dist/components/faq/index.cjs +7 -6
  48. package/dist/components/faq/index.js +6 -5
  49. package/dist/components/features/index.cjs +9 -4
  50. package/dist/components/features/index.js +8 -3
  51. package/dist/components/features/remote-session/index.d.ts +2 -0
  52. package/dist/components/features/remote-session/index.d.ts.map +1 -1
  53. package/dist/components/features/remote-session/remote-desktop-view.d.ts +6 -2
  54. package/dist/components/features/remote-session/remote-desktop-view.d.ts.map +1 -1
  55. package/dist/components/features/remote-session/remote-session-keep-modals.d.ts +3 -3
  56. package/dist/components/features/remote-session/remote-session-keep-modals.d.ts.map +1 -1
  57. package/dist/components/features/remote-session/remote-session-ticket-modals.d.ts +29 -0
  58. package/dist/components/features/remote-session/remote-session-ticket-modals.d.ts.map +1 -0
  59. package/dist/components/help-center-pages/index.cjs +51 -50
  60. package/dist/components/help-center-pages/index.js +15 -14
  61. package/dist/components/index.cjs +33 -28
  62. package/dist/components/index.js +21 -16
  63. package/dist/components/meeting-scheduler/index.cjs +25 -24
  64. package/dist/components/meeting-scheduler/index.js +4 -3
  65. package/dist/components/navigation/index.cjs +6 -5
  66. package/dist/components/navigation/index.js +5 -4
  67. package/dist/components/onboarding-guides/index.cjs +9 -8
  68. package/dist/components/onboarding-guides/index.js +8 -7
  69. package/dist/components/product-demo/index.cjs +7 -7
  70. package/dist/components/product-demo/index.js +7 -7
  71. package/dist/components/related-content/index.cjs +3 -3
  72. package/dist/components/related-content/index.js +2 -2
  73. package/dist/components/related-content/related-content-section.d.ts +6 -6
  74. package/dist/components/related-content/related-content-section.d.ts.map +1 -1
  75. package/dist/components/tickets/index.cjs +8 -7
  76. package/dist/components/tickets/index.js +7 -6
  77. package/dist/components/ui/autocomplete.d.ts +6 -0
  78. package/dist/components/ui/autocomplete.d.ts.map +1 -1
  79. package/dist/components/ui/index.cjs +7 -6
  80. package/dist/components/ui/index.js +6 -5
  81. package/dist/{devices-ATHYJUVQ.cjs → devices-5A5TY4JD.cjs} +6 -5
  82. package/dist/{devices-NYCRQ3PG.js → devices-PLDAEUSL.js} +4 -3
  83. package/dist/hooks/index.cjs +6 -4
  84. package/dist/hooks/index.js +5 -3
  85. package/dist/hooks/use-self-fetch.d.ts +3 -0
  86. package/dist/hooks/use-self-fetch.d.ts.map +1 -1
  87. package/dist/index.cjs +14 -8
  88. package/dist/index.js +15 -9
  89. package/dist/{knowledge-EHNRYT7U.cjs → knowledge-FKTJ723Z.cjs} +5 -4
  90. package/dist/{knowledge-DEKYJXBS.js → knowledge-G6Q5KRWV.js} +4 -3
  91. package/dist/{logs-3V765P7O.cjs → logs-CJCXUBU6.cjs} +13 -12
  92. package/dist/{logs-Q7YFIVYI.js → logs-QMV6XIC4.js} +4 -3
  93. package/dist/{policies-BTIENK5A.js → policies-G6D7AX6F.js} +4 -3
  94. package/dist/{policies-TXU5AIXS.cjs → policies-QMRTK3CY.cjs} +9 -8
  95. package/dist/{remote-session-R6VAFESR.cjs → remote-session-2JVI7RVA.cjs} +4 -3
  96. package/dist/{remote-session-GSGZOFDX.js → remote-session-DBD6OSTX.js} +2 -1
  97. package/dist/{software-update-SW2HOBHW.js → software-update-6BQM6BKH.js} +6 -4
  98. package/dist/{software-update-MINTICZY.cjs → software-update-PNZMRDBT.cjs} +11 -9
  99. package/package.json +1 -1
  100. package/src/components/features/remote-session/index.ts +2 -0
  101. package/src/components/features/remote-session/remote-desktop-view.tsx +86 -63
  102. package/src/components/features/remote-session/remote-session-keep-modals.tsx +4 -4
  103. package/src/components/features/remote-session/remote-session-ticket-modals.tsx +140 -0
  104. package/src/components/related-content/related-content-section.tsx +46 -47
  105. package/src/components/ui/autocomplete.tsx +18 -4
  106. package/src/hooks/use-self-fetch.ts +7 -0
@@ -4,6 +4,7 @@ import type { ReactNode } from 'react';
4
4
  import {
5
5
  ChatOffIcon,
6
6
  ChatTextIcon,
7
+ Chevron02DownIcon,
7
8
  Collapse02Icon,
8
9
  Expand02Icon,
9
10
  MonitorIcon,
@@ -19,8 +20,12 @@ import { RemoteDesktopFullscreenToolbar } from './remote-desktop-fullscreen-tool
19
20
  /** The page chrome both the view and its skeleton render in. */
20
21
  const PAGE_CLASS = 'h-full overflow-hidden px-[var(--spacing-system-l)] pb-[var(--spacing-system-l)]';
21
22
 
23
+ /** The header card: the device row, and under it the display row when there is one to pick. */
22
24
  const CONTROLS_BAR_CLASS =
23
- 'flex flex-shrink-0 items-center justify-between gap-[var(--spacing-system-mf)] rounded-md border border-ods-border bg-ods-card px-[var(--spacing-system-mf)] py-[var(--spacing-system-xs)]';
25
+ 'flex flex-shrink-0 flex-col overflow-hidden rounded-md border border-ods-border bg-ods-card';
26
+
27
+ const CONTROLS_ROW_CLASS =
28
+ 'flex items-center justify-between gap-[var(--spacing-system-mf)] px-[var(--spacing-system-mf)] py-[var(--spacing-system-xs)]';
24
29
 
25
30
  export interface RemoteDesktopViewProps {
26
31
  /** First line of the device header. */
@@ -36,9 +41,13 @@ export interface RemoteDesktopViewProps {
36
41
  fullscreen?: boolean;
37
42
  onEnterFullscreen: () => void;
38
43
  onExitFullscreen: () => void;
39
- /** The display switcher's menu; the switcher is hidden while this is empty (a single display). */
44
+ /**
45
+ * The display switcher's menu, one entry per separate display. The switcher is
46
+ * the full-width row under the device row (Figma 2155:109503), and it is left
47
+ * out while this is empty: one display, or an agent that reports none.
48
+ */
40
49
  displayMenuGroups?: ActionsMenuGroup[];
41
- /** The switcher's label, e.g. "Display All" or "Display 2". */
50
+ /** The switcher's label, e.g. "Display 2", or "Display" before the agent has named one. */
42
51
  currentDisplayLabel: string;
43
52
  actionsMenuGroups: ActionsMenuGroup[];
44
53
  onOpenSettings: () => void;
@@ -86,61 +95,73 @@ export function RemoteDesktopView({
86
95
  }: RemoteDesktopViewProps) {
87
96
  const controlsBar = (
88
97
  <div className={CONTROLS_BAR_CLASS}>
89
- <div className="flex min-w-0 items-center gap-[var(--spacing-system-mf)]">
90
- <div className="flex-shrink-0 rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-xsf)]">
91
- <MonitorIcon className="h-4 w-4 text-ods-text-primary" />
92
- </div>
93
- <div className="flex min-w-0 flex-col">
94
- <TruncateText>{deviceName}</TruncateText>
95
- <TruncateText
96
- variant="h6"
97
- tone="secondary"
98
- >{`Desktop • ${organizationName || 'Unknown Customer'}`}</TruncateText>
98
+ <div className={CONTROLS_ROW_CLASS}>
99
+ <div className="flex min-w-0 items-center gap-[var(--spacing-system-mf)]">
100
+ <div className="flex-shrink-0 rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-xsf)]">
101
+ <MonitorIcon className="h-4 w-4 text-ods-text-primary" />
102
+ </div>
103
+ <div className="flex min-w-0 flex-col">
104
+ <TruncateText>{deviceName}</TruncateText>
105
+ <TruncateText
106
+ variant="h6"
107
+ tone="secondary"
108
+ >{`Desktop • ${organizationName || 'Unknown Customer'}`}</TruncateText>
109
+ </div>
99
110
  </div>
100
- </div>
101
- <div className="flex flex-shrink-0 items-center gap-[var(--spacing-system-xs)]">
102
- {displayMenuGroups.length > 0 && (
103
- <ActionsMenuDropdown
104
- groups={displayMenuGroups}
105
- customTrigger={
106
- <Button variant="outline" leftIcon={<MonitorIcon className="h-4 w-4 md:h-6 md:w-6" />}>
107
- {/* A narrow page keeps the icon and drops the words: the bar never pushes the device's name out. */}
108
- <span className="sr-only content-md:not-sr-only">{currentDisplayLabel}</span>
109
- </Button>
110
- }
111
+ <div className="flex flex-shrink-0 items-center gap-[var(--spacing-system-xs)]">
112
+ {onToggleChat && (
113
+ <Button
114
+ variant="outline"
115
+ onClick={onToggleChat}
116
+ leftIcon={
117
+ chatOpen ? (
118
+ <ChatOffIcon className="h-4 w-4 md:h-6 md:w-6" />
119
+ ) : (
120
+ <ChatTextIcon className="h-4 w-4 md:h-6 md:w-6" />
121
+ )
122
+ }
123
+ >
124
+ {/* A narrow page keeps the icon and drops the words: the bar never pushes the device's name out. */}
125
+ <span className="sr-only content-md:not-sr-only">{chatOpen ? 'Close Chat' : 'Open Chat'}</span>
126
+ </Button>
127
+ )}
128
+ <ActionsMenuDropdown groups={actionsMenuGroups} triggerAriaLabel="Actions" />
129
+ <Button
130
+ variant="outline"
131
+ size="icon"
132
+ aria-label="Settings"
133
+ onClick={onOpenSettings}
134
+ leftIcon={<Settings01Icon />}
111
135
  />
112
- )}
113
- {onToggleChat && (
114
136
  <Button
115
137
  variant="outline"
116
- onClick={onToggleChat}
117
- leftIcon={
118
- chatOpen ? (
119
- <ChatOffIcon className="h-4 w-4 md:h-6 md:w-6" />
120
- ) : (
121
- <ChatTextIcon className="h-4 w-4 md:h-6 md:w-6" />
122
- )
123
- }
124
- >
125
- <span className="sr-only content-md:not-sr-only">{chatOpen ? 'Close Chat' : 'Open Chat'}</span>
126
- </Button>
127
- )}
128
- <ActionsMenuDropdown groups={actionsMenuGroups} triggerAriaLabel="Actions" />
129
- <Button
130
- variant="outline"
131
- size="icon"
132
- aria-label="Settings"
133
- onClick={onOpenSettings}
134
- leftIcon={<Settings01Icon />}
135
- />
136
- <Button
137
- variant="outline"
138
- size="icon"
139
- aria-label={fullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
140
- onClick={fullscreen ? onExitFullscreen : onEnterFullscreen}
141
- leftIcon={fullscreen ? <Collapse02Icon /> : <Expand02Icon />}
142
- />
138
+ size="icon"
139
+ aria-label={fullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
140
+ onClick={fullscreen ? onExitFullscreen : onEnterFullscreen}
141
+ leftIcon={fullscreen ? <Collapse02Icon /> : <Expand02Icon />}
142
+ />
143
+ </div>
143
144
  </div>
145
+ {/* The monitor selector is a row of its own under the device row, the full
146
+ width of the card (Figma 2155:109503): the label never competes with the
147
+ device's name for the header's width. Hidden with one display or none. */}
148
+ {displayMenuGroups.length > 0 && (
149
+ <ActionsMenuDropdown
150
+ groups={displayMenuGroups}
151
+ align="start"
152
+ customTrigger={
153
+ <button
154
+ type="button"
155
+ aria-label="Switch display"
156
+ className="flex w-full items-center gap-[var(--spacing-system-xs)] border-t border-ods-border p-[var(--spacing-system-sf)] text-left text-ods-text-primary outline-none transition-colors hover:bg-ods-bg-hover focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ods-focus"
157
+ >
158
+ <MonitorIcon className="h-6 w-6 shrink-0" />
159
+ <span className="min-w-0 flex-1 truncate text-h4">{currentDisplayLabel}</span>
160
+ <Chevron02DownIcon className="h-6 w-6 shrink-0" />
161
+ </button>
162
+ }
163
+ />
164
+ )}
144
165
  </div>
145
166
  );
146
167
 
@@ -189,17 +210,19 @@ export function RemoteDesktopViewSkeleton({ onBack }: Pick<RemoteDesktopViewProp
189
210
  return (
190
211
  <PageLayout className={PAGE_CLASS} backButton={onBack ? { label: 'Back', onClick: onBack } : undefined}>
191
212
  <div className={CONTROLS_BAR_CLASS}>
192
- <div className="flex min-w-0 items-center gap-[var(--spacing-system-mf)]">
193
- <Skeleton className="h-9 w-9 flex-shrink-0 rounded-md" />
194
- <div className="flex min-w-0 flex-col gap-[var(--spacing-system-xxs)]">
195
- <Skeleton className="h-5 w-48" />
196
- <Skeleton className="h-4 w-36" />
213
+ <div className={CONTROLS_ROW_CLASS}>
214
+ <div className="flex min-w-0 items-center gap-[var(--spacing-system-mf)]">
215
+ <Skeleton className="h-9 w-9 flex-shrink-0 rounded-md" />
216
+ <div className="flex min-w-0 flex-col gap-[var(--spacing-system-xxs)]">
217
+ <Skeleton className="h-5 w-48" />
218
+ <Skeleton className="h-4 w-36" />
219
+ </div>
220
+ </div>
221
+ <div className="flex flex-shrink-0 items-center gap-[var(--spacing-system-xs)]">
222
+ <Skeleton className="h-11 w-11 rounded-lg md:h-12 md:w-12" />
223
+ <Skeleton className="h-11 w-11 rounded-lg md:h-12 md:w-12" />
224
+ <Skeleton className="h-11 w-11 rounded-lg md:h-12 md:w-12" />
197
225
  </div>
198
- </div>
199
- <div className="flex flex-shrink-0 items-center gap-[var(--spacing-system-xs)]">
200
- <Skeleton className="h-11 w-11 rounded-lg md:h-12 md:w-12" />
201
- <Skeleton className="h-11 w-11 rounded-lg md:h-12 md:w-12" />
202
- <Skeleton className="h-11 w-11 rounded-lg md:h-12 md:w-12" />
203
226
  </div>
204
227
  </div>
205
228
 
@@ -151,8 +151,8 @@ export interface ReleaseKeepingModalProps {
151
151
  keptOn: string;
152
152
  /** The reason as it reads in the sentence, e.g. "Client dispute". */
153
153
  reason: string;
154
- /** The ticket or reference the Keep was placed for. */
155
- ticket?: string | null;
154
+ /** The tickets assigned to the recording, as they read in the line, e.g. "TKT-4631"; the line is left out without any. */
155
+ tickets?: string[];
156
156
  /** When the recording will be deleted once released, formatted. */
157
157
  expiresOn: string;
158
158
  /** The original expiry date when it has already passed, so the release adds the grace period. */
@@ -169,7 +169,7 @@ export function ReleaseKeepingModal({
169
169
  keptBy,
170
170
  keptOn,
171
171
  reason,
172
- ticket,
172
+ tickets = [],
173
173
  expiresOn,
174
174
  dueOn,
175
175
  isPending = false,
@@ -185,7 +185,7 @@ export function ReleaseKeepingModal({
185
185
  <p>
186
186
  Kept by {keptBy} on {keptOn} for {reason}.
187
187
  </p>
188
- {ticket && <p>Ticket: {ticket}</p>}
188
+ {tickets.length > 0 && <p>Ticket: {tickets.join(', ')}</p>}
189
189
  </div>
190
190
 
191
191
  <KeepNotice icon={<HourglassIcon size={24} color="currentColor" />} title={`It will expire on ${expiresOn}`}>
@@ -0,0 +1,140 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { Autocomplete, type AutocompleteOption } from '../../ui/autocomplete';
5
+ import { Button } from '../../ui/button/button';
6
+ import { ModalV2, ModalV2Footer, ModalV2Header, ModalV2Title } from '../../ui/modal-v2';
7
+
8
+ export interface AssignTicketModalProps {
9
+ isOpen: boolean;
10
+ onClose: () => void;
11
+ /**
12
+ * The tickets that can be assigned: label = title, value = ticket id. Leave
13
+ * out the ones already assigned to the recording.
14
+ */
15
+ options: AutocompleteOption<string>[];
16
+ /** Server-side search: called with the typed text; the caller refreshes `options`. Client-side filtering otherwise. */
17
+ onSearch?: (query: string) => void;
18
+ /** The ticket search is in flight. */
19
+ loading?: boolean;
20
+ /** The assignment is in flight: the button spins and the dialog refuses to close. */
21
+ isPending?: boolean;
22
+ onConfirm: (ticketIds: string[]) => void;
23
+ }
24
+
25
+ /** Assign Ticket: links one or more tickets to a recording. */
26
+ export function AssignTicketModal({
27
+ isOpen,
28
+ onClose,
29
+ options,
30
+ onSearch,
31
+ loading = false,
32
+ isPending = false,
33
+ onConfirm,
34
+ }: AssignTicketModalProps) {
35
+ const [ticketIds, setTicketIds] = useState<string[]>([]);
36
+ // Titles of the picked tickets: a server-side search replaces `options`, and
37
+ // a picked ticket missing from the new results would otherwise show its id.
38
+ const [titles, setTitles] = useState<Record<string, string>>({});
39
+
40
+ // Every opening starts blank. Reset while rendering, not from an effect: the
41
+ // modal stays mounted when it closes, so an effect would paint the opening
42
+ // frame with the previous selection still in the field.
43
+ const [seededOpen, setSeededOpen] = useState(isOpen);
44
+ if (seededOpen !== isOpen) {
45
+ setSeededOpen(isOpen);
46
+ if (isOpen) {
47
+ setTicketIds([]);
48
+ setTitles({});
49
+ }
50
+ }
51
+
52
+ const handleChange = (ids: string[]) => {
53
+ setTicketIds(ids);
54
+ setTitles(prev => {
55
+ const next: Record<string, string> = {};
56
+ for (const id of ids) next[id] = options.find(option => option.value === id)?.label ?? prev[id] ?? id;
57
+ return next;
58
+ });
59
+ };
60
+
61
+ const handleAssign = () => {
62
+ if (ticketIds.length === 0 || isPending) return;
63
+ onConfirm(ticketIds);
64
+ };
65
+
66
+ return (
67
+ <ModalV2 isOpen={isOpen} onClose={isPending ? () => {} : onClose} className="text-left md:max-w-[600px]">
68
+ <ModalV2Header>
69
+ <ModalV2Title>Assign Ticket</ModalV2Title>
70
+ </ModalV2Header>
71
+
72
+ <Autocomplete
73
+ multiple
74
+ wrapTags
75
+ label="Tickets"
76
+ labelVariant="large"
77
+ options={options}
78
+ value={ticketIds}
79
+ onChange={handleChange}
80
+ renderTag={option => titles[option.value] ?? option.label}
81
+ onInputChange={onSearch ? (value, reason) => reason === 'input' && onSearch(value) : undefined}
82
+ disableClientFilter={Boolean(onSearch)}
83
+ loading={loading}
84
+ noOptionsText="No tickets found"
85
+ disabled={isPending}
86
+ />
87
+
88
+ <ModalV2Footer>
89
+ <Button type="button" variant="outline" onClick={onClose} disabled={isPending} className="flex-1 md:hidden">
90
+ Cancel
91
+ </Button>
92
+ <div className="hidden flex-1 md:block" />
93
+ <Button
94
+ type="button"
95
+ variant="accent"
96
+ onClick={handleAssign}
97
+ loading={isPending}
98
+ disabled={ticketIds.length === 0}
99
+ className="flex-1"
100
+ >
101
+ Assign
102
+ </Button>
103
+ </ModalV2Footer>
104
+ </ModalV2>
105
+ );
106
+ }
107
+
108
+ export interface UnassignTicketModalProps {
109
+ isOpen: boolean;
110
+ onClose: () => void;
111
+ /** The unassignment is in flight: the button spins and the dialog refuses to close. */
112
+ isPending?: boolean;
113
+ onConfirm: () => void;
114
+ }
115
+
116
+ /** Unassign Ticket: unlinks a ticket from a recording; the ticket itself is unchanged. */
117
+ export function UnassignTicketModal({ isOpen, onClose, isPending = false, onConfirm }: UnassignTicketModalProps) {
118
+ return (
119
+ <ModalV2 isOpen={isOpen} onClose={isPending ? () => {} : onClose} className="text-left md:max-w-[600px]">
120
+ <ModalV2Header>
121
+ <ModalV2Title>Unassign Ticket</ModalV2Title>
122
+ </ModalV2Header>
123
+
124
+ <p className="w-full text-ods-text-primary text-h4">
125
+ This Ticket will no longer be linked to this remote session. The ticket itself stays as it is, and you can
126
+ assign it again anytime.
127
+ </p>
128
+
129
+ <ModalV2Footer>
130
+ <Button type="button" variant="outline" onClick={onClose} disabled={isPending} className="flex-1 md:hidden">
131
+ Cancel
132
+ </Button>
133
+ <div className="hidden flex-1 md:block" />
134
+ <Button type="button" variant="destructive" onClick={onConfirm} loading={isPending} className="flex-1">
135
+ Unassign
136
+ </Button>
137
+ </ModalV2Footer>
138
+ </ModalV2>
139
+ );
140
+ }
@@ -28,12 +28,12 @@
28
28
  * `renderSkeletonForType` so the placeholder height matches the loaded card
29
29
  * exactly (zero layout shift on resolve).
30
30
  *
31
- * One API call per content type via the shared list-URL builder
32
- * (`buildListUrl` — injectable; defaults to the lib's byte-parity-tested
33
- * builder prefixed with `apiBaseUrl`). Fetching uses `useSelfFetch` (plain
34
- * fetch, NO react-query) so third-party embedders need no QueryClientProvider;
35
- * cards are imported via DEEP module paths (not the chat barrel) so this
36
- * chunk never reaches `@tanstack/react-query`.
31
+ * One API call per page of a content type, carrying only that page's ids, via
32
+ * the shared list-URL builder (`buildListUrl` — injectable; defaults to the
33
+ * lib's byte-parity-tested builder prefixed with `apiBaseUrl`). Fetching uses
34
+ * `useSelfFetch` (plain fetch, NO react-query) so third-party embedders need
35
+ * no QueryClientProvider; cards are imported via DEEP module paths (not the
36
+ * chat barrel) so this chunk never reaches `@tanstack/react-query`.
37
37
  *
38
38
  * The per-type card + skeleton dispatch lives in `./card-registry`
39
39
  * (`RELATED_CARD_REGISTRY`): ONE entry per content type, each carrying that
@@ -49,7 +49,7 @@
49
49
  */
50
50
 
51
51
  import type React from 'react';
52
- import { useEffect, useMemo, useRef, useState } from 'react';
52
+ import { useMemo, useState } from 'react';
53
53
  import { useSelfFetch } from '../../hooks/use-self-fetch';
54
54
  import type { ContentRef, ContentRefWithReason } from '../../types/content-ref';
55
55
  import {
@@ -246,23 +246,29 @@ function CardForType({
246
246
  }
247
247
 
248
248
  // =============================================================================
249
- // Fetch all items for a type in ONE server-sorted call, via the injectable
250
- // list-URL builder. `useSelfFetch` (URL = cache key) replaces the hub's old
251
- // react-query usage: `enabled` ≙ `url === null`, `!res.ok`/network error ≙
252
- // `error → items null → group renders nothing`. Accepted deltas vs
253
- // react-query: no retry/backoff, no focus refetch, no cross-mount cache.
249
+ // Fetch ONE page of a group — only the ids on screen go into the list URL.
250
+ // Pages already fetched are kept by url, so paging back renders without a
251
+ // request. `useSelfFetch` replaces the hub's old react-query usage: `enabled`
252
+ // ≙ `url === null` (no list API → `items: null`), `!res.ok`/network error ≙
253
+ // an empty page. Accepted deltas vs react-query: no retry/backoff, no focus
254
+ // refetch, no cross-mount cache.
254
255
  // =============================================================================
255
256
 
256
257
  function useGroupItems(type: string, refs: ContentRef[], buildUrl: (type: string, ids: string[]) => string | null) {
257
258
  const ids = refs.map(r => r.id);
258
259
  const url = ids.length > 0 ? buildUrl(type, ids) : null;
259
- const { data, isLoading } = useSelfFetch<unknown>(url);
260
- const items = data != null ? extractItems(data) : null;
261
- return { items, isLoading };
260
+ const [pages, setPages] = useState<ReadonlyMap<string, unknown[]>>(() => new Map());
261
+ const cached = url !== null ? pages.get(url) : undefined;
262
+ const { data, dataUrl, error } = useSelfFetch<unknown>(cached ? null : url);
263
+ if (data != null && dataUrl !== null && !pages.has(dataUrl)) {
264
+ setPages(new Map(pages).set(dataUrl, extractItems(data)));
265
+ }
266
+ if (url === null) return { items: null, isLoading: false };
267
+ return { items: cached ?? (error ? [] : null), isLoading: !cached && !error };
262
268
  }
263
269
 
264
270
  // =============================================================================
265
- // Per-group renderer — one API call, server-sorted, then render cards via the
271
+ // Per-group renderer — one API call per page, then render cards via the
266
272
  // dispatcher with per-type skeletons + per-type layout from CONTENT_REF_GROUPS.
267
273
  // =============================================================================
268
274
 
@@ -325,41 +331,33 @@ function ContentGroup({
325
331
  * its heading too — no orphaned titles. */
326
332
  heading: React.ReactNode;
327
333
  }) {
328
- const { items, isLoading } = useGroupItems(type, refs, buildUrl);
329
334
  const config = resolveGroupConfig(type);
330
335
  const isListLayout = config.layout === 'list';
331
336
  const cardSize = config.gridSize;
332
337
 
333
338
  // Per-group pagination for big groups (author pages): GROUP_PAGE_SIZE items
334
- // per page with the standard Pagination control below the group. Client-side
335
- // slicing — useGroupItems already fetched every row in one batched call, so
336
- // page flips are instant. Hooks live above every early return (file
337
- // convention). Page is clamped so a shrinking refs array (suggestion
338
- // refetch) can never strand the view past the last page, and RESET when the
339
- // ref set actually changes (shrink→grow must not return to a stale page).
339
+ // per page with the standard Pagination control below the group, each page
340
+ // fetched on its own. Back to page 1 whenever the ref set changes (a
341
+ // suggestion refetch can shrink it) — reset while rendering, so the stale
342
+ // page is never requested.
340
343
  const [page, setPage] = useState(1);
341
344
  const refsKey = refs.map(r => r.id).join('|');
342
- const prevRefsKeyRef = useRef(refsKey);
343
- useEffect(() => {
344
- if (prevRefsKeyRef.current !== refsKey) {
345
- prevRefsKeyRef.current = refsKey;
346
- setPage(1);
347
- }
348
- }, [refsKey]);
345
+ const [pagedRefsKey, setPagedRefsKey] = useState(refsKey);
346
+ if (pagedRefsKey !== refsKey) {
347
+ setPagedRefsKey(refsKey);
348
+ setPage(1);
349
+ }
349
350
  const totalGroupPages = pageCount(refs.length, GROUP_PAGE_SIZE);
350
- const safePage = Math.min(page, totalGroupPages);
351
- const visibleGroupRefs =
352
- refs.length > GROUP_PAGE_SIZE ? refs.slice((safePage - 1) * GROUP_PAGE_SIZE, safePage * GROUP_PAGE_SIZE) : refs;
351
+ const pageRefs = refs.slice((page - 1) * GROUP_PAGE_SIZE, page * GROUP_PAGE_SIZE);
352
+ const { items, isLoading } = useGroupItems(type, pageRefs, buildUrl);
353
353
  const groupPagination =
354
- totalGroupPages > 1 ? (
355
- <Pagination currentPage={safePage} totalPages={totalGroupPages} onPageChange={setPage} />
356
- ) : null;
357
-
358
- // Skeleton gate: `isLoading && !items` — SSR HTML and the client's first
359
- // paint render identical skeletons (useSelfFetch starts isLoading=true on
360
- // both sides), and once items exist they are never replaced by skeletons.
361
- if (isLoading && !items) {
362
- const skeletons = visibleGroupRefs.map(r => (
354
+ totalGroupPages > 1 ? <Pagination currentPage={page} totalPages={totalGroupPages} onPageChange={setPage} /> : null;
355
+
356
+ // Skeletons until this page's rows land — SSR HTML and the client's first
357
+ // paint render identical skeletons (useSelfFetch starts loading on both
358
+ // sides). The pager stays in place while an unfetched page loads.
359
+ if (isLoading) {
360
+ const skeletons = pageRefs.map(r => (
363
361
  <div key={r.id}>{renderSkeletonForType(type, cardSize, adminCampaignCard)}</div>
364
362
  ));
365
363
  return (
@@ -370,11 +368,12 @@ function ContentGroup({
370
368
  ) : (
371
369
  <div className={gridClassFor(columns)}>{skeletons}</div>
372
370
  )}
371
+ {groupPagination}
373
372
  </div>
374
373
  );
375
374
  }
376
375
 
377
- if (!items || items.length === 0) return null;
376
+ if (!items) return null;
378
377
 
379
378
  // Index fetched rows by id, then render in REF order — refs carry the
380
379
  // intended sequence (suggestion mode: the engine's tier order, so
@@ -393,7 +392,7 @@ function ContentGroup({
393
392
  };
394
393
  const itemById = new Map(items.map(it => [rowKey(it), it]));
395
394
 
396
- const cards = visibleGroupRefs
395
+ const cards = pageRefs
397
396
  .map(contentRef => {
398
397
  const itemId = String(contentRef.id);
399
398
  const item = itemById.get(itemId);
@@ -426,9 +425,9 @@ function ContentGroup({
426
425
  .filter(Boolean);
427
426
 
428
427
  if (cards.length === 0) {
429
- // Current PAGE resolved zero cards (rows deleted between the ref fetch
430
- // and the group fetch, or a stricter list-API gate dropped them). When a
431
- // pager exists the user must keep the controls to navigate back —
428
+ // Current PAGE resolved zero cards (its fetch failed, rows were deleted
429
+ // between the ref fetch and the page fetch, or a stricter list-API gate
430
+ // dropped them). When a pager exists the user must keep the controls —
432
431
  // dropping the whole group would strand them. A genuinely empty group
433
432
  // (no pager) still vanishes with its heading.
434
433
  if (groupPagination) {
@@ -140,6 +140,12 @@ export interface AutocompleteMultipleProps<T = string> extends AutocompleteBaseP
140
140
  limitTags?: number | 'auto';
141
141
  /** Custom render function for the "+N" overflow chip */
142
142
  getLimitTagsText?: (more: number) => ReactNode;
143
+ /**
144
+ * Show every tag in full, wrapping onto more lines: the field grows instead
145
+ * of truncating labels and collapsing the rest into "+N". `limitTags` is
146
+ * ignored. For short pickers whose picks must stay readable (ticket titles).
147
+ */
148
+ wrapTags?: boolean;
143
149
  }
144
150
 
145
151
  export type AutocompleteProps<T = string> = AutocompleteSingleProps<T> | AutocompleteMultipleProps<T>;
@@ -189,6 +195,7 @@ function AutocompleteInner<T = string>(props: AutocompleteProps<T>, ref: Forward
189
195
  // Multiple-only props
190
196
  const maxItems = multiple ? (props as AutocompleteMultipleProps<T>).maxItems : undefined;
191
197
  const renderTag = multiple ? (props as AutocompleteMultipleProps<T>).renderTag : undefined;
198
+ const wrapTags = multiple && ((props as AutocompleteMultipleProps<T>).wrapTags ?? false);
192
199
  const limitTagsProp = multiple ? ((props as AutocompleteMultipleProps<T>).limitTags ?? 'auto') : 'auto';
193
200
  const getLimitTagsText = multiple
194
201
  ? ((props as AutocompleteMultipleProps<T>).getLimitTagsText ?? ((more: number) => `+${more}`))
@@ -252,7 +259,7 @@ function AutocompleteInner<T = string>(props: AutocompleteProps<T>, ref: Forward
252
259
  inputRef,
253
260
  } = useAutoLimitTags({
254
261
  count: multiple ? selectedOptions.length : 0,
255
- limitTags: multiple ? limitTagsProp : 0,
262
+ limitTags: multiple ? (wrapTags ? selectedOptions.length : limitTagsProp) : 0,
256
263
  placeholder: inputPlaceholder,
257
264
  });
258
265
 
@@ -506,15 +513,22 @@ function AutocompleteInner<T = string>(props: AutocompleteProps<T>, ref: Forward
506
513
  </span>
507
514
  )}
508
515
 
509
- {/* Middle zone: tags + input — single line with overflow */}
510
- <div ref={middleRef} className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden px-2">
516
+ {/* Middle zone: tags + input — single line with overflow, or wrapped lines with `wrapTags` */}
517
+ <div
518
+ ref={middleRef}
519
+ className={cn(
520
+ 'flex min-w-0 flex-1 items-center gap-2 px-2',
521
+ wrapTags ? 'flex-wrap py-[var(--spacing-system-xsf)]' : 'overflow-hidden',
522
+ )}
523
+ >
511
524
  {/* Tags (multiple mode only) */}
512
525
  {multiple &&
513
526
  visibleTags.map(option => (
514
527
  <Tag
515
528
  key={String(option.value)}
516
529
  variant="outline"
517
- labelClassName="max-w-[90px] truncate"
530
+ className={wrapTags ? 'max-w-full' : undefined}
531
+ labelClassName={wrapTags ? 'truncate' : 'max-w-[90px] truncate'}
518
532
  label={renderTag ? renderTag(option) : option.label}
519
533
  onClose={
520
534
  !disabled
@@ -5,6 +5,9 @@ import { contentFetch } from '../utils/embed-content-fetch';
5
5
 
6
6
  export interface UseSelfFetchResult<T> {
7
7
  data: T | null;
8
+ /** The url `data` came from (`null` until something lands). While a new url
9
+ * loads, `data` still holds the previous one — compare before trusting it. */
10
+ dataUrl: string | null;
8
11
  /** Imperatively patch the fetched data (e.g. optimistic vote updates). */
9
12
  setData: Dispatch<SetStateAction<T | null>>;
10
13
  isLoading: boolean;
@@ -48,6 +51,7 @@ export function useSelfFetch<T>(
48
51
  const initialData = options?.initialData;
49
52
  const revalidateOnVisibleAfterMs = options?.revalidateOnVisibleAfterMs;
50
53
  const [data, setData] = useState<T | null>(initialData ?? null);
54
+ const [dataUrl, setDataUrl] = useState<string | null>(initialData !== undefined ? url : null);
51
55
  const [isLoading, setIsLoading] = useState(initialData === undefined && url !== null);
52
56
  const [error, setError] = useState(false);
53
57
  const [reloadKey, setReloadKey] = useState(0);
@@ -78,6 +82,7 @@ export function useSelfFetch<T>(
78
82
  // `initialData` is `undefined`.
79
83
  if (syncedProps.initialData !== initialData && initialData !== undefined) {
80
84
  setData(initialData);
85
+ setDataUrl(url);
81
86
  }
82
87
  // Fetching just got disabled — nothing will land to clear the spinner.
83
88
  if (syncedProps.url !== url && url === null) {
@@ -101,6 +106,7 @@ export function useSelfFetch<T>(
101
106
  const json = (await res.json()) as T;
102
107
  if (!cancelled) {
103
108
  setData(json);
109
+ setDataUrl(url);
104
110
  dataUrlRef.current = url; // remember the url we now hold data for
105
111
  dataAtRef.current = Date.now();
106
112
  }
@@ -149,6 +155,7 @@ export function useSelfFetch<T>(
149
155
 
150
156
  return {
151
157
  data,
158
+ dataUrl,
152
159
  setData,
153
160
  isLoading,
154
161
  error,