@startsimpli/ui 0.4.79 → 0.4.81

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.79",
3
+ "version": "0.4.81",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -43,8 +43,8 @@
43
43
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
44
44
  "react": "^18.0.0 || ^19.0.0",
45
45
  "react-dom": "^18.0.0 || ^19.0.0",
46
- "@startsimpli/auth": "^0.4.31",
47
- "@startsimpli/api": "^0.5.48"
46
+ "@startsimpli/api": "^0.5.50",
47
+ "@startsimpli/auth": "^0.4.31"
48
48
  },
49
49
  "peerDependenciesMeta": {
50
50
  "@startsimpli/api": {
@@ -119,6 +119,21 @@ function actorName(actor?: ActivityTimelineActor): string {
119
119
  return (actor?.name ?? '').trim() || (actor?.email ?? '').trim();
120
120
  }
121
121
 
122
+ /**
123
+ * The second half of the identity — the address, when the display name is
124
+ * something else (startsim-768w.19.8).
125
+ *
126
+ * A display name is free text: an account called "QA Foundry" reads exactly
127
+ * like the name of a FOUNDRY, so a correct feed looked to its owner like it was
128
+ * showing another tenant's activity. The email is the unambiguous handle, and
129
+ * it is what turns a name that could be anything into a person. Empty when the
130
+ * name IS the email, because printing it twice is noise.
131
+ */
132
+ function actorHandle(actor?: ActivityTimelineActor): string {
133
+ const email = (actor?.email ?? '').trim();
134
+ return email && email !== actorName(actor) ? email : '';
135
+ }
136
+
122
137
  function dayKey(iso: string): string {
123
138
  const d = new Date(iso);
124
139
  return Number.isNaN(d.getTime()) ? 'unknown' : d.toDateString();
@@ -181,6 +196,7 @@ function TimelineEntry({
181
196
  (tone ? TONE_MARKER[tone] : undefined) ||
182
197
  'bg-muted text-muted-foreground';
183
198
  const who = actorName(activity.actor);
199
+ const handle = actorHandle(activity.actor);
184
200
  // With no icon the marker itself becomes the avatar, so the metadata line
185
201
  // shows the name alone rather than the same initials twice.
186
202
  const markerIsAvatar = !Icon && !!initials;
@@ -224,13 +240,14 @@ function TimelineEntry({
224
240
 
225
241
  <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
226
242
  {who && (
227
- <span className="flex items-center gap-1.5">
243
+ <span className="flex flex-wrap items-center gap-x-1.5">
228
244
  {initials && !markerIsAvatar && (
229
245
  <span className="flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground">
230
246
  {initials}
231
247
  </span>
232
248
  )}
233
- {who}
249
+ <span>{who}</span>
250
+ {handle && <span className="text-xs text-muted-foreground/80">{handle}</span>}
234
251
  </span>
235
252
  )}
236
253
 
@@ -101,7 +101,12 @@ export function ActivityFeedPanel({
101
101
  pageSize = 25,
102
102
  refetchInterval = false,
103
103
  title = 'Activity',
104
- description = 'Everything that happened here, newest first — as much as your access level allows.',
104
+ // WHO DID WHAT — the user's own framing of this surface, and the line that
105
+ // separates it from a deployment/run history (startsim-768w.19.8). The old
106
+ // copy hedged with "as much as your access level allows", which named a
107
+ // constraint the reader cannot act on; the permission filtering is invisible
108
+ // and correct by design, so it does not belong in the description.
109
+ description = 'Who did what here, newest first — sign-ins, invites and role changes, settings and pipeline edits, and who started each build.',
105
110
  headerAside,
106
111
  renderFooter,
107
112
  showFilters = true,
@@ -0,0 +1,229 @@
1
+ /**
2
+ * startsim-768w.19.8 (RED) — the activity feed LABELS a row honestly.
3
+ *
4
+ * The user reported "activity in marketing agents foundry is showing me the
5
+ * activity of a different foundry". It was not a filtering bug: every row
6
+ * carried the right tenant. It was a LABELLING bug that mimics a data leak
7
+ * perfectly, and all three parts of it live in this layer:
8
+ *
9
+ * 1. THE ACTOR CHIP NAMED SOMETHING THAT LOOKS LIKE AN ORG. The account's
10
+ * display name is "QA Foundry", which collides with a DIFFERENT foundry's
11
+ * name — so a correct feed reads as cross-tenant contamination. A row must
12
+ * identify the PERSON unambiguously, which means the email is never
13
+ * dropped in favour of a name that could be anything.
14
+ * 2. THE TARGET WAS A RAW UUID — "membership · dc0d739f-a622-…" tells nobody
15
+ * anything. Resolve it to a human string from the row's own metadata, and
16
+ * when it cannot be resolved render NOTHING rather than a UUID.
17
+ * 3. RESENDS WERE INDISTINGUISHABLE FROM ORIGINAL INVITES. The same invitee
18
+ * appeared twice; the second carried `metadata.resend` and rendered
19
+ * identically, so a correct trail read as a duplicate.
20
+ *
21
+ * DESIGN GUARD, same as the rest of this layer: nothing below branches on a
22
+ * known action. Target resolution reads generic DESCRIPTOR keys off `metadata`,
23
+ * so a brand-new event type is labelled the moment the backend records one.
24
+ */
25
+ import { render, screen, within } from '@testing-library/react'
26
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
27
+
28
+ import { ActivityTimeline } from '../../ActivityTimeline'
29
+ import {
30
+ ActivityFeedPanel,
31
+ activityNote,
32
+ activityTarget,
33
+ toActivityTimelineItems,
34
+ type ActivityEvent,
35
+ } from '../index'
36
+
37
+ const emptyPage = { count: 0, next: null, previous: null, results: [] }
38
+
39
+ function event(over: Partial<ActivityEvent> & { id: string }): ActivityEvent {
40
+ return {
41
+ action: 'state_changed',
42
+ type: 'state_changed',
43
+ target_type: 'state',
44
+ target_id: '',
45
+ metadata: {},
46
+ actor: null,
47
+ actor_email: null,
48
+ state: null,
49
+ previous_state: null,
50
+ source: 'orchestrator',
51
+ reason: '',
52
+ error: '',
53
+ run_id: '',
54
+ run_kind: '',
55
+ created_at: '2026-07-20T12:00:00Z',
56
+ ...over,
57
+ }
58
+ }
59
+
60
+ const INVITE = event({
61
+ id: 'ev-1',
62
+ action: 'member_invited',
63
+ type: 'member_invited',
64
+ target_type: 'membership',
65
+ target_id: 'dc0d739f-a622-431d-beb2-de169acda594',
66
+ metadata: { email: 'nordby@ogmc.ai', role: 'admin', auto_created: true },
67
+ actor: { id: 'u-qa', email: 'qa-foundry@startsimpli.com', name: 'QA Foundry' },
68
+ actor_email: 'qa-foundry@startsimpli.com',
69
+ })
70
+
71
+ // --------------------------------------------------------------------------
72
+ // 1. THE ACTOR IS A PERSON, and says so
73
+ // --------------------------------------------------------------------------
74
+
75
+ describe('the actor chip identifies a PERSON, not an org', () => {
76
+ it('shows the email alongside a display name that could be read as a company', () => {
77
+ render(<ActivityTimeline activities={toActivityTimelineItems([INVITE])} />)
78
+ // Both halves of the identity. Without the email, "QA Foundry" is
79
+ // indistinguishable from the name of another foundry, which is exactly what
80
+ // the user read as cross-tenant contamination.
81
+ expect(screen.getByText('QA Foundry')).toBeInTheDocument()
82
+ expect(screen.getByText('qa-foundry@startsimpli.com')).toBeInTheDocument()
83
+ })
84
+
85
+ it('does not print the same identity twice when there is no display name', () => {
86
+ const rows = toActivityTimelineItems([
87
+ event({ ...INVITE, id: 'ev-2', actor: { id: 'u-2', email: 'ada@x.com', name: '' } }),
88
+ ])
89
+ render(<ActivityTimeline activities={rows} />)
90
+ expect(screen.getAllByText('ada@x.com')).toHaveLength(1)
91
+ })
92
+
93
+ it('treats a name the server derived from the email as no name at all', () => {
94
+ // Some backends fall back to the email inside the display name. Echoing it
95
+ // twice is noise, not identity.
96
+ const rows = toActivityTimelineItems([
97
+ event({ ...INVITE, id: 'ev-3', actor: { id: 'u-3', email: 'ada@x.com', name: 'ada@x.com' } }),
98
+ ])
99
+ render(<ActivityTimeline activities={rows} />)
100
+ expect(screen.getAllByText('ada@x.com')).toHaveLength(1)
101
+ })
102
+
103
+ it('still renders a row the control plane recorded itself, with no actor', () => {
104
+ const rows = toActivityTimelineItems([event({ id: 'ev-4', state: 'live', actor: null })])
105
+ render(<ActivityTimeline activities={rows} />)
106
+ expect(screen.getByText('Live')).toBeInTheDocument()
107
+ })
108
+ })
109
+
110
+ // --------------------------------------------------------------------------
111
+ // 2. THE TARGET IS A HUMAN STRING, or nothing
112
+ // --------------------------------------------------------------------------
113
+
114
+ describe('activityTarget resolves what was touched', () => {
115
+ it('names the invitee instead of the membership UUID', () => {
116
+ expect(activityTarget(INVITE)).toBe('nordby@ogmc.ai')
117
+ expect(activityTarget(INVITE)).not.toMatch(/dc0d739f/)
118
+ })
119
+
120
+ it('renders NOTHING rather than a UUID when nothing human is recorded', () => {
121
+ const opaque = event({
122
+ id: 'ev-5',
123
+ action: 'thing_happened',
124
+ type: 'thing_happened',
125
+ target_type: 'membership',
126
+ target_id: 'dc0d739f-a622-431d-beb2-de169acda594',
127
+ metadata: {},
128
+ })
129
+ expect(activityTarget(opaque)).toBe('')
130
+ })
131
+
132
+ it('keeps a target id a human can actually use', () => {
133
+ const pr = event({
134
+ id: 'ev-6',
135
+ action: 'app_ui_proposed',
136
+ type: 'app_ui_proposed',
137
+ target_type: 'pull_request',
138
+ target_id: '42',
139
+ metadata: {},
140
+ })
141
+ expect(activityTarget(pr)).toBe('pull_request · 42')
142
+ })
143
+
144
+ it('labels an event type it has never heard of from that row own metadata', () => {
145
+ // No vocabulary: a new backend event only has to record a descriptor.
146
+ const fresh = event({
147
+ id: 'ev-7',
148
+ action: 'schema_type_created',
149
+ type: 'schema_type_created',
150
+ target_type: 'schema_type',
151
+ target_id: 'b1f0f2c4-7f24-4a2f-9d69-6a0f0e9a1111',
152
+ metadata: { name: 'Artist' },
153
+ })
154
+ expect(activityTarget(fresh)).toBe('Artist')
155
+ })
156
+
157
+ it('prefers the row own reason or error over the target, as before', () => {
158
+ const failed = event({
159
+ id: 'ev-8',
160
+ action: 'lifecycle_failed',
161
+ type: 'lifecycle_failed',
162
+ error: 'CodeBuild step exited 1',
163
+ target_type: 'run',
164
+ target_id: 'f496dea4-0000-0000-0000-000000000000',
165
+ })
166
+ const [item] = toActivityTimelineItems([failed])
167
+ expect(item.detail).toBe('CodeBuild step exited 1')
168
+ })
169
+
170
+ it('puts the resolved target on the timeline item instead of the UUID', () => {
171
+ const [item] = toActivityTimelineItems([INVITE])
172
+ expect(item.detail).toBe('nordby@ogmc.ai')
173
+ expect(String(item.detail)).not.toMatch(/dc0d739f/)
174
+ })
175
+ })
176
+
177
+ // --------------------------------------------------------------------------
178
+ // 3. A RESEND IS NOT A SECOND INVITE
179
+ // --------------------------------------------------------------------------
180
+
181
+ describe('a resend is distinguishable from the original', () => {
182
+ const RESEND = event({
183
+ ...INVITE,
184
+ id: 'ev-9',
185
+ metadata: { email: 'nordby@ogmc.ai', role: 'admin', resend: true },
186
+ created_at: '2026-07-21T12:00:00Z',
187
+ })
188
+
189
+ it('marks the resent row', () => {
190
+ expect(activityNote(RESEND)).toBe('resend')
191
+ expect(activityNote(INVITE)).toBeUndefined()
192
+ })
193
+
194
+ it('keeps the source note for a transition nobody fired', () => {
195
+ const drift = event({ id: 'ev-10', state: 'paused', source: 'reconcile' })
196
+ expect(activityNote(drift)).toBe('detected by drift reconcile')
197
+ })
198
+
199
+ it('renders the two invites for the same person differently', () => {
200
+ render(<ActivityTimeline activities={toActivityTimelineItems([RESEND, INVITE])} />)
201
+ const rows = document.querySelectorAll('[data-activity-id]')
202
+ expect(rows).toHaveLength(2)
203
+ const first = within(rows[0] as HTMLElement)
204
+ const second = within(rows[1] as HTMLElement)
205
+ expect(first.getByText('resend')).toBeInTheDocument()
206
+ expect(second.queryByText('resend')).toBeNull()
207
+ })
208
+ })
209
+
210
+ // --------------------------------------------------------------------------
211
+ // 4. THE COPY states the tab scope, without an unactionable hedge
212
+ // --------------------------------------------------------------------------
213
+
214
+ describe('the default panel copy', () => {
215
+ it('no longer hedges about the reader access level', () => {
216
+ // The default description is a plain statement of what the tab holds. The
217
+ // permission filtering is invisible and correct by design — naming it only
218
+ // tells the reader about a constraint they cannot act on.
219
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
220
+ render(
221
+ <QueryClientProvider client={qc}>
222
+ <ActivityFeedPanel client={{ list: jest.fn().mockResolvedValue(emptyPage) }} />
223
+ </QueryClientProvider>,
224
+ )
225
+ const panel = within(screen.getByTestId('activity-feed-panel'))
226
+ expect(panel.queryByText(/access level/i)).toBeNull()
227
+ expect(panel.getByText(/who did what/i)).toBeInTheDocument()
228
+ })
229
+ })
@@ -9,7 +9,11 @@
9
9
  * a humanised sentence with a neutral tone; it never falls through to a wrong
10
10
  * label like "State changed", and it is never dropped.
11
11
  */
12
- import type { ActivityTimelineItem, ActivityTone } from '../ActivityTimeline';
12
+ import type {
13
+ ActivityTimelineActor,
14
+ ActivityTimelineItem,
15
+ ActivityTone,
16
+ } from '../ActivityTimeline';
13
17
  import type {
14
18
  ActivityEvent,
15
19
  ActivityFilterOption,
@@ -113,13 +117,87 @@ export function activityHead(
113
117
  return (events ?? []).find((e) => actionOf(e) === 'state_changed' && !!e.state);
114
118
  }
115
119
 
120
+ /** A machine id nobody can read — a UUID. Printing one where a human string
121
+ * belongs ("membership · dc0d739f-a622-…") tells the reader nothing, so it is
122
+ * the one value this layer refuses to render (startsim-768w.19.8). */
123
+ const OPAQUE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
124
+
125
+ /**
126
+ * Generic DESCRIPTOR keys — the names an event uses for "the human string for
127
+ * the thing I touched". Deliberately a naming convention, NOT a per-action map:
128
+ * a member invite records `email`, a schema change records `name`, a pipeline
129
+ * change records `source`/`source_key`, and an event type nobody has written
130
+ * yet is labelled the moment it records any of them. Nothing here branches on
131
+ * an action, so registering an event type still needs no UI change.
132
+ */
133
+ const TARGET_DESCRIPTORS = [
134
+ 'email', 'name', 'title', 'label', 'source', 'source_name',
135
+ 'repo', 'slug', 'path', 'source_key', 'provider_key', 'credential_type',
136
+ ];
137
+
138
+ /**
139
+ * What this event touched, in words a person can read — the invitee's email,
140
+ * the source's name, the schema type — resolved from the row's OWN metadata.
141
+ *
142
+ * Falls back to `type · id` only for an id a human can actually use (a PR
143
+ * number), and to the EMPTY STRING for an opaque one. Rendering nothing is
144
+ * strictly more informative than rendering a UUID.
145
+ */
146
+ export function activityTarget(event: ActivityEvent): string {
147
+ const meta = event.metadata ?? {};
148
+ for (const key of TARGET_DESCRIPTORS) {
149
+ const value = meta[key];
150
+ if (typeof value !== 'string') continue;
151
+ const text = value.trim();
152
+ if (text && !OPAQUE_ID.test(text)) return text;
153
+ }
154
+ const id = String(event.target_id ?? '').trim();
155
+ if (!id || OPAQUE_ID.test(id)) return '';
156
+ return [event.target_type, id].filter(Boolean).join(' · ');
157
+ }
158
+
159
+ /**
160
+ * The small pill next to the title — the thing that makes two otherwise
161
+ * identical rows different.
162
+ *
163
+ * A RESENT invite is the case that mattered: the same invitee appeared twice
164
+ * and the second row rendered identically to the first, so a correct trail read
165
+ * as a duplicated one. `metadata.resend` is the flag the writer already
166
+ * records; this is where it becomes visible. The lifecycle source notes explain
167
+ * a state the operator did not fire themselves (startsim-bq7s.1).
168
+ */
169
+ export function activityNote(event: ActivityEvent): string | undefined {
170
+ if (event.metadata?.resend === true) return 'resend';
171
+ return event.source ? SOURCE_NOTE[event.source] : undefined;
172
+ }
173
+
174
+ /**
175
+ * The PERSON behind a row — never an org.
176
+ *
177
+ * The display name is free text a user typed, so it can read exactly like the
178
+ * name of a foundry ("QA Foundry"), and a correct feed then looks like
179
+ * cross-tenant contamination. The email is the one unambiguous handle, so it is
180
+ * carried alongside the name rather than replaced by it; a `name` that is only
181
+ * the email echoed back is dropped, because saying it twice is not identity.
182
+ */
183
+ export function activityActor(event: ActivityEvent): ActivityTimelineActor | undefined {
184
+ const raw = event.actor ?? (event.actor_email ? { email: event.actor_email } : undefined);
185
+ if (!raw) return undefined;
186
+ const email = (raw.email ?? '').trim();
187
+ const name = (raw.name ?? '').trim();
188
+ return { ...raw, email: email || undefined, name: name && name !== email ? name : undefined };
189
+ }
190
+
116
191
  /** The body line for a row: the failure message, else the stated reason, else
117
- * what was touched. */
192
+ * what was touched, in words. */
118
193
  function detailOf(event: ActivityEvent): string {
119
194
  if (event.error) return event.error;
120
195
  if (event.reason) return event.reason;
121
- const target = [event.target_type, event.target_id].filter(Boolean).join(' · ');
122
- return actionOf(event) === 'state_changed' ? '' : target;
196
+ // A lifecycle transition already names its outcome in the TITLE, so echoing
197
+ // the target ("state · live") underneath it is noise. Derived from the row
198
+ // carrying a state, not from its action string.
199
+ if (event.state) return '';
200
+ return activityTarget(event);
123
201
  }
124
202
 
125
203
  /** Map any stream row onto the shared timeline's item shape. Nothing in here
@@ -129,17 +207,16 @@ export function toActivityTimelineItems(
129
207
  ): ActivityTimelineItem[] {
130
208
  return (events ?? []).map((event) => {
131
209
  const tone = activityTone(event);
132
- const actor = event.actor ?? (event.actor_email ? { email: event.actor_email } : undefined);
133
210
  const detail = detailOf(event);
134
211
  return {
135
212
  id: event.id,
136
213
  type: actionOf(event),
137
214
  title: activityLabel(event),
138
215
  occurredAt: event.created_at,
139
- actor: actor ?? undefined,
216
+ actor: activityActor(event),
140
217
  tone,
141
218
  subtitle: event.previous_state && event.state ? `from ${event.previous_state}` : undefined,
142
- note: event.source ? SOURCE_NOTE[event.source] : undefined,
219
+ note: activityNote(event),
143
220
  detail: detail || undefined,
144
221
  detailTone: event.error ? 'error' : 'muted',
145
222
  };
@@ -35,6 +35,9 @@ export {
35
35
  activityLabel,
36
36
  activityTone,
37
37
  activityHead,
38
+ activityActor,
39
+ activityNote,
40
+ activityTarget,
38
41
  toActivityTimelineItems,
39
42
  deriveActivityFilterOptions,
40
43
  } from './activity-events';