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
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Message avatar continuity across session switches

Status: proposed
Translation: current
PR: https://github.com/LodyAI/Lody/pull/1736

[中文](2026-10-20-message-avatar-switch-continuity.zh.md)

## Abstract

Message avatars can briefly show a fallback when returning to a conversation even
though the image bytes are cached. A bounded local correction now preloads the
cached blob used by a stable avatar and removes an effect-delayed source update.
The same isolated Chromium experiment changes from a fallback to an immediately
loaded image on the first blob return, with one avatar HTTP request on both runs.
Decoding runs asynchronously only for used avatars, so cache reads and first paint
do not wait and startup does not decode every stored avatar. Missing sender
metadata remains a separate, unresolved condition; this note remains proposed for
that investigation and does not attribute a deployed switch to one cause.

## Responsibilities and chosen scope

`lib/avatar-cache.ts` owns the renderer-wide URL-keyed memory map and persistent
`lody-avatar-image-v2` CacheStorage bucket. Reads after 24 hours serve the old image
and revalidate in the background; equal bytes retain the existing URL. Navigation
never clears this cache. These keys, TTL and byte-refresh behavior are unchanged.

`hooks/use-stable-avatar-src.ts` preserves the initial remote URL on a cold mount
while filling the cache. It now requests preloading of that local blob after fill,
or when a restored entry is used. The entry retains one off-DOM `Image`, loaded
with asynchronous decoding, for reuse across mounts. Replacing an already warmed
entry warms its successor. Startup restoration alone does not decode stored images.
No first paint or cache read awaits decoding, and preloading never fetches a remote
URL. There are no new timers, subscriptions, profile caches or retained session DOM.

The hook also tracks the input identity alongside its resolved source. New,
removed or newly available inputs derive their current value during render.
Source changes therefore do not commit the previous user's image or wait for an
effect to select an already available image. Background state updates retain the
existing source cleanup fence.

`UserAvatar` still delegates image readiness and broken-image fallback to
`@lody/ui` and Base UI 1.7.0. Its synchronous `complete`/`naturalWidth` path can
reuse a warmed image. A completely cold image, an unfinished preload or browser
memory-pressure eviction can still require the normal loading fallback.

The [sender identity decision](../../implemented/feature/2026-09-08-chat-sender-identity.md)
and [UI avatar migration](../../implemented/feature/2026-09-13-ui-avatar-kbd.md)
remain applicable. This correction changes neither identity nor avatar appearance.

## Synthetic browser evidence

Checkout `4cc74cae8` with existing unrelated working-tree changes, Node 24.14.0,
and headless Chromium 145.0.7632.6. A temporary Vite page imports the production
`UserAvatar`, stable-source hook, cache and UI primitive. A local synthetic SVG
is the only avatar; no product data or external image server is involved.

| State | Before: immediate / next frame | After: immediate / next frame |
| --------------------------------------------------------------------------------------------------------------------- | ------------------------------ | ----------------------------- |
| Original URL loaded; blob bytes cached; leave for an avatar-free surface and return using the blob for the first time | Fallback / fallback | Loaded / loaded |
| Return again with the same decoded blob and available metadata | Loaded / loaded | Loaded / loaded |
| Return with metadata absent, while the blob remains hot and decoded | Fallback / fallback | Fallback / fallback |
| Supply metadata to that mounted avatar | Fallback / loaded | Loaded / loaded |

Each run serves one avatar HTTP request. The original URL is loaded before the
first return, and both returns reuse the same blob URL within that run. Samples
observe DOM availability before paint, not pixel presentation or a guaranteed
number of delayed frames. A hot decoded return is already immediate before the
correction; not every Base UI remount flashes.

## Lifecycle and Effect assessment

The stable-source hook triggers preloading; the shared cache entry owns its image
and decode. Component cleanup fences local updates without aborting an image
another avatar may use. Existing entry replacement, delayed object-URL revocation
and memory clearing own release. Decode rejection is caught and preserves the
existing fallback. One image object is retained per used cached avatar; the browser
still controls decoded-pixel retention. No new wait is added before UI handoff.

