diff --git a/.agents/skills/verify-deepseekbot/features/remote-hosts.md b/.agents/skills/verify-deepseekbot/features/remote-hosts.md
index a640a16e39..305db01512 100644
--- a/.agents/skills/verify-deepseekbot/features/remote-hosts.md
+++ b/.agents/skills/verify-deepseekbot/features/remote-hosts.md
@@ -8,6 +8,8 @@ The browser pairs a Remote Host as a Paired Device and reads its Host Feed throu
- `tabs`: the icon strip filters All, This host and each Remote Host; tooltips name the Host, and Chinese uses 全部、本机、远程.
- `open`: a remote row opens a Host-scoped conversation.
- `avatars`: remote Bot portraits and group images/member stacks appear in rows and conversation headers; Bot message authors and live drafts use the same remote portrait.
+- `activity-unread`: remote Bot rows display received activity; DM/group unread counts come from their Host Inbox.
+- `read`: a visible remote conversation at its latest message marks that message read on its owning Host and refreshes the shared Inbox.
- `upgrade`: existing browser pairings and the selected tab survive the storage-key rename.
- `remove`: removing a Host forgets its rows, stops its Inbox and releases cached avatar object URLs.
@@ -25,7 +27,9 @@ For avatars, give Host B one uploaded Bot image, one seeded or composed Bot, a g
## Gotchas
-- Row activity/unread remains a separate slice. Remote avatars consume Track 0's authenticated image cache; This Host retains its current read adapter.
+- Remote avatars consume the authenticated image cache; This Host retains its current read adapter.
+- With unread messages on both Hosts, compare each row and tab with `channelUnreadCounts`. Open the remote DM/group, verify its count clears on that Host while the other Host stays unread. Keep an active Bot running to inspect its activity indicator. Stop only the task-owned remote Host and verify cached rows/counts remain with an offline tab.
+- Activity timestamps are receipt times; they are not authoritative tool start times.
- Remote chat displays text and attachment names; images and cards are not qualified.
- Host looks remain device-local, including each phone's own icon and colour. DSH Desktop and two physical machines over a real tailnet are not qualified by a two-Host local run.
- Dismiss Continue and Skip onboarding overlays before selecting Messages. Retain any failed drive evidence before retrying.
diff --git a/.github/pr-screenshots/2500-t2/a-d.png b/.github/pr-screenshots/2500-t2/a-d.png
new file mode 100644
index 0000000000..579bac25d4
Binary files /dev/null and b/.github/pr-screenshots/2500-t2/a-d.png differ
diff --git a/.github/pr-screenshots/2500-t2/a-l.png b/.github/pr-screenshots/2500-t2/a-l.png
new file mode 100644
index 0000000000..45af862a83
Binary files /dev/null and b/.github/pr-screenshots/2500-t2/a-l.png differ
diff --git a/.github/pr-screenshots/2500-t2/b-d.png b/.github/pr-screenshots/2500-t2/b-d.png
new file mode 100644
index 0000000000..c6814bcaaf
Binary files /dev/null and b/.github/pr-screenshots/2500-t2/b-d.png differ
diff --git a/.github/pr-screenshots/2500-t2/b-l.png b/.github/pr-screenshots/2500-t2/b-l.png
new file mode 100644
index 0000000000..d12b93fb60
Binary files /dev/null and b/.github/pr-screenshots/2500-t2/b-l.png differ
diff --git a/.github/pr-screenshots/2500-t2/c-d.png b/.github/pr-screenshots/2500-t2/c-d.png
new file mode 100644
index 0000000000..b543637d42
Binary files /dev/null and b/.github/pr-screenshots/2500-t2/c-d.png differ
diff --git a/.github/pr-screenshots/2500-t2/c-l.png b/.github/pr-screenshots/2500-t2/c-l.png
new file mode 100644
index 0000000000..57126efd94
Binary files /dev/null and b/.github/pr-screenshots/2500-t2/c-l.png differ
diff --git a/.github/pr-screenshots/2500-t2/i-d.png b/.github/pr-screenshots/2500-t2/i-d.png
new file mode 100644
index 0000000000..c0598d4d6b
Binary files /dev/null and b/.github/pr-screenshots/2500-t2/i-d.png differ
diff --git a/.github/pr-screenshots/2500-t2/i-l.png b/.github/pr-screenshots/2500-t2/i-l.png
new file mode 100644
index 0000000000..913727154b
Binary files /dev/null and b/.github/pr-screenshots/2500-t2/i-l.png differ
diff --git a/.github/pr-screenshots/2500-t2/j-d.png b/.github/pr-screenshots/2500-t2/j-d.png
new file mode 100644
index 0000000000..88326f2c70
Binary files /dev/null and b/.github/pr-screenshots/2500-t2/j-d.png differ
diff --git a/.github/pr-screenshots/2500-t2/j-l.png b/.github/pr-screenshots/2500-t2/j-l.png
new file mode 100644
index 0000000000..aab194f2c1
Binary files /dev/null and b/.github/pr-screenshots/2500-t2/j-l.png differ
diff --git a/.github/pr-screenshots/2500-t2/k-d.png b/.github/pr-screenshots/2500-t2/k-d.png
new file mode 100644
index 0000000000..5ddc6fe893
Binary files /dev/null and b/.github/pr-screenshots/2500-t2/k-d.png differ
diff --git a/.github/pr-screenshots/2500-t2/k-l.png b/.github/pr-screenshots/2500-t2/k-l.png
new file mode 100644
index 0000000000..53f66145c0
Binary files /dev/null and b/.github/pr-screenshots/2500-t2/k-l.png differ
diff --git a/.github/pr-screenshots/2500-t2/o-d.png b/.github/pr-screenshots/2500-t2/o-d.png
new file mode 100644
index 0000000000..e966d5fcf7
Binary files /dev/null and b/.github/pr-screenshots/2500-t2/o-d.png differ
diff --git a/.github/pr-screenshots/2500-t2/o-l.png b/.github/pr-screenshots/2500-t2/o-l.png
new file mode 100644
index 0000000000..1e89babfdd
Binary files /dev/null and b/.github/pr-screenshots/2500-t2/o-l.png differ
diff --git a/.github/pr-screenshots/2500-t2/r-d.png b/.github/pr-screenshots/2500-t2/r-d.png
new file mode 100644
index 0000000000..2e28efc866
Binary files /dev/null and b/.github/pr-screenshots/2500-t2/r-d.png differ
diff --git a/.github/pr-screenshots/2500-t2/r-l.png b/.github/pr-screenshots/2500-t2/r-l.png
new file mode 100644
index 0000000000..fb96e1ecf1
Binary files /dev/null and b/.github/pr-screenshots/2500-t2/r-l.png differ
diff --git a/.github/pr-screenshots/2500-t2/w-d.png b/.github/pr-screenshots/2500-t2/w-d.png
new file mode 100644
index 0000000000..52843c7c54
Binary files /dev/null and b/.github/pr-screenshots/2500-t2/w-d.png differ
diff --git a/.github/pr-screenshots/2500-t2/w-l.png b/.github/pr-screenshots/2500-t2/w-l.png
new file mode 100644
index 0000000000..0ba06976e1
Binary files /dev/null and b/.github/pr-screenshots/2500-t2/w-l.png differ
diff --git a/changes/2500-remote-activity-unread.md b/changes/2500-remote-activity-unread.md
new file mode 100644
index 0000000000..144fa0b7a1
--- /dev/null
+++ b/changes/2500-remote-activity-unread.md
@@ -0,0 +1,11 @@
+---
+section: Added
+---
+
+## en
+
+- Remote Host chats now show Bot activity and unread counts, reading a remote conversation updates its own Host's read position ([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500)).
+
+## zh
+
+- 远程会话列表现在显示 Bot 活动状态和未读数,阅读远程会话会更新其所属 Host 的已读位置([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500))。
diff --git a/packages/client/src/client/bot-sidebar.tsx b/packages/client/src/client/bot-sidebar.tsx
index be330a07ea..b46f5283de 100644
--- a/packages/client/src/client/bot-sidebar.tsx
+++ b/packages/client/src/client/bot-sidebar.tsx
@@ -1,3 +1,5 @@
+import { UnreadCount } from './unread-count.js';
+export { unreadCountLabel } from './unread-count.js';
import { Menu } from './appearance-primitives.js';
import { ALL_HOSTS, remoteHosts } from './remote-hosts.js';
import { useChannelDockActive } from './channel-dock.js';
@@ -515,32 +517,6 @@ function ChannelMessageTime({
);
}
-export function unreadCountLabel(count: number): string {
- return count > 99 ? '99+' : String(count);
-}
-
-function UnreadCount({
- count,
- muted = false,
- corner = false,
- t,
-}: {
- count: number;
- muted?: boolean;
- corner?: boolean;
- t: BotHarnessTranslate;
-}): ReactElement | null {
- if (count <= 0) return null;
- return (
-
- {unreadCountLabel(count)}
-
- );
-}
-
function MutedMark({ t }: { t: BotHarnessTranslate }): ReactElement {
return (
diff --git a/packages/client/src/client/host-tabs.tsx b/packages/client/src/client/host-tabs.tsx
index 779d89092c..a12e921712 100644
--- a/packages/client/src/client/host-tabs.tsx
+++ b/packages/client/src/client/host-tabs.tsx
@@ -3,6 +3,7 @@ import * as stylex from '@stylexjs/stylex';
import type { RemoteHost, RemoteConversationTarget } from './remote-hosts.js';
import { ALL_HOSTS, remoteHosts, THIS_HOST } from './remote-hosts.js';
+import { UnreadCount } from './unread-count.js';
import { ChannelSidebarIcon } from './channel-sidebar-icon.js';
import { RemoteAvatar } from './remote-avatar.js';
import { ConfirmDialog, FormDialog } from './ds/form-dialog.js';
@@ -559,18 +560,21 @@ export function AddRemoteHostDialog({
function rows(remote: RemoteHost): (RemoteConversationTarget & {
key: string;
+ unread: number;
preview?: string;
previewAt?: string;
})[] {
const roster = remote.snapshot.roster;
if (roster === undefined) return [];
const hostId = remote.host.hostId;
+ const unread = remote.inbox.status === 'ready' ? (remote.inbox.unread ?? {}) : {};
return [
...roster.bots.map((bot) => ({
key: `bot:${bot.slug}`,
hostId,
title: bot.displayName,
botSlug: bot.slug,
+ unread: bot.dmChannelId === undefined ? 0 : (unread[bot.dmChannelId] ?? 0),
...(bot.preview === undefined ? {} : { preview: bot.preview }),
...(bot.previewAt === undefined ? {} : { previewAt: bot.previewAt }),
})),
@@ -579,6 +583,7 @@ function rows(remote: RemoteHost): (RemoteConversationTarget & {
hostId,
title: channel.name,
channelId: channel.id,
+ unread: unread[channel.id] ?? 0,
...(channel.preview === undefined ? {} : { preview: channel.preview }),
...(channel.previewAt === undefined ? {} : { previewAt: channel.previewAt }),
})),
@@ -623,7 +628,7 @@ export function RemoteHostRows({
diff --git a/packages/client/src/client/remote-avatar.tsx b/packages/client/src/client/remote-avatar.tsx
index 3d53296d88..527c5fd724 100644
--- a/packages/client/src/client/remote-avatar.tsx
+++ b/packages/client/src/client/remote-avatar.tsx
@@ -5,6 +5,7 @@ import { PersonaBotAvatar } from './avatar.js';
import { GroupChannelIcon, type GroupAvatarBot } from './group-avatar-stack.js';
import type { BotHarnessTranslate } from './locale.js';
import type { RemoteConversationTarget } from './remote-hosts.js';
+import type { RemoteBotActivity } from './remote-host-activity.js';
function avatarBot(bot: RosterBot, avatars: ReadonlyMap): GroupAvatarBot {
const avatar = bot.avatarUrl === undefined ? undefined : avatars.get(bot.avatarUrl);
@@ -22,12 +23,14 @@ export function RemoteAvatar({
avatars,
target,
size,
+ activity,
t,
}: {
roster: RosterView | undefined;
avatars: ReadonlyMap;
target: Pick;
size: number;
+ activity?: RemoteBotActivity | undefined;
t: BotHarnessTranslate;
}): ReactElement | null {
if (target.channelId !== undefined) {
@@ -58,7 +61,9 @@ export function RemoteAvatar({
appearance={portrait?.appearance}
avatarSeed={portrait?.avatarSeed}
size={size}
- indicator={false}
+ state={activity?.state}
+ activity={activity?.activity}
+ indicator={activity !== undefined}
t={t}
/>
);
diff --git a/packages/client/src/client/remote-conversation-read.ts b/packages/client/src/client/remote-conversation-read.ts
new file mode 100644
index 0000000000..cecd49f340
--- /dev/null
+++ b/packages/client/src/client/remote-conversation-read.ts
@@ -0,0 +1,38 @@
+import { createReadMarker, readTarget } from '../../../../apps/mobile/src/ui/chat/mark-read.js';
+import { shownInChat, type Conversation } from '../../../../apps/mobile/src/host/conversation.js';
+
+export function observeRemoteRead(
+ node: HTMLElement,
+ conversation: Pick,
+ mark: (messageId: string) => Promise,
+): () => void {
+ const marker = createReadMarker((_channelId, messageId) => mark(messageId));
+ const document = node.ownerDocument;
+ const offer = (): void => {
+ const current = conversation.getSnapshot();
+ if (
+ document.visibilityState === 'hidden' ||
+ node.getClientRects().length === 0 ||
+ Math.abs(node.scrollTop) > 24 ||
+ current.status !== 'live' ||
+ current.fromCache
+ ) {
+ marker.stop();
+ return;
+ }
+ marker.offer('conversation', readTarget(current.messages.filter(shownInChat)));
+ };
+ const release = conversation.subscribe(offer);
+ const resize = new ResizeObserver(offer);
+ resize.observe(node);
+ node.addEventListener('scroll', offer);
+ document.addEventListener('visibilitychange', offer);
+ offer();
+ return () => {
+ release();
+ resize.disconnect();
+ marker.stop();
+ node.removeEventListener('scroll', offer);
+ document.removeEventListener('visibilitychange', offer);
+ };
+}
diff --git a/packages/client/src/client/remote-conversation-view.tsx b/packages/client/src/client/remote-conversation-view.tsx
index 5e558951b8..f6765be6a9 100644
--- a/packages/client/src/client/remote-conversation-view.tsx
+++ b/packages/client/src/client/remote-conversation-view.tsx
@@ -15,6 +15,8 @@ import { RemoteAvatar } from './remote-avatar.js';
import { IconButton } from './ds/icon-button.js';
import { border, color, radius, space, text } from './ds/tokens.stylex.js';
import type { BotHarnessTranslate } from './locale.js';
+import { observeRemoteRead } from './remote-conversation-read.js';
+import { useMountedResource } from './mounted-resource.js';
const styles = stylex.create({
root: {
@@ -178,6 +180,15 @@ export function RemoteConversationView({
[sources, target.hostId, target.botSlug, target.channelId],
);
const snapshot = useConversation(conversation);
+ const markReadMount = useMountedResource(
+ (node) => {
+ if (conversation === undefined) return;
+ return observeRemoteRead(node, conversation, (messageId) =>
+ sources.markRead(target, messageId),
+ );
+ },
+ [sources, conversation, target.hostId, target.botSlug, target.channelId],
+ );
const [draft, setDraft] = useState('');
const labels = useMemo(
() => ({
@@ -230,7 +241,7 @@ export function RemoteConversationView({
{snapshot.reason ?? t('remoteHosts.interrupted')}
) : null}
-
+
{snapshot.drafts.map((entry) => (
diff --git a/packages/client/src/client/remote-host-activity.ts b/packages/client/src/client/remote-host-activity.ts
new file mode 100644
index 0000000000..2894c8ea76
--- /dev/null
+++ b/packages/client/src/client/remote-host-activity.ts
@@ -0,0 +1,74 @@
+import type { RosterBot } from '../../../../apps/mobile/src/host/client.js';
+import type {
+ WireToolActivity,
+ WireSessionActivity,
+} from '../../../../apps/mobile/src/host/wire.js';
+import type {
+ PersonaBotSessionActivity,
+ PersonaBotToolActivity,
+} from '../../../core/src/state/tool-activity.js';
+
+export interface RemoteBotActivity {
+ state: RosterBot['state'];
+ activity?: PersonaBotToolActivity;
+ sessions?: readonly PersonaBotSessionActivity[];
+}
+
+export function createRemoteActivityProjection() {
+ const sessions = new WeakMap();
+ const received = new WeakMap();
+ const tool = (value: WireToolActivity, at: number): PersonaBotToolActivity => {
+ let activity = received.get(value);
+ if (activity === undefined) {
+ activity = { ...value, toolKind: 'other', startedAt: at };
+ received.set(value, activity);
+ }
+ return activity;
+ };
+ return (bots: readonly RosterBot[], at: number): ReadonlyMap =>
+ new Map(
+ bots.map((bot) => [
+ bot.slug,
+ {
+ state: bot.state,
+ ...(bot.activity === undefined
+ ? {}
+ : {
+ activity: {
+ ...tool(bot.activity, at),
+ ...(bot.sessions === undefined
+ ? {}
+ : {
+ sources: (['orchestrator', 'assignment', 'subagent'] as const).flatMap(
+ (role) => {
+ const count = bot.sessions!.filter(
+ (session) => session.role === role,
+ ).length;
+ return count > 0 ? [{ role, count }] : [];
+ },
+ ),
+ }),
+ },
+ }),
+ ...(bot.sessions === undefined
+ ? {}
+ : {
+ sessions: bot.sessions.map((session) => {
+ let projected = sessions.get(session);
+ if (projected === undefined) {
+ const { activity, ...identity } = session;
+ projected = {
+ ...identity,
+ revision: 0,
+ at,
+ ...(activity === undefined ? {} : { activity: tool(activity, at) }),
+ };
+ sessions.set(session, projected);
+ }
+ return projected;
+ }),
+ }),
+ },
+ ]),
+ );
+}
diff --git a/packages/client/src/client/remote-hosts.ts b/packages/client/src/client/remote-hosts.ts
index c39a677677..e00b083ecd 100644
--- a/packages/client/src/client/remote-hosts.ts
+++ b/packages/client/src/client/remote-hosts.ts
@@ -22,6 +22,7 @@ import {
type PairingRefusal,
} from '../../../../apps/mobile/src/host/pairing.js';
import { store } from './store.js';
+import { createRemoteActivityProjection, type RemoteBotActivity } from './remote-host-activity.js';
export const REMOTE_HOSTS_STORAGE_KEY = 'botharness.remoteHosts.v1';
export const HOST_TAB_STORAGE_KEY = 'botharness.hostTab.v1';
@@ -39,6 +40,7 @@ export interface RemoteHost {
host: SavedHost;
snapshot: HostSnapshot;
inbox: InboxSnapshot;
+ activity?: ReadonlyMap;
}
export interface RemoteHostsSnapshot {
@@ -61,6 +63,7 @@ export interface RemoteHosts {
open(target: RemoteConversationTarget): void;
close(): void;
conversation(target: RemoteConversationTarget): Conversation | undefined;
+ markRead(target: RemoteConversationTarget, messageId: string): Promise;
}
export interface HostStorage {
@@ -113,6 +116,7 @@ interface HostEntry {
release: () => void;
inbox: { feed: Inbox; release: () => void } | undefined;
retained: InboxSnapshot;
+ activity: ReadonlyMap;
}
const idleInbox: InboxSnapshot = { status: 'idle' };
@@ -175,10 +179,11 @@ export function createRemoteHosts(options: {
};
const publish = (): void => {
- const hosts = [...clients.values()].map(({ host, client, inbox, retained }) => ({
+ const hosts = [...clients.values()].map(({ host, client, inbox, retained, activity }) => ({
host,
snapshot: client.getSnapshot(),
inbox: inbox?.feed.getSnapshot() ?? retained,
+ activity,
}));
fetchAvatars(hosts);
const known = tab === ALL_HOSTS || tab === THIS_HOST || clients.has(tab);
@@ -216,12 +221,17 @@ export function createRemoteHosts(options: {
const attach = (host: SavedHost): void => {
const client = createHostClient({ routes: host.routes, credential: host.credential, carrier });
+ const projectActivity = createRemoteActivityProjection();
const entry: HostEntry = {
host,
client,
- release: client.subscribe(publish),
+ release: client.subscribe(() => {
+ entry.activity = projectActivity(client.getSnapshot().roster?.bots ?? [], Date.now());
+ publish();
+ }),
inbox: undefined,
retained: idleInbox,
+ activity: new Map(),
};
clients.set(host.hostId, entry);
if (listeners.size > 0) start(entry);
@@ -296,6 +306,17 @@ export function createRemoteHosts(options: {
open = undefined;
publish();
},
+ async markRead(target, messageId) {
+ const entry = clients.get(target.hostId);
+ if (entry === undefined) return;
+ const channelId =
+ target.channelId ??
+ entry.client.getSnapshot().roster?.bots.find((bot) => bot.slug === target.botSlug)
+ ?.dmChannelId;
+ if (channelId === undefined) throw new Error('The remote Channel is not ready.');
+ await entry.client.call('channelMarkRead', { channelId, messageId });
+ await entry.inbox?.feed.refresh();
+ },
conversation(target) {
const entry = clients.get(target.hostId);
if (entry === undefined) return undefined;
diff --git a/packages/client/src/client/unread-count.tsx b/packages/client/src/client/unread-count.tsx
new file mode 100644
index 0000000000..fa29e6ffd5
--- /dev/null
+++ b/packages/client/src/client/unread-count.tsx
@@ -0,0 +1,28 @@
+import type { ReactElement } from 'react';
+import type { BotHarnessTranslate } from './locale.js';
+
+export function unreadCountLabel(count: number): string {
+ return count > 99 ? '99+' : String(count);
+}
+
+export function UnreadCount({
+ count,
+ muted = false,
+ corner = false,
+ t,
+}: {
+ count: number;
+ muted?: boolean;
+ corner?: boolean;
+ t: BotHarnessTranslate;
+}): ReactElement | null {
+ if (count <= 0) return null;
+ return (
+
+ {unreadCountLabel(count)}
+
+ );
+}
diff --git a/packages/client/test/remote-conversation-read.test.ts b/packages/client/test/remote-conversation-read.test.ts
new file mode 100644
index 0000000000..94f2c6ce54
--- /dev/null
+++ b/packages/client/test/remote-conversation-read.test.ts
@@ -0,0 +1,116 @@
+import { createRequire } from 'node:module';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import type { ConversationSnapshot } from '../../../apps/mobile/src/host/conversation.js';
+import { observeRemoteRead } from '../src/client/remote-conversation-read.js';
+
+const {
+ JSDOM,
+}: {
+ JSDOM: new (
+ html: string,
+ options: { pretendToBeVisual: boolean },
+ ) => {
+ window: Window & typeof globalThis;
+ };
+} = createRequire(import.meta.url)('jsdom');
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+});
+
+describe('visible remote read acknowledgement', () => {
+ it('marks only live visible latest messages and cancels on scrolling, hiding, interruption and disposal', async () => {
+ vi.useFakeTimers();
+ const dom = new JSDOM('', { pretendToBeVisual: true });
+ const node = dom.window.document.querySelector('div')!;
+ let visible = true;
+ Object.defineProperty(node, 'getClientRects', { value: () => ({ length: visible ? 1 : 0 }) });
+ let resized = () => {};
+ const disconnect = vi.fn();
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ constructor(callback: () => void) {
+ resized = callback;
+ }
+ observe() {}
+ disconnect = disconnect;
+ },
+ );
+ const listeners = new Set<() => void>();
+ let snapshot: ConversationSnapshot = {
+ status: 'live',
+ fromCache: false,
+ hasOlder: false,
+ loadingOlder: false,
+ drafts: [],
+ messages: [],
+ };
+ const publish = (id: string) => {
+ snapshot = {
+ ...snapshot,
+ messages: [
+ {
+ id,
+ at: '2026-10-21T00:00:00Z',
+ author: 'bot',
+ body: id,
+ delivery: 'committed',
+ attachments: [],
+ },
+ ],
+ };
+ listeners.forEach((listener) => listener());
+ };
+ const mark = vi.fn(async () => {});
+ const stop = observeRemoteRead(
+ node,
+ {
+ getSnapshot: () => snapshot,
+ subscribe(listener) {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ },
+ mark,
+ );
+ publish('first');
+ await vi.advanceTimersByTimeAsync(250);
+ expect(mark).toHaveBeenCalledExactlyOnceWith('first');
+ publish('away');
+ node.scrollTop = -100;
+ node.dispatchEvent(new dom.window.Event('scroll'));
+ await vi.advanceTimersByTimeAsync(250);
+ expect(mark).toHaveBeenCalledTimes(1);
+ node.scrollTop = 0;
+ node.dispatchEvent(new dom.window.Event('scroll'));
+ visible = false;
+ resized();
+ await vi.advanceTimersByTimeAsync(250);
+ expect(mark).toHaveBeenCalledTimes(1);
+ visible = true;
+ resized();
+ await vi.advanceTimersByTimeAsync(250);
+ expect(mark).toHaveBeenLastCalledWith('away');
+ publish('interrupted');
+ snapshot = { ...snapshot, status: 'interrupted' };
+ resized();
+ await vi.advanceTimersByTimeAsync(250);
+ expect(mark).toHaveBeenCalledTimes(2);
+ snapshot = { ...snapshot, status: 'live', fromCache: true };
+ publish('cache');
+ await vi.advanceTimersByTimeAsync(250);
+ expect(mark).toHaveBeenCalledTimes(2);
+ snapshot = { ...snapshot, fromCache: false };
+ publish('unmounted');
+ stop();
+ await vi.advanceTimersByTimeAsync(250);
+ expect(mark).toHaveBeenCalledTimes(2);
+ expect(listeners.size).toBe(0);
+ expect(disconnect).toHaveBeenCalledOnce();
+ dom.window.close();
+ });
+});
diff --git a/packages/client/test/remote-host-activity.test.ts b/packages/client/test/remote-host-activity.test.ts
new file mode 100644
index 0000000000..7d9e7906cb
--- /dev/null
+++ b/packages/client/test/remote-host-activity.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest';
+import type { RosterBot } from '../../../apps/mobile/src/host/client.js';
+import { createRemoteActivityProjection } from '../src/client/remote-host-activity.js';
+
+describe('remote activity presentation', () => {
+ it('keeps received tool and session times stable across unrelated Host updates', () => {
+ const project = createRemoteActivityProjection();
+ const bot: RosterBot = {
+ slug: 'ada',
+ displayName: 'Ada',
+ roles: [],
+ computerAccess: false,
+ browserAccess: false,
+ paused: false,
+ state: 'working',
+ activity: { effect: 'executing', toolName: 'bash', activeToolCount: 2 },
+ sessions: [
+ {
+ id: 'assignment',
+ role: 'assignment',
+ state: 'working',
+ activity: { effect: 'coding', activeToolCount: 1 },
+ },
+ ],
+ };
+ const first = project([bot], 100).get('ada')!;
+ expect(first.activity).toMatchObject({
+ toolKind: 'other',
+ startedAt: 100,
+ effect: 'executing',
+ activeToolCount: 2,
+ sources: [{ role: 'assignment', count: 1 }],
+ });
+ expect(first.sessions?.[0]).toMatchObject({
+ id: 'assignment',
+ at: 100,
+ activity: { toolKind: 'other', startedAt: 100 },
+ });
+ const republished = project([{ ...bot, preview: 'new preview' }], 200).get('ada')!;
+ expect(republished.activity?.startedAt).toBe(100);
+ expect(republished.sessions?.[0]).toBe(first.sessions?.[0]);
+ const changed = project(
+ [{ ...bot, activity: { effect: 'searching', activeToolCount: 1 } }],
+ 300,
+ );
+ expect(changed.get('ada')?.activity).toMatchObject({ effect: 'searching', startedAt: 300 });
+ const { activity: _activity, sessions: _sessions, ...idle } = bot;
+ expect(project([{ ...idle, state: 'idle' }], 400).get('ada')).toEqual({ state: 'idle' });
+ expect(project([], 500).size).toBe(0);
+ });
+});
diff --git a/packages/client/test/remote-host-rows.test.tsx b/packages/client/test/remote-host-rows.test.tsx
new file mode 100644
index 0000000000..42bf7fbb4d
--- /dev/null
+++ b/packages/client/test/remote-host-rows.test.tsx
@@ -0,0 +1,156 @@
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { describe, expect, it, vi } from 'vitest';
+import type { RemoteHost } from '../src/client/remote-hosts.js';
+
+vi.mock('@deepseek-ai/dsh-client-ui-primitives', () => ({
+ Button: () => null,
+ Modal: () => null,
+ Input: () => null,
+ Textarea: () => null,
+ Tooltip: ({ children }: { children?: import('react').ReactNode }) => children,
+}));
+
+const fixture = vi.hoisted(() => ({
+ hosts: [] as RemoteHost[],
+ avatars: new Map(),
+}));
+vi.mock('../src/client/remote-hosts.js', async (original) => ({
+ ...(await original()),
+ remoteHosts: () => ({
+ getSnapshot: () => ({ hosts: fixture.hosts, avatars: fixture.avatars, tab: 'all' }),
+ subscribe: () => () => {},
+ }),
+}));
+
+import { RemoteHostRows } from '../src/client/host-tabs.js';
+import { zhTranslate } from '../src/client/locale.js';
+import { bindTobPresentation } from '../src/client/tob-presentation.js';
+
+const remote: RemoteHost = {
+ host: {
+ hostId: 'studio',
+ hostName: 'Studio',
+ routes: [],
+ deviceId: 'browser',
+ credential: 'test',
+ },
+ snapshot: {
+ connection: { status: 'stopped', route: '' },
+ roster: {
+ bots: [
+ {
+ slug: 'tob',
+ displayName: 'Ada',
+ roles: [],
+ state: 'working',
+ computerAccess: false,
+ browserAccess: false,
+ paused: false,
+ dmChannelId: 'private-ada',
+ avatarUrl: 'https://studio.example/avatar/tob',
+ preview: 'Studio preview',
+ },
+ ],
+ channels: [{ id: 'group', name: 'Team', members: ['tob'], memberCount: 1 }],
+ },
+ },
+ inbox: {
+ status: 'ready',
+ sections: { action: [], replies: [], unread: [], handled: [] },
+ unread: { 'private-ada': 2, group: 3 },
+ },
+ activity: new Map([
+ [
+ 'tob',
+ {
+ state: 'working',
+ activity: { effect: 'coding', toolKind: 'other', startedAt: 100, activeToolCount: 1 },
+ },
+ ],
+ ]),
+};
+
+describe('remote Host rows', () => {
+ it('uses the DM channel id and group id for unread badges and the Host activity for the avatar', () => {
+ fixture.hosts = [remote];
+ fixture.avatars = new Map([['https://studio.example/avatar/tob', 'blob:studio-tob']]);
+ const html = renderToStaticMarkup(
+ createElement(RemoteHostRows, { remote, query: '', showSource: true, t: zhTranslate }),
+ );
+ expect(html).toContain('data-state="working"');
+ expect(html).toContain('data-effect="coding"');
+ expect(html).toContain('2 条未读');
+ expect(html).toContain('3 条未读');
+ expect(html.match(/bh-unread-corner/g)).toHaveLength(2);
+ expect(html).toContain('bh-group-avatar-stack');
+ expect(html.match(/src="blob:studio-tob"/g)).toHaveLength(2);
+ expect(html).toContain('Studio preview');
+ expect(html).toContain('data-remote-host-mark="studio"');
+ });
+
+ it('keeps each Host portrait, activity and unread together for equal Bot slugs without local lookup', () => {
+ const other: RemoteHost = {
+ ...remote,
+ host: { ...remote.host, hostId: 'lab', hostName: 'Lab' },
+ snapshot: {
+ ...remote.snapshot,
+ roster: {
+ bots: [
+ {
+ ...remote.snapshot.roster!.bots[0]!,
+ state: 'idle',
+ avatarUrl: 'https://lab.example/avatar/tob',
+ preview: 'Lab preview',
+ },
+ ],
+ channels: [],
+ },
+ },
+ inbox: {
+ status: 'ready',
+ sections: { action: [], replies: [], unread: [], handled: [] },
+ unread: { 'private-ada': 7 },
+ },
+ activity: new Map([['tob', { state: 'idle' }]]),
+ };
+ fixture.hosts = [remote, other];
+ fixture.avatars = new Map([
+ ['https://studio.example/avatar/tob', 'blob:studio-tob'],
+ ['https://lab.example/avatar/tob', 'blob:lab-tob'],
+ ]);
+ const local = vi.fn(() => undefined);
+ const release = bindTobPresentation({
+ bot: local,
+ icon: () => 'bot',
+ subscribe: () => () => {},
+ });
+ try {
+ const render = (host: RemoteHost) =>
+ renderToStaticMarkup(
+ createElement(RemoteHostRows, {
+ remote: host,
+ query: '',
+ showSource: true,
+ t: zhTranslate,
+ }),
+ );
+ const studio = render(remote);
+ const lab = render(other);
+ expect(studio).toContain('src="blob:studio-tob"');
+ expect(studio).not.toContain('blob:lab-tob');
+ expect(studio).toContain('data-effect="coding"');
+ expect(studio).toContain('2 条未读');
+ expect(lab).toContain('src="blob:lab-tob"');
+ expect(lab).not.toContain('blob:studio-tob');
+ expect(lab).toContain('data-state="idle"');
+ expect(lab).not.toContain('data-effect="coding"');
+ expect(lab).toContain('7 条未读');
+ expect(lab).toContain('Lab preview');
+ expect(lab).toContain('data-remote-host-mark="lab"');
+ expect(local).not.toHaveBeenCalled();
+ } finally {
+ release();
+ }
+ });
+});
diff --git a/packages/client/test/remote-hosts.test.ts b/packages/client/test/remote-hosts.test.ts
index 46f8d88ec6..1a4d079d80 100644
--- a/packages/client/test/remote-hosts.test.ts
+++ b/packages/client/test/remote-hosts.test.ts
@@ -1,4 +1,4 @@
-import { afterEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, assert, describe, expect, it, vi } from 'vitest';
import type { HostCarrier } from '../../../apps/mobile/src/host/carrier.js';
import { FakeHost } from '../../../apps/mobile/test/fake-host.js';
@@ -194,6 +194,46 @@ function liveHost() {
}
describe('Host Feed lifecycle', () => {
+ it('marks the remote DM channel read through its own credential and refreshes its Inbox', async () => {
+ const { host, storage } = liveHost();
+ host.methods.set('channels', () => ({
+ channels: [{ id: 'dm-ada', type: 'dm', botSlug: 'ada', name: 'Ada', members: ['ada'] }],
+ }));
+ host.methods.set('channelMarkRead', () => {
+ host.methods.set('channelUnreadCounts', () => ({ channels: [] }));
+ return { accepted: true };
+ });
+ const sources = createRemoteHosts({ carrier: host.carrier, storage });
+ releases.push(sources.subscribe(() => {}));
+ await vi.waitFor(() =>
+ expect(sources.getSnapshot().hosts[0]?.inbox).toMatchObject({
+ status: 'ready',
+ unread: { 'dm-ada': 2 },
+ }),
+ );
+ await sources.markRead({ hostId: host.hostId, title: 'Ada', botSlug: 'ada' }, 'message-2');
+ expect(host.calls.filter(({ method }) => method === 'channelMarkRead')).toEqual([
+ { method: 'channelMarkRead', args: { channelId: 'dm-ada', messageId: 'message-2' } },
+ ]);
+ const inbox = sources.getSnapshot().hosts[0]?.inbox;
+ assert(inbox?.status === 'ready');
+ expect(inbox.unread).toEqual({});
+ await sources.markRead({ hostId: 'another-host', title: 'Ada', botSlug: 'ada' }, 'message-2');
+ expect(host.calls.filter(({ method }) => method === 'channelMarkRead')).toHaveLength(1);
+ });
+
+ it('keeps canonical unread counts when the owning Host refuses a read update', async () => {
+ const { host, storage } = liveHost();
+ const sources = createRemoteHosts({ carrier: host.carrier, storage });
+ releases.push(sources.subscribe(() => {}));
+ await vi.waitFor(() => expect(sources.getSnapshot().hosts[0]?.inbox.status).toBe('ready'));
+ host.failing.add('channelMarkRead');
+ await expect(
+ sources.markRead({ hostId: host.hostId, title: 'Group', channelId: 'group' }, 'message-2'),
+ ).rejects.toThrow();
+ expect(sources.getSnapshot().hosts[0]?.inbox).toMatchObject({ unread: { 'dm-ada': 2 } });
+ });
+
it('retains legacy pairings when browser storage refuses the migration write', () => {
const { host, saved } = liveHost();
const storage = memoryStorage({