diff --git a/.changeset/7142-activity-timeline-empty-i18n.md b/.changeset/7142-activity-timeline-empty-i18n.md
new file mode 100644
index 000000000..268d7ec35
--- /dev/null
+++ b/.changeset/7142-activity-timeline-empty-i18n.md
@@ -0,0 +1,23 @@
+---
+'@object-ui/plugin-detail': patch
+---
+
+`ActivityTimeline`'s empty state speaks the session locale (objectui#7142).
+
+The title was a raw English JSX literal — `title="No activity recorded"`, not a
+`t()` call and not an inline `defaultValue` — so it never reached the pack
+system and stayed English in all ten locales. Measured before the fix by
+rendering `activities={[]}` under a zh `I18nProvider`: the card read
+`"Activity(0)No activity recorded"`, while its sibling `RecordActivityTimeline`
+rendered `"活动(0)全部动态暂无活动记录"` from the same packs.
+
+The call site now reads `detail.noActivity`, the key the sibling already uses.
+Reusing it rather than minting a second key is a measured decision: the `en`
+pack value for that key is `'No activity recorded'`, byte-identical to the
+literal it replaces, so both surfaces were already saying the same words in
+English and a new key would have forked one sentence across ten packs for no
+copy difference. No pack was edited — the key is already translated in all ten,
+verified by reading `detail.noActivity` out of each pack object.
+
+Both routes to the box are covered: an empty `activities` array, and a
+populated timeline filtered down to a type with no entries.
diff --git a/packages/plugin-detail/src/ActivityTimeline.i18n.test.tsx b/packages/plugin-detail/src/ActivityTimeline.i18n.test.tsx
new file mode 100644
index 000000000..e9fcf84c8
--- /dev/null
+++ b/packages/plugin-detail/src/ActivityTimeline.i18n.test.tsx
@@ -0,0 +1,122 @@
+/**
+ * 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.
+ */
+
+/**
+ * `ActivityTimeline`'s empty state speaks the session locale — objectui#7142.
+ *
+ * The title was the raw English literal `"No activity recorded"`, written
+ * straight into the JSX. It was not a `t()` call and not an inline
+ * `defaultValue`, so it never reached the pack system at all and a zh session
+ * read English. Measured before the fix by rendering `activities={[]}` under a
+ * zh `I18nProvider`: the card came out `"Activity(0)No activity recorded"`,
+ * while its sibling `RecordActivityTimeline` rendered `"活动(0)全部动态暂无活动记录"`
+ * from the same packs.
+ *
+ * The call site now reads `detail.noActivity` — the key the sibling already
+ * uses (`RecordActivityTimeline.tsx`), reused rather than forked. That reuse is
+ * a measured decision, not an assumption: the `en` pack value for the key is
+ * `'No activity recorded'`, **byte-identical** to the literal being replaced,
+ * so both surfaces were already saying the same words in English and a second
+ * key would have forked one sentence across ten packs for no copy difference.
+ *
+ * `zh` and `ar` are the load-bearing assertions. An `en`-only test would have
+ * been green *before* the fix too — the literal and the `en` pack value are the
+ * same string — so English proves nothing here. Non-Latin locales are what
+ * discriminate the pack lookup from the hardcoded literal.
+ *
+ * The provider-less case is asserted alongside them because
+ * `useDetailTranslation` is a `createSafeTranslation` hook: a host with no
+ * `I18nProvider` must get the English default from
+ * `DETAIL_DEFAULT_TRANSLATIONS`, never the raw key `detail.noActivity` in the
+ * empty box. No inline `defaultValue` is used anywhere here (objectui#3517) —
+ * the key resolves from the packs or from that defaults map.
+ */
+
+import * as React from 'react';
+import { describe, it, expect, afterEach } from 'vitest';
+import { render, screen, cleanup } from '@testing-library/react';
+import { I18nProvider } from '@object-ui/i18n';
+import type { ActivityEntry } from '@object-ui/types';
+import { ActivityTimeline } from './ActivityTimeline';
+
+afterEach(() => cleanup());
+
+/** The pack values for `detail.noActivity`, read from the packs themselves. */
+const EN = 'No activity recorded';
+const ZH = '暂无活动记录';
+const AR = 'لا يوجد نشاط مسجل';
+const RAW_KEY = 'detail.noActivity';
+
+function renderEmptyIn(language: string) {
+ return render(
+
+
+ ,
+ );
+}
+
+describe('ActivityTimeline empty state — locale resolution (objectui#7142)', () => {
+ it('reads the zh pack value under a zh session', () => {
+ renderEmptyIn('zh');
+
+ expect(screen.getByText(ZH)).toBeTruthy();
+ // The regression itself: English must not survive into a zh session.
+ expect(screen.queryByText(EN)).toBeNull();
+ });
+
+ it('reads the ar pack value under an ar session', () => {
+ renderEmptyIn('ar');
+
+ expect(screen.getByText(AR)).toBeTruthy();
+ expect(screen.queryByText(EN)).toBeNull();
+ });
+
+ it('still reads English under an en session', () => {
+ renderEmptyIn('en');
+
+ expect(screen.getByText(EN)).toBeTruthy();
+ });
+
+ it('falls back to the English default with no provider mounted', () => {
+ render();
+
+ expect(screen.getByText(EN)).toBeTruthy();
+ });
+
+ it('never renders the raw key, provider or not', () => {
+ renderEmptyIn('zh');
+ expect(screen.queryByText(RAW_KEY)).toBeNull();
+ cleanup();
+
+ render();
+ expect(screen.queryByText(RAW_KEY)).toBeNull();
+ });
+
+ it('translates the empty state reached by filtering, not just by an empty list', () => {
+ // The other route to this box: a non-empty timeline filtered down to a type
+ // that has no entries. Same call site, but it is the only one a host reaches
+ // with `activities` actually populated.
+ const activities: ActivityEntry[] = [
+ {
+ id: 'a1',
+ type: 'comment',
+ user: 'Ada',
+ timestamp: '2026-01-02T00:00:00.000Z',
+ } as ActivityEntry,
+ ];
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText(ZH)).toBeTruthy();
+ expect(screen.queryByText(EN)).toBeNull();
+ });
+});
diff --git a/packages/plugin-detail/src/ActivityTimeline.tsx b/packages/plugin-detail/src/ActivityTimeline.tsx
index 94ece81b6..6bb6ce321 100644
--- a/packages/plugin-detail/src/ActivityTimeline.tsx
+++ b/packages/plugin-detail/src/ActivityTimeline.tsx
@@ -10,6 +10,7 @@ import * as React from 'react';
import { cn, Card, CardHeader, CardTitle, CardContent, DataEmptyState } from '@object-ui/components';
import { Activity, Edit, PlusCircle, Trash2, MessageSquare, ArrowRightLeft, Filter } from 'lucide-react';
import type { ActivityEntry } from '@object-ui/types';
+import { useDetailTranslation } from './useDetailTranslation';
export type ActivityFilterType = ActivityEntry['type'] | 'all';
@@ -92,6 +93,7 @@ export const ActivityTimeline: React.FC = ({
defaultFilter = 'all',
className,
}) => {
+ const { t } = useDetailTranslation();
const [activeFilter, setActiveFilter] = React.useState(defaultFilter);
const filteredActivities = React.useMemo(() => {
@@ -135,7 +137,7 @@ export const ActivityTimeline: React.FC = ({
)}
{filteredActivities.length === 0 ? (
-
+
) : (
{/* Timeline line */}