From f7d3c39b5e5c17860784f80652ed66cd9a078294 Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 26 Nov 2025 14:04:00 -0800 Subject: [PATCH 01/15] consolidate help/tips/settings into one widget button --- frontend/app/block/block.tsx | 2 + .../app/view/waveconfig/waveconfig-model.ts | 23 ++ frontend/app/view/waveconfig/waveconfig.tsx | 17 ++ frontend/app/workspace/widgets.tsx | 235 ++++++++++++------ 4 files changed, 196 insertions(+), 81 deletions(-) create mode 100644 frontend/app/view/waveconfig/waveconfig-model.ts create mode 100644 frontend/app/view/waveconfig/waveconfig.tsx diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index d8260965d4..7b307b9f33 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -36,6 +36,7 @@ import clsx from "clsx"; import { atom, useAtomValue } from "jotai"; import { memo, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { QuickTipsViewModel } from "../view/quicktipsview/quicktipsview"; +import { WaveConfigViewModel } from "../view/waveconfig/waveconfig-model"; import "./block.scss"; import { BlockFrame } from "./blockframe"; import { blockViewToIcon, blockViewToName } from "./blockutil"; @@ -54,6 +55,7 @@ BlockRegistry.set("launcher", LauncherViewModel); BlockRegistry.set("tsunami", TsunamiViewModel); BlockRegistry.set("aifilediff", AiFileDiffViewModel); BlockRegistry.set("secretstore", SecretStoreViewModel); +BlockRegistry.set("waveconfig", WaveConfigViewModel); function makeViewModel(blockId: string, blockView: string, nodeModel: BlockNodeModel): ViewModel { const ctor = BlockRegistry.get(blockView); diff --git a/frontend/app/view/waveconfig/waveconfig-model.ts b/frontend/app/view/waveconfig/waveconfig-model.ts new file mode 100644 index 0000000000..8d3cdfd0da --- /dev/null +++ b/frontend/app/view/waveconfig/waveconfig-model.ts @@ -0,0 +1,23 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { atom, Atom } from "jotai"; +import { WaveConfigView } from "./waveconfig"; + +class WaveConfigViewModel implements ViewModel { + viewType: string; + viewIcon: Atom; + viewName: Atom; + + constructor() { + this.viewType = "waveconfig"; + this.viewIcon = atom("gear"); + this.viewName = atom("Wave Config"); + } + + get viewComponent(): ViewComponent { + return WaveConfigView; + } +} + +export { WaveConfigViewModel }; \ No newline at end of file diff --git a/frontend/app/view/waveconfig/waveconfig.tsx b/frontend/app/view/waveconfig/waveconfig.tsx new file mode 100644 index 0000000000..e1a63a04b1 --- /dev/null +++ b/frontend/app/view/waveconfig/waveconfig.tsx @@ -0,0 +1,17 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { memo } from "react"; + +const WaveConfigView = memo(() => { + return ( +
+
Settings
+
Settings view coming soon...
+
+ ); +}); + +WaveConfigView.displayName = "WaveConfigView"; + +export { WaveConfigView }; diff --git a/frontend/app/workspace/widgets.tsx b/frontend/app/workspace/widgets.tsx index 100c9f6869..98dc32d378 100644 --- a/frontend/app/workspace/widgets.tsx +++ b/frontend/app/workspace/widgets.tsx @@ -202,6 +202,102 @@ const AppsFloatingWindow = memo( } ); +const SettingsFloatingWindow = memo( + ({ + isOpen, + onClose, + referenceElement, + }: { + isOpen: boolean; + onClose: () => void; + referenceElement: HTMLElement; + }) => { + const { refs, floatingStyles, context } = useFloating({ + open: isOpen, + onOpenChange: onClose, + placement: "left-start", + middleware: [offset(-2), shift({ padding: 12 })], + whileElementsMounted: autoUpdate, + elements: { + reference: referenceElement, + }, + }); + + const dismiss = useDismiss(context); + const { getFloatingProps } = useInteractions([dismiss]); + + if (!isOpen) return null; + + const menuItems = [ + { + icon: "gear", + label: "Settings", + onClick: () => { + const blockDef: BlockDef = { + meta: { + view: "waveconfig", + }, + }; + createBlock(blockDef, false, true); + onClose(); + }, + }, + { + icon: "lightbulb", + label: "Tips", + onClick: () => { + const blockDef: BlockDef = { + meta: { + view: "tips", + }, + }; + createBlock(blockDef, true, true); + onClose(); + }, + }, + { + icon: "circle-question", + label: "Help", + onClick: () => { + const blockDef: BlockDef = { + meta: { + view: "help", + }, + }; + createBlock(blockDef); + onClose(); + }, + }, + ]; + + return ( + +
+ {menuItems.map((item, idx) => ( +
+
+ +
+
{item.label}
+
+ ))} +
+
+ ); + } +); + +SettingsFloatingWindow.displayName = "SettingsFloatingWindow"; + const Widgets = memo(() => { const fullConfig = useAtomValue(atoms.fullConfigAtom); const hasCustomAIPresets = useAtomValue(atoms.hasCustomAIPresetsAtom); @@ -209,26 +305,6 @@ const Widgets = memo(() => { const containerRef = useRef(null); const measurementRef = useRef(null); - const helpWidget: WidgetConfigType = { - icon: "circle-question", - label: "help", - blockdef: { - meta: { - view: "help", - }, - }, - }; - const tipsWidget: WidgetConfigType = { - icon: "lightbulb", - label: "tips", - blockdef: { - meta: { - view: "tips", - }, - }, - magnified: true, - }; - const showHelp = fullConfig?.settings?.["widget:showhelp"] ?? true; const featureWaveAppBuilder = fullConfig?.settings?.["feature:waveappbuilder"] ?? false; const widgetsMap = fullConfig?.widgets ?? {}; const filteredWidgets = hasCustomAIPresets @@ -238,6 +314,8 @@ const Widgets = memo(() => { const [isAppsOpen, setIsAppsOpen] = useState(false); const appsButtonRef = useRef(null); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const settingsButtonRef = useRef(null); const checkModeNeeded = useCallback(() => { if (!containerRef.current || !measurementRef.current) return; @@ -252,7 +330,7 @@ const Widgets = memo(() => { newMode = "compact"; // Calculate total widget count for supercompact check - const totalWidgets = (widgets?.length || 0) + (showHelp ? 2 : 0); + const totalWidgets = (widgets?.length || 0) + 1; const minHeightPerWidget = 32; const requiredHeight = totalWidgets * minHeightPerWidget; @@ -264,7 +342,7 @@ const Widgets = memo(() => { if (newMode !== mode) { setMode(newMode); } - }, [mode, widgets, showHelp]); + }, [mode, widgets]); useEffect(() => { const resizeObserver = new ResizeObserver(() => { @@ -282,7 +360,7 @@ const Widgets = memo(() => { useEffect(() => { checkModeNeeded(); - }, [widgets, showHelp, checkModeNeeded]); + }, [widgets, checkModeNeeded]); const handleWidgetsBarContextMenu = (e: React.MouseEvent) => { e.preventDefault(); @@ -299,31 +377,6 @@ const Widgets = memo(() => { }); }, }, - { - label: "Show Help Widgets", - submenu: [ - { - label: "On", - type: "checkbox", - checked: showHelp, - click: () => { - fireAndForget(async () => { - await RpcApi.SetConfigCommand(TabRpcClient, { "widget:showhelp": true }); - }); - }, - }, - { - label: "Off", - type: "checkbox", - checked: !showHelp, - click: () => { - fireAndForget(async () => { - await RpcApi.SetConfigCommand(TabRpcClient, { "widget:showhelp": false }); - }); - }, - }, - ], - }, ]; ContextMenuModel.showContextMenu(menu, e); }; @@ -343,29 +396,32 @@ const Widgets = memo(() => { ))}
- {isDev() || featureWaveAppBuilder || showHelp ? ( -
- {isDev() || featureWaveAppBuilder ? ( -
setIsAppsOpen(!isAppsOpen)} - > - -
- -
-
+
+ {isDev() || featureWaveAppBuilder ? ( +
setIsAppsOpen(!isAppsOpen)} + > + +
+ +
+
+
+ ) : null} +
setIsSettingsOpen(!isSettingsOpen)} + > + +
+
- ) : null} - {showHelp ? ( - <> - - - - ) : null} +
- ) : null} +
) : ( <> @@ -391,12 +447,22 @@ const Widgets = memo(() => {
) : null} - {showHelp ? ( - <> - - - - ) : null} +
setIsSettingsOpen(!isSettingsOpen)} + > + +
+ +
+ {mode === "normal" && ( +
+ settings +
+ )} +
+
)} {isDev() ? ( @@ -415,6 +481,13 @@ const Widgets = memo(() => { referenceElement={appsButtonRef.current} /> )} + {settingsButtonRef.current && ( + setIsSettingsOpen(false)} + referenceElement={settingsButtonRef.current} + /> + )}
{ ))}
- {showHelp ? ( - <> - - - - ) : null} +
+
+ +
+
settings
+
{isDev() ? (
From a2ab931ce4189ca5a5b6fbfdd021b9c43a3413fe Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 26 Nov 2025 14:10:57 -0800 Subject: [PATCH 02/15] fix alignment, remove label --- frontend/app/workspace/widgets.tsx | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/frontend/app/workspace/widgets.tsx b/frontend/app/workspace/widgets.tsx index 98dc32d378..56d5507c39 100644 --- a/frontend/app/workspace/widgets.tsx +++ b/frontend/app/workspace/widgets.tsx @@ -436,14 +436,16 @@ const Widgets = memo(() => { onClick={() => setIsAppsOpen(!isAppsOpen)} > -
- -
- {mode === "normal" && ( -
- apps +
+
+
- )} + {mode === "normal" && ( +
+ apps +
+ )} +
) : null} @@ -456,11 +458,6 @@ const Widgets = memo(() => {
- {mode === "normal" && ( -
- settings -
- )}
From a25f715479eac3646061dcbd927ad527dc7c33cb Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 26 Nov 2025 14:38:16 -0800 Subject: [PATCH 03/15] first cut at config file editor --- frontend/app/view/waveconfig/waveconfig.tsx | 175 +++++++++++++++++++- 1 file changed, 170 insertions(+), 5 deletions(-) diff --git a/frontend/app/view/waveconfig/waveconfig.tsx b/frontend/app/view/waveconfig/waveconfig.tsx index e1a63a04b1..9cb994a54b 100644 --- a/frontend/app/view/waveconfig/waveconfig.tsx +++ b/frontend/app/view/waveconfig/waveconfig.tsx @@ -1,13 +1,178 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { memo } from "react"; +import { getApi } from "@/app/store/global"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; +import { base64ToString, stringToBase64 } from "@/util/util"; +import { atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from "jotai"; +import { memo, useCallback, useEffect } from "react"; + +type ConfigFile = { + name: string; + path: string; + language: string; +}; + +const configFiles: ConfigFile[] = [ + { name: "General", path: "settings.json", language: "json" }, + { name: "Connections", path: "connections.json", language: "json" }, + { name: "Widgets", path: "widgets.json", language: "json" }, + { name: "AI Presets", path: "presets/ai.json", language: "json" }, +]; + +const selectedFileAtom = atom(null) as PrimitiveAtom; +const fileContentAtom = atom("") as PrimitiveAtom; +const originalContentAtom = atom("") as PrimitiveAtom; +const isLoadingAtom = atom(false) as PrimitiveAtom; +const isSavingAtom = atom(false) as PrimitiveAtom; +const errorMessageAtom = atom(null) as PrimitiveAtom; + +const WaveConfigView = memo(({ blockId }: { blockId: string }) => { + const selectedFile = useAtomValue(selectedFileAtom); + const setSelectedFile = useSetAtom(selectedFileAtom); + const [fileContent, setFileContent] = useAtom(fileContentAtom); + const [originalContent, setOriginalContent] = useAtom(originalContentAtom); + const isLoading = useAtomValue(isLoadingAtom); + const setIsLoading = useSetAtom(isLoadingAtom); + const isSaving = useAtomValue(isSavingAtom); + const setIsSaving = useSetAtom(isSavingAtom); + const [errorMessage, setErrorMessage] = useAtom(errorMessageAtom); + + const loadFile = useCallback( + async (file: ConfigFile) => { + setIsLoading(true); + setErrorMessage(null); + try { + const configDir = getApi().getConfigDir(); + const fullPath = `${configDir}/${file.path}`; + const fileData = await RpcApi.FileReadCommand(TabRpcClient, { + info: { path: fullPath }, + }); + const content = fileData?.data64 ? base64ToString(fileData.data64) : ""; + setFileContent(content); + setOriginalContent(content); + setSelectedFile(file); + } catch (err) { + setErrorMessage(`Failed to load ${file.name}: ${err.message || String(err)}`); + setFileContent(""); + setOriginalContent(""); + } finally { + setIsLoading(false); + } + }, + [setFileContent, setOriginalContent, setSelectedFile, setIsLoading, setErrorMessage] + ); + + const saveFile = useCallback(async () => { + if (!selectedFile) return; + setIsSaving(true); + setErrorMessage(null); + try { + const configDir = getApi().getConfigDir(); + const fullPath = `${configDir}/${selectedFile.path}`; + await RpcApi.FileWriteCommand(TabRpcClient, { + info: { path: fullPath }, + data64: stringToBase64(fileContent), + }); + setOriginalContent(fileContent); + } catch (err) { + setErrorMessage(`Failed to save ${selectedFile.name}: ${err.message || String(err)}`); + } finally { + setIsSaving(false); + } + }, [selectedFile, fileContent, setOriginalContent, setIsSaving, setErrorMessage]); + + useEffect(() => { + if (configFiles.length > 0 && !selectedFile) { + loadFile(configFiles[0]); + } + }, [selectedFile, loadFile]); + + const hasChanges = fileContent !== originalContent; + + const prettyPrint = useCallback(() => { + try { + const parsed = JSON.parse(fileContent); + const formatted = JSON.stringify(parsed, null, 2); + setFileContent(formatted); + } catch { + // Do nothing if JSON doesn't parse + } + }, [fileContent, setFileContent]); -const WaveConfigView = memo(() => { return ( -
-
Settings
-
Settings view coming soon...
+
+
+
Config Files
+ {configFiles.map((file) => ( +
loadFile(file)} + className={`px-3 py-2 rounded cursor-pointer transition-colors ${ + selectedFile?.path === file.path ? "bg-accentbg text-primary" : "hover:bg-secondary/50" + }`} + > + {file.name} +
+ ))} +
+
+ {errorMessage && ( +
+ {errorMessage} +
+ )} + {selectedFile && ( + <> +
+
+
{selectedFile.name}
+
+ {selectedFile.path} +
+
+
+ {hasChanges && Unsaved changes} + + +
+
+
+ {isLoading ? ( +
+ Loading... +
+ ) : ( + + )} +
+ + )} +
); }); From 071bc871a64b087b9124870fe0d80b07326bc503 Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 26 Nov 2025 14:51:26 -0800 Subject: [PATCH 04/15] working on editor interface --- frontend/app/view/waveconfig/waveconfig.tsx | 119 +++++++++++++------- 1 file changed, 79 insertions(+), 40 deletions(-) diff --git a/frontend/app/view/waveconfig/waveconfig.tsx b/frontend/app/view/waveconfig/waveconfig.tsx index 9cb994a54b..f43614dcb8 100644 --- a/frontend/app/view/waveconfig/waveconfig.tsx +++ b/frontend/app/view/waveconfig/waveconfig.tsx @@ -1,13 +1,15 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { Tooltip } from "@/app/element/tooltip"; import { getApi } from "@/app/store/global"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; +import { checkKeyPressed, keydownWrapper } from "@/util/keyutil"; import { base64ToString, stringToBase64 } from "@/util/util"; import { atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from "jotai"; -import { memo, useCallback, useEffect } from "react"; +import { memo, useCallback, useEffect, useMemo } from "react"; type ConfigFile = { name: string; @@ -28,6 +30,7 @@ const originalContentAtom = atom("") as PrimitiveAtom; const isLoadingAtom = atom(false) as PrimitiveAtom; const isSavingAtom = atom(false) as PrimitiveAtom; const errorMessageAtom = atom(null) as PrimitiveAtom; +const validationErrorAtom = atom(null) as PrimitiveAtom; const WaveConfigView = memo(({ blockId }: { blockId: string }) => { const selectedFile = useAtomValue(selectedFileAtom); @@ -39,6 +42,7 @@ const WaveConfigView = memo(({ blockId }: { blockId: string }) => { const isSaving = useAtomValue(isSavingAtom); const setIsSaving = useSetAtom(isSavingAtom); const [errorMessage, setErrorMessage] = useAtom(errorMessageAtom); + const [validationError, setValidationError] = useAtom(validationErrorAtom); const loadFile = useCallback( async (file: ConfigFile) => { @@ -67,22 +71,33 @@ const WaveConfigView = memo(({ blockId }: { blockId: string }) => { const saveFile = useCallback(async () => { if (!selectedFile) return; - setIsSaving(true); - setErrorMessage(null); + try { - const configDir = getApi().getConfigDir(); - const fullPath = `${configDir}/${selectedFile.path}`; - await RpcApi.FileWriteCommand(TabRpcClient, { - info: { path: fullPath }, - data64: stringToBase64(fileContent), - }); - setOriginalContent(fileContent); + const parsed = JSON.parse(fileContent); + const formatted = JSON.stringify(parsed, null, 2); + + setIsSaving(true); + setErrorMessage(null); + setValidationError(null); + + try { + const configDir = getApi().getConfigDir(); + const fullPath = `${configDir}/${selectedFile.path}`; + await RpcApi.FileWriteCommand(TabRpcClient, { + info: { path: fullPath }, + data64: stringToBase64(formatted), + }); + setFileContent(formatted); + setOriginalContent(formatted); + } catch (err) { + setErrorMessage(`Failed to save ${selectedFile.name}: ${err.message || String(err)}`); + } finally { + setIsSaving(false); + } } catch (err) { - setErrorMessage(`Failed to save ${selectedFile.name}: ${err.message || String(err)}`); - } finally { - setIsSaving(false); + setValidationError(`Invalid JSON: ${err.message || String(err)}`); } - }, [selectedFile, fileContent, setOriginalContent, setIsSaving, setErrorMessage]); + }, [selectedFile, fileContent, setFileContent, setOriginalContent, setIsSaving, setErrorMessage, setValidationError]); useEffect(() => { if (configFiles.length > 0 && !selectedFile) { @@ -92,15 +107,26 @@ const WaveConfigView = memo(({ blockId }: { blockId: string }) => { const hasChanges = fileContent !== originalContent; - const prettyPrint = useCallback(() => { - try { - const parsed = JSON.parse(fileContent); - const formatted = JSON.stringify(parsed, null, 2); - setFileContent(formatted); - } catch { - // Do nothing if JSON doesn't parse - } - }, [fileContent, setFileContent]); + useEffect(() => { + const handleKeyDown = keydownWrapper((e: WaveKeyboardEvent) => { + if (checkKeyPressed(e, "Cmd:s")) { + if (hasChanges && !isSaving) { + saveFile(); + } + return true; + } + return false; + }); + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [hasChanges, isSaving, saveFile]); + + const saveTooltip = useMemo(() => { + const platform = getApi().getPlatform(); + const shortcut = platform === "darwin" ? "Cmd+S" : "Alt+S"; + return `Save (${shortcut})`; + }, []); return (
@@ -119,11 +145,6 @@ const WaveConfigView = memo(({ blockId }: { blockId: string }) => { ))}
- {errorMessage && ( -
- {errorMessage} -
- )} {selectedFile && ( <>
@@ -135,25 +156,43 @@ const WaveConfigView = memo(({ blockId }: { blockId: string }) => {
{hasChanges && Unsaved changes} + + + +
+
+ {errorMessage && ( +
+ {errorMessage} +
+ )} + {validationError && ( +
+ {validationError}
-
+ )}
{isLoading ? (
From 6c644747af915175fbf248444b7d4f8d9ce297da Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 26 Nov 2025 14:54:39 -0800 Subject: [PATCH 05/15] schema working --- frontend/app/view/waveconfig/waveconfig.tsx | 24 ++++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/frontend/app/view/waveconfig/waveconfig.tsx b/frontend/app/view/waveconfig/waveconfig.tsx index f43614dcb8..aa0cacc686 100644 --- a/frontend/app/view/waveconfig/waveconfig.tsx +++ b/frontend/app/view/waveconfig/waveconfig.tsx @@ -43,13 +43,13 @@ const WaveConfigView = memo(({ blockId }: { blockId: string }) => { const setIsSaving = useSetAtom(isSavingAtom); const [errorMessage, setErrorMessage] = useAtom(errorMessageAtom); const [validationError, setValidationError] = useAtom(validationErrorAtom); + const configDir = useMemo(() => getApi().getConfigDir(), []); const loadFile = useCallback( async (file: ConfigFile) => { setIsLoading(true); setErrorMessage(null); try { - const configDir = getApi().getConfigDir(); const fullPath = `${configDir}/${file.path}`; const fileData = await RpcApi.FileReadCommand(TabRpcClient, { info: { path: fullPath }, @@ -66,22 +66,21 @@ const WaveConfigView = memo(({ blockId }: { blockId: string }) => { setIsLoading(false); } }, - [setFileContent, setOriginalContent, setSelectedFile, setIsLoading, setErrorMessage] + [configDir, setFileContent, setOriginalContent, setSelectedFile, setIsLoading, setErrorMessage] ); const saveFile = useCallback(async () => { if (!selectedFile) return; - + try { const parsed = JSON.parse(fileContent); const formatted = JSON.stringify(parsed, null, 2); - + setIsSaving(true); setErrorMessage(null); setValidationError(null); - + try { - const configDir = getApi().getConfigDir(); const fullPath = `${configDir}/${selectedFile.path}`; await RpcApi.FileWriteCommand(TabRpcClient, { info: { path: fullPath }, @@ -97,7 +96,16 @@ const WaveConfigView = memo(({ blockId }: { blockId: string }) => { } catch (err) { setValidationError(`Invalid JSON: ${err.message || String(err)}`); } - }, [selectedFile, fileContent, setFileContent, setOriginalContent, setIsSaving, setErrorMessage, setValidationError]); + }, [ + configDir, + selectedFile, + fileContent, + setFileContent, + setOriginalContent, + setIsSaving, + setErrorMessage, + setValidationError, + ]); useEffect(() => { if (configFiles.length > 0 && !selectedFile) { @@ -202,7 +210,7 @@ const WaveConfigView = memo(({ blockId }: { blockId: string }) => { Date: Wed, 26 Nov 2025 15:14:46 -0800 Subject: [PATCH 06/15] add secretstore to settings, add a max-width --- frontend/app/view/secretstore/secretstore.tsx | 22 +-- .../app/view/waveconfig/waveconfig-model.ts | 139 +++++++++++++++-- frontend/app/view/waveconfig/waveconfig.tsx | 140 +++--------------- frontend/app/workspace/widgets.tsx | 13 ++ 4 files changed, 172 insertions(+), 142 deletions(-) diff --git a/frontend/app/view/secretstore/secretstore.tsx b/frontend/app/view/secretstore/secretstore.tsx index 83e32a14fe..4368f33d37 100644 --- a/frontend/app/view/secretstore/secretstore.tsx +++ b/frontend/app/view/secretstore/secretstore.tsx @@ -40,7 +40,7 @@ LoadingSpinner.displayName = "LoadingSpinner"; const EmptyState = memo(({ onAddSecret }: { onAddSecret: () => void }) => { return ( -
+

No Secrets

Add a secret to get started

@@ -83,7 +83,7 @@ interface SecretListViewProps { const SecretListView = memo(({ secretNames, onSelectSecret, onAddSecret }: SecretListViewProps) => { return ( -
+

Secrets

{secretNames.length} @@ -92,7 +92,7 @@ const SecretListView = memo(({ secretNames, onSelectSecret, onAddSecret }: Secre {secretNames.map((name) => (
onSelectSecret(name)} > @@ -102,7 +102,7 @@ const SecretListView = memo(({ secretNames, onSelectSecret, onAddSecret }: Secre ))}
@@ -140,7 +140,7 @@ const AddSecretForm = memo( const isNameInvalid = newSecretName !== "" && !secretNameRegex.test(newSecretName); return ( -
+

Add New Secret

@@ -217,7 +217,7 @@ const SecretDetailView = memo(({ model }: SecretDetailViewProps) => { } return ( -
+

{secretName}

@@ -326,7 +326,9 @@ export const SecretStoreView = memo(({ model }: { blockId: string; model: Secret if (storageBackendError) { return (
- +
+ +
); } @@ -334,7 +336,9 @@ export const SecretStoreView = memo(({ model }: { blockId: string; model: Secret if (isLoading && secretNames.length === 0 && !selectedSecret) { return (
- +
+ +
); } @@ -374,7 +378,7 @@ export const SecretStoreView = memo(({ model }: { blockId: string; model: Secret return (
{errorMessage && ( -
+
)} diff --git a/frontend/app/view/waveconfig/waveconfig-model.ts b/frontend/app/view/waveconfig/waveconfig-model.ts index 8d3cdfd0da..16744e33e0 100644 --- a/frontend/app/view/waveconfig/waveconfig-model.ts +++ b/frontend/app/view/waveconfig/waveconfig-model.ts @@ -1,23 +1,130 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { atom, Atom } from "jotai"; -import { WaveConfigView } from "./waveconfig"; - -class WaveConfigViewModel implements ViewModel { - viewType: string; - viewIcon: Atom; - viewName: Atom; - - constructor() { - this.viewType = "waveconfig"; - this.viewIcon = atom("gear"); - this.viewName = atom("Wave Config"); +import { getApi } from "@/app/store/global"; +import { globalStore } from "@/app/store/jotaiStore"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { WaveConfigView } from "@/app/view/waveconfig/waveconfig"; +import { base64ToString, stringToBase64 } from "@/util/util"; +import { atom, type PrimitiveAtom } from "jotai"; + +export type ConfigFile = { + name: string; + path: string; + language: string; +}; + +const configFiles: ConfigFile[] = [ + { name: "General", path: "settings.json", language: "json" }, + { name: "Connections", path: "connections.json", language: "json" }, + { name: "Widgets", path: "widgets.json", language: "json" }, + { name: "AI Presets", path: "presets/ai.json", language: "json" }, +]; + +export class WaveConfigViewModel implements ViewModel { + blockId: string; + viewType = "waveconfig"; + viewIcon = atom("gear"); + viewName = atom("Wave Config"); + viewComponent = WaveConfigView; + + selectedFileAtom: PrimitiveAtom; + fileContentAtom: PrimitiveAtom; + originalContentAtom: PrimitiveAtom; + isLoadingAtom: PrimitiveAtom; + isSavingAtom: PrimitiveAtom; + errorMessageAtom: PrimitiveAtom; + validationErrorAtom: PrimitiveAtom; + configDir: string; + saveShortcut: string; + + constructor(blockId: string) { + this.blockId = blockId; + this.configDir = getApi().getConfigDir(); + const platform = getApi().getPlatform(); + this.saveShortcut = platform === "darwin" ? "Cmd+S" : "Alt+S"; + + this.selectedFileAtom = atom(null) as PrimitiveAtom; + this.fileContentAtom = atom(""); + this.originalContentAtom = atom(""); + this.isLoadingAtom = atom(false); + this.isSavingAtom = atom(false); + this.errorMessageAtom = atom(null) as PrimitiveAtom; + this.validationErrorAtom = atom(null) as PrimitiveAtom; } - get viewComponent(): ViewComponent { - return WaveConfigView; + getConfigFiles(): ConfigFile[] { + return configFiles; + } + + hasChanges(): boolean { + const fileContent = globalStore.get(this.fileContentAtom); + const originalContent = globalStore.get(this.originalContentAtom); + return fileContent !== originalContent; + } + + async loadFile(file: ConfigFile) { + globalStore.set(this.isLoadingAtom, true); + globalStore.set(this.errorMessageAtom, null); + try { + const fullPath = `${this.configDir}/${file.path}`; + const fileData = await RpcApi.FileReadCommand(TabRpcClient, { + info: { path: fullPath }, + }); + const content = fileData?.data64 ? base64ToString(fileData.data64) : ""; + globalStore.set(this.fileContentAtom, content); + globalStore.set(this.originalContentAtom, content); + globalStore.set(this.selectedFileAtom, file); + } catch (err) { + globalStore.set(this.errorMessageAtom, `Failed to load ${file.name}: ${err.message || String(err)}`); + globalStore.set(this.fileContentAtom, ""); + globalStore.set(this.originalContentAtom, ""); + } finally { + globalStore.set(this.isLoadingAtom, false); + } } -} -export { WaveConfigViewModel }; \ No newline at end of file + async saveFile() { + const selectedFile = globalStore.get(this.selectedFileAtom); + if (!selectedFile) return; + + const fileContent = globalStore.get(this.fileContentAtom); + + try { + const parsed = JSON.parse(fileContent); + const formatted = JSON.stringify(parsed, null, 2); + + globalStore.set(this.isSavingAtom, true); + globalStore.set(this.errorMessageAtom, null); + globalStore.set(this.validationErrorAtom, null); + + try { + const fullPath = `${this.configDir}/${selectedFile.path}`; + await RpcApi.FileWriteCommand(TabRpcClient, { + info: { path: fullPath }, + data64: stringToBase64(formatted), + }); + globalStore.set(this.fileContentAtom, formatted); + globalStore.set(this.originalContentAtom, formatted); + } catch (err) { + globalStore.set( + this.errorMessageAtom, + `Failed to save ${selectedFile.name}: ${err.message || String(err)}` + ); + } finally { + globalStore.set(this.isSavingAtom, false); + } + } catch (err) { + globalStore.set(this.validationErrorAtom, `Invalid JSON: ${err.message || String(err)}`); + } + } + + clearError() { + globalStore.set(this.errorMessageAtom, null); + } + + clearValidationError() { + globalStore.set(this.validationErrorAtom, null); + } +} diff --git a/frontend/app/view/waveconfig/waveconfig.tsx b/frontend/app/view/waveconfig/waveconfig.tsx index aa0cacc686..0a573aebc1 100644 --- a/frontend/app/view/waveconfig/waveconfig.tsx +++ b/frontend/app/view/waveconfig/waveconfig.tsx @@ -2,124 +2,34 @@ // SPDX-License-Identifier: Apache-2.0 import { Tooltip } from "@/app/element/tooltip"; -import { getApi } from "@/app/store/global"; -import { RpcApi } from "@/app/store/wshclientapi"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; +import type { WaveConfigViewModel } from "@/app/view/waveconfig/waveconfig-model"; import { checkKeyPressed, keydownWrapper } from "@/util/keyutil"; -import { base64ToString, stringToBase64 } from "@/util/util"; -import { atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from "jotai"; -import { memo, useCallback, useEffect, useMemo } from "react"; - -type ConfigFile = { - name: string; - path: string; - language: string; -}; - -const configFiles: ConfigFile[] = [ - { name: "General", path: "settings.json", language: "json" }, - { name: "Connections", path: "connections.json", language: "json" }, - { name: "Widgets", path: "widgets.json", language: "json" }, - { name: "AI Presets", path: "presets/ai.json", language: "json" }, -]; - -const selectedFileAtom = atom(null) as PrimitiveAtom; -const fileContentAtom = atom("") as PrimitiveAtom; -const originalContentAtom = atom("") as PrimitiveAtom; -const isLoadingAtom = atom(false) as PrimitiveAtom; -const isSavingAtom = atom(false) as PrimitiveAtom; -const errorMessageAtom = atom(null) as PrimitiveAtom; -const validationErrorAtom = atom(null) as PrimitiveAtom; - -const WaveConfigView = memo(({ blockId }: { blockId: string }) => { - const selectedFile = useAtomValue(selectedFileAtom); - const setSelectedFile = useSetAtom(selectedFileAtom); - const [fileContent, setFileContent] = useAtom(fileContentAtom); - const [originalContent, setOriginalContent] = useAtom(originalContentAtom); - const isLoading = useAtomValue(isLoadingAtom); - const setIsLoading = useSetAtom(isLoadingAtom); - const isSaving = useAtomValue(isSavingAtom); - const setIsSaving = useSetAtom(isSavingAtom); - const [errorMessage, setErrorMessage] = useAtom(errorMessageAtom); - const [validationError, setValidationError] = useAtom(validationErrorAtom); - const configDir = useMemo(() => getApi().getConfigDir(), []); - - const loadFile = useCallback( - async (file: ConfigFile) => { - setIsLoading(true); - setErrorMessage(null); - try { - const fullPath = `${configDir}/${file.path}`; - const fileData = await RpcApi.FileReadCommand(TabRpcClient, { - info: { path: fullPath }, - }); - const content = fileData?.data64 ? base64ToString(fileData.data64) : ""; - setFileContent(content); - setOriginalContent(content); - setSelectedFile(file); - } catch (err) { - setErrorMessage(`Failed to load ${file.name}: ${err.message || String(err)}`); - setFileContent(""); - setOriginalContent(""); - } finally { - setIsLoading(false); - } - }, - [configDir, setFileContent, setOriginalContent, setSelectedFile, setIsLoading, setErrorMessage] - ); - - const saveFile = useCallback(async () => { - if (!selectedFile) return; - - try { - const parsed = JSON.parse(fileContent); - const formatted = JSON.stringify(parsed, null, 2); - - setIsSaving(true); - setErrorMessage(null); - setValidationError(null); - - try { - const fullPath = `${configDir}/${selectedFile.path}`; - await RpcApi.FileWriteCommand(TabRpcClient, { - info: { path: fullPath }, - data64: stringToBase64(formatted), - }); - setFileContent(formatted); - setOriginalContent(formatted); - } catch (err) { - setErrorMessage(`Failed to save ${selectedFile.name}: ${err.message || String(err)}`); - } finally { - setIsSaving(false); - } - } catch (err) { - setValidationError(`Invalid JSON: ${err.message || String(err)}`); - } - }, [ - configDir, - selectedFile, - fileContent, - setFileContent, - setOriginalContent, - setIsSaving, - setErrorMessage, - setValidationError, - ]); +import { useAtom, useAtomValue } from "jotai"; +import { memo, useEffect } from "react"; + +const WaveConfigView = memo(({ blockId, model }: ViewComponentProps) => { + const selectedFile = useAtomValue(model.selectedFileAtom); + const [fileContent, setFileContent] = useAtom(model.fileContentAtom); + const isLoading = useAtomValue(model.isLoadingAtom); + const isSaving = useAtomValue(model.isSavingAtom); + const errorMessage = useAtomValue(model.errorMessageAtom); + const validationError = useAtomValue(model.validationErrorAtom); + const configFiles = model.getConfigFiles(); useEffect(() => { if (configFiles.length > 0 && !selectedFile) { - loadFile(configFiles[0]); + model.loadFile(configFiles[0]); } - }, [selectedFile, loadFile]); + }, [selectedFile, model]); - const hasChanges = fileContent !== originalContent; + const hasChanges = model.hasChanges(); useEffect(() => { const handleKeyDown = keydownWrapper((e: WaveKeyboardEvent) => { if (checkKeyPressed(e, "Cmd:s")) { if (hasChanges && !isSaving) { - saveFile(); + model.saveFile(); } return true; } @@ -128,13 +38,9 @@ const WaveConfigView = memo(({ blockId }: { blockId: string }) => { window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [hasChanges, isSaving, saveFile]); + }, [hasChanges, isSaving, model]); - const saveTooltip = useMemo(() => { - const platform = getApi().getPlatform(); - const shortcut = platform === "darwin" ? "Cmd+S" : "Alt+S"; - return `Save (${shortcut})`; - }, []); + const saveTooltip = `Save (${model.saveShortcut})`; return (
@@ -143,7 +49,7 @@ const WaveConfigView = memo(({ blockId }: { blockId: string }) => { {configFiles.map((file) => (
loadFile(file)} + onClick={() => model.loadFile(file)} className={`px-3 py-2 rounded cursor-pointer transition-colors ${ selectedFile?.path === file.path ? "bg-accentbg text-primary" : "hover:bg-secondary/50" }`} @@ -166,7 +72,7 @@ const WaveConfigView = memo(({ blockId }: { blockId: string }) => { {hasChanges && Unsaved changes} +
{configFiles.map((file) => (
model.loadFile(file)} + onClick={() => handleFileSelect(file)} className={`px-4 py-2 border-b border-border cursor-pointer transition-colors whitespace-nowrap overflow-hidden text-ellipsis ${ selectedFile?.path === file.path ? "bg-accentbg text-primary" : "hover:bg-secondary/50" }`} @@ -37,7 +52,7 @@ const ConfigSidebar = memo(({ model }: ConfigSidebarProps) => { {deprecatedConfigFiles.map((file) => (
model.loadFile(file)} + onClick={() => handleFileSelect(file)} className={`px-4 py-2 border-t border-border cursor-pointer transition-colors ${ selectedFile?.path === file.path ? "bg-accentbg text-primary" : "hover:bg-secondary/50" }`} @@ -71,6 +86,7 @@ const WaveConfigView = memo(({ blockId, model }: ViewComponentProps - +
+ {isMenuOpen && ( +
setIsMenuOpen(false)} /> + )} +
+ +
{selectedFile && ( <>
+
{selectedFile.name}
{selectedFile.path} diff --git a/frontend/tailwindsetup.css b/frontend/tailwindsetup.css index cf400b209c..7661b3f91a 100644 --- a/frontend/tailwindsetup.css +++ b/frontend/tailwindsetup.css @@ -65,6 +65,7 @@ --ansi-brightcyan: #b7b8cb; --ansi-brightwhite: #f0f0f0; + --container-w600: 600px; --container-w450: 450px; --container-xs: 300px; --container-xxs: 200px; From 83aa03ab4b8abdc94e0fcc158754045ed9718bc8 Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 26 Nov 2025 17:35:50 -0800 Subject: [PATCH 10/15] fix resize behavior --- frontend/app/view/waveconfig/waveconfig.tsx | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/frontend/app/view/waveconfig/waveconfig.tsx b/frontend/app/view/waveconfig/waveconfig.tsx index 45d197960f..5dafa112c7 100644 --- a/frontend/app/view/waveconfig/waveconfig.tsx +++ b/frontend/app/view/waveconfig/waveconfig.tsx @@ -6,8 +6,9 @@ import { globalStore } from "@/app/store/jotaiStore"; import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; import type { ConfigFile, WaveConfigViewModel } from "@/app/view/waveconfig/waveconfig-model"; import { checkKeyPressed, keydownWrapper } from "@/util/keyutil"; +import { debounce } from "throttle-debounce"; import { useAtom, useAtomValue } from "jotai"; -import { memo, useCallback, useEffect } from "react"; +import { memo, useCallback, useEffect, useRef } from "react"; interface ConfigSidebarProps { model: WaveConfigViewModel; @@ -88,6 +89,7 @@ const WaveConfigView = memo(({ blockId, model }: ViewComponentProps(null); const handleEditorMount = useCallback( (editor) => { @@ -118,6 +120,20 @@ const WaveConfigView = memo(({ blockId, model }: ViewComponentProps window.removeEventListener("keydown", handleKeyDown); }, [hasChanges, isSaving, model]); + useEffect(() => { + if (!editorContainerRef.current) { + return; + } + const debouncedLayout = debounce(100, () => { + if (model.editorRef.current) { + model.editorRef.current.layout(); + } + }); + const resizeObserver = new ResizeObserver(debouncedLayout); + resizeObserver.observe(editorContainerRef.current); + return () => resizeObserver.disconnect(); + }, [model]); + const saveTooltip = `Save (${model.saveShortcut})`; return ( @@ -128,7 +144,7 @@ const WaveConfigView = memo(({ blockId, model }: ViewComponentProps
-
+
{selectedFile && ( <>
From 6caeca13597cea9207e2b50e8d851b2dbc582bb8 Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 26 Nov 2025 18:16:56 -0800 Subject: [PATCH 11/15] working on layout --- .../app/view/waveconfig/waveconfig-model.ts | 14 +++++++-- frontend/app/view/waveconfig/waveconfig.tsx | 29 ++++++++++++------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/frontend/app/view/waveconfig/waveconfig-model.ts b/frontend/app/view/waveconfig/waveconfig-model.ts index 5f081b8176..80b88e75a6 100644 --- a/frontend/app/view/waveconfig/waveconfig-model.ts +++ b/frontend/app/view/waveconfig/waveconfig-model.ts @@ -23,6 +23,8 @@ const configFiles: ConfigFile[] = [ { name: "General", path: "settings.json", language: "json" }, { name: "Connections", path: "connections.json", language: "json" }, { name: "Widgets", path: "widgets.json", language: "json" }, + { name: "Wave AI", path: "waveai.json", language: "json" }, + { name: "Backgrounds", path: "presets/bg.json", language: "json" }, ]; const deprecatedConfigFiles: ConfigFile[] = [ @@ -41,6 +43,7 @@ export class WaveConfigViewModel implements ViewModel { selectedFileAtom: PrimitiveAtom; fileContentAtom: PrimitiveAtom; originalContentAtom: PrimitiveAtom; + hasEditedAtom: PrimitiveAtom; isLoadingAtom: PrimitiveAtom; isSavingAtom: PrimitiveAtom; errorMessageAtom: PrimitiveAtom; @@ -60,6 +63,7 @@ export class WaveConfigViewModel implements ViewModel { this.selectedFileAtom = atom(null) as PrimitiveAtom; this.fileContentAtom = atom(""); this.originalContentAtom = atom(""); + this.hasEditedAtom = atom(false); this.isLoadingAtom = atom(false); this.isSavingAtom = atom(false); this.errorMessageAtom = atom(null) as PrimitiveAtom; @@ -86,14 +90,17 @@ export class WaveConfigViewModel implements ViewModel { } hasChanges(): boolean { - const fileContent = globalStore.get(this.fileContentAtom); - const originalContent = globalStore.get(this.originalContentAtom); - return fileContent !== originalContent; + return globalStore.get(this.hasEditedAtom); + } + + markAsEdited() { + globalStore.set(this.hasEditedAtom, true); } async loadFile(file: ConfigFile) { globalStore.set(this.isLoadingAtom, true); globalStore.set(this.errorMessageAtom, null); + globalStore.set(this.hasEditedAtom, false); try { const fullPath = `${this.configDir}/${file.path}`; const fileData = await RpcApi.FileReadCommand(TabRpcClient, { @@ -138,6 +145,7 @@ export class WaveConfigViewModel implements ViewModel { }); globalStore.set(this.fileContentAtom, formatted); globalStore.set(this.originalContentAtom, formatted); + globalStore.set(this.hasEditedAtom, false); } catch (err) { globalStore.set( this.errorMessageAtom, diff --git a/frontend/app/view/waveconfig/waveconfig.tsx b/frontend/app/view/waveconfig/waveconfig.tsx index 5dafa112c7..fac85f7f26 100644 --- a/frontend/app/view/waveconfig/waveconfig.tsx +++ b/frontend/app/view/waveconfig/waveconfig.tsx @@ -49,12 +49,11 @@ const ConfigSidebar = memo(({ model }: ConfigSidebarProps) => { ))} {deprecatedConfigFiles.length > 0 && ( <> -
{deprecatedConfigFiles.map((file) => (
handleFileSelect(file)} - className={`px-4 py-2 border-t border-border cursor-pointer transition-colors ${ + className={`px-4 py-2 border-b border-border cursor-pointer transition-colors ${ selectedFile?.path === file.path ? "bg-accentbg text-primary" : "hover:bg-secondary/50" }`} > @@ -88,9 +87,17 @@ const WaveConfigView = memo(({ blockId, model }: ViewComponentProps(null); + const handleContentChange = useCallback( + (newContent: string) => { + setFileContent(newContent); + model.markAsEdited(); + }, + [setFileContent, model] + ); + const handleEditorMount = useCallback( (editor) => { model.editorRef.current = editor; @@ -148,21 +155,21 @@ const WaveConfigView = memo(({ blockId, model }: ViewComponentProps
-
+
-
{selectedFile.name}
-
+
{selectedFile.name}
+
{selectedFile.path}
-
- {hasChanges && Unsaved changes} - +
+ {hasChanges && Unsaved changes} + -
{selectedFile.name}
-
+
+ {selectedFile.name} +
+ {selectedFile.docsUrl && ( + + + + )} +
{selectedFile.path}
- {hasChanges && Unsaved changes} + {hasChanges && ( + + Unsaved changes + + )}