@startsimpli/ui 0.4.13 → 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 (73) hide show
  1. package/README.md +457 -398
  2. package/package.json +20 -13
  3. package/src/components/__tests__/calendar-view-popup.test.tsx +42 -0
  4. package/src/components/__tests__/calendar-view.test.tsx +97 -0
  5. package/src/components/__tests__/chat.test.tsx +129 -0
  6. package/src/components/__tests__/meetings-list.test.tsx +114 -0
  7. package/src/components/__tests__/slide-deck-viewer.test.tsx +82 -0
  8. package/src/components/__tests__/upcoming-meetings.test.tsx +104 -0
  9. package/src/components/__tests__/workspace.test.tsx +106 -0
  10. package/src/components/account/__tests__/account.test.tsx +5 -32
  11. package/src/components/account/change-password-form.tsx +1 -28
  12. package/src/components/calendar/calendar-view.tsx +253 -0
  13. package/src/components/calendar/index.ts +20 -0
  14. package/src/components/calendar/meetings-list.tsx +202 -0
  15. package/src/components/calendar/upcoming-meetings.tsx +211 -0
  16. package/src/components/chat/ChatComposer.tsx +113 -0
  17. package/src/components/chat/ChatMessage.tsx +81 -0
  18. package/src/components/chat/ChatThread.tsx +57 -0
  19. package/src/components/chat/index.ts +12 -0
  20. package/src/components/chat/types.ts +20 -0
  21. package/src/components/index.ts +16 -0
  22. package/src/components/slide-deck/SlideCanvas.tsx +68 -0
  23. package/src/components/slide-deck/SlideDeckViewer.tsx +144 -0
  24. package/src/components/slide-deck/SlideFilmstrip.tsx +73 -0
  25. package/src/components/slide-deck/index.ts +7 -0
  26. package/src/components/slide-deck/types.ts +18 -0
  27. package/src/components/team/DomainClaimCard.tsx +170 -0
  28. package/src/components/team/InviteMemberDialog.tsx +182 -0
  29. package/src/components/team/LeaveTeamDialog.tsx +130 -0
  30. package/src/components/team/MembersTable.tsx +138 -0
  31. package/src/components/team/OrgSwitcher.tsx +68 -0
  32. package/src/components/team/PendingInvitationCallout.tsx +106 -0
  33. package/src/components/team/RoleSelector.tsx +68 -0
  34. package/src/components/team/__tests__/team-components.test.tsx +352 -0
  35. package/src/components/team/domain-claim-card-default-class-names.ts +45 -0
  36. package/src/components/team/index.ts +57 -0
  37. package/src/components/team/invite-member-dialog-default-class-names.ts +41 -0
  38. package/src/components/team/leave-team-dialog-default-class-names.ts +33 -0
  39. package/src/components/team/members-table-default-class-names.ts +39 -0
  40. package/src/components/team/org-switcher-default-class-names.ts +13 -0
  41. package/src/components/team/pending-invitation-callout-default-class-names.ts +22 -0
  42. package/src/components/team/role-selector-default-class-names.ts +11 -0
  43. package/src/components/team/types.ts +97 -0
  44. package/src/components/workflows/ExecNodeDetails.tsx +83 -0
  45. package/src/components/workflows/ExecutionTimeline.tsx +146 -0
  46. package/src/components/workflows/NodeInspector.tsx +257 -0
  47. package/src/components/workflows/NodePalette.tsx +119 -0
  48. package/src/components/workflows/WorkflowCanvas.tsx +113 -0
  49. package/src/components/workflows/WorkflowEdge.tsx +65 -0
  50. package/src/components/workflows/WorkflowEditor.tsx +130 -0
  51. package/src/components/workflows/WorkflowNode.tsx +198 -0
  52. package/src/components/workflows/WorkflowRunViewer.tsx +81 -0
  53. package/src/components/workflows/__tests__/ExecutionTimeline.test.tsx +99 -0
  54. package/src/components/workflows/__tests__/NodeInspector.test.tsx +74 -0
  55. package/src/components/workflows/__tests__/NodePalette.test.tsx +46 -0
  56. package/src/components/workflows/__tests__/WorkflowCanvas.test.tsx +59 -0
  57. package/src/components/workflows/__tests__/WorkflowNode.test.tsx +92 -0
  58. package/src/components/workflows/__tests__/WorkflowRunViewer.test.tsx +138 -0
  59. package/src/components/workflows/__tests__/auto-layout.test.ts +107 -0
  60. package/src/components/workflows/__tests__/serialization.test.ts +278 -0
  61. package/src/components/workflows/exec-status.ts +90 -0
  62. package/src/components/workflows/hooks/useCanvasGraph.ts +70 -0
  63. package/src/components/workflows/hooks/useNodeStatusOverlay.ts +47 -0
  64. package/src/components/workflows/index.ts +78 -0
  65. package/src/components/workflows/layout/auto-layout.ts +142 -0
  66. package/src/components/workflows/node-icons.ts +31 -0
  67. package/src/components/workflows/serialization.ts +171 -0
  68. package/src/components/workflows/theme/categories.ts +96 -0
  69. package/src/components/workflows/types.ts +231 -0
  70. package/src/components/workflows/workflows.css +29 -0
  71. package/src/components/workspace/DualPaneWorkspace.tsx +187 -0
  72. package/src/components/workspace/SplitPane.tsx +174 -0
  73. package/src/components/workspace/index.ts +4 -0
