Skip to content
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
fc82e0b
fix(character-card): 将角色卡列表标题星形图标及文字颜色从白色改为 #40C5F1
ErrorAP717 May 29, 2026
2e3d900
fix(chat): preserve avatar tool icons in dark mode
ErrorAP717 May 29, 2026
d0495f3
Merge branch 'Project-N-E-K-O:main' into main
ErrorAP717 Jun 1, 2026
85d295b
Merge branch 'Project-N-E-K-O:main' into main
ErrorAP717 Jun 2, 2026
023f5e8
Merge branch 'Project-N-E-K-O:main' into main
ErrorAP717 Jun 2, 2026
b0c2089
feat(chat): add avatar tool quickbar management
ErrorAP717 Jun 2, 2026
8506000
style(chat): add avatar tool manager visuals
ErrorAP717 Jun 2, 2026
56798bf
test(chat): cover avatar tool quickbar flows
ErrorAP717 Jun 2, 2026
e43859c
docs(agent): add project handoff notes
ErrorAP717 Jun 2, 2026
3f5bf47
style(chat): polish avatar tool manager visuals
ErrorAP717 Jun 2, 2026
45bdb6a
chore(i18n): shorten avatar tool manager title
ErrorAP717 Jun 2, 2026
a2c4410
Merge remote-tracking branch 'origin/main' into tools
ErrorAP717 Jun 2, 2026
7af52ba
merge: resolve conflict with upstream main
ErrorAP717 Jun 2, 2026
6559c88
fix: address CodeRabbit review suggestions (focus trap, touch-action,…
ErrorAP717 Jun 2, 2026
41791ba
merge: merge official upstream main into tools
ErrorAP717 Jun 2, 2026
8640ee8
docs(agent): clarify high-risk artifact rule
ErrorAP717 Jun 2, 2026
6e17c3d
merge: resolve conflicts with upstream/main (live captions, tool UI)
ErrorAP717 Jun 3, 2026
7f543b0
feat(chat): asset versioning for avatar tools + manager drag UX refin…
ErrorAP717 Jun 3, 2026
f547c12
fix(chat): exclude avatar-tool-manager-dialog from compact geometry c…
ErrorAP717 Jun 3, 2026
c622900
fix(chat): address Codex review — untrack .agent notes, shrink icon, …
ErrorAP717 Jun 3, 2026
5dee50a
fix(chat): manager dialog as compact cursor zone + restore pointer-ev…
ErrorAP717 Jun 3, 2026
2c54642
fix(chat): prefer coordinate hit-test over event target for slot drops
ErrorAP717 Jun 3, 2026
33c0ca7
fix(chat): restore pointer-events in anchored mode + quickbar host ge…
ErrorAP717 Jun 3, 2026
2035461
fix(chat): include quickbar edit button in compact host hit geometry
ErrorAP717 Jun 3, 2026
3e9b2d6
fix(chat): exempt edit button from slot filter + report manager dialo…
ErrorAP717 Jun 3, 2026
5b31a23
fix(chat): correct pointer-events test assertion + remove dead force-…
ErrorAP717 Jun 3, 2026
58face7
fix(chat): remove unused aria label constants flagged by noUnusedLocals
ErrorAP717 Jun 4, 2026
942cb87
fix(chat): remove harmful is-positioned override in narrow CSS media …
ErrorAP717 Jun 4, 2026
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
292 changes: 240 additions & 52 deletions frontend/react-neko-chat/src/App.test.tsx

Large diffs are not rendered by default.

319 changes: 128 additions & 191 deletions frontend/react-neko-chat/src/App.tsx

Large diffs are not rendered by default.

730 changes: 730 additions & 0 deletions frontend/react-neko-chat/src/AvatarToolItemManager.tsx

Large diffs are not rendered by default.

98 changes: 98 additions & 0 deletions frontend/react-neko-chat/src/AvatarToolQuickbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { MouseEvent as ReactMouseEvent } from 'react';
import { i18n } from './i18n';
import {
type AvatarToolId,
type AvatarToolItem,
type CursorVariant,
resolveAvatarToolMenuIconVisual,
withAvatarToolAssetVersion,
} from './avatarTools';

type AvatarToolQuickbarProps = {
activeToolIds: AvatarToolId[];
activeCursorToolId: string | null;
availableTools: AvatarToolItem[];
disabled?: boolean;
getToolVariant: (toolId: AvatarToolId) => CursorVariant;
onToolClick: (tool: AvatarToolItem, event: ReactMouseEvent<HTMLButtonElement>) => void;
onEditClick: (event: ReactMouseEvent<HTMLButtonElement>) => void;
};

function getToolLabel(tool: AvatarToolItem): string {
return i18n(tool.labelKey, tool.labelFallback);
}

export default function AvatarToolQuickbar({
activeToolIds,
activeCursorToolId,
availableTools,
disabled = false,
getToolVariant,
onToolClick,
onEditClick,
}: AvatarToolQuickbarProps) {
const availableById = new Map(availableTools.map(tool => [tool.id, tool]));
const activeTools = activeToolIds
.map(toolId => availableById.get(toolId))
.filter((tool): tool is AvatarToolItem => !!tool);

return (
<div
id="composer-avatar-tool-quickbar"
className="avatar-tool-quickbar"
Comment on lines +40 to +42
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 Include the quickbar in compact host geometry

In the Electron compact/minimize-ball host, static/app-react-chat-window.js builds native/hit rects for the tool fan from .compact-input-tool-item and the old .composer-icon-popover .composer-icon-button selector. This new quickbar is absolutely positioned outside the fan’s own rect but uses only .avatar-tool-quickbar, so the quick tool and edit buttons no longer contribute native hit regions; in that compact desktop context, the visible controls outside the base fan can be missed by click-through/hit testing unless the host geometry selector is updated or the quickbar keeps a collected selector.

Useful? React with 👍 / 👎.

role="group"
aria-label={i18n('chat.avatarToolQuickbarAriaLabel', 'Avatar quick tools')}
data-avatar-tool-quickbar-empty={activeTools.length === 0 ? 'true' : 'false'}
>
<div className="avatar-tool-quickbar-scroll">
{activeTools.length > 0 ? activeTools.map((tool) => {
const label = getToolLabel(tool);
const visual = resolveAvatarToolMenuIconVisual(tool, getToolVariant(tool.id));
return (
<button
key={tool.id}
className={`composer-icon-button avatar-tool-quickbar-button${activeCursorToolId === tool.id ? ' is-active' : ''}`}
type="button"
aria-label={label}
aria-pressed={activeCursorToolId === tool.id}
title={label}
disabled={disabled}
onClick={(event) => onToolClick(tool, event)}
>
<img
className={`composer-icon-button-image avatar-tool-quickbar-image avatar-tool-icon avatar-tool-icon-${tool.id}`}
src={visual.imagePath}
style={{
transform: `translate(${visual.offsetX}px, ${visual.offsetY}px) scale(${tool.menuIconScale ?? 1})`,
}}
alt=""
aria-hidden="true"
/>
</button>
);
}) : (
<span className="avatar-tool-quickbar-empty">
{i18n('chat.avatarToolQuickbarEmpty', 'No quick tools')}
</span>
)}
</div>
<button
className="avatar-tool-quickbar-edit"
type="button"
aria-label={i18n('chat.avatarToolEdit', 'Edit quick tools')}
title={i18n('chat.avatarToolEdit', 'Edit quick tools')}
disabled={disabled}
onClick={onEditClick}
>
<img
className="avatar-tool-quickbar-edit-image"
src={withAvatarToolAssetVersion('/static/icons/edit_tool_unified.png')}
alt=""
aria-hidden="true"
/>
</button>
</div>
);
}

export type { AvatarToolQuickbarProps };
185 changes: 185 additions & 0 deletions frontend/react-neko-chat/src/avatarTools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import type { AvatarInteractionPayload } from './message-schema';

export type AvatarToolId = AvatarInteractionPayload['toolId'];

export type CursorVariant = 'primary' | 'secondary' | 'tertiary';

declare global {
interface Window {
__NEKO_REACT_CHAT_ASSET_VERSION__?: string;
}
}

export type AvatarToolItem = {
id: AvatarToolId;
labelKey: string;
labelFallback: string;
iconImagePath: string;
iconImagePathAlt?: string;
iconImagePathAlt2?: string;
menuIconScale?: number;
menuIconOffsetX?: number;
menuIconOffsetY?: number;
menuIconOffsetXAlt?: number;
menuIconOffsetYAlt?: number;
menuIconOffsetXAlt2?: number;
menuIconOffsetYAlt2?: number;
cursorImagePath: string;
cursorImagePathAlt?: string;
cursorImagePathAlt2?: string;
cursorHotspotX?: number;
cursorHotspotY?: number;
};

export const ACTIVE_AVATAR_TOOLS_STORAGE_KEY = 'neko.reactChatWindow.activeAvatarTools';
export const MAX_ACTIVE_AVATAR_TOOLS = 3;
export const DEFAULT_ACTIVE_AVATAR_TOOL_IDS: AvatarToolId[] = ['lollipop', 'fist', 'hammer'];

export const AVAILABLE_AVATAR_TOOLS: AvatarToolItem[] = [
{
id: 'lollipop',
labelKey: 'chat.toolLollipop',
labelFallback: '棒棒糖',
iconImagePath: '/static/icons/chat_sugar1.png',
iconImagePathAlt: '/static/icons/chat_sugar2.png',
iconImagePathAlt2: '/static/icons/chat_sugar3.png',
cursorImagePath: '/static/icons/chat_sugar1_cursor.png',
cursorImagePathAlt: '/static/icons/chat_sugar2_cursor.png',
menuIconScale: 1.18,
cursorHotspotX: 27,
cursorHotspotY: 46,
},
{
id: 'fist',
labelKey: 'chat.toolFist',
labelFallback: '猫爪',
iconImagePath: '/static/icons/cat_claw1.png',
iconImagePathAlt: '/static/icons/cat_claw2.png',
cursorImagePath: '/static/icons/cat_claw1_cursor.png',
cursorImagePathAlt: '/static/icons/cat_claw2_cursor.png',
cursorHotspotX: 39,
cursorHotspotY: 46,
},
{
id: 'hammer',
labelKey: 'chat.toolHammer',
labelFallback: '锤子',
iconImagePath: '/static/icons/chat_hammer1.png',
iconImagePathAlt: '/static/icons/chat_hammer2.png',
cursorImagePath: '/static/icons/chat_hammer1_cursor.png',
cursorImagePathAlt: '/static/icons/chat_hammer2_cursor.png',
menuIconScale: 1.52,
menuIconOffsetX: -8,
menuIconOffsetY: 4,
menuIconOffsetXAlt: 1,
menuIconOffsetYAlt: -1,
cursorHotspotX: 50,
cursorHotspotY: 54,
},
];

const AVAILABLE_AVATAR_TOOL_IDS = new Set<AvatarToolId>(AVAILABLE_AVATAR_TOOLS.map(item => item.id));

function getReactChatAssetVersion(): string {
if (typeof window === 'undefined') return '';
const version = window.__NEKO_REACT_CHAT_ASSET_VERSION__;
return typeof version === 'string' ? version.trim() : '';
}

export function withAvatarToolAssetVersion(path: string): string {
const version = getReactChatAssetVersion();
if (!version || !path) return path;
const separator = path.includes('?') ? '&' : '?';
return `${path}${separator}v=${encodeURIComponent(version)}`;
}

export function isAvatarToolId(value: unknown): value is AvatarToolId {
return typeof value === 'string' && AVAILABLE_AVATAR_TOOL_IDS.has(value as AvatarToolId);
}

export function sanitizeAvatarToolIds(value: unknown): AvatarToolId[] {
if (!Array.isArray(value)) {
return [...DEFAULT_ACTIVE_AVATAR_TOOL_IDS];
}

const next: AvatarToolId[] = [];
value.forEach((candidate) => {
if (!isAvatarToolId(candidate)) return;
if (next.includes(candidate)) return;
if (next.length >= MAX_ACTIVE_AVATAR_TOOLS) return;
next.push(candidate);
});
return next;
}

export function readPersistedActiveAvatarToolIds(): AvatarToolId[] {
if (typeof window === 'undefined') {
return [...DEFAULT_ACTIVE_AVATAR_TOOL_IDS];
}

try {
const rawValue = window.localStorage?.getItem(ACTIVE_AVATAR_TOOLS_STORAGE_KEY);
if (rawValue === null || typeof rawValue === 'undefined') {
return [...DEFAULT_ACTIVE_AVATAR_TOOL_IDS];
}
return sanitizeAvatarToolIds(JSON.parse(rawValue));
} catch {
return [...DEFAULT_ACTIVE_AVATAR_TOOL_IDS];
}
}

export function persistActiveAvatarToolIds(ids: AvatarToolId[]) {
if (typeof window === 'undefined') return;
try {
window.localStorage?.setItem(
ACTIVE_AVATAR_TOOLS_STORAGE_KEY,
JSON.stringify(sanitizeAvatarToolIds(ids)),
);
} catch {
// Keep in-memory state when localStorage is unavailable.
}
}

export function resolveAvatarToolImagePaths(item: AvatarToolItem, variant: CursorVariant) {
const iconImagePath = variant === 'tertiary' && item.iconImagePathAlt2
? item.iconImagePathAlt2
: variant === 'secondary' && item.iconImagePathAlt
? item.iconImagePathAlt
: item.iconImagePath;
const cursorImagePath = variant === 'tertiary' && item.cursorImagePathAlt2
? item.cursorImagePathAlt2
: variant === 'secondary' && item.cursorImagePathAlt
? item.cursorImagePathAlt
: variant === 'tertiary' && item.cursorImagePathAlt
? item.cursorImagePathAlt
: item.cursorImagePath;

return {
iconImagePath: withAvatarToolAssetVersion(iconImagePath),
cursorImagePath: withAvatarToolAssetVersion(cursorImagePath),
};
}

export function resolveAvatarToolMenuIconVisual(item: AvatarToolItem, variant: CursorVariant) {
const imagePath = variant === 'tertiary' && item.iconImagePathAlt2
? item.iconImagePathAlt2
: variant === 'secondary' && item.iconImagePathAlt
? item.iconImagePathAlt
: item.iconImagePath;
const offsetX = variant === 'tertiary'
? (item.menuIconOffsetXAlt2 ?? item.menuIconOffsetXAlt ?? item.menuIconOffsetX ?? 0)
: variant === 'secondary'
? (item.menuIconOffsetXAlt ?? item.menuIconOffsetX ?? 0)
: (item.menuIconOffsetX ?? 0);
const offsetY = variant === 'tertiary'
? (item.menuIconOffsetYAlt2 ?? item.menuIconOffsetYAlt ?? item.menuIconOffsetY ?? 0)
: variant === 'secondary'
? (item.menuIconOffsetYAlt ?? item.menuIconOffsetY ?? 0)
: (item.menuIconOffsetY ?? 0);

return {
imagePath: withAvatarToolAssetVersion(imagePath),
offsetX,
offsetY,
};
}
Loading
Loading