Skip to content

Commit b437edd

Browse files
committed
new focus system! and resolved a LOT of dependency issues (main <=> renderer) + circular deps.
1 parent d29c541 commit b437edd

23 files changed

Lines changed: 390 additions & 192 deletions

electron.vite.config.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,66 @@ import tsconfigPaths from "vite-tsconfig-paths";
1313
const CHROME = "chrome140";
1414
const NODE = "node22";
1515

16+
// for debugging
17+
// target is like -- path.resolve(__dirname, "frontend/app/workspace/workspace-layout-model.ts");
18+
function whoImportsTarget(target: string) {
19+
return {
20+
name: "who-imports-target",
21+
buildEnd() {
22+
// Build reverse graph: child -> [importers...]
23+
const parents = new Map<string, string[]>();
24+
for (const id of (this as any).getModuleIds()) {
25+
const info = (this as any).getModuleInfo(id);
26+
if (!info) continue;
27+
for (const child of [...info.importedIds, ...info.dynamicallyImportedIds]) {
28+
const arr = parents.get(child) ?? [];
29+
arr.push(id);
30+
parents.set(child, arr);
31+
}
32+
}
33+
34+
// Walk upward from TARGET and print paths to entries
35+
const entries = [...parents.keys()].filter((id) => {
36+
const m = (this as any).getModuleInfo(id);
37+
return m?.isEntry;
38+
});
39+
40+
const seen = new Set<string>();
41+
const stack: string[] = [];
42+
const dfs = (node: string) => {
43+
if (seen.has(node)) return;
44+
seen.add(node);
45+
stack.push(node);
46+
const ps = parents.get(node) || [];
47+
if (ps.length === 0) {
48+
// hit a root (likely main entry or plugin virtual)
49+
console.log("\nImporter chain:");
50+
stack
51+
.slice()
52+
.reverse()
53+
.forEach((s) => console.log(" ↳", s));
54+
} else {
55+
for (const p of ps) dfs(p);
56+
}
57+
stack.pop();
58+
};
59+
60+
if (!parents.has(target)) {
61+
console.log(`[who-imports] TARGET not in MAIN graph: ${target}`);
62+
} else {
63+
dfs(target);
64+
}
65+
},
66+
async resolveId(id: any, importer: any) {
67+
const r = await (this as any).resolve(id, importer, { skipSelf: true });
68+
if (r?.id === target) {
69+
console.log(`[resolve] ${importer} -> ${id} -> ${r.id}`);
70+
}
71+
return null;
72+
},
73+
};
74+
}
75+
1676
export default defineConfig({
1777
main: {
1878
root: ".",

emain/emain-wsh.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import { WindowService } from "@/app/store/services";
5+
import { RpcResponseHelper, WshClient } from "@/app/store/wshclient";
56
import { RpcApi } from "@/app/store/wshclientapi";
67
import { Notification } from "electron";
78
import { getResolvedUpdateChannel } from "emain/updater";
8-
import { RpcResponseHelper, WshClient } from "../frontend/app/store/wshclient";
99
import { getWebContentsByBlockId, webGetSelector } from "./emain-web";
1010
import { createBrowserWindow, getWaveWindowById, getWaveWindowByWorkspaceId } from "./emain-window";
1111
import { unamePlatform } from "./platform";

emain/emain.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { PNG } from "pngjs";
1212
import { sprintf } from "sprintf-js";
1313
import { Readable } from "stream";
1414
import * as services from "../frontend/app/store/services";
15-
import { initElectronWshrpc, shutdownWshrpc } from "../frontend/app/store/wshrpcutil";
15+
import { initElectronWshrpc, shutdownWshrpc } from "../frontend/app/store/wshrpcutil-base";
1616
import { getWebServerEndpoint } from "../frontend/util/endpoints";
1717
import * as keyutil from "../frontend/util/keyutil";
1818
import { fireAndForget, sleep } from "../frontend/util/util";
@@ -25,7 +25,7 @@ import {
2525
setForceQuit,
2626
setGlobalIsQuitting,
2727
setGlobalIsStarting,
28-
setWasActive,
28+
setWasActive,
2929
setWasInFg,
3030
} from "./emain-activity";
3131
import { ensureHotSpareTab, getWaveTabViewByWebContentsId, setMaxTabCacheSize } from "./emain-tabview";

frontend/app/aipanel/aipanel.tsx

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,20 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import { WaveUIMessagePart } from "@/app/aipanel/aitypes";
5+
import { waveAIHasSelection } from "@/app/aipanel/waveai-focus-utils";
56
import { ErrorBoundary } from "@/app/element/errorboundary";
7+
import { focusManager } from "@/app/store/focusManager";
68
import { atoms, getSettingsKeyAtom } from "@/app/store/global";
79
import { globalStore } from "@/app/store/jotaiStore";
8-
import { workspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
10+
import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
911
import { getWebServerEndpoint } from "@/util/endpoints";
12+
import { getElemAsStr } from "@/util/focusutil";
1013
import { checkKeyPressed, keydownWrapper } from "@/util/keyutil";
1114
import { cn } from "@/util/util";
1215
import { useChat } from "@ai-sdk/react";
1316
import { DefaultChatTransport } from "ai";
1417
import * as jotai from "jotai";
15-
import { memo, useEffect, useRef, useState } from "react";
18+
import { memo, useCallback, useEffect, useRef, useState } from "react";
1619
import { createDataUrl, formatFileSizeError, isAcceptableFile, normalizeMimeType, validateFileSize } from "./ai-utils";
1720
import { AIDroppedFiles } from "./aidroppedfiles";
1821
import { AIPanelHeader } from "./aipanelheader";
@@ -37,9 +40,10 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => {
3740
const inputRef = useRef<AIPanelInputRef>(null);
3841
const isLayoutMode = jotai.useAtomValue(atoms.controlShiftDelayAtom);
3942
const showOverlayBlockNums = jotai.useAtomValue(getSettingsKeyAtom("app:showoverlayblocknums")) ?? true;
40-
const isFocused = jotai.useAtomValue(atoms.waveAIFocusedAtom);
43+
const focusType = jotai.useAtomValue(focusManager.focusType);
44+
const isFocused = focusType === "waveai";
4145
const telemetryEnabled = jotai.useAtomValue(getSettingsKeyAtom("telemetry:enabled")) ?? false;
42-
const isPanelVisible = jotai.useAtomValue(workspaceLayoutModel.panelVisibleAtom);
46+
const isPanelVisible = jotai.useAtomValue(WorkspaceLayoutModel.getInstance().panelVisibleAtom);
4347

4448
const { messages, sendMessage, status, setMessages, error } = useChat({
4549
transport: new DefaultChatTransport({
@@ -246,19 +250,27 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => {
246250
}
247251
};
248252

253+
const handleFocusCapture = useCallback((event: React.FocusEvent) => {
254+
console.log("Wave AI focus capture", getElemAsStr(event.target));
255+
focusManager.requestWaveAIFocus();
256+
}, []);
257+
249258
const handleClick = (e: React.MouseEvent) => {
250-
// Check if the click target is an interactive element
251259
const target = e.target as HTMLElement;
252260
const isInteractive = target.closest('button, a, input, textarea, select, [role="button"], [tabindex]');
253261

254262
if (isInteractive) {
255263
return;
256264
}
257265

258-
// Use setTimeout to avoid interfering with other click actions
266+
const hasSelection = waveAIHasSelection();
267+
if (hasSelection) {
268+
focusManager.requestWaveAIFocus();
269+
return;
270+
}
271+
259272
setTimeout(() => {
260-
const selection = window.getSelection();
261-
if (!selection || selection.toString().length === 0) {
273+
if (!waveAIHasSelection()) {
262274
model.focusInput();
263275
}
264276
}, 0);
@@ -268,6 +280,7 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => {
268280

269281
return (
270282
<div
283+
data-waveai-panel="true"
271284
className={cn(
272285
"bg-gray-900 flex flex-col relative h-[calc(100%-4px)] mt-1",
273286
className,
@@ -279,6 +292,7 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => {
279292
borderBottomRightRadius: 10,
280293
borderBottomLeftRadius: 10,
281294
}}
295+
onFocusCapture={handleFocusCapture}
282296
onDragOver={handleDragOver}
283297
onDragEnter={handleDragEnter}
284298
onDragLeave={handleDragLeave}

frontend/app/aipanel/aipanelinput.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import { formatFileSizeError, isAcceptableFile, validateFileSize } from "@/app/aipanel/ai-utils";
5+
import { waveAIHasFocusWithin } from "@/app/aipanel/waveai-focus-utils";
56
import { type WaveAIModel } from "@/app/aipanel/waveai-model";
67
import { atoms, globalStore } from "@/app/store/global";
7-
import { workspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
8+
import { focusManager } from "@/app/store/focusManager";
9+
import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
810
import { cn } from "@/util/util";
911
import { useAtomValue } from "jotai";
1012
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef } from "react";
@@ -24,10 +26,11 @@ export interface AIPanelInputRef {
2426

2527
export const AIPanelInput = memo(
2628
forwardRef<AIPanelInputRef, AIPanelInputProps>(({ input, setInput, onSubmit, status, model }, ref) => {
27-
const isFocused = useAtomValue(atoms.waveAIFocusedAtom);
29+
const focusType = useAtomValue(focusManager.focusType);
30+
const isFocused = focusType === "waveai";
2831
const textareaRef = useRef<HTMLTextAreaElement>(null);
2932
const fileInputRef = useRef<HTMLInputElement>(null);
30-
const isPanelOpen = useAtomValue(workspaceLayoutModel.panelVisibleAtom);
33+
const isPanelOpen = useAtomValue(WorkspaceLayoutModel.getInstance().panelVisibleAtom);
3134

3235
const resizeTextarea = useCallback(() => {
3336
const textarea = textareaRef.current;
@@ -54,11 +57,19 @@ export const AIPanelInput = memo(
5457
};
5558

5659
const handleFocus = useCallback(() => {
57-
globalStore.set(atoms.waveAIFocusedAtom, true);
60+
focusManager.requestWaveAIFocus();
5861
}, []);
5962

60-
const handleBlur = useCallback(() => {
61-
globalStore.set(atoms.waveAIFocusedAtom, false);
63+
const handleBlur = useCallback((e: React.FocusEvent) => {
64+
if (e.relatedTarget === null) {
65+
return;
66+
}
67+
68+
if (waveAIHasFocusWithin()) {
69+
return;
70+
}
71+
72+
focusManager.requestNodeFocus();
6273
}, []);
6374

6475
useEffect(() => {

frontend/app/aipanel/aipanelmessages.tsx

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

4-
import { workspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
4+
import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
55
import { useAtomValue } from "jotai";
66
import { memo, useEffect, useRef } from "react";
77
import { AIMessage } from "./aimessage";
@@ -25,7 +25,7 @@ interface AIPanelMessagesProps {
2525
}
2626

2727
export const AIPanelMessages = memo(({ messages, status, isLoadingChat }: AIPanelMessagesProps) => {
28-
const isPanelOpen = useAtomValue(workspaceLayoutModel.panelVisibleAtom);
28+
const isPanelOpen = useAtomValue(WorkspaceLayoutModel.getInstance().panelVisibleAtom);
2929
const messagesEndRef = useRef<HTMLDivElement>(null);
3030
const messagesContainerRef = useRef<HTMLDivElement>(null);
3131

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright 2025, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
export function findWaveAIPanel(element: HTMLElement): HTMLElement | null {
5+
let current: HTMLElement = element;
6+
while (current) {
7+
if (current.hasAttribute("data-waveai-panel")) {
8+
return current;
9+
}
10+
current = current.parentElement;
11+
}
12+
return null;
13+
}
14+
15+
export function waveAIHasFocusWithin(): boolean {
16+
const focused = document.activeElement;
17+
if (focused instanceof HTMLElement) {
18+
const waveAIPanel = findWaveAIPanel(focused);
19+
if (waveAIPanel) return true;
20+
}
21+
22+
const sel = document.getSelection();
23+
if (sel && sel.anchorNode && sel.rangeCount > 0 && !sel.isCollapsed) {
24+
let anchor = sel.anchorNode;
25+
if (anchor instanceof Text) {
26+
anchor = anchor.parentElement;
27+
}
28+
if (anchor instanceof HTMLElement) {
29+
const waveAIPanel = findWaveAIPanel(anchor);
30+
if (waveAIPanel) return true;
31+
}
32+
}
33+
34+
return false;
35+
}
36+
37+
export function waveAIHasSelection(): boolean {
38+
const sel = document.getSelection();
39+
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
40+
return false;
41+
}
42+
43+
let anchor = sel.anchorNode;
44+
if (anchor instanceof Text) {
45+
anchor = anchor.parentElement;
46+
}
47+
if (anchor instanceof HTMLElement) {
48+
return findWaveAIPanel(anchor) != null;
49+
}
50+
51+
return false;
52+
}

frontend/app/aipanel/waveai-model.tsx

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

4-
import { getTabMetaKeyAtom } from "@/app/store/global";
4+
import { atoms, getTabMetaKeyAtom } from "@/app/store/global";
55
import { globalStore } from "@/app/store/jotaiStore";
66
import * as WOS from "@/app/store/wos";
77
import { RpcApi } from "@/app/store/wshclientapi";
88
import { TabRpcClient } from "@/app/store/wshrpcutil";
9-
import { workspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
10-
import { atoms } from "@/store/global";
9+
import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
1110
import * as jotai from "jotai";
1211
import type React from "react";
1312
import { createImagePreview, resizeImage } from "./ai-utils";
@@ -36,17 +35,17 @@ export class WaveAIModel {
3635
const tabId = globalStore.get(atoms.staticTabId);
3736
const chatIdMetaAtom = getTabMetaKeyAtom(tabId, "waveai:chatid");
3837
let chatIdValue = globalStore.get(chatIdMetaAtom);
39-
38+
4039
if (chatIdValue == null) {
4140
chatIdValue = crypto.randomUUID();
4241
RpcApi.SetMetaCommand(TabRpcClient, {
4342
oref: WOS.makeORef("tab", tabId),
4443
meta: { "waveai:chatid": chatIdValue },
4544
});
4645
}
47-
46+
4847
this.chatId = jotai.atom(chatIdValue);
49-
48+
5049
this.modelAtom = jotai.atom((get) => {
5150
const tabId = get(atoms.staticTabId);
5251
const modelMetaAtom = getTabMetaKeyAtom(tabId, "waveai:model");
@@ -114,7 +113,7 @@ export class WaveAIModel {
114113
this.clearFiles();
115114
const newChatId = crypto.randomUUID();
116115
globalStore.set(this.chatId, newChatId);
117-
116+
118117
const tabId = globalStore.get(atoms.staticTabId);
119118
RpcApi.SetMetaCommand(TabRpcClient, {
120119
oref: WOS.makeORef("tab", tabId),
@@ -135,8 +134,8 @@ export class WaveAIModel {
135134
}
136135

137136
focusInput() {
138-
if (!workspaceLayoutModel.getAIPanelVisible()) {
139-
workspaceLayoutModel.setAIPanelVisible(true);
137+
if (!WorkspaceLayoutModel.getInstance().getAIPanelVisible()) {
138+
WorkspaceLayoutModel.getInstance().setAIPanelVisible(true);
140139
}
141140
if (this.inputRef?.current) {
142141
this.inputRef.current.focus();
@@ -159,16 +158,16 @@ export class WaveAIModel {
159158
} catch (error) {
160159
console.error("Failed to load chat:", error);
161160
this.setError("Failed to load chat. Starting new chat...");
162-
161+
163162
const newChatId = crypto.randomUUID();
164163
globalStore.set(this.chatId, newChatId);
165-
164+
166165
const tabId = globalStore.get(atoms.staticTabId);
167166
RpcApi.SetMetaCommand(TabRpcClient, {
168167
oref: WOS.makeORef("tab", tabId),
169168
meta: { "waveai:chatid": newChatId },
170169
});
171-
170+
172171
return [];
173172
}
174173
}

frontend/app/block/blockframe.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
useBlockAtom,
1717
WOS,
1818
} from "@/app/store/global";
19-
import { workspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
19+
import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
2020
import { RpcApi } from "@/app/store/wshclientapi";
2121
import { TabRpcClient } from "@/app/store/wshrpcutil";
2222
import { ErrorBoundary } from "@/element/errorboundary";
@@ -525,7 +525,7 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => {
525525
const { nodeModel, viewModel, blockModel, preview, numBlocksInTab, children } = props;
526526
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", nodeModel.blockId));
527527
const isFocused = jotai.useAtomValue(nodeModel.isFocused);
528-
const aiPanelVisible = jotai.useAtomValue(workspaceLayoutModel.panelVisibleAtom);
528+
const aiPanelVisible = jotai.useAtomValue(WorkspaceLayoutModel.getInstance().panelVisibleAtom);
529529
const viewIconUnion = util.useAtomValueSafe(viewModel?.viewIcon) ?? blockViewToIcon(blockData?.meta?.view);
530530
const customBg = util.useAtomValueSafe(viewModel?.blockBg);
531531
const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection);

0 commit comments

Comments
 (0)