Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
15 changes: 10 additions & 5 deletions frontend/react-neko-chat/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -538,18 +538,23 @@ describe('App', () => {
fireEvent.click(container.querySelector<HTMLButtonElement>('.compact-history-visibility-handle')!);
expect(container.querySelector('.compact-export-history-anchor')).toHaveAttribute('data-compact-export-history-visibility', 'closing');

await act(async () => {
await vi.advanceTimersByTimeAsync(COMPACT_EXPORT_HISTORY_VISIBILITY_ANIMATION_MS);
});
expect(container.querySelector('.compact-export-history-anchor')).toBeNull();

rerender(
<App
chatSurfaceMode="compact"
compactChatState="input"
messages={[initialMessage, userMessage, assistantMessage]}
/>,
);
expect(container.querySelector('.compact-export-history-anchor')).toHaveAttribute('data-compact-export-history-visibility', 'closing');
expect(container.querySelector('[data-compact-export-history-message-id="user-history-after-close"]')).toBeNull();
expect(container.querySelector('[data-compact-export-history-message-id="assistant-history-after-close"]')).toBeNull();

await act(async () => {
await vi.advanceTimersByTimeAsync(COMPACT_EXPORT_HISTORY_VISIBILITY_ANIMATION_MS);
});
expect(container.querySelector('.compact-export-history-anchor')).toBeNull();

rerender(<App chatSurfaceMode="compact" compactChatState="input" messages={[initialMessage, userMessage, assistantMessage]} />);
expect(container.querySelector('.compact-export-history-anchor')).toBeNull();
expect(container.querySelector('[data-compact-export-history-message-id="user-history-after-close"]')).toBeNull();
expect(container.querySelector('[data-compact-export-history-message-id="assistant-history-after-close"]')).toBeNull();
Expand Down
11 changes: 9 additions & 2 deletions frontend/react-neko-chat/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1286,6 +1286,7 @@ export default function App({
const [compactSurfaceResizeWidth, setCompactSurfaceResizeWidth] = useState<number | null>(null);
const [compactExportHistoryOpen, setCompactExportHistoryOpen] = useState(readPersistedCompactExportHistoryOpen);
const [compactExportHistoryMounted, setCompactExportHistoryMounted] = useState(readPersistedCompactExportHistoryOpen);
const [compactExportHistoryClosingMessages, setCompactExportHistoryClosingMessages] = useState<ChatMessage[] | null>(null);
const [compactExportControlsOpen, setCompactExportControlsOpen] = useState(false);
const [compactExportPreviewOpen, setCompactExportPreviewOpen] = useState(false);
const [compactExportSelectedIds, setCompactExportSelectedIds] = useState<Set<string>>(() => new Set());
Expand Down Expand Up @@ -1557,21 +1558,24 @@ export default function App({
}, []);
const openCompactExportHistory = useCallback(() => {
clearCompactExportHistoryUnmountTimer();
setCompactExportHistoryClosingMessages(null);
setCompactExportHistoryMounted(true);
setCompactExportHistoryOpen(true);
persistCompactExportHistoryOpen(true);
setCompactExportAutoScrollToBottom(true);
}, [clearCompactExportHistoryUnmountTimer]);
const closeCompactExportHistory = useCallback(() => {
clearCompactExportHistoryUnmountTimer();
setCompactExportHistoryClosingMessages(messages);
setCompactExportHistoryOpen(false);
persistCompactExportHistoryOpen(false);
setCompactExportPreviewOpen(false);
compactExportHistoryUnmountTimerRef.current = window.setTimeout(() => {
setCompactExportHistoryMounted(false);
setCompactExportHistoryClosingMessages(null);
compactExportHistoryUnmountTimerRef.current = null;
}, COMPACT_EXPORT_HISTORY_VISIBILITY_ANIMATION_MS);
}, [clearCompactExportHistoryUnmountTimer]);
}, [clearCompactExportHistoryUnmountTimer, messages]);
useEffect(() => () => {
clearCompactExportHistoryUnmountTimer();
}, [clearCompactExportHistoryUnmountTimer]);
Expand Down Expand Up @@ -5187,9 +5191,12 @@ export default function App({
? (typeof document !== 'undefined' ? createPortal(choiceLayerNode, document.body) : choiceLayerNode)
: null;

const compactExportHistoryMessages = compactExportHistoryOpen
? messages
: (compactExportHistoryClosingMessages || messages);
const compactExportHistoryElement = isCompactSurface && compactExportHistoryMounted ? (
<CompactExportHistoryPanel
messages={messages}
messages={compactExportHistoryMessages}
selectedIds={compactExportSelectedIds}
selectedCount={compactExportSelectedCount}
selectableCount={compactExportSelectableCount}
Expand Down
6 changes: 2 additions & 4 deletions static/app-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -1853,12 +1853,10 @@
NEKO_MODEL_CAT_TRANSITION_MIN_SIZE,
Math.min(NEKO_MODEL_CAT_TRANSITION_MAX_SIZE, Math.round(basis * NEKO_MODEL_CAT_TRANSITION_SIZE_FACTOR))
);
const maxLeft = Math.max(0, window.innerWidth - size);
const maxTop = Math.max(0, window.innerHeight - size);

return {
left: Math.max(0, Math.min(Math.round(centerX - size / 2), maxLeft)),
top: Math.max(0, Math.min(Math.round(centerY - size / 2), maxTop)),
left: Math.round(centerX - size / 2),
top: Math.round(centerY - size / 2),
width: size,
height: size,
};
Expand Down
Binary file modified static/assets/neko-idle/cat_model_change.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
123 changes: 114 additions & 9 deletions static/avatar-ui-buttons.js
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ const _NEKO_IDLE_CAT1_COMPACT_TOP_EDGE_DROP_PX = 52;
const _NEKO_IDLE_CAT1_COMPACT_TOP_EDGE_DROP_ANIMATION_MS = 360;
const _NEKO_IDLE_CAT1_COMPACT_TOP_EDGE_DROP_COOLDOWN_MS = 900;
const _NEKO_IDLE_CAT1_COMPACT_SURFACE_SETTLE_SYNC_MS = 160;
const _NEKO_IDLE_CAT1_COMPACT_MIRROR_SETTLE_HIDE_DELAY_MS = 180;
const _NEKO_IDLE_CAT1_WALK_ENTER_DISTANCE_PX = 120;
const _NEKO_IDLE_CAT1_WALK_EXIT_DISTANCE_PX = 14;
const _NEKO_IDLE_CAT1_WALK_SPEED_PX_PER_SEC = 82;
Expand Down Expand Up @@ -576,6 +577,7 @@ function _playNekoIdleCat1AmbientSound(token) {
_pickNekoIdleCat1AmbientSoundUrl(),
_NEKO_IDLE_CAT1_AMBIENT_SOUND_VOLUME
);
_playNekoIdleCat1SoundReaction();
}

function _scheduleNekoIdleCat1AmbientSoundInterval(intervalStartedAt) {
Expand Down Expand Up @@ -639,6 +641,77 @@ function _fadeOutNekoIdleCat1DragSound() {
_fadeOutNekoIdleSoundAudio(_nekoIdleCat1DragSoundState, _NEKO_IDLE_CAT1_DRAG_SOUND_FADE_OUT_MS);
}

function _syncNekoIdleCat1CompactMirrorReaction(button, container, assetUrl, reason) {
const state = button && (button.__nekoIdleReturnSubactionState || button.__nekoIdleCat1Journey);
if (!state ||
!container ||
!container.__nekoIdleCat1CompactMirrorActive ||
state.targetKind !== _NEKO_IDLE_CAT1_TARGET_KIND_COMPACT_TOP_EDGE) {
return;
}

const surfaceRect = _getNekoIdleChatCompactSurfaceRect();
if (!surfaceRect) return;
_setNekoIdleCat1CompactMirrorActive(button, container, true, {
reason: reason || 'cat1-sound-reaction',
surfaceRect: surfaceRect,
target: {
anchorRatio: state.compactFollowAnchorRatio
},
assetUrl: assetUrl
});
}

function _playNekoIdleCat1SoundReaction() {
_forEachNekoIdleReturnButton((button) => {
if (_normalizeNekoIdleReturnTier(button.getAttribute('data-neko-idle-tier')) !== _NEKO_IDLE_TIER_CAT1) return;
if (_isNekoIdleReturnDragActionActive(button)) return;
const container = _getNekoIdleReturnContainerFromButton(button);
if (!container || container.style.display === 'none') return;
const state = button.__nekoIdleReturnSubactionState || button.__nekoIdleCat1Journey;
if (!state || state.targetKind !== _NEKO_IDLE_CAT1_TARGET_KIND_COMPACT_TOP_EDGE) return;
const art = button.querySelector('.neko-idle-return-art');
if (!art) return;

_playNekoIdleHoverArt(art, _NEKO_IDLE_TIER_CAT1);
const reactionSrc = art.__nekoIdleHoverSrc;
if (!reactionSrc) return;
const reactionStartedAt = Math.max(0, Number(art.__nekoIdleHoverStartedAt) || Date.now());
const hoverToken = art.__nekoIdleHoverToken || 0;
const mirrorToken = (art.__nekoIdleCat1CompactMirrorReactionToken || 0) + 1;
art.__nekoIdleCat1CompactMirrorReactionToken = mirrorToken;
_finishNekoIdleHoverArtAfterPlayback(art, _NEKO_IDLE_TIER_CAT1);
_syncNekoIdleCat1CompactMirrorReaction(button, container, reactionSrc, 'cat1-sound-reaction');

_getNekoIdleGifDurationMs(reactionSrc).then((durationMs) => {
if ((art.__nekoIdleCat1CompactMirrorReactionToken || 0) !== mirrorToken) return;
const elapsedMs = Math.max(0, Date.now() - reactionStartedAt);
const remainingMs = Math.max(0, (Number(durationMs) || 0) - elapsedMs);
window.setTimeout(() => {
if ((art.__nekoIdleCat1CompactMirrorReactionToken || 0) !== mirrorToken) return;
const latestHoverToken = art.__nekoIdleHoverToken || 0;
const hoverStillPlaying = latestHoverToken === hoverToken;
const hoverFinishedThisReaction = latestHoverToken === hoverToken + 1 && !art.__nekoIdleHoverSrc;
if (!hoverStillPlaying && !hoverFinishedThisReaction) return;
const latestState = button.__nekoIdleReturnSubactionState || button.__nekoIdleCat1Journey;
const latestContainer = _getNekoIdleReturnContainerFromButton(button);
if (!latestState ||
latestState.targetKind !== _NEKO_IDLE_CAT1_TARGET_KIND_COMPACT_TOP_EDGE ||
!latestContainer ||
!latestContainer.__nekoIdleCat1CompactMirrorActive) {
return;
}
_syncNekoIdleCat1CompactMirrorReaction(
button,
latestContainer,
_getNekoIdleReturnCurrentArtUrl(button, _NEKO_IDLE_TIER_CAT1),
'cat1-sound-reaction-finished'
);
}, remainingMs);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
}

const _NEKO_IDLE_RETURN_SUBACTION_CAT1_CHAT_FOLLOW = Object.freeze({
id: 'cat1-chat-follow',
tier: _NEKO_IDLE_TIER_CAT1,
Expand Down Expand Up @@ -1308,29 +1381,61 @@ function _getNekoIdleScreenRectFromCompactSurfaceRect(rect) {
}

function _postNekoIdleCat1CompactMirrorState(payload) {
const message = Object.assign({
action: 'idle_cat1_compact_mirror_state',
source: 'pet-window',
lanlan_name: _getNekoIdleCurrentLanlanName(),
timestamp: Date.now()
}, payload || {});
let dispatchedLocal = false;
try {
window.dispatchEvent(new CustomEvent('neko:idle-cat1-compact-mirror-state', {
detail: Object.assign({
via: 'local'
}, message)
Comment on lines +1392 to +1395
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't mirror compact CAT1 locally from the pet window

When the compact chat runs in the separate Electron chat window, templates/index.html loads both avatar-ui-buttons.js and app-react-chat-window.js, so this new local dispatch is consumed by the pet window's handleIdleCat1CompactMirrorState before the broadcast reaches the chat window. In that context the original cat is hidden by is-cat1-compact-mirror-active, while the pet window also creates a compact mirror using the chat window's screen rect, which can leave a duplicate/mispositioned cat in the pet window; the local event should be gated to same-page compact surfaces or ignored by the pet window.

Useful? React with 👍 / 👎.

}));
dispatchedLocal = true;
} catch (_) {}

const channel = window.appInterpage && window.appInterpage.nekoBroadcastChannel;
if (!channel || typeof channel.postMessage !== 'function') return false;
if (!channel || typeof channel.postMessage !== 'function') return dispatchedLocal;
try {
channel.postMessage(Object.assign({
action: 'idle_cat1_compact_mirror_state',
source: 'pet-window',
lanlan_name: _getNekoIdleCurrentLanlanName(),
timestamp: Date.now()
}, payload || {}));
channel.postMessage(message);
return true;
} catch (error) {
if (typeof console !== 'undefined' && console.warn) {
console.warn('[NekoIdleCat1] compact mirror postMessage failed:', error && error.message ? error.message : error);
}
return false;
return dispatchedLocal;
}
}

function _setNekoIdleCat1CompactMirrorActive(button, container, active, options = {}) {
if (!container) return false;
const nextActive = !!active;
const state = button && (button.__nekoIdleReturnSubactionState || button.__nekoIdleCat1Journey);
if (nextActive && container.__nekoIdleCat1CompactMirrorSettleTimer) {
clearTimeout(container.__nekoIdleCat1CompactMirrorSettleTimer);
container.__nekoIdleCat1CompactMirrorSettleTimer = 0;
}
if (!nextActive) {
const inactiveReason = options.reason || 'inactive';
if (
inactiveReason === 'compact-surface-settled' &&
!options.forceImmediate &&
container.__nekoIdleCat1CompactMirrorActive
) {
if (!container.__nekoIdleCat1CompactMirrorSettleTimer) {
container.__nekoIdleCat1CompactMirrorSettleTimer = setTimeout(() => {
container.__nekoIdleCat1CompactMirrorSettleTimer = 0;
_setNekoIdleCat1CompactMirrorActive(button, container, false, {
reason: inactiveReason,
forceImmediate: true
});
}, _NEKO_IDLE_CAT1_COMPACT_MIRROR_SETTLE_HIDE_DELAY_MS);
}
return true;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (!container.__nekoIdleCat1CompactMirrorActive) return true;
container.__nekoIdleCat1CompactMirrorActive = false;
container.classList.remove('is-cat1-compact-mirror-active');
Expand Down Expand Up @@ -1365,7 +1470,7 @@ function _setNekoIdleCat1CompactMirrorActive(button, container, active, options
},
overlapPx: _NEKO_IDLE_CAT1_COMPACT_TOP_EDGE_OVERLAP_PX,
sidePaddingPx: _NEKO_IDLE_CAT1_COMPACT_TOP_EDGE_SIDE_PADDING_PX,
assetUrl: _getNekoIdleReturnAssetUrl(_NEKO_IDLE_TIER_CAT1),
assetUrl: options.assetUrl || _getNekoIdleReturnAssetUrl(_NEKO_IDLE_TIER_CAT1),
facingRight: !!(state && state.facingRight)
});
if (!posted) return false;
Expand Down
22 changes: 22 additions & 0 deletions tests/unit/test_avatar_return_button_idle_tiers_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,14 @@ def test_model_cat_transition_contract_is_present():
assert "NEKO_MODEL_CAT_TRANSITION_MAX_SIZE = 680" in source
assert "NEKO_MODEL_CAT_TRANSITION_SIZE_FACTOR = 0.86" in source
assert "Math.round(basis * NEKO_MODEL_CAT_TRANSITION_SIZE_FACTOR)" in source
transition_rect_block = source[
source.index("function normalizeNekoTransitionRect(anchorRect, container, coverRect)"):
source.index("function clearNekoModelCatTransitionOverlay()")
]
assert "left: Math.round(centerX - size / 2)" in transition_rect_block
assert "top: Math.round(centerY - size / 2)" in transition_rect_block
assert "maxLeft" not in transition_rect_block
assert "maxTop" not in transition_rect_block
assert "const transitionAnchorRect = savedGoodbyeRect || activeReturnButtonContainer.getBoundingClientRect()" in source
assert "function mergeNekoTransitionAnchorRect(anchorRect, coverRect)" in source
assert "const coverRect = options.coverRect || null" in source
Expand Down Expand Up @@ -414,6 +422,12 @@ def test_cat1_voice_sounds_are_limited_to_non_drag_and_drag_states():
assert "urls[Math.floor(Math.random() * urls.length)]" in source
assert "_scheduleNekoIdleCat1AmbientSoundInterval(startedAt + _NEKO_IDLE_CAT1_AMBIENT_SOUND_INTERVAL_MS)" in source
assert "normalizedTier !== _NEKO_IDLE_TIER_CAT1 || _isAnyNekoIdleReturnDragActionActive()" in source
assert "_playNekoIdleCat1SoundReaction()" in source
assert "state.targetKind !== _NEKO_IDLE_CAT1_TARGET_KIND_COMPACT_TOP_EDGE" in source
assert "_playNekoIdleHoverArt(art, _NEKO_IDLE_TIER_CAT1);" in source
assert "const reactionSrc = art.__nekoIdleHoverSrc;" in source
assert "const reactionStartedAt = Math.max(0, Number(art.__nekoIdleHoverStartedAt) || Date.now());" in source
assert "_finishNekoIdleHoverArtAfterPlayback(art, _NEKO_IDLE_TIER_CAT1);" in source
assert "_playNekoIdleCat1DragSound(tier)" in source
assert "_fadeOutNekoIdleCat1DragSound()" in source
assert "_fadeOutNekoIdleSoundAudio(_nekoIdleCat1DragSoundState, _NEKO_IDLE_CAT1_DRAG_SOUND_FADE_OUT_MS)" in source
Expand Down Expand Up @@ -445,6 +459,14 @@ def test_cat1_walk_to_minimized_chat_contract_is_present():
assert 'compactTopEdgeFastMoveCount: 0' in source
assert 'state.compactTopEdgeFastMoveCount = 0' in source
assert 'state.compactTopEdgeFastMoveCount >= _NEKO_IDLE_CAT1_COMPACT_TOP_EDGE_DROP_FAST_MOVE_COUNT' in source
assert "function _postNekoIdleCat1CompactMirrorState(payload)" in source
assert "new CustomEvent('neko:idle-cat1-compact-mirror-state'" in source
assert "via: 'local'" in source
assert "return dispatchedLocal;" in source
assert "assetUrl: options.assetUrl || _getNekoIdleReturnAssetUrl(_NEKO_IDLE_TIER_CAT1)" in source
assert "_syncNekoIdleCat1CompactMirrorReaction(button, container, reactionSrc, 'cat1-sound-reaction')" in source
assert "_getNekoIdleGifDurationMs(reactionSrc)" in source
assert "const remainingMs = Math.max(0, (Number(durationMs) || 0) - elapsedMs);" in source
assert '_NEKO_IDLE_RETURN_SUBACTION_CAT1_CHAT_FOLLOW' in source
assert '_NEKO_IDLE_RETURN_SUBACTION_PROFILES' in source
assert '_getNekoIdleReturnSubactionProfile' in source
Expand Down
Loading