Effect feature/fix assessment: this is a browser adapter and React source-derivation
correction within an existing Promise cache. A complete native unit would also
change fetch, revalidation, persistence and application ownership. That exceeds
this small behavior fix, so migration is deferred without a runtime/dependency or
ledger change.

## Remaining profile investigation

`ai-gui/index.tsx` first resolves the sender through `auth:getUserById`. Without
`user.image`, the image cache has no lookup key. The authenticated adapter shares
active queries, but its source registry and hook-local retained snapshots are not
a durable profile cache across all unmounts. `SessionChatInterface` also watches
the current user's profile for pending messages, so an existing same-user watch
can preserve it: a metadata miss must be observed, not assumed for each switch.

If a matching deployed recording confirms missing metadata, evaluate existing
account/workspace-scoped identity as an initial source, preserving authoritative
updates, deletion and account/workspace invalidation. Do not broaden this patch
into a generic stale-query cache or relax stale-access protections. Queries remain
unchanged in the local correction; genuinely absent metadata retains its fallback.

## Verification and limits

Five avatar-cache, seven avatar-source/component and five sender-identity tests
pass; component type-checking passes. New cases render the real `UserAvatar` with
explicitly signalled browser image loads and assert original first display,
warmed return, restored offline return, decoding failure and first-commit source
arrival/change/removal. A temporary ablation with unpatched source fails the warm
return and first-commit regressions; the patched sources are restored afterward.

Synthetic source, before/after browser results and ablation output are outside the
repository at `/private/tmp/lody-avatar-investigation-20261020/`. The existing
browser tab could not be connected for live validation. No deployed revision,
live metadata gap, Electron behavior, memory-pressure eviction or product timing
was measured. In an isolated checkout based on `7d6695cf3`, `pnpm check` and
`pnpm format` pass, including all 586 component test files (5,739 tests), CLI tests,
type-checking, lint and repository boundaries. Documentation checking retains six
existing broken links into absent ACP submodules and reports no new errors.
No dependency or Spec changed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# 会话切换时的消息头像连续显示

Status: proposed
Translation: current
PR: https://github.com/LodyAI/Lody/pull/1736

[English](2026-10-20-message-avatar-switch-continuity.md)

## 摘要

切回会话时,即使图片字节已经缓存,消息头像也可能短暂显示占位图。一个小范围本地
修复现在会预加载稳定头像实际使用的缓存 blob,并消除通过 effect 延迟更新来源的
额外阶段。同一 Chromium 隔离实验中,首次切回 blob 从占位图变为立即显示已加载
图片,前后均只有一次头像 HTTP 请求。解码异步进行且仅针对实际使用的头像,缓存
读取和首次显示不等待,启动也不会解码所有已存头像。发送者资料暂缺仍是另一条尚未
解决的条件;本笔记为该调查保留提案状态,没有将线上现象归因到唯一原因。

## 职责与本次范围

`lib/avatar-cache.ts` 拥有按 URL 索引、跨会话共享的渲染进程内存缓存,以及
`lody-avatar-image-v2` CacheStorage 持久缓存。超过 24 小时后的读取先返回旧图,
再后台校验;字节不变则保留现有 URL。导航不会清理该缓存。缓存键、TTL 和图片字节
更新策略均不变。

`hooks/use-stable-avatar-src.ts` 在冷挂载时保留首次使用的原始 URL,同时填充缓存。
现在它在填充完成或实际使用恢复条目时,请求预加载这个本地 blob。条目保留一个采用
异步解码的离屏 `Image`,供后续挂载复用。已预热条目的替换也会预热新条目。仅启动
恢复不会解码所有已存图片。首次显示和缓存读取均不等待解码;预加载不抓取远程 URL。
没有增加定时器、订阅、用户资料缓存或会话 DOM 保留。

Hook 还将输入身份与解析结果一起保存。新来源、移除来源和刚取得的来源在渲染时直接
派生当前值。因此来源变化不会提交上一个用户的图片,也不需要等待 effect 才选择已经
可用的图片。后台状态更新继续遵循原有来源清理边界。

`UserAvatar` 仍然将图片就绪和坏图占位交给 `@lody/ui` 与 Base UI 1.7.0。
它的同步 `complete` / `naturalWidth` 路径可以复用已预热图片。完全冷的图片、尚未
完成的预加载或浏览器内存压力淘汰,仍可能需要正常加载占位。

