@startsimpli/ui 0.4.14 → 0.4.15

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 (71) hide show
  1. package/README.md +457 -398
  2. package/package.json +18 -13
  3. package/src/components/__tests__/calendar-view-popup.test.tsx +42 -0
  4. package/src/components/__tests__/chat.test.tsx +129 -0
  5. package/src/components/__tests__/meetings-list.test.tsx +114 -0
  6. package/src/components/__tests__/slide-deck-viewer.test.tsx +82 -0
  7. package/src/components/__tests__/workspace.test.tsx +106 -0
  8. package/src/components/account/__tests__/account.test.tsx +5 -32
  9. package/src/components/account/change-password-form.tsx +1 -28
  10. package/src/components/calendar/calendar-view.tsx +31 -0
  11. package/src/components/calendar/index.ts +7 -0
  12. package/src/components/calendar/meetings-list.tsx +202 -0
  13. package/src/components/calendar/upcoming-meetings.tsx +5 -5
  14. package/src/components/chat/ChatComposer.tsx +113 -0
  15. package/src/components/chat/ChatMessage.tsx +81 -0
  16. package/src/components/chat/ChatThread.tsx +57 -0
  17. package/src/components/chat/index.ts +12 -0
  18. package/src/components/chat/types.ts +20 -0
  19. package/src/components/index.ts +13 -0
  20. package/src/components/slide-deck/SlideCanvas.tsx +68 -0
  21. package/src/components/slide-deck/SlideDeckViewer.tsx +144 -0
  22. package/src/components/slide-deck/SlideFilmstrip.tsx +73 -0
  23. package/src/components/slide-deck/index.ts +7 -0
  24. package/src/components/slide-deck/types.ts +18 -0
  25. package/src/components/team/DomainClaimCard.tsx +170 -0
  26. package/src/components/team/InviteMemberDialog.tsx +182 -0
  27. package/src/components/team/LeaveTeamDialog.tsx +130 -0
  28. package/src/components/team/MembersTable.tsx +138 -0
  29. package/src/components/team/OrgSwitcher.tsx +68 -0
  30. package/src/components/team/PendingInvitationCallout.tsx +106 -0
  31. package/src/components/team/RoleSelector.tsx +68 -0
  32. package/src/components/team/__tests__/team-components.test.tsx +352 -0
  33. package/src/components/team/domain-claim-card-default-class-names.ts +45 -0
  34. package/src/components/team/index.ts +57 -0
  35. package/src/components/team/invite-member-dialog-default-class-names.ts +41 -0
  36. package/src/components/team/leave-team-dialog-default-class-names.ts +33 -0
  37. package/src/components/team/members-table-default-class-names.ts +39 -0
  38. package/src/components/team/org-switcher-default-class-names.ts +13 -0
  39. package/src/components/team/pending-invitation-callout-default-class-names.ts +22 -0
  40. package/src/components/team/role-selector-default-class-names.ts +11 -0
  41. package/src/components/team/types.ts +97 -0
  42. package/src/components/workflows/ExecNodeDetails.tsx +83 -0
  43. package/src/components/workflows/ExecutionTimeline.tsx +146 -0
  44. package/src/components/workflows/NodeInspector.tsx +257 -0
  45. package/src/components/workflows/NodePalette.tsx +119 -0
  46. package/src/components/workflows/WorkflowCanvas.tsx +113 -0
  47. package/src/components/workflows/WorkflowEdge.tsx +65 -0
  48. package/src/components/workflows/WorkflowEditor.tsx +130 -0
  49. package/src/components/workflows/WorkflowNode.tsx +198 -0
  50. package/src/components/workflows/WorkflowRunViewer.tsx +81 -0
  51. package/src/components/workflows/__tests__/ExecutionTimeline.test.tsx +99 -0
  52. package/src/components/workflows/__tests__/NodeInspector.test.tsx +74 -0
  53. package/src/components/workflows/__tests__/NodePalette.test.tsx +46 -0
  54. package/src/components/workflows/__tests__/WorkflowCanvas.test.tsx +59 -0
  55. package/src/components/workflows/__tests__/WorkflowNode.test.tsx +92 -0
  56. package/src/components/workflows/__tests__/WorkflowRunViewer.test.tsx +138 -0
  57. package/src/components/workflows/__tests__/auto-layout.test.ts +107 -0
  58. package/src/components/workflows/__tests__/serialization.test.ts +278 -0
  59. package/src/components/workflows/exec-status.ts +90 -0
  60. package/src/components/workflows/hooks/useCanvasGraph.ts +70 -0
  61. package/src/components/workflows/hooks/useNodeStatusOverlay.ts +47 -0
  62. package/src/components/workflows/index.ts +78 -0
  63. package/src/components/workflows/layout/auto-layout.ts +142 -0
  64. package/src/components/workflows/node-icons.ts +31 -0
  65. package/src/components/workflows/serialization.ts +171 -0
  66. package/src/components/workflows/theme/categories.ts +96 -0
  67. package/src/components/workflows/types.ts +231 -0
  68. package/src/components/workflows/workflows.css +29 -0
  69. package/src/components/workspace/DualPaneWorkspace.tsx +187 -0
  70. package/src/components/workspace/SplitPane.tsx +174 -0
  71. package/src/components/workspace/index.ts +4 -0
