Skip to content

Commit 3e0c425

Browse files
committed
much better wave ai panel slideout transitions
1 parent 8db1dc3 commit 3e0c425

4 files changed

Lines changed: 135 additions & 53 deletions

File tree

frontend/app/aipanel/aipanel.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { WaveUIMessagePart } from "@/app/aipanel/aitypes";
55
import { ErrorBoundary } from "@/app/element/errorboundary";
66
import { atoms, getSettingsKeyAtom } from "@/app/store/global";
7+
import { workspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
78
import { globalStore } from "@/app/store/jotaiStore";
89
import { getWebServerEndpoint } from "@/util/endpoints";
910
import { checkKeyPressed, keydownWrapper } from "@/util/keyutil";
@@ -37,6 +38,7 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => {
3738
const showOverlayBlockNums = jotai.useAtomValue(getSettingsKeyAtom("app:showoverlayblocknums")) ?? true;
3839
const isFocused = jotai.useAtomValue(atoms.waveAIFocusedAtom);
3940
const telemetryEnabled = jotai.useAtomValue(getSettingsKeyAtom("telemetry:enabled")) ?? false;
41+
const isPanelVisible = jotai.useAtomValue(workspaceLayoutModel.panelVisibleAtom);
4042

4143
const { messages, sendMessage, status, setMessages, error } = useChat({
4244
transport: new DefaultChatTransport({
@@ -277,6 +279,7 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => {
277279
onDragLeave={handleDragLeave}
278280
onDrop={handleDrop}
279281
onClick={handleClick}
282+
inert={!isPanelVisible ? true : undefined}
280283
>
281284
{isDragOver && (
282285
<div

frontend/app/store/keymodel.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -538,12 +538,7 @@ function registerGlobalKeys() {
538538
});
539539
globalKeyMap.set("Cmd:Shift:a", () => {
540540
const currentVisible = workspaceLayoutModel.getAIPanelVisible();
541-
if (!currentVisible) {
542-
WaveAIModel.getInstance().focusInput();
543-
} else {
544-
workspaceLayoutModel.setAIPanelVisible(false);
545-
globalRefocus();
546-
}
541+
workspaceLayoutModel.setAIPanelVisible(!currentVisible);
547542
return true;
548543
});
549544
const allKeys = Array.from(globalKeyMap.keys());

frontend/app/workspace/workspace-layout-model.ts

Lines changed: 115 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,52 @@
11
// Copyright 2025, Command Line Inc.
22
// SPDX-License-Identifier: Apache-2.0
33

4-
import { getTabMetaKeyAtom } from "@/app/store/global";
4+
import { WaveAIModel } from "@/app/aipanel/waveai-model";
5+
import { getTabMetaKeyAtom, refocusNode } from "@/app/store/global";
56
import { globalStore } from "@/app/store/jotaiStore";
67
import * as WOS from "@/app/store/wos";
78
import { RpcApi } from "@/app/store/wshclientapi";
89
import { TabRpcClient } from "@/app/store/wshrpcutil";
10+
import { getLayoutModelForStaticTab } from "@/layout/lib/layoutModelHooks";
911
import { atoms, isDev } from "@/store/global";
12+
import debug from "debug";
1013
import * as jotai from "jotai";
1114
import { debounce } from "lodash-es";
1215
import { ImperativePanelGroupHandle, ImperativePanelHandle } from "react-resizable-panels";
1316

17+
const dlog = debug("wave:workspace");
18+
1419
const AIPANEL_DEFAULTWIDTH = 300;
1520
const AIPANEL_MINWIDTH = 250;
1621
const AIPANEL_MAXWIDTHRATIO = 0.5;
1722

1823
class WorkspaceLayoutModel {
1924
aiPanelRef: ImperativePanelHandle | null;
2025
panelGroupRef: ImperativePanelGroupHandle | null;
21-
inResize: boolean;
26+
panelContainerRef: HTMLDivElement | null;
27+
aiPanelWrapperRef: HTMLDivElement | null;
28+
inResize: boolean; // prevents recursive setLayout calls (setLayout triggers onLayout which calls setLayout)
2229
private aiPanelVisible: boolean;
2330
private aiPanelWidth: number | null;
2431
private debouncedPersistWidth: (width: number) => void;
2532
private initialized: boolean = false;
33+
private transitionTimeoutRef: NodeJS.Timeout | null = null;
34+
private focusTimeoutRef: NodeJS.Timeout | null = null;
2635
panelVisibleAtom: jotai.PrimitiveAtom<boolean>;
2736

2837
constructor() {
2938
this.aiPanelRef = null;
3039
this.panelGroupRef = null;
40+
this.panelContainerRef = null;
41+
this.aiPanelWrapperRef = null;
3142
this.inResize = false;
3243
this.aiPanelVisible = isDev();
3344
this.aiPanelWidth = null;
3445
this.panelVisibleAtom = jotai.atom(this.aiPanelVisible);
3546

47+
this.handleWindowResize = this.handleWindowResize.bind(this);
48+
this.handlePanelLayout = this.handlePanelLayout.bind(this);
49+
3650
this.debouncedPersistWidth = debounce((width: number) => {
3751
try {
3852
RpcApi.SetMetaCommand(TabRpcClient, {
@@ -77,10 +91,84 @@ class WorkspaceLayoutModel {
7791
return getTabMetaKeyAtom(this.getTabId(), "waveai:panelwidth");
7892
}
7993

80-
registerRefs(aiPanelRef: ImperativePanelHandle, panelGroupRef: ImperativePanelGroupHandle): void {
94+
registerRefs(
95+
aiPanelRef: ImperativePanelHandle,
96+
panelGroupRef: ImperativePanelGroupHandle,
97+
panelContainerRef: HTMLDivElement,
98+
aiPanelWrapperRef: HTMLDivElement
99+
): void {
81100
this.aiPanelRef = aiPanelRef;
82101
this.panelGroupRef = panelGroupRef;
102+
this.panelContainerRef = panelContainerRef;
103+
this.aiPanelWrapperRef = aiPanelWrapperRef;
83104
this.syncAIPanelRef();
105+
this.updateWrapperWidth();
106+
}
107+
108+
updateWrapperWidth(): void {
109+
if (!this.aiPanelWrapperRef) {
110+
return;
111+
}
112+
const width = this.getAIPanelWidth();
113+
this.aiPanelWrapperRef.style.width = `${width}px`;
114+
}
115+
116+
enableTransitions(duration: number): void {
117+
if (!this.panelContainerRef) {
118+
return;
119+
}
120+
const panels = this.panelContainerRef.querySelectorAll("[data-panel]");
121+
dlog("set transition ease-in-out", panels);
122+
panels.forEach((panel: HTMLElement) => {
123+
panel.style.transition = "flex 0.2s ease-in-out";
124+
});
125+
126+
if (this.transitionTimeoutRef) {
127+
clearTimeout(this.transitionTimeoutRef);
128+
}
129+
this.transitionTimeoutRef = setTimeout(() => {
130+
if (!this.panelContainerRef) {
131+
return;
132+
}
133+
const panels = this.panelContainerRef.querySelectorAll("[data-panel]");
134+
dlog("set transition none", panels);
135+
panels.forEach((panel: HTMLElement) => {
136+
panel.style.transition = "none";
137+
});
138+
}, duration);
139+
}
140+
141+
handleWindowResize(): void {
142+
if (!this.panelGroupRef) {
143+
return;
144+
}
145+
const newWindowWidth = window.innerWidth;
146+
const aiPanelPercentage = this.getAIPanelPercentage(newWindowWidth);
147+
const mainContentPercentage = this.getMainContentPercentage(newWindowWidth);
148+
this.inResize = true;
149+
const layout = [aiPanelPercentage, mainContentPercentage];
150+
this.panelGroupRef.setLayout(layout);
151+
this.inResize = false;
152+
}
153+
154+
handlePanelLayout(sizes: number[]): void {
155+
dlog("handlePanelLayout", "inResize:", this.inResize, "sizes:", sizes);
156+
if (this.inResize) {
157+
return;
158+
}
159+
if (!this.panelGroupRef) {
160+
return;
161+
}
162+
163+
const currentWindowWidth = window.innerWidth;
164+
const aiPanelPixelWidth = (sizes[0] / 100) * currentWindowWidth;
165+
this.handleAIPanelResize(aiPanelPixelWidth, currentWindowWidth);
166+
const newPercentage = this.getAIPanelPercentage(currentWindowWidth);
167+
const mainContentPercentage = 100 - newPercentage;
168+
this.inResize = true;
169+
const layout = [newPercentage, mainContentPercentage];
170+
this.panelGroupRef.setLayout(layout);
171+
this.inResize = false;
84172
}
85173

86174
syncAIPanelRef(): void {
@@ -125,13 +213,36 @@ class WorkspaceLayoutModel {
125213
if (!isDev() && visible) {
126214
return;
127215
}
216+
if (this.focusTimeoutRef != null) {
217+
clearTimeout(this.focusTimeoutRef);
218+
this.focusTimeoutRef = null;
219+
}
128220
this.aiPanelVisible = visible;
129221
globalStore.set(this.panelVisibleAtom, visible);
130222
RpcApi.SetMetaCommand(TabRpcClient, {
131223
oref: WOS.makeORef("tab", this.getTabId()),
132224
meta: { "waveai:panelopen": visible },
133225
});
226+
this.enableTransitions(250);
134227
this.syncAIPanelRef();
228+
229+
if (visible) {
230+
this.focusTimeoutRef = setTimeout(() => {
231+
WaveAIModel.getInstance().focusInput();
232+
this.focusTimeoutRef = null;
233+
}, 350);
234+
} else {
235+
const layoutModel = getLayoutModelForStaticTab();
236+
const focusedNode = globalStore.get(layoutModel.focusedNode);
237+
if (focusedNode == null) {
238+
layoutModel.focusFirstNode();
239+
return;
240+
}
241+
const blockId = focusedNode?.data?.blockId;
242+
if (blockId != null) {
243+
refocusNode(blockId);
244+
}
245+
}
135246
}
136247

137248
getAIPanelWidth(): number {
@@ -144,6 +255,7 @@ class WorkspaceLayoutModel {
144255

145256
setAIPanelWidth(width: number): void {
146257
this.aiPanelWidth = width;
258+
this.updateWrapperWidth();
147259
this.debouncedPersistWidth(width);
148260
}
149261

@@ -172,10 +284,6 @@ class WorkspaceLayoutModel {
172284
}
173285
const clampedWidth = this.getClampedAIPanelWidth(width, windowWidth);
174286
this.setAIPanelWidth(clampedWidth);
175-
176-
if (!this.getAIPanelVisible()) {
177-
this.setAIPanelVisible(true);
178-
}
179287
}
180288
}
181289

frontend/app/workspace/workspace.tsx

Lines changed: 16 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -26,58 +26,34 @@ const WorkspaceElem = memo(() => {
2626
const initialAiPanelPercentage = workspaceLayoutModel.getAIPanelPercentage(window.innerWidth);
2727
const panelGroupRef = useRef<ImperativePanelGroupHandle>(null);
2828
const aiPanelRef = useRef<ImperativePanelHandle>(null);
29+
const panelContainerRef = useRef<HTMLDivElement>(null);
30+
const aiPanelWrapperRef = useRef<HTMLDivElement>(null);
2931

3032
useEffect(() => {
31-
if (aiPanelRef.current && panelGroupRef.current) {
32-
workspaceLayoutModel.registerRefs(aiPanelRef.current, panelGroupRef.current);
33+
if (aiPanelRef.current && panelGroupRef.current && panelContainerRef.current && aiPanelWrapperRef.current) {
34+
workspaceLayoutModel.registerRefs(aiPanelRef.current, panelGroupRef.current, panelContainerRef.current, aiPanelWrapperRef.current);
3335
}
3436
}, []);
3537

3638
useEffect(() => {
37-
const handleResize = () => {
38-
if (!panelGroupRef.current) {
39-
return;
40-
}
41-
const newWindowWidth = window.innerWidth;
42-
const aiPanelPercentage = workspaceLayoutModel.getAIPanelPercentage(newWindowWidth);
43-
const mainContentPercentage = workspaceLayoutModel.getMainContentPercentage(newWindowWidth);
44-
workspaceLayoutModel.inResize = true;
45-
const layout = [aiPanelPercentage, mainContentPercentage];
46-
panelGroupRef.current.setLayout(layout);
47-
workspaceLayoutModel.inResize = false;
48-
};
49-
50-
window.addEventListener("resize", handleResize);
51-
return () => window.removeEventListener("resize", handleResize);
39+
window.addEventListener("resize", workspaceLayoutModel.handleWindowResize);
40+
return () => window.removeEventListener("resize", workspaceLayoutModel.handleWindowResize);
5241
}, []);
5342

54-
const handlePanelLayout = (sizes: number[]) => {
55-
if (workspaceLayoutModel.inResize) {
56-
return;
57-
}
58-
const currentWindowWidth = window.innerWidth;
59-
const aiPanelPixelWidth = (sizes[0] / 100) * currentWindowWidth;
60-
workspaceLayoutModel.handleAIPanelResize(aiPanelPixelWidth, currentWindowWidth);
61-
const newPercentage = workspaceLayoutModel.getAIPanelPercentage(currentWindowWidth);
62-
const mainContentPercentage = 100 - newPercentage;
63-
workspaceLayoutModel.inResize = true;
64-
const layout = [newPercentage, mainContentPercentage];
65-
panelGroupRef.current.setLayout(layout);
66-
workspaceLayoutModel.inResize = false;
67-
};
68-
69-
const handleCloseAIPanel = () => {
70-
workspaceLayoutModel.setAIPanelVisible(false);
71-
};
72-
7343
return (
7444
<div className="flex flex-col w-full flex-grow overflow-hidden">
7545
<TabBar key={ws.oid} workspace={ws} />
76-
<div className="flex flex-row flex-grow overflow-hidden">
46+
<div ref={panelContainerRef} className="flex flex-row flex-grow overflow-hidden">
7747
<ErrorBoundary key={tabId}>
78-
<PanelGroup direction="horizontal" onLayout={handlePanelLayout} ref={panelGroupRef}>
79-
<Panel ref={aiPanelRef} collapsible defaultSize={initialAiPanelPercentage} order={1}>
80-
<AIPanel onClose={handleCloseAIPanel} />
48+
<PanelGroup
49+
direction="horizontal"
50+
onLayout={workspaceLayoutModel.handlePanelLayout}
51+
ref={panelGroupRef}
52+
>
53+
<Panel ref={aiPanelRef} collapsible defaultSize={initialAiPanelPercentage} order={1} className="overflow-hidden">
54+
<div ref={aiPanelWrapperRef} className="w-full h-full">
55+
<AIPanel onClose={() => workspaceLayoutModel.setAIPanelVisible(false)} />
56+
</div>
8157
</Panel>
8258
<PanelResizeHandle className="w-0.5 bg-transparent hover:bg-gray-500/20 transition-colors" />
8359
<Panel order={2} defaultSize={100 - initialAiPanelPercentage}>

0 commit comments

Comments
 (0)