@startsimpli/ui 0.4.42 → 0.4.44

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.
@@ -60,6 +60,18 @@ export * from './workspace'
60
60
  // Chat surface primitives (ChatThread, ChatMessage, ChatComposer)
61
61
  export * from './chat'
62
62
 
63
+ // Terminal (xterm.js) — generic presentational terminal + skeleton + themes
64
+ // (startsim-768w.16.7.E). Import styles from '@startsimpli/ui/terminal/styles'.
65
+ export * from './terminal'
66
+
67
+ // Diff viewer — presentational unified-diff renderer (no fetching)
68
+ export * from './diff'
69
+
70
+ // Agent-session surfaces (startsim-768w.16.7.E): SessionCard/List/StatusBadge,
71
+ // ConversationTimeline, and the live AgentSessionConsole (driven by
72
+ // @startsimpli/realtime/react). All generic — decoupled from any app model.
73
+ export * from './session'
74
+
63
75
  // Slide-deck viewer (Google-Slides-style canvas + filmstrip)
64
76
  export * from './slide-deck'
65
77
 
@@ -147,3 +159,6 @@ export type {
147
159
  ActivityTypeOption,
148
160
  OutcomeOption,
149
161
  } from './LogActivityDialog'
162
+
163
+ // Record detail read-view
164
+ export * from './record-detail'
@@ -1,6 +1,12 @@
1
1
  'use client'
2
2
 
3
3
  import * as React from 'react'
4
+ import {
5
+ DragDropContext,
6
+ Droppable,
7
+ Draggable,
8
+ type DropResult,
9
+ } from '@hello-pangea/dnd'
4
10
  import { cn } from '../../lib/utils'
5
11
 
