@proteos/ui 0.50.1 → 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.50.1",
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.50.1"
59
+ "@proteos/sdk": "0.51.1"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@biomejs/biome": "^2.4.13",
@@ -78,8 +78,8 @@
78
78
  "typescript": "^6.0.3",
79
79
  "vite": "^8.0.10",
80
80
  "vitest": "^3.2.4",
81
- "@proteos/biome-config": "0.0.0",
82
- "@proteos/tsconfig": "0.0.0"
81
+ "@proteos/tsconfig": "0.0.0",
82
+ "@proteos/biome-config": "0.0.0"
83
83
  },
84
84
  "scripts": {
85
85
  "lint": "biome check .",
@@ -15,6 +15,8 @@ const meta: Meta<typeof Badge> = {
15
15
  control: { type: 'inline-radio' },
16
16
  options: ['default', 'neutral', 'active', 'success', 'warning', 'danger', 'custom'],
17
17
  },
18
+ shape: { control: { type: 'inline-radio' }, options: ['pill', 'rounded'] },
19
+ hasDot: { control: 'boolean' },
18
20
  },
19
21
  }
20
22
 
@@ -63,6 +65,29 @@ export const WithColorAndIcon: Story = {
63
65
  ),
64
66
  }
65
67
 
68
+ /** The enum-value chip: `rounded` shape, no dot. A value with an icon shows
69
+ * it; one without reads as plain text on its tint. This is exactly what
70
+ * the page-layout enum controls, table cells and filter chips render. */
71
+ export const EnumChip: Story = {
72
+ parameters: { controls: { hideNoControlsWarning: true } },
73
+ render: () => (
74
+ <div className="flex flex-wrap items-center gap-2">
75
+ <Badge shape="rounded" hasDot={false} variant="active">
76
+ to do
77
+ </Badge>
78
+ <Badge shape="rounded" hasDot={false} color="#5b8def">
79
+ in progress
80
+ </Badge>
81
+ <Badge shape="rounded" hasDot={false} icon="CircleCheck" color="#3f9d6b">
82
+ done
83
+ </Badge>
84
+ <Badge shape="rounded" hasDot={false} icon="Flame" color="#d94f4f">
85
+ urgent
86
+ </Badge>
87
+ </div>
88
+ ),
89
+ }
90
+
66
91
  export const InContext: Story = {
67
92
  parameters: { controls: { hideNoControlsWarning: true } },
68
93
  render: () => (
@@ -53,6 +53,28 @@ describe('Badge', () => {
53
53
  expect(badge.className).toContain('before:hidden')
54
54
  })
55
55
 
56
+ it('defaults to the pill shape with a leading dot', () => {
57
+ render(<Badge>quoted</Badge>)
58
+ const badge = screen.getByText('quoted')
59
+ expect(badge.className).toContain('rounded-pill')
60
+ expect(badge.className).toContain('before:content')
61
+ })
62
+
63
+ it('renders the rounded shape without a dot when asked', () => {
64
+ render(
65
+ <Badge shape="rounded" hasDot={false}>
66
+ to do
67
+ </Badge>,
68
+ )
69
+ const badge = screen.getByText('to do')
70
+ expect(badge.className).toContain('rounded-chip')
71
+ expect(badge.className).not.toContain('rounded-pill')
72
+ expect(badge.className).not.toContain('before:content')
73
+ // Register classes still emit `before:*`; the pseudo must be hidden so it
74
+ // does not occupy a flex gap in front of the text.
75
+ expect(badge.className).toContain('before:hidden')
76
+ })
77
+
56
78
  it('keeps the dot when the icon name is unknown', () => {
57
79
  render(<Badge icon="not-an-icon">done</Badge>)
58
80
  const badge = screen.getByText('done')
@@ -5,24 +5,28 @@ import { cn } from '../../lib/cn'
5
5
  import { resolveLucideIcon } from '../icon-picker/icon-picker'
6
6
 
7
7
  /* =========================================================================
8
- Badge — mono, pill-shaped status chip with a leading dot.
9
- Matches the Proteos spec's six registers: default, neutral, active (indigo),
10
- success, warning, danger. Paper-compatible fills — never pure-color.
8
+ Badge — mono status chip. Matches the Proteos spec's six registers:
9
+ default, neutral, active (indigo), success, warning, danger.
10
+ Paper-compatible fills — never pure-color.
11
+
12
+ Chrome knobs, orthogonal to the register:
13
+ - `shape` — `pill` (default, status badges) or `rounded` (9px corners,
14
+ the enum-value chip on record pages, tables, pickers and filters).
15
+ - `hasDot` — the leading dot. Default on; enum chips turn it off so a
16
+ value without an icon reads as plain text on tint.
11
17
 
12
18
  Two data-driven escape hatches for schema-defined values (enum options,
13
19
  labels): `color` — a #rrggbb tint that drives the dot, border and fill
14
20
  via color-mix while the text stays ink so any user-picked color keeps
15
21
  its contrast in light/dark/paper; and `icon` — a Lucide name that
16
- replaces the dot.
22
+ replaces the dot (whatever `hasDot` says).
17
23
  ========================================================================= */
18
24
 
19
25
  export const badgeStyles = tv({
20
26
  base: [
21
27
  'inline-flex items-center gap-1.5',
22
28
  'font-mono text-[11.5px] tracking-[-0.005em]',
23
- 'px-2.5 py-0.5 rounded-pill border',
24
- // Leading dot
25
- 'before:content-[""] before:block before:size-1.5 before:rounded-full',
29
+ 'px-2.5 py-0.5 border',
26
30
  ],
27
31
  variants: {
28
32
  variant: {
@@ -57,8 +61,17 @@ export const badgeStyles = tv({
57
61
  'bg-[color-mix(in_oklab,var(--badge-color)_12%,var(--color-surface))]',
58
62
  ],
59
63
  },
64
+ shape: {
65
+ pill: 'rounded-pill',
66
+ rounded: 'rounded-chip',
67
+ },
68
+ // Leading dot — its color comes from the register's `before:bg-*`.
69
+ hasDot: {
70
+ true: 'before:content-[""] before:block before:size-1.5 before:rounded-full',
71
+ false: '',
72
+ },
60
73
  },
61
- defaultVariants: { variant: 'default' },
74
+ defaultVariants: { variant: 'default', shape: 'pill', hasDot: true },
62
75
  })
63
76
 
64
77
  export type BadgeVariants = VariantProps<typeof badgeStyles>
@@ -81,15 +94,33 @@ const ICON_TONE: Record<NonNullable<BadgeVariants['variant']>, string> = {
81
94
  custom: 'text-[var(--badge-color)]',
82
95
  }
83
96
 
84
- export function Badge({ variant, color, icon, className, style, children, ...props }: BadgeProps) {
97
+ export function Badge({
98
+ variant,
99
+ shape,
100
+ hasDot,
101
+ color,
102
+ icon,
103
+ className,
104
+ style,
105
+ children,
106
+ ...props
107
+ }: BadgeProps) {
85
108
  const Icon = icon ? resolveLucideIcon(icon) : null
86
109
  const register: NonNullable<BadgeVariants['variant']> = color ? 'custom' : (variant ?? 'default')
87
110
  const mergedStyle = color ? ({ ...style, '--badge-color': color } as CSSProperties) : style
111
+ // The register's `before:bg-*` classes alone materialize an empty ::before
112
+ // (Tailwind injects `content` for every `before:` utility), which as a flex
113
+ // child still eats one `gap`. Hide it whenever no dot should show.
114
+ const showDot = hasDot !== false && !Icon
88
115
  return (
89
116
  <span
90
117
  {...props}
91
118
  style={mergedStyle}
92
- className={cn(badgeStyles({ variant: register }), Icon ? 'before:hidden' : null, className)}
119
+ className={cn(
120
+ badgeStyles({ variant: register, shape, hasDot }),
121
+ showDot ? null : 'before:hidden',
122
+ className,
123
+ )}
93
124
  >
94
125
  {Icon ? <Icon aria-hidden className={cn('size-3 shrink-0', ICON_TONE[register])} /> : null}
95
126
  {children}
@@ -29,6 +29,10 @@ export type BadgeMultiSelectOption<T extends Key = Key> = {
29
29
  label: string
30
30
  /** Badge color register. Defaults to `default`. */
31
31
  variant?: BadgeVariants['variant']
32
+ /** Badge corner shape. Defaults to `pill`; enum chips use `rounded`. */
33
+ shape?: BadgeVariants['shape']
34
+ /** Leading dot. Defaults to on; enum chips turn it off. */
35
+ hasDot?: boolean
32
36
  /** `#rrggbb` tint — overrides `variant` (see Badge). */
33
37
  color?: string
34
38
  /** Lucide icon name; replaces the badge dot. */
@@ -36,14 +40,20 @@ export type BadgeMultiSelectOption<T extends Key = Key> = {
36
40
  isDisabled?: boolean
37
41
  }
38
42
 
39
- /** The Badge chrome props an option carries — spread onto every Badge it renders as. */
40
- function badgeChrome(option: {
43
+ type BadgeChrome = {
41
44
  variant?: BadgeVariants['variant'] | undefined
45
+ shape?: BadgeVariants['shape'] | undefined
46
+ hasDot?: boolean | undefined
42
47
  color?: string | undefined
43
48
  icon?: string | undefined
44
- }) {
49
+ }
50
+
51
+ /** The Badge chrome props an option carries — spread onto every Badge it renders as. */
52
+ function badgeChrome(option: BadgeChrome) {
45
53
  return {
46
54
  ...(option.variant ? { variant: option.variant } : {}),
55
+ ...(option.shape ? { shape: option.shape } : {}),
56
+ ...(option.hasDot !== undefined ? { hasDot: option.hasDot } : {}),
47
57
  ...(option.color ? { color: option.color } : {}),
48
58
  ...(option.icon ? { icon: option.icon } : {}),
49
59
  }
@@ -243,21 +253,16 @@ export function BadgeMultiSelect<T extends Key>({
243
253
 
244
254
  function RemovableBadge({
245
255
  children,
246
- variant,
247
- color,
248
- icon,
249
256
  onRemove,
250
257
  isDisabled,
251
- }: {
258
+ ...chrome
259
+ }: BadgeChrome & {
252
260
  children: ReactNode
253
- variant?: BadgeVariants['variant']
254
- color?: string
255
- icon?: string
256
261
  onRemove: () => void
257
262
  isDisabled: boolean
258
263
  }) {
259
264
  return (
260
- <Badge {...badgeChrome({ variant, color, icon })}>
265
+ <Badge {...badgeChrome(chrome)}>
261
266
  <span>{children}</span>
262
267
  <button
263
268
  type="button"
@@ -0,0 +1,81 @@
1
+ import type { Contact, ConversationBrief } from '@proteos/sdk'
2
+ import type { Meta, StoryObj } from '@storybook/react-vite'
3
+
4
+ import { ConversationBriefCard } from './conversation-brief-card'
5
+ import { ConversationBriefPreview } from './conversation-brief-preview'
6
+ import { ConversationBriefReview } from './conversation-brief-review'
7
+ import type { ConversationBriefClient } from './types'
8
+
9
+ const meta: Meta<typeof ConversationBriefCard> = {
10
+ title: 'Components/ConversationBriefCard',
11
+ component: ConversationBriefCard,
12
+ tags: ['autodocs'],
13
+ parameters: { layout: 'padded' },
14
+ }
15
+ export default meta
16
+ type Story = StoryObj<typeof ConversationBriefCard>
17
+
18
+ export const Prepared: Story = {
19
+ args: {
20
+ title: 'Northline renewal',
21
+ summary: 'Dana Ruiz · phone · renewal · Confirm the Q4 volumes before pricing.',
22
+ },
23
+ }
24
+
25
+ export const CallHeld: Story = {
26
+ args: { ...Prepared.args, outcome: 'used' } as Story['args'],
27
+ }
28
+
29
+ export const Discarded: Story = {
30
+ args: { ...Prepared.args, outcome: 'discarded' } as Story['args'],
31
+ }
32
+
33
+ const sampleBrief: ConversationBrief = {
34
+ id: 'brief-1',
35
+ org_id: 'org-1',
36
+ contact_ids: ['contact-1'],
37
+ subject: 'Northline renewal',
38
+ channel: 'phone',
39
+ type_key: 'renewal',
40
+ status: 'prepared',
41
+ content:
42
+ 'Objective: renew the Northline contract for 12 months.\n\n- Confirm Q4 volumes before quoting.\n- They asked about the API rate limits last time — have the new tiers ready.\n- Avoid committing to the March migration date.',
43
+ created_at: '2026-09-09T10:00:00Z',
44
+ created_by: { type: 'agent', id: 'agent-1' },
45
+ updated_at: '2026-09-09T10:00:00Z',
46
+ updated_by: { type: 'agent', id: 'agent-1' },
47
+ }
48
+
49
+ const sampleContact = {
50
+ id: 'contact-1',
51
+ name: 'Dana Ruiz',
52
+ addresses: [
53
+ { id: 'a1', kind: 'phone', value: '+4930123456' },
54
+ { id: 'a2', kind: 'phone', value: '+4917612345' },
55
+ ],
56
+ } as unknown as Contact
57
+
58
+ const client: ConversationBriefClient = {
59
+ getConversationBrief: async () => sampleBrief,
60
+ getContact: async () => sampleContact,
61
+ }
62
+
63
+ /** The connected card with the built-in preview modal and a host dial. */
64
+ export const Connected: StoryObj = {
65
+ render: () => (
66
+ <ConversationBriefReview
67
+ conversationBriefId="brief-1"
68
+ client={client}
69
+ onCall={(brief, contact, phoneNumber) =>
70
+ window.alert(`dial ${contact.name} at ${phoneNumber} for ${brief.id}`)
71
+ }
72
+ />
73
+ ),
74
+ }
75
+
76
+ /** The preview on its own — as the softphone / hub call panel would embed it. */
77
+ export const Preview: StoryObj = {
78
+ render: () => (
79
+ <ConversationBriefPreview brief={sampleBrief} contacts={[sampleContact]} onCall={() => {}} />
80
+ ),
81
+ }
@@ -0,0 +1,102 @@
1
+ import { Phone } from 'lucide-react'
2
+ import type { ReactNode } from 'react'
3
+ import { Button as AriaButton } from 'react-aria-components'
4
+ import { tv } from 'tailwind-variants'
5
+
6
+ import { cn } from '../../lib/cn'
7
+ import type { ConversationBriefOutcome } from './types'
8
+
9
+ /* =========================================================================
10
+ ConversationBriefCard — the compact entry point to a prepared call: phone glyph,
11
+ eyebrow, the contact's name, the brief's type / opening line, and one
12
+ action ("Open Brief"). Pure chrome — it neither fetches nor dials, so it can
13
+ sit in a chat flow, a record page, a task list or a custom component alike.
14
+ ConversationBriefReview is the connected wrapper; reach for this one only when the
15
+ host already holds the brief.
16
+
17
+ Once the brief is used or discarded the action is replaced by a status
18
+ label rather than removed — the row keeps its shape and its record.
19
+ ========================================================================= */
20
+
21
+ export const conversationBriefCardStyles = tv({
22
+ slots: {
23
+ root: [
24
+ 'flex items-center gap-2.5 rounded-md border border-accent/40 px-3 py-2',
25
+ 'bg-[color-mix(in_oklab,var(--color-accent)_6%,var(--color-surface))]',
26
+ ],
27
+ icon: [
28
+ 'grid size-8 shrink-0 place-items-center rounded-full text-accent',
29
+ 'bg-[color-mix(in_oklab,var(--color-accent)_12%,transparent)]',
30
+ ],
31
+ body: 'min-w-0 flex-1',
32
+ eyebrow: 'font-mono text-[10px] uppercase tracking-[0.08em] text-accent',
33
+ title: 'truncate text-[13px] font-medium text-ink',
34
+ summary: 'truncate text-[12px] text-ink-3',
35
+ action: [
36
+ 'shrink-0 cursor-pointer rounded-sm border border-accent/50 px-2.5 py-1',
37
+ 'font-mono text-[11px] text-accent outline-none',
38
+ 'hover:bg-[color-mix(in_oklab,var(--color-accent)_12%,transparent)]',
39
+ 'focus-visible:shadow-[var(--shadow-focus)]',
40
+ 'disabled:cursor-default disabled:opacity-50',
41
+ ],
42
+ outcome: 'shrink-0 font-mono text-[11px] text-ink-3',
43
+ },
44
+ })
45
+
46
+ const styles = conversationBriefCardStyles()
47
+
48
+ export const CONVERSATION_BRIEF_OUTCOME_LABEL: Record<ConversationBriefOutcome, string> = {
49
+ used: 'Call held',
50
+ discarded: 'Discarded',
51
+ }
52
+
53
+ export interface ConversationBriefCardProps {
54
+ /** Headline: the brief's subject, else the contact's name. */
55
+ title: string
56
+ /** Second line: the contact, channel, type and/or the opening line. */
57
+ summary?: string
58
+ /** Default 'Conversation brief — prepared'. */
59
+ eyebrow?: ReactNode
60
+ /** Default 'Open Brief'. */
61
+ openLabel?: string
62
+ /** Terminal outcome. Set ⇒ the action is replaced by its status label. */
63
+ outcome?: ConversationBriefOutcome | null
64
+ onOpen?: () => void
65
+ isOpenDisabled?: boolean
66
+ className?: string
67
+ }
68
+
69
+ export function ConversationBriefCard({
70
+ title,
71
+ summary,
72
+ eyebrow = 'Conversation brief — prepared',
73
+ openLabel = 'Open Brief',
74
+ outcome,
75
+ onOpen,
76
+ isOpenDisabled = false,
77
+ className,
78
+ }: ConversationBriefCardProps) {
79
+ return (
80
+ <div className={cn(styles.root(), className)}>
81
+ <span className={styles.icon()}>
82
+ <Phone className="size-4" aria-hidden />
83
+ </span>
84
+ <div className={styles.body()}>
85
+ <p className={styles.eyebrow()}>{eyebrow}</p>
86
+ <p className={styles.title()}>{title}</p>
87
+ {summary ? <p className={styles.summary()}>{summary}</p> : null}
88
+ </div>
89
+ {outcome ? (
90
+ <span className={styles.outcome()}>{CONVERSATION_BRIEF_OUTCOME_LABEL[outcome]}</span>
91
+ ) : (
92
+ <AriaButton
93
+ onPress={onOpen ?? (() => {})}
94
+ isDisabled={isOpenDisabled}
95
+ className={styles.action()}
96
+ >
97
+ {openLabel}
98
+ </AriaButton>
99
+ )}
100
+ </div>
101
+ )
102
+ }
@@ -0,0 +1,193 @@
1
+ import type { Contact, ConversationBrief } from '@proteos/sdk'
2
+ import { Pencil, Phone } from 'lucide-react'
3
+ import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
4
+ import { Button as AriaButton } from 'react-aria-components'
5
+
6
+ import { cn } from '../../lib/cn'
7
+ import { Button } from '../button'
8
+ import { Select } from '../select'
9
+ import { contactPhoneNumbers } from './types'
10
+
11
+ /* =========================================================================
12
+ ConversationBriefPreview — the brief read in full before (and during) a call: the
13
+ contact, the guidance text, and — when the host can dial — the number to
14
+ call and the Call button. Fetch-agnostic and dial-agnostic: the host hands
15
+ in the loaded brief + contact and, optionally, `onCall`; without `onCall`
16
+ it is a read-only preview (a custom component's own modal, the hub's call
17
+ panel). The host may slot extra controls (a caller-id picker) via
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.
26
+ ========================================================================= */
27
+
28
+ export interface ConversationBriefPreviewProps {
29
+ brief: ConversationBrief
30
+ /** The brief's contacts (the resolved subset — a failed lookup is simply
31
+ * absent). Names head the preview; phone addresses are the dial
32
+ * candidates. */
33
+ contacts: Contact[]
34
+ /** Renders the contact/number pickers + Call button. Absent ⇒ read-only
35
+ * preview. Receives the chosen contact and number. */
36
+ onCall?: (contact: Contact, phoneNumber: string) => void
37
+ /** Disables the Call button (softphone busy, another call running…). */
38
+ isCallDisabled?: boolean
39
+ /** Explains why calling is disabled (shown under the controls). */
40
+ callDisabledReason?: string
41
+ /** Host-slotted controls rendered next to the number picker (caller id). */
42
+ controls?: ReactNode
43
+ /** Hide the built-in Call button — for hosts that render the action in
44
+ * their own footer; pair with `onSelectionChange` to know what to dial. */
45
+ isCallButtonHidden?: boolean
46
+ /** Fires whenever the chosen contact / number changes (and once on mount),
47
+ * so a host rendering its own Call button knows the current target.
48
+ * `phoneNumber` is '' when the chosen contact has no number. */
49
+ onSelectionChange?: (contact: Contact | null, phoneNumber: string) => void
50
+ /** Default 'Call'. */
51
+ callLabel?: string
52
+ className?: string
53
+ /** Visual override for the guidance text box (e.g. a max height). */
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
61
+ }
62
+
63
+ export function ConversationBriefPreview({
64
+ brief,
65
+ contacts,
66
+ onCall,
67
+ isCallDisabled = false,
68
+ callDisabledReason,
69
+ controls,
70
+ isCallButtonHidden = false,
71
+ onSelectionChange,
72
+ callLabel = 'Call',
73
+ className,
74
+ contentClassName,
75
+ renderContent,
76
+ onEdit,
77
+ editor,
78
+ }: ConversationBriefPreviewProps) {
79
+ const [pickedContactId, setPickedContactId] = useState('')
80
+ const contact = contacts.find((entry) => entry.id === pickedContactId) ?? contacts[0] ?? null
81
+ const numbers = useMemo(() => contactPhoneNumbers(contact), [contact])
82
+ const [pickedNumber, setPickedNumber] = useState('')
83
+ const phoneNumber = numbers.includes(pickedNumber) ? pickedNumber : numbers[0] || ''
84
+ const isOpen = brief.status === 'prepared'
85
+ const names = contacts
86
+ .map((entry) => entry.name)
87
+ .filter(Boolean)
88
+ .join(', ')
89
+
90
+ // Latest-closure ref: the host's callback is routinely a fresh arrow, and
91
+ // the effect must fire on TARGET changes only.
92
+ const onSelectionChangeRef = useRef(onSelectionChange)
93
+ onSelectionChangeRef.current = onSelectionChange
94
+ useEffect(() => {
95
+ onSelectionChangeRef.current?.(contact, phoneNumber)
96
+ }, [contact, phoneNumber])
97
+
98
+ return (
99
+ <div className={cn('flex flex-col gap-3', className)}>
100
+ <div className="min-w-0">
101
+ <p className="truncate text-[14px] font-semibold text-ink">
102
+ {brief.subject || names || 'Prepared conversation'}
103
+ </p>
104
+ <p className="font-mono text-[10.5px] text-ink-4">
105
+ {[
106
+ brief.subject ? names : '',
107
+ brief.channel ? `Channel ${brief.channel}` : 'Any channel',
108
+ brief.type_key ? `Type ${brief.type_key}` : '',
109
+ brief.status === 'prepared'
110
+ ? 'Prepared'
111
+ : brief.status === 'used'
112
+ ? 'Call held'
113
+ : 'Discarded',
114
+ ]
115
+ .filter(Boolean)
116
+ .join(' · ')}
117
+ </p>
118
+ </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
+ )}
148
+ {onCall ? (
149
+ <div className="flex flex-col gap-2 border-t border-rule-2 pt-3">
150
+ {contacts.length > 1 ? (
151
+ <Select
152
+ aria-label="Who to call"
153
+ selectedKey={contact?.id ?? ''}
154
+ onSelectionChange={(key) => {
155
+ setPickedContactId(String(key))
156
+ setPickedNumber('')
157
+ }}
158
+ options={contacts.map((entry) => ({ id: entry.id, label: entry.name || entry.id }))}
159
+ />
160
+ ) : null}
161
+ {numbers.length > 1 ? (
162
+ <Select
163
+ aria-label="Number to call"
164
+ selectedKey={phoneNumber}
165
+ onSelectionChange={(key) => setPickedNumber(String(key))}
166
+ options={numbers.map((number) => ({ id: number, label: number }))}
167
+ />
168
+ ) : (
169
+ <p className="font-mono text-[12px] text-ink-3">
170
+ {phoneNumber ||
171
+ (contact ? 'This contact has no phone number on file.' : 'No contact to call.')}
172
+ </p>
173
+ )}
174
+ {controls}
175
+ {callDisabledReason ? (
176
+ <p className="text-[12px] text-warning">{callDisabledReason}</p>
177
+ ) : null}
178
+ {isCallButtonHidden ? null : (
179
+ <div className="flex justify-end">
180
+ <Button
181
+ isDisabled={isCallDisabled || !isOpen || phoneNumber === '' || !contact}
182
+ onPress={() => contact && onCall(contact, phoneNumber)}
183
+ >
184
+ <Phone className="size-3.5" aria-hidden />
185
+ {callLabel}
186
+ </Button>
187
+ </div>
188
+ )}
189
+ </div>
190
+ ) : null}
191
+ </div>
192
+ )
193
+ }