@startsimpli/ui 0.4.80 → 0.4.82

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.80",
3
+ "version": "0.4.82",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -44,7 +44,7 @@
44
44
  "react": "^18.0.0 || ^19.0.0",
45
45
  "react-dom": "^18.0.0 || ^19.0.0",
46
46
  "@startsimpli/auth": "^0.4.31",
47
- "@startsimpli/api": "^0.5.49"
47
+ "@startsimpli/api": "^0.5.51"
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
 
@@ -0,0 +1,55 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * AccountActivityPanel — the PLATFORM-SCOPE activity composer
5
+ * (startsim-768w.19.1.5).
6
+ *
7
+ * The same event stream as `ActivityFeedPanel` renders for one foundry, at the
8
+ * scope ABOVE any foundry: the rows that belong to no single one of them.
9
+ * Sign-ins are the whole population today — central auth mints tokens for every
10
+ * app, so a login is recorded once, for the person, with an empty tenant slug.
11
+ * A user with access to N foundries would otherwise get N duplicate rows or one
12
+ * arbitrarily-attributed one; the row is right, and it needed a reader with a
13
+ * scope of its own.
14
+ *
15
+ * DELIBERATELY ALMOST NOTHING. The paging, filtering, day grouping, infinite
16
+ * scroll and three empty states are `ActivityFeedPanel`'s, and the rendering is
17
+ * `ActivityTimeline`'s — both already built to render whatever the stream
18
+ * returns and to humanise an action they have never seen. What this adds is the
19
+ * COPY: an account-scope surface has to say why its rows name no foundry, and
20
+ * has to describe an empty page without claiming nothing happened, because the
21
+ * server answers a caller who may not read sign-ins with an empty page rather
22
+ * than a 403. Every prop stays overridable — a host that wants different words
23
+ * passes them, it does not fork the panel.
24
+ *
25
+ * The consuming app supplies `{ list: (p) => api.foundry.accountActivity(p) }`
26
+ * and its theme classNames. Nothing else — rule #9.
27
+ */
28
+ import { ActivityFeedPanel, type ActivityFeedPanelProps } from './ActivityFeedPanel';
29
+
30
+ /** Same props as the feed it composes: there is no account-only knob, and a
31
+ * `slug` would be a lie — these events have no foundry. */
32
+ export type AccountActivityPanelProps = ActivityFeedPanelProps;
33
+
34
+ /** Cache namespace for the platform-scope stream. Deliberately NOT under a
35
+ * foundry key, so invalidating one foundry never refetches it and vice versa. */
36
+ export const ACCOUNT_ACTIVITY_QUERY_KEY = ['account', 'activity'] as const;
37
+
38
+ export function AccountActivityPanel(props: AccountActivityPanelProps) {
39
+ return (
40
+ <ActivityFeedPanel
41
+ queryKey={ACCOUNT_ACTIVITY_QUERY_KEY}
42
+ title="Account activity"
43
+ description="Sign-ins across your account, newest first. These belong to the person rather than to one foundry, so they live here instead of on a foundry's own Activity tab."
44
+ // NOT "nothing has happened": an admin, member or viewer gets an empty
45
+ // page rather than a 403 (a 403 would confirm the rows exist), so copy
46
+ // that asserted the stream was empty would be wrong for most readers.
47
+ emptyTitle="No account activity to show"
48
+ emptyDescription="Sign-ins show up here. Sign-in history — who signed in, when and from where — is visible to foundry owners."
49
+ filteredEmptyTitle="No account activity matches these filters"
50
+ filteredEmptyDescription="Widen the date range or clear a filter to see more."
51
+ errorTitle="Could not load account activity"
52
+ {...props}
53
+ />
54
+ );
55
+ }
@@ -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,153 @@
1
+ /**
2
+ * startsim-768w.19.1.5 (RED) — the PLATFORM-SCOPE activity surface.
3
+ *
4
+ * `record_login()` writes a `member_login` row for every successful sign-in,
5
+ * with `tenant_slug=""` because a control-plane login belongs to the PERSON, not
6
+ * to one of the N foundries they can reach. The only reader was the per-foundry
7
+ * feed, which filters by slug — so the row was captured, permissioned and
8
+ * unreachable in every UI.
9
+ *
10
+ * THE SURFACE IS A CONSUMER, NOT A SECOND FEED. Everything here already exists:
11
+ * `ActivityFeedPanel` (paging, filters, day grouping, three empty states) over
12
+ * `ActivityTimeline` (the primitive), both built to render whatever the stream
13
+ * returns and to humanise an action they have never seen. The account scope
14
+ * differs by ONE thing — which client call it makes — plus the copy that says
15
+ * why these rows name no foundry. A second timeline here would be the bug.
16
+ */
17
+ import * as fs from 'node:fs'
18
+ import * as path from 'node:path'
19
+ import { render, screen, waitFor, within } from '@testing-library/react'
20
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
21
+
22
+ import {
23
+ AccountActivityPanel,
24
+ activityLabel,
25
+ activityTarget,
26
+ toActivityTimelineItems,
27
+ type ActivityEvent,
28
+ type ActivityPage,
29
+ type ActivityQuery,
30
+ } from '../index'
31
+
32
+ function login(over: Partial<ActivityEvent> & { id: string }): ActivityEvent {
33
+ return {
34
+ action: 'member_login',
35
+ type: 'member_login',
36
+ target_type: 'user',
37
+ target_id: '3f1c9e40-2b7a-4a11-9c33-8f0b2a5d61ee',
38
+ metadata: { app: 'foundry', method: 'credentials', user_agent: 'Firefox/141.0' },
39
+ actor: { id: 'u-1', email: 'ada@x.com', name: 'Ada Lovelace' },
40
+ actor_email: 'ada@x.com',
41
+ state: null,
42
+ previous_state: null,
43
+ source: '',
44
+ reason: '',
45
+ error: '',
46
+ run_id: '',
47
+ run_kind: '',
48
+ created_at: '2026-07-27T19:38:00Z',
49
+ ...over,
50
+ }
51
+ }
52
+
53
+ function page(results: ActivityEvent[], over: Partial<ActivityPage> = {}): ActivityPage {
54
+ return { count: results.length, next: null, previous: null, results, ...over }
55
+ }
56
+
57
+ function withQuery(ui: React.ReactElement) {
58
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
59
+ return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>)
60
+ }
61
+
62
+ function timeline() {
63
+ return within(screen.getByTestId('activity-timeline'))
64
+ }
65
+
66
+ describe('AccountActivityPanel', () => {
67
+ it('renders a login the per-foundry feed could never reach', async () => {
68
+ const list = jest.fn(async () => page([login({ id: 'e1' })]))
69
+ withQuery(<AccountActivityPanel client={{ list }} />)
70
+
71
+ await waitFor(() => expect(timeline().getByText('Member login')).toBeInTheDocument())
72
+ expect(timeline().getByText('Ada Lovelace')).toBeInTheDocument()
73
+ })
74
+
75
+ it('says which app the sign-in was for', async () => {
76
+ const list = jest.fn(async () => page([login({ id: 'e2' })]))
77
+ withQuery(<AccountActivityPanel client={{ list }} />)
78
+
79
+ // `app` is the row's own descriptor for what it touched. Without it the
80
+ // target app is captured on the row and invisible on the page — the same
81
+ // shape of bug one level down.
82
+ await waitFor(() => expect(timeline().getByText('foundry')).toBeInTheDocument())
83
+ })
84
+
85
+ it('pages server-side (rule #8) rather than asking for everything', async () => {
86
+ const list = jest.fn(async (_p: ActivityQuery) => page([login({ id: 'e3' })]))
87
+ withQuery(<AccountActivityPanel client={{ list }} />)
88
+
89
+ await waitFor(() => expect(list).toHaveBeenCalled())
90
+ const params = list.mock.calls[0][0] as ActivityQuery
91
+ expect(params.page).toBe(1)
92
+ expect(typeof params.pageSize).toBe('number')
93
+ })
94
+
95
+ it('takes no slug — the events belong to no foundry', () => {
96
+ const list = jest.fn(async () => page([]))
97
+ // A slug prop would be a type error; the runtime check is that the panel
98
+ // renders from the injected client alone.
99
+ withQuery(<AccountActivityPanel client={{ list }} />)
100
+ expect(screen.getByTestId('activity-feed-panel')).toBeInTheDocument()
101
+ })
102
+
103
+ it('explains an empty page without claiming nothing happened', async () => {
104
+ const list = jest.fn(async () => page([]))
105
+ withQuery(<AccountActivityPanel client={{ list }} />)
106
+
107
+ // An admin/member/viewer gets an empty page rather than a 403, so the copy
108
+ // must not assert "no sign-ins have happened".
109
+ await screen.findByText(/no account activity/i)
110
+ const body = screen.getByTestId('activity-feed-panel').textContent ?? ''
111
+ expect(body).toMatch(/sign-in/i)
112
+ expect(body).toMatch(/owner/i)
113
+ expect(body).not.toMatch(/nothing recorded/i)
114
+ })
115
+
116
+ it('lets a host override the shared copy', async () => {
117
+ const list = jest.fn(async () => page([]))
118
+ withQuery(<AccountActivityPanel client={{ list }} title="Sign-in history" />)
119
+ await waitFor(() => expect(screen.getByText('Sign-in history')).toBeInTheDocument())
120
+ })
121
+ })
122
+
123
+ describe('the platform scope reuses the shared feed', () => {
124
+ it('humanises member_login with no new vocabulary', () => {
125
+ expect(activityLabel(login({ id: 'e4' }))).toBe('Member login')
126
+ })
127
+
128
+ it('renders the app instead of the opaque user id', () => {
129
+ expect(activityTarget(login({ id: 'e5' }))).toBe('foundry')
130
+ // No app on the row -> nothing, never the UUID.
131
+ expect(activityTarget(login({ id: 'e6', metadata: { method: 'sso' } }))).toBe('')
132
+ })
133
+
134
+ it('maps a login onto the same timeline item shape as any other row', () => {
135
+ const [item] = toActivityTimelineItems([login({ id: 'e7' })])
136
+ expect(item.title).toBe('Member login')
137
+ expect(item.actor?.email).toBe('ada@x.com')
138
+ expect(item.occurredAt).toBe('2026-07-27T19:38:00Z')
139
+ })
140
+
141
+ it('does not render its own list — it composes ActivityFeedPanel', () => {
142
+ const src = fs.readFileSync(
143
+ path.resolve(__dirname, '..', 'AccountActivityPanel.tsx'), 'utf8')
144
+ expect(src).toMatch(/<ActivityFeedPanel/)
145
+ // No list of its own, no second query, no second timeline. (The doc comment
146
+ // may NAME the timeline; what it may not do is import or render one.)
147
+ const banned = [/\.map\(/, /useInfiniteQuery/, /<ActivityTimeline/,
148
+ /from '\.\.\/ActivityTimeline'/]
149
+ for (const re of banned) {
150
+ expect(`${re.source}:${re.test(src)}`).toBe(`${re.source}:false`)
151
+ }
152
+ })
153
+ })
@@ -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,92 @@ 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
+ // `app` is what a platform-scope row touched: a sign-in records which app it
136
+ // minted a token for, and its `target_id` is the actor's own UUID, so without
137
+ // this key the app is captured on the row and invisible on the page
138
+ // (startsim-768w.19.1.5). Same convention, not a login special case.
139
+ 'app',
140
+ 'repo', 'slug', 'path', 'source_key', 'provider_key', 'credential_type',
141
+ ];
142
+
143
+ /**
144
+ * What this event touched, in words a person can read — the invitee's email,
145
+ * the source's name, the schema type — resolved from the row's OWN metadata.
146
+ *
147
+ * Falls back to `type · id` only for an id a human can actually use (a PR
148
+ * number), and to the EMPTY STRING for an opaque one. Rendering nothing is
149
+ * strictly more informative than rendering a UUID.
150
+ */
151
+ export function activityTarget(event: ActivityEvent): string {
152
+ const meta = event.metadata ?? {};
153
+ for (const key of TARGET_DESCRIPTORS) {
154
+ const value = meta[key];
155
+ if (typeof value !== 'string') continue;
156
+ const text = value.trim();
157
+ if (text && !OPAQUE_ID.test(text)) return text;
158
+ }
159
+ const id = String(event.target_id ?? '').trim();
160
+ if (!id || OPAQUE_ID.test(id)) return '';
161
+ return [event.target_type, id].filter(Boolean).join(' · ');
162
+ }
163
+
164
+ /**
165
+ * The small pill next to the title — the thing that makes two otherwise
166
+ * identical rows different.
167
+ *
168
+ * A RESENT invite is the case that mattered: the same invitee appeared twice
169
+ * and the second row rendered identically to the first, so a correct trail read
170
+ * as a duplicated one. `metadata.resend` is the flag the writer already
171
+ * records; this is where it becomes visible. The lifecycle source notes explain
172
+ * a state the operator did not fire themselves (startsim-bq7s.1).
173
+ */
174
+ export function activityNote(event: ActivityEvent): string | undefined {
175
+ if (event.metadata?.resend === true) return 'resend';
176
+ return event.source ? SOURCE_NOTE[event.source] : undefined;
177
+ }
178
+
179
+ /**
180
+ * The PERSON behind a row — never an org.
181
+ *
182
+ * The display name is free text a user typed, so it can read exactly like the
183
+ * name of a foundry ("QA Foundry"), and a correct feed then looks like
184
+ * cross-tenant contamination. The email is the one unambiguous handle, so it is
185
+ * carried alongside the name rather than replaced by it; a `name` that is only
186
+ * the email echoed back is dropped, because saying it twice is not identity.
187
+ */
188
+ export function activityActor(event: ActivityEvent): ActivityTimelineActor | undefined {
189
+ const raw = event.actor ?? (event.actor_email ? { email: event.actor_email } : undefined);
190
+ if (!raw) return undefined;
191
+ const email = (raw.email ?? '').trim();
192
+ const name = (raw.name ?? '').trim();
193
+ return { ...raw, email: email || undefined, name: name && name !== email ? name : undefined };
194
+ }
195
+
116
196
  /** The body line for a row: the failure message, else the stated reason, else
117
- * what was touched. */
197
+ * what was touched, in words. */
118
198
  function detailOf(event: ActivityEvent): string {
119
199
  if (event.error) return event.error;
120
200
  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;
201
+ // A lifecycle transition already names its outcome in the TITLE, so echoing
202
+ // the target ("state · live") underneath it is noise. Derived from the row
203
+ // carrying a state, not from its action string.
204
+ if (event.state) return '';
205
+ return activityTarget(event);
123
206
  }
124
207
 
125
208
  /** Map any stream row onto the shared timeline's item shape. Nothing in here
@@ -129,17 +212,16 @@ export function toActivityTimelineItems(
129
212
  ): ActivityTimelineItem[] {
130
213
  return (events ?? []).map((event) => {
131
214
  const tone = activityTone(event);
132
- const actor = event.actor ?? (event.actor_email ? { email: event.actor_email } : undefined);
133
215
  const detail = detailOf(event);
134
216
  return {
135
217
  id: event.id,
136
218
  type: actionOf(event),
137
219
  title: activityLabel(event),
138
220
  occurredAt: event.created_at,
139
- actor: actor ?? undefined,
221
+ actor: activityActor(event),
140
222
  tone,
141
223
  subtitle: event.previous_state && event.state ? `from ${event.previous_state}` : undefined,
142
- note: event.source ? SOURCE_NOTE[event.source] : undefined,
224
+ note: activityNote(event),
143
225
  detail: detail || undefined,
144
226
  detailTone: event.error ? 'error' : 'muted',
145
227
  };
@@ -12,6 +12,11 @@
12
12
  * never seen rather than dropping or mislabelling it.
13
13
  */
14
14
  export { ActivityFeedPanel, type ActivityFeedPanelProps } from './ActivityFeedPanel';
15
+ export {
16
+ AccountActivityPanel,
17
+ ACCOUNT_ACTIVITY_QUERY_KEY,
18
+ type AccountActivityPanelProps,
19
+ } from './AccountActivityPanel';
15
20
  export {
16
21
  ACTIVITY_FEED_PANEL_DEFAULTS,
17
22
  type ActivityFeedPanelClassNames,
@@ -35,6 +40,9 @@ export {
35
40
  activityLabel,
36
41
  activityTone,
37
42
  activityHead,
43
+ activityActor,
44
+ activityNote,
45
+ activityTarget,
38
46
  toActivityTimelineItems,
39
47
  deriveActivityFilterOptions,
40
48
  } from './activity-events';