@flamingo-stack/openframe-frontend-core 0.0.216 → 0.0.217

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 (103) hide show
  1. package/dist/{chunk-SMCG2CCC.cjs → chunk-6DCKL73F.cjs} +24 -24
  2. package/dist/{chunk-SMCG2CCC.cjs.map → chunk-6DCKL73F.cjs.map} +1 -1
  3. package/dist/{chunk-QTKU6ULP.js → chunk-BVFRD34B.js} +2 -2
  4. package/dist/{chunk-CDLYRFDE.js → chunk-ENBGG2K2.js} +3767 -3610
  5. package/dist/chunk-ENBGG2K2.js.map +1 -0
  6. package/dist/{chunk-K4DFAVSO.cjs → chunk-G2HHSZ3S.cjs} +9 -9
  7. package/dist/{chunk-K4DFAVSO.cjs.map → chunk-G2HHSZ3S.cjs.map} +1 -1
  8. package/dist/{chunk-2V4SACHE.js → chunk-L6IBKPVM.js} +2 -2
  9. package/dist/{chunk-572WQWIX.cjs → chunk-MVQ3OODK.cjs} +9 -9
  10. package/dist/{chunk-572WQWIX.cjs.map → chunk-MVQ3OODK.cjs.map} +1 -1
  11. package/dist/{chunk-GVNQAGXB.js → chunk-N5IKPYRL.js} +3 -81
  12. package/dist/chunk-N5IKPYRL.js.map +1 -0
  13. package/dist/{chunk-VC3ND5RB.js → chunk-SWZUZYWR.js} +2 -2
  14. package/dist/{chunk-IH76P5R6.cjs → chunk-TYIBMDUZ.cjs} +8 -86
  15. package/dist/chunk-TYIBMDUZ.cjs.map +1 -0
  16. package/dist/{chunk-ZGTDUPTW.cjs → chunk-YWDC5BXM.cjs} +382 -225
  17. package/dist/chunk-YWDC5BXM.cjs.map +1 -0
  18. package/dist/components/chat/chat-attachment-bar.d.ts +13 -2
  19. package/dist/components/chat/chat-attachment-bar.d.ts.map +1 -1
  20. package/dist/components/chat/chat-input.d.ts.map +1 -1
  21. package/dist/components/chat/chat-message-row.d.ts +25 -0
  22. package/dist/components/chat/chat-message-row.d.ts.map +1 -0
  23. package/dist/components/chat/index.cjs +6 -2
  24. package/dist/components/chat/index.cjs.map +1 -1
  25. package/dist/components/chat/index.d.ts +1 -0
  26. package/dist/components/chat/index.d.ts.map +1 -1
  27. package/dist/components/chat/index.js +5 -1
  28. package/dist/components/chat/types/component.types.d.ts +8 -1
  29. package/dist/components/chat/types/component.types.d.ts.map +1 -1
  30. package/dist/components/contact/index.cjs +3 -3
  31. package/dist/components/contact/index.js +2 -2
  32. package/dist/components/features/index.cjs +2 -2
  33. package/dist/components/features/index.js +1 -1
  34. package/dist/components/form.d.ts +1 -1
  35. package/dist/components/form.d.ts.map +1 -1
  36. package/dist/components/index.cjs +56 -52
  37. package/dist/components/index.cjs.map +1 -1
  38. package/dist/components/index.js +9 -5
  39. package/dist/components/index.js.map +1 -1
  40. package/dist/components/navigation/index.cjs +2 -2
  41. package/dist/components/navigation/index.js +1 -1
  42. package/dist/components/onboarding-guides/index.cjs +18 -18
  43. package/dist/components/onboarding-guides/index.js +3 -3
  44. package/dist/components/shared/dev-section/dev-card-row.d.ts +5 -45
  45. package/dist/components/shared/dev-section/dev-card-row.d.ts.map +1 -1
  46. package/dist/components/shared/legal-document/use-legal-docs.d.ts.map +1 -1
  47. package/dist/components/tickets/help-center-card.d.ts.map +1 -1
  48. package/dist/components/tickets/help-center-list.d.ts.map +1 -1
  49. package/dist/components/tickets/hooks/use-ticket-engagements.d.ts +9 -1
  50. package/dist/components/tickets/hooks/use-ticket-engagements.d.ts.map +1 -1
  51. package/dist/components/tickets/hooks/use-tickets-list.d.ts +7 -0
  52. package/dist/components/tickets/hooks/use-tickets-list.d.ts.map +1 -1
  53. package/dist/components/tickets/index.cjs +294 -255
  54. package/dist/components/tickets/index.cjs.map +1 -1
  55. package/dist/components/tickets/index.d.ts +1 -0
  56. package/dist/components/tickets/index.d.ts.map +1 -1
  57. package/dist/components/tickets/index.js +360 -321
  58. package/dist/components/tickets/index.js.map +1 -1
  59. package/dist/components/tickets/ticket-detail-drawer.d.ts.map +1 -1
  60. package/dist/components/tickets/ticket-reply-composer.d.ts +33 -0
  61. package/dist/components/tickets/ticket-reply-composer.d.ts.map +1 -0
  62. package/dist/components/tickets/types.d.ts +13 -0
  63. package/dist/components/tickets/types.d.ts.map +1 -1
  64. package/dist/components/ui/index.cjs +6 -2
  65. package/dist/components/ui/index.cjs.map +1 -1
  66. package/dist/components/ui/index.js +5 -1
  67. package/dist/components/ui/ticket-attachments-list.d.ts +5 -1
  68. package/dist/components/ui/ticket-attachments-list.d.ts.map +1 -1
  69. package/dist/index.cjs +6 -2
  70. package/dist/index.cjs.map +1 -1
  71. package/dist/index.js +5 -1
  72. package/dist/utils/index.cjs +59 -4
  73. package/dist/utils/index.cjs.map +1 -1
  74. package/dist/utils/index.js +59 -4
  75. package/dist/utils/index.js.map +1 -1
  76. package/dist/utils/scroll-into-view.d.ts +43 -48
  77. package/dist/utils/scroll-into-view.d.ts.map +1 -1
  78. package/package.json +1 -1
  79. package/src/components/chat/chat-attachment-bar.tsx +58 -22
  80. package/src/components/chat/chat-input.tsx +68 -29
  81. package/src/components/chat/chat-message-row.tsx +124 -0
  82. package/src/components/chat/index.ts +1 -0
  83. package/src/components/chat/types/component.types.ts +8 -1
  84. package/src/components/shared/dev-section/dev-card-row.tsx +5 -183
  85. package/src/components/shared/legal-document/use-legal-docs.ts +5 -1
  86. package/src/components/tickets/help-center-card.tsx +26 -29
  87. package/src/components/tickets/help-center-list.tsx +57 -10
  88. package/src/components/tickets/hooks/use-ticket-engagements.ts +17 -1
  89. package/src/components/tickets/hooks/use-tickets-list.ts +13 -0
  90. package/src/components/tickets/index.ts +4 -0
  91. package/src/components/tickets/ticket-detail-drawer.tsx +144 -200
  92. package/src/components/tickets/ticket-reply-composer.tsx +195 -0
  93. package/src/components/tickets/types.ts +14 -0
  94. package/src/components/ui/ticket-attachments-list.tsx +26 -8
  95. package/src/styles/app-globals.css +13 -0
  96. package/src/utils/scroll-into-view.ts +127 -53
  97. package/dist/chunk-CDLYRFDE.js.map +0 -1
  98. package/dist/chunk-GVNQAGXB.js.map +0 -1
  99. package/dist/chunk-IH76P5R6.cjs.map +0 -1
  100. package/dist/chunk-ZGTDUPTW.cjs.map +0 -1
  101. /package/dist/{chunk-QTKU6ULP.js.map → chunk-BVFRD34B.js.map} +0 -0
  102. /package/dist/{chunk-2V4SACHE.js.map → chunk-L6IBKPVM.js.map} +0 -0
  103. /package/dist/{chunk-VC3ND5RB.js.map → chunk-SWZUZYWR.js.map} +0 -0
