From fb631d9d8ab786301ad219a95ff507bbbc013f8d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:45:14 +0000 Subject: [PATCH 01/12] feat(plugin-detail): fold consecutive same-actor update entries in the record activity timeline The sys_activity -> FeedItem mapper now carries the raw activity type, the row's metadata.kind, actor_id and changed_fields as optional FeedItem members. RecordActivityTimeline folds runs of adjacent 'updated' rows by one actor (at most 10 minutes apart) into one row with the entry count and the union of the changed fields' labels; the row expands to its entries. Claude-Session: https://claude.ai/code/session_01PoTpJPgXim1ybDgRyat4BY Co-authored-by: Claude --- ui/packages/i18n/src/locales/ar.ts | 18 + ui/packages/i18n/src/locales/de.ts | 10 + ui/packages/i18n/src/locales/en.ts | 10 + ui/packages/i18n/src/locales/es.ts | 12 + ui/packages/i18n/src/locales/fr.ts | 12 + ui/packages/i18n/src/locales/ja.ts | 10 + ui/packages/i18n/src/locales/ko.ts | 10 + ui/packages/i18n/src/locales/pt.ts | 12 + ui/packages/i18n/src/locales/ru.ts | 14 + ui/packages/i18n/src/locales/zh.ts | 10 + .../src/RecordActivityTimeline.tsx | 470 +++++++++++++----- .../src/renderers/recordActivityFeed.ts | 87 +++- .../plugin-detail/src/useDetailTranslation.ts | 12 + ui/packages/types/src/views.ts | 29 ++ 14 files changed, 566 insertions(+), 150 deletions(-) diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index ac057c1c960..26841188c0b 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -1353,6 +1353,24 @@ const ar = { edited: "(معدل)", via: "عبر {{source}}", viewSource: "عرض المصدر", + // objectstack#25959 — a run of consecutive updates folded into one feed row. + foldedUpdates: "تم تحديث {{fields}}", + foldedUpdateCount: "{{count}} تحديث", + foldedUpdateCount_zero: "{{count}} تحديث", + foldedUpdateCount_one: "{{count}} تحديث", + foldedUpdateCount_two: "تحديثان ({{count}})", + foldedUpdateCount_few: "{{count}} تحديثات", + foldedUpdateCount_many: "{{count}} تحديثًا", + foldedUpdateCount_other: "{{count}} تحديث", + foldedMoreFields: "{{count}} أخرى", + foldedMoreFields_zero: "{{count}} أخرى", + foldedMoreFields_one: "{{count}} أخرى", + foldedMoreFields_two: "{{count}} أخريان", + foldedMoreFields_few: "{{count}} أخرى", + foldedMoreFields_many: "{{count}} أخرى", + foldedMoreFields_other: "{{count}} أخرى", + expandFolded: "عرض كل تحديث", + collapseFolded: "إخفاء التحديثات", replyCount: "{{count}} رد", replyCount_zero: "{{count}} رد", replyCount_one: "{{count}} رد", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 2dca8512ae5..24f41672ae1 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -1215,6 +1215,16 @@ const de = { edited: "(bearbeitet)", via: "über {{source}}", viewSource: "Quelle anzeigen", + // objectstack#25959 — a run of consecutive updates folded into one feed row. + foldedUpdates: "{{fields}} aktualisiert", + foldedUpdateCount: "{{count}} Aktualisierung", + foldedUpdateCount_one: "{{count}} Aktualisierung", + foldedUpdateCount_other: "{{count}} Aktualisierungen", + foldedMoreFields: "{{count}} weiteres", + foldedMoreFields_one: "{{count}} weiteres", + foldedMoreFields_other: "{{count}} weitere", + expandFolded: "Jede Aktualisierung anzeigen", + collapseFolded: "Aktualisierungen ausblenden", replyCount: "{{count}} Antwort", replyCount_one: "{{count}} Antwort", replyCount_other: "{{count}} Antworten", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index ef2ecfa2f7c..bd09277e105 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -1584,6 +1584,16 @@ const en = { edited: '(edited)', via: 'via {{source}}', viewSource: 'View source', + // objectstack#25959 — a run of consecutive updates folded into one feed row. + foldedUpdates: 'Updated {{fields}}', + foldedUpdateCount: '{{count}} updates', + foldedUpdateCount_one: '{{count}} update', + foldedUpdateCount_other: '{{count}} updates', + foldedMoreFields: '{{count}} more', + foldedMoreFields_one: '{{count}} more', + foldedMoreFields_other: '{{count}} more', + expandFolded: 'Show each update', + collapseFolded: 'Hide updates', // Replies replyCount: '{{count}} reply', replyCount_one: '{{count}} reply', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 464ece759f5..1de6b7b607c 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -1251,6 +1251,18 @@ const es = { edited: "(editado)", via: "vía {{source}}", viewSource: "Ver origen", + // objectstack#25959 — a run of consecutive updates folded into one feed row. + foldedUpdates: "Actualizó {{fields}}", + foldedUpdateCount: "{{count}} actualización", + foldedUpdateCount_one: "{{count}} actualización", + foldedUpdateCount_many: "{{count}} de actualizaciones", + foldedUpdateCount_other: "{{count}} actualizaciones", + foldedMoreFields: "{{count}} más", + foldedMoreFields_one: "{{count}} más", + foldedMoreFields_many: "{{count}} más", + foldedMoreFields_other: "{{count}} más", + expandFolded: "Mostrar cada actualización", + collapseFolded: "Ocultar actualizaciones", replyCount: "{{count}} respuesta", replyCount_one: "{{count}} respuesta", replyCount_many: "{{count}} de respuestas", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 16c2d6bef70..0a67f5427bf 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -1248,6 +1248,18 @@ const fr = { edited: "(modifié)", via: "via {{source}}", viewSource: "Voir la source", + // objectstack#25959 — a run of consecutive updates folded into one feed row. + foldedUpdates: "A mis à jour {{fields}}", + foldedUpdateCount: "{{count}} mise à jour", + foldedUpdateCount_one: "{{count}} mise à jour", + foldedUpdateCount_many: "{{count}} de mises à jour", + foldedUpdateCount_other: "{{count}} mises à jour", + foldedMoreFields: "{{count}} autre", + foldedMoreFields_one: "{{count}} autre", + foldedMoreFields_many: "{{count}} d’autres", + foldedMoreFields_other: "{{count}} autres", + expandFolded: "Afficher chaque mise à jour", + collapseFolded: "Masquer les mises à jour", replyCount: "{{count}} réponse", replyCount_one: "{{count}} réponse", replyCount_many: "{{count}} de réponses", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index ae922186d1a..dbf95894fe4 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -1227,6 +1227,16 @@ const ja = { edited: "(編集済み)", via: "{{source}} 経由", viewSource: "ソースを表示", + // objectstack#25959 — a run of consecutive updates folded into one feed row. + foldedUpdates: "{{fields}} を更新", + foldedUpdateCount: "{{count}} 回", + foldedUpdateCount_one: "{{count}} 回", + foldedUpdateCount_other: "{{count}} 回", + foldedMoreFields: "他 {{count}} 件", + foldedMoreFields_one: "他 {{count}} 件", + foldedMoreFields_other: "他 {{count}} 件", + expandFolded: "各更新を表示", + collapseFolded: "更新を隠す", replyCount: "{{count}} 件の返信", replyCount_one: "{{count}} 件の返信", replyCount_other: "{{count}} 件の返信", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 4d2e691ec58..66e987c4f06 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -1215,6 +1215,16 @@ const ko = { edited: "(수정됨)", via: "{{source}} 통해", viewSource: "원본 보기", + // objectstack#25959 — a run of consecutive updates folded into one feed row. + foldedUpdates: "{{fields}} 업데이트", + foldedUpdateCount: "{{count}}회", + foldedUpdateCount_one: "{{count}}회", + foldedUpdateCount_other: "{{count}}회", + foldedMoreFields: "외 {{count}}개", + foldedMoreFields_one: "외 {{count}}개", + foldedMoreFields_other: "외 {{count}}개", + expandFolded: "각 업데이트 보기", + collapseFolded: "업데이트 숨기기", replyCount: "답글 {{count}}개", replyCount_one: "답글 {{count}}개", replyCount_other: "답글 {{count}}개", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 6947902e67b..9dd09a008d4 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -1248,6 +1248,18 @@ const pt = { edited: "(editado)", via: "via {{source}}", viewSource: "Ver origem", + // objectstack#25959 — a run of consecutive updates folded into one feed row. + foldedUpdates: "Atualizou {{fields}}", + foldedUpdateCount: "{{count}} atualização", + foldedUpdateCount_one: "{{count}} atualização", + foldedUpdateCount_many: "{{count}} de atualizações", + foldedUpdateCount_other: "{{count}} atualizações", + foldedMoreFields: "mais {{count}}", + foldedMoreFields_one: "mais {{count}}", + foldedMoreFields_many: "mais {{count}}", + foldedMoreFields_other: "mais {{count}}", + expandFolded: "Mostrar cada atualização", + collapseFolded: "Ocultar atualizações", replyCount: "{{count}} resposta", replyCount_one: "{{count}} resposta", replyCount_many: "{{count}} de respostas", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index bd5316c6919..2b9af9f5f21 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -1300,6 +1300,20 @@ const ru = { edited: "(изменено)", via: "через {{source}}", viewSource: "Показать источник", + // objectstack#25959 — a run of consecutive updates folded into one feed row. + foldedUpdates: "Обновлено: {{fields}}", + foldedUpdateCount: "{{count}} обновление", + foldedUpdateCount_one: "{{count}} обновление", + foldedUpdateCount_few: "{{count}} обновления", + foldedUpdateCount_many: "{{count}} обновлений", + foldedUpdateCount_other: "{{count}} обновления", + foldedMoreFields: "ещё {{count}}", + foldedMoreFields_one: "ещё {{count}}", + foldedMoreFields_few: "ещё {{count}}", + foldedMoreFields_many: "ещё {{count}}", + foldedMoreFields_other: "ещё {{count}}", + expandFolded: "Показать каждое обновление", + collapseFolded: "Скрыть обновления", replyCount: "{{count}} ответ", replyCount_one: "{{count}} ответ", replyCount_few: "{{count}} ответа", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index e7253201c8d..d041fe2309c 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -1328,6 +1328,16 @@ const zh = { edited: '(已编辑)', via: '通过 {{source}}', viewSource: '查看来源', + // objectstack#25959 — a run of consecutive updates folded into one feed row. + foldedUpdates: '更新了 {{fields}}', + foldedUpdateCount: '{{count}} 次', + foldedUpdateCount_one: '{{count}} 次', + foldedUpdateCount_other: '{{count}} 次', + foldedMoreFields: '其他 {{count}} 项', + foldedMoreFields_one: '其他 {{count}} 项', + foldedMoreFields_other: '其他 {{count}} 项', + expandFolded: '展开每次更新', + collapseFolded: '收起更新', // Replies replyCount: '{{count}} 条回复', replyCount_one: '{{count}} 条回复', diff --git a/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx b/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx index 6728eef7a2a..090a016a75e 100644 --- a/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx +++ b/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx @@ -37,6 +37,7 @@ import { RichTextCommentInput } from './RichTextCommentInput'; import { CommentAttachment, type Attachment } from './CommentAttachment'; import { CommentBody } from './CommentAuthorControls'; import { useDisplayLocale } from '@object-ui/i18n'; +import { useRecordContext, useSafeFieldLabel } from '@object-ui/react'; import { useDetailTranslation } from './useDetailTranslation'; /** @@ -241,6 +242,111 @@ function filterItems(items: FeedItem[], mode: FeedFilterMode): FeedItem[] { } } +/** + * The longest gap between two adjacent updates that still folds them into one + * row (objectstack#25959). Measured between NEIGHBOURS, not from the first + * entry, so a flow that writes the record every few minutes for an hour is one + * run, and a gap longer than this starts a new one. + */ +export const FOLD_WINDOW_MS = 10 * 60 * 1000; + +/** One row of the rendered timeline: a feed item, or a run of folded updates. */ +export type TimelineEntry = + | { kind: 'item'; item: FeedItem } + | { kind: 'fold'; id: string; items: FeedItem[] }; + +/** + * Whether a feed item is a plain update that may fold (objectstack#25959). + * + * Only an `updated` row read from `sys_activity`: `activityType` is set by + * `activityRowToFeedItem`, so a host item without it never folds. A row with a + * domain kind (`activityKind`, a fired milestone) is an event in its own + * right and never folds, and neither does a create, a delete, a comment or a + * completed task, whose `activityType` is not `updated`. A row whose summary + * is empty still folds and still counts. + */ +function isFoldableUpdate(item: FeedItem): boolean { + return ( + item.activityType === 'updated' + && item.type === 'field_change' + && item.activityKind === undefined + && !item.parentId + ); +} + +function foldTime(item: FeedItem): number { + return item.createdAt ? Date.parse(item.createdAt) : Number.NaN; +} + +/** + * Whether `next` continues the run `prev` ends: the same actor (`actorId`, + * `null` for the system, and the same display name, so two unnamed writers are + * never merged into one), and no more than {@link FOLD_WINDOW_MS} apart. + */ +function continuesRun(prev: FeedItem, next: FeedItem): boolean { + if ((prev.actorId ?? null) !== (next.actorId ?? null)) return false; + if (prev.actor !== next.actor) return false; + const a = foldTime(prev); + const b = foldTime(next); + if (Number.isNaN(a) || Number.isNaN(b)) return false; + return Math.abs(b - a) <= FOLD_WINDOW_MS; +} + +/** + * Fold runs of consecutive updates by one actor into single timeline rows + * (objectstack#25959). + * + * Rendering only. Every item stays in the feed and in its run, in order, so + * expanding a fold shows each entry as it was written; nothing is dropped or + * merged in the data. A run is adjacent items of the list as the reader sees + * it (after the filter), each a foldable update ({@link isFoldableUpdate}) that + * continues the one before it ({@link continuesRun}). Any other row between two + * updates ends the run. A run of one is an ordinary row. + */ +export function foldFeedItems(items: readonly FeedItem[]): TimelineEntry[] { + const entries: TimelineEntry[] = []; + let run: FeedItem[] = []; + const flush = () => { + if (run.length > 1) { + entries.push({ kind: 'fold', id: `fold:${String(run[0].id)}`, items: run }); + } else if (run.length === 1) { + entries.push({ kind: 'item', item: run[0] }); + } + run = []; + }; + for (const item of items) { + if (!isFoldableUpdate(item)) { + flush(); + entries.push({ kind: 'item', item }); + continue; + } + if (run.length > 0 && !continuesRun(run[run.length - 1], item)) flush(); + run.push(item); + } + flush(); + return entries; +} + +/** The distinct changed fields of a run, in first-seen order. */ +function changedFieldsOf(items: readonly FeedItem[]): string[] { + const seen = new Set(); + for (const item of items) { + for (const field of item.changedFields ?? []) seen.add(field); + } + return Array.from(seen); +} + +/** How many field labels a folded row names before it says "+N". */ +const FOLD_FIELDS_SHOWN = 3; + +function formatList(values: string[], locale: string): string { + try { + return new Intl.ListFormat(locale, { style: 'long', type: 'conjunction' }).format(values); + } catch { + return values.join(', '); + } +} + /** * RecordActivityTimeline — Unified timeline renderer for Airtable-style activity feeds. * @@ -355,6 +461,37 @@ export const RecordActivityTimeline: React.FC = ({ return map; }, [filtered, enableThreading]); + // Runs of consecutive updates by one actor, folded into one row each + // (objectstack#25959). Folded at render from the rows the reader sees; the + // feed itself keeps every entry. + const timelineEntries = React.useMemo(() => foldFeedItems(rootItems), [rootItems]); + // Which folded rows the reader has expanded, by the fold's id. + const [expandedFolds, setExpandedFolds] = React.useState>(() => new Set()); + const toggleFold = React.useCallback((id: string) => { + setExpandedFolds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + // A changed field's label: the object's field label where the page has the + // object schema (`RecordContextProvider`), through the same translated + // `fieldLabel` the record's own sections use; the machine name otherwise. + const recordContext = useRecordContext(); + const { fieldLabel } = useSafeFieldLabel(); + const labelOfField = React.useCallback( + (name: string): string => { + const objectName = recordContext?.objectName; + const def = (recordContext?.objectSchema as { fields?: Record } | undefined) + ?.fields?.[name]; + const authored = typeof def?.label === 'string' && def.label ? def.label : name; + return objectName ? fieldLabel(objectName, name, authored) : authored; + }, + [recordContext?.objectName, recordContext?.objectSchema, fieldLabel], + ); + const handleFilterChange = React.useCallback( (mode: FeedFilterMode) => { if (onFilterChange) { @@ -418,6 +555,208 @@ export const RecordActivityTimeline: React.FC = ({ } }, [onLoadMore]); + // One feed item's row. A folded run renders each of its entries with this + // same function when expanded (objectstack#25959). + const renderItem = (item: FeedItem): React.ReactNode => { + const Icon = FEED_TYPE_ICONS[item.type] || Zap; + const colorClass = + FEED_TYPE_COLORS[item.type] || 'bg-gray-100 text-gray-600'; + // Threading (objectui#12144). The reply LIST shows only under a + // `replyCount` above zero, the one threading gate: the host that + // read the rows sets it. The reply INPUT does not wait for a + // first reply, so a comment with none offers it wherever the + // host hands over `onAddReply`. + const replies = + (item.replyCount ?? 0) > 0 ? (repliesByParent.get(item.id) ?? NO_REPLIES) : NO_REPLIES; + const offersReply = !!onAddReply && canTakeReply(item); + // Undefined on a row that cannot store a reaction: no Add + // reaction button, and its chips render disabled (objectui#11035). + const toggleReaction = + enableReactions && onToggleReaction && canStoreReaction(item) + ? (emoji: string) => onToggleReaction(item.id, emoji) + : undefined; + + return ( +
+
+ {/* Icon */} +
+ {item.actorAvatarUrl ? ( + {item.actor} + ) : ( + + )} +
+ {/* Content */} +
+
+ {item.actor} + {item.source && ( + + {t('detail.via', { source: item.source })} + + )} + + {formatTimestamp(item.createdAt, t, displayLocale)} + + {item.edited && ( + {t('detail.edited')} + )} + {item.pinned && ( + 📌 {t('detail.pinned')} + )} +
+ + {/* Body text, and the author's Edit and Delete on their own + comment (objectui#12268). The host decides whose + comment it is (`authoredByViewer`) and hands each + handler over under the caller's grant; a row that + offers neither renders the same paragraph as before. */} + + + {/* Drill to source rich entity (ADR-0052 ActivityPointer) */} + {item.sourceObject && item.sourceId != null && ( + + {t('detail.viewSource')} + → + + )} + + {/* Field changes */} + {item.type === 'field_change' && item.fieldChanges && ( +
+ {item.fieldChanges.map((change, idx) => ( + + ))} +
+ )} + + {/* Reactions */} + {enableReactions && item.reactions && item.reactions.length > 0 && ( +
+ +
+ )} + + {/* Add reaction button (even if no reactions yet) */} + {(!item.reactions || item.reactions.length === 0) && toggleReaction && ( +
+ +
+ )} +
+
+ + {/* Threading */} + {enableThreading && (replies.length > 0 || offersReply) && ( + + )} +
+ ); + }; + + // A run of consecutive updates by one actor, as one row (objectstack#25959): + // who, when the latest entry was written, how many entries, and the union of + // the fields they changed. The toggle expands the run to its entries, each + // rendered exactly as an unfolded row. + const renderFold = (entry: Extract): React.ReactNode => { + const latest = entry.items[entry.items.length - 1]; + const count = entry.items.length; + const labels = changedFieldsOf(entry.items).map(labelOfField); + const shown = labels.slice(0, FOLD_FIELDS_SHOWN); + const rest = labels.length - shown.length; + const named = rest > 0 ? [...shown, t('detail.foldedMoreFields', { count: rest })] : shown; + // No field named on any entry (an unstamped row, or a reader served none + // of the fields) still reads as updates, and still counts. + const summary = named.length > 0 + ? t('detail.foldedUpdates', { fields: formatList(named, displayLocale) }) + : t('detail.activityUpdated'); + const expanded = expandedFolds.has(entry.id); + const Icon = FEED_TYPE_ICONS.field_change; + return ( +
+
+
+ {latest.actorAvatarUrl ? ( + {latest.actor} + ) : ( + + )} +
+
+
+ {latest.actor} + + {formatTimestamp(latest.createdAt, t, displayLocale)} + + + {t('detail.foldedUpdateCount', { count })} + +
+