[发送者身份决策](../../implemented/feature/2026-09-08-chat-sender-identity.md)和
[UI 头像迁移](../../implemented/feature/2026-09-13-ui-avatar-kbd.md)仍然适用。
本修复不改变身份或头像外观。

## 合成浏览器证据

实验基于 `4cc74cae8` 及工作区已有的无关改动,使用 Node 24.14.0 和无界面的
Chromium 145.0.7632.6。临时 Vite 页面导入生产 `UserAvatar`、稳定来源 Hook、
缓存和 UI 原语。唯一头像是本地生成的合成 SVG,不涉及产品数据或外部图片服务器。

| 状态 | 修复前:立即/下一帧 | 修复后:立即/下一帧 |
| ------------------------------------------------------------------------- | -------------------- | -------------------- |
| 原始 URL 已加载、blob 字节已缓存;切到没有头像的界面后返回,首次使用 blob | 占位/占位 | 已加载/已加载 |
| 同一个 blob 已解码,资料就绪,再次返回 | 已加载/已加载 | 已加载/已加载 |
| blob 仍然热缓存且已解码,但返回时缺少资料 | 占位/占位 | 占位/占位 |
| 为该已挂载头像补上资料 | 占位/已加载 | 已加载/已加载 |

前后两次实验各只有一次头像 HTTP 请求到达本地服务器。第一次返回前,原始 URL
已经加载;每次实验内的两次返回复用同一个缓存 blob URL。样本观察绘制前的 DOM
可用状态,不是像素呈现测量,也不保证延迟帧数。已解码的热返回在修复前就能立即
显示,不能说每次 Base UI 重新挂载都会闪。

## 生命周期与 Effect 评估

稳定来源 Hook 触发预加载,共享缓存条目拥有图片及解码。组件卸载会阻止其本地更新,
但不终止其他头像可能使用的共享图片。沿用已有的条目替换、对象 URL 延迟撤销和内存
清理职责释放资源。解码拒绝被处理,保留已有占位。每个实际使用的缓存头像保留一个
图片对象;解码像素是否继续保留仍由浏览器控制。UI 交接前不增加新的等待。

Effect 功能/修复评估:本次是在已有 Promise 缓存中修正浏览器适配和 React 来源
派生。完整原生单元还需改变抓取、后台校验、持久化和应用所有权,超出这个小范围行为
修复,因此延后迁移,不增加运行时或依赖,也不改变迁移台账。

## 尚待调查的用户资料路径

`ai-gui/index.tsx` 先通过 `auth:getUserById` 解析发送者。缺少 `user.image` 时,
图片缓存没有查询键。认证适配器共享活跃查询,但查询源注册表和 Hook 内保留快照
不是跨所有卸载的持久资料缓存。`SessionChatInterface` 也会为待发送消息订阅当前
用户资料,因此已有的同用户订阅可能保住结果;不能假定每次切换都缺资料,必须观察。

如果匹配部署版本的录制确认资料缺失,再评估用已有账户/工作区范围内身份提供初始
来源,并保留权威更新、删除以及账户/工作区失效逻辑。不将本次补丁扩展成通用过期
查询缓存,不放宽过期访问数据的保护。本地修复没有改变查询;确实缺资料时保留占位。

## 验证与限制

头像缓存五项、头像来源/组件七项、发送者身份五项测试通过,组件类型检查通过。
新增用例渲染真实 `UserAvatar`,显式控制浏览器图片加载,检查原图首次显示、预热
返回、持久恢复后的离线返回、解码失败,以及资料到达/变化/移除时的首次提交。
临时换回未修复源码时,预热返回和首次提交两项回归用例失败;随后恢复了修复源码。

合成源码、浏览器前后结果和消融输出保存在仓库外的
`/private/tmp/lody-avatar-investigation-20261020/`。未能连接已有浏览器页面完成线上
验证。没有测量部署版本、真实资料缺口、Electron 行为、内存压力淘汰或产品时延。
基于 `7d6695cf3` 的隔离检出中,`pnpm check` 和 `pnpm format` 通过,包含组件包
全部 586 个测试文件(5,739 项测试)、CLI 测试、类型检查、lint 和仓库边界检查。
文档检查保留六个指向缺失 ACP 子模块的既有坏链接,没有新增错误。没有修改依赖
或 Spec。
13 changes: 13 additions & 0 deletions packages/components/src/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ Missing or mismatched Providers expose no catalog; extension changes invalidate
old capabilities until a matching probe arrives. A matching extension catalog
must not be read as though no extensions were selected.

