@proteos/ui 0.51.0 → 0.51.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteos/ui",
3
- "version": "0.51.0",
3
+ "version": "0.51.1",
4
4
  "license": "FSL-1.1-Apache-2.0",
5
5
  "description": "Proteos React component library (React Aria Components + Tailwind v4).",
6
6
  "repository": {
@@ -56,7 +56,7 @@
56
56
  "tailwind-variants": "^3.2.2",
57
57
  "tailwindcss": "^4.2.4",
58
58
  "thinking-orbs": "^0.3.1",
59
- "@proteos/sdk": "0.51.0"
59
+ "@proteos/sdk": "0.51.1"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@biomejs/biome": "^2.4.13",
@@ -1,6 +1,7 @@
1
1
  import type { Contact, ConversationBrief } from '@proteos/sdk'
2
- import { Phone } from 'lucide-react'
2
+ import { Pencil, Phone } from 'lucide-react'
3
3
  import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
4
+ import { Button as AriaButton } from 'react-aria-components'
4
5
 
5
6
  import { cn } from '../../lib/cn'
6
7
  import { Button } from '../button'
@@ -15,6 +16,13 @@ import { contactPhoneNumbers } from './types'
15
16
  it is a read-only preview (a custom component's own modal, the hub's call
16
17
  panel). The host may slot extra controls (a caller-id picker) via
17
18
  `controls`.
19
+
20
+ The guidance text is markdown-canonical but the lib carries no markdown
21
+ renderer: hosts inject one via `renderContent` (the web app passes its
22
+ MarkdownContent); without it the text shows as plain pre-wrapped lines.
23
+ Editing follows the same seam — `onEdit` reveals a pencil on hover over
24
+ the guidance box, and `editor` lets the host swap the box for its own
25
+ editing surface while a draft is open.
18
26
  ========================================================================= */
19
27
 
20
28
  export interface ConversationBriefPreviewProps {
@@ -44,6 +52,12 @@ export interface ConversationBriefPreviewProps {
44
52
  className?: string
45
53
  /** Visual override for the guidance text box (e.g. a max height). */
46
54
  contentClassName?: string
55
+ /** Renders the guidance text (markdown). Absent ⇒ plain pre-wrapped text. */
56
+ renderContent?: (content: string) => ReactNode
57
+ /** Shows an Edit button on hover over the guidance box. */
58
+ onEdit?: () => void
59
+ /** Replaces the guidance box wholesale — the host's editing surface. */
60
+ editor?: ReactNode
47
61
  }
48
62
 
49
63
  export function ConversationBriefPreview({
@@ -58,6 +72,9 @@ export function ConversationBriefPreview({
58
72
  callLabel = 'Call',
59
73
  className,
60
74
  contentClassName,
75
+ renderContent,
76
+ onEdit,
77
+ editor,
61
78
  }: ConversationBriefPreviewProps) {
62
79
  const [pickedContactId, setPickedContactId] = useState('')
63
80
  const contact = contacts.find((entry) => entry.id === pickedContactId) ?? contacts[0] ?? null
@@ -99,15 +116,35 @@ export function ConversationBriefPreview({
99
116
  .join(' · ')}
100
117
  </p>
101
118
  </div>
102
- <div
103
- className={cn(
104
- 'whitespace-pre-wrap rounded-md border border-rule-2 bg-surface-2/60 px-3 py-2.5',
105
- 'text-[13px] leading-relaxed text-ink-2 overflow-y-auto [scrollbar-width:thin]',
106
- contentClassName,
107
- )}
108
- >
109
- {brief.content}
110
- </div>
119
+ {editor ?? (
120
+ <div className="group/brief-content relative">
121
+ <div
122
+ className={cn(
123
+ 'rounded-md border border-rule-2 bg-surface-2/60 px-3 py-2.5',
124
+ 'text-[13px] leading-relaxed text-ink-2 overflow-y-auto [scrollbar-width:thin]',
125
+ renderContent ? null : 'whitespace-pre-wrap',
126
+ contentClassName,
127
+ )}
128
+ >
129
+ {renderContent ? renderContent(brief.content) : brief.content}
130
+ </div>
131
+ {onEdit ? (
132
+ <AriaButton
133
+ aria-label="Edit brief"
134
+ onPress={onEdit}
135
+ className={cn(
136
+ 'absolute right-1.5 top-1.5 inline-flex size-7 cursor-pointer items-center justify-center',
137
+ 'rounded-md bg-surface/80 text-ink-3 outline-none transition-opacity',
138
+ 'opacity-0 group-hover/brief-content:opacity-100 focus-visible:opacity-100',
139
+ 'data-[hovered]:bg-surface-3 data-[hovered]:text-ink',
140
+ 'focus-visible:shadow-[var(--shadow-focus)]',
141
+ )}
142
+ >
143
+ <Pencil className="size-3.5" aria-hidden />
144
+ </AriaButton>
145
+ ) : null}
146
+ </div>
147
+ )}
111
148
  {onCall ? (
112
149
  <div className="flex flex-col gap-2 border-t border-rule-2 pt-3">
113
150
  {contacts.length > 1 ? (
@@ -212,3 +212,39 @@ describe('ConversationBriefPreview', () => {
212
212
  expect(screen.getByText(/no phone number/)).toBeInTheDocument()
213
213
  })
214
214
  })
215
+
216
+ describe('ConversationBriefPreview — content seam', () => {
217
+ it('renders the guidance through renderContent when provided', () => {
218
+ render(
219
+ <ConversationBriefPreview
220
+ brief={brief({ content: '**bold** guidance' })}
221
+ contacts={[contact()]}
222
+ renderContent={(content) => <em data-testid="rendered">{content.toUpperCase()}</em>}
223
+ />,
224
+ )
225
+ expect(screen.getByTestId('rendered')).toHaveTextContent('**BOLD** GUIDANCE')
226
+ })
227
+
228
+ it('offers an Edit button only when onEdit is given', async () => {
229
+ const onEdit = vi.fn()
230
+ const { rerender } = render(<ConversationBriefPreview brief={brief()} contacts={[contact()]} />)
231
+ expect(screen.queryByRole('button', { name: 'Edit brief' })).toBeNull()
232
+ rerender(<ConversationBriefPreview brief={brief()} contacts={[contact()]} onEdit={onEdit} />)
233
+ await userEvent.click(screen.getByRole('button', { name: 'Edit brief' }))
234
+ expect(onEdit).toHaveBeenCalledTimes(1)
235
+ })
236
+
237
+ it('swaps the guidance box for the host editor', () => {
238
+ render(
239
+ <ConversationBriefPreview
240
+ brief={brief()}
241
+ contacts={[contact()]}
242
+ onEdit={() => {}}
243
+ editor={<textarea aria-label="Draft" defaultValue="editing" />}
244
+ />,
245
+ )
246
+ expect(screen.getByRole('textbox', { name: 'Draft' })).toBeInTheDocument()
247
+ expect(screen.queryByText('Ask about the renewal.', { exact: false })).toBeNull()
248
+ expect(screen.queryByRole('button', { name: 'Edit brief' })).toBeNull()
249
+ })
250
+ })
@@ -1,5 +1,5 @@
1
1
  import type { Contact, ConversationBrief } from '@proteos/sdk'
2
- import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
3
3
 
4
4
  import { ModalBody, ModalContent, ModalHeader, ModalTitle } from '../modal'
5
5
  import { ConversationBriefCard } from './conversation-brief-card'
@@ -53,6 +53,8 @@ export interface ConversationBriefReviewProps {
53
53
  overlayClassName?: string
54
54
  openLabel?: string
55
55
  className?: string
56
+ /** Built-in modal only: markdown renderer for the guidance text. */
57
+ renderContent?: (content: string) => ReactNode
56
58
  }
57
59
 
58
60
  export function ConversationBriefReview({
@@ -69,6 +71,7 @@ export function ConversationBriefReview({
69
71
  overlayClassName,
70
72
  openLabel,
71
73
  className,
74
+ renderContent,
72
75
  }: ConversationBriefReviewProps) {
73
76
  const [brief, setBrief] = useState<ConversationBrief | null>(null)
74
77
  const [contacts, setContacts] = useState<Contact[]>([])
@@ -149,6 +152,7 @@ export function ConversationBriefReview({
149
152
  contentClassName="max-h-[50dvh]"
150
153
  isCallDisabled={isCallDisabled}
151
154
  {...(callDisabledReason !== undefined ? { callDisabledReason } : {})}
155
+ {...(renderContent !== undefined ? { renderContent } : {})}
152
156
  {...(onCall && isDialableConversationBrief(brief)
153
157
  ? {
154
158
  onCall: (contact: Contact, phoneNumber: string) => {