diff --git a/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx b/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx
index 321f382936d..c8c45a8c715 100644
--- a/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx
+++ b/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx
@@ -67,6 +67,10 @@ const features: Feature[] = [{
title: 'LLMs.txt',
description: 'Serve llms.txt, per-entry markdown exports, and Accept: text/markdown content negotiation for AI and LLM tooling',
flag: 'llmsTxt'
+}, {
+ title: 'Editor presence',
+ description: 'Show avatars in the editor header and the post list for staff currently editing a post.',
+ flag: 'editorPresence'
}];
const AlphaFeatures: React.FC = () => {
diff --git a/ghost/admin/app/components/gh-presence-avatars.hbs b/ghost/admin/app/components/gh-presence-avatars.hbs
new file mode 100644
index 00000000000..fe635a1ee61
--- /dev/null
+++ b/ghost/admin/app/components/gh-presence-avatars.hbs
@@ -0,0 +1,27 @@
+{{#if this.users.length}}
+
+ {{#each this.users as |user|}}
+
+ {{/each}}
+ {{#if this.overflowCount}}
+
+ +{{this.overflowCount}}
+
+
+ {{/if}}
+
+{{/if}}
diff --git a/ghost/admin/app/components/gh-presence-avatars.js b/ghost/admin/app/components/gh-presence-avatars.js
new file mode 100644
index 00000000000..47aa8dd5549
--- /dev/null
+++ b/ghost/admin/app/components/gh-presence-avatars.js
@@ -0,0 +1,64 @@
+import Component from '@glimmer/component';
+import {inject as service} from '@ember/service';
+
+// Smaller cap in the editor header — that row uses flex-wrap and a
+// fixed height, so a stack wider than the available space wraps and
+// overlaps page content. Post-list rows have a dedicated row of width
+// per item and can comfortably hold one more.
+const CAP_BY_SIZE = {md: 2, sm: 3};
+
+export default class GhPresenceAvatars extends Component {
+ @service presence;
+
+ get _allUsers() {
+ const raw = this.presence.usersForPost(this.args.postId);
+
+ // Detect first-name collisions so we can disambiguate ("Alex S."
+ // / "Alex J.") rather than rendering two identical tooltips.
+ const firstNameCounts = new Map();
+ const parsed = raw.map((user) => {
+ const name = user.name || 'Someone';
+ const parts = name.split(/\s+/).filter(Boolean);
+ const firstName = parts[0] || name;
+ firstNameCounts.set(firstName, (firstNameCounts.get(firstName) || 0) + 1);
+ return {user, name, parts, firstName};
+ });
+
+ return parsed.map(({user, name, parts, firstName}) => {
+ let display = firstName;
+ if (firstNameCounts.get(firstName) > 1 && parts.length > 1) {
+ display = `${firstName} ${parts[parts.length - 1][0].toUpperCase()}.`;
+ }
+ const tooltip = display.length > 20 ? `${display.slice(0, 20)}…` : display;
+ return {
+ id: user.id,
+ name,
+ firstName: display,
+ tooltipText: user.isIdle ? `${tooltip} (idle)` : tooltip,
+ profileImage: user.profileImage || null,
+ isIdle: Boolean(user.isIdle),
+ initials: parts.slice(0, 2).map(part => part[0]).join('').toUpperCase()
+ };
+ });
+ }
+
+ get _cap() {
+ return CAP_BY_SIZE[this.args.size === 'sm' ? 'sm' : 'md'];
+ }
+
+ get users() {
+ return this._allUsers.slice(0, this._cap);
+ }
+
+ get overflowCount() {
+ return Math.max(0, this._allUsers.length - this._cap);
+ }
+
+ get overflowTooltip() {
+ return this._allUsers.slice(this._cap).map(u => u.firstName).join(', ');
+ }
+
+ get sizeClass() {
+ return this.args.size === 'sm' ? 'gh-presence-avatars--sm' : 'gh-presence-avatars--md';
+ }
+}
diff --git a/ghost/admin/app/components/posts-list/list-item-analytics.hbs b/ghost/admin/app/components/posts-list/list-item-analytics.hbs
index d407579512d..96b9b841c3c 100644
--- a/ghost/admin/app/components/posts-list/list-item-analytics.hbs
+++ b/ghost/admin/app/components/posts-list/list-item-analytics.hbs
@@ -330,6 +330,11 @@
{{/if}}
+ {{!-- Presence column --}}
+ {{#if (feature 'editorPresence')}}
+