Skip to content

Commit 986e250

Browse files
committed
lots of focus work and keyboard shortcut work for aipanel
1 parent bd42c04 commit 986e250

7 files changed

Lines changed: 138 additions & 30 deletions

File tree

frontend/app/aipanel/aipanel.tsx

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import { WaveUIMessagePart } from "@/app/aipanel/aitypes";
5-
import { atoms } from "@/app/store/global";
5+
import { atoms, getSettingsKeyAtom } from "@/app/store/global";
66
import { globalStore } from "@/app/store/jotaiStore";
77
import { getWebServerEndpoint } from "@/util/endpoints";
88
import { checkKeyPressed, keydownWrapper } from "@/util/keyutil";
99
import { cn } from "@/util/util";
1010
import { useChat } from "@ai-sdk/react";
1111
import { DefaultChatTransport } from "ai";
12+
import * as jotai from "jotai";
1213
import { memo, useEffect, useRef, useState } from "react";
1314
import { createDataUrl, isAcceptableFile, normalizeMimeType } from "./ai-utils";
1415
import { AIDroppedFiles } from "./aidroppedfiles";
@@ -29,6 +30,8 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => {
2930
const model = WaveAIModel.getInstance();
3031
const realMessageRef = useRef<AIMessage>(null);
3132
const inputRef = useRef<AIPanelInputRef>(null);
33+
const isLayoutMode = jotai.useAtomValue(atoms.controlShiftDelayAtom);
34+
const showOverlayBlockNums = jotai.useAtomValue(getSettingsKeyAtom("app:showoverlayblocknums")) ?? true;
3235

3336
const { messages, sendMessage, status, setMessages, error } = useChat({
3437
transport: new DefaultChatTransport({
@@ -198,7 +201,7 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => {
198201
// Check if the click target is an interactive element
199202
const target = e.target as HTMLElement;
200203
const isInteractive = target.closest('button, a, input, textarea, select, [role="button"], [tabindex]');
201-
204+
202205
if (isInteractive) {
203206
return;
204207
}
@@ -212,10 +215,12 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => {
212215
}, 0);
213216
};
214217

218+
const showBlockMask = isLayoutMode && showOverlayBlockNums;
219+
215220
return (
216221
<div
217222
className={cn(
218-
"bg-gray-900 border-t border-gray-600 flex flex-col relative mt-1",
223+
"bg-gray-900 border-t border-gray-600 flex flex-col relative h-[calc(100%-3px)] mt-1",
219224
className,
220225
isDragOver && "bg-gray-800 border-accent"
221226
)}
@@ -231,17 +236,39 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => {
231236
onClick={handleClick}
232237
>
233238
{isDragOver && (
234-
<div className="absolute inset-0 bg-accent/20 border-2 border-dashed border-accent rounded-lg flex items-center justify-center z-10 p-4">
239+
<div
240+
key="drag-overlay"
241+
className="absolute inset-0 bg-accent/20 border-2 border-dashed border-accent rounded-lg flex items-center justify-center z-10 p-4"
242+
>
235243
<div className="text-accent text-center">
236244
<i className="fa fa-upload text-3xl mb-2"></i>
237245
<div className="text-lg font-semibold">Drop files here</div>
238246
<div className="text-sm">Images, PDFs, and text/code files supported</div>
239247
</div>
240248
</div>
241249
)}
250+
{showBlockMask && (
251+
<div
252+
key="block-mask"
253+
className="absolute top-0 left-0 right-0 bottom-0 border-1 border-transparent pointer-events-auto select-none p-0.5"
254+
style={{
255+
borderRadius: "var(--block-border-radius)",
256+
zIndex: "var(--zindex-block-mask-inner)",
257+
}}
258+
>
259+
<div
260+
className="w-full mt-[44px] h-[calc(100%-44px)] flex items-center justify-center"
261+
style={{
262+
backgroundColor: "rgb(from var(--block-bg-color) r g b / 50%)",
263+
}}
264+
>
265+
<div className="font-bold opacity-70 mt-[-25%] text-[60px]">0</div>
266+
</div>
267+
</div>
268+
)}
242269
<AIPanelHeader onClose={onClose} model={model} />
243270

244-
<div className="flex-1 flex flex-col min-h-0">
271+
<div key="main-content" className="flex-1 flex flex-col min-h-0">
245272
<AIPanelMessages messages={messages} status={status} />
246273
{errorMessage && (
247274
<div className="px-4 py-2 text-red-400 bg-red-900/20 border-l-4 border-red-500 mx-2 mb-2">

frontend/app/aipanel/waveai-model.tsx

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

4-
import * as jotai from "jotai";
54
import { globalStore } from "@/app/store/jotaiStore";
5+
import { workspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
6+
import * as jotai from "jotai";
7+
import type React from "react";
8+
import type { AIPanelInputRef } from "./aipanelinput";
69

710
export interface DroppedFile {
811
id: string;
@@ -14,14 +17,26 @@ export interface DroppedFile {
1417
}
1518

1619
export class WaveAIModel {
20+
private static instance: WaveAIModel | null = null;
21+
private inputRef: React.RefObject<AIPanelInputRef> | null = null;
22+
1723
widgetAccess: jotai.PrimitiveAtom<boolean> = jotai.atom(true);
1824
droppedFiles: jotai.PrimitiveAtom<DroppedFile[]> = jotai.atom([]);
1925
chatId: jotai.PrimitiveAtom<string> = jotai.atom(crypto.randomUUID());
20-
21-
private tabId: string;
2226

23-
constructor(tabId: string) {
24-
this.tabId = tabId;
27+
private constructor() {
28+
// Private constructor prevents direct instantiation
29+
}
30+
31+
static getInstance(): WaveAIModel {
32+
if (!WaveAIModel.instance) {
33+
WaveAIModel.instance = new WaveAIModel();
34+
}
35+
return WaveAIModel.instance;
36+
}
37+
38+
static resetInstance(): void {
39+
WaveAIModel.instance = null;
2540
}
2641

2742
addFile(file: File): DroppedFile {
@@ -34,7 +49,7 @@ export class WaveAIModel {
3449
};
3550

3651
// Create preview URL for images
37-
if (file.type.startsWith('image/')) {
52+
if (file.type.startsWith("image/")) {
3853
droppedFile.previewUrl = URL.createObjectURL(file);
3954
}
4055

@@ -46,22 +61,22 @@ export class WaveAIModel {
4661

4762
removeFile(fileId: string) {
4863
const currentFiles = globalStore.get(this.droppedFiles);
49-
const fileToRemove = currentFiles.find(f => f.id === fileId);
50-
64+
const fileToRemove = currentFiles.find((f) => f.id === fileId);
65+
5166
// Cleanup preview URL if it exists
5267
if (fileToRemove?.previewUrl) {
5368
URL.revokeObjectURL(fileToRemove.previewUrl);
5469
}
5570

56-
const updatedFiles = currentFiles.filter(f => f.id !== fileId);
71+
const updatedFiles = currentFiles.filter((f) => f.id !== fileId);
5772
globalStore.set(this.droppedFiles, updatedFiles);
5873
}
5974

6075
clearFiles() {
6176
const currentFiles = globalStore.get(this.droppedFiles);
62-
77+
6378
// Cleanup all preview URLs
64-
currentFiles.forEach(file => {
79+
currentFiles.forEach((file) => {
6580
if (file.previewUrl) {
6681
URL.revokeObjectURL(file.previewUrl);
6782
}
@@ -75,4 +90,19 @@ export class WaveAIModel {
7590
globalStore.set(this.chatId, crypto.randomUUID());
7691
}
7792

78-
}
93+
registerInputRef(ref: React.RefObject<AIPanelInputRef>) {
94+
this.inputRef = ref;
95+
}
96+
97+
focusInput() {
98+
if (!workspaceLayoutModel.getAIPanelVisible()) {
99+
workspaceLayoutModel.setAIPanelVisible(true);
100+
}
101+
if (this.inputRef?.current) {
102+
this.inputRef.current.focus();
103+
}
104+
}
105+
}
106+
107+
// Export singleton instance for easy access
108+
export const waveAIModel = WaveAIModel.getInstance();

frontend/app/store/keymodel.ts

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

4+
import { waveAIModel } from "@/app/aipanel/waveai-model";
45
import {
56
atoms,
67
createBlock,
@@ -17,6 +18,7 @@ import {
1718
replaceBlock,
1819
WOS,
1920
} from "@/app/store/global";
21+
import { workspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
2022
import {
2123
deleteLayoutModelForTab,
2224
getLayoutModelForTab,
@@ -29,7 +31,6 @@ import { CHORD_TIMEOUT } from "@/util/sharedconst";
2931
import { fireAndForget } from "@/util/util";
3032
import * as jotai from "jotai";
3133
import { modalsModel } from "./modalmodel";
32-
import { workspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
3334

3435
type KeyHandler = (event: WaveKeyboardEvent) => boolean;
3536

@@ -137,11 +138,21 @@ function switchBlockByBlockNum(index: number) {
137138
return;
138139
}
139140
layoutModel.switchNodeFocusByBlockNum(index);
141+
setTimeout(() => {
142+
globalRefocus();
143+
}, 10);
140144
}
141145

142146
function switchBlockInDirection(tabId: string, direction: NavigateDirection) {
143147
const layoutModel = getLayoutModelForTabById(tabId);
144-
layoutModel.switchNodeFocusInDirection(direction);
148+
const navResult = layoutModel.switchNodeFocusInDirection(direction);
149+
if (navResult.atLeft) {
150+
waveAIModel.focusInput();
151+
return;
152+
}
153+
setTimeout(() => {
154+
globalRefocus();
155+
}, 10);
145156
}
146157

147158
function getAllTabs(ws: Workspace): string[] {
@@ -485,6 +496,14 @@ function registerGlobalKeys() {
485496
return true;
486497
});
487498
}
499+
globalKeyMap.set("Ctrl:Shift:c{Digit0}", () => {
500+
waveAIModel.focusInput();
501+
return true;
502+
});
503+
globalKeyMap.set("Ctrl:Shift:c{Numpad0}", () => {
504+
waveAIModel.focusInput();
505+
return true;
506+
});
488507
function activateSearch(event: WaveKeyboardEvent): boolean {
489508
const bcm = getBlockComponentModel(getFocusedBlockInStaticTab());
490509
// Ctrl+f is reserved in most shells
@@ -518,7 +537,12 @@ function registerGlobalKeys() {
518537
});
519538
globalKeyMap.set("Cmd:Shift:a", () => {
520539
const currentVisible = workspaceLayoutModel.getAIPanelVisible();
521-
workspaceLayoutModel.setAIPanelVisible(!currentVisible);
540+
if (!currentVisible) {
541+
waveAIModel.focusInput();
542+
} else {
543+
workspaceLayoutModel.setAIPanelVisible(false);
544+
globalRefocus();
545+
}
522546
return true;
523547
});
524548
const allKeys = Array.from(globalKeyMap.keys());

frontend/app/tab/tabbar.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -633,19 +633,20 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
633633
getApi().showContextMenu(workspace.oid);
634634
}
635635

636-
function onSparklesClick() {
636+
function onWaveAIClick() {
637637
const currentVisible = workspaceLayoutModel.getAIPanelVisible();
638638
workspaceLayoutModel.setAIPanelVisible(!currentVisible);
639639
}
640640

641641
const tabsWrapperWidth = tabIds.length * tabWidthRef.current;
642642
const waveaiButton = isDev() ? (
643643
<div
644-
className="flex h-[26px] px-3 justify-end items-center gap-3 rounded-md mr-1 box-border text-accent cursor-pointer bg-hover hover:bg-hoverbg transition-colors"
644+
className="flex h-[26px] px-1.5 justify-end items-center rounded-md mr-1 box-border text-accent cursor-pointer bg-hover hover:bg-hoverbg transition-colors"
645645
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
646-
onClick={onSparklesClick}
646+
onClick={onWaveAIClick}
647647
>
648648
<i className="fa fa-sparkles" />
649+
<span className="font-bold ml-1 -top-px font-mono">AI</span>
649650
</div>
650651
) : undefined;
651652
const appMenuButton =

frontend/app/workspace/workspace.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ const WorkspaceElem = memo(() => {
7777
<ErrorBoundary key={tabId}>
7878
<PanelGroup direction="horizontal" onLayout={handlePanelLayout} ref={panelGroupRef}>
7979
<Panel ref={aiPanelRef} collapsible defaultSize={initialAiPanelPercentage} order={1}>
80-
<AIPanel className="h-full" onClose={handleCloseAIPanel} />
80+
<AIPanel onClose={handleCloseAIPanel} />
8181
</Panel>
8282
<PanelResizeHandle className="w-0.5 bg-transparent hover:bg-gray-500/20 transition-colors" />
8383
<Panel order={2} defaultSize={100 - initialAiPanelPercentage}>

frontend/layout/lib/layoutModel.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
LayoutTreeState,
4747
LayoutTreeSwapNodeAction,
4848
NavigateDirection,
49+
NavigationResult,
4950
NodeModel,
5051
PreviewRenderer,
5152
ResizeHandleProps,
@@ -978,13 +979,13 @@ export class LayoutModel {
978979
* Switch focus to the next node in the given direction in the layout.
979980
* @param direction The direction in which to switch focus.
980981
*/
981-
switchNodeFocusInDirection(direction: NavigateDirection) {
982+
switchNodeFocusInDirection(direction: NavigateDirection): NavigationResult {
982983
const curNodeId = this.focusedNodeId;
983984

984985
// If no node is focused, set focus to the first leaf.
985986
if (!curNodeId) {
986987
this.focusNode(this.getter(this.leafOrder)[0].nodeid);
987-
return;
988+
return { success: true };
988989
}
989990

990991
const offset = navigateDirectionToOffset(direction);
@@ -999,12 +1000,12 @@ export class LayoutModel {
9991000
}
10001001
const curNodePos = nodePositions.get(curNodeId);
10011002
if (!curNodePos) {
1002-
return;
1003+
return { success: false };
10031004
}
10041005
nodePositions.delete(curNodeId);
10051006
const boundingRect = this.displayContainerRef?.current.getBoundingClientRect();
10061007
if (!boundingRect) {
1007-
return;
1008+
return { success: false };
10081009
}
10091010
const maxX = boundingRect.left + boundingRect.width;
10101011
const maxY = boundingRect.top + boundingRect.height;
@@ -1029,12 +1030,26 @@ export class LayoutModel {
10291030
curPoint.x += offset.x * moveAmount;
10301031
curPoint.y += offset.y * moveAmount;
10311032
if (curPoint.x < 0 || curPoint.x > maxX || curPoint.y < 0 || curPoint.y > maxY) {
1032-
return;
1033+
// Determine which boundary was hit
1034+
const result: NavigationResult = { success: false };
1035+
if (curPoint.x < 0) {
1036+
result.atLeft = true;
1037+
}
1038+
if (curPoint.x > maxX) {
1039+
result.atRight = true;
1040+
}
1041+
if (curPoint.y < 0) {
1042+
result.atTop = true;
1043+
}
1044+
if (curPoint.y > maxY) {
1045+
result.atBottom = true;
1046+
}
1047+
return result;
10331048
}
10341049
const nodeId = findNodeAtPoint(nodePositions, curPoint);
10351050
if (nodeId != null) {
10361051
this.focusNode(nodeId);
1037-
return;
1052+
return { success: true };
10381053
}
10391054
}
10401055
}

frontend/layout/lib/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,3 +386,14 @@ export interface NodeModel {
386386
dragHandleRef?: React.RefObject<HTMLDivElement>;
387387
displayContainerRef: React.RefObject<HTMLDivElement>;
388388
}
389+
390+
/**
391+
* Result object returned by switchNodeFocusInDirection method.
392+
*/
393+
export interface NavigationResult {
394+
success: boolean;
395+
atLeft?: boolean;
396+
atTop?: boolean;
397+
atBottom?: boolean;
398+
atRight?: boolean;
399+
}

0 commit comments

Comments
 (0)