Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .agents/skills/verify-deepseekbot/features/remote-hosts.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,27 @@ The browser pairs a Remote Host as a Paired Device and reads its Host Feed throu
## Sub-features

- `pair`: Add remote redeems the other Host's pairing link.
- `tabs`: All, This host and each Remote Host filter the channel list; Chinese uses 全部、本机、远程.
- `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.
- `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.

## How to get to it (user POV)

Enter Bot mode, then Messages. Choose Add remote above the channel list. Paste a link from the other Host's Settings → Devices → Pair a phone. Choose All or the Remote Host tab, then a Bot or group row. Right-click the Host tab to remove it.
Enter Bot mode, then Messages. Open the channel header's add menu and choose Add remote. Paste a link from the other Host's Settings → Devices → Pair a phone. Choose All or the Remote Host icon, then a Bot or group row. Right-click the Host icon and choose Remove to remove it.

## Driving it with verify-app

Launch two independently named instances with `node scripts/verify-app.mjs launch --name <name>`. Run `doctor` for each. Open both local login URLs. Pair from Host A's browser using the link created on Host B. Verify the new tab, All rows, remote conversation header and canonical device record on Host B. Capture tabs and the Add remote dialog in English and Chinese, light and dark. For the rename baseline, run the pre-change Client with the same browser storage, upgrade, and verify the pairing and selected Host remain without another offer.

Read the browser console and run `node scripts/verify-app.mjs observe --name <name>` after startup and relevant actions. For a reply test select `deepseek-v4-pro`, send a uniquely marked message and compare the visible response to Host B's canonical timeline. Inspect the remote timeline in reverse DOM order: newest is first.

For avatars, give Host B one uploaded Bot image, one seeded or composed Bot, a group with those members and a group with an uploaded image. Open each from All and the Host icon. Compare the row, header and Bot message author with Host B's canonical roster and timeline. A direct `bot-tool channel_send` needs an existing trusted Source Event in that Channel; seed a Human group message with the members' wake policies set to silent before the direct call. Inspect the browser's device-authorized avatar GET and CORS response, the loaded object-URL image dimensions and both themes. Equal slugs on Host A and Host B must retain Host B's portrait, including group member stacks. Change Host B's avatar and verify the visible portrait updates; remove the Remote Host and verify its rows and portraits disappear. Streaming draft authors also use the portrait; mark the live-model draft path untested unless driven with the specified model.

## Gotchas

- The Inbox and authenticated avatar cache are scaffold state; rows do not yet display their counts or avatars in Track 0.
- Row activity/unread remains a separate slice. Remote avatars consume Track 0's authenticated image cache; This Host retains its current read adapter.
- Remote chat displays text and attachment names; images and cards are not qualified.
- The Host look is still per phone. DSH Desktop and two physical machines over a real tailnet are not qualified by a two-Host local run.
- 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-t1/after-all-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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-t1/after-dm-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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-t1/before-dm-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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-t1/removed-dark.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-t1/restored-dark.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-t1/updated-dark.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-avatars.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
section: Added
---

## en

- Remote Host chats now show Bot portraits and group avatars in the channel list, conversation header and Bot message authors, using that Host's authenticated images and appearance; saved avatar edits refresh through the Host Feed ([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500)).

## zh

- 远程会话现可在频道列表、会话标题和 Bot 消息作者处显示头像,使用所属 Host 的认证图片与外观;群聊支持自定义头像和成员头像拼图,保存头像后通过 Host Feed 刷新([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500))。
11 changes: 11 additions & 0 deletions changes/2500-stylex-portable-bundles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
section: Fixed
---

## en

- Client bundles now use the same token-derived StyleX classes on Windows and Linux, keeping checked-in Browser and Computer bundles reproducible ([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500)).

## zh

- 客户端构建现在在 Windows 和 Linux 使用相同的 StyleX 令牌派生类名,使提交到仓库的 Browser 和 Computer 产物可重复生成([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500))。
6 changes: 2 additions & 4 deletions packages/client/src/client/avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -363,10 +363,8 @@ export function PersonaBotAvatar({
nativeTitle = true,
t = zhTranslate,
}: PersonaBotAvatarProps): ReactElement {
const systemIcon = useTobDefaultIcon(personaBotId);
const currentTob = useTobBot(personaBotId);
const tobIcon = preview ? undefined : systemIcon;
const tob = preview ? undefined : currentTob;
const tobIcon = useTobDefaultIcon(preview ? undefined : personaBotId);
const tob = useTobBot(preview ? undefined : personaBotId);
const name = tob?.displayName ?? inputName;
const src = tob ? tob.avatar : inputSrc;
const appearance = tob ? tob.appearance : inputAppearance;
Expand Down
20 changes: 14 additions & 6 deletions packages/client/src/client/group-avatar-stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,16 @@ import type { BotSummary, ChannelSummary } from './store.js';
const MAX_STACKED_FACES = 4;
const FACE_SCALE = [0.64, 0.6, 0.43] as const;

export function groupAvatarStackBots(
export type GroupAvatarBot = Pick<
BotSummary,
'slug' | 'displayName' | 'avatar' | 'appearance' | 'avatarSeed'
>;

export function groupAvatarStackBots<Bot extends GroupAvatarBot>(
members: readonly string[],
bots: ReadonlyMap<string, BotSummary>,
): BotSummary[] {
const faces: BotSummary[] = [];
bots: ReadonlyMap<string, Bot>,
): Bot[] {
const faces: Bot[] = [];
for (const slug of members) {
const bot = bots.get(slug);
if (bot !== undefined) faces.push(bot);
Expand All @@ -28,14 +33,16 @@ export function GroupChannelIcon({
size,
hashSize,
t,
preview = false,
}: {
channel: ChannelSummary;
bots: ReadonlyMap<string, BotSummary>;
channel: Pick<ChannelSummary, 'avatar' | 'members'>;
bots: ReadonlyMap<string, GroupAvatarBot>;
className: string;
groupClassName: string;
size: number;
hashSize: number;
t: BotHarnessTranslate;
preview?: boolean;
}): ReactElement {
const faces = channel.avatar ? [] : groupAvatarStackBots(channel.members, bots);
const tile = channel.avatar !== undefined || faces.length > 0;
Expand All @@ -52,6 +59,7 @@ export function GroupChannelIcon({
key={bot.slug}
t={t}
personaBotId={bot.slug}
preview={preview}
name={bot.displayName}
src={bot.avatar}
appearance={bot.appearance}
Expand Down
16 changes: 9 additions & 7 deletions packages/client/src/client/host-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,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 { ChannelSidebarIcon } from './channel-sidebar-icon.js';
import { RemoteAvatar } from './remote-avatar.js';
import { ConfirmDialog, FormDialog } from './ds/form-dialog.js';
import { PointerMenu, type MenuPoint } from './ds/pointer-menu.js';
import { TextField } from './ds/field.js';
Expand Down Expand Up @@ -558,7 +559,6 @@ export function AddRemoteHostDialog({

function rows(remote: RemoteHost): (RemoteConversationTarget & {
key: string;
icon: string;
preview?: string;
previewAt?: string;
})[] {
Expand All @@ -571,7 +571,6 @@ function rows(remote: RemoteHost): (RemoteConversationTarget & {
hostId,
title: bot.displayName,
botSlug: bot.slug,
icon: 'bot',
...(bot.preview === undefined ? {} : { preview: bot.preview }),
...(bot.previewAt === undefined ? {} : { previewAt: bot.previewAt }),
})),
Expand All @@ -580,7 +579,6 @@ function rows(remote: RemoteHost): (RemoteConversationTarget & {
hostId,
title: channel.name,
channelId: channel.id,
icon: 'users',
...(channel.preview === undefined ? {} : { preview: channel.preview }),
...(channel.previewAt === undefined ? {} : { previewAt: channel.previewAt }),
})),
Expand Down Expand Up @@ -613,7 +611,7 @@ export function RemoteHostRows({
t: BotHarnessTranslate;
}): ReactElement | null {
const sources = remoteHosts();
const open = useRemoteHosts().open;
const { open, avatars } = useRemoteHosts();
const needle = query.trim().toLocaleLowerCase();
const visible = rows(remote).filter(
(row) => needle.length === 0 || row.title.toLocaleLowerCase().includes(needle),
Expand Down Expand Up @@ -643,9 +641,13 @@ export function RemoteHostRows({
}}
>
<span className="bh-row-avatar">
<span className="bh-channel-slot">
<ChannelSidebarIcon name={row.icon} />
</span>
<RemoteAvatar
roster={remote.snapshot.roster}
avatars={avatars}
target={row}
size={34}
t={t}
/>
</span>
<span className="bh-body">
<span className="bh-top">
Expand Down
65 changes: 65 additions & 0 deletions packages/client/src/client/remote-avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { ReactElement } from 'react';
import type { RosterBot, RosterView } from '../../../../apps/mobile/src/host/client.js';
import { isAvatarAppearance } from '../../../core/src/bots/avatar-appearance.js';
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';

function avatarBot(bot: RosterBot, avatars: ReadonlyMap<string, string>): GroupAvatarBot {
const avatar = bot.avatarUrl === undefined ? undefined : avatars.get(bot.avatarUrl);
return {
slug: bot.slug,
displayName: bot.displayName,
...(avatar === undefined ? {} : { avatar }),
...(isAvatarAppearance(bot.appearance) ? { appearance: bot.appearance } : {}),
...(bot.avatarSeed === undefined ? {} : { avatarSeed: bot.avatarSeed }),
};
}

export function RemoteAvatar({
roster,
avatars,
target,
size,
t,
}: {
roster: RosterView | undefined;
avatars: ReadonlyMap<string, string>;
target: Pick<RemoteConversationTarget, 'botSlug' | 'channelId' | 'title'>;
size: number;
t: BotHarnessTranslate;
}): ReactElement | null {
if (target.channelId !== undefined) {
const channel = roster?.channels.find((entry) => entry.id === target.channelId);
if (channel === undefined) return null;
return (
<GroupChannelIcon
channel={channel}
bots={new Map((roster?.bots ?? []).map((bot) => [bot.slug, avatarBot(bot, avatars)]))}
preview
className="bh-channel-slot"
groupClassName="bh-group-channel-slot"
size={size}
hashSize={18}
t={t}
/>
);
}
if (target.botSlug === undefined) return null;
const bot = roster?.bots.find((entry) => entry.slug === target.botSlug);
const portrait = bot === undefined ? undefined : avatarBot(bot, avatars);
return (
<PersonaBotAvatar
personaBotId={target.botSlug}
preview
name={portrait?.displayName ?? target.title}
src={portrait?.avatar}
appearance={portrait?.appearance}
avatarSeed={portrait?.avatarSeed}
size={size}
indicator={false}
t={t}
/>
);
}
46 changes: 40 additions & 6 deletions packages/client/src/client/remote-conversation-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { remoteHosts, type RemoteConversationTarget } from './remote-hosts.js';
import { useRemoteHosts } from './host-tabs.js';
import { ChannelSidebarIcon } from './channel-sidebar-icon.js';
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';
Expand Down Expand Up @@ -65,7 +66,14 @@ const styles = stylex.create({
},
message: { display: 'flex', flexDirection: 'column', gap: space.x1, minWidth: 0 },
human: { alignItems: 'flex-end' },
author: { color: color.subtle, fontSize: text.metaSize, lineHeight: text.metaLine },
author: {
display: 'flex',
alignItems: 'center',
gap: space.x3,
color: color.subtle,
fontSize: text.metaSize,
lineHeight: text.metaLine,
},
bubble: {
maxWidth: 'min(720px, 100%)',
paddingBlock: space.x3,
Expand Down Expand Up @@ -163,9 +171,8 @@ export function RemoteConversationView({
t: BotHarnessTranslate;
}): ReactElement {
const sources = remoteHosts();
const remote = useRemoteHosts().hosts.find(
(candidate) => candidate.host.hostId === target.hostId,
);
const { hosts, avatars } = useRemoteHosts();
const remote = hosts.find((candidate) => candidate.host.hostId === target.hostId);
const conversation = useMemo(
() => sources.conversation(target),
[sources, target.hostId, target.botSlug, target.channelId],
Expand Down Expand Up @@ -197,6 +204,13 @@ export function RemoteConversationView({
data-remote-conversation={target.hostId}
>
<header {...stylex.props(styles.header)}>
<RemoteAvatar
roster={remote?.snapshot.roster}
avatars={avatars}
target={target}
size={34}
t={t}
/>
<span {...stylex.props(styles.title)}>{target.title}</span>
<span {...stylex.props(styles.source)}>
<ChannelSidebarIcon name="monitor" />
Expand All @@ -219,7 +233,16 @@ export function RemoteConversationView({
<div {...stylex.props(styles.timeline)} data-remote-timeline="">
{snapshot.drafts.map((entry) => (
<div key={entry.draftId} {...stylex.props(styles.message)}>
<span {...stylex.props(styles.author)}>{bots.get(entry.botSlug) ?? entry.botSlug}</span>
<span {...stylex.props(styles.author)}>
<RemoteAvatar
roster={remote?.snapshot.roster}
avatars={avatars}
target={{ botSlug: entry.botSlug, title: bots.get(entry.botSlug) ?? entry.botSlug }}
size={24}
t={t}
/>
{bots.get(entry.botSlug) ?? entry.botSlug}
</span>
<div {...stylex.props(styles.bubble)}>
<MarkdownText text={entry.body} streaming labels={labels} />
</div>
Expand All @@ -231,7 +254,18 @@ export function RemoteConversationView({
data-remote-message={message.id}
{...stylex.props(styles.message, message.author === 'human' && styles.human)}
>
<span {...stylex.props(styles.author)}>{authorName(message, bots, t)}</span>
<span {...stylex.props(styles.author)}>
{message.author === 'bot' && message.bot !== undefined ? (
<RemoteAvatar
roster={remote?.snapshot.roster}
avatars={avatars}
target={{ botSlug: message.bot, title: authorName(message, bots, t) }}
size={24}
t={t}
/>
) : null}
{authorName(message, bots, t)}
</span>
<div {...stylex.props(styles.bubble, message.author === 'human' && styles.humanBubble)}>
<MarkdownText
text={
Expand Down
9 changes: 5 additions & 4 deletions packages/client/src/client/tob-presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ const subscribe = (listener: () => void): (() => void) => {
};
};

export function useTobDefaultIcon(slug: string): BotModeIcon | undefined {
const read = () => (source?.bot(slug)?.tob?.defaultAvatar ? source.icon() : undefined);
export function useTobDefaultIcon(slug: string | undefined): BotModeIcon | undefined {
const read = () =>
slug !== undefined && source?.bot(slug)?.tob?.defaultAvatar ? source.icon() : undefined;
return useSyncExternalStore(subscribe, read, read);
}

Expand All @@ -46,9 +47,9 @@ export function useTobName(slug: string | undefined, fallback: string): string {
return useSyncExternalStore(subscribe, read, read);
}

export function useTobBot(slug: string): BotSummary | undefined {
export function useTobBot(slug: string | undefined): BotSummary | undefined {
const read = () => {
const bot = source?.bot(slug);
const bot = slug === undefined ? undefined : source?.bot(slug);
return bot?.tob ? bot : undefined;
};
return useSyncExternalStore(subscribe, read, read);
Expand Down
Loading
Loading