@startsimpli/ui 0.4.80 → 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 +3 -3
- package/src/components/ActivityTimeline.tsx +19 -2
- package/src/components/activity/ActivityFeedPanel.tsx +6 -1
- package/src/components/activity/__tests__/activity-labels.test.tsx +229 -0
- package/src/components/activity/activity-events.ts +84 -7
- package/src/components/activity/index.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
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/
|
|
47
|
-
"@startsimpli/
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
122
|
-
|
|
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:
|
|
216
|
+
actor: activityActor(event),
|
|
140
217
|
tone,
|
|
141
218
|
subtitle: event.previous_state && event.state ? `from ${event.previous_state}` : undefined,
|
|
142
|
-
note: event
|
|
219
|
+
note: activityNote(event),
|
|
143
220
|
detail: detail || undefined,
|
|
144
221
|
detailTone: event.error ? 'error' : 'muted',
|
|
145
222
|
};
|