@@ -1,63 +1,58 @@
1
1
  /**
2
- * `scrollElementIntoView` — canonical "smooth scroll element to top
3
- * of viewport, account for sticky chrome, optionally adjust for
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
- * Before this util existed, ~3 different call sites in the lib + hub
7
- * had the same 5-line snippet copy-pasted with subtle differences:
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
- * - `useUnifiedNav` same-URL re-scroll branch (hub)
10
- * - `<HelpCenterCard>` click-to-expand (lib, with cross-row
11
- * layout-shift adjustment)
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
- * The canonical pattern is `window.scrollTo({top, behavior:'smooth'})`
15
- * with a pre-computed pixel target NOT `element.scrollIntoView()`.
16
- * Pre-computing the target lets the browser run a clean uninterrupted
17
- * smooth animation to a fixed pixel value. `scrollIntoView` re-targets
18
- * continuously as the page layout shifts during the animation, which
19
- * causes visible jitter when content above the target is also moving
20
- * (a sibling collapsing, an async image loading, …).
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
- * The `adjustTargetY` callback is the escape hatch for cases where
23
- * the consumer KNOWS about an upcoming layout shift and can compute
24
- * the correct FINAL target before the animation starts. Example: in
25
- * `HelpCenterCard`, clicking row B while row A above is currently
26
- * expanded A's drawer collapses simultaneously with B's expansion,
27
- * shifting B's tile up by A's drawer height. The consumer passes
28
- * `adjustTargetY: raw => raw - getAboveDrawerHeight()` and the
29
- * browser smooth-scrolls to the post-collapse position directly.
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
- * BELOW sticky chrome. Defaults to 0. Pass `96` (matches
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
- /** Scroll animation style. Defaults to `'smooth'`. Use `'instant'`
37
- * for imperative jumps where animation would feel laggy (deep
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
- * headerOffset`) and returns the FINAL pixel target. Use this
43
- * when the caller knows about a layout shift that will happen
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
- * accounting for sticky chrome via `headerOffset`. Returns void; the
53
- * scroll runs async via the browser's smooth-scroll engine.
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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,MAAM,WAAW,4BAA4B;IAC3C;;8DAE0D;IAC1D,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;gDAE4C;IAC5C,QAAQ,CAAC,EAAE,cAAc,CAAA;IACzB;;;;;;;kCAO8B;IAC9B,aAAa,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAA;CAC/C;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,WAAW,GAAG,IAAI,GAAG,SAAS,EACtC,OAAO,GAAE,4BAAiC,GACzC,IAAI,CAON"}
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.216",
3
+ "version": "0.0.217",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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="inline-block h-7 w-7 shrink-0" />
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="h-4 w-4" />}
163
- className="!h-7 !w-7 !p-0 shrink-0 text-ods-text-muted hover:text-ods-text-primary"
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="flex-shrink-0 px-5 pb-2">
193
- <div className="flex items-center gap-2 flex-wrap">
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="flex items-center gap-2 rounded-md border border-ods-border bg-ods-card px-2 py-1.5 max-w-[240px]"
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 className="relative h-8 w-8 shrink-0 overflow-hidden rounded bg-ods-bg">
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="flex h-full w-full items-center justify-center text-[10px] font-mono uppercase text-ods-text-muted">
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="truncate text-xs text-ods-text-primary" title={file.name}>
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
- <div className="text-[10px] text-ods-text-muted">
261
- {status === 'sniffing' && 'Checking…'}
262
- {status === 'uploading' && `${progress}% · ${formatFileSize(file.size)}`}
263
- {status === 'ready' && formatFileSize(file.size)}
264
- {status === 'error' && (
265
- <span className="text-ods-attention-red-error" title={errorMessage}>
266
- {errorMessage ?? 'Upload failed'}
267
- </span>
268
- )}
269
- </div>
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="h-3.5 w-3.5" />}
281
- className="!h-5 !w-5 !p-0 shrink-0 text-ods-text-muted hover:text-ods-text-primary"
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
- el.focus()
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: skip when sending or
109
- // disabled, fire onSend with the trimmed value, clear local state.
110
- // The caller is responsible for any pre-fill UX (none here this
111
- // is the one-click "Recent" path).
112
- if (sending || disabled || !onSend) return
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. `sending` /
123
- // `disabled` / `onSend` change session-rarely; including them here
124
- // is correct for closure freshness and rebuilds < 5 times per chat
125
- // session an order of magnitude fewer than per-keystroke.
126
- [focusTextarea, sending, disabled, onSend],
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
- const message = value.trim()
131
- if (message && !sending && !disabled && onSend) {
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
- const sendDisabled = sending || disabled || !value.trim()
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
- onSend?: (message: string) => void
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 {