6
12
  export interface KanbanColumnConfig {
@@ -9,6 +15,36 @@ export interface KanbanColumnConfig {
9
15
  color?: string
10
16
  }
11
17
 
18
+ /** A resolved card move derived from a drag, in board terms (not DnD terms). */
19
+ export interface KanbanMove {
20
+ cardId: string
21
+ fromColumnId: string
22
+ toColumnId: string
23
+ toIndex: number
24
+ }
25
+
26
+ /**
27
+ * Pure resolver: turn a @hello-pangea/dnd DropResult into a KanbanMove, or null
28
+ * when the drag is a no-op (dropped outside any column, or back onto the same slot).
29
+ * Exported so consumers and tests can reason about a move without a DOM.
30
+ */
31
+ export function resolveKanbanMove(result: DropResult): KanbanMove | null {
32
+ const { draggableId, source, destination } = result
33
+ if (!destination) return null
34
+ if (
35
+ source.droppableId === destination.droppableId &&
36
+ source.index === destination.index
37
+ ) {
38
+ return null
39
+ }
40
+ return {
41
+ cardId: draggableId,
42
+ fromColumnId: source.droppableId,
43
+ toColumnId: destination.droppableId,
44
+ toIndex: destination.index,
45
+ }
46
+ }
47
+
12
48
  export interface KanbanBoardProps<T> {
13
49
  columns: KanbanColumnConfig[]
14
50
  items: Record<string, T[]>
@@ -20,6 +56,15 @@ export interface KanbanBoardProps<T> {
20
56
  columnRef?: (columnId: string) => React.Ref<HTMLDivElement> | undefined
21
57
  isColumnOver?: (columnId: string) => boolean
22
58
  className?: string
59
+ /**
60
+ * DnD (opt-in). Provide BOTH `getCardId` and `onCardMove` to make cards draggable
61
+ * between columns via @hello-pangea/dnd — all drag mechanics live here in
62
+ * @startsimpli/ui; consumers stay dnd-agnostic and just persist the move in
63
+ * `onCardMove`. Omit either and the board renders exactly as before (no DnD;
64
+ * `columnRef`/`isColumnOver` honored for a parent's own manual wiring).
65
+ */
66
+ getCardId?: (item: T) => string
67
+ onCardMove?: (move: KanbanMove) => void
23
68
  }
24
69
 
25
70
  export function KanbanBoard<T>({
@@ -33,71 +78,135 @@ export function KanbanBoard<T>({
33
78
  columnRef,
34
79
  isColumnOver,
35
80
  className,
81
+ getCardId,
82
+ onCardMove,
36
83
  }: KanbanBoardProps<T>) {
37
- return (
38
- <div className={cn('flex gap-4 overflow-x-auto pb-4', className)}>
39
- {columns.map((column) => {
40
- const columnItems = items[column.id] ?? []
41
- const ref = columnRef?.(column.id)
42
- const isOver = isColumnOver?.(column.id) ?? false
84
+ const dndEnabled = !!(getCardId && onCardMove)
43
85
 
44
- return (
45
- <div
46
- key={column.id}
47
- ref={ref}
48
- style={{ minWidth: columnWidth, maxWidth: columnWidth }}
49
- className={cn(
50
- 'flex flex-col rounded-lg border bg-muted/50 transition-colors',
51
- isOver && 'border-primary ring-2 ring-primary/20',
52
- )}
53
- >
54
- {/* Column Header */}
55
- {renderColumnHeader ? (
56
- renderColumnHeader(column, columnItems)
57
- ) : (
58
- <div className="flex items-center justify-between px-3 py-2 border-b">
59
- <div className="flex items-center gap-2">
60
- {column.color && (
61
- <span
62
- className="w-2.5 h-2.5 rounded-full shrink-0"
63
- style={{ backgroundColor: column.color }}
64
- aria-hidden="true"
65
- />
66
- )}
67
- <h3 className="text-sm font-semibold text-foreground truncate">
68
- {column.label}
69
- </h3>
70
- </div>
71
- <span className="inline-flex items-center justify-center rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
72
- {columnItems.length}
73
- </span>
74
- </div>
75
- )}
86
+ const handleDragEnd = React.useCallback(
87
+ (result: DropResult) => {
88
+ const move = resolveKanbanMove(result)
89
+ if (move) onCardMove?.(move)
90
+ },
91
+ [onCardMove],
92
+ )
93
+
94
+ const columnHeader = (column: KanbanColumnConfig, columnItems: T[]) =>
95
+ renderColumnHeader ? (
96
+ renderColumnHeader(column, columnItems)
97
+ ) : (
98
+ <div className="flex items-center justify-between px-3 py-2 border-b">
99
+ <div className="flex items-center gap-2">
100
+ {column.color && (
101
+ <span
102
+ className="w-2.5 h-2.5 rounded-full shrink-0"
103
+ style={{ backgroundColor: column.color }}
104
+ aria-hidden="true"
105
+ />
106
+ )}
107
+ <h3 className="text-sm font-semibold text-foreground truncate">
108
+ {column.label}
109
+ </h3>
110
+ </div>
111
+ <span className="inline-flex items-center justify-center rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
112
+ {columnItems.length}
113
+ </span>
114
+ </div>
115
+ )
116
+
117
+ const emptyState = (
118
+ <p className="text-xs text-muted-foreground text-center py-8">
119
+ {emptyColumnMessage}
120
+ </p>
121
+ )
76
122
 
77
- {/* Column Body */}
78
- <div className="flex-1 overflow-y-auto p-2 space-y-2">
79
- {columnItems.length === 0 ? (
80
- <p className="text-xs text-muted-foreground text-center py-8">
81
- {emptyColumnMessage}
82
- </p>
83
- ) : (
84
- columnItems.map((item, index) => (
85
- <React.Fragment key={index}>
86
- {renderCard(item)}
87
- </React.Fragment>
88
- ))
89
- )}
90
- </div>
123
+ const renderColumn = (column: KanbanColumnConfig) => {
124
+ const columnItems = items[column.id] ?? []
125
+ const footer = renderColumnFooter && (
126
+ <div className="border-t px-3 py-2">{renderColumnFooter(column)}</div>
127
+ )
91
128
 
92
- {/* Column Footer */}
93
- {renderColumnFooter && (
94
- <div className="border-t px-3 py-2">
95
- {renderColumnFooter(column)}
129
+ if (dndEnabled) {
130
+ return (
131
+ <div
132
+ key={column.id}
133
+ style={{ minWidth: columnWidth, maxWidth: columnWidth }}
134
+ className="flex flex-col rounded-lg border bg-muted/50 transition-colors"
135
+ >
136
+ {columnHeader(column, columnItems)}
137
+ <Droppable droppableId={column.id}>
138
+ {(provided, snapshot) => (
139
+ <div
140
+ ref={provided.innerRef}
141
+ {...provided.droppableProps}
142
+ className={cn(
143
+ 'flex-1 overflow-y-auto p-2 space-y-2 transition-colors',
144
+ snapshot.isDraggingOver && 'bg-primary/5',
145
+ )}
146
+ >
147
+ {columnItems.length === 0 && !snapshot.isDraggingOver
148
+ ? emptyState
149
+ : columnItems.map((item, index) => {
150
+ const id = getCardId!(item)
151
+ return (
152
+ <Draggable key={id} draggableId={id} index={index}>
153
+ {(dp, ds) => (
154
+ <div
155
+ ref={dp.innerRef}
156
+ {...dp.draggableProps}
157
+ {...dp.dragHandleProps}
158
+ className={cn(ds.isDragging && 'opacity-80')}
159
+ >
160
+ {renderCard(item)}
161
+ </div>
162
+ )}
163
+ </Draggable>
164
+ )
165
+ })}
166
+ {provided.placeholder}
96
167
  </div>
97
168
  )}
98
- </div>
99
- )
100
- })}
169
+ </Droppable>
170
+ {footer}
171
+ </div>
172
+ )
173
+ }
174
+
175
+ // Non-DnD (backward compatible): honor columnRef/isColumnOver for a parent's own
176
+ // manual drag wiring, render cards keyed by index as before.
177
+ const ref = columnRef?.(column.id)
178
+ const isOver = isColumnOver?.(column.id) ?? false
179
+ return (
180
+ <div
181
+ key={column.id}
182
+ ref={ref}
183
+ style={{ minWidth: columnWidth, maxWidth: columnWidth }}
184
+ className={cn(
185
+ 'flex flex-col rounded-lg border bg-muted/50 transition-colors',
186
+ isOver && 'border-primary ring-2 ring-primary/20',
187
+ )}
188
+ >
189
+ {columnHeader(column, columnItems)}
190
+ <div className="flex-1 overflow-y-auto p-2 space-y-2">
191
+ {columnItems.length === 0
192
+ ? emptyState
193
+ : columnItems.map((item, index) => (
194
+ <React.Fragment key={index}>{renderCard(item)}</React.Fragment>
195
+ ))}
196
+ </div>
197
+ {footer}
198
+ </div>
199
+ )
200
+ }
201
+
202
+ const board = (
203
+ <div className={cn('flex gap-4 overflow-x-auto pb-4', className)}>
204
+ {columns.map(renderColumn)}
101
205
  </div>
102
206
  )
207
+
208
+ if (dndEnabled) {
209
+ return <DragDropContext onDragEnd={handleDragEnd}>{board}</DragDropContext>
210
+ }
211
+ return board
103
212
  }
@@ -1,5 +1,6 @@
1
1
  import { render, screen } from '@testing-library/react'
2
- import { KanbanBoard } from '../KanbanBoard'
2
+ import type { DropResult } from '@hello-pangea/dnd'
3
+ import { KanbanBoard, resolveKanbanMove } from '../KanbanBoard'
3
4
 
4
5
  interface Card {
5
6
  id: string
@@ -154,4 +155,66 @@ describe('KanbanBoard', () => {
154
155
  const firstCol = container.querySelector('[style*="320"]')
155
156
  expect(firstCol).toHaveClass('ring-2')
156
157
  })
158
+
159
+ it('renders draggable cards when getCardId + onCardMove are provided (DnD enabled)', () => {
160
+ const onCardMove = jest.fn()
161
+ render(
162
+ <KanbanBoard
163
+ columns={columns}
164
+ items={items}
165
+ renderCard={renderCard}
166
+ getCardId={(c) => c.id}
167
+ onCardMove={onCardMove}
168
+ />
169
+ )
170
+ // cards still render through the @hello-pangea/dnd path without crashing
171
+ expect(screen.getByTestId('card-1')).toBeInTheDocument()
172
+ expect(screen.getByTestId('card-3')).toBeInTheDocument()
173
+ expect(screen.getByText('To Do')).toBeInTheDocument()
174
+ })
175
+ })
176
+
177
+ describe('resolveKanbanMove', () => {
178
+ const drop = (
179
+ draggableId: string,
180
+ from: string,
181
+ fromIndex: number,
182
+ to?: string,
183
+ toIndex?: number,
184
+ ): DropResult =>
185
+ ({
186
+ draggableId,
187
+ type: 'DEFAULT',
188
+ source: { droppableId: from, index: fromIndex },
189
+ destination: to === undefined ? null : { droppableId: to, index: toIndex ?? 0 },
190
+ reason: 'DROP',
191
+ mode: 'FLUID',
192
+ combine: null,
193
+ } as unknown as DropResult)
194
+
195
+ it('resolves a cross-column move', () => {
196
+ expect(resolveKanbanMove(drop('c1', 'new', 0, 'surfaced', 2))).toEqual({
197
+ cardId: 'c1',
198
+ fromColumnId: 'new',
199
+ toColumnId: 'surfaced',
200
+ toIndex: 2,
201
+ })
202
+ })
203
+
204
+ it('returns null when dropped outside any column', () => {
205
+ expect(resolveKanbanMove(drop('c1', 'new', 0))).toBeNull()
206
+ })
207
+
208
+ it('returns null when dropped back onto the same slot', () => {
209
+ expect(resolveKanbanMove(drop('c1', 'new', 1, 'new', 1))).toBeNull()
210
+ })
211
+
212
+ it('resolves a within-column reorder', () => {
213
+ expect(resolveKanbanMove(drop('c1', 'new', 0, 'new', 3))).toEqual({
214
+ cardId: 'c1',
215
+ fromColumnId: 'new',
216
+ toColumnId: 'new',
217
+ toIndex: 3,
218
+ })
219
+ })
157
220
  })
@@ -1,2 +1,2 @@
1
- export { KanbanBoard } from './KanbanBoard'
2
- export type { KanbanBoardProps, KanbanColumnConfig } from './KanbanBoard'
1
+ export { KanbanBoard, resolveKanbanMove } from './KanbanBoard'
2
+ export type { KanbanBoardProps, KanbanColumnConfig, KanbanMove } from './KanbanBoard'
@@ -0,0 +1,92 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { cn } from '../../lib/utils'
5
+
6
+ export type RecordFieldKind = 'text' | 'longtext' | 'url' | 'boolean' | 'json'
7
+
8
+ export interface RecordField {
9
+ label: string
10
+ value: string
11
+ kind?: RecordFieldKind
12
+ }
13
+
14
+ export interface RecordDetailProps {
15
+ fields: RecordField[]
16
+ emptyMessage?: string
17
+ className?: string
18
+ }
19
+
20
+ const URL_RE = /^https?:\/\//i
21
+
22
+ /**
23
+ * Read-optimized view of a record's fields (startsim-768w.17.4). Renders short
24
+ * fields as a compact label/value list (auto-linking urls), long-text fields as
25
+ * readable, newline-preserving prose blocks (right for extracted article bodies),
26
+ * and json as a scrollable code block. Generic: a consumer maps its own schema to
27
+ * `fields` and this owns the layout, so every Foundry tenant's detail drawer reads
28
+ * the same without duplicating the view.
29
+ */
30
+ export function RecordDetail({ fields, emptyMessage = 'No details.', className }: RecordDetailProps) {
31
+ const present = fields.filter((f) => f.value != null && String(f.value).trim() !== '')
32
+ const shorts = present.filter((f) => f.kind !== 'longtext' && f.kind !== 'json')
33
+ const longs = present.filter((f) => f.kind === 'longtext')
34
+ const jsons = present.filter((f) => f.kind === 'json')
35
+
36
+ if (present.length === 0) {
37
+ return <p className={cn('text-sm text-muted-foreground', className)}>{emptyMessage}</p>
38
+ }
39
+
40
+ return (
41
+ <div className={cn('space-y-4', className)}>
42
+ {shorts.length > 0 && (
43
+ <dl className="space-y-1.5">
44
+ {shorts.map((f, i) => {
45
+ const isUrl = f.kind === 'url' || URL_RE.test(f.value)
46
+ return (
47
+ <div key={i} className="flex gap-2 text-sm">
48
+ <dt className="w-28 shrink-0 capitalize text-muted-foreground">{f.label}</dt>
49
+ <dd className="min-w-0 flex-1 break-words">
50
+ {isUrl ? (
51
+ <a
52
+ href={f.value}
53
+ target="_blank"
54
+ rel="noopener noreferrer"
55
+ className="break-all text-primary underline underline-offset-2 hover:no-underline"
56
+ >
57
+ {f.value}
58
+ </a>
59
+ ) : (
60
+ f.value
61
+ )}
62
+ </dd>
63
+ </div>
64
+ )
65
+ })}
66
+ </dl>
67
+ )}
68
+
69
+ {longs.map((f, i) => (
70
+ <section key={i} className="space-y-1.5">
71
+ <h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
72
+ {f.label}
73
+ </h4>
74
+ <div className="whitespace-pre-wrap text-sm leading-relaxed text-foreground">
75
+ {f.value}
76
+ </div>
77
+ </section>
78
+ ))}
79
+
80
+ {jsons.map((f, i) => (
81
+ <section key={i} className="space-y-1.5">
82
+ <h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
83
+ {f.label}
84
+ </h4>
85
+ <pre className="overflow-x-auto rounded-md bg-muted p-3 text-xs leading-relaxed">
86
+ {f.value}
87
+ </pre>
88
+ </section>
89
+ ))}
90
+ </div>
91
+ )
92
+ }
@@ -0,0 +1,42 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { RecordDetail, type RecordField } from '../RecordDetail'
3
+
4
+ const fields: RecordField[] = [
5
+ { label: 'url', value: 'https://example.com/a', kind: 'url' },
6
+ { label: 'source', value: 'The National', kind: 'text' },
7
+ { label: 'content', value: 'Para one.\nPara two.', kind: 'longtext' },
8
+ { label: 'empty', value: '', kind: 'text' },
9
+ ]
10
+
11
+ describe('RecordDetail', () => {
12
+ it('renders a url field as a new-tab clickable link', () => {
13
+ render(<RecordDetail fields={fields} />)
14
+ const link = screen.getByRole('link', { name: 'https://example.com/a' })
15
+ expect(link).toHaveAttribute('href', 'https://example.com/a')
16
+ expect(link).toHaveAttribute('target', '_blank')
17
+ expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'))
18
+ })
19
+
20
+ it('renders long-text as a newline-preserving prose block', () => {
21
+ const { container } = render(<RecordDetail fields={fields} />)
22
+ const prose = container.querySelector('.whitespace-pre-wrap')
23
+ expect(prose).toBeInTheDocument()
24
+ expect(prose?.textContent).toContain('Para one.')
25
+ expect(prose?.textContent).toContain('Para two.')
26
+ })
27
+
28
+ it('skips empty fields', () => {
29
+ render(<RecordDetail fields={fields} />)
30
+ expect(screen.queryByText('empty')).not.toBeInTheDocument()
31
+ })
32
+
33
+ it('auto-links a text field whose value looks like a url', () => {
34
+ render(<RecordDetail fields={[{ label: 'link', value: 'https://x.io', kind: 'text' }]} />)
35
+ expect(screen.getByRole('link', { name: 'https://x.io' })).toBeInTheDocument()
36
+ })
37
+
38
+ it('shows the empty message when no field has a value', () => {
39
+ render(<RecordDetail fields={[{ label: 'a', value: '' }]} emptyMessage="Nothing here" />)
40
+ expect(screen.getByText('Nothing here')).toBeInTheDocument()
41
+ })
42
+ })
@@ -0,0 +1,2 @@
1
+ export { RecordDetail } from './RecordDetail'
2
+ export type { RecordDetailProps, RecordField, RecordFieldKind } from './RecordDetail'