@startsimpli/integrations 0.2.24 → 0.2.26

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.
@@ -28,6 +28,14 @@ import * as React from 'react';
28
28
 
29
29
  import { isStalled, runCoverage, runProgress } from '../../corpus/runs';
30
30
  import { stripMarkdown } from '../Markdown';
31
+ import {
32
+ UnfilteredNotice,
33
+ WorkItemRow,
34
+ changedSince,
35
+ compareForScreen,
36
+ failureMessage,
37
+ isOverdue,
38
+ } from './work-items';
31
39
  import type {
32
40
  Commitment,
33
41
  CommitmentQuery,
@@ -104,6 +112,9 @@ const STATUS_TABS: Array<{ key: 'open' | 'overdue' | 'recent' | 'done'; label: s
104
112
 
105
113
  const WEEK_MS = 7 * 24 * 3600 * 1000;
106
114
 
115
+ /** Which filters THIS screen asks for, and therefore owes a confession about. */
116
+ const TRACKER_FILTER_KEYS = ['status', 'owner', 'dueBefore', 'changedAfter'] as const;
117
+
107
118
  /** States the poll stops at. A finished run is a record, not a subscription. */
108
119
  const TERMINAL_RUN = new Set(['succeeded', 'failed', 'cancelled']);
109
120
 
@@ -330,10 +341,6 @@ export function TrackerPage({
330
341
  // Rendering it as "in progress" is a spinner that never ends.
331
342
  const sweepStalled = Boolean(sweep && isStalled(sweep, clock));
332
343
 
333
- const unfiltered = (['status', 'owner', 'dueBefore', 'changedAfter'] as const).filter(
334
- (key) => Boolean(query[key]) && !applied.includes(key),
335
- );
336
-
337
344
  return (
338
345
  <div className={cn.root}>
339
346
  <section className={cn.section}>
@@ -418,16 +425,16 @@ export function TrackerPage({
418
425
  ) : null}
419
426
  </div>
420
427
 
421
- {unfiltered.length ? (
422
- // The same honesty the Library owes about search, owed here about
423
- // status — and it matters more here, because "outstanding" is the
424
- // claim the whole screen makes.
425
- <p className={cn.warning} role="status">
426
- This workspace’s data API did not filter by {unfiltered.join(', ')}, so this list was
427
- narrowed in the browser over the records it could fetch. Counts are over that set, not
428
- over the whole corpus.
429
- </p>
430
- ) : null}
428
+ {/* The same honesty the Library owes about search, owed here about
429
+ status — and it matters more here, because "outstanding" is the
430
+ claim the whole screen makes. Shared with the planning surface, which
431
+ asks for one more key. */}
432
+ <UnfilteredNotice
433
+ requested={TRACKER_FILTER_KEYS}
434
+ applied={applied}
435
+ query={query}
436
+ className={cn.warning}
437
+ />
431
438
 
432
439
  {error ? <p className={cn.error} role="alert">{error}</p> : null}
433
440
  {sweep ? (
@@ -450,7 +457,7 @@ export function TrackerPage({
450
457
  ) : null}
451
458
 
452
459
  {visible.map((item) => (
453
- <CommitmentRow
460
+ <WorkItemRow
454
461
  key={item.externalId}
455
462
  item={item}
456
463
  now={clock}
@@ -465,94 +472,6 @@ export function TrackerPage({
465
472
 
466
473
  // ---------------------------------------------------------------------------
467
474
 
468
- function CommitmentRow({
469
- item, now, classNames: cn, onOpenDocument,
470
- }: {
471
- item: Commitment;
472
- now: number;
473
- classNames: Record<TrackerSlot, string>;
474
- onOpenDocument?: (documentId: string) => void;
475
- }) {
476
- const overdue = isOverdue(item, now);
477
- const age = item.firstSeenAt ? Math.floor((now - Date.parse(item.firstSeenAt)) / 86_400_000) : null;
478
-
479
- return (
480
- <article className={cn.row}>
481
- <div className="flex flex-wrap items-center gap-2">
482
- <span className={cn.badge}>{item.subtype}</span>
483
- {item.status !== 'open' ? <span className={cn.badge}>{item.status}</span> : null}
484
- <h3 className="text-sm font-medium text-gray-900">{item.title}</h3>
485
- </div>
486
-
487
- <div className="mt-1 flex flex-wrap items-center gap-3">
488
- {item.ownerName ? (
489
- <span className="text-sm text-gray-700">{item.ownerName}</span>
490
- ) : (
491
- // Stated, not blank. "Nobody said who" is a fact about the meeting and
492
- // it is the single most actionable thing on this screen.
493
- <span className={cn.muted}>No owner named</span>
494
- )}
495
- {item.subject ? <span className={cn.muted}>· {item.subject}</span> : null}
496
- {item.dueAt ? (
497
- <span className={overdue ? cn.due : cn.muted}>
498
- {overdue ? 'Overdue ' : 'Due '}
499
- {new Date(item.dueAt).toLocaleDateString()}
500
- </span>
501
- ) : null}
502
- {age !== null && age > 0 ? (
503
- <span className={cn.muted}>
504
- {/* The sentence the product exists to say. */}
505
- open for {age} day{age === 1 ? '' : 's'}
506
- </span>
507
- ) : null}
508
- {item.mentions.length > 1 ? (
509
- <span className={cn.badge}>raised in {item.mentions.length} meetings</span>
510
- ) : null}
511
- </div>
512
-
513
- {item.detail ? <p className="mt-2 text-sm text-gray-700">{item.detail}</p> : null}
514
-
515
- {item.statusReason ? (
516
- <p className="mt-2 text-sm text-gray-600">
517
- {/* Why does it say this is closed? Answerable without opening the
518
- meeting, or nobody will trust a single closed row. */}
519
- {item.status === 'done' ? 'Closed by' : 'Blocked by'}: “{stripMarkdown(item.statusReason)}”
520
- </p>
521
- ) : null}
522
-
523
- <details className="mt-2">
524
- <summary className="cursor-pointer text-sm text-gray-600">
525
- From {item.mentions.length} meeting{item.mentions.length === 1 ? '' : 's'}
526
- </summary>
527
- <div className="mt-2 space-y-2">
528
- {item.mentions.map((mention) => (
529
- <div key={`${mention.documentId}:${mention.originKey}`} className="border-t pt-2">
530
- <div className="flex flex-wrap items-center gap-2">
531
- <button
532
- type="button"
533
- className="text-sm text-blue-700 underline"
534
- onClick={() => onOpenDocument?.(mention.documentId)}
535
- >
536
- {mention.occurredAt ? new Date(mention.occurredAt).toLocaleDateString() : 'Undated meeting'}
537
- </button>
538
- {/* Where the row came from. A model-derived item and one the
539
- summariser itself wrote down are different levels of
540
- evidence, and the difference belongs on screen. */}
541
- <span className={cn.badge}>
542
- {mention.extractor === 'rules' ? 'from the meeting’s own next steps' : 'read from the notes'}
543
- </span>
544
- </div>
545
- {mention.quote ? (
546
- <p className="mt-1 text-sm italic text-gray-600">“{stripMarkdown(mention.quote)}”</p>
547
- ) : null}
548
- </div>
549
- ))}
550
- </div>
551
- </details>
552
- </article>
553
- );
554
- }
555
-
556
475
  /**
557
476
  * A SWEEP IN FLIGHT, and afterwards what it did (bd startsim-768w.38.28).
558
477
  *
@@ -785,37 +704,6 @@ function RunSummary({
785
704
 
786
705
  // ---------------------------------------------------------------------------
787
706
 
788
- function isOverdue(item: Commitment, now: number): boolean {
789
- if (!item.dueAt) return false;
790
- if (item.status !== 'open' && item.status !== 'blocked') return false;
791
- const at = Date.parse(item.dueAt);
792
- return !Number.isNaN(at) && at < now;
793
- }
794
707
 
795
- function changedSince(item: Commitment, since: number): boolean {
796
- const stamps = [item.lastSeenAt, item.resolvedAt].filter(Boolean) as string[];
797
- return stamps.some((stamp) => Date.parse(stamp) >= since);
798
- }
799
708
 
800
- /**
801
- * Overdue first, then the oldest thing still open.
802
- *
803
- * Sorting by "raised longest ago and still not done" rather than by recency is
804
- * the whole difference between a list of meetings and a list of debts.
805
- */
806
- function compareForScreen(now: number) {
807
- return (a: Commitment, b: Commitment): number => {
808
- const overdue = Number(isOverdue(b, now)) - Number(isOverdue(a, now));
809
- if (overdue !== 0) return overdue;
810
- if (a.dueAt && b.dueAt && a.dueAt !== b.dueAt) return a.dueAt.localeCompare(b.dueAt);
811
- if (a.dueAt !== b.dueAt) return a.dueAt ? -1 : 1;
812
- return (a.firstSeenAt ?? '').localeCompare(b.firstSeenAt ?? '');
813
- };
814
- }
815
709
 
816
- function failureMessage(err: unknown): string {
817
- const status = (err as { status?: number })?.status;
818
- if (status === 403) return 'You do not have access to this workspace’s records.';
819
- if (status === 401) return 'Your session has expired. Sign in again.';
820
- return (err as { message?: string })?.message || 'That could not be loaded right now.';
821
- }
@@ -47,6 +47,33 @@ export interface ConnectorSchema {
47
47
 
48
48
  export type ConnectorSchemaLike = ConnectorSchema | ConnectorFieldSpec[] | null | undefined;
49
49
 
50
+ /**
51
+ * What `startOAuth`/`oauthInitiate` hand back (bd startsim-768w.22.25.22).
52
+ *
53
+ * `authorization_url` is COMPLETE — endpoint, `client_id`, `response_type`,
54
+ * `redirect_uri`, `state`, and whatever else the connector's own
55
+ * `authorization_params()` decided the vendor needs (Dropbox's includes
56
+ * `token_access_type=offline`, which is what makes the grant durable). The
57
+ * client opens it and adds nothing: it has no `client_id` of its own and must
58
+ * not have one to choose.
59
+ *
60
+ * `state` is opaque and signed. It is echoed by the vendor to the callback and
61
+ * is the only thing tying the redirect back to a connection; nothing in the
62
+ * browser needs to read it.
63
+ */
64
+ export interface OAuthStart {
65
+ authorization_url: string;
66
+ state: string;
67
+ redirect_uri: string;
68
+ }
69
+
70
+ /** Vendor-side setup steps, written by the connector in Python. */
71
+ export interface ConnectionInstructions {
72
+ steps?: string[];
73
+ headers?: Record<string, string>;
74
+ [key: string]: unknown;
75
+ }
76
+
50
77
  export interface ConnectorCatalogEntry {
51
78
  key: string;
52
79
  name: string;
@@ -285,6 +312,22 @@ export interface ConnectionsApi {
285
312
  setMemberConnectorPolicy?(slug: string, connectorKey: string, allowed: boolean):
286
313
  Promise<{ connector_key: string; member_connect_allowed: boolean; changed: boolean }>;
287
314
 
315
+ /**
316
+ * START a consent round trip on an OAuth connector (bd startsim-768w.22.25.23).
317
+ *
318
+ * Optional so an app pinned to an older `@startsimpli/api` keeps compiling.
319
+ * The card branches on `auth_mode` AND on this verb being present, so a stale
320
+ * client renders the connector as not connectable from this screen rather
321
+ * than a button that cannot fire.
322
+ */
323
+ startOAuth?(slug: string, input: {
324
+ connector_key: string;
325
+ name?: string;
326
+ config?: Record<string, unknown>;
327
+ capabilities_enabled?: string[];
328
+ return_to?: string;
329
+ }): Promise<OAuthStart>;
330
+
288
331
  /** The roster: every member-owned connection in the foundry. Metadata only. */
289
332
  memberConnections?(slug: string): Promise<{
290
333
  results: MemberConnectionRow[];
@@ -357,4 +400,20 @@ export interface MemberConnectionsApi {
357
400
  rotateCredential(id: string, credentials: Record<string, unknown>): Promise<Connection>;
358
401
  disconnect(id: string): Promise<void>;
359
402
  connectionRuns(id: string): Promise<{ results: ConnectionRun[] }>;
403
+ /**
404
+ * The OAuth half of the surface (bd startsim-768w.22.25.14/.21). Optional so
405
+ * an app pinned to an older `@startsimpli/api` still type-checks; the card
406
+ * branches on `auth_mode` and renders the hand-off only when both the mode
407
+ * says oauth AND the verb is present, so a stale client degrades to saying
408
+ * the connector cannot be connected here rather than to a dead button.
409
+ */
410
+ startOAuth?(input: {
411
+ connector_key: string;
412
+ name?: string;
413
+ config?: Record<string, unknown>;
414
+ capabilities_enabled?: string[];
415
+ return_to?: string;
416
+ }): Promise<OAuthStart>;
417
+ oauthInitiate?(id: string, input?: { return_to?: string }): Promise<OAuthStart>;
418
+ connectionInstructions?(id: string): Promise<ConnectionInstructions>;
360
419
  }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * THE PIECES TWO SCREENS SHARE — the item row, the honesty banner, and the
3
+ * handful of predicates that decide what "overdue" and "changed" mean
4
+ * (bd startsim-8l6ef).
5
+ *
6
+ * WHY THIS FILE EXISTS. `TrackerPage` and `PlanningPage` are two views over ONE
7
+ * list of items: the tracker asks "what came out of my meetings and is still
8
+ * owed", planning asks "what are we trying to achieve and what is under it".
9
+ * Both render the same row and both owe the same confession when the server did
10
+ * not honour a filter. Writing that twice is the two-lists failure moved up a
11
+ * level — the rows would agree while the screens slowly stopped agreeing about
12
+ * what they meant (CLAUDE.md rule 9, and
13
+ * feedback_share_page_composers_not_just_primitives).
14
+ *
15
+ * So the row and the banner live here, the extraction-run machinery stays in
16
+ * `TrackerPage` where it belongs, and the focus tree stays in `PlanningPage`.
17
+ *
18
+ * NOTHING HERE FETCHES. Every component takes what it renders.
19
+ */
20
+ import * as React from 'react';
21
+
22
+ import { isAuthored } from '../../corpus/planning';
23
+ import { stripMarkdown } from '../Markdown';
24
+ import type { Commitment } from '../../corpus/types';
25
+
26
+ export type WorkItemSlot =
27
+ | 'badge' | 'muted' | 'row' | 'due' | 'warning';
28
+
29
+ export const WORK_ITEM_DEFAULTS: Record<WorkItemSlot, string> = {
30
+ badge: 'inline-flex items-center rounded-full border border-gray-300 px-2 py-0.5 text-xs text-gray-700',
31
+ muted: 'text-sm text-gray-500',
32
+ row: 'rounded-lg border border-gray-200 p-4',
33
+ due: 'text-sm font-medium text-red-700',
34
+ warning: 'rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900',
35
+ };
36
+
37
+ const DAY_MS = 86_400_000;
38
+
39
+ /** `WorkItem.Priority` — 0 critical .. 4 backlog, ascending is urgent. */
40
+ export const PRIORITY_LABELS = ['Critical', 'High', 'Medium', 'Low', 'Backlog'] as const;
41
+
42
+ export function priorityLabel(priority?: number): string | null {
43
+ if (priority === undefined || priority === null) return null;
44
+ return PRIORITY_LABELS[priority] ?? null;
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // predicates — what the screens mean by "overdue" and "changed"
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /**
52
+ * Past its stated date and still owed.
53
+ *
54
+ * A `done` or `dropped` item is never overdue however old its date is, because
55
+ * an overdue count is a list of things to do and a finished item is not one.
56
+ */
57
+ export function isOverdue(item: Commitment, now: number): boolean {
58
+ if (!item.dueAt) return false;
59
+ if (item.status === 'done' || item.status === 'dropped') return false;
60
+ return Date.parse(item.dueAt) < now;
61
+ }
62
+
63
+ export function changedSince(item: Commitment, since: number): boolean {
64
+ const stamps = [item.lastSeenAt, item.resolvedAt].filter(Boolean) as string[];
65
+ return stamps.some((stamp) => Date.parse(stamp) >= since);
66
+ }
67
+
68
+ /**
69
+ * Overdue first, then the oldest thing still open.
70
+ *
71
+ * Sorting by "raised longest ago and still not done" rather than by recency is
72
+ * the whole difference between a list of meetings and a list of debts.
73
+ */
74
+ export function compareForScreen(now: number) {
75
+ return (a: Commitment, b: Commitment): number => {
76
+ const overdue = Number(isOverdue(b, now)) - Number(isOverdue(a, now));
77
+ if (overdue !== 0) return overdue;
78
+ if (a.dueAt && b.dueAt && a.dueAt !== b.dueAt) return a.dueAt.localeCompare(b.dueAt);
79
+ if (a.dueAt !== b.dueAt) return a.dueAt ? -1 : 1;
80
+ return (a.firstSeenAt ?? '').localeCompare(b.firstSeenAt ?? '');
81
+ };
82
+ }
83
+
84
+ export function failureMessage(err: unknown): string {
85
+ const status = (err as { status?: number })?.status;
86
+ if (status === 403) return 'You do not have access to this workspace’s records.';
87
+ if (status === 401) return 'Your session has expired. Sign in again.';
88
+ return (err as { message?: string })?.message || 'That could not be loaded right now.';
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // the honesty banner
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /**
96
+ * "This workspace's data API did not filter by X."
97
+ *
98
+ * THE REQUESTED KEYS ARE A PARAMETER, and that is the change from the constant
99
+ * the tracker used to carry. Planning asks for `focus` as well, and a hardcoded
100
+ * list would have quietly excluded it from the confession — the failure being
101
+ * silence about silence.
102
+ *
103
+ * Note what this does NOT do: it never decides on its own whether a filter was
104
+ * honoured. `applied` is the SERVER's report, reconciled back into the
105
+ * composer's vocabulary by the client (a `focus` request comes back as
106
+ * `related_to`). A banner that guessed would eventually announce a gap that had
107
+ * been closed, which is exactly what shipped once already.
108
+ */
109
+ export function UnfilteredNotice<K extends string>({
110
+ requested, applied, query, className,
111
+ }: {
112
+ requested: readonly K[];
113
+ applied: readonly string[];
114
+ query: Partial<Record<K, unknown>>;
115
+ className: string;
116
+ }) {
117
+ const unfiltered = requested.filter((key) => Boolean(query[key]) && !applied.includes(key));
118
+ if (!unfiltered.length) return null;
119
+ return (
120
+ <p className={className} role="status">
121
+ This workspace’s data API did not filter by {unfiltered.join(', ')}, so this list was narrowed
122
+ in the browser over the records it could fetch. Counts are over that set, not over the whole
123
+ corpus.
124
+ </p>
125
+ );
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // the row
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /**
133
+ * One item, wherever it is shown.
134
+ *
135
+ * THE AUTHORED-ITEM BRANCH IS THE POINT OF THE LIFT. This row used to state
136
+ * unconditionally that an item came "From N meetings" and then list them — true
137
+ * of everything on the screen, back when the only way to get a row was
138
+ * extraction. Now a person can type one, and an authored item has no mentions:
139
+ * rendered by the old row it read "From 0 meetings" above an empty disclosure,
140
+ * which says the meeting record is missing rather than that there never was one.
141
+ *
142
+ * The provenance line is therefore a fork, not a count, and both arms say
143
+ * something true. That difference IS the placement argument made visible: an
144
+ * extracted item and an authored one live in one list, and the list can still
145
+ * tell you which is which.
146
+ */
147
+ export function WorkItemRow({
148
+ item, now, classNames: cn, onOpenDocument, actions,
149
+ }: {
150
+ item: Commitment;
151
+ now: number;
152
+ classNames: Record<WorkItemSlot, string>;
153
+ onOpenDocument?: (documentId: string) => void;
154
+ /** Per-row controls the host screen owns (file, edit, complete). */
155
+ actions?: React.ReactNode;
156
+ }) {
157
+ const overdue = isOverdue(item, now);
158
+ const age = item.firstSeenAt ? Math.floor((now - Date.parse(item.firstSeenAt)) / DAY_MS) : null;
159
+ const authored = isAuthored(item);
160
+ const priority = priorityLabel(item.priority);
161
+
162
+ return (
163
+ <article className={cn.row}>
164
+ <div className="flex flex-wrap items-center gap-2">
165
+ <span className={cn.badge}>{item.subtype}</span>
166
+ {item.status !== 'open' ? <span className={cn.badge}>{item.status}</span> : null}
167
+ {/* Only when set. An unset priority means nobody has triaged this, and
168
+ printing "Medium" would turn a missing judgement into a stated one. */}
169
+ {priority ? <span className={cn.badge}>{priority}</span> : null}
170
+ <h3 className="text-sm font-medium text-gray-900">{item.title}</h3>
171
+ {actions ? <span className="ml-auto flex items-center gap-2">{actions}</span> : null}
172
+ </div>
173
+
174
+ <div className="mt-1 flex flex-wrap items-center gap-3">
175
+ {item.ownerName ? (
176
+ <span className="text-sm text-gray-700">{item.ownerName}</span>
177
+ ) : (
178
+ // Stated, not blank. "Nobody said who" is a fact about the meeting and
179
+ // it is the single most actionable thing on this screen.
180
+ <span className={cn.muted}>No owner named</span>
181
+ )}
182
+ {item.subject ? <span className={cn.muted}>· {item.subject}</span> : null}
183
+ {item.dueAt ? (
184
+ <span className={overdue ? cn.due : cn.muted}>
185
+ {overdue ? 'Overdue ' : 'Due '}
186
+ {new Date(item.dueAt).toLocaleDateString()}
187
+ </span>
188
+ ) : null}
189
+ {age !== null && age > 0 ? (
190
+ <span className={cn.muted}>
191
+ {/* The sentence the product exists to say. */}
192
+ open for {age} day{age === 1 ? '' : 's'}
193
+ </span>
194
+ ) : null}
195
+ {item.mentions.length > 1 ? (
196
+ <span className={cn.badge}>raised in {item.mentions.length} meetings</span>
197
+ ) : null}
198
+ </div>
199
+
200
+ {item.detail ? <p className="mt-2 text-sm text-gray-700">{item.detail}</p> : null}
201
+
202
+ {item.statusReason ? (
203
+ <p className="mt-2 text-sm text-gray-600">
204
+ {/* Why does it say this is closed? Answerable without opening the
205
+ meeting, or nobody will trust a single closed row. */}
206
+ {item.status === 'done' ? 'Closed by' : 'Blocked by'}: “{stripMarkdown(item.statusReason)}”
207
+ </p>
208
+ ) : null}
209
+
210
+ {item.mentions.length === 0 ? (
211
+ // An authored item is its own evidence: somebody typed it, on purpose,
212
+ // and there is no meeting to cite. Said in one line rather than through
213
+ // an empty disclosure that looks like a load that failed.
214
+ <p className={`mt-2 ${cn.muted}`}>
215
+ {authored ? 'Added by hand' : 'No meeting recorded for this item'}
216
+ </p>
217
+ ) : (
218
+ <details className="mt-2">
219
+ <summary className="cursor-pointer text-sm text-gray-600">
220
+ From {item.mentions.length} meeting{item.mentions.length === 1 ? '' : 's'}
221
+ </summary>
222
+ <div className="mt-2 space-y-2">
223
+ {item.mentions.map((mention) => (
224
+ <div key={`${mention.documentId}:${mention.originKey}`} className="border-t pt-2">
225
+ <div className="flex flex-wrap items-center gap-2">
226
+ <button
227
+ type="button"
228
+ className="text-sm text-blue-700 underline"
229
+ onClick={() => onOpenDocument?.(mention.documentId)}
230
+ >
231
+ {mention.occurredAt
232
+ ? new Date(mention.occurredAt).toLocaleDateString()
233
+ : 'Undated meeting'}
234
+ </button>
235
+ {/* Where the row came from. A model-derived item and one the
236
+ summariser itself wrote down are different levels of
237
+ evidence, and the difference belongs on screen. */}
238
+ <span className={cn.badge}>
239
+ {mention.extractor === 'rules'
240
+ ? 'from the meeting’s own next steps'
241
+ : 'read from the notes'}
242
+ </span>
243
+ </div>
244
+ {mention.quote ? (
245
+ <p className="mt-1 text-sm italic text-gray-600">
246
+ “{stripMarkdown(mention.quote)}”
247
+ </p>
248
+ ) : null}
249
+ </div>
250
+ ))}
251
+ </div>
252
+ </details>
253
+ )}
254
+ </article>
255
+ );
256
+ }