mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
fix(dsh-plugin-browserskill): stop observation overlay from flashing between frames
Hold the last ready thumbnail until the next one decodes, and swap the img src in place instead of remounting through a placeholder.
This commit is contained in:
@@ -79,6 +79,8 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Fade only on first mount. The <img> is reused across frames (src swap,
|
||||
no React key) so this animation must not replay on every breath. */
|
||||
.thumb {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
|
||||
@@ -139,7 +139,7 @@ function StripItem(props: {
|
||||
useEffect(() => {
|
||||
store.ensureThumbnail(thumbId);
|
||||
}, [store, thumbId]);
|
||||
const thumb = thumbId !== undefined ? store.getSnapshot().thumbnails[thumbId] : undefined;
|
||||
const thumb = store.getSnapshot().displayFrames[obs.sessionId];
|
||||
const state = obs.dead === true ? "dead" : statusOf(obs);
|
||||
return (
|
||||
<div
|
||||
@@ -158,7 +158,7 @@ function StripItem(props: {
|
||||
onClick={() => onTogglePin(obs.sessionId)}
|
||||
>
|
||||
<span className={css["strip-thumb"]}>
|
||||
{thumb?.status === "ready" && thumb.url !== undefined ? (
|
||||
{thumb?.url !== undefined ? (
|
||||
<img src={thumb.url} alt="" />
|
||||
) : (
|
||||
<span className={css["strip-thumb-empty"]} />
|
||||
@@ -222,7 +222,11 @@ function OverlayBody(props: {
|
||||
useEffect(() => {
|
||||
store.ensureThumbnail(thumbId);
|
||||
}, [store, thumbId]);
|
||||
const thumb = thumbId !== undefined ? store.getSnapshot().thumbnails[thumbId] : undefined;
|
||||
// Paint the last good frame while the next attachment decodes — swapping
|
||||
// to a placeholder (and remounting <img> with a fade) is what made the
|
||||
// card flash on every breath / action-end capture.
|
||||
const thumb = focus !== undefined ? store.getSnapshot().displayFrames[focus.sessionId] : undefined;
|
||||
const displayUrl = thumb?.url;
|
||||
|
||||
const canInterrupt =
|
||||
available &&
|
||||
@@ -266,11 +270,10 @@ function OverlayBody(props: {
|
||||
) : null}
|
||||
</div>
|
||||
<div className={css.stage}>
|
||||
{thumb?.status === "ready" && thumb.url !== undefined ? (
|
||||
{displayUrl !== undefined ? (
|
||||
<img
|
||||
key={thumb.url}
|
||||
className={css.thumb}
|
||||
src={thumb.url}
|
||||
src={displayUrl}
|
||||
alt={`session ${focus?.sessionId ?? ""} view`}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* SSE increment stream (with a state refetch on every (re)open — events that
|
||||
* fired while the stream was down are otherwise lost forever), on-demand
|
||||
* thumbnail blob loading through the session-authorized readAttachment path,
|
||||
* and the interrupt call. All I/O is injected so tests never touch a network.
|
||||
* and the interrupt call. The last ready blob per session is held until its
|
||||
* replacement decodes so the overlay can swap frames in place. All I/O is
|
||||
* injected so tests never touch a network.
|
||||
*/
|
||||
|
||||
import type { ObservationEvent, SessionObservation } from "../observation";
|
||||
@@ -38,6 +40,12 @@ export interface OverlaySnapshot {
|
||||
/** Whether the SSE increment stream is currently subscribed. */
|
||||
readonly subscribed: boolean;
|
||||
readonly thumbnails: Readonly<Record<string, ThumbnailState>>;
|
||||
/**
|
||||
* sessionId → the frame the overlay should paint. While a newer attachment
|
||||
* is still loading (or failed), this keeps the last good URL so the card
|
||||
* never drops to a placeholder between breaths.
|
||||
*/
|
||||
readonly displayFrames: Readonly<Record<string, ThumbnailState>>;
|
||||
/** False when the host reports the browser/daemon as unreachable. */
|
||||
readonly available: boolean;
|
||||
}
|
||||
@@ -55,14 +63,17 @@ function revoke(url: string | undefined): void {
|
||||
export class ObservationClientStore {
|
||||
private sessions = new Map<string, SessionObservation>();
|
||||
private thumbs = new Map<string, ThumbnailState>();
|
||||
/** sessionId → the attachment id currently displayed for it (leak guard). */
|
||||
/** sessionId → the attachment id currently advertised for it. */
|
||||
private readonly thumbBySession = new Map<string, string>();
|
||||
/** sessionId → last successfully decoded frame (held across the next load). */
|
||||
private readonly lastReady = new Map<string, { attachmentId: string; url: string }>();
|
||||
private listeners = new Set<() => void>();
|
||||
private events: EventSourceLike | undefined;
|
||||
private snapshot: OverlaySnapshot = {
|
||||
sessions: [],
|
||||
subscribed: false,
|
||||
thumbnails: {},
|
||||
displayFrames: {},
|
||||
available: true,
|
||||
};
|
||||
private available = true;
|
||||
@@ -82,11 +93,43 @@ export class ObservationClientStore {
|
||||
sessions: [...this.sessions.values()],
|
||||
subscribed: this.events !== undefined,
|
||||
thumbnails: Object.fromEntries(this.thumbs),
|
||||
displayFrames: this.buildDisplayFrames(),
|
||||
available: this.available,
|
||||
};
|
||||
for (const listener of [...this.listeners]) listener();
|
||||
}
|
||||
|
||||
/** Frame the overlay should paint for one session (last good while next loads). */
|
||||
private frameFor(sessionId: string): ThumbnailState | undefined {
|
||||
const attachmentId = this.thumbBySession.get(sessionId);
|
||||
const current = attachmentId !== undefined ? this.thumbs.get(attachmentId) : undefined;
|
||||
if (current?.status === "ready" && current.url !== undefined) return current;
|
||||
const held = this.lastReady.get(sessionId);
|
||||
if (held !== undefined) {
|
||||
return {
|
||||
status: current?.status === "error" ? "error" : "ready",
|
||||
url: held.url,
|
||||
};
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private buildDisplayFrames(): Record<string, ThumbnailState> {
|
||||
const frames: Record<string, ThumbnailState> = {};
|
||||
for (const sessionId of this.sessions.keys()) {
|
||||
const frame = this.frameFor(sessionId);
|
||||
if (frame !== undefined) frames[sessionId] = frame;
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
private sessionOf(attachmentId: string): string | undefined {
|
||||
for (const [sessionId, id] of this.thumbBySession) {
|
||||
if (id === attachmentId) return sessionId;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** (Re)pull the full state: initial load and every SSE (re)open. */
|
||||
private refreshState(): void {
|
||||
void this.deps
|
||||
@@ -141,28 +184,42 @@ export class ObservationClientStore {
|
||||
for (const thumb of this.thumbs.values()) revoke(thumb.url);
|
||||
this.thumbs.clear();
|
||||
this.thumbBySession.clear();
|
||||
this.lastReady.clear();
|
||||
this.sessions.clear();
|
||||
this.publish();
|
||||
}
|
||||
|
||||
/** Forget one session's tracked thumbnail, revoking its blob URL. */
|
||||
/** Forget one session's tracked + held frames, revoking their blob URLs. */
|
||||
private dropThumb(sessionId: string): void {
|
||||
const attachmentId = this.thumbBySession.get(sessionId);
|
||||
if (attachmentId === undefined) return;
|
||||
const held = this.lastReady.get(sessionId);
|
||||
this.thumbBySession.delete(sessionId);
|
||||
revoke(this.thumbs.get(attachmentId)?.url);
|
||||
this.thumbs.delete(attachmentId);
|
||||
this.lastReady.delete(sessionId);
|
||||
if (attachmentId !== undefined) {
|
||||
revoke(this.thumbs.get(attachmentId)?.url);
|
||||
this.thumbs.delete(attachmentId);
|
||||
}
|
||||
if (held !== undefined && held.attachmentId !== attachmentId) {
|
||||
revoke(held.url);
|
||||
this.thumbs.delete(held.attachmentId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track the frame a session currently shows; replacing a frame revokes the
|
||||
* old blob URL immediately — blob URLs must not accumulate one per frame.
|
||||
* Track the frame a session currently advertises. The last *ready* blob is
|
||||
* kept until the replacement decodes — dropping it on the upsert is what
|
||||
* made the overlay flash a placeholder between breaths.
|
||||
*/
|
||||
private trackThumb(sessionId: string, attachmentId: string | undefined): void {
|
||||
const previous = this.thumbBySession.get(sessionId);
|
||||
if (previous === attachmentId) return;
|
||||
this.dropThumb(sessionId);
|
||||
if (attachmentId !== undefined) this.thumbBySession.set(sessionId, attachmentId);
|
||||
else this.thumbBySession.delete(sessionId);
|
||||
const heldId = this.lastReady.get(sessionId)?.attachmentId;
|
||||
if (previous !== undefined && previous !== heldId) {
|
||||
revoke(this.thumbs.get(previous)?.url);
|
||||
this.thumbs.delete(previous);
|
||||
}
|
||||
}
|
||||
|
||||
private apply(event: ObservationEvent): void {
|
||||
@@ -171,6 +228,7 @@ export class ObservationClientStore {
|
||||
for (const thumb of this.thumbs.values()) revoke(thumb.url);
|
||||
this.thumbs.clear();
|
||||
this.thumbBySession.clear();
|
||||
this.lastReady.clear();
|
||||
} else if (event.type === "remove" && event.session !== undefined) {
|
||||
this.sessions.delete(event.session.sessionId);
|
||||
this.dropThumb(event.session.sessionId);
|
||||
@@ -185,8 +243,9 @@ export class ObservationClientStore {
|
||||
|
||||
/**
|
||||
* Ensure a thumbnail load is in flight for one attachment reference. New
|
||||
* frames replace the old URL; failures keep the last good frame (the caller
|
||||
* renders `status: 'error'` as a small badge over the old image).
|
||||
* frames replace the old URL only after they decode; failures keep the last
|
||||
* good frame (the caller renders `status: 'error'` as a small badge over
|
||||
* the old image).
|
||||
*/
|
||||
ensureThumbnail(attachmentId: string | undefined): void {
|
||||
if (attachmentId === undefined || this.thumbs.has(attachmentId)) return;
|
||||
@@ -200,6 +259,15 @@ export class ObservationClientStore {
|
||||
return;
|
||||
}
|
||||
this.thumbs.set(attachmentId, { status: "ready", url });
|
||||
const sessionId = this.sessionOf(attachmentId);
|
||||
if (sessionId !== undefined) {
|
||||
const previous = this.lastReady.get(sessionId);
|
||||
if (previous !== undefined && previous.attachmentId !== attachmentId) {
|
||||
revoke(previous.url);
|
||||
this.thumbs.delete(previous.attachmentId);
|
||||
}
|
||||
this.lastReady.set(sessionId, { attachmentId, url });
|
||||
}
|
||||
this.publish();
|
||||
},
|
||||
() => {
|
||||
|
||||
Reference in New Issue
Block a user