Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
213 changes: 213 additions & 0 deletions frontend/react-neko-chat/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ const COMPACT_SPEECH_FALLBACK_REVEAL_DELAY_MS = 700;
const SPEECH_PLAYBACK_STATE_STORAGE_KEY = 'neko_speech_playback_state';
const SPEECH_PLAYBACK_CHANNEL_NAME = 'neko_speech_playback_channel';
const COMPACT_EXPORT_HISTORY_OPEN_STORAGE_KEY = 'neko.reactChatWindow.compactExportHistoryOpen';
const COMPACT_HISTORY_HEIGHT_STORAGE_KEY = 'neko.reactChatWindow.compactHistorySlotHeight';
export const COMPACT_EXPORT_HISTORY_VISIBILITY_ANIMATION_MS = 560;
const COMPACT_INPUT_TOOL_WHEEL_ITEM_COUNT = 7;
const COMPACT_INPUT_TOOL_WHEEL_DRAG_THRESHOLD = 22;
Expand Down Expand Up @@ -130,6 +131,17 @@ const COMPACT_SURFACE_RESIZE_MOBILE_MIN_WIDTH = 280;
const COMPACT_SURFACE_RESIZE_MAX_WIDTH = 720;
const COMPACT_SURFACE_RESIZE_VIEWPORT_GUTTER = 32;
const COMPACT_SURFACE_RESIZE_MOBILE_VIEWPORT_GUTTER = 16;
// compact 历史堆砌区(CompactExportHistoryPanel)顶部 resize bar 的高度上限钳位参数。
// 下限压到 ~1-2 个气泡以便节约屏幕;上限对齐 anchor 的 max-height(width*1.46 / 78% 视口),
// 避免拖超 anchor 二次截断产生「拖了没反应」的死区。默认(未拖动)公式仍是 width*1.18 / 63%。
const COMPACT_HISTORY_SLOT_MIN_HEIGHT = 120;
const COMPACT_HISTORY_SLOT_MAX_WIDTH_RATIO = 1.46;
const COMPACT_HISTORY_SLOT_MAX_VIEWPORT_RATIO = 0.78;
const COMPACT_HISTORY_SLOT_DEFAULT_WIDTH_RATIO = 1.18;
const COMPACT_HISTORY_SLOT_DEFAULT_VIEWPORT_RATIO = 0.63;
// scroll 区上方的 bar(12px+margin) 与下方 controls(展开块 ≤44px) 的固定 chrome;
// 从 anchor max-height 里扣掉,避免拖到上限时 scroll 吃满 anchor、controls 溢出被裁成非交互。
const COMPACT_HISTORY_SLOT_CHROME_RESERVE = 72;
const COMPACT_CHOICE_PLACEMENT_HYSTERESIS = 24;
const COMPOSER_OPTION_MARQUEE_MIN_DISTANCE = 6;
const COMPOSER_OPTION_MARQUEE_MIN_DURATION_MS = 1400;
Expand All @@ -152,6 +164,14 @@ type CompactSurfaceResizeState = {
captureTarget: Element | null;
};

type CompactHistoryResizeState = {
pointerId: number;
startPointerY: number;
startHeight: number;
lastHeight: number;
captureTarget: Element | null;
};

