diff --git a/aiprompts/tailwind-container-queries.md b/aiprompts/tailwind-container-queries.md index 007cc080cf..646bf970bb 100644 --- a/aiprompts/tailwind-container-queries.md +++ b/aiprompts/tailwind-container-queries.md @@ -19,20 +19,52 @@ In v3: install `@tailwindcss/container-queries`. - `@container` marks the parent. - `@sm:` / `@md:` refer to **container width**, not viewport. +#### Max-Width Container Queries + +For max-width queries, use `@max-` prefix: + +```html +
+ +
Only on containers < sm
+ + +
+ Fixed overlay on small, normal on large +
+
+``` + +- `@max-sm:` = max-width query (container **below** sm breakpoint) +- `@sm:` = min-width query (container **at or above** sm breakpoint) + +**IMPORTANT**: The syntax is `@max-w600:` NOT `max-@w600:` (prefix comes before the @) + #### Notes - Based on native CSS container queries (well supported in modern browsers). - Breakpoints for container queries reuse Tailwind’s `sm`, `md`, `lg`, etc. scales. - Safe for modern webapps; no IE/legacy support. -we have special breakpoints set up for panels: +We have special breakpoints set up for panels: + --container-w600: 600px; + --container-w450: 450px; --container-xs: 300px; --container-xxs: 200px; --container-tiny: 120px; since often sm, md, and lg are too big for panels. -so to use you'd do: +Usage examples: + +```html + +
-@xs:ml-4 + +
+ + +
+``` diff --git a/cmd/generateschema/main-generateschema.go b/cmd/generateschema/main-generateschema.go index aa16eeb960..5480f3dd5d 100644 --- a/cmd/generateschema/main-generateschema.go +++ b/cmd/generateschema/main-generateschema.go @@ -18,6 +18,8 @@ const WaveSchemaSettingsFileName = "schema/settings.json" const WaveSchemaConnectionsFileName = "schema/connections.json" const WaveSchemaAiPresetsFileName = "schema/aipresets.json" const WaveSchemaWidgetsFileName = "schema/widgets.json" +const WaveSchemaBgPresetsFileName = "schema/bgpresets.json" +const WaveSchemaWaveAIFileName = "schema/waveai.json" func generateSchema(template any, dir string) error { settingsSchema := jsonschema.Reflect(template) @@ -59,4 +61,16 @@ func main() { if err != nil { log.Fatalf("widgets schema error: %v", err) } + + bgPresetsTemplate := make(map[string]wconfig.BgPresetsType) + err = generateSchema(&bgPresetsTemplate, WaveSchemaBgPresetsFileName) + if err != nil { + log.Fatalf("bg presets schema error: %v", err) + } + + waveAITemplate := make(map[string]wconfig.AIModeConfigType) + err = generateSchema(&waveAITemplate, WaveSchemaWaveAIFileName) + if err != nil { + log.Fatalf("waveai schema error: %v", err) + } } diff --git a/cmd/wsh/cmd/wshcmd-editconfig.go b/cmd/wsh/cmd/wshcmd-editconfig.go index 2adf1b7647..5f2153dd77 100644 --- a/cmd/wsh/cmd/wshcmd-editconfig.go +++ b/cmd/wsh/cmd/wshcmd-editconfig.go @@ -5,12 +5,10 @@ package cmd import ( "fmt" - "path/filepath" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var editConfigMagnified bool @@ -34,32 +32,23 @@ func editConfigRun(cmd *cobra.Command, args []string) (rtnErr error) { sendActivity("editconfig", rtnErr == nil) }() - // Get config directory from Wave info - resp, err := wshclient.WaveInfoCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000}) - if err != nil { - return fmt.Errorf("getting Wave info: %w", err) - } - configFile := "settings.json" // default if len(args) > 0 { configFile = args[0] } - settingsFile := filepath.Join(resp.ConfigDir, configFile) - wshCmd := &wshrpc.CommandCreateBlockData{ BlockDef: &waveobj.BlockDef{ Meta: map[string]interface{}{ - waveobj.MetaKey_View: "preview", - waveobj.MetaKey_File: settingsFile, - waveobj.MetaKey_Edit: true, + waveobj.MetaKey_View: "waveconfig", + waveobj.MetaKey_File: configFile, }, }, Magnified: editConfigMagnified, Focused: true, } - _, err = RpcClient.SendRpcRequest(wshrpc.Command_CreateBlock, wshCmd, &wshrpc.RpcOpts{Timeout: 2000}) + _, err := RpcClient.SendRpcRequest(wshrpc.Command_CreateBlock, wshCmd, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("opening config file: %w", err) } 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/codeeditor/schemaendpoints.ts b/frontend/app/view/codeeditor/schemaendpoints.ts index 5056f7121e..0f79837eb1 100644 --- a/frontend/app/view/codeeditor/schemaendpoints.ts +++ b/frontend/app/view/codeeditor/schemaendpoints.ts @@ -37,6 +37,8 @@ const allFilepaths: Map> = new Map(); allFilepaths.set(`${getWebServerEndpoint()}/schema/settings.json`, makeConfigPathMatches("/settings.json")); allFilepaths.set(`${getWebServerEndpoint()}/schema/connections.json`, makeConfigPathMatches("/connections.json")); allFilepaths.set(`${getWebServerEndpoint()}/schema/aipresets.json`, makeConfigPathMatches("/presets/ai.json")); +allFilepaths.set(`${getWebServerEndpoint()}/schema/bgpresets.json`, makeConfigPathMatches("/presets/bg.json")); +allFilepaths.set(`${getWebServerEndpoint()}/schema/waveai.json`, makeConfigPathMatches("/waveai.json")); allFilepaths.set(`${getWebServerEndpoint()}/schema/widgets.json`, makeConfigPathMatches("/widgets.json")); async function getSchemaEndpointInfo(endpoint: string): Promise { 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 new file mode 100644 index 0000000000..bd5536b299 --- /dev/null +++ b/frontend/app/view/waveconfig/waveconfig-model.ts @@ -0,0 +1,312 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { BlockNodeModel } from "@/app/block/blocktypes"; +import { getApi, getBlockMetaKeyAtom, WOS } 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"; +import type * as MonacoTypes from "monaco-editor/esm/vs/editor/editor.api"; +import * as React from "react"; + +type ValidationResult = { success: true } | { error: string }; +type ConfigValidator = (parsed: any) => ValidationResult; + +export type ConfigFile = { + name: string; + path: string; + language: string; + deprecated?: boolean; + docsUrl?: string; + validator?: ConfigValidator; +}; + +function validateBgJson(parsed: any): ValidationResult { + const keys = Object.keys(parsed); + for (const key of keys) { + if (!key.startsWith("bg@")) { + return { error: `Invalid key "${key}": all top-level keys must start with "bg@"` }; + } + } + return { success: true }; +} + +function validateAiJson(parsed: any): ValidationResult { + const keys = Object.keys(parsed); + for (const key of keys) { + if (!key.startsWith("ai@")) { + return { error: `Invalid key "${key}": all top-level keys must start with "ai@"` }; + } + } + return { success: true }; +} + +function validateWaveAiJson(parsed: any): ValidationResult { + const keys = Object.keys(parsed); + const keyPattern = /^[a-zA-Z0-9_@-]+$/; + for (const key of keys) { + if (!keyPattern.test(key)) { + return { + error: `Invalid key "${key}": keys must only contain letters, numbers, underscores, @ and hyphens`, + }; + } + } + return { success: true }; +} + +const configFiles: ConfigFile[] = [ + { + name: "General", + path: "settings.json", + language: "json", + docsUrl: "https://docs.waveterm.dev/config", + }, + { + name: "Connections", + path: "connections.json", + language: "json", + docsUrl: "https://docs.waveterm.dev/connections", + }, + { + name: "Widgets", + path: "widgets.json", + language: "json", + docsUrl: "https://docs.waveterm.dev/customwidgets", + }, + { + name: "Wave AI", + path: "waveai.json", + language: "json", + validator: validateWaveAiJson, + }, + { + name: "Backgrounds", + path: "presets/bg.json", + language: "json", + docsUrl: "https://docs.waveterm.dev/presets#background-configurations", + validator: validateBgJson, + }, +]; + +const deprecatedConfigFiles: ConfigFile[] = [ + { + name: "Presets", + path: "presets.json", + language: "json", + deprecated: true, + }, + { + name: "AI Presets", + path: "presets/ai.json", + language: "json", + deprecated: true, + docsUrl: "https://docs.waveterm.dev/ai-presets", + validator: validateAiJson, + }, +]; + +export class WaveConfigViewModel implements ViewModel { + blockId: string; + viewType = "waveconfig"; + viewIcon = atom("gear"); + viewName = atom("Wave Config"); + viewComponent = WaveConfigView; + noPadding = atom(true); + nodeModel: BlockNodeModel; + + selectedFileAtom: PrimitiveAtom; + fileContentAtom: PrimitiveAtom; + originalContentAtom: PrimitiveAtom; + hasEditedAtom: PrimitiveAtom; + isLoadingAtom: PrimitiveAtom; + isSavingAtom: PrimitiveAtom; + errorMessageAtom: PrimitiveAtom; + validationErrorAtom: PrimitiveAtom; + isMenuOpenAtom: PrimitiveAtom; + presetsJsonExistsAtom: PrimitiveAtom; + configDir: string; + saveShortcut: string; + editorRef: React.RefObject; + + constructor(blockId: string, nodeModel: BlockNodeModel) { + this.blockId = blockId; + this.nodeModel = nodeModel; + 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.hasEditedAtom = atom(false); + this.isLoadingAtom = atom(false); + this.isSavingAtom = atom(false); + this.errorMessageAtom = atom(null) as PrimitiveAtom; + this.validationErrorAtom = atom(null) as PrimitiveAtom; + this.isMenuOpenAtom = atom(false); + this.presetsJsonExistsAtom = atom(false); + this.editorRef = React.createRef(); + + this.checkPresetsJsonExists(); + this.initialize(); + } + + async checkPresetsJsonExists() { + try { + const fullPath = `${this.configDir}/presets.json`; + const fileInfo = await RpcApi.FileInfoCommand(TabRpcClient, { + info: { path: fullPath }, + }); + if (!fileInfo.notfound) { + globalStore.set(this.presetsJsonExistsAtom, true); + } + } catch { + // File doesn't exist + } + } + + initialize() { + const selectedFile = globalStore.get(this.selectedFileAtom); + if (!selectedFile) { + const metaFileAtom = getBlockMetaKeyAtom(this.blockId, "file"); + const savedFilePath = globalStore.get(metaFileAtom); + + let fileToLoad: ConfigFile | null = null; + if (savedFilePath) { + fileToLoad = + configFiles.find((f) => f.path === savedFilePath) || + deprecatedConfigFiles.find((f) => f.path === savedFilePath) || + null; + } + + if (!fileToLoad) { + fileToLoad = configFiles[0]; + } + + if (fileToLoad) { + this.loadFile(fileToLoad); + } + } + } + + getConfigFiles(): ConfigFile[] { + return configFiles; + } + + getDeprecatedConfigFiles(): ConfigFile[] { + const presetsJsonExists = globalStore.get(this.presetsJsonExistsAtom); + return deprecatedConfigFiles.filter((f) => { + if (f.path === "presets.json") { + return presetsJsonExists; + } + return true; + }); + } + + hasChanges(): boolean { + 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, { + info: { path: fullPath }, + }); + const content = fileData?.data64 ? base64ToString(fileData.data64) : ""; + globalStore.set(this.originalContentAtom, content); + if (content.trim() === "") { + globalStore.set(this.fileContentAtom, "{\n\n}"); + } else { + globalStore.set(this.fileContentAtom, content); + } + globalStore.set(this.selectedFileAtom, file); + RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("block", this.blockId), + meta: { file: file.path }, + }); + } 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); + } + } + + async saveFile() { + const selectedFile = globalStore.get(this.selectedFileAtom); + if (!selectedFile) return; + + const fileContent = globalStore.get(this.fileContentAtom); + + try { + const parsed = JSON.parse(fileContent); + + if (typeof parsed !== "object" || parsed == null || Array.isArray(parsed)) { + globalStore.set(this.validationErrorAtom, "JSON must be an object, not an array, primitive, or null"); + return; + } + + if (selectedFile.validator) { + const validationResult = selectedFile.validator(parsed); + if ("error" in validationResult) { + globalStore.set(this.validationErrorAtom, validationResult.error); + return; + } + } + + 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); + globalStore.set(this.hasEditedAtom, false); + } 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); + } + + giveFocus(): boolean { + if (this.editorRef?.current) { + this.editorRef.current.focus(); + return true; + } + return false; + } +} diff --git a/frontend/app/view/waveconfig/waveconfig.tsx b/frontend/app/view/waveconfig/waveconfig.tsx new file mode 100644 index 0000000000..1465684c58 --- /dev/null +++ b/frontend/app/view/waveconfig/waveconfig.tsx @@ -0,0 +1,252 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Tooltip } from "@/app/element/tooltip"; +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 { useAtom, useAtomValue } from "jotai"; +import { memo, useCallback, useEffect, useRef } from "react"; +import { debounce } from "throttle-debounce"; + +interface ConfigSidebarProps { + model: WaveConfigViewModel; +} + +const ConfigSidebar = memo(({ model }: ConfigSidebarProps) => { + const selectedFile = useAtomValue(model.selectedFileAtom); + const [isMenuOpen, setIsMenuOpen] = useAtom(model.isMenuOpenAtom); + const configFiles = model.getConfigFiles(); + const deprecatedConfigFiles = model.getDeprecatedConfigFiles(); + + const handleFileSelect = (file: ConfigFile) => { + model.loadFile(file); + setIsMenuOpen(false); + }; + + return ( +
+
+ Config Files + +
+ {configFiles.map((file) => ( +
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" + }`} + > + {file.name} +
+ ))} + {deprecatedConfigFiles.length > 0 && ( + <> + {deprecatedConfigFiles.map((file) => ( +
handleFileSelect(file)} + 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" + }`} + > +
+ {file.name} + + deprecated + +
+
+ ))} + + )} +
+ ); +}); + +ConfigSidebar.displayName = "ConfigSidebar"; + +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 [isMenuOpen, setIsMenuOpen] = useAtom(model.isMenuOpenAtom); + const hasChanges = useAtomValue(model.hasEditedAtom); + const editorContainerRef = useRef(null); + + const handleContentChange = useCallback( + (newContent: string) => { + setFileContent(newContent); + model.markAsEdited(); + }, + [setFileContent, model] + ); + + const handleEditorMount = useCallback( + (editor) => { + model.editorRef.current = editor; + const isFocused = globalStore.get(model.nodeModel.isFocused); + if (isFocused) { + editor.focus(); + } + return () => { + model.editorRef.current = null; + }; + }, + [model] + ); + + useEffect(() => { + const handleKeyDown = keydownWrapper((e: WaveKeyboardEvent) => { + if (checkKeyPressed(e, "Cmd:s")) { + if (hasChanges && !isSaving) { + model.saveFile(); + } + return true; + } + return false; + }); + + window.addEventListener("keydown", handleKeyDown); + return () => 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 ( +
+ {isMenuOpen && ( +
setIsMenuOpen(false)} /> + )} +
+ +
+
+ {selectedFile && ( + <> +
+
+ +
+ {selectedFile.name} +
+ {selectedFile.docsUrl && ( + + + + )} +
+ {selectedFile.path} +
+
+
+ {hasChanges && ( + + Unsaved changes + + )} + + + +
+
+ {errorMessage && ( +
+ {errorMessage} + +
+ )} + {validationError && ( +
+ {validationError} + +
+ )} +
+ {isLoading ? ( +
+ Loading... +
+ ) : ( + + )} +
+ + )} +
+
+ ); +}); + +WaveConfigView.displayName = "WaveConfigView"; + +export { WaveConfigView }; diff --git a/frontend/app/workspace/widgets.tsx b/frontend/app/workspace/widgets.tsx index 100c9f6869..5cf95abe4f 100644 --- a/frontend/app/workspace/widgets.tsx +++ b/frontend/app/workspace/widgets.tsx @@ -202,6 +202,115 @@ 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: "lock", + label: "Secrets", + onClick: () => { + const blockDef: BlockDef = { + meta: { + view: "secretstore", + }, + }; + createBlock(blockDef, false, 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 +318,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 +327,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 +343,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 +355,7 @@ const Widgets = memo(() => { if (newMode !== mode) { setMode(newMode); } - }, [mode, widgets, showHelp]); + }, [mode, widgets]); useEffect(() => { const resizeObserver = new ResizeObserver(() => { @@ -282,7 +373,7 @@ const Widgets = memo(() => { useEffect(() => { checkModeNeeded(); - }, [widgets, showHelp, checkModeNeeded]); + }, [widgets, checkModeNeeded]); const handleWidgetsBarContextMenu = (e: React.MouseEvent) => { e.preventDefault(); @@ -299,31 +390,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 +409,32 @@ const Widgets = memo(() => { ))}
- {isDev() || featureWaveAppBuilder || showHelp ? ( -
- {isDev() || featureWaveAppBuilder ? ( -
setIsAppsOpen(!isAppsOpen)} - > - -
- -
-
+
+ {isDev() || featureWaveAppBuilder ? ( +
setIsAppsOpen(!isAppsOpen)} + > + +
+ +
+
+
+ ) : null} +
setIsSettingsOpen(!isSettingsOpen)} + > + +
+
- ) : null} - {showHelp ? ( - <> - - - - ) : null} +
- ) : null} +
) : ( <> @@ -380,23 +449,30 @@ const Widgets = memo(() => { onClick={() => setIsAppsOpen(!isAppsOpen)} > -
- -
- {mode === "normal" && ( -
- apps +
+
+
- )} + {mode === "normal" && ( +
+ apps +
+ )} +
) : null} - {showHelp ? ( - <> - - - - ) : null} +
setIsSettingsOpen(!isSettingsOpen)} + > + +
+ +
+
+
)} {isDev() ? ( @@ -415,6 +491,13 @@ const Widgets = memo(() => { referenceElement={appsButtonRef.current} /> )} + {settingsButtonRef.current && ( + setIsSettingsOpen(false)} + referenceElement={settingsButtonRef.current} + /> + )}
{ ))}
- {showHelp ? ( - <> - - - - ) : null} +
+
+ +
+
settings
+
{isDev() ? (
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; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index d00e629b4a..4c30d15bba 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -29,7 +29,7 @@ declare global { "ai:apitokensecretname"?: string; "ai:capabilities"?: string[]; "waveai:cloud"?: boolean; - "waveai:premium": boolean; + "waveai:premium"?: boolean; }; // wshrpc.ActivityDisplayType diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index c493cf49d5..a034d42140 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -810,6 +810,17 @@ type WidgetConfigType struct { BlockDef waveobj.BlockDef `json:"blockdef"` } +type BgPresetsType struct { + BgClear bool `json:"bg:*,omitempty"` + Bg string `json:"bg,omitempty" jsonschema_description:"CSS background property value"` + BgOpacity float64 `json:"bg:opacity,omitempty" jsonschema_description:"Background opacity (0.0-1.0)"` + BgBlendMode string `json:"bg:blendmode,omitempty" jsonschema_description:"CSS background-blend-mode property value"` + BgBorderColor string `json:"bg:bordercolor,omitempty" jsonschema_description:"Block frame border color"` + BgActiveBorderColor string `json:"bg:activebordercolor,omitempty" jsonschema_description:"Block frame focused border color"` + DisplayName string `json:"display:name,omitempty" jsonschema_description:"The name shown in the context menu"` + DisplayOrder float64 `json:"display:order,omitempty" jsonschema_description:"Determines the order of the background in the context menu"` +} + type MimeTypeConfigType struct { Icon string `json:"icon"` Color string `json:"color"` diff --git a/schema/bgpresets.json b/schema/bgpresets.json new file mode 100644 index 0000000000..d9c9bf9e5c --- /dev/null +++ b/schema/bgpresets.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "BgPresetsType": { + "properties": { + "bg:*": { + "type": "boolean" + }, + "bg": { + "type": "string", + "description": "CSS background property value" + }, + "bg:opacity": { + "type": "number", + "description": "Background opacity (0.0-1.0)" + }, + "bg:blendmode": { + "type": "string", + "description": "CSS background-blend-mode property value" + }, + "bg:bordercolor": { + "type": "string", + "description": "Block frame border color" + }, + "bg:activebordercolor": { + "type": "string", + "description": "Block frame focused border color" + }, + "display:name": { + "type": "string", + "description": "The name shown in the context menu" + }, + "display:order": { + "type": "number", + "description": "Determines the order of the background in the context menu" + } + }, + "additionalProperties": false, + "type": "object" + } + }, + "additionalProperties": { + "$ref": "#/$defs/BgPresetsType" + }, + "type": "object" +} diff --git a/schema/waveai.json b/schema/waveai.json new file mode 100644 index 0000000000..1f598c6a4d --- /dev/null +++ b/schema/waveai.json @@ -0,0 +1,71 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "AIModeConfigType": { + "properties": { + "display:name": { + "type": "string" + }, + "display:order": { + "type": "number" + }, + "display:icon": { + "type": "string" + }, + "display:shortdesc": { + "type": "string" + }, + "display:description": { + "type": "string" + }, + "ai:apitype": { + "type": "string" + }, + "ai:model": { + "type": "string" + }, + "ai:thinkinglevel": { + "type": "string" + }, + "ai:baseurl": { + "type": "string" + }, + "ai:apiversion": { + "type": "string" + }, + "ai:apitoken": { + "type": "string" + }, + "ai:apitokensecretname": { + "type": "string" + }, + "ai:capabilities": { + "items": { + "type": "string" + }, + "type": "array" + }, + "waveai:cloud": { + "type": "boolean" + }, + "waveai:premium": { + "type": "boolean" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "display:name", + "display:icon", + "display:description", + "ai:apitype", + "ai:model", + "ai:thinkinglevel" + ] + } + }, + "additionalProperties": { + "$ref": "#/$defs/AIModeConfigType" + }, + "type": "object" +} \ No newline at end of file