@@ -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
+ }
@@ -0,0 +1,211 @@
1
+ "use client"
2
+
3
+ /**
4
+ * UpcomingMeetings — sidebar/widget listing the next N meetings with status,
5
+ * date, time, and an optional join-link.
6
+ *
7
+ * Pure presentational. The consumer owns the data fetch + filtering — pass
8
+ * the already-filtered, already-sorted meetings via the `meetings` prop. This
9
+ * keeps the component decoupled from any specific API client or data shape
10
+ * beyond the small interface declared below.
11
+ *
12
+ * lifecycle:calendar-ui (raise-simpli-azc)
13
+ */
14
+
15
+ import * as React from "react"
16
+ import { CalendarDays, Clock, ExternalLink, Plus } from "lucide-react"
17
+ import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
18
+ import { Button } from "../ui/button"
19
+ import { cn } from "../../utils"
20
+
21
+ export type UpcomingMeetingStatus = "pending" | "confirmed" | "cancelled" | "completed"
22
+
23
+ export interface UpcomingMeeting {
24
+ id: string | number
25
+ title: string
26
+ /** ISO 8601 timestamp string */
27
+ scheduled_at: string
28
+ status: UpcomingMeetingStatus
29
+ investor_name?: string
30
+ investor_email?: string
31
+ meeting_link?: string
32
+ }
33
+
34
+ export interface UpcomingMeetingsProps<T extends UpcomingMeeting = UpcomingMeeting> {
35
+ meetings: T[]
36
+ /** Whether the list is still loading. Renders skeletons. */
37
+ loading?: boolean
38
+ /** Optional click handler when a meeting row is clicked (whole row). */
39
+ onMeetingClick?: (meeting: T) => void
40
+ /** Optional URL for the "View all" header link. Hidden if absent. */
41
+ viewAllHref?: string
42
+ /** Optional click handler for the "View all" header link. Used instead of href if provided. */
43
+ onViewAll?: () => void
44
+ /** Optional click handler for the empty-state "Schedule one" CTA. Hidden if absent. */
45
+ onSchedule?: () => void
46
+ /** Custom class name for the outer Card. */
47
+ className?: string
48
+ /** Title shown in the card header. Defaults to "Upcoming Meetings". */
49
+ title?: string
50
+ /** Number of skeleton rows to show while loading. Defaults to 3. */
51
+ loadingRowCount?: number
52
+ }
53
+
54
+ const STATUS_BADGE: Record<UpcomingMeetingStatus, { 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
+ })
67
+ }
68
+
69
+ function formatTime(isoStr: string): string {
70
+ return new Date(isoStr).toLocaleTimeString("en-US", {
71
+ hour: "2-digit",
72
+ minute: "2-digit",
73
+ })
74
+ }
75
+
76
+ /**
77
+ * @example
78
+ * <UpcomingMeetings
79
+ * meetings={upcoming}
80
+ * loading={isLoading}
81
+ * viewAllHref="/calendar"
82
+ * onSchedule={() => setShowSchedule(true)}
83
+ * />
84
+ */
85
+ export function UpcomingMeetings<T extends UpcomingMeeting = UpcomingMeeting>({
86
+ meetings,
87
+ loading = false,
88
+ onMeetingClick,
89
+ viewAllHref,
90
+ onViewAll,
91
+ onSchedule,
92
+ className,
93
+ title = "Upcoming Meetings",
94
+ loadingRowCount = 3,
95
+ }: UpcomingMeetingsProps<T>) {
96
+ return (
97
+ <Card className={className}>
98
+ <CardHeader className="pb-3">
99
+ <div className="flex items-center justify-between">
100
+ <CardTitle className="text-sm font-semibold flex items-center gap-2">
101
+ <CalendarDays className="h-4 w-4" />
102
+ {title}
103
+ </CardTitle>
104
+ {(viewAllHref || onViewAll) && (
105
+ <Button
106
+ variant="ghost"
107
+ size="sm"
108
+ className="h-7 text-xs"
109
+ asChild={!!viewAllHref && !onViewAll}
110
+ onClick={onViewAll}
111
+ >
112
+ {viewAllHref && !onViewAll ? <a href={viewAllHref}>View all</a> : <span>View all</span>}
113
+ </Button>
114
+ )}
115
+ </div>
116
+ </CardHeader>
117
+
118
+ <CardContent className="pt-0">
119
+ {loading ? (
120
+ <div className="space-y-2">
121
+ {Array.from({ length: loadingRowCount }).map((_, i) => (
122
+ <div key={i} className="h-14 bg-muted animate-pulse rounded-md" />
123
+ ))}
124
+ </div>
125
+ ) : meetings.length === 0 ? (
126
+ <div className="text-center py-6 text-muted-foreground">
127
+ <CalendarDays className="h-8 w-8 mx-auto mb-2 opacity-40" />
128
+ <p className="text-xs">No upcoming meetings</p>
129
+ {onSchedule && (
130
+ <Button variant="outline" size="sm" className="mt-3 h-7 text-xs" onClick={onSchedule}>
131
+ <Plus className="mr-1 h-3 w-3" />
132
+ Schedule one
133
+ </Button>
134
+ )}
135
+ </div>
136
+ ) : (
137
+ <div className="space-y-2">
138
+ {meetings.map(m => {
139
+ const badge = STATUS_BADGE[m.status]
140
+ const interactive = !!onMeetingClick
141
+ return (
142
+ <div
143
+ key={m.id}
144
+ role={interactive ? "button" : undefined}
145
+ tabIndex={interactive ? 0 : undefined}
146
+ onClick={interactive ? () => onMeetingClick(m) : undefined}
147
+ onKeyDown={
148
+ interactive
149
+ ? e => {
150
+ if (e.key === "Enter" || e.key === " ") {
151
+ e.preventDefault()
152
+ onMeetingClick(m)
153
+ }
154
+ }
155
+ : undefined
156
+ }
157
+ className={cn(
158
+ "border rounded-lg p-2.5 space-y-1 transition-colors",
159
+ interactive && "hover:bg-muted/40 cursor-pointer",
160
+ )}
161
+ >
162
+ <div className="flex items-start justify-between gap-2">
163
+ <p className="font-medium text-xs leading-tight line-clamp-1">{m.title}</p>
164
+ <span
165
+ className={cn(
166
+ "text-[10px] px-1.5 py-0.5 rounded font-medium whitespace-nowrap shrink-0",
167
+ badge.className,
168
+ )}
169
+ >
170
+ {badge.label}
171
+ </span>
172
+ </div>
173
+
174
+ {(m.investor_name || m.investor_email) && (
175
+ <p className="text-[11px] text-muted-foreground truncate">
176
+ {m.investor_name || m.investor_email}
177
+ </p>
178
+ )}
179
+
180
+ <div className="flex items-center gap-3 text-[11px] text-muted-foreground">
181
+ <span className="flex items-center gap-1">
182
+ <CalendarDays className="h-3 w-3" />
183
+ {formatDate(m.scheduled_at)}
184
+ </span>
185
+ <span className="flex items-center gap-1">
186
+ <Clock className="h-3 w-3" />
187
+ {formatTime(m.scheduled_at)}
188
+ </span>
189
+ </div>
190
+
191
+ {m.meeting_link && (
192
+ <a
193
+ href={m.meeting_link}
194
+ target="_blank"
195
+ rel="noreferrer"
196
+ onClick={e => e.stopPropagation()}
197
+ className="flex items-center gap-1 text-[11px] text-primary hover:underline"
198
+ >
199
+ <ExternalLink className="h-3 w-3" />
200
+ Join
201
+ </a>
202
+ )}
203
+ </div>
204
+ )
205
+ })}
206
+ </div>
207
+ )}
208
+ </CardContent>
209
+ </Card>
210
+ )
211
+ }
@@ -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'