function createCompactHistoryDropRequestId() {
return `compact-history-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
Expand Down Expand Up @@ -373,6 +393,53 @@ function persistCompactExportHistoryOpen(open: boolean) {
}
}

function readPersistedCompactHistorySlotHeight(): number | null {
if (typeof window === 'undefined') return null;
try {
const persisted = window.localStorage?.getItem(COMPACT_HISTORY_HEIGHT_STORAGE_KEY);
if (persisted === null || persisted === undefined) return null;
const value = Number(persisted);
return Number.isFinite(value) && value > 0 ? value : null;
} catch {
return null;
}
}

function persistCompactHistorySlotHeight(value: number | null) {
if (typeof window === 'undefined') return;
try {
if (value === null) {
window.localStorage?.removeItem(COMPACT_HISTORY_HEIGHT_STORAGE_KEY);
} else {
window.localStorage?.setItem(COMPACT_HISTORY_HEIGHT_STORAGE_KEY, String(Math.round(value)));
}
} catch {
// localStorage can be unavailable in restricted hosts; keep the in-memory state.
}
}

// 历史区高度上限的基数:Electron 独立窗口用工作区高度(窗口可能只覆盖部分屏,不能用 innerHeight),
// 网页路径用视口高度。与 styles.css 里默认公式的 63vh / workarea*0.63 取同一基数。
function getCompactHistoryViewportBase(): number {
if (typeof window === 'undefined') return 900;
const desktopLayout = (window as typeof window & {
__nekoDesktopCompactLayout?: { workArea?: { height?: number } | null } | null;
}).__nekoDesktopCompactLayout;
const workAreaHeight = Number(desktopLayout?.workArea?.height);
if (isDesktopCompactSurfaceLayoutActive() && Number.isFinite(workAreaHeight) && workAreaHeight > 0) {
return workAreaHeight;
}
return window.innerHeight || 900;
}

function getCompactHistoryResizePointerY(event: ReactPointerEvent<HTMLDivElement>): number {
const screenY = Number(event.screenY);
if (Number.isFinite(screenY)) {
return screenY;
}
return event.clientY;
}

type SpeechPlaybackState = {
active: boolean;
turnId?: string | null;
Expand Down Expand Up @@ -1284,13 +1351,16 @@ export default function App({
const [compactInputToolWheelChargeDirection, setCompactInputToolWheelChargeDirection] = useState<1 | -1 | null>(null);
const [compactInputToolWheelChargeReleaseActive, setCompactInputToolWheelChargeReleaseActive] = useState(false);
const [compactSurfaceResizeWidth, setCompactSurfaceResizeWidth] = useState<number | null>(null);
const [compactHistorySlotHeight, setCompactHistorySlotHeight] = useState<number | null>(readPersistedCompactHistorySlotHeight);
const [compactHistoryResizeActive, setCompactHistoryResizeActive] = useState(false);
const [compactExportHistoryOpen, setCompactExportHistoryOpen] = useState(readPersistedCompactExportHistoryOpen);
const [compactExportHistoryMounted, setCompactExportHistoryMounted] = useState(readPersistedCompactExportHistoryOpen);
const [compactExportControlsOpen, setCompactExportControlsOpen] = useState(false);
const [compactExportPreviewOpen, setCompactExportPreviewOpen] = useState(false);
const [compactExportSelectedIds, setCompactExportSelectedIds] = useState<Set<string>>(() => new Set());
const [compactExportAutoScrollToBottom, setCompactExportAutoScrollToBottom] = useState(true);
const compactSurfaceResizeStateRef = useRef<CompactSurfaceResizeState | null>(null);
const compactHistoryResizeStateRef = useRef<CompactHistoryResizeState | null>(null);
const compactHistoryVisibilitySuppressClickRef = useRef(false);
const compactExportHistoryUnmountTimerRef = useRef<number | null>(null);
const submittingRef = useRef(false);
Expand Down Expand Up @@ -2732,6 +2802,144 @@ export default function App({
finishCompactSurfaceResize(event);
}, [finishCompactSurfaceResize]);

const getCompactHistorySlotMaxHeight = useCallback(() => {
const surfaceWidth = getCurrentCompactSurfaceWidth();
const base = getCompactHistoryViewportBase();
// anchor 的 max-height = min(width*1.46, 78%),但 panel 里 scroll 上方有 bar、下方有 controls;
// 先扣掉这部分非滚动 chrome,scroll 区才不会吃满 anchor 把 controls / 底部气泡顶出可视/可点区。
const anchorMax = Math.min(
surfaceWidth * COMPACT_HISTORY_SLOT_MAX_WIDTH_RATIO,
base * COMPACT_HISTORY_SLOT_MAX_VIEWPORT_RATIO,
);
return Math.round(Math.max(
COMPACT_HISTORY_SLOT_MIN_HEIGHT,
anchorMax - COMPACT_HISTORY_SLOT_CHROME_RESERVE,
));
}, [getCurrentCompactSurfaceWidth]);

const getClampedCompactHistorySlotHeight = useCallback((height: number) => (
Math.round(Math.max(
COMPACT_HISTORY_SLOT_MIN_HEIGHT,
Math.min(height, getCompactHistorySlotMaxHeight()),
))
), [getCompactHistorySlotMaxHeight]);

// 用户未拖动过时(slot 为 null),起拖高度取 styles.css 默认公式值(width*1.18 / 63%),
// 保证拖动第一帧从当前可见高度连续起步、不跳变。
const getCompactHistoryStartHeight = useCallback(() => {
// 起拖基准必须是「当前可见高度」(按当前约束 clamp 后)。存量高度可能来自更大屏 / 更宽 surface,
// 此时面板已被钳到 max、若用 stale 大值做基准,向下拖会出现「先拖一段没反应」的死区。
if (compactHistorySlotHeight !== null) {
return getClampedCompactHistorySlotHeight(compactHistorySlotHeight);
}
const surfaceWidth = getCurrentCompactSurfaceWidth();
const base = getCompactHistoryViewportBase();
return getClampedCompactHistorySlotHeight(Math.round(Math.min(
surfaceWidth * COMPACT_HISTORY_SLOT_DEFAULT_WIDTH_RATIO,
base * COMPACT_HISTORY_SLOT_DEFAULT_VIEWPORT_RATIO,
)));
}, [compactHistorySlotHeight, getClampedCompactHistorySlotHeight, getCurrentCompactSurfaceWidth]);

const applyCompactHistorySlotHeightVar = useCallback((height: number | null) => {
if (typeof document === 'undefined') return;
if (height === null) {
document.documentElement.style.removeProperty('--compact-history-slot-height');
} else {
document.documentElement.style.setProperty(
'--compact-history-slot-height',
`${getClampedCompactHistorySlotHeight(height)}px`,
);
}
// CSS 变量变更不会自己通知宿主;让宿主重算 history 命中 rect / Electron 窗口 bounds / 鼠标穿透区。
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('neko:compact-interaction-geometry-refresh'));
}
}, [getClampedCompactHistorySlotHeight]);

const finishCompactHistoryResize = useCallback((event?: ReactPointerEvent<HTMLDivElement>) => {
const resizeState = compactHistoryResizeStateRef.current;
if (!resizeState) return;
if (event && resizeState.pointerId !== event.pointerId) return;
persistCompactHistorySlotHeight(resizeState.lastHeight);
setCompactHistorySlotHeight(resizeState.lastHeight);
Comment thread
wehos marked this conversation as resolved.
Outdated
const captureTarget = resizeState.captureTarget;
if (captureTarget && typeof captureTarget.releasePointerCapture === 'function') {
try {
if (captureTarget.hasPointerCapture?.(resizeState.pointerId)) {
captureTarget.releasePointerCapture(resizeState.pointerId);
}
} catch (_) {}
}
compactHistoryResizeStateRef.current = null;
setCompactHistoryResizeActive(false);
}, []);

const handleCompactHistoryResizePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (!isCompactSurface) return;
if (event.pointerType === 'mouse' && event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
const startHeight = getCompactHistoryStartHeight();
compactHistoryResizeStateRef.current = {
pointerId: event.pointerId,
startPointerY: getCompactHistoryResizePointerY(event),
startHeight,
lastHeight: getClampedCompactHistorySlotHeight(startHeight),
captureTarget: event.currentTarget,
};
setCompactHistoryResizeActive(true);
try {
event.currentTarget.setPointerCapture?.(event.pointerId);
} catch (_) {}
}, [getClampedCompactHistorySlotHeight, getCompactHistoryStartHeight, isCompactSurface]);

const handleCompactHistoryResizePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
const resizeState = compactHistoryResizeStateRef.current;
if (!resizeState || resizeState.pointerId !== event.pointerId) return;
event.preventDefault();
event.stopPropagation();
// bar 在堆砌区顶部:上拖(deltaY < 0)增高,下拖减高。
const deltaY = getCompactHistoryResizePointerY(event) - resizeState.startPointerY;
const nextHeight = getClampedCompactHistorySlotHeight(resizeState.startHeight - deltaY);
resizeState.lastHeight = nextHeight;
setCompactHistorySlotHeight(nextHeight);
applyCompactHistorySlotHeightVar(nextHeight);
}, [applyCompactHistorySlotHeightVar, getClampedCompactHistorySlotHeight]);

const handleCompactHistoryResizePointerUp = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
finishCompactHistoryResize(event);
}, [finishCompactHistoryResize]);

const handleCompactHistoryResizePointerCancel = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
finishCompactHistoryResize(event);
}, [finishCompactHistoryResize]);

// 把已存/恢复的高度写进 CSS 变量(覆盖默认公式);slot 为 null 时清掉、回落默认。
useEffect(() => {
if (!isCompactSurface) return;
applyCompactHistorySlotHeightVar(compactHistorySlotHeight);
}, [applyCompactHistorySlotHeightVar, compactHistorySlotHeight, isCompactSurface]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 视口 / 工作区 / compact surface 宽度变化后,按新约束重写 CSS 变量(用新 max clamp 显示高度)。
// 刻意不改 state、不覆盖 storage:存量 raw 值保留,换屏 / 改宽再放大时能恢复;起拖死区另由
// getCompactHistoryStartHeight 对基准 clamp 解决。
useEffect(() => {
if (!isCompactSurface) return undefined;
const reapplySlotHeight = () => applyCompactHistorySlotHeightVar(compactHistorySlotHeight);
window.addEventListener('resize', reapplySlotHeight);
window.addEventListener('neko:desktop-compact-layout-change', reapplySlotHeight);
window.addEventListener('neko:compact-surface-resize-width-change', reapplySlotHeight);
return () => {
window.removeEventListener('resize', reapplySlotHeight);
window.removeEventListener('neko:desktop-compact-layout-change', reapplySlotHeight);
window.removeEventListener('neko:compact-surface-resize-width-change', reapplySlotHeight);
};
}, [applyCompactHistorySlotHeightVar, compactHistorySlotHeight, isCompactSurface]);

useEffect(() => {
if (!isCompactSurface || compactSurfaceEffectiveWidth === null) {
applyCompactSurfaceResizeWidthVar(null);
Expand Down Expand Up @@ -5212,6 +5420,11 @@ export default function App({
isDropTargetAt={isCompactHistoryDropTargetAt}
onDropToTarget={handleCompactHistoryDropToAvatar}
onDragStateChange={onCompactHistoryDragStateChange}
historyResizeActive={compactHistoryResizeActive}
onHistoryResizePointerDown={handleCompactHistoryResizePointerDown}
onHistoryResizePointerMove={handleCompactHistoryResizePointerMove}
onHistoryResizePointerUp={handleCompactHistoryResizePointerUp}
onHistoryResizePointerCancel={handleCompactHistoryResizePointerCancel}
/>
) : null;
const compactExportHistoryNode = compactExportHistoryElement;
Expand Down
17 changes: 17 additions & 0 deletions frontend/react-neko-chat/src/CompactExportHistoryPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,23 @@ function renderPanel(overrides: Partial<Parameters<typeof CompactExportHistoryPa
}

describe('CompactExportHistoryPanel', () => {
it('shows the history height resize bar only outside preview and wires its hit-region', () => {
const { container, rerender } = renderPanel({ previewOpen: false, visibilityState: 'open' });
const bar = container.querySelector('.compact-export-history-resize-bar');
expect(bar).not.toBeNull();
expect(bar?.getAttribute('data-compact-hit-region-id')).toBe('history:resize');
expect(bar?.getAttribute('data-compact-hit-region-kind')).toBe('resize');
expect(bar?.getAttribute('data-compact-no-drag')).toBe('true');

rerender(<CompactExportHistoryPanel {...createPanelProps({ previewOpen: true, visibilityState: 'open' })} />);
expect(container.querySelector('.compact-export-history-resize-bar')).toBeNull();
});

it('marks the history resize bar active while dragging', () => {
const { container } = renderPanel({ previewOpen: false, visibilityState: 'open', historyResizeActive: true });
expect(container.querySelector('.compact-export-history-resize-bar.is-active')).not.toBeNull();
});

it('pins the history list to bottom when returning from preview', () => {
const scrollTopValues: number[] = [];
const scrollTopByElement = new WeakMap<HTMLElement, number>();
Expand Down
25 changes: 25 additions & 0 deletions frontend/react-neko-chat/src/CompactExportHistoryPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ type CompactExportHistoryPanelProps = {
isDropTargetAt?: (point: CompactHistoryDropPoint) => boolean;
onDropToTarget?: (request: CompactHistoryDropRequest) => Promise<boolean | void> | boolean | void;
onDragStateChange?: (state: CompactHistoryDragStatePayload) => void;
historyResizeActive?: boolean;
onHistoryResizePointerDown?: (event: ReactPointerEvent<HTMLDivElement>) => void;
onHistoryResizePointerMove?: (event: ReactPointerEvent<HTMLDivElement>) => void;
onHistoryResizePointerUp?: (event: ReactPointerEvent<HTMLDivElement>) => void;
onHistoryResizePointerCancel?: (event: ReactPointerEvent<HTMLDivElement>) => void;
};

export type CompactHistoryDragType = 'image' | 'bubble';
Expand Down Expand Up @@ -1225,6 +1230,11 @@ export default function CompactExportHistoryPanel({
isDropTargetAt,
onDropToTarget,
onDragStateChange,
historyResizeActive,
onHistoryResizePointerDown,
onHistoryResizePointerMove,
onHistoryResizePointerUp,
onHistoryResizePointerCancel,
}: CompactExportHistoryPanelProps) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const scrollbarDragRef = useRef<ScrollbarDragState | null>(null);
Expand Down Expand Up @@ -2498,6 +2508,21 @@ export default function CompactExportHistoryPanel({
<div className="compact-export-history-panel">
{previewOpen ? previewNode : (
<>
{/* 堆砌区顶部的高度 resize bar:平时透明,hover / 拖动中半透明显现(is-active)。
放在 scroll 之前,命中区随 anchor 的 children hit-scope 上报给宿主、Electron 下可点不穿透。 */}
<div
className={clsx('compact-export-history-resize-bar', { 'is-active': historyResizeActive })}
data-compact-hit-region={historyInteractive ? 'true' : undefined}
data-compact-hit-region-id={historyInteractive ? 'history:resize' : undefined}
data-compact-hit-region-kind={historyInteractive ? 'resize' : undefined}
data-compact-no-drag="true"
aria-hidden="true"
onPointerDown={onHistoryResizePointerDown}
Comment thread
wehos marked this conversation as resolved.
Outdated
onPointerMove={onHistoryResizePointerMove}
onPointerUp={onHistoryResizePointerUp}
onPointerCancel={onHistoryResizePointerCancel}
onLostPointerCapture={onHistoryResizePointerCancel}
/>
<div
ref={scrollRef}
className="compact-export-history-scroll"
Expand Down
32 changes: 32 additions & 0 deletions frontend/react-neko-chat/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2629,6 +2629,38 @@ body.electron-chat-window.subtitle-web-host .compact-export-history-anchor {
0 8px 20px rgba(36, 151, 217, 0.12);
}

/* 堆砌区顶部的高度 resize bar:视觉抓手平时透明,hover 整个历史区 / 拖动中(is-active)半透明显现。
与左右宽度 handle 一致采用「平时低调、靠近才显形」手感(opacity 切换,无过渡)。
注意:透明只施加在 ::after 伪元素上,bar 本体保持不透明——宿主几何收集器
(app-react-chat-window.js)会按 computed opacity <= 0.01 丢弃 hit-region,
若 bar 本体透明则 Electron 下整条会鼠标穿透、永远 hover/点不到、无法发起拖拽。 */
.compact-export-history-resize-bar {
flex: 0 0 auto;
height: 12px;
margin: 0 8px 2px;
cursor: ns-resize;
pointer-events: auto;
touch-action: none;
-webkit-app-region: no-drag;
}

.compact-export-history-resize-bar::after {
content: "";
display: block;
width: 40px;
height: 4px;
margin: 4px auto 0;
border-radius: 999px;
background: rgba(120, 142, 170, 0.75);
opacity: 0;
}

.compact-export-history-anchor:hover .compact-export-history-resize-bar::after,
.compact-export-history-resize-bar:hover::after,
.compact-export-history-resize-bar.is-active::after {
opacity: 0.55;
}

.compact-history-drag-layer {
position: fixed;
left: var(--compact-history-drag-left, 0px);
Expand Down
Loading
Loading