{summary}

+ +
+
+ {expanded && ( +
+ {entry.items.map((item) => renderItem(item))} +
+ )} +
+ ); + }; + return (
= ({
- {rootItems.map((item) => { - const Icon = FEED_TYPE_ICONS[item.type] || Zap; - const colorClass = - FEED_TYPE_COLORS[item.type] || 'bg-gray-100 text-gray-600'; - // Threading (objectui#12144). The reply LIST shows only under a - // `replyCount` above zero, the one threading gate: the host that - // read the rows sets it. The reply INPUT does not wait for a - // first reply, so a comment with none offers it wherever the - // host hands over `onAddReply`. - const replies = - (item.replyCount ?? 0) > 0 ? (repliesByParent.get(item.id) ?? NO_REPLIES) : NO_REPLIES; - const offersReply = !!onAddReply && canTakeReply(item); - // Undefined on a row that cannot store a reaction: no Add - // reaction button, and its chips render disabled (objectui#11035). - const toggleReaction = - enableReactions && onToggleReaction && canStoreReaction(item) - ? (emoji: string) => onToggleReaction(item.id, emoji) - : undefined; - - return ( -
-
- {/* Icon */} -
- {item.actorAvatarUrl ? ( - {item.actor} - ) : ( - - )} -
- {/* Content */} -
-
- {item.actor} - {item.source && ( - - {t('detail.via', { source: item.source })} - - )} - - {formatTimestamp(item.createdAt, t, displayLocale)} - - {item.edited && ( - {t('detail.edited')} - )} - {item.pinned && ( - 📌 {t('detail.pinned')} - )} -
- - {/* Body text, and the author's Edit and Delete on their own - comment (objectui#12268). The host decides whose - comment it is (`authoredByViewer`) and hands each - handler over under the caller's grant; a row that - offers neither renders the same paragraph as before. */} - - - {/* Drill to source rich entity (ADR-0052 ActivityPointer) */} - {item.sourceObject && item.sourceId != null && ( - - {t('detail.viewSource')} - → - - )} - - {/* Field changes */} - {item.type === 'field_change' && item.fieldChanges && ( -
- {item.fieldChanges.map((change, idx) => ( - - ))} -
- )} - - {/* Reactions */} - {enableReactions && item.reactions && item.reactions.length > 0 && ( -
- -
- )} - - {/* Add reaction button (even if no reactions yet) */} - {(!item.reactions || item.reactions.length === 0) && toggleReaction && ( -
- -
- )} -
-
- - {/* Threading */} - {enableThreading && (replies.length > 0 || offersReply) && ( - - )} -
- ); - })} + {timelineEntries.map((entry) => + entry.kind === 'fold' ? renderFold(entry) : renderItem(entry.item), + )}
)} diff --git a/ui/packages/plugin-detail/src/renderers/recordActivityFeed.ts b/ui/packages/plugin-detail/src/renderers/recordActivityFeed.ts index 8b704626b0c..69019b408c2 100644 --- a/ui/packages/plugin-detail/src/renderers/recordActivityFeed.ts +++ b/ui/packages/plugin-detail/src/renderers/recordActivityFeed.ts @@ -375,38 +375,70 @@ function isLoggedInteraction(item: FeedItem): boolean { } /** - * Whether a `sys_activity` row carries the milestone marker, - * `metadata.kind === 'milestone'` (objectui#12106). + * A `sys_activity` row's domain kind, `metadata.kind` (ADR-0052 §5), or + * `undefined` when the row carries none. The milestone marker + * (objectui#12106) is the kind `'milestone'`; since objectstack#25959 the + * timeline's fold reads the kind too. * * `metadata` is a `textarea` column, declared as "JSON-serialized additional * context". plugin-audit writes it with `safeStringify`, the read-side * redaction re-serialises it, and the writer's own test reads the served row - * with `JSON.parse(r.metadata)`. So the marker is read from that JSON string + * with `JSON.parse(r.metadata)`. So the kind is read from that JSON string * and from nothing else. ⛔ Not from an already-parsed object: no served row * carries one, and reading both would be a second dialect (AGENTS.md #0.1). - * - * Every other value is "no marker": an absent or non-string `metadata`, a - * string that is not a JSON object, or a `kind` that is not `'milestone'` (an - * app may write its own kind, such as `'email'`). Such a row keeps today's - * reading. That includes every row a runtime from before objectstack#22771 - * serves, and every row the redaction served without the kind because the - * reader is not served a field the milestone watches. + * Only `kind` is read: the recorded `old` / `new` values in the same JSON are + * never looked at here. + * + * Every other value is "no kind": an absent or non-string `metadata`, a + * string that is not a JSON object, or a `kind` that is not a non-empty + * string. An app may write its own kind, such as `'email'`, and a row that is + * not a milestone keeps today's reading. That includes every row a runtime + * from before objectstack#22771 serves, and every row the redaction served + * without the kind because the reader is not served a field the milestone + * watches. */ -function isMilestoneActivityRow(row: SysActivityRow): boolean { +function activityKindOf(row: SysActivityRow): string | undefined { const raw = row?.metadata; - if (typeof raw !== 'string') return false; + if (typeof raw !== 'string') return undefined; let parsed: unknown; try { parsed = JSON.parse(raw); } catch { - return false; + return undefined; } - return ( - typeof parsed === 'object' - && parsed !== null - && !Array.isArray(parsed) - && (parsed as { kind?: unknown }).kind === ACTIVITY_MILESTONE_KIND - ); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined; + const kind = (parsed as { kind?: unknown }).kind; + return typeof kind === 'string' && kind ? kind : undefined; +} + +/** + * `sys_activity.actor_id` as an id, or `null` when the row names no user + * (objectstack#25959). A read that passes `$expand: ['actor_id']` gets the + * user's record in place of the id, so the id is that record's `id`; a row + * whose actor the engine kept as the bare id answers that id. + */ +function activityActorId(row: SysActivityRow): string | number | null { + const actor = row?.actor_id; + if (actor && typeof actor === 'object') { + const id = (actor as { id?: unknown }).id; + return typeof id === 'string' || typeof id === 'number' ? id : null; + } + return typeof actor === 'string' || typeof actor === 'number' ? actor : null; +} + +/** + * `sys_activity.changed_fields`, the field names the recorded change touched + * (objectstack#25959), or `undefined` when the row does not carry the column. + * + * It is a `tags` column, served as an array of machine names and already + * narrowed to the fields this reader may see (plugin-audit's + * `activity-changed-fields.ts`). `null` there means the row is not stamped + * yet, which is not the same answer as `[]`, so it is not carried either. + */ +function activityChangedFields(row: SysActivityRow): string[] | undefined { + const raw = row?.changed_fields; + if (!Array.isArray(raw)) return undefined; + return raw.filter((f): f is string => typeof f === 'string' && f.length > 0); } /** A row as `sys_activity` returns it. Loose on purpose: the block reads a @@ -949,11 +981,15 @@ export function activityRowToFeedItem( if (!known) warnUnknownActivityType(rawType); const mapped = known ? ACTIVITY_TYPE_TO_FEED_TYPE[rawType] : UNMAPPED_ACTIVITY_FEED_TYPE; if (!mapped) return null; - // The marker is read only for a row that would otherwise be hidden as - // completed, so every other row is mapped without parsing its `metadata`. - const feedType = COMPLETED_FEED_TYPES.has(mapped) && isMilestoneActivityRow(row) + // The kind is read for every row since objectstack#25959: the timeline's + // fold needs it to keep a milestone fired as an `updated` row out of a run + // of plain updates. The milestone override still applies only to a row that + // would otherwise be hidden as completed. + const activityKind = activityKindOf(row); + const feedType = COMPLETED_FEED_TYPES.has(mapped) && activityKind === ACTIVITY_MILESTONE_KIND ? MILESTONE_ACTIVITY_FEED_TYPE : mapped; + const changedFields = activityChangedFields(row); return { id: row.id as string | number, type: feedType, @@ -967,6 +1003,13 @@ export function activityRowToFeedItem( // source record (a `sys_email` row, a call/meeting task, …). sourceObject: row.source_object ?? undefined, sourceId: (row.source_id ?? undefined) as string | number | undefined, + // What the timeline's fold keys on (objectstack#25959). `type` above is + // the presentation, shared by five activity types; these say which row + // this is and who wrote it. + activityType: rawType, + ...(activityKind ? { activityKind } : {}), + actorId: activityActorId(row), + ...(changedFields ? { changedFields } : {}), }; } diff --git a/ui/packages/plugin-detail/src/useDetailTranslation.ts b/ui/packages/plugin-detail/src/useDetailTranslation.ts index a876b73e7bf..c85dc853479 100644 --- a/ui/packages/plugin-detail/src/useDetailTranslation.ts +++ b/ui/packages/plugin-detail/src/useDetailTranslation.ts @@ -208,6 +208,18 @@ export const DETAIL_DEFAULT_TRANSLATIONS: Record = { 'detail.deleteComment': 'Delete comment', 'detail.via': 'via {{source}}', 'detail.viewSource': 'View source', + // objectstack#25959 — a run of consecutive updates by one actor, folded into + // one feed row: its summary, its entry count, the "N more" tail of a long + // field list, and the expand toggle's two labels. + 'detail.foldedUpdates': 'Updated {{fields}}', + 'detail.foldedUpdateCount': '{{count}} updates', + 'detail.foldedUpdateCount_one': '{{count}} update', + 'detail.foldedUpdateCount_other': '{{count}} updates', + 'detail.foldedMoreFields': '{{count}} more', + 'detail.foldedMoreFields_one': '{{count}} more', + 'detail.foldedMoreFields_other': '{{count}} more', + 'detail.expandFolded': 'Show each update', + 'detail.collapseFolded': 'Hide updates', // Replies 'detail.replyCount': '{{count}} reply', 'detail.replyCount_one': '{{count}} reply', diff --git a/ui/packages/types/src/views.ts b/ui/packages/types/src/views.ts index 47f292b2b8c..785afe2da49 100644 --- a/ui/packages/types/src/views.ts +++ b/ui/packages/types/src/views.ts @@ -627,6 +627,35 @@ export interface FeedItem { */ sourceObject?: string; sourceId?: string | number; + /** + * The `sys_activity` row's own `type` (`updated`, `created`, …), set by + * `activityRowToFeedItem` (`@object-ui/plugin-detail`) on every item it maps + * (objectstack#25959). `type` above is the feed PRESENTATION, and it maps + * `created`, `updated`, `deleted`, `assigned` and `shared` all to + * `field_change`, so only this member tells an update from the rest. Absent + * on an item that was not read from `sys_activity`. + */ + activityType?: string; + /** + * The row's domain kind, `sys_activity.metadata.kind` (ADR-0052 §5): for + * example `'milestone'` on a row a declared `activityMilestones` entry + * fired. Set by the same mapper when the row carries one (objectstack#25959). + */ + activityKind?: string; + /** + * Who acted, as an id: `sys_activity.actor_id`, or `null` when the row names + * no user (a flow, a rollup or a seed, i.e. the system). Set by the same + * mapper on every item it maps (objectstack#25959). `actor` above is the + * display name. + */ + actorId?: string | number | null; + /** + * The machine names of the fields the recorded change touched: + * `sys_activity.changed_fields` as the server serves it, already narrowed to + * the fields this reader may see. Set by the same mapper when the row + * carries the column (objectstack#25959). Names only, never values. + */ + changedFields?: string[]; } /** From 7a6756b6bc7ac5fa553cd9531dbd25336df9d8c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:46:46 +0000 Subject: [PATCH 02/12] test(plugin-detail): pin the activity timeline fold; changeset Claude-Session: https://claude.ai/code/session_01PoTpJPgXim1ybDgRyat4BY Co-authored-by: Claude --- .changeset/25959-activity-fold.md | 16 ++ ...RecordActivityTimeline.fold-25959.test.tsx | 257 ++++++++++++++++++ ...dActivityFeed.milestoneKind-12106.test.tsx | 6 +- .../__tests__/recordActivityFeed.test.ts | 6 + 4 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 .changeset/25959-activity-fold.md create mode 100644 ui/packages/plugin-detail/src/__tests__/RecordActivityTimeline.fold-25959.test.tsx diff --git a/.changeset/25959-activity-fold.md b/.changeset/25959-activity-fold.md new file mode 100644 index 00000000000..3d5024a9148 --- /dev/null +++ b/.changeset/25959-activity-fold.md @@ -0,0 +1,16 @@ +--- +'@object-ui/types': patch +'@object-ui/plugin-detail': patch +'@object-ui/i18n': patch +--- + +feat(plugin-detail): the record activity timeline folds consecutive updates by one actor into one row with a count + +Clause-②: yes +Metadata-protocol: none + +- **Before.** Every `sys_activity` update was its own feed row. When a flow, a rollup or a seed updated a record repeatedly, the system's rows buried the people's comments: a demo campaign showed thirteen consecutive "System · Updated Campaign …" rows in its discussion feed. +- **Now.** `RecordActivityTimeline` folds a run of adjacent `updated` entries by the same actor (`actor_id`, `null` for the system, and the same display name), each no more than 10 minutes after the one before it, into one row. The row shows the actor, the time of the latest entry, the entry count and the union of the changed fields, by their field labels where the page has the object schema and by machine name otherwise (`Updated Number Sent, Leads, and Responses · 13 updates`). A toggle (`aria-expanded`) expands it to every entry. +- **Rendering only.** No entry is dropped or merged in the data, and the panel heading still counts every entry. Create, delete, assign, share, comment, completed and milestone rows never fold, and any of them between two updates ends the run. An entry without a summary still folds and still counts. Field names come from `sys_activity.changed_fields`, never from the recorded values. +- **`FeedItem` (`@object-ui/types`).** Four new optional members, set by `activityRowToFeedItem`: `activityType` (the row's own `sys_activity.type`), `activityKind` (`metadata.kind`, e.g. `'milestone'`), `actorId` and `changedFields`. A host item without them renders as before and never folds. +- **Strings.** `detail.foldedUpdates`, `detail.foldedUpdateCount`, `detail.foldedMoreFields`, `detail.expandFolded` and `detail.collapseFolded`, in all ten packs. diff --git a/ui/packages/plugin-detail/src/__tests__/RecordActivityTimeline.fold-25959.test.tsx b/ui/packages/plugin-detail/src/__tests__/RecordActivityTimeline.fold-25959.test.tsx new file mode 100644 index 00000000000..a656f81078a --- /dev/null +++ b/ui/packages/plugin-detail/src/__tests__/RecordActivityTimeline.fold-25959.test.tsx @@ -0,0 +1,257 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Consecutive update entries by one actor fold into one timeline row + * (objectstack#25959). + * + * Seen on HotCRM's demo campaign: thirteen consecutive "System · Updated + * Campaign …" rows, one per enrolled member, buried the human comments. The + * fold is rendering only: every entry stays in the feed and the folded row + * expands to them. Create, delete, comment and milestone rows never fold, and + * an entry with no summary still counts. + * + * Real subjects: `activityRowToFeedItem` (the mapper the record page and the + * `record:activity` block both use), `foldFeedItems` and + * `RecordActivityTimeline`. The rows are `sys_activity` rows as the record + * page reads them, with `actor_id` expanded. + */ + +import * as React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, within } from '@testing-library/react'; +import type { FeedItem } from '@object-ui/types'; +import { RecordContextProvider } from '@object-ui/react'; +import { RecordActivityTimeline, foldFeedItems, FOLD_WINDOW_MS } from '../RecordActivityTimeline'; +import { activityRowToFeedItem, type SysActivityRow } from '../renderers/recordActivityFeed'; +import { DETAIL_DEFAULT_TRANSLATIONS } from '../useDetailTranslation'; + +const EXPAND = DETAIL_DEFAULT_TRANSLATIONS['detail.expandFolded']; +const COLLAPSE = DETAIL_DEFAULT_TRANSLATIONS['detail.collapseFolded']; + +const T0 = Date.parse('2026-10-25T08:00:00.000Z'); +const at = (minutes: number) => new Date(T0 + minutes * 60_000).toISOString(); + +const ADA = { id: 'u-ada', name: 'Ada Lovelace' }; + +let seq = 0; +/** One `sys_activity` row. `actor_id: null` is the system (a flow, a rollup, a seed). */ +function row(over: Partial & { minute: number }): SysActivityRow { + const { minute, ...rest } = over; + seq += 1; + return { + id: `a-${seq}`, + type: 'updated', + summary: `Updated Campaign "CPG-0001" #${seq}`, + timestamp: at(minute), + actor_id: null, + changed_fields: ['num_sent'], + ...rest, + }; +} + +function mapAll(rows: SysActivityRow[]): FeedItem[] { + return rows.map((r) => activityRowToFeedItem(r, 'System')).filter((i): i is FeedItem => i !== null); +} + +const kinds = (items: FeedItem[]) => + foldFeedItems(items).map((e) => (e.kind === 'fold' ? `fold:${e.items.length}` : 'item')); + +afterEach(() => { + cleanup(); +}); + +describe('activityRowToFeedItem carries what the fold keys on (objectstack#25959)', () => { + it('carries the raw type, the actor id and the changed fields', () => { + const item = activityRowToFeedItem( + row({ minute: 0, actor_id: ADA, changed_fields: ['num_sent', 'responses'] }), + 'System', + ); + expect(item).toMatchObject({ + type: 'field_change', + activityType: 'updated', + actorId: 'u-ada', + changedFields: ['num_sent', 'responses'], + }); + expect(item?.activityKind).toBeUndefined(); + }); + + it('a bare `actor_id` is the id; a row naming no user is `null`', () => { + expect(activityRowToFeedItem(row({ minute: 0, actor_id: 'u-hidden' }), 'System')?.actorId).toBe('u-hidden'); + expect(activityRowToFeedItem(row({ minute: 0, actor_id: null }), 'System')?.actorId).toBeNull(); + expect(activityRowToFeedItem(row({ minute: 0, actor_id: undefined }), 'System')?.actorId).toBeNull(); + }); + + it('an unstamped row (`changed_fields` null) carries no field list, which is not the same as an empty one', () => { + expect(activityRowToFeedItem(row({ minute: 0, changed_fields: null }), 'System')?.changedFields).toBeUndefined(); + expect(activityRowToFeedItem(row({ minute: 0, changed_fields: [] }), 'System')?.changedFields).toEqual([]); + }); + + it('carries a milestone row\'s kind, from `metadata.kind` only', () => { + const milestone = row({ minute: 0, metadata: JSON.stringify({ kind: 'milestone', old: { a: 1 }, new: { a: 2 } }) }); + expect(activityRowToFeedItem(milestone, 'System')?.activityKind).toBe('milestone'); + }); +}); + +describe('foldFeedItems (objectstack#25959)', () => { + it('folds a run of adjacent updates by one actor, keeping every entry in order', () => { + const items = mapAll([row({ minute: 0 }), row({ minute: 1 }), row({ minute: 2 })]); + const entries = foldFeedItems(items); + expect(entries).toHaveLength(1); + expect(entries[0].kind).toBe('fold'); + expect(entries[0].kind === 'fold' && entries[0].items).toEqual(items); + }); + + it('a run of one is an ordinary row', () => { + expect(kinds(mapAll([row({ minute: 0 })]))).toEqual(['item']); + }); + + it('a different actor, or the system next to a person, ends the run', () => { + expect(kinds(mapAll([ + row({ minute: 0 }), + row({ minute: 1 }), + row({ minute: 2, actor_id: ADA }), + row({ minute: 3, actor_id: ADA }), + row({ minute: 4, actor_id: { id: 'u-grace', name: 'Grace' } }), + ]))).toEqual(['fold:2', 'fold:2', 'item']); + }); + + it('two rows naming no user under different names are not one actor', () => { + expect(kinds(mapAll([ + row({ minute: 0, actor_name: 'Nightly import' }), + row({ minute: 1, actor_name: 'Rollup' }), + ]))).toEqual(['item', 'item']); + }); + + it('a gap longer than the window between neighbours starts a new run', () => { + const window = FOLD_WINDOW_MS / 60_000; + expect(window).toBe(10); + expect(kinds(mapAll([ + row({ minute: 0 }), + row({ minute: window }), + row({ minute: 2 * window }), + row({ minute: 3 * window + 1 }), + row({ minute: 3 * window + 2 }), + ]))).toEqual(['fold:3', 'fold:2']); + }); + + it('create, delete, assign, share, completed and milestone rows never fold, and each ends a run', () => { + const neverFold = mapAll([ + row({ minute: 0, type: 'created' }), + row({ minute: 1, type: 'deleted' }), + row({ minute: 2, type: 'assigned' }), + row({ minute: 3, type: 'shared' }), + row({ minute: 4, type: 'completed' }), + row({ minute: 5, metadata: JSON.stringify({ kind: 'milestone' }) }), + row({ minute: 6, metadata: JSON.stringify({ kind: 'milestone' }) }), + ]); + expect(kinds(neverFold)).toEqual(['item', 'item', 'item', 'item', 'item', 'item', 'item']); + + expect(kinds(mapAll([ + row({ minute: 0 }), + row({ minute: 1, type: 'created' }), + row({ minute: 2 }), + ]))).toEqual(['item', 'item', 'item']); + }); + + it('a comment between two updates ends the run; a host item without an activity type never folds', () => { + const [u1, u2] = mapAll([row({ minute: 0 }), row({ minute: 2 })]); + const comment: FeedItem = { id: 'c1', type: 'comment', actor: 'System', body: 'Looks good', createdAt: at(1) }; + expect(kinds([u1, comment, u2])).toEqual(['item', 'item', 'item']); + const hostChange: FeedItem = { id: 'h1', type: 'field_change', actor: 'System', createdAt: at(3) }; + expect(kinds([hostChange, { ...hostChange, id: 'h2' }])).toEqual(['item', 'item']); + }); + + it('an entry without a summary still folds and still counts', () => { + expect(kinds(mapAll([row({ minute: 0, summary: null }), row({ minute: 1, summary: '' }), row({ minute: 2 })]))) + .toEqual(['fold:3']); + }); +}); + +/** Thirteen system updates, as on the campaign the card was filed from, then a person's comment. */ +function campaignFeed(): FeedItem[] { + const updates = mapAll( + Array.from({ length: 13 }, (_, i) => + row({ minute: i * 0.5, changed_fields: i % 2 ? ['num_sent', 'responses'] : ['num_sent', 'leads'] }), + ), + ); + const comment: FeedItem = { id: 'c-1', type: 'comment', actor: 'Grace', body: 'Kick-off went well', createdAt: at(30) }; + return [...updates, comment]; +} + +const CAMPAIGN_SCHEMA = { + name: 'crm_campaign', + fields: { + num_sent: { type: 'number', label: 'Number Sent' }, + responses: { type: 'number', label: 'Responses' }, + leads: { type: 'number', label: 'Leads' }, + }, +}; + +function mountInRecord(items: FeedItem[]) { + render( + + + , + ); +} + +describe('RecordActivityTimeline renders a folded run as one row (objectstack#25959)', () => { + it('thirteen system updates read as one row with the count and the union of field labels', () => { + mountInRecord(campaignFeed()); + const folds = screen.getAllByTestId('activity-fold'); + expect(folds).toHaveLength(1); + const fold = folds[0]; + expect(fold.getAttribute('data-fold-count')).toBe('13'); + expect(within(fold).getByTestId('activity-fold-count').textContent).toBe('13 updates'); + expect(within(fold).getByText('Updated Number Sent, Leads, and Responses')).toBeTruthy(); + // The individual summaries are not on screen until the row is expanded, + // and the person's comment is no longer buried. + expect(screen.queryByText(/Updated Campaign "CPG-0001"/)).toBeNull(); + expect(screen.getByText('Kick-off went well')).toBeTruthy(); + // The header still counts every entry: nothing was dropped. + expect(screen.getByRole('heading').textContent).toContain('(14)'); + }); + + it('the toggle expands the run to every entry and collapses it again, saying which it does', () => { + mountInRecord(campaignFeed()); + const toggle = screen.getByRole('button', { name: EXPAND }); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + fireEvent.click(toggle); + const expanded = screen.getByRole('button', { name: COLLAPSE }); + expect(expanded.getAttribute('aria-expanded')).toBe('true'); + expect(screen.getAllByText(/Updated Campaign "CPG-0001"/)).toHaveLength(13); + fireEvent.click(expanded); + expect(screen.queryByText(/Updated Campaign "CPG-0001"/)).toBeNull(); + }); + + it('outside a record page the field list falls back to machine names', () => { + render(); + expect(screen.getByText('Updated num_sent and responses')).toBeTruthy(); + }); + + it('a long field list names three and counts the rest', () => { + render( + , + ); + expect(screen.getByText('Updated a, b, c, and 2 more')).toBeTruthy(); + }); + + it('a run whose entries name no fields still reads as updates and still counts', () => { + render( + , + ); + const fold = screen.getByTestId('activity-fold'); + expect(within(fold).getByText(DETAIL_DEFAULT_TRANSLATIONS['detail.activityUpdated'])).toBeTruthy(); + expect(within(fold).getByTestId('activity-fold-count').textContent).toBe('2 updates'); + }); +}); diff --git a/ui/packages/plugin-detail/src/renderers/__tests__/recordActivityFeed.milestoneKind-12106.test.tsx b/ui/packages/plugin-detail/src/renderers/__tests__/recordActivityFeed.milestoneKind-12106.test.tsx index 35a8ffc09ea..d1f4b33f702 100644 --- a/ui/packages/plugin-detail/src/renderers/__tests__/recordActivityFeed.milestoneKind-12106.test.tsx +++ b/ui/packages/plugin-detail/src/renderers/__tests__/recordActivityFeed.milestoneKind-12106.test.tsx @@ -107,11 +107,13 @@ describe('the mapper reads the milestone marker (objectui#12106)', () => { expect(PRODUCED_FEED_TYPES.has(MILESTONE_ACTIVITY_FEED_TYPE)).toBe(true); }); - it('changes only the type: the rest of the item is built as before', () => { + it('changes only the type: the rest of the item is built as before, plus the kind it carries', () => { const marked = activityRowToFeedItem(CASE_RESOLVED, 'System'); const unmarked = activityRowToFeedItem({ ...CASE_RESOLVED, metadata: undefined }, 'System'); expect(unmarked?.type).toBe('task'); - expect(marked).toEqual({ ...unmarked, type: MILESTONE_ACTIVITY_FEED_TYPE }); + // `activityKind` is carried since objectstack#25959, so the timeline's fold + // can keep a milestone out of a run of plain updates. + expect(marked).toEqual({ ...unmarked, type: MILESTONE_ACTIVITY_FEED_TYPE, activityKind: 'milestone' }); }); it.each([ diff --git a/ui/packages/plugin-detail/src/renderers/__tests__/recordActivityFeed.test.ts b/ui/packages/plugin-detail/src/renderers/__tests__/recordActivityFeed.test.ts index 4a3bdad45cb..131cc92d132 100644 --- a/ui/packages/plugin-detail/src/renderers/__tests__/recordActivityFeed.test.ts +++ b/ui/packages/plugin-detail/src/renderers/__tests__/recordActivityFeed.test.ts @@ -194,6 +194,10 @@ describe('sys_activity row → FeedItem', () => { createdAt: '2026-03-04T05:06:07.000Z', sourceObject: 'sys_email', sourceId: 'email-9', + // objectstack#25959: what the timeline's fold keys on. A row with no + // `actor_id` names the system (`null`). + activityType: 'updated', + actorId: null, }); }); @@ -258,6 +262,8 @@ describe('a scheduled activity reaches the feed (objectui#5840)', () => { createdAt: '2026-04-01T09:00:00.000Z', sourceObject: 'crm_event', sourceId: 'evt-3', + activityType: 'scheduled', + actorId: null, }); }); From 03f749afe5d91775674b78e4aacfe6f521729ca2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:10:50 +0000 Subject: [PATCH 03/12] refactor(plugin-detail): move the activity fold into the feed's pure module Keeps RecordActivityTimeline.tsx a components-only module (react-refresh). Claude-Session: https://claude.ai/code/session_01PoTpJPgXim1ybDgRyat4BY Co-authored-by: Claude --- .../src/RecordActivityTimeline.tsx | 86 +---------------- ...RecordActivityTimeline.fold-25959.test.tsx | 4 +- .../src/renderers/recordActivityFeed.ts | 92 ++++++++++++++++++- 3 files changed, 93 insertions(+), 89 deletions(-) diff --git a/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx b/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx index 090a016a75e..e34aedace96 100644 --- a/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx +++ b/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx @@ -39,6 +39,7 @@ import { CommentBody } from './CommentAuthorControls'; import { useDisplayLocale } from '@object-ui/i18n'; import { useRecordContext, useSafeFieldLabel } from '@object-ui/react'; import { useDetailTranslation } from './useDetailTranslation'; +import { foldFeedItems, type TimelineEntry } from './renderers/recordActivityFeed'; /** * Which slice of the record feed the timeline shows. @@ -242,91 +243,6 @@ function filterItems(items: FeedItem[], mode: FeedFilterMode): FeedItem[] { } } -/** - * The longest gap between two adjacent updates that still folds them into one - * row (objectstack#25959). Measured between NEIGHBOURS, not from the first - * entry, so a flow that writes the record every few minutes for an hour is one - * run, and a gap longer than this starts a new one. - */ -export const FOLD_WINDOW_MS = 10 * 60 * 1000; - -/** One row of the rendered timeline: a feed item, or a run of folded updates. */ -export type TimelineEntry = - | { kind: 'item'; item: FeedItem } - | { kind: 'fold'; id: string; items: FeedItem[] }; - -/** - * Whether a feed item is a plain update that may fold (objectstack#25959). - * - * Only an `updated` row read from `sys_activity`: `activityType` is set by - * `activityRowToFeedItem`, so a host item without it never folds. A row with a - * domain kind (`activityKind`, a fired milestone) is an event in its own - * right and never folds, and neither does a create, a delete, a comment or a - * completed task, whose `activityType` is not `updated`. A row whose summary - * is empty still folds and still counts. - */ -function isFoldableUpdate(item: FeedItem): boolean { - return ( - item.activityType === 'updated' - && item.type === 'field_change' - && item.activityKind === undefined - && !item.parentId - ); -} - -function foldTime(item: FeedItem): number { - return item.createdAt ? Date.parse(item.createdAt) : Number.NaN; -} - -/** - * Whether `next` continues the run `prev` ends: the same actor (`actorId`, - * `null` for the system, and the same display name, so two unnamed writers are - * never merged into one), and no more than {@link FOLD_WINDOW_MS} apart. - */ -function continuesRun(prev: FeedItem, next: FeedItem): boolean { - if ((prev.actorId ?? null) !== (next.actorId ?? null)) return false; - if (prev.actor !== next.actor) return false; - const a = foldTime(prev); - const b = foldTime(next); - if (Number.isNaN(a) || Number.isNaN(b)) return false; - return Math.abs(b - a) <= FOLD_WINDOW_MS; -} - -/** - * Fold runs of consecutive updates by one actor into single timeline rows - * (objectstack#25959). - * - * Rendering only. Every item stays in the feed and in its run, in order, so - * expanding a fold shows each entry as it was written; nothing is dropped or - * merged in the data. A run is adjacent items of the list as the reader sees - * it (after the filter), each a foldable update ({@link isFoldableUpdate}) that - * continues the one before it ({@link continuesRun}). Any other row between two - * updates ends the run. A run of one is an ordinary row. - */ -export function foldFeedItems(items: readonly FeedItem[]): TimelineEntry[] { - const entries: TimelineEntry[] = []; - let run: FeedItem[] = []; - const flush = () => { - if (run.length > 1) { - entries.push({ kind: 'fold', id: `fold:${String(run[0].id)}`, items: run }); - } else if (run.length === 1) { - entries.push({ kind: 'item', item: run[0] }); - } - run = []; - }; - for (const item of items) { - if (!isFoldableUpdate(item)) { - flush(); - entries.push({ kind: 'item', item }); - continue; - } - if (run.length > 0 && !continuesRun(run[run.length - 1], item)) flush(); - run.push(item); - } - flush(); - return entries; -} - /** The distinct changed fields of a run, in first-seen order. */ function changedFieldsOf(items: readonly FeedItem[]): string[] { const seen = new Set(); diff --git a/ui/packages/plugin-detail/src/__tests__/RecordActivityTimeline.fold-25959.test.tsx b/ui/packages/plugin-detail/src/__tests__/RecordActivityTimeline.fold-25959.test.tsx index a656f81078a..3c509e56e46 100644 --- a/ui/packages/plugin-detail/src/__tests__/RecordActivityTimeline.fold-25959.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/RecordActivityTimeline.fold-25959.test.tsx @@ -27,8 +27,8 @@ import { describe, it, expect, afterEach } from 'vitest'; import { render, screen, cleanup, fireEvent, within } from '@testing-library/react'; import type { FeedItem } from '@object-ui/types'; import { RecordContextProvider } from '@object-ui/react'; -import { RecordActivityTimeline, foldFeedItems, FOLD_WINDOW_MS } from '../RecordActivityTimeline'; -import { activityRowToFeedItem, type SysActivityRow } from '../renderers/recordActivityFeed'; +import { RecordActivityTimeline } from '../RecordActivityTimeline'; +import { activityRowToFeedItem, foldFeedItems, FOLD_WINDOW_MS, type SysActivityRow } from '../renderers/recordActivityFeed'; import { DETAIL_DEFAULT_TRANSLATIONS } from '../useDetailTranslation'; const EXPAND = DETAIL_DEFAULT_TRANSLATIONS['detail.expandFolded']; diff --git a/ui/packages/plugin-detail/src/renderers/recordActivityFeed.ts b/ui/packages/plugin-detail/src/renderers/recordActivityFeed.ts index 69019b408c2..8c8518256de 100644 --- a/ui/packages/plugin-detail/src/renderers/recordActivityFeed.ts +++ b/ui/packages/plugin-detail/src/renderers/recordActivityFeed.ts @@ -7,7 +7,7 @@ * * `record:activity` — the pure half (objectui#3165). * - * Three things live here, all testable without a DOM: + * Four things live here, all testable without a DOM: * * 1. the `sys_activity` row → {@link FeedItem} map the block's self-fetch * uses (the same map `RecordDetailView` applies to the rows it merges @@ -17,7 +17,10 @@ * / `limit`) into observable behaviour; * 3. {@link isRefusedFeedRead}, which tells a REFUSED feed read from an empty * or failed one. The block's self-fetch and `RecordDetailView`'s two feed - * reads use the same verdict (objectui#11195). + * reads use the same verdict (objectui#11195); + * 4. {@link foldFeedItems}, which folds a run of consecutive updates by one + * actor into one timeline row (objectstack#25959). `RecordActivityTimeline` + * renders its answer. * * (2) exists as its own function on purpose. Before #3165 every one of those * inputs was a filter over a feed that was hard-coded empty — declared, @@ -1116,6 +1119,91 @@ export function applyFeedConfig( return { items: page, total, hasMore: total > size }; } +/** + * The longest gap between two adjacent updates that still folds them into one + * row (objectstack#25959). Measured between NEIGHBOURS, not from the first + * entry, so a flow that writes the record every few minutes for an hour is one + * run, and a gap longer than this starts a new one. + */ +export const FOLD_WINDOW_MS = 10 * 60 * 1000; + +/** One row of the rendered timeline: a feed item, or a run of folded updates. */ +export type TimelineEntry = + | { kind: 'item'; item: FeedItem } + | { kind: 'fold'; id: string; items: FeedItem[] }; + +/** + * Whether a feed item is a plain update that may fold (objectstack#25959). + * + * Only an `updated` row read from `sys_activity`: `activityType` is set by + * `activityRowToFeedItem`, so a host item without it never folds. A row with a + * domain kind (`activityKind`, a fired milestone) is an event in its own + * right and never folds, and neither does a create, a delete, a comment or a + * completed task, whose `activityType` is not `updated`. A row whose summary + * is empty still folds and still counts. + */ +function isFoldableUpdate(item: FeedItem): boolean { + return ( + item.activityType === 'updated' + && item.type === 'field_change' + && item.activityKind === undefined + && !item.parentId + ); +} + +function foldTime(item: FeedItem): number { + return item.createdAt ? Date.parse(item.createdAt) : Number.NaN; +} + +/** + * Whether `next` continues the run `prev` ends: the same actor (`actorId`, + * `null` for the system, and the same display name, so two unnamed writers are + * never merged into one), and no more than {@link FOLD_WINDOW_MS} apart. + */ +function continuesRun(prev: FeedItem, next: FeedItem): boolean { + if ((prev.actorId ?? null) !== (next.actorId ?? null)) return false; + if (prev.actor !== next.actor) return false; + const a = foldTime(prev); + const b = foldTime(next); + if (Number.isNaN(a) || Number.isNaN(b)) return false; + return Math.abs(b - a) <= FOLD_WINDOW_MS; +} + +/** + * Fold runs of consecutive updates by one actor into single timeline rows + * (objectstack#25959). + * + * Rendering only. Every item stays in the feed and in its run, in order, so + * expanding a fold shows each entry as it was written; nothing is dropped or + * merged in the data. A run is adjacent items of the list as the reader sees + * it (after the filter), each a foldable update ({@link isFoldableUpdate}) that + * continues the one before it ({@link continuesRun}). Any other row between two + * updates ends the run. A run of one is an ordinary row. + */ +export function foldFeedItems(items: readonly FeedItem[]): TimelineEntry[] { + const entries: TimelineEntry[] = []; + let run: FeedItem[] = []; + const flush = () => { + if (run.length > 1) { + entries.push({ kind: 'fold', id: `fold:${String(run[0].id)}`, items: run }); + } else if (run.length === 1) { + entries.push({ kind: 'item', item: run[0] }); + } + run = []; + }; + for (const item of items) { + if (!isFoldableUpdate(item)) { + flush(); + entries.push({ kind: 'item', item }); + continue; + } + if (run.length > 0 && !continuesRun(run[run.length - 1], item)) flush(); + run.push(item); + } + flush(); + return entries; +} + /** Sort a feed chronologically and de-duplicate by id (feeds are append-only). */ export function mergeFeedItems(...groups: readonly FeedItem[][]): FeedItem[] { const byId = new Map(); From a9d3183c86be7ee008e10d43d968fadab752475d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:30:21 +0000 Subject: [PATCH 04/12] fix(plugin-detail): join the folded row's field list with the pack's list punctuation Intl.ListFormat is outside the package's TS lib, and the repo measured and rejected it for field lists (no separator in zh/ru unit styles). Claude-Session: https://claude.ai/code/session_01PoTpJPgXim1ybDgRyat4BY Co-authored-by: Claude --- .changeset/25959-activity-fold.md | 2 +- .../plugin-detail/src/RecordActivityTimeline.tsx | 13 ++++--------- .../RecordActivityTimeline.fold-25959.test.tsx | 6 +++--- .../plugin-detail/src/useDetailTranslation.ts | 3 +++ 4 files changed, 11 insertions(+), 13 deletions(-) diff --git a/.changeset/25959-activity-fold.md b/.changeset/25959-activity-fold.md index 3d5024a9148..24cc54980ce 100644 --- a/.changeset/25959-activity-fold.md +++ b/.changeset/25959-activity-fold.md @@ -10,7 +10,7 @@ Clause-②: yes Metadata-protocol: none - **Before.** Every `sys_activity` update was its own feed row. When a flow, a rollup or a seed updated a record repeatedly, the system's rows buried the people's comments: a demo campaign showed thirteen consecutive "System · Updated Campaign …" rows in its discussion feed. -- **Now.** `RecordActivityTimeline` folds a run of adjacent `updated` entries by the same actor (`actor_id`, `null` for the system, and the same display name), each no more than 10 minutes after the one before it, into one row. The row shows the actor, the time of the latest entry, the entry count and the union of the changed fields, by their field labels where the page has the object schema and by machine name otherwise (`Updated Number Sent, Leads, and Responses · 13 updates`). A toggle (`aria-expanded`) expands it to every entry. +- **Now.** `RecordActivityTimeline` folds a run of adjacent `updated` entries by the same actor (`actor_id`, `null` for the system, and the same display name), each no more than 10 minutes after the one before it, into one row. The row shows the actor, the time of the latest entry, the entry count and the union of the changed fields, by their field labels where the page has the object schema and by machine name otherwise (`Updated Number Sent, Leads, Responses · 13 updates`). A toggle (`aria-expanded`) expands it to every entry. - **Rendering only.** No entry is dropped or merged in the data, and the panel heading still counts every entry. Create, delete, assign, share, comment, completed and milestone rows never fold, and any of them between two updates ends the run. An entry without a summary still folds and still counts. Field names come from `sys_activity.changed_fields`, never from the recorded values. - **`FeedItem` (`@object-ui/types`).** Four new optional members, set by `activityRowToFeedItem`: `activityType` (the row's own `sys_activity.type`), `activityKind` (`metadata.kind`, e.g. `'milestone'`), `actorId` and `changedFields`. A host item without them renders as before and never folds. - **Strings.** `detail.foldedUpdates`, `detail.foldedUpdateCount`, `detail.foldedMoreFields`, `detail.expandFolded` and `detail.collapseFolded`, in all ten packs. diff --git a/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx b/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx index e34aedace96..a7bec93c9bf 100644 --- a/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx +++ b/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx @@ -255,14 +255,6 @@ function changedFieldsOf(items: readonly FeedItem[]): string[] { /** How many field labels a folded row names before it says "+N". */ const FOLD_FIELDS_SHOWN = 3; -function formatList(values: string[], locale: string): string { - try { - return new Intl.ListFormat(locale, { style: 'long', type: 'conjunction' }).format(values); - } catch { - return values.join(', '); - } -} - /** * RecordActivityTimeline — Unified timeline renderer for Airtable-style activity feeds. * @@ -615,8 +607,11 @@ export const RecordActivityTimeline: React.FC = ({ const named = rest > 0 ? [...shown, t('detail.foldedMoreFields', { count: rest })] : shown; // No field named on any entry (an unstamped row, or a reader served none // of the fields) still reads as updates, and still counts. + // Joined with the pack's list punctuation, the key the form's field lists + // use: `Intl.ListFormat` was measured and rejected there, because its + // `unit` styles join zh and ru with no separator at all. const summary = named.length > 0 - ? t('detail.foldedUpdates', { fields: formatList(named, displayLocale) }) + ? t('detail.foldedUpdates', { fields: named.join(t('validation.formInvalidJoiner')) }) : t('detail.activityUpdated'); const expanded = expandedFolds.has(entry.id); const Icon = FEED_TYPE_ICONS.field_change; diff --git a/ui/packages/plugin-detail/src/__tests__/RecordActivityTimeline.fold-25959.test.tsx b/ui/packages/plugin-detail/src/__tests__/RecordActivityTimeline.fold-25959.test.tsx index 3c509e56e46..e7672a9c831 100644 --- a/ui/packages/plugin-detail/src/__tests__/RecordActivityTimeline.fold-25959.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/RecordActivityTimeline.fold-25959.test.tsx @@ -209,7 +209,7 @@ describe('RecordActivityTimeline renders a folded run as one row (objectstack#25 const fold = folds[0]; expect(fold.getAttribute('data-fold-count')).toBe('13'); expect(within(fold).getByTestId('activity-fold-count').textContent).toBe('13 updates'); - expect(within(fold).getByText('Updated Number Sent, Leads, and Responses')).toBeTruthy(); + expect(within(fold).getByText('Updated Number Sent, Leads, Responses')).toBeTruthy(); // The individual summaries are not on screen until the row is expanded, // and the person's comment is no longer buried. expect(screen.queryByText(/Updated Campaign "CPG-0001"/)).toBeNull(); @@ -232,7 +232,7 @@ describe('RecordActivityTimeline renders a folded run as one row (objectstack#25 it('outside a record page the field list falls back to machine names', () => { render(); - expect(screen.getByText('Updated num_sent and responses')).toBeTruthy(); + expect(screen.getByText('Updated num_sent, responses')).toBeTruthy(); }); it('a long field list names three and counts the rest', () => { @@ -241,7 +241,7 @@ describe('RecordActivityTimeline renders a folded run as one row (objectstack#25 items={mapAll([row({ minute: 0, changed_fields: ['a', 'b', 'c'] }), row({ minute: 1, changed_fields: ['d', 'e'] })])} />, ); - expect(screen.getByText('Updated a, b, c, and 2 more')).toBeTruthy(); + expect(screen.getByText('Updated a, b, c, 2 more')).toBeTruthy(); }); it('a run whose entries name no fields still reads as updates and still counts', () => { diff --git a/ui/packages/plugin-detail/src/useDetailTranslation.ts b/ui/packages/plugin-detail/src/useDetailTranslation.ts index c85dc853479..a887e3312c6 100644 --- a/ui/packages/plugin-detail/src/useDetailTranslation.ts +++ b/ui/packages/plugin-detail/src/useDetailTranslation.ts @@ -220,6 +220,9 @@ export const DETAIL_DEFAULT_TRANSLATIONS: Record = { 'detail.foldedMoreFields_other': '{{count}} more', 'detail.expandFolded': 'Show each update', 'detail.collapseFolded': 'Hide updates', + // …and the punctuation that joins its field list: the shared list key the + // form's field lists already use, not a second spelling of it. + 'validation.formInvalidJoiner': ', ', // Replies 'detail.replyCount': '{{count}} reply', 'detail.replyCount_one': '{{count}} reply', From d91b6ddfafa047179d5c802a71de6f57610cd796 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:33:06 +0000 Subject: [PATCH 05/12] chore(sdui): re-record the manifest record for the activity fold Claude-Session: https://claude.ai/code/session_01PoTpJPgXim1ybDgRyat4BY Co-authored-by: Claude --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 1555759c7e3..f58a129c92a 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "ad9e0875b83f339e03c528fc3be774592615dfdc5ee5fe9becc4376ddb5f0d52" + "registrySourcesSha256": "44f3c398f7a83775aa50b91826ea1df799de164d0bbadd79b4c4ac57b49cc71e" } } From 1c8752a096577b60d6c99b2bd797dcc31205596c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:01:21 +0000 Subject: [PATCH 06/12] chore(sdui): re-record the manifest record after merging main Claude-Session: https://claude.ai/code/session_01PoTpJPgXim1ybDgRyat4BY Co-authored-by: Claude --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index f65f470a705..1fab29b1a49 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "3fe916234f1b5346bff70f6f99322077630b851ebef81c9ae6ba8c4ac08e2063" + "registrySourcesSha256": "a82df14b00c160f2587106ac39b0236f088092d9b7fff39035a022f6a359c427" } } From 15e8210d9eb0b31092fd9f5f449a8f5a588ade3b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:37:11 +0000 Subject: [PATCH 07/12] fix(plugin-detail): name the fold's entry type ActivityTimelineEntry TimelineEntry is already exported by app-shell (buildDebugApi.ts); the one-authority-per-exported-name pin refuses a second declaration. Claude-Session: https://claude.ai/code/session_01PoTpJPgXim1ybDgRyat4BY Co-authored-by: Claude --- ui/packages/plugin-detail/src/RecordActivityTimeline.tsx | 4 ++-- .../plugin-detail/src/renderers/recordActivityFeed.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx b/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx index a7bec93c9bf..640d05a05c7 100644 --- a/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx +++ b/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx @@ -39,7 +39,7 @@ import { CommentBody } from './CommentAuthorControls'; import { useDisplayLocale } from '@object-ui/i18n'; import { useRecordContext, useSafeFieldLabel } from '@object-ui/react'; import { useDetailTranslation } from './useDetailTranslation'; -import { foldFeedItems, type TimelineEntry } from './renderers/recordActivityFeed'; +import { foldFeedItems, type ActivityTimelineEntry } from './renderers/recordActivityFeed'; /** * Which slice of the record feed the timeline shows. @@ -598,7 +598,7 @@ export const RecordActivityTimeline: React.FC = ({ // who, when the latest entry was written, how many entries, and the union of // the fields they changed. The toggle expands the run to its entries, each // rendered exactly as an unfolded row. - const renderFold = (entry: Extract): React.ReactNode => { + const renderFold = (entry: Extract): React.ReactNode => { const latest = entry.items[entry.items.length - 1]; const count = entry.items.length; const labels = changedFieldsOf(entry.items).map(labelOfField); diff --git a/ui/packages/plugin-detail/src/renderers/recordActivityFeed.ts b/ui/packages/plugin-detail/src/renderers/recordActivityFeed.ts index 8c8518256de..d2735e022af 100644 --- a/ui/packages/plugin-detail/src/renderers/recordActivityFeed.ts +++ b/ui/packages/plugin-detail/src/renderers/recordActivityFeed.ts @@ -1128,7 +1128,7 @@ export function applyFeedConfig( export const FOLD_WINDOW_MS = 10 * 60 * 1000; /** One row of the rendered timeline: a feed item, or a run of folded updates. */ -export type TimelineEntry = +export type ActivityTimelineEntry = | { kind: 'item'; item: FeedItem } | { kind: 'fold'; id: string; items: FeedItem[] }; @@ -1180,8 +1180,8 @@ function continuesRun(prev: FeedItem, next: FeedItem): boolean { * continues the one before it ({@link continuesRun}). Any other row between two * updates ends the run. A run of one is an ordinary row. */ -export function foldFeedItems(items: readonly FeedItem[]): TimelineEntry[] { - const entries: TimelineEntry[] = []; +export function foldFeedItems(items: readonly FeedItem[]): ActivityTimelineEntry[] { + const entries: ActivityTimelineEntry[] = []; let run: FeedItem[] = []; const flush = () => { if (run.length > 1) { From ed63e3c5192fe72b24c6606d282eb83272f8a788 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:40:55 +0000 Subject: [PATCH 08/12] chore(sdui): re-record the manifest record after the rename Claude-Session: https://claude.ai/code/session_01PoTpJPgXim1ybDgRyat4BY Co-authored-by: Claude --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 1fab29b1a49..76437526aa3 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "a82df14b00c160f2587106ac39b0236f088092d9b7fff39035a022f6a359c427" + "registrySourcesSha256": "ae54f593092bdcab8e3a9aa66d46bbdc626ae71d1953313d4ec33c91bdc1d18d" } } From 65612daaf3cda2ce3ce6db7de1475a2a4b802328 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 11:09:38 +0000 Subject: [PATCH 09/12] chore(sdui): re-record the manifest record after merging main Claude-Session: https://claude.ai/code/session_01PoTpJPgXim1ybDgRyat4BY Co-authored-by: Claude --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 684a23dffc1..bd38c76e26f 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "0e127be18a1104d857822dc5c71a6380e4f00b0db729cd8b8a230a11c120fe78" + "registrySourcesSha256": "d7aca769a2ade07d7ed89e1d52a206e96f26122fd9c497830c2dfb4cff7795fa" } } From f0e8d84b302303425733a38a499d091cf201da23 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 19:37:40 +0000 Subject: [PATCH 10/12] chore(sdui): re-record the plugin-detail, i18n and types source records after merging main Claude-Session: https://claude.ai/code/session_01PoTpJPgXim1ybDgRyat4BY Co-authored-by: Claude --- scripts/sdui-manifest.sources/i18n.json | 2 +- scripts/sdui-manifest.sources/plugin-detail.json | 2 +- scripts/sdui-manifest.sources/types.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/sdui-manifest.sources/i18n.json b/scripts/sdui-manifest.sources/i18n.json index 9acdc472ef8..cd9a7cf5779 100644 --- a/scripts/sdui-manifest.sources/i18n.json +++ b/scripts/sdui-manifest.sources/i18n.json @@ -1 +1 @@ -{"package":"ui/packages/i18n","sources":31,"sha256":"7e7907af7823796538ad5a70e56fb172dc533ec3988a2318c8aeddf7069af14a","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/i18n","sources":31,"sha256":"b9b5c5779caf80f36364d6188c2952727ec1d62ba4cced75e4a58044dbedcd4a","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/scripts/sdui-manifest.sources/plugin-detail.json b/scripts/sdui-manifest.sources/plugin-detail.json index 053011d42f8..7d009b67eb2 100644 --- a/scripts/sdui-manifest.sources/plugin-detail.json +++ b/scripts/sdui-manifest.sources/plugin-detail.json @@ -1 +1 @@ -{"package":"ui/packages/plugin-detail","sources":54,"sha256":"e22491dc392ede00c47cdea1d193d7c03c9c01751e65ee851adb17f2038b7ad2","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/plugin-detail","sources":54,"sha256":"3a6fffd41940e71d2402028d44c078cf9cdb7e9428120b180af072491ecfa2a9","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/scripts/sdui-manifest.sources/types.json b/scripts/sdui-manifest.sources/types.json index b4a5258ce1e..7790621d945 100644 --- a/scripts/sdui-manifest.sources/types.json +++ b/scripts/sdui-manifest.sources/types.json @@ -1 +1 @@ -{"package":"ui/packages/types","sources":81,"sha256":"30832d40db85588aa490e7d3f7b2e2ff689071f4e84176bcab96bacf74060d4f","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/types","sources":81,"sha256":"2f80c316cdd86f59c62a034d4ddf65ffd99ef1d36909cc00af790ea3226eeb81","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} From 7b3dc299298fb4288595866f414a3792e4b54364 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 20:57:11 +0000 Subject: [PATCH 11/12] chore(sdui): re-record the i18n and types source records after merging main Claude-Session: https://claude.ai/code/session_01PoTpJPgXim1ybDgRyat4BY Co-authored-by: Claude --- scripts/sdui-manifest.sources/i18n.json | 2 +- scripts/sdui-manifest.sources/types.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/sdui-manifest.sources/i18n.json b/scripts/sdui-manifest.sources/i18n.json index 0c252a718c8..1c103456f58 100644 --- a/scripts/sdui-manifest.sources/i18n.json +++ b/scripts/sdui-manifest.sources/i18n.json @@ -1 +1 @@ -{"package":"ui/packages/i18n","sources":31,"sha256":"83894d7c91938618b320bda4f6664c1b39a0d5d6e7c3238e8e5eac5c51e1ec39","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/i18n","sources":31,"sha256":"5a3a48c9a7e742bea2cdc7faff397a81fc1e7e17f5dd33ec7b2d17d7aa8e1755","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/scripts/sdui-manifest.sources/types.json b/scripts/sdui-manifest.sources/types.json index c264a9f0078..3afa6f37e67 100644 --- a/scripts/sdui-manifest.sources/types.json +++ b/scripts/sdui-manifest.sources/types.json @@ -1 +1 @@ -{"package":"ui/packages/types","sources":81,"sha256":"d7928a68db8d76df5f05933d139f9d3d46e2fd87f74945f69c74e079f1a8eda2","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/types","sources":81,"sha256":"0373b9f45d8be39821641918e2e8058b8016ad24232b39111193116732fdf00f","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} From e7a0e4243e1ab4ba8564bef472db2a63db9091ca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 22:46:32 +0000 Subject: [PATCH 12/12] chore(sdui): re-record the source records after merging main Claude-Session: https://claude.ai/code/session_01PoTpJPgXim1ybDgRyat4BY Co-authored-by: Claude --- scripts/sdui-manifest.sources/i18n.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.sources/i18n.json b/scripts/sdui-manifest.sources/i18n.json index 84836d6c007..2d5791a08d1 100644 --- a/scripts/sdui-manifest.sources/i18n.json +++ b/scripts/sdui-manifest.sources/i18n.json @@ -1 +1 @@ -{"package":"ui/packages/i18n","sources":31,"sha256":"4bcb36ff124e98b8405929277c2acd9afdeb73347b814df67cf26a80727996af","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/i18n","sources":31,"sha256":"a5459185e0836b6ae5f87ff81d0e75d2be96455c5f18661c47c12c8f49d81871","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}