@flamingo-stack/openframe-frontend-core 0.0.216 → 0.0.217-snapshot.20260601003634
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/{chunk-SMCG2CCC.cjs → chunk-6DCKL73F.cjs} +24 -24
- package/dist/{chunk-SMCG2CCC.cjs.map → chunk-6DCKL73F.cjs.map} +1 -1
- package/dist/{chunk-QTKU6ULP.js → chunk-BVFRD34B.js} +2 -2
- package/dist/{chunk-CDLYRFDE.js → chunk-ENBGG2K2.js} +3767 -3610
- package/dist/chunk-ENBGG2K2.js.map +1 -0
- package/dist/{chunk-K4DFAVSO.cjs → chunk-G2HHSZ3S.cjs} +9 -9
- package/dist/{chunk-K4DFAVSO.cjs.map → chunk-G2HHSZ3S.cjs.map} +1 -1
- package/dist/{chunk-2V4SACHE.js → chunk-L6IBKPVM.js} +2 -2
- package/dist/{chunk-572WQWIX.cjs → chunk-MVQ3OODK.cjs} +9 -9
- package/dist/{chunk-572WQWIX.cjs.map → chunk-MVQ3OODK.cjs.map} +1 -1
- package/dist/{chunk-GVNQAGXB.js → chunk-N5IKPYRL.js} +3 -81
- package/dist/chunk-N5IKPYRL.js.map +1 -0
- package/dist/{chunk-VC3ND5RB.js → chunk-SWZUZYWR.js} +2 -2
- package/dist/{chunk-IH76P5R6.cjs → chunk-TYIBMDUZ.cjs} +8 -86
- package/dist/chunk-TYIBMDUZ.cjs.map +1 -0
- package/dist/{chunk-ZGTDUPTW.cjs → chunk-YWDC5BXM.cjs} +382 -225
- package/dist/chunk-YWDC5BXM.cjs.map +1 -0
- package/dist/components/chat/chat-attachment-bar.d.ts +13 -2
- package/dist/components/chat/chat-attachment-bar.d.ts.map +1 -1
- package/dist/components/chat/chat-input.d.ts.map +1 -1
- package/dist/components/chat/chat-message-row.d.ts +25 -0
- package/dist/components/chat/chat-message-row.d.ts.map +1 -0
- package/dist/components/chat/index.cjs +6 -2
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.d.ts +1 -0
- package/dist/components/chat/index.d.ts.map +1 -1
- package/dist/components/chat/index.js +5 -1
- package/dist/components/chat/types/component.types.d.ts +8 -1
- package/dist/components/chat/types/component.types.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/form.d.ts +1 -1
- package/dist/components/form.d.ts.map +1 -1
- package/dist/components/index.cjs +56 -52
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +9 -5
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/onboarding-guides/index.cjs +18 -18
- package/dist/components/onboarding-guides/index.js +3 -3
- package/dist/components/shared/dev-section/dev-card-row.d.ts +5 -45
- package/dist/components/shared/dev-section/dev-card-row.d.ts.map +1 -1
- package/dist/components/shared/legal-document/use-legal-docs.d.ts.map +1 -1
- package/dist/components/tickets/help-center-card.d.ts.map +1 -1
- package/dist/components/tickets/help-center-list.d.ts.map +1 -1
- package/dist/components/tickets/hooks/use-ticket-engagements.d.ts +9 -1
- package/dist/components/tickets/hooks/use-ticket-engagements.d.ts.map +1 -1
- package/dist/components/tickets/hooks/use-tickets-list.d.ts +7 -0
- package/dist/components/tickets/hooks/use-tickets-list.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +309 -256
- package/dist/components/tickets/index.cjs.map +1 -1
- package/dist/components/tickets/index.d.ts +1 -0
- package/dist/components/tickets/index.d.ts.map +1 -1
- package/dist/components/tickets/index.js +376 -323
- package/dist/components/tickets/index.js.map +1 -1
- package/dist/components/tickets/ticket-detail-drawer.d.ts.map +1 -1
- package/dist/components/tickets/ticket-reply-composer.d.ts +33 -0
- package/dist/components/tickets/ticket-reply-composer.d.ts.map +1 -0
- package/dist/components/tickets/types.d.ts +13 -0
- package/dist/components/tickets/types.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +6 -2
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +5 -1
- package/dist/components/ui/ticket-attachments-list.d.ts +5 -1
- package/dist/components/ui/ticket-attachments-list.d.ts.map +1 -1
- package/dist/index.cjs +6 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +5 -1
- package/dist/utils/index.cjs +59 -4
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.js +59 -4
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/scroll-into-view.d.ts +43 -48
- package/dist/utils/scroll-into-view.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/chat-attachment-bar.tsx +58 -22
- package/src/components/chat/chat-input.tsx +68 -29
- package/src/components/chat/chat-message-row.tsx +124 -0
- package/src/components/chat/index.ts +1 -0
- package/src/components/chat/types/component.types.ts +8 -1
- package/src/components/shared/dev-section/dev-card-row.tsx +5 -183
- package/src/components/shared/legal-document/use-legal-docs.ts +5 -1
- package/src/components/tickets/help-center-card.tsx +26 -29
- package/src/components/tickets/help-center-list.tsx +57 -10
- package/src/components/tickets/hooks/use-ticket-engagements.ts +41 -5
- package/src/components/tickets/hooks/use-tickets-list.ts +13 -0
- package/src/components/tickets/index.ts +4 -0
- package/src/components/tickets/ticket-detail-drawer.tsx +144 -200
- package/src/components/tickets/ticket-reply-composer.tsx +195 -0
- package/src/components/tickets/types.ts +14 -0
- package/src/components/ui/ticket-attachments-list.tsx +26 -8
- package/src/styles/app-globals.css +13 -0
- package/src/utils/scroll-into-view.ts +127 -53
- package/dist/chunk-CDLYRFDE.js.map +0 -1
- package/dist/chunk-GVNQAGXB.js.map +0 -1
- package/dist/chunk-IH76P5R6.cjs.map +0 -1
- package/dist/chunk-ZGTDUPTW.cjs.map +0 -1
- /package/dist/{chunk-QTKU6ULP.js.map → chunk-BVFRD34B.js.map} +0 -0
- /package/dist/{chunk-2V4SACHE.js.map → chunk-L6IBKPVM.js.map} +0 -0
- /package/dist/{chunk-VC3ND5RB.js.map → chunk-SWZUZYWR.js.map} +0 -0
|
@@ -1,63 +1,58 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `scrollElementIntoView` — canonical "
|
|
3
|
-
*
|
|
4
|
-
* known layout shifts" helper.
|
|
2
|
+
* `scrollElementIntoView` — canonical "scroll an element to the top of the
|
|
3
|
+
* viewport, account for sticky chrome, survive layout shifts" helper.
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* One shared implementation so every caller (the ticket drawer expand, the
|
|
6
|
+
* hub's `useUnifiedNav` / `use-nav-link` hash scroll, doc-tree, delivery
|
|
7
|
+
* `?focus=`, sticky-section-nav, …) inherits the SAME cancellation-proof
|
|
8
|
+
* motion.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* - Future ticket-row / docs anchor scrolls
|
|
10
|
+
* WHY A SELF-DRIVEN rAF TWEEN INSTEAD OF `window.scrollTo({behavior:'smooth'})`:
|
|
11
|
+
* the native smooth scroll is CANCELLABLE, and in real pages it gets cancelled
|
|
12
|
+
* constantly:
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
14
|
+
* - Browser SCROLL ANCHORING: when content is inserted/removed above or
|
|
15
|
+
* around the target (a collapsible drawer expanding, an async image
|
|
16
|
+
* loading, a list re-rendering) the browser issues a synchronous scrollTop
|
|
17
|
+
* correction to keep the anchored element stable. Per CSSOM-View "perform a
|
|
18
|
+
* scroll" step 1 ("abort any ongoing smooth scroll"), that correction
|
|
19
|
+
* ABORTS an in-flight native smooth scroll — so it lands as an instant jump.
|
|
20
|
+
* Anchoring is suppressed when the scroll offset is 0, which is exactly why
|
|
21
|
+
* a native smooth scroll appears to work the FIRST time (page at top) and
|
|
22
|
+
* jumps on every repeat (page already scrolled). This was a multi-day
|
|
23
|
+
* "smooth only works once" bug on the /tickets drawer.
|
|
24
|
+
* - A second programmatic scroll on the same frame, or a `focus()` without
|
|
25
|
+
* `{preventScroll:true}`, cancels it the same way.
|
|
21
26
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* A tween that re-asserts the position with INSTANT writes every frame is
|
|
28
|
+
* immune: there is no "ongoing native smooth scroll" for anchoring/focus to
|
|
29
|
+
* abort, and any correction that lands between our frames is overwritten on the
|
|
30
|
+
* next frame. We also RECOMPUTE the target each frame, so an element whose
|
|
31
|
+
* final position is still settling (drawer still expanding, images loading)
|
|
32
|
+
* is tracked to its resting place instead of animating to a stale pixel.
|
|
33
|
+
*
|
|
34
|
+
* Honors `prefers-reduced-motion` (jumps instantly) and cancels on genuine user
|
|
35
|
+
* scroll intent (wheel / touch) so we never fight the user.
|
|
30
36
|
*/
|
|
31
37
|
export interface ScrollElementIntoViewOptions {
|
|
32
|
-
/** Pixels to subtract from the target element's `top` so it lands
|
|
33
|
-
*
|
|
34
|
-
* `scroll-mt-24`) for the standard hub header offset. */
|
|
38
|
+
/** Pixels to subtract from the target element's `top` so it lands BELOW
|
|
39
|
+
* sticky chrome. Defaults to 0. Pass `96` for the standard hub header. */
|
|
35
40
|
headerOffset?: number;
|
|
36
|
-
/**
|
|
37
|
-
*
|
|
38
|
-
* link land, programmatic focus moves). */
|
|
41
|
+
/** `'smooth'` (default) runs the self-driven tween; `'instant'` / `'auto'`
|
|
42
|
+
* jump in one synchronous write (deep-link land, programmatic focus moves). */
|
|
39
43
|
behavior?: ScrollBehavior;
|
|
40
|
-
/** Optional adjustment applied to the computed pixel target. The
|
|
41
|
-
* callback receives the "raw" Y (`element.top + scrollY -
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* between the call and the animation completing — the browser's
|
|
45
|
-
* smooth-scroll commits to a single pixel value, so providing the
|
|
46
|
-
* post-shift target up front lands the element correctly even as
|
|
47
|
-
* content above it moves. */
|
|
44
|
+
/** Optional adjustment applied to the computed pixel target each frame. The
|
|
45
|
+
* callback receives the "raw" Y (`element.top + scrollY - headerOffset`) and
|
|
46
|
+
* returns the FINAL target. Use when the caller knows about a layout shift
|
|
47
|
+
* (e.g. a sibling drawer collapsing) the geometry can't yet reflect. */
|
|
48
48
|
adjustTargetY?: (rawTargetY: number) => number;
|
|
49
|
+
/** Tween duration in ms (smooth only). Default 320. */
|
|
50
|
+
durationMs?: number;
|
|
49
51
|
}
|
|
50
52
|
/**
|
|
51
|
-
* Scroll the page so `target` lands at the top of the viewport
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* Accepts:
|
|
56
|
-
* - `HTMLElement` — direct reference (most common from `useRef`).
|
|
57
|
-
* - `null` / `undefined` — no-op so callers can pass refs without
|
|
58
|
-
* defensive branching.
|
|
59
|
-
*
|
|
60
|
-
* SSR-safe: short-circuits when `window` is undefined.
|
|
53
|
+
* Scroll the page so `target` lands at the top of the viewport (below sticky
|
|
54
|
+
* chrome via `headerOffset`). SSR-safe; `null`/`undefined` target is a no-op so
|
|
55
|
+
* callers can pass refs without defensive branching.
|
|
61
56
|
*/
|
|
62
57
|
export declare function scrollElementIntoView(target: HTMLElement | null | undefined, options?: ScrollElementIntoViewOptions): void;
|
|
63
58
|
//# sourceMappingURL=scroll-into-view.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scroll-into-view.d.ts","sourceRoot":"","sources":["../../src/utils/scroll-into-view.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"scroll-into-view.d.ts","sourceRoot":"","sources":["../../src/utils/scroll-into-view.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEH,MAAM,WAAW,4BAA4B;IAC3C;+EAC2E;IAC3E,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;oFACgF;IAChF,QAAQ,CAAC,EAAE,cAAc,CAAA;IACzB;;;6EAGyE;IACzE,aAAa,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAA;IAC9C,uDAAuD;IACvD,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAqBD;;;;GAIG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,WAAW,GAAG,IAAI,GAAG,SAAS,EACtC,OAAO,GAAE,4BAAiC,GACzC,IAAI,CAmEN"}
|
package/package.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
'use client'
|
|
2
|
+
// compact-size support added for the ticket-drawer reply composer.
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Chat-attachment UI primitives.
|
|
@@ -32,8 +33,14 @@ import { useEffect, useRef, useState } from 'react'
|
|
|
32
33
|
import { PlusIcon } from '../icons-v2-generated/signs-and-symbols/plus-icon'
|
|
33
34
|
import { XmarkIcon } from '../icons-v2-generated/signs-and-symbols/xmark-icon'
|
|
34
35
|
import { Button } from '../ui/button'
|
|
36
|
+
import { cn } from '../../utils/cn'
|
|
35
37
|
import { ANTHROPIC_SUPPORTED_IMAGE_MIME } from './utils/chat-attachment-markdown'
|
|
36
38
|
|
|
39
|
+
/** Chip strip / chip density. `compact` shrinks the thumbnail, padding, text
|
|
40
|
+
* and max-width for narrow surfaces (e.g. the ticket-drawer reply composer);
|
|
41
|
+
* `default` is the global Ask-AI chat sizing. */
|
|
42
|
+
export type ChatAttachmentSize = 'default' | 'compact'
|
|
43
|
+
|
|
37
44
|
// ===========================================================================
|
|
38
45
|
// CONSTANTS & TYPES — inlined from hub `lib/config/chat-attachment-config.ts`
|
|
39
46
|
// ===========================================================================
|
|
@@ -88,6 +95,9 @@ export interface ChatAttachmentAddButtonProps {
|
|
|
88
95
|
onAddFiles: (files: FileList | File[]) => void
|
|
89
96
|
/** External disable (e.g. while chat is streaming). */
|
|
90
97
|
disabled?: boolean
|
|
98
|
+
/** Density. `compact` renders a smaller 24×24 button for narrow surfaces
|
|
99
|
+
* (ticket-drawer composer); `default` is the 28×28 global-chat button. */
|
|
100
|
+
size?: ChatAttachmentSize
|
|
91
101
|
}
|
|
92
102
|
|
|
93
103
|
/**
|
|
@@ -111,11 +121,16 @@ export function ChatAttachmentAddButton({
|
|
|
111
121
|
attachmentsCount,
|
|
112
122
|
onAddFiles,
|
|
113
123
|
disabled = false,
|
|
124
|
+
size = 'default',
|
|
114
125
|
}: ChatAttachmentAddButtonProps) {
|
|
115
126
|
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
|
127
|
+
const compact = size === 'compact'
|
|
128
|
+
// Keep the placeholder footprint in lockstep with the real button size so
|
|
129
|
+
// there's zero layout shift when `attachmentsEnabled` resolves.
|
|
130
|
+
const boxClass = compact ? 'h-6 w-6' : 'h-7 w-7'
|
|
116
131
|
|
|
117
132
|
if (!attachmentsEnabled) {
|
|
118
|
-
return <span aria-hidden="true" className=
|
|
133
|
+
return <span aria-hidden="true" className={cn('inline-block shrink-0', boxClass)} />
|
|
119
134
|
}
|
|
120
135
|
|
|
121
136
|
const slotsAvailable = Math.max(
|
|
@@ -159,8 +174,8 @@ export function ChatAttachmentAddButton({
|
|
|
159
174
|
? 'Add attachments'
|
|
160
175
|
: `Limit reached (${CHAT_ATTACHMENT_CONCURRENT_UPLOADS_PER_USER})`
|
|
161
176
|
}
|
|
162
|
-
leftIcon={<PlusIcon className=
|
|
163
|
-
className=
|
|
177
|
+
leftIcon={<PlusIcon className={compact ? 'h-3.5 w-3.5' : 'h-4 w-4'} />}
|
|
178
|
+
className={cn('!p-0 shrink-0 text-ods-text-muted hover:text-ods-text-primary', compact ? '!h-6 !w-6' : '!h-7 !w-7')}
|
|
164
179
|
/>
|
|
165
180
|
</>
|
|
166
181
|
)
|
|
@@ -175,6 +190,10 @@ export interface ChatAttachmentChipStripProps {
|
|
|
175
190
|
onRemove: (id: string) => void
|
|
176
191
|
/** External disable (e.g. while chat is streaming). */
|
|
177
192
|
disabled?: boolean
|
|
193
|
+
/** Density. `compact` (smaller thumbnail/padding/text, narrower chips) suits
|
|
194
|
+
* narrow surfaces like the ticket-drawer reply composer; `default` is the
|
|
195
|
+
* global Ask-AI chat sizing. */
|
|
196
|
+
size?: ChatAttachmentSize
|
|
178
197
|
}
|
|
179
198
|
|
|
180
199
|
/**
|
|
@@ -186,17 +205,20 @@ export function ChatAttachmentChipStrip({
|
|
|
186
205
|
attachments,
|
|
187
206
|
onRemove,
|
|
188
207
|
disabled = false,
|
|
208
|
+
size = 'default',
|
|
189
209
|
}: ChatAttachmentChipStripProps) {
|
|
190
210
|
if (attachments.length === 0) return null
|
|
211
|
+
const compact = size === 'compact'
|
|
191
212
|
return (
|
|
192
|
-
<div className=
|
|
193
|
-
<div className=
|
|
213
|
+
<div className={cn('flex-shrink-0', compact ? 'px-3 pb-1.5' : 'px-5 pb-2')}>
|
|
214
|
+
<div className={cn('flex items-center flex-wrap', compact ? 'gap-1.5' : 'gap-2')}>
|
|
194
215
|
{attachments.map((att) => (
|
|
195
216
|
<AttachmentChip
|
|
196
217
|
key={att.id}
|
|
197
218
|
attachment={att}
|
|
198
219
|
onRemove={() => onRemove(att.id)}
|
|
199
220
|
disabled={disabled}
|
|
221
|
+
size={size}
|
|
200
222
|
/>
|
|
201
223
|
))}
|
|
202
224
|
</div>
|
|
@@ -212,11 +234,13 @@ interface AttachmentChipProps {
|
|
|
212
234
|
attachment: StagedAttachment
|
|
213
235
|
onRemove: () => void
|
|
214
236
|
disabled: boolean
|
|
237
|
+
size?: ChatAttachmentSize
|
|
215
238
|
}
|
|
216
239
|
|
|
217
|
-
function AttachmentChip({ attachment, onRemove, disabled }: AttachmentChipProps) {
|
|
240
|
+
function AttachmentChip({ attachment, onRemove, disabled, size = 'default' }: AttachmentChipProps) {
|
|
218
241
|
const { file, status, progress, errorMessage } = attachment
|
|
219
242
|
const isImage = (ANTHROPIC_SUPPORTED_IMAGE_MIME as readonly string[]).includes(file.type)
|
|
243
|
+
const compact = size === 'compact'
|
|
220
244
|
|
|
221
245
|
// Inline thumbnail for images during STAGING (pre-upload-complete).
|
|
222
246
|
// Local `URL.createObjectURL` blob — the user sees their image
|
|
@@ -225,19 +249,27 @@ function AttachmentChip({ attachment, onRemove, disabled }: AttachmentChipProps)
|
|
|
225
249
|
|
|
226
250
|
return (
|
|
227
251
|
<div
|
|
228
|
-
className=
|
|
252
|
+
className={cn(
|
|
253
|
+
'flex items-center rounded-md border border-ods-border bg-ods-card',
|
|
254
|
+
compact ? 'gap-1.5 px-1.5 py-1 max-w-[180px]' : 'gap-2 px-2 py-1.5 max-w-[240px]',
|
|
255
|
+
)}
|
|
229
256
|
role="group"
|
|
230
257
|
aria-label={`Attachment ${file.name}`}
|
|
231
258
|
>
|
|
232
259
|
{/* Thumbnail OR file-type initials */}
|
|
233
|
-
<div
|
|
260
|
+
<div
|
|
261
|
+
className={cn(
|
|
262
|
+
'relative shrink-0 overflow-hidden rounded bg-ods-bg',
|
|
263
|
+
compact ? 'h-6 w-6' : 'h-8 w-8',
|
|
264
|
+
)}
|
|
265
|
+
>
|
|
234
266
|
{isImage && blobUrl ? (
|
|
235
267
|
// eslint-disable-next-line @next/next/no-img-element -- blob: URLs
|
|
236
268
|
// cannot go through next/image; this is a transient pre-upload
|
|
237
269
|
// preview, NOT the chat-history render path.
|
|
238
270
|
<img src={blobUrl} alt={file.name} className="h-full w-full object-cover" />
|
|
239
271
|
) : (
|
|
240
|
-
<div className=
|
|
272
|
+
<div className={cn('flex h-full w-full items-center justify-center font-mono uppercase text-ods-text-muted', compact ? 'text-[9px]' : 'text-[10px]')}>
|
|
241
273
|
{extLabel(file.name)}
|
|
242
274
|
</div>
|
|
243
275
|
)}
|
|
@@ -254,19 +286,23 @@ function AttachmentChip({ attachment, onRemove, disabled }: AttachmentChipProps)
|
|
|
254
286
|
|
|
255
287
|
{/* Filename + status */}
|
|
256
288
|
<div className="min-w-0 flex-1">
|
|
257
|
-
<div className=
|
|
289
|
+
<div className={cn('truncate text-ods-text-primary', compact ? 'text-[11px] leading-tight' : 'text-xs')} title={file.name}>
|
|
258
290
|
{file.name}
|
|
259
291
|
</div>
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
{
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
292
|
+
{/* Compact mode hides the size/status sub-line for ready files to keep the
|
|
293
|
+
chip to a single line; uploading/error states still surface. */}
|
|
294
|
+
{(!compact || status !== 'ready') && (
|
|
295
|
+
<div className={cn('text-ods-text-muted', compact ? 'text-[9px] leading-tight' : 'text-[10px]')}>
|
|
296
|
+
{status === 'sniffing' && 'Checking…'}
|
|
297
|
+
{status === 'uploading' && `${progress}% · ${formatFileSize(file.size)}`}
|
|
298
|
+
{status === 'ready' && formatFileSize(file.size)}
|
|
299
|
+
{status === 'error' && (
|
|
300
|
+
<span className="text-ods-attention-red-error" title={errorMessage}>
|
|
301
|
+
{errorMessage ?? 'Upload failed'}
|
|
302
|
+
</span>
|
|
303
|
+
)}
|
|
304
|
+
</div>
|
|
305
|
+
)}
|
|
270
306
|
</div>
|
|
271
307
|
|
|
272
308
|
{/* Remove button — UI-Kit Button with `transparent` + `small`. */}
|
|
@@ -277,8 +313,8 @@ function AttachmentChip({ attachment, onRemove, disabled }: AttachmentChipProps)
|
|
|
277
313
|
onClick={onRemove}
|
|
278
314
|
disabled={disabled}
|
|
279
315
|
aria-label={`Remove ${file.name}`}
|
|
280
|
-
leftIcon={<XmarkIcon className=
|
|
281
|
-
className=
|
|
316
|
+
leftIcon={<XmarkIcon className={compact ? 'h-3 w-3' : 'h-3.5 w-3.5'} />}
|
|
317
|
+
className={cn('!p-0 shrink-0 text-ods-text-muted hover:text-ods-text-primary', compact ? '!h-4 !w-4' : '!h-5 !w-5')}
|
|
282
318
|
/>
|
|
283
319
|
</div>
|
|
284
320
|
)
|
|
@@ -34,6 +34,11 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|
|
34
34
|
disabled = false,
|
|
35
35
|
autoFocus = false,
|
|
36
36
|
fullWidth = false,
|
|
37
|
+
// Composer extension — pulled out so it never falls through to the
|
|
38
|
+
// underlying <textarea> as an unknown DOM attribute. `allowEmptySend`
|
|
39
|
+
// lets surfaces that attach files (e.g. the ticket reply composer) send
|
|
40
|
+
// with empty text once an attachment is ready.
|
|
41
|
+
allowEmptySend = false,
|
|
37
42
|
...inputProps
|
|
38
43
|
} = rest
|
|
39
44
|
|
|
@@ -47,7 +52,17 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|
|
47
52
|
if (disabled) return
|
|
48
53
|
const el = textareaRef.current
|
|
49
54
|
if (!el || el.disabled) return
|
|
50
|
-
|
|
55
|
+
// ALWAYS `preventScroll`. Focusing a chat composer must never yank the
|
|
56
|
+
// viewport: in the global Ask-AI chat the input is a sticky, always-
|
|
57
|
+
// visible composer (preventScroll is a no-op there), and in the ticket
|
|
58
|
+
// reply composer the input sits BELOW a tall fixed-height feed — a
|
|
59
|
+
// scroll-on-focus there fights the card's smooth scroll-to-top when the
|
|
60
|
+
// drawer opens. This bit a specific flow: deep-link a ticket → close →
|
|
61
|
+
// reopen, then the post-state-transition refocus effect below fired a
|
|
62
|
+
// bare `focus()` AFTER the card's `window.scrollTo({behavior:'smooth'})`,
|
|
63
|
+
// scrolling the textarea into view and cancelling the smooth animation.
|
|
64
|
+
// `preventScroll` on every focus path removes the whole class of bug.
|
|
65
|
+
el.focus({ preventScroll: true })
|
|
51
66
|
}, [disabled])
|
|
52
67
|
|
|
53
68
|
useEffect(() => {
|
|
@@ -63,6 +78,34 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|
|
63
78
|
const valueRef = useRef(value)
|
|
64
79
|
valueRef.current = value
|
|
65
80
|
|
|
81
|
+
// Shared send path for the Send button, Enter key, and imperative submit().
|
|
82
|
+
// Replaces the old eager `setValue('')`: the draft clears ONLY when the send
|
|
83
|
+
// is not rejected (`onSend` returns / resolves !== false), so a failed ticket
|
|
84
|
+
// reply keeps the user's text. `allowEmptySend` lets attachment surfaces send
|
|
85
|
+
// with empty text. Memoized on its gate inputs ONLY (not `value`) so it stays
|
|
86
|
+
// stable across keystrokes — the imperative handle below depends on it without
|
|
87
|
+
// rebuilding per keystroke (preserves the `valueRef` optimization above).
|
|
88
|
+
const fire = useCallback(
|
|
89
|
+
(message: string) => {
|
|
90
|
+
const can = (message.length > 0 || allowEmptySend) && !sending && !disabled
|
|
91
|
+
if (!can || !onSend) return
|
|
92
|
+
const result = onSend(message)
|
|
93
|
+
const done = () => {
|
|
94
|
+
setValue('')
|
|
95
|
+
shouldRefocusRef.current = true
|
|
96
|
+
focusTextarea()
|
|
97
|
+
}
|
|
98
|
+
if (result instanceof Promise) {
|
|
99
|
+
void result.then((ok) => {
|
|
100
|
+
if (ok !== false) done()
|
|
101
|
+
})
|
|
102
|
+
} else if (result !== false) {
|
|
103
|
+
done()
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
[allowEmptySend, sending, disabled, onSend, focusTextarea],
|
|
107
|
+
)
|
|
108
|
+
|
|
66
109
|
// Expose the `ChatInputRef` shape so parents can imperatively pre-fill
|
|
67
110
|
// the input (used by the empty-state quick-action chips that translate
|
|
68
111
|
// to `/<id> ` slash invocations) and focus the textarea programmatically.
|
|
@@ -86,7 +129,7 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|
|
86
129
|
requestAnimationFrame(() => {
|
|
87
130
|
const el = textareaRef.current
|
|
88
131
|
if (!el || disabled || el.disabled) return
|
|
89
|
-
el.focus()
|
|
132
|
+
el.focus({ preventScroll: true })
|
|
90
133
|
el.setSelectionRange(next.length, next.length)
|
|
91
134
|
})
|
|
92
135
|
},
|
|
@@ -100,41 +143,34 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|
|
100
143
|
requestAnimationFrame(() => {
|
|
101
144
|
const el = textareaRef.current
|
|
102
145
|
if (!el) return
|
|
103
|
-
el.focus()
|
|
146
|
+
el.focus({ preventScroll: true })
|
|
104
147
|
el.setSelectionRange(clamped, clamped)
|
|
105
148
|
})
|
|
106
149
|
},
|
|
107
150
|
submit: (next: string) => {
|
|
108
|
-
// Mirror the user-typed Send-button path
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
// is
|
|
112
|
-
|
|
113
|
-
const trimmed = next.trim()
|
|
114
|
-
if (!trimmed) return
|
|
115
|
-
onSend(trimmed)
|
|
116
|
-
setValue('')
|
|
117
|
-
shouldRefocusRef.current = true
|
|
118
|
-
focusTextarea()
|
|
151
|
+
// Mirror the user-typed Send-button path via the shared `fire` helper
|
|
152
|
+
// (success-gated clear). Reads `next` (its argument), NOT the textarea
|
|
153
|
+
// `value` — the "Recent" quick-action injects a `/cmd` string while
|
|
154
|
+
// `value` is empty, so reading `value` here would submit nothing.
|
|
155
|
+
fire(next.trim())
|
|
119
156
|
},
|
|
120
157
|
getValue: () => valueRef.current,
|
|
121
158
|
}),
|
|
122
|
-
// `valueRef` and `shouldRefocusRef` are stable refs. `
|
|
123
|
-
// `
|
|
124
|
-
// is
|
|
125
|
-
//
|
|
126
|
-
|
|
159
|
+
// `valueRef` and `shouldRefocusRef` are stable refs. `disabled` (read by
|
|
160
|
+
// `setValue`'s rAF guard) and `focusTextarea` change session-rarely; `fire`
|
|
161
|
+
// is memoized on its gate inputs (`sending`/`onSend`/`allowEmptySend` all
|
|
162
|
+
// live inside it), so the handle rebuilds only when those change — still
|
|
163
|
+
// far fewer than per-keystroke.
|
|
164
|
+
[focusTextarea, disabled, fire],
|
|
127
165
|
)
|
|
128
166
|
|
|
167
|
+
// The Enter key calls handleSubmit directly (bypassing the Send button's
|
|
168
|
+
// per-render `sendDisabled`), so it must read a fresh gate — `fire` is
|
|
169
|
+
// memoized on every gate input, so depending on `fire` + `value` is both
|
|
170
|
+
// fresh and exhaustive-deps-clean.
|
|
129
171
|
const handleSubmit = useCallback(() => {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
onSend(message)
|
|
133
|
-
setValue('')
|
|
134
|
-
shouldRefocusRef.current = true
|
|
135
|
-
focusTextarea()
|
|
136
|
-
}
|
|
137
|
-
}, [value, sending, disabled, onSend, focusTextarea])
|
|
172
|
+
fire(value.trim())
|
|
173
|
+
}, [fire, value])
|
|
138
174
|
|
|
139
175
|
// Slash-command autocomplete state. Detection runs in render so the
|
|
140
176
|
// keyboard handler can branch on it without re-parsing.
|
|
@@ -194,7 +230,7 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|
|
194
230
|
requestAnimationFrame(() => {
|
|
195
231
|
const el = textareaRef.current
|
|
196
232
|
if (!el || el.disabled) return
|
|
197
|
-
el.focus()
|
|
233
|
+
el.focus({ preventScroll: true })
|
|
198
234
|
el.setSelectionRange(next.length, next.length)
|
|
199
235
|
})
|
|
200
236
|
},
|
|
@@ -287,7 +323,10 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|
|
287
323
|
}, [onStop, isStopping])
|
|
288
324
|
|
|
289
325
|
const isStopMode = sending && !!onStop
|
|
290
|
-
|
|
326
|
+
// Send gate: `allowEmptySend` lets empty text send (attachments-only).
|
|
327
|
+
// Default (false) collapses to the original `!value.trim()` gate.
|
|
328
|
+
const hasContent = value.trim().length > 0 || allowEmptySend
|
|
329
|
+
const sendDisabled = sending || disabled || !hasContent
|
|
291
330
|
|
|
292
331
|
return (
|
|
293
332
|
<div
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `<ChatMessageRow>` — THE single source of truth for a Slack-channel-style
|
|
5
|
+
* message row. Rendered by BOTH:
|
|
6
|
+
* - the OpenMSP Slack-community feed (hub `components/slack/chat-interface.tsx`)
|
|
7
|
+
* - the customer ticket conversation feed (lib `ticket-detail-drawer.tsx`)
|
|
8
|
+
*
|
|
9
|
+
* Both surfaces render THIS component, so they are pixel-identical by
|
|
10
|
+
* construction — avatar size, font sizes, weights, spacing, and line-heights
|
|
11
|
+
* can never drift apart. The markup is the verbatim Slack `MessageItem` layout
|
|
12
|
+
* (avatar `w-8 h-8 md:w-10 md:h-10 rounded-lg object-cover` + bold name +
|
|
13
|
+
* relative time + `whitespace-pre-wrap` body). Colors are ODS theme tokens
|
|
14
|
+
* ONLY (`text-ods-text-primary` / `text-ods-text-secondary`). The `text-[Npx]`
|
|
15
|
+
* / `font-body` / `leading-*` utilities are FONT/SIZE, not color — they are
|
|
16
|
+
* required to match the Slack typography exactly.
|
|
17
|
+
*
|
|
18
|
+
* `footer` is the only per-surface variation slot: the Slack feed passes its
|
|
19
|
+
* "N replies" badge; the ticket feed passes `<TicketAttachmentsList>`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import Image from '../../embed-shims/next-image'
|
|
23
|
+
import { getFirstLastInitials } from '../../utils/format'
|
|
24
|
+
import { useProxiedImageUrl } from './hooks/use-proxied-image-url'
|
|
25
|
+
import type { ReactNode } from 'react'
|
|
26
|
+
|
|
27
|
+
export interface ChatMessageRowProps {
|
|
28
|
+
/** Display name (bold, top-left). */
|
|
29
|
+
displayName: string
|
|
30
|
+
/** Avatar image URL. Proxied via `useProxiedImageUrl`; falls back to
|
|
31
|
+
* initials in a same-sized `rounded-lg` box when absent. */
|
|
32
|
+
avatarUrl?: string | null
|
|
33
|
+
/** Pre-formatted relative-time label (e.g. "2h ago"). Caller formats it —
|
|
34
|
+
* Slack passes its server `displayTime`, tickets pass
|
|
35
|
+
* `formatRelativeTime(createdAt)`. Empty/undefined hides the time. */
|
|
36
|
+
timeLabel?: string | null
|
|
37
|
+
/** Message body. Empty + no footer renders nothing under the header. */
|
|
38
|
+
body: string
|
|
39
|
+
/** Per-surface slot under the body: Slack reply badge / ticket attachments. */
|
|
40
|
+
footer?: ReactNode
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function ChatMessageRow({
|
|
44
|
+
displayName,
|
|
45
|
+
avatarUrl,
|
|
46
|
+
timeLabel,
|
|
47
|
+
body,
|
|
48
|
+
footer,
|
|
49
|
+
}: ChatMessageRowProps) {
|
|
50
|
+
const proxiedAvatar = useProxiedImageUrl(avatarUrl ?? '')
|
|
51
|
+
const src = proxiedAvatar || avatarUrl || undefined
|
|
52
|
+
const hasBody = body.trim().length > 0
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="flex gap-2 md:gap-3 w-full min-w-0">
|
|
56
|
+
{/* Avatar — verbatim Slack sizing: 32px → 40px, rounded-lg, object-cover.
|
|
57
|
+
Initials fallback uses the SAME box so layout is identical with or
|
|
58
|
+
without an image. */}
|
|
59
|
+
{src ? (
|
|
60
|
+
<Image
|
|
61
|
+
src={src}
|
|
62
|
+
alt={displayName}
|
|
63
|
+
className="w-8 h-8 md:w-10 md:h-10 rounded-lg object-cover flex-shrink-0"
|
|
64
|
+
width={40}
|
|
65
|
+
height={40}
|
|
66
|
+
/>
|
|
67
|
+
) : (
|
|
68
|
+
<div className="w-8 h-8 md:w-10 md:h-10 rounded-lg flex-shrink-0 flex items-center justify-center bg-ods-bg border border-ods-border text-[12px] font-medium text-ods-text-primary font-body">
|
|
69
|
+
{getFirstLastInitials(displayName) || '?'}
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
|
|
73
|
+
{/* Message content */}
|
|
74
|
+
<div className="flex-1 min-w-0 max-w-full">
|
|
75
|
+
{/* Header — name + relative time. Verbatim Slack typography. */}
|
|
76
|
+
<div className="flex items-center gap-2 max-w-full mb-1 min-w-0">
|
|
77
|
+
<span className="text-[14px] md:text-[15px] font-bold leading-[1.33] text-ods-text-primary font-body tracking-[-0.02em] truncate">
|
|
78
|
+
{displayName}
|
|
79
|
+
</span>
|
|
80
|
+
{timeLabel && (
|
|
81
|
+
<span className="text-[11px] md:text-[12px] font-medium leading-[1.43] text-ods-text-secondary font-body flex-shrink-0">
|
|
82
|
+
{timeLabel}
|
|
83
|
+
</span>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
{/* Body — verbatim Slack: 12/14px, pre-wrap, break-words. */}
|
|
88
|
+
{hasBody && (
|
|
89
|
+
<div className="text-[12px] md:text-[14px] font-medium leading-[1.43] text-ods-text-primary font-body whitespace-pre-wrap break-words min-w-0 max-w-full">
|
|
90
|
+
{body}
|
|
91
|
+
</div>
|
|
92
|
+
)}
|
|
93
|
+
|
|
94
|
+
{footer}
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Skeleton with 1:1 structural parity to `<ChatMessageRow>` — SAME wrapper
|
|
102
|
+
* (`flex gap-2 md:gap-3`), SAME avatar box (`w-8 h-8 md:w-10 md:h-10
|
|
103
|
+
* rounded-lg`), SAME header `mb-1`, and bar heights matching the real
|
|
104
|
+
* name/time/body line-heights so the loading→loaded swap does not reflow.
|
|
105
|
+
*/
|
|
106
|
+
export function ChatMessageRowSkeleton() {
|
|
107
|
+
// Bars use `bg-ods-border` (NOT `bg-ods-skeleton` — that token resolves to
|
|
108
|
+
// transparent in this build, leaving the box visually empty).
|
|
109
|
+
return (
|
|
110
|
+
<div className="flex gap-2 md:gap-3 w-full min-w-0">
|
|
111
|
+
<div className="w-8 h-8 md:w-10 md:h-10 rounded-lg flex-shrink-0 bg-ods-border animate-pulse" />
|
|
112
|
+
<div className="flex-1 min-w-0">
|
|
113
|
+
{/* Header row — name + time bars, same mb-1 + gap-2 as the real header. */}
|
|
114
|
+
<div className="flex items-center gap-2 mb-1">
|
|
115
|
+
<div className="h-[15px] md:h-[20px] w-24 md:w-32 bg-ods-border rounded animate-pulse" />
|
|
116
|
+
<div className="h-[12px] md:h-[16px] w-12 md:w-16 bg-ods-border rounded animate-pulse" />
|
|
117
|
+
</div>
|
|
118
|
+
{/* Two body lines — match the 12/14px body line-height. */}
|
|
119
|
+
<div className="h-[14px] md:h-[18px] w-full bg-ods-border rounded animate-pulse" />
|
|
120
|
+
<div className="h-[14px] md:h-[18px] w-3/4 bg-ods-border rounded animate-pulse mt-1" />
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
@@ -12,6 +12,7 @@ export * from './chat-input'
|
|
|
12
12
|
export * from './slash-command-suggestions'
|
|
13
13
|
export * from './chat-message-enhanced'
|
|
14
14
|
export * from './chat-message-list'
|
|
15
|
+
export * from './chat-message-row'
|
|
15
16
|
|
|
16
17
|
export * from './chat-quick-action'
|
|
17
18
|
export * from './chat-ticket-list'
|
|
@@ -284,7 +284,10 @@ export interface SlashCommandsProp {
|
|
|
284
284
|
}
|
|
285
285
|
|
|
286
286
|
export interface ChatInputProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'onSubmit'> {
|
|
287
|
-
|
|
287
|
+
/** Source-compatible widening: returning `false` (or a Promise resolving to
|
|
288
|
+
* `false`) tells the input to KEEP the draft (e.g. a failed send). `void` /
|
|
289
|
+
* `true` clears as before — preserves every existing caller's behavior. */
|
|
290
|
+
onSend?: (message: string) => void | boolean | Promise<boolean>
|
|
288
291
|
onStop?: () => void | Promise<void>
|
|
289
292
|
sending?: boolean
|
|
290
293
|
awaitingResponse?: boolean
|
|
@@ -306,6 +309,10 @@ export interface ChatInputProps extends Omit<TextareaHTMLAttributes<HTMLTextArea
|
|
|
306
309
|
* primitives (no raw HTML elements) and ODS tokens for theming.
|
|
307
310
|
* Backward compat: omit to disable autocomplete entirely. */
|
|
308
311
|
slashCommands?: SlashCommandsProp
|
|
312
|
+
/** When true, send is allowed with EMPTY text (e.g. an attachments-only
|
|
313
|
+
* reply); `onSend('')` fires. Default false → today's text-required gate.
|
|
314
|
+
* Used by the ticket reply composer so a file-only reply can send. */
|
|
315
|
+
allowEmptySend?: boolean
|
|
309
316
|
}
|
|
310
317
|
|
|
311
318
|
export interface ChatInputRef {
|