## Avatar sources

`use-stable-avatar-src.ts` keeps the initial remote URL on a cold mount while
filling the byte cache, then asks `lib/avatar-cache.ts` to preload and asynchronously
decode that local blob for a return visit. Only avatars used by this hook are
decoded; startup restoration does not decode the entire persistent cache. The
cache entry retains the off-DOM image for the same lifetime as its blob URL.
Source changes select the current URL synchronously, including delayed profile
arrival and image removal, while background callbacks remain tied to their source.
First paint and cache reads never await decoding. Fully cold images and missing
profile data can still show a fallback. Evidence and limits:
[avatar continuity](../../../../.agents/notes/proposed/bug-fix/2026-10-20-message-avatar-switch-continuity.md).

## Cloud query recovery

`use-recoverable-convex-query.ts` owns the authenticated query adapter. Each client,
Expand Down
25 changes: 17 additions & 8 deletions packages/components/src/hooks/use-stable-avatar-src.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { peekAvatarBlobUrl, resolveAvatarBlobUrl } from '@/lib/avatar-cache';
import { peekAvatarBlobUrl, preloadAvatarBlobUrl, resolveAvatarBlobUrl } from '@/lib/avatar-cache';

/**
* Resolve an avatar URL to a locally-cached blob URL, cache-first, and swap to
Expand All @@ -15,26 +15,33 @@ import { peekAvatarBlobUrl, resolveAvatarBlobUrl } from '@/lib/avatar-cache';
* - No hot blob yet: paint the raw URL now — it IS the current remote, served
* from the browser HTTP cache — and fill the blob cache in the background
* WITHOUT swapping. The bytes are identical, so a raw→blob swap would only
* cause a re-decode flicker; the blob is used on the next mount.
* cause a re-decode flicker; the cache warms the blob for the next mount.
*
* `useCachedAvatarSrc` deliberately DOES swap raw→blob on first load — it is
* tuned for GitHub owner avatars whose short `max-age` makes the raw URL an
* unreliable paint, so the blob is the first dependable one. Don't conflate.
*/
export function useStableAvatarSrc(src?: string | null): string | undefined {
const [avatarSrc, setAvatarSrc] = useState<string | null | undefined>(
() => peekAvatarBlobUrl(src) ?? src
);
const [avatar, setAvatar] = useState(() => ({ src, resolved: peekAvatarBlobUrl(src) ?? src }));
// A profile/source change must paint its own image in this commit, including
// undefined → URL after metadata arrives, without showing the previous user.
const avatarSrc = avatar.src === src ? avatar.resolved : (peekAvatarBlobUrl(src) ?? src);

useEffect(() => {
let cancelled = false;
const setAvatarSrc = (resolved: string | null | undefined) => {
setAvatar((current) =>
current.src === src && current.resolved === resolved ? current : { src, resolved }
);
};

if (src == null) {
setAvatarSrc(src);
} else {
const cachedBlobUrl = peekAvatarBlobUrl(src);
if (cachedBlobUrl != null) {
// Local copy is hot: paint it now, revalidate the remote, swap on change.
preloadAvatarBlobUrl(src);
setAvatarSrc(cachedBlobUrl);
void resolveAvatarBlobUrl(src, {
onUpdate: (updated) => {
Expand All @@ -48,9 +55,11 @@ export function useStableAvatarSrc(src?: string | null): string | undefined {
// cache in the background; deliberately don't swap, to avoid a re-decode
// flicker when the bytes are the same.
setAvatarSrc(src);
void resolveAvatarBlobUrl(src).catch(() => {
/* Already showing the raw URL; a failed hydration retries next mount. */
});
void resolveAvatarBlobUrl(src)
.then(() => preloadAvatarBlobUrl(src))
.catch(() => {
/* Already showing the raw URL; a failed hydration retries next mount. */
});
}
}

Expand Down
Loading
Loading