@@ -11,7 +11,6 @@ export interface ChangePasswordFormProps {
11
11
  onSubmit: (values: {
12
12
  old_password: string
13
13
  new_password: string
14
- new_password_confirm: string
15
14
  }) => Promise<void>
16
15
  /** Called after a successful password change (e.g. to sign out) */
17
16
  onSuccess?: () => void
@@ -21,38 +20,27 @@ export interface ChangePasswordFormProps {
21
20
  export function ChangePasswordForm({ onSubmit, onSuccess, disabled }: ChangePasswordFormProps) {
22
21
  const [oldPassword, setOldPassword] = useState('')
23
22
  const [newPassword, setNewPassword] = useState('')
24
- const [confirmPassword, setConfirmPassword] = useState('')
25
23
  const [saving, setSaving] = useState(false)
26
24
  const [error, setError] = useState<string | null>(null)
27
25
  const [success, setSuccess] = useState(false)
28
26
 
29
- const isValid =
30
- oldPassword.length > 0 &&
31
- newPassword.length >= 8 &&
32
- newPassword === confirmPassword
27
+ const isValid = oldPassword.length > 0 && newPassword.length >= 8
33
28
 
34
29
  const handleSubmit = async (e: React.FormEvent) => {
35
30
  e.preventDefault()
36
31
  setError(null)
37
32
  setSuccess(false)
38
33
 
39
- if (newPassword !== confirmPassword) {
40
- setError('New passwords do not match.')
41
- return
42
- }
43
-
44
34
  setSaving(true)
45
35
 
46
36
  try {
47
37
  await onSubmit({
48
38
  old_password: oldPassword,
49
39
  new_password: newPassword,
50
- new_password_confirm: confirmPassword,
51
40
  })
52
41
  setSuccess(true)
53
42
  setOldPassword('')
54
43
  setNewPassword('')
55
- setConfirmPassword('')
56
44
  onSuccess?.()
57
45
  } catch (err) {
58
46
  setError(err instanceof Error ? err.message : 'Failed to change password')
@@ -100,21 +88,6 @@ export function ChangePasswordForm({ onSubmit, onSuccess, disabled }: ChangePass
100
88
  )}
101
89
  </div>
102
90
 
103
- <div className="space-y-2">
104
- <Label htmlFor="cp-confirm-password">Confirm new password</Label>
105
- <Input
106
- id="cp-confirm-password"
107
- type="password"
108
- value={confirmPassword}
109
- onChange={(e) => setConfirmPassword(e.target.value)}
110
- disabled={disabled || saving}
111
- autoComplete="new-password"
112
- />
113
- {confirmPassword.length > 0 && newPassword !== confirmPassword && (
114
- <p className="text-xs text-destructive">Passwords do not match.</p>
115
- )}
116
- </div>
117
-
118
91
  {error && (
119
92
  <p className="text-sm text-destructive">{error}</p>
120
93
  )}
@@ -115,6 +115,22 @@ export interface CalendarViewProps<T = unknown> {
115
115
  minHeight?: number
116
116
  /** className passed to the wrapping div for Tailwind overrides. */
117
117
  className?: string
118
+ /**
119
+ * Per-event styling. Receives the original CalendarEvent (with resource)
120
+ * and returns optional className / inline style applied to the rendered
121
+ * event block. Useful for color-coding by source (e.g. real Outlook event
122
+ * vs locally-scheduled meeting).
123
+ */
124
+ eventPropGetter?: (event: CalendarEvent<T>) => {
125
+ className?: string
126
+ style?: React.CSSProperties
127
+ }
128
+ /**
129
+ * When the month grid runs out of vertical space, react-big-calendar
130
+ * collapses the overflow into a "+N more" link. Set true (default) to
131
+ * open the day's full event list as a popover when that link is clicked.
132
+ */
133
+ popup?: boolean
118
134
  }
119
135
 
120
136
  /**
@@ -150,6 +166,8 @@ export function CalendarView<T = unknown>({
150
166
  selectable = true,
151
167
  minHeight = 600,
152
168
  className,
169
+ eventPropGetter,
170
+ popup = true,
153
171
  }: CalendarViewProps<T>) {
154
172
  const normalized = React.useMemo(() => events.map(normalizeEvent), [events])
155
173
 
@@ -196,6 +214,17 @@ export function CalendarView<T = unknown>({
196
214
  [views],
197
215
  )
198
216
 
217
+ const rbcEventPropGetter = React.useCallback(
218
+ (rbcEvent: object) => {
219
+ if (!eventPropGetter) return {}
220
+ const id = (rbcEvent as InternalRBCEvent).id
221
+ const original = eventByInternalId.get(id)
222
+ if (!original) return {}
223
+ return eventPropGetter(original)
224
+ },
225
+ [eventPropGetter, eventByInternalId],
226
+ )
227
+
199
228
  return (
200
229
  <div className={className} style={{ minHeight }}>
201
230
  <Calendar
@@ -215,6 +244,8 @@ export function CalendarView<T = unknown>({
215
244
  onNavigate={onNavigate}
216
245
  onView={handleViewChange}
217
246
  selectable={selectable}
247
+ popup={popup}
248
+ eventPropGetter={eventPropGetter ? rbcEventPropGetter : undefined}
218
249
  style={{ height: "100%", minHeight }}
219
250
  />
220
251
  </div>
@@ -11,3 +11,10 @@ export type {
11
11
  UpcomingMeetingStatus,
12
12
  UpcomingMeetingsProps,
13
13
  } from "./upcoming-meetings"
14
+
15
+ export { MeetingsList } from "./meetings-list"
16
+ export type {
17
+ MeetingsListItem,
18
+ MeetingsListProps,
19
+ MeetingsListStatus,
20
+ } from "./meetings-list"
@@ -0,0 +1,202 @@
1
+ "use client"
2
+
3
+ /**
4
+ * MeetingsList — table-style meeting list with status badges, join links,
5
+ * and an optional delete action.
6
+ *
7
+ * Distinct from UpcomingMeetings (sidebar card-stack); this is the
8
+ * full-width table-row layout used by /calendar's "All Meetings" section
9
+ * and /fundraises/[id]/outreach's MeetingsTab (Upcoming + Past lists).
10
+ *
11
+ * Pure presentational. The consumer owns sort, filter, fetch, and delete
12
+ * confirmation.
13
+ *
14
+ * lifecycle:calendar-ui (raise-simpli-9cp)
15
+ */
16
+
17
+ import * as React from "react"
18
+ import { CalendarDays, ExternalLink, Plus, Trash2 } from "lucide-react"
19
+ import { Button } from "../ui/button"
20
+ import { cn } from "../../utils"
21
+
22
+ export type MeetingsListStatus = "pending" | "confirmed" | "cancelled" | "completed"
23
+
24
+ export interface MeetingsListItem {
25
+ id: string | number
26
+ title: string
27
+ /** ISO 8601 timestamp string */
28
+ scheduled_at: string
29
+ status: MeetingsListStatus
30
+ duration_minutes?: number
31
+ investor_name?: string
32
+ investor_email?: string
33
+ meeting_link?: string
34
+ }
35
+
36
+ export interface MeetingsListProps<T extends MeetingsListItem = MeetingsListItem> {
37
+ meetings: T[]
38
+ /** Whether the list is still loading. */
39
+ loading?: boolean
40
+ /** Number of skeleton rows to show while loading. Defaults to 4. */
41
+ loadingRowCount?: number
42
+ /** If set, each row gets a delete (trash) button that calls this with the id. */
43
+ onDelete?: (id: string | number) => void
44
+ /** If set, the whole row becomes clickable. */
45
+ onRowClick?: (meeting: T) => void
46
+ /** If set, the empty state shows a "Schedule First Meeting" CTA wired to this. */
47
+ onSchedule?: () => void
48
+ /** Empty-state copy. Defaults to "No meetings scheduled yet." */
49
+ emptyMessage?: string
50
+ /** className passed to the wrapping div. */
51
+ className?: string
52
+ }
53
+
54
+ const STATUS_BADGE: Record<MeetingsListStatus, { label: string; className: string }> = {
55
+ pending: { label: "Pending", className: "bg-yellow-100 text-yellow-800" },
56
+ confirmed: { label: "Confirmed", className: "bg-green-100 text-green-800" },
57
+ cancelled: { label: "Cancelled", className: "bg-red-100 text-red-800" },
58
+ completed: { label: "Completed", className: "bg-gray-100 text-gray-800" },
59
+ }
60
+
61
+ function formatDate(isoStr: string): string {
62
+ return new Date(isoStr).toLocaleDateString("en-US", {
63
+ weekday: "short",
64
+ month: "short",
65
+ day: "numeric",
66
+ year: "numeric",
67
+ })
68
+ }
69
+
70
+ function formatTime(isoStr: string): string {
71
+ return new Date(isoStr).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })
72
+ }
73
+
74
+ /**
75
+ * @example
76
+ * <MeetingsList
77
+ * meetings={meetings}
78
+ * loading={loading}
79
+ * onDelete={id => deleteMeeting(id)}
80
+ * onSchedule={() => setShowSchedule(true)}
81
+ * />
82
+ */
83
+ export function MeetingsList<T extends MeetingsListItem = MeetingsListItem>({
84
+ meetings,
85
+ loading = false,
86
+ loadingRowCount = 4,
87
+ onDelete,
88
+ onRowClick,
89
+ onSchedule,
90
+ emptyMessage = "No meetings scheduled yet.",
91
+ className,
92
+ }: MeetingsListProps<T>) {
93
+ if (loading) {
94
+ return (
95
+ <div className={cn("p-4 space-y-2", className)}>
96
+ {Array.from({ length: loadingRowCount }).map((_, i) => (
97
+ <div key={i} className="h-12 bg-muted animate-pulse rounded" />
98
+ ))}
99
+ </div>
100
+ )
101
+ }
102
+
103
+ if (meetings.length === 0) {
104
+ return (
105
+ <div className={cn("text-center py-12 text-muted-foreground", className)}>
106
+ <CalendarDays className="h-10 w-10 mx-auto mb-3 opacity-40" />
107
+ <p>{emptyMessage}</p>
108
+ {onSchedule && (
109
+ <Button className="mt-3" onClick={onSchedule}>
110
+ <Plus className="mr-2 h-4 w-4" />
111
+ Schedule First Meeting
112
+ </Button>
113
+ )}
114
+ </div>
115
+ )
116
+ }
117
+
118
+ return (
119
+ <div className={cn("divide-y", className)}>
120
+ {meetings.map(m => {
121
+ const badge = STATUS_BADGE[m.status]
122
+ const interactive = !!onRowClick
123
+ return (
124
+ <div
125
+ key={m.id}
126
+ role={interactive ? "button" : undefined}
127
+ tabIndex={interactive ? 0 : undefined}
128
+ onClick={interactive ? () => onRowClick(m) : undefined}
129
+ onKeyDown={
130
+ interactive
131
+ ? e => {
132
+ if (e.key === "Enter" || e.key === " ") {
133
+ e.preventDefault()
134
+ onRowClick(m)
135
+ }
136
+ }
137
+ : undefined
138
+ }
139
+ className={cn(
140
+ "flex items-center justify-between px-4 py-3 transition-colors",
141
+ interactive && "hover:bg-muted/50 cursor-pointer",
142
+ !interactive && "hover:bg-muted/50",
143
+ )}
144
+ >
145
+ <div className="flex items-center gap-4 min-w-0">
146
+ <div className="min-w-0">
147
+ <p className="font-medium text-sm truncate">{m.title}</p>
148
+ {(m.investor_name || m.investor_email) && (
149
+ <p className="text-xs text-muted-foreground truncate">
150
+ {m.investor_name || m.investor_email}
151
+ </p>
152
+ )}
153
+ </div>
154
+ </div>
155
+
156
+ <div className="flex items-center gap-4 shrink-0 ml-4">
157
+ <div className="text-right text-xs text-muted-foreground hidden sm:block">
158
+ <p>{formatDate(m.scheduled_at)}</p>
159
+ <p>
160
+ {formatTime(m.scheduled_at)}
161
+ {m.duration_minutes ? ` · ${m.duration_minutes}min` : ""}
162
+ </p>
163
+ </div>
164
+
165
+ <span className={cn("text-xs px-2 py-0.5 rounded font-medium", badge.className)}>
166
+ {badge.label}
167
+ </span>
168
+
169
+ {m.meeting_link && (
170
+ <a
171
+ href={m.meeting_link}
172
+ target="_blank"
173
+ rel="noreferrer"
174
+ onClick={e => e.stopPropagation()}
175
+ className="text-primary"
176
+ title="Join meeting"
177
+ >
178
+ <ExternalLink className="h-4 w-4" />
179
+ </a>
180
+ )}
181
+
182
+ {onDelete && (
183
+ <Button
184
+ variant="ghost"
185
+ size="sm"
186
+ onClick={e => {
187
+ e.stopPropagation()
188
+ onDelete(m.id)
189
+ }}
190
+ className="h-7 w-7 p-0 text-muted-foreground hover:text-red-600"
191
+ aria-label="Delete meeting"
192
+ >
193
+ <Trash2 className="h-3.5 w-3.5" />
194
+ </Button>
195
+ )}
196
+ </div>
197
+ </div>
198
+ )
199
+ })}
200
+ </div>
201
+ )
202
+ }
@@ -31,12 +31,12 @@ export interface UpcomingMeeting {
31
31
  meeting_link?: string
32
32
  }
33
33
 
34
- export interface UpcomingMeetingsProps {
35
- meetings: UpcomingMeeting[]
34
+ export interface UpcomingMeetingsProps<T extends UpcomingMeeting = UpcomingMeeting> {
35
+ meetings: T[]
36
36
  /** Whether the list is still loading. Renders skeletons. */
37
37
  loading?: boolean
38
38
  /** Optional click handler when a meeting row is clicked (whole row). */
39
- onMeetingClick?: (meeting: UpcomingMeeting) => void
39
+ onMeetingClick?: (meeting: T) => void
40
40
  /** Optional URL for the "View all" header link. Hidden if absent. */
41
41
  viewAllHref?: string
42
42
  /** Optional click handler for the "View all" header link. Used instead of href if provided. */
@@ -82,7 +82,7 @@ function formatTime(isoStr: string): string {
82
82
  * onSchedule={() => setShowSchedule(true)}
83
83
  * />
84
84
  */
85
- export function UpcomingMeetings({
85
+ export function UpcomingMeetings<T extends UpcomingMeeting = UpcomingMeeting>({
86
86
  meetings,
87
87
  loading = false,
88
88
  onMeetingClick,
@@ -92,7 +92,7 @@ export function UpcomingMeetings({
92
92
  className,
93
93
  title = "Upcoming Meetings",
94
94
  loadingRowCount = 3,
95
- }: UpcomingMeetingsProps) {
95
+ }: UpcomingMeetingsProps<T>) {
96
96
  return (
97
97
  <Card className={className}>
98
98
  <CardHeader className="pb-3">
@@ -0,0 +1,113 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { SendHorizonal } from 'lucide-react'
5
+ import { cn } from '../../lib/utils'
6
+
7
+ export interface ChatComposerProps {
8
+ /** Controlled value. Omit for uncontrolled usage. */
9
+ value?: string
10
+ onChange?: (value: string) => void
11
+ /** Called with the trimmed message text on submit. */
12
+ onSubmit: (text: string) => void
13
+ placeholder?: string
14
+ /** Disables input entirely. */
15
+ disabled?: boolean
16
+ /** Submission in flight — input stays editable but submit is blocked. */
17
+ busy?: boolean
18
+ submitLabel?: string
19
+ /** Max rows the textarea grows to before scrolling. Default 10. */
20
+ maxRows?: number
21
+ className?: string
22
+ }
23
+
24
+ /**
25
+ * Chat input with auto-growing textarea. Enter submits, Shift+Enter inserts a
26
+ * newline, and large pastes are accepted as-is. Controlled or uncontrolled.
27
+ */
28
+ export function ChatComposer({
29
+ value,
30
+ onChange,
31
+ onSubmit,
32
+ placeholder = 'Message…',
33
+ disabled = false,
34
+ busy = false,
35
+ submitLabel = 'Send',
36
+ maxRows = 10,
37
+ className,
38
+ }: ChatComposerProps) {
39
+ const isControlled = value !== undefined
40
+ const [internal, setInternal] = React.useState('')
41
+ const text = isControlled ? value! : internal
42
+ const textareaRef = React.useRef<HTMLTextAreaElement>(null)
43
+
44
+ const setText = (next: string) => {
45
+ if (!isControlled) setInternal(next)
46
+ onChange?.(next)
47
+ }
48
+
49
+ // auto-grow
50
+ React.useEffect(() => {
51
+ const el = textareaRef.current
52
+ if (!el) return
53
+ el.style.height = 'auto'
54
+ const lineHeight = 24
55
+ el.style.height = `${Math.min(el.scrollHeight, lineHeight * maxRows)}px`
56
+ }, [text, maxRows])
57
+
58
+ const canSubmit = !disabled && !busy && text.trim().length > 0
59
+
60
+ const submit = () => {
61
+ if (!canSubmit) return
62
+ onSubmit(text.trim())
63
+ if (!isControlled) setInternal('')
64
+ }
65
+
66
+ const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
67
+ if (e.key !== 'Enter') return
68
+ // Cmd/Ctrl+Enter always submits (handy for multi-line content).
69
+ if (e.metaKey || e.ctrlKey) {
70
+ e.preventDefault()
71
+ submit()
72
+ return
73
+ }
74
+ if (e.shiftKey) return // explicit newline
75
+ // Bare Enter submits ONLY single-line content. When the value already
76
+ // spans multiple lines (e.g. a pasted outline) Enter inserts a newline so
77
+ // the multi-line block stays intact — submit via the button or Cmd+Enter.
78
+ if (text.includes('\n')) return
79
+ e.preventDefault()
80
+ submit()
81
+ }
82
+
83
+ return (
84
+ <div className={cn('flex items-end gap-2 border-t border-border bg-background p-3', className)}>
85
+ <textarea
86
+ ref={textareaRef}
87
+ value={text}
88
+ onChange={(e) => setText(e.target.value)}
89
+ onKeyDown={onKeyDown}
90
+ placeholder={placeholder}
91
+ disabled={disabled}
92
+ rows={1}
93
+ className={cn(
94
+ 'flex-1 resize-none rounded-lg border border-input bg-background px-3 py-2 text-sm shadow-sm',
95
+ 'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
96
+ 'disabled:cursor-not-allowed disabled:opacity-50'
97
+ )}
98
+ />
99
+ <button
100
+ type="button"
101
+ onClick={submit}
102
+ disabled={!canSubmit}
103
+ aria-label={submitLabel}
104
+ className={cn(
105
+ 'inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-opacity',
106
+ 'hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40'
107
+ )}
108
+ >
109
+ <SendHorizonal className="h-4 w-4" aria-hidden="true" />
110
+ </button>
111
+ </div>
112
+ )
113
+ }
@@ -0,0 +1,81 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { Wrench } from 'lucide-react'
5
+ import { cn } from '../../lib/utils'
6
+ import type { ChatMessageData } from './types'
7
+
8
+ export interface ChatMessageProps {
9
+ message: ChatMessageData
10
+ /** Optional custom renderer for the message body (e.g. markdown). */
11
+ renderContent?: (content: string, message: ChatMessageData) => React.ReactNode
12
+ className?: string
13
+ }
14
+
15
+ function StreamingDots() {
16
+ return (
17
+ <span className="ml-1 inline-flex gap-0.5 align-middle" aria-hidden="true">
18
+ <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" />
19
+ <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" />
20
+ <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current" />
21
+ </span>
22
+ )
23
+ }
24
+
25
+ /**
26
+ * A single chat row. Text messages render as aligned bubbles (user right,
27
+ * assistant left); `tool` / `status` kinds render as compact centered system
28
+ * rows. Fully generic — no presentation-specific knowledge.
29
+ */
30
+ export function ChatMessage({ message, renderContent, className }: ChatMessageProps) {
31
+ const { role, content, status, kind = 'text', toolName } = message
32
+ const isStreaming = status === 'streaming'
33
+ const isError = status === 'error'
34
+
35
+ if (kind === 'tool' || kind === 'status') {
36
+ return (
37
+ <div
38
+ data-role={role}
39
+ data-kind={kind}
40
+ data-status={status}
41
+ aria-busy={isStreaming || undefined}
42
+ className={cn(
43
+ 'mx-auto flex max-w-[90%] items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-1.5 text-xs text-muted-foreground',
44
+ isError && 'border-red-300 bg-red-50 text-red-700',
45
+ className
46
+ )}
47
+ >
48
+ {kind === 'tool' && <Wrench className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />}
49
+ {toolName && <span className="font-medium text-foreground">{toolName}</span>}
50
+ <span className="truncate">{content}</span>
51
+ {isStreaming && <StreamingDots />}
52
+ </div>
53
+ )
54
+ }
55
+
56
+ const isUser = role === 'user'
57
+ const body = renderContent ? renderContent(content, message) : content
58
+
59
+ return (
60
+ <div
61
+ data-role={role}
62
+ data-kind={kind}
63
+ data-status={status}
64
+ aria-busy={isStreaming || undefined}
65
+ className={cn('flex w-full', isUser ? 'justify-end' : 'justify-start', className)}
66
+ >
67
+ <div
68
+ className={cn(
69
+ 'max-w-[85%] whitespace-pre-wrap break-words rounded-2xl px-4 py-2 text-sm shadow-sm',
70
+ isUser
71
+ ? 'rounded-br-sm bg-primary text-primary-foreground'
72
+ : 'rounded-bl-sm bg-muted text-foreground',
73
+ isError && 'border border-red-300 bg-red-50 text-red-700'
74
+ )}
75
+ >
76
+ {body}
77
+ {isStreaming && <StreamingDots />}
78
+ </div>
79
+ </div>
80
+ )
81
+ }
@@ -0,0 +1,57 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { cn } from '../../lib/utils'
5
+ import { ChatMessage } from './ChatMessage'
6
+ import type { ChatMessageData } from './types'
7
+
8
+ export interface ChatThreadProps {
9
+ messages: ChatMessageData[]
10
+ /** Rendered when there are no messages. */
11
+ emptyState?: React.ReactNode
12
+ /** Custom message renderer; defaults to <ChatMessage>. */
13
+ renderMessage?: (message: ChatMessageData) => React.ReactNode
14
+ /** Auto-scroll to the newest message on change. Default true. */
15
+ autoScroll?: boolean
16
+ renderContent?: (content: string, message: ChatMessageData) => React.ReactNode
17
+ className?: string
18
+ }
19
+
20
+ /**
21
+ * Scrollable, auto-scrolling list of chat messages. App-agnostic.
22
+ */
23
+ export function ChatThread({
24
+ messages,
25
+ emptyState,
26
+ renderMessage,
27
+ autoScroll = true,
28
+ renderContent,
29
+ className,
30
+ }: ChatThreadProps) {
31
+ const anchorRef = React.useRef<HTMLDivElement>(null)
32
+
33
+ React.useEffect(() => {
34
+ if (autoScroll) anchorRef.current?.scrollIntoView({ block: 'end' })
35
+ }, [messages, autoScroll])
36
+
37
+ if (messages.length === 0 && emptyState) {
38
+ return (
39
+ <div className={cn('flex h-full items-center justify-center p-6 text-center', className)}>
40
+ {emptyState}
41
+ </div>
42
+ )
43
+ }
44
+
45
+ return (
46
+ <div className={cn('flex h-full flex-col gap-3 overflow-y-auto p-4', className)} role="log" aria-live="polite">
47
+ {messages.map((m) =>
48
+ renderMessage ? (
49
+ <React.Fragment key={m.id}>{renderMessage(m)}</React.Fragment>
50
+ ) : (
51
+ <ChatMessage key={m.id} message={m} renderContent={renderContent} />
52
+ )
53
+ )}
54
+ <div ref={anchorRef} data-testid="chat-thread-anchor" />
55
+ </div>
56
+ )
57
+ }
@@ -0,0 +1,12 @@
1
+ export { ChatMessage } from './ChatMessage'
2
+ export type { ChatMessageProps } from './ChatMessage'
3
+ export { ChatThread } from './ChatThread'
4
+ export type { ChatThreadProps } from './ChatThread'
5
+ export { ChatComposer } from './ChatComposer'
6
+ export type { ChatComposerProps } from './ChatComposer'
7
+ export type {
8
+ ChatMessageData,
9
+ ChatRole,
10
+ ChatMessageStatus,
11
+ ChatMessageKind,
12
+ } from './types'
@@ -0,0 +1,20 @@
1
+ export type ChatRole = 'user' | 'assistant' | 'system' | 'tool'
2
+
3
+ export type ChatMessageStatus = 'streaming' | 'complete' | 'error'
4
+
5
+ export type ChatMessageKind = 'text' | 'tool' | 'status'
6
+
7
+ export interface ChatMessageData {
8
+ id: string
9
+ role: ChatRole
10
+ content: string
11
+ /** Display name for the author (defaults derived from role). */
12
+ author?: string
13
+ timestamp?: string | number | Date
14
+ /** `streaming` shows a live indicator; `error` styles the bubble as failed. */
15
+ status?: ChatMessageStatus
16
+ /** `tool`/`status` render as compact system rows rather than chat bubbles. */
17
+ kind?: ChatMessageKind
18
+ /** Name of the tool when kind === 'tool'. */
19
+ toolName?: string
20
+ }
@@ -53,6 +53,15 @@ export * from './unified-table/utils'
53
53
  // Export Navigation
54
54
  export * from './navigation/sidebar'
55
55
 
56
+ // Workspace layout primitives (SplitPane, DualPaneWorkspace) — generic two-pane shell
57
+ export * from './workspace'
58
+
59
+ // Chat surface primitives (ChatThread, ChatMessage, ChatComposer)
60
+ export * from './chat'
61
+
62
+ // Slide-deck viewer (Google-Slides-style canvas + filmstrip)
63
+ export * from './slide-deck'
64
+
56
65
  // HTML rendering with XSS protection
57
66
  export { SafeHtml } from './safe-html'
58
67
 
@@ -101,6 +110,10 @@ export * from './command-palette'
101
110
  // Settings components (SettingsLayout, SettingsNav, SettingsCard)
102
111
  export * from './settings'
103
112
 
113
+ // Team management (startsim-o7s) — MembersTable, InviteMemberDialog, RoleSelector,
114
+ // PendingInvitationCallout, DomainClaimCard, LeaveTeamDialog, OrgSwitcher.
115
+ export * from './team'
116
+
104
117
  // Kanban board layout
105
118
  export * from './kanban'
106
119