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
12 changes: 11 additions & 1 deletion apps/desktop/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { useEditorBufferStore, selectContentForNote } from './stores/editorBuffe
import { useHeadingJumpStore } from './stores/headingJumpStore';
import { initGsapRuntime, playMotion, setPerformanceLow } from './motion/gsapRuntime';
import { shouldPlaySidebarIn } from './motion/sidebarIn';
import { shouldPlayPanelIn } from './motion/panelIn';
import { usePerformanceStore } from './stores/performanceStore';

function NotesApp() {
Expand Down Expand Up @@ -148,6 +149,8 @@ function SignedInApp({
const hideNoteList = distractionFree;
const sidebarRef = useRef<HTMLElement>(null);
const sidebarWasHiddenRef = useRef(hideSidebar);
const aiPanelRef = useRef<HTMLElement>(null);
const aiPanelWasOpenRef = useRef(false);

useLayoutEffect(() => {
if (shouldPlaySidebarIn(sidebarWasHiddenRef.current, hideSidebar)) {
Expand Down Expand Up @@ -408,6 +411,13 @@ function SignedInApp({
},
});

useLayoutEffect(() => {
if (shouldPlayPanelIn(aiPanelWasOpenRef.current, isAiPanelOpen)) {
playMotion('panel-in', aiPanelRef.current);
}
aiPanelWasOpenRef.current = isAiPanelOpen;
}, [isAiPanelOpen]);

const selectedQuickFilter = navigation.kind === 'global' ? navigation.filter : null;
const aiConfigCache = useRef<Record<string, unknown>>({});

Expand Down Expand Up @@ -581,7 +591,7 @@ function SignedInApp({
</main>

{isAiPanelOpen && (
<aside className="app__ai-panel">
<aside ref={aiPanelRef} className="app__ai-panel">
<AiPanel
onClose={closeAiPanel}
getCurrentNote={aiGetCurrentNote}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--bg-base);
font-size: 13px;
}
Expand Down
10 changes: 2 additions & 8 deletions apps/desktop/src/renderer/components/ai/AiPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useState, useRef, useEffect, useLayoutEffect, useCallback } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
import { Send } from 'lucide';
import type { ChatMessage, NoteContext, AiPanelMode } from '@dripnex/ai-core';
import { playMotion } from '../../motion/gsapRuntime';
import { useSettingsStore, selectAi } from '../../stores/settings';
import { Icon } from '../../ui/icons/Icon';
import { AiMessage } from './AiMessage';
Expand Down Expand Up @@ -59,7 +58,6 @@ export function AiPanel({
const [embeddedCount, setEmbeddedCount] = useState(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const activeRequestRef = useRef<string | null>(null);
const commandActiveRef = useRef(false);

Expand Down Expand Up @@ -95,10 +93,6 @@ export function AiPanel({
inputRef.current?.focus();
}, []);

useLayoutEffect(() => {
playMotion('panel-in', panelRef.current);
}, []);

useEffect(() => {
setMode(initialMode);
}, [initialMode]);
Expand Down Expand Up @@ -226,7 +220,7 @@ export function AiPanel({
}, []);

return (
<div ref={panelRef} className={sc('ai-panel')}>
<div className={sc('ai-panel')}>
<AiPanelHeader
mode={mode}
contextCount={contextCount}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ describe('gsapRuntime', () => {
expect(sidebar?.vars.duration).toBeCloseTo(0.18);
expect(sidebar?.vars.x).toBe(0);
gsap.killTweensOf(row);

const panel = playMotion('panel-in', target);
expect(panel?.vars.duration).toBeCloseTo(0.18);
expect(panel?.vars.x).toBe(0);
gsap.killTweensOf(row);
});

it('snaps gate-in when Performance is Low', () => {
Expand Down
54 changes: 54 additions & 0 deletions apps/desktop/src/renderer/motion/__tests__/panelIn.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest';
import { playMotion, setMotionScale, setPerformanceLow } from '../gsapRuntime';
import { shouldPlayPanelIn } from '../panelIn';

const here = dirname(fileURLToPath(import.meta.url));
const appSrc = readFileSync(join(here, '../../App.tsx'), 'utf8');
const aiPanelSrc = readFileSync(join(here, '../../components/ai/AiPanel.tsx'), 'utf8');
const globalCss = readFileSync(join(here, '../../styles/global.css'), 'utf8');

describe('shouldPlayPanelIn', () => {
it('plays only when the panel goes from closed to open', () => {
expect(shouldPlayPanelIn(false, true)).toBe(true);
expect(shouldPlayPanelIn(true, true)).toBe(false);
expect(shouldPlayPanelIn(false, false)).toBe(false);
expect(shouldPlayPanelIn(true, false)).toBe(false);
});
});

describe('panel-in runtime', () => {
afterEach(() => {
setMotionScale(1);
setPerformanceLow(false);
});

it('travels 8px from the right in 180ms', () => {
const pane = { opacity: 0, x: 8, y: 0, scale: 1 };
const tween = playMotion('panel-in', pane as unknown as Element);
expect(tween?.vars.duration).toBeCloseTo(0.18);
expect(tween?.vars.x).toBe(0);
expect(tween?.vars.opacity).toBe(1);
});

it('snaps when Performance is Low', () => {
setPerformanceLow(true);
const pane = { opacity: 0, x: 8, y: 0, scale: 1 };
expect(playMotion('panel-in', pane as unknown as Element)).toBeNull();
expect(pane.opacity).toBe(1);
expect(pane.x).toBe(0);
});
});

describe('AI panel chrome wiring', () => {
it('plays GSAP panel-in on the aside, not the inner clip box', () => {
expect(appSrc).toContain("playMotion('panel-in'");
expect(appSrc).toContain('shouldPlayPanelIn');
expect(appSrc).toContain('aiPanelRef');
expect(aiPanelSrc).not.toContain("playMotion('panel-in'");
expect(globalCss).not.toMatch(/\.app__ai-panel\s*\{[^}]*transition:\s*width/s);
expect(globalCss).not.toMatch(/\.app__ai-panel\s*\{[^}]*overflow:\s*hidden/s);
});
});
13 changes: 13 additions & 0 deletions apps/desktop/src/renderer/motion/gsapRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,19 @@ export function playMotion(
}
);
}
if (name === 'panel-in') {
return gsap.fromTo(
target,
{ opacity: 0, x: 8 },
{
opacity: 1,
x: 0,
duration: scaledDuration(180),
ease,
onComplete: options.onComplete,
}
);
}
if (name === 'welcome-in' || name === 'gate-in') {
return gsap.fromTo(
target,
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/renderer/motion/panelIn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/** Play the 8px enter only when the AI panel goes from closed to open. */
export function shouldPlayPanelIn(wasOpen: boolean, open: boolean): boolean {
return !wasOpen && open;
}
3 changes: 2 additions & 1 deletion apps/desktop/src/renderer/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,10 @@ html[data-frosted='true'] .app {

.app__ai-panel {
flex-shrink: 0;
min-height: 0;
width: 360px;
border-left: 1px solid var(--border);
overflow: hidden;
overflow: visible;
display: flex;
flex-direction: column;
}
Expand Down
1 change: 1 addition & 0 deletions docs/research/2026-08-28-electron-ux-quality.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,4 @@ Every 10 minutes: one significant UX/UI change, a plugin, or a palette — never
- 2026-08-28 — Source-mode Jump to Next/Previous Math: next `$math$` / `$$math$$`, wraps, fences and spaced dollars skipped. Does not rewrite. Files: `apps/desktop/src/renderer/plugins/jumpMath.ts`, `apps/desktop/src/renderer/plugins/wrapMath.ts`, `apps/desktop/src/renderer/plugins/index.ts`, `apps/desktop/src/renderer/plugins/__tests__/editorPacks.test.ts`.
- 2026-08-28 — Source-mode Wrap as Strikethrough / Unwrap Strikethrough: wraps the selection as `~~text~~`; peels the mark under the cursor. Fences, tilde runs, and existing marks skipped. Does not rewrite the rest of the note. Files: `apps/desktop/src/renderer/plugins/wrapStrike.ts`, `apps/desktop/src/renderer/plugins/index.ts`, `apps/desktop/src/renderer/plugins/__tests__/editorPacks.test.ts`.
- 2026-08-28 — Source-mode Jump to Next/Previous Strikethrough: next `~~text~~`, wraps, fences and tilde runs skipped. Does not rewrite. Files: `apps/desktop/src/renderer/plugins/jumpStrike.ts`, `apps/desktop/src/renderer/plugins/wrapStrike.ts`, `apps/desktop/src/renderer/plugins/index.ts`, `apps/desktop/src/renderer/plugins/__tests__/editorPacks.test.ts`.
- 2026-08-28 — GSAP `panel-in` on the AI aside (8px from the right). Width snaps; inner `.ai-panel` no longer owns the tween so overflow does not clip it. Reduced-motion / Low skip the enter. Files: `apps/desktop/src/renderer/motion/gsapRuntime.ts`, `apps/desktop/src/renderer/motion/panelIn.ts`, `apps/desktop/src/renderer/App.tsx`, `apps/desktop/src/renderer/components/ai/AiPanel.tsx`, `apps/desktop/src/renderer/styles/global.css`, `apps/desktop/src/renderer/motion/__tests__/panelIn.test.ts`, `apps/desktop/src/renderer/motion/__tests__/gsapRuntime.test.ts`.
Loading