Skip to content
6 changes: 5 additions & 1 deletion .agents/skills/verify-deepseekbot/features/remote-hosts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Binary file added .github/pr-screenshots/2500-t2/a-d.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/2500-t2/a-l.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/2500-t2/b-d.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/2500-t2/b-l.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/2500-t2/c-d.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/2500-t2/c-l.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/2500-t2/i-d.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/2500-t2/i-l.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/2500-t2/j-d.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/2500-t2/j-l.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/2500-t2/k-d.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/2500-t2/k-l.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/2500-t2/o-d.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/2500-t2/o-l.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/2500-t2/r-d.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/2500-t2/r-l.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/2500-t2/w-d.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/2500-t2/w-l.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions changes/2500-remote-activity-unread.md
Original file line number Diff line number Diff line change
@@ -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))。
28 changes: 2 additions & 26 deletions packages/client/src/client/bot-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 (
<span
className={`bh-unread-count${corner ? ' bh-unread-corner' : ''}${muted ? ' bh-unread-count-muted' : ''}`}
aria-label={t('roster.unreadCount', { count })}
>
{unreadCountLabel(count)}
</span>
);
}

function MutedMark({ t }: { t: BotHarnessTranslate }): ReactElement {
return (
<span className="bh-muted-mark" role="img" aria-label={t('roster.muted')}>
Expand Down
9 changes: 8 additions & 1 deletion packages/client/src/client/host-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 }),
})),
Expand All @@ -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 }),
})),
Expand Down Expand Up @@ -623,7 +628,7 @@ export function RemoteHostRows({
<button
key={row.key}
type="button"
className={`bh-channel-row${sameTarget(open, row) ? ' bh-selected' : ''}`}
className={`bh-channel-row${row.unread > 0 ? ' bh-has-unread' : ''}${sameTarget(open, row) ? ' bh-selected' : ''}`}
data-remote-row={row.key}
aria-label={
showSource
Expand All @@ -646,8 +651,10 @@ export function RemoteHostRows({
avatars={avatars}
target={row}
size={34}
activity={row.botSlug === undefined ? undefined : remote.activity?.get(row.botSlug)}
t={t}
/>
<UnreadCount count={row.unread} corner t={t} />
</span>
<span className="bh-body">
<span className="bh-top">
Expand Down
7 changes: 6 additions & 1 deletion packages/client/src/client/remote-avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>): GroupAvatarBot {
const avatar = bot.avatarUrl === undefined ? undefined : avatars.get(bot.avatarUrl);
Expand All @@ -22,12 +23,14 @@ export function RemoteAvatar({
avatars,
target,
size,
activity,
t,
}: {
roster: RosterView | undefined;
avatars: ReadonlyMap<string, string>;
target: Pick<RemoteConversationTarget, 'botSlug' | 'channelId' | 'title'>;
size: number;
activity?: RemoteBotActivity | undefined;
t: BotHarnessTranslate;
}): ReactElement | null {
if (target.channelId !== undefined) {
Expand Down Expand Up @@ -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}
/>
);
Expand Down
38 changes: 38 additions & 0 deletions packages/client/src/client/remote-conversation-read.ts
Original file line number Diff line number Diff line change
@@ -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<Conversation, 'subscribe' | 'getSnapshot'>,
mark: (messageId: string) => Promise<void>,
): () => 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);
};
}
13 changes: 12 additions & 1 deletion packages/client/src/client/remote-conversation-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -178,6 +180,15 @@ export function RemoteConversationView({
[sources, target.hostId, target.botSlug, target.channelId],
);
const snapshot = useConversation(conversation);
const markReadMount = useMountedResource<HTMLDivElement>(
(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<MarkdownLabels>(
() => ({
Expand Down Expand Up @@ -230,7 +241,7 @@ export function RemoteConversationView({
{snapshot.reason ?? t('remoteHosts.interrupted')}
</p>
) : null}
<div {...stylex.props(styles.timeline)} data-remote-timeline="">
<div ref={markReadMount} {...stylex.props(styles.timeline)} data-remote-timeline="">
{snapshot.drafts.map((entry) => (
<div key={entry.draftId} {...stylex.props(styles.message)}>
<span {...stylex.props(styles.author)}>
Expand Down
74 changes: 74 additions & 0 deletions packages/client/src/client/remote-host-activity.ts
Original file line number Diff line number Diff line change
@@ -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<WireSessionActivity, PersonaBotSessionActivity>();
const received = new WeakMap<WireToolActivity, PersonaBotToolActivity>();
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<string, RemoteBotActivity> =>
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;
}),
}),
},
]),
);
}
25 changes: 23 additions & 2 deletions packages/client/src/client/remote-hosts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -39,6 +40,7 @@ export interface RemoteHost {
host: SavedHost;
snapshot: HostSnapshot;
inbox: InboxSnapshot;
activity?: ReadonlyMap<string, RemoteBotActivity>;
}

export interface RemoteHostsSnapshot {
Expand All @@ -61,6 +63,7 @@ export interface RemoteHosts {
open(target: RemoteConversationTarget): void;
close(): void;
conversation(target: RemoteConversationTarget): Conversation | undefined;
markRead(target: RemoteConversationTarget, messageId: string): Promise<void>;
}

export interface HostStorage {
Expand Down Expand Up @@ -113,6 +116,7 @@ interface HostEntry {
release: () => void;
inbox: { feed: Inbox; release: () => void } | undefined;
retained: InboxSnapshot;
activity: ReadonlyMap<string, RemoteBotActivity>;
}

const idleInbox: InboxSnapshot = { status: 'idle' };
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading