diff --git a/.agents/skills/verify-deepseekbot/features/remote-hosts.md b/.agents/skills/verify-deepseekbot/features/remote-hosts.md index 47ca3cd349..a640a16e39 100644 --- a/.agents/skills/verify-deepseekbot/features/remote-hosts.md +++ b/.agents/skills/verify-deepseekbot/features/remote-hosts.md @@ -5,14 +5,15 @@ 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 @@ -20,9 +21,11 @@ Launch two independently named instances with `node scripts/verify-app.mjs launc Read the browser console and run `node scripts/verify-app.mjs observe --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. diff --git a/.github/pr-screenshots/2500-t1/after-all-dark.png b/.github/pr-screenshots/2500-t1/after-all-dark.png new file mode 100644 index 0000000000..4722e0f879 Binary files /dev/null and b/.github/pr-screenshots/2500-t1/after-all-dark.png differ diff --git a/.github/pr-screenshots/2500-t1/after-all-light.png b/.github/pr-screenshots/2500-t1/after-all-light.png new file mode 100644 index 0000000000..f659c1ea1f Binary files /dev/null and b/.github/pr-screenshots/2500-t1/after-all-light.png differ diff --git a/.github/pr-screenshots/2500-t1/after-custom-dark.png b/.github/pr-screenshots/2500-t1/after-custom-dark.png new file mode 100644 index 0000000000..a47158e39a Binary files /dev/null and b/.github/pr-screenshots/2500-t1/after-custom-dark.png differ diff --git a/.github/pr-screenshots/2500-t1/after-custom-light.png b/.github/pr-screenshots/2500-t1/after-custom-light.png new file mode 100644 index 0000000000..fa4921f770 Binary files /dev/null and b/.github/pr-screenshots/2500-t1/after-custom-light.png differ diff --git a/.github/pr-screenshots/2500-t1/after-dm-dark.png b/.github/pr-screenshots/2500-t1/after-dm-dark.png new file mode 100644 index 0000000000..44078481ea Binary files /dev/null and b/.github/pr-screenshots/2500-t1/after-dm-dark.png differ diff --git a/.github/pr-screenshots/2500-t1/after-dm-light.png b/.github/pr-screenshots/2500-t1/after-dm-light.png new file mode 100644 index 0000000000..b460ab5468 Binary files /dev/null and b/.github/pr-screenshots/2500-t1/after-dm-light.png differ diff --git a/.github/pr-screenshots/2500-t1/after-group-dark.png b/.github/pr-screenshots/2500-t1/after-group-dark.png new file mode 100644 index 0000000000..633234d6b5 Binary files /dev/null and b/.github/pr-screenshots/2500-t1/after-group-dark.png differ diff --git a/.github/pr-screenshots/2500-t1/after-group-light.png b/.github/pr-screenshots/2500-t1/after-group-light.png new file mode 100644 index 0000000000..103e718e79 Binary files /dev/null and b/.github/pr-screenshots/2500-t1/after-group-light.png differ diff --git a/.github/pr-screenshots/2500-t1/avatar-cleared-dark.png b/.github/pr-screenshots/2500-t1/avatar-cleared-dark.png new file mode 100644 index 0000000000..ee0de6eca8 Binary files /dev/null and b/.github/pr-screenshots/2500-t1/avatar-cleared-dark.png differ diff --git a/.github/pr-screenshots/2500-t1/before-all-dark.png b/.github/pr-screenshots/2500-t1/before-all-dark.png new file mode 100644 index 0000000000..cb94dd03e3 Binary files /dev/null and b/.github/pr-screenshots/2500-t1/before-all-dark.png differ diff --git a/.github/pr-screenshots/2500-t1/before-all-light.png b/.github/pr-screenshots/2500-t1/before-all-light.png new file mode 100644 index 0000000000..6dc5475499 Binary files /dev/null and b/.github/pr-screenshots/2500-t1/before-all-light.png differ diff --git a/.github/pr-screenshots/2500-t1/before-custom-dark.png b/.github/pr-screenshots/2500-t1/before-custom-dark.png new file mode 100644 index 0000000000..7291f7984e Binary files /dev/null and b/.github/pr-screenshots/2500-t1/before-custom-dark.png differ diff --git a/.github/pr-screenshots/2500-t1/before-custom-light.png b/.github/pr-screenshots/2500-t1/before-custom-light.png new file mode 100644 index 0000000000..c5f4bd4766 Binary files /dev/null and b/.github/pr-screenshots/2500-t1/before-custom-light.png differ diff --git a/.github/pr-screenshots/2500-t1/before-dm-dark.png b/.github/pr-screenshots/2500-t1/before-dm-dark.png new file mode 100644 index 0000000000..7d3ee7d728 Binary files /dev/null and b/.github/pr-screenshots/2500-t1/before-dm-dark.png differ diff --git a/.github/pr-screenshots/2500-t1/before-dm-light.png b/.github/pr-screenshots/2500-t1/before-dm-light.png new file mode 100644 index 0000000000..f2c238d64c Binary files /dev/null and b/.github/pr-screenshots/2500-t1/before-dm-light.png differ diff --git a/.github/pr-screenshots/2500-t1/before-group-dark.png b/.github/pr-screenshots/2500-t1/before-group-dark.png new file mode 100644 index 0000000000..57fe8c4252 Binary files /dev/null and b/.github/pr-screenshots/2500-t1/before-group-dark.png differ diff --git a/.github/pr-screenshots/2500-t1/before-group-light.png b/.github/pr-screenshots/2500-t1/before-group-light.png new file mode 100644 index 0000000000..34f30be1db Binary files /dev/null and b/.github/pr-screenshots/2500-t1/before-group-light.png differ diff --git a/.github/pr-screenshots/2500-t1/removed-dark.png b/.github/pr-screenshots/2500-t1/removed-dark.png new file mode 100644 index 0000000000..2e28b110ce Binary files /dev/null and b/.github/pr-screenshots/2500-t1/removed-dark.png differ diff --git a/.github/pr-screenshots/2500-t1/restored-dark.png b/.github/pr-screenshots/2500-t1/restored-dark.png new file mode 100644 index 0000000000..29692a014c Binary files /dev/null and b/.github/pr-screenshots/2500-t1/restored-dark.png differ diff --git a/.github/pr-screenshots/2500-t1/updated-dark.png b/.github/pr-screenshots/2500-t1/updated-dark.png new file mode 100644 index 0000000000..d2711b12c8 Binary files /dev/null and b/.github/pr-screenshots/2500-t1/updated-dark.png differ diff --git a/changes/2500-remote-avatars.md b/changes/2500-remote-avatars.md new file mode 100644 index 0000000000..5c5700dd1e --- /dev/null +++ b/changes/2500-remote-avatars.md @@ -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))。 diff --git a/changes/2500-stylex-portable-bundles.md b/changes/2500-stylex-portable-bundles.md new file mode 100644 index 0000000000..a471469071 --- /dev/null +++ b/changes/2500-stylex-portable-bundles.md @@ -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))。 diff --git a/packages/client/src/client/avatar.tsx b/packages/client/src/client/avatar.tsx index a77104ac38..caf7bff923 100644 --- a/packages/client/src/client/avatar.tsx +++ b/packages/client/src/client/avatar.tsx @@ -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; diff --git a/packages/client/src/client/group-avatar-stack.tsx b/packages/client/src/client/group-avatar-stack.tsx index 587527f51b..d72d8dafbc 100644 --- a/packages/client/src/client/group-avatar-stack.tsx +++ b/packages/client/src/client/group-avatar-stack.tsx @@ -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( members: readonly string[], - bots: ReadonlyMap, -): BotSummary[] { - const faces: BotSummary[] = []; + bots: ReadonlyMap, +): Bot[] { + const faces: Bot[] = []; for (const slug of members) { const bot = bots.get(slug); if (bot !== undefined) faces.push(bot); @@ -28,14 +33,16 @@ export function GroupChannelIcon({ size, hashSize, t, + preview = false, }: { - channel: ChannelSummary; - bots: ReadonlyMap; + channel: Pick; + bots: ReadonlyMap; 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; @@ -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} diff --git a/packages/client/src/client/host-tabs.tsx b/packages/client/src/client/host-tabs.tsx index 72cf6be660..779d89092c 100644 --- a/packages/client/src/client/host-tabs.tsx +++ b/packages/client/src/client/host-tabs.tsx @@ -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'; @@ -558,7 +559,6 @@ export function AddRemoteHostDialog({ function rows(remote: RemoteHost): (RemoteConversationTarget & { key: string; - icon: string; preview?: string; previewAt?: string; })[] { @@ -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 }), })), @@ -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 }), })), @@ -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), @@ -643,9 +641,13 @@ export function RemoteHostRows({ }} > - - - + diff --git a/packages/client/src/client/remote-avatar.tsx b/packages/client/src/client/remote-avatar.tsx new file mode 100644 index 0000000000..3d53296d88 --- /dev/null +++ b/packages/client/src/client/remote-avatar.tsx @@ -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): 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; + target: Pick; + 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 ( + [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 ( + + ); +} diff --git a/packages/client/src/client/remote-conversation-view.tsx b/packages/client/src/client/remote-conversation-view.tsx index d5e024c08c..5e558951b8 100644 --- a/packages/client/src/client/remote-conversation-view.tsx +++ b/packages/client/src/client/remote-conversation-view.tsx @@ -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'; @@ -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, @@ -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], @@ -197,6 +204,13 @@ export function RemoteConversationView({ data-remote-conversation={target.hostId} >
+ {target.title} @@ -219,7 +233,16 @@ export function RemoteConversationView({
{snapshot.drafts.map((entry) => (
- {bots.get(entry.botSlug) ?? entry.botSlug} + + + {bots.get(entry.botSlug) ?? entry.botSlug} +
@@ -231,7 +254,18 @@ export function RemoteConversationView({ data-remote-message={message.id} {...stylex.props(styles.message, message.author === 'human' && styles.human)} > - {authorName(message, bots, t)} + + {message.author === 'bot' && message.bot !== undefined ? ( + + ) : null} + {authorName(message, bots, t)} +
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); } @@ -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); diff --git a/packages/client/test/remote-avatar.test.tsx b/packages/client/test/remote-avatar.test.tsx new file mode 100644 index 0000000000..1c7d828504 --- /dev/null +++ b/packages/client/test/remote-avatar.test.tsx @@ -0,0 +1,115 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; +import type { RosterBot, RosterView } from '../../../apps/mobile/src/host/client.js'; +import { RemoteAvatar } from '../src/client/remote-avatar.js'; +import { bindTobPresentation } from '../src/client/tob-presentation.js'; +import { zhTranslate } from '../src/client/locale.js'; +import { resolvedAvatarRecipe } from '../../core/src/bots/primary-color.js'; + +const bot: RosterBot = { + slug: 'tob', + displayName: 'Remote Tob', + roles: [], + computerAccess: false, + browserAccess: false, + state: 'idle', + paused: false, + avatarSeed: 2, + avatarUrl: 'https://studio.example.ts.net/botharness-device/api/botharness/avatar/tob', +}; +const avatars = new Map([[bot.avatarUrl!, 'blob:remote-tob']]); +const roster: RosterView = { + bots: [bot], + channels: [{ id: 'crew', name: 'Crew', members: ['tob'], memberCount: 1 }], +}; +const render = ( + target: { title: string; botSlug?: string; channelId?: string }, + feed = roster, + images = avatars, +) => + renderToStaticMarkup( + createElement(RemoteAvatar, { + target, + roster: feed, + avatars: images, + size: 34, + t: zhTranslate, + }), + ); + +describe('RemoteAvatar', () => { + it('uses authenticated object URLs and never looks up an identically named local Tob', () => { + const local = vi.fn(() => undefined); + const release = bindTobPresentation({ + bot: local, + icon: () => 'bot', + subscribe: () => () => {}, + }); + try { + const portrait = render({ botSlug: 'tob', title: 'Remote Tob' }); + const group = render({ channelId: 'crew', title: 'Crew' }); + for (const markup of [portrait, group]) { + expect(markup).toContain('src="blob:remote-tob"'); + expect(markup).toContain('Remote Tob'); + expect(markup).not.toContain(bot.avatarUrl); + } + expect(local).not.toHaveBeenCalled(); + } finally { + release(); + } + }); + + it('uses the remote seed while an image is unavailable and for a removed author', () => { + const pending = render({ botSlug: 'tob', title: 'Remote Tob' }, roster, new Map()); + expect(pending).toContain('data-media="seeded"'); + expect(pending).not.toContain(bot.avatarUrl); + const missing = render({ botSlug: 'gone', title: 'Former Bot' }); + expect(missing).toContain('Former Bot'); + expect(missing).toContain('data-media="seeded"'); + }); + + it('renders supported appearance and falls back for unknown appearance versions', () => { + const appearance = { revision: 'a'.repeat(64), recipe: resolvedAvatarRecipe(bot) }; + const supported = render( + { botSlug: 'tob', title: 'Remote Tob' }, + { ...roster, bots: [{ ...bot, appearance }] }, + ); + expect(supported).toContain('data-media="composed"'); + const unknown = render( + { botSlug: 'tob', title: 'Remote Tob' }, + { ...roster, bots: [{ ...bot, appearance: { version: 999 } }] }, + ); + expect(unknown).toContain('src="blob:remote-tob"'); + }); + + it('uses the group data URL ahead of member faces and keeps empty groups as hash tiles', () => { + const image = 'data:image/png;base64,avatar'; + const custom = render( + { channelId: 'crew', title: 'Crew' }, + { ...roster, channels: [{ ...roster.channels[0]!, avatar: image }] }, + ); + expect(custom).toContain(`src="${image}"`); + expect(custom).not.toContain('bh-group-avatar-stack'); + const empty = render( + { channelId: 'crew', title: 'Crew' }, + { ...roster, channels: [{ ...roster.channels[0]!, members: [] }] }, + ); + expect(empty).toContain(' { + const other = { + ...bot, + avatarUrl: 'https://other.example.ts.net/botharness-device/api/botharness/avatar/tob', + }; + const images = new Map([...avatars, [other.avatarUrl, 'blob:other-tob']]); + expect(render({ botSlug: 'tob', title: 'Remote Tob' }, roster, images)).toContain( + 'src="blob:remote-tob"', + ); + expect( + render({ botSlug: 'tob', title: 'Remote Tob' }, { ...roster, bots: [other] }, images), + ).toContain('src="blob:other-tob"'); + }); +}); diff --git a/packages/core/src/bots/registry.ts b/packages/core/src/bots/registry.ts index 09c03296c6..0894768ef4 100644 --- a/packages/core/src/bots/registry.ts +++ b/packages/core/src/bots/registry.ts @@ -69,6 +69,7 @@ export interface PersonaBotRegistryOptions { onImport?: (event: { phase: 'complete' | 'failed'; count?: number; durationMs: number }) => void; now?: () => Date; onDisplayNameChanged?: () => void; + onAvatarChanged?: () => void; onAppearanceImported?: (record: PersonaBotRecord) => void; initializeMemory?: (memoryDir: string) => MemoryRepositoryInitialization; @@ -637,6 +638,7 @@ export function createPersonaBotRegistry(options: PersonaBotRegistryOptions): Pe if (record === undefined) return { ok: false, reason: 'not-found' }; const previousName = record.displayName; const previousAvatar = record.avatar; + const previousAppearance = record.appearance; const previousProfile = JSON.stringify([ record.roles, record.tag, @@ -682,6 +684,8 @@ export function createPersonaBotRegistry(options: PersonaBotRegistryOptions): Pe } write(record); if (record.displayName !== previousName) options.onDisplayNameChanged?.(); + if (record.avatar !== previousAvatar || record.appearance !== previousAppearance) + options.onAvatarChanged?.(); if (patch.avatar !== undefined && record.avatar !== previousAvatar) capture('avatar_edited'); if ( record.displayName !== previousName || @@ -699,6 +703,7 @@ export function createPersonaBotRegistry(options: PersonaBotRegistryOptions): Pe const updated = { ...record, ...derived }; if (isTob(port, slug) && origin === 'custom') updated.avatarCustomized = true; write(updated); + if (record.appearance?.revision !== updated.appearance.revision) options.onAvatarChanged?.(); syncDescriptor(updated); capture('avatar_edited'); return { ok: true, record: updated }; diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index ea4c706de0..447a72f552 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -506,6 +506,7 @@ export function createCore( options.warn?.('bot-name-publication-failed'); } }, + onAvatarChanged: () => live?.publishRosterCommitted(), cloneMemory: (destination, url) => cloneMemoryRepository({ destination, url }), ...(options.capture === undefined ? {} : { capture: options.capture }), syncDescriptor: (memoryDir, record, sync) => { diff --git a/packages/core/test/device-gateway.test.ts b/packages/core/test/device-gateway.test.ts index d859217aef..cfe68a3b82 100644 --- a/packages/core/test/device-gateway.test.ts +++ b/packages/core/test/device-gateway.test.ts @@ -48,6 +48,14 @@ async function fakeDsh(connectOffer?: Parameters[0][ req.on('data', (chunk: Buffer) => chunks.push(chunk)); req.on('end', () => { state.seen.push({ ...req.headers, path: url.pathname }); + if ( + url.pathname === '/api/botharness/bot-avatar' && + url.searchParams.get('slug') === 'ada' && + url.searchParams.get('v') === 'portrait-1' + ) { + res.writeHead(200, { 'content-type': 'image/png', 'set-cookie': 'leak=1' }); + return res.end(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + } res.writeHead(200, { 'content-type': 'application/json', 'set-cookie': 'leak=1' }); res.end(JSON.stringify({ path: url.pathname, body: Buffer.concat(chunks).toString() })); }); @@ -220,6 +228,54 @@ describe('device gateway', () => { expect(web.status).toBe(403); }); + it('forwards cross-origin avatar GET bytes only for an admitted device without cookies', async () => { + const host = await fakeDsh(); + const browser = 'http://127.0.0.1:41000'; + const paired = await pair(host.base, host.pairing.createOffer().offer); + const authorization = `Device ${paired.body.credential}`; + const url = `${host.base}/botharness-device/api/botharness/bot-avatar?slug=ada&v=portrait-1`; + const preflight = await fetch(url, { + method: 'OPTIONS', + headers: { + origin: browser, + 'access-control-request-method': 'GET', + 'access-control-request-headers': 'authorization', + }, + }); + expect(preflight.status).toBe(204); + expect(preflight.headers.get('access-control-allow-methods')).toContain('GET'); + expect(preflight.headers.get('access-control-allow-headers')).toContain('authorization'); + const image = await fetch(url, { headers: { origin: browser, authorization } }); + expect(image.status).toBe(200); + expect(image.headers.get('content-type')).toBe('image/png'); + expect(image.headers.get('access-control-allow-origin')).toBe(browser); + expect(image.headers.get('vary')).toBe('origin'); + expect(image.headers.get('access-control-allow-credentials')).toBeNull(); + expect(image.headers.get('set-cookie')).toBeNull(); + expect(Buffer.from(await image.arrayBuffer())).toEqual( + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + ); + expect(host.state.seen.at(-1)).toMatchObject({ + path: '/api/botharness/bot-avatar', + cookie: 'dsh=1', + }); + expect(host.state.seen.at(-1)!.authorization).toBeUndefined(); + expect(host.state.seen.at(-1)!.origin).toBeUndefined(); + const admitted = host.state.seen.length; + expect((await fetch(url, { headers: { origin: browser } })).status).toBe(401); + expect( + (await fetch(url, { headers: { origin: 'https://evil.example', authorization } })).status, + ).toBe(403); + expect( + (await fetch(url, { headers: { origin: browser, authorization, cookie: 'dsh=1' } })).status, + ).toBe(403); + host.pairing.revoke(paired.body.deviceId!); + const revoked = await fetch(url, { headers: { origin: browser, authorization } }); + expect(revoked.status).toBe(401); + expect(await revoked.json()).toEqual({ code: 'device-revoked' }); + expect(host.state.seen).toHaveLength(admitted); + }); + it('opens a browser stream with a one-time ticket instead of a header', async () => { const host = await fakeDsh(); const browser = 'https://studio.tailnet.ts.net'; diff --git a/packages/core/test/registry.test.ts b/packages/core/test/registry.test.ts index 11faa7e19b..8a69a2e2d2 100644 --- a/packages/core/test/registry.test.ts +++ b/packages/core/test/registry.test.ts @@ -36,6 +36,39 @@ describe('isValidSlug', () => { }); describe('createPersonaBotRegistry', () => { + it('publishes committed avatar changes and skips unchanged or refused edits', () => { + const root = createRoot(); + let refuse = false; + const database = registryDatabase(root, { + faultInjector: ({ stage }) => { + if (refuse && stage === 'before-commit') throw new Error('write refused'); + }, + }); + const portraits: (string | undefined)[] = []; + const registry = createTestRegistry({ + rootDir: root, + database, + onAvatarChanged: () => portraits.push(registry.get('ada')?.avatar), + }); + registry.create({ slug: 'ada', displayName: 'Ada' }); + registry.setAppearance('ada', DEFAULT_ILLUSTRATED_RECIPE); + const composed = registry.get('ada')!.avatar!; + expect(portraits).toEqual([composed]); + registry.setAppearance('ada', DEFAULT_ILLUSTRATED_RECIPE); + registry.update('ada', { displayName: 'Ada Lovelace' }); + expect(portraits).toHaveLength(1); + registry.update('ada', { avatar: composed }); + expect(registry.get('ada')?.appearance).toBeUndefined(); + expect(portraits).toEqual([composed, composed]); + registry.update('ada', { avatar: '' }); + expect(portraits).toEqual([composed, composed, undefined]); + registry.update('ada', { avatar: '' }); + expect(portraits).toHaveLength(3); + refuse = true; + expect(() => registry.setAppearance('ada', DEFAULT_ILLUSTRATED_RECIPE)).toThrow(); + expect(portraits).toHaveLength(3); + }); + it('retains the committed appearance when the owner refuses the next commit', () => { const root = createRoot(); let refuse = false; diff --git a/scripts/stylex-plugin.mjs b/scripts/stylex-plugin.mjs index 82ef2b3f7f..3f25f8cf2b 100644 --- a/scripts/stylex-plugin.mjs +++ b/scripts/stylex-plugin.mjs @@ -2,11 +2,41 @@ import { transformAsync } from '@babel/core'; import syntaxJsx from '@babel/plugin-syntax-jsx'; import syntaxTypescript from '@babel/plugin-syntax-typescript'; import stylexBabel from '@stylexjs/babel-plugin'; +import { existsSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join, relative } from 'node:path'; export const STYLEX_CSS_PLACEHOLDER = '@bh-stylex-css'; const SOURCE = /\.[cm]?[jt]sx?$/; +function canonicalFilePath(file, rootDir) { + for (let dir = dirname(file); ; dir = dirname(dir)) { + const manifest = join(dir, 'package.json'); + if (existsSync(manifest)) { + const { name } = JSON.parse(readFileSync(manifest, 'utf8')); + return `${name}:${relative(dir, file).replaceAll('\\', '/')}`; + } + if (dirname(dir) === dir) return relative(rootDir, file).replaceAll('\\', '/'); + } +} + +function resolveStylexFile(specifier, importer) { + const require = createRequire(importer); + const base = specifier.replace(/\.(?:[cm]?js|tsx?|jsx)$/, ''); + for (const candidate of [ + specifier, + ...['.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs'].map((suffix) => `${base}${suffix}`), + ]) { + try { + return require.resolve(candidate); + } catch (error) { + if (error.code !== 'MODULE_NOT_FOUND') throw error; + } + } + return null; +} + export function stylexOptions(rootDir) { return { dev: false, @@ -15,7 +45,11 @@ export function stylexOptions(rootDir) { classNamePrefix: 'bhx', styleResolution: 'property-specificity', treeshakeCompensation: true, - unstable_moduleResolution: { type: 'commonJS', rootDir }, + unstable_moduleResolution: { + type: 'custom', + getCanonicalFilePath: (file) => canonicalFilePath(file, rootDir), + filePathResolver: resolveStylexFile, + }, }; } diff --git a/scripts/test/stylex-plugin.test.mjs b/scripts/test/stylex-plugin.test.mjs index 0dd6afa661..73cbaa8134 100644 --- a/scripts/test/stylex-plugin.test.mjs +++ b/scripts/test/stylex-plugin.test.mjs @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { stylexCss } from '../stylex-plugin.mjs'; +import { resolve } from 'node:path'; +import { readFileSync } from 'node:fs'; +import { stylexCss, stylexPlugin } from '../stylex-plugin.mjs'; const longhand = ['bhxa', { ltr: '.bhxa{padding-top:4px}', rtl: null }, 4000]; const shorthand = ['bhxb', { ltr: '.bhxb{padding:8px}', rtl: null }, 2000]; @@ -10,6 +12,31 @@ function selectorOf(css, name) { } describe('StyleX stylesheet', () => { + it.each(['', '.js', '.ts'])( + 'uses portable token-derived classes with %s imports', + async (suffix) => { + const plugin = stylexPlugin({ rootDir: process.cwd() }); + const source = `import * as stylex from '@stylexjs/stylex'; +import { space } from './ds/tokens.stylex${suffix}'; +export const styles = stylex.create({ root: { padding: space.x5 } });`; + const filename = resolve('packages/client/src/client/stylex-platform-probe.ts'); + const tokens = resolve('packages/client/src/client/ds/tokens.stylex.ts'); + await plugin.transform(readFileSync(tokens, 'utf8'), tokens); + const lf = await plugin.transform(source, filename); + const crlf = await plugin.transform(source.replaceAll('\n', '\r\n'), filename); + expect(lf.code).toContain('bhx1tsjn1a'); + expect(crlf.code).toBe(lf.code); + const css = plugin.renderChunk('const css = "@bh-stylex-css";'); + expect(css.code).toContain('bhx1tsjn1a'); + expect(css.code).toContain('padding:16px'); + expect(css.code).not.toContain('var(--bhx'); + }, + ); + + it('keeps CSS output stable when source transforms finish in another order', () => { + expect(stylexCss([longhand, shorthand, hover])).toBe(stylexCss([hover, shorthand, longhand])); + }); + it('gives a class the same specificity in every bundle that ships it', () => { const full = stylexCss([longhand, shorthand, hover]); const partial = stylexCss([longhand]);