From 1da25f07a2b4e660b35077ae77e1b41386245ae1 Mon Sep 17 00:00:00 2001 From: sawka Date: Sun, 26 Oct 2025 18:48:45 -0700 Subject: [PATCH 01/13] working on an app selection modal --- emain/emain-ipc.ts | 2 +- frontend/app/modals/modal.scss | 2 +- frontend/app/store/global.ts | 2 + frontend/app/store/wshclientapi.ts | 5 + frontend/builder/app-selection-modal.tsx | 180 +++++++++++++++++++++++ frontend/builder/builder-app.tsx | 9 +- frontend/types/custom.d.ts | 1 + frontend/wave.ts | 2 + pkg/waveappstore/waveappstore.go | 9 ++ pkg/wshrpc/wshclient/wshclient.go | 6 + pkg/wshrpc/wshrpctypes.go | 5 + pkg/wshrpc/wshserver/wshserver.go | 5 + 12 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 frontend/builder/app-selection-modal.tsx diff --git a/emain/emain-ipc.ts b/emain/emain-ipc.ts index a3d8fab758..ebc17363f9 100644 --- a/emain/emain-ipc.ts +++ b/emain/emain-ipc.ts @@ -396,7 +396,7 @@ export function initIpcHandlers() { }); electron.ipcMain.on("open-builder", (event, appId?: string) => { - fireAndForget(() => createBuilderWindow(appId || "")); + fireAndForget(() => createBuilderWindow(appId || "")); }); electron.ipcMain.on("open-new-window", () => fireAndForget(createNewWaveWindow)); diff --git a/frontend/app/modals/modal.scss b/frontend/app/modals/modal.scss index 4471ca5d9a..e52c62286b 100644 --- a/frontend/app/modals/modal.scss +++ b/frontend/app/modals/modal.scss @@ -14,7 +14,7 @@ .modal-backdrop { position: fixed; - top: 0; + top: 36px; left: 0; right: 0; bottom: 0; diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index f6f37432e8..8ccffcd4ba 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -54,6 +54,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { const windowIdAtom = atom(initOpts.windowId) as PrimitiveAtom; const clientIdAtom = atom(initOpts.clientId) as PrimitiveAtom; const builderIdAtom = atom(initOpts.builderId) as PrimitiveAtom; + const builderAppIdAtom = atom(null) as PrimitiveAtom; const waveWindowTypeAtom = atom((get) => { const builderId = get(builderIdAtom); return builderId != null ? "builder" : "tab"; @@ -172,6 +173,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { // initialized in wave.ts (will not be null inside of application) clientId: clientIdAtom, builderId: builderIdAtom, + builderAppId: builderAppIdAtom, waveWindowType: waveWindowTypeAtom, uiContext: uiContextAtom, client: clientAtom, diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index e2220ea827..652a02220a 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -297,6 +297,11 @@ class RpcApiType { return client.wshRpcCall("getwaveairatelimit", null, opts); } + // command "listalleditableapps" [call] + ListAllEditableAppsCommand(client: WshClient, opts?: RpcOpts): Promise { + return client.wshRpcCall("listalleditableapps", null, opts); + } + // command "message" [call] MessageCommand(client: WshClient, data: CommandMessageData, opts?: RpcOpts): Promise { return client.wshRpcCall("message", data, opts); diff --git a/frontend/builder/app-selection-modal.tsx b/frontend/builder/app-selection-modal.tsx new file mode 100644 index 0000000000..2902d61555 --- /dev/null +++ b/frontend/builder/app-selection-modal.tsx @@ -0,0 +1,180 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { FlexiModal } from "@/app/modals/modal"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { atoms, globalStore } from "@/store/global"; +import { useEffect, useState } from "react"; + +const MaxAppNameLength = 50; +const AppNameRegex = /^[a-zA-Z0-9_-]+$/; + +export function AppSelectionModal() { + const [apps, setApps] = useState([]); + const [loading, setLoading] = useState(true); + const [newAppName, setNewAppName] = useState(""); + const [error, setError] = useState(""); + const [inputError, setInputError] = useState(""); + + useEffect(() => { + loadApps(); + }, []); + + const validateAppName = (name: string) => { + if (!name.trim()) { + setInputError(""); + return false; + } + if (name.length > MaxAppNameLength) { + setInputError(`Name must be ${MaxAppNameLength} characters or less`); + return false; + } + if (!AppNameRegex.test(name)) { + setInputError("Only letters, numbers, hyphens, and underscores allowed"); + return false; + } + setInputError(""); + return true; + }; + + const loadApps = async () => { + try { + const appList = await RpcApi.ListAllEditableAppsCommand(TabRpcClient); + setApps(appList || []); + } catch (err) { + console.error("Failed to load apps:", err); + setError("Failed to load apps"); + } finally { + setLoading(false); + } + }; + + const handleSelectApp = (appId: string) => { + globalStore.set(atoms.builderAppId, appId); + }; + + const handleCreateNew = () => { + const trimmedName = newAppName.trim(); + + if (!trimmedName) { + setError("WaveApp name cannot be empty"); + return; + } + + if (trimmedName.length > MaxAppNameLength) { + setError(`WaveApp name must be ${MaxAppNameLength} characters or less`); + return; + } + + if (!AppNameRegex.test(trimmedName)) { + setError("WaveApp name can only contain letters, numbers, hyphens, and underscores"); + return; + } + + const draftAppId = `draft/${trimmedName}`; + globalStore.set(atoms.builderAppId, draftAppId); + }; + + const isDraftApp = (appId: string) => { + return appId.startsWith("draft/"); + }; + + const getAppDisplayName = (appId: string) => { + const parts = appId.split("/"); + if (parts.length === 2) { + const isDraft = parts[0] === "draft"; + return isDraft ? `${parts[1]} (draft)` : parts[1]; + } + return appId; + }; + + if (loading) { + return ( + +
Loading apps...
+
+ ); + } + + return ( + +
+

Select a WaveApp to Edit

+ + {error && ( +
+
+ + {error} +
+
+ )} + + {apps.length > 0 && ( +
+

Existing WaveApps

+
+ {apps.map((appId) => ( + + ))} +
+
+ )} + + {apps.length > 0 &&
} + +
+

Create New WaveApp

+
+
+ { + const value = e.target.value; + setNewAppName(value); + validateAppName(value); + }} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.nativeEvent.isComposing && newAppName.trim() && !inputError) { + handleCreateNew(); + } + }} + placeholder="my-app" + maxLength={MaxAppNameLength} + className={`flex-1 px-3 py-2 bg-panel border rounded-l focus:outline-none transition-colors ${ + inputError ? "border-error" : "border-border focus:border-accent" + }`} + autoFocus + /> + +
+ {inputError && ( +
+ + {inputError} +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/builder/builder-app.tsx b/frontend/builder/builder-app.tsx index dadf4919c5..9400475bdc 100644 --- a/frontend/builder/builder-app.tsx +++ b/frontend/builder/builder-app.tsx @@ -1,10 +1,13 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { AppSelectionModal } from "@/builder/app-selection-modal"; import { BuilderWorkspace } from "@/builder/builder-workspace"; -import { globalStore } from "@/store/global"; +import { atoms, globalStore } from "@/store/global"; import { appHandleKeyDown } from "@/store/keymodel"; +import { isBlank } from "@/util/util"; import * as keyutil from "@/util/keyutil"; +import { useAtomValue } from "jotai"; import { Provider } from "jotai"; import { useEffect } from "react"; @@ -26,6 +29,8 @@ const BuilderKeyHandlers = () => { }; export function BuilderApp({ initOpts, onFirstRender }: BuilderAppProps) { + const builderAppId = useAtomValue(atoms.builderAppId); + useEffect(() => { onFirstRender(); }, []); @@ -40,7 +45,7 @@ export function BuilderApp({ initOpts, onFirstRender }: BuilderAppProps) { > {/* Title bar - draggable area */} - + {isBlank(builderAppId) ? : } ); diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index bdc29620db..3c5c26650a 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -9,6 +9,7 @@ declare global { type GlobalAtomsType = { clientId: jotai.Atom; // readonly builderId: jotai.PrimitiveAtom; // readonly (for builder mode) + builderAppId: jotai.PrimitiveAtom; // app being edited in builder mode waveWindowType: jotai.Atom<"tab" | "builder">; // derived from builderId client: jotai.Atom; // driven from WOS uiContext: jotai.Atom; // driven from windowId, tabId diff --git a/frontend/wave.ts b/frontend/wave.ts index dd9d3b471a..807dcc7a40 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -283,6 +283,8 @@ async function initBuilder(initOpts: BuilderInitOpts) { builderId: initOpts.builderId, }); (window as any).globalAtoms = atoms; + + globalStore.set(atoms.builderAppId, initOpts.appId); const globalWS = initWshrpc(makeBuilderRouteId(initOpts.builderId)); (window as any).globalWS = globalWS; diff --git a/pkg/waveappstore/waveappstore.go b/pkg/waveappstore/waveappstore.go index 6328339fc4..ef244b507c 100644 --- a/pkg/waveappstore/waveappstore.go +++ b/pkg/waveappstore/waveappstore.go @@ -17,6 +17,9 @@ import ( const ( AppNSLocal = "local" AppNSDraft = "draft" + + MaxNamespaceLen = 30 + MaxAppNameLen = 50 ) var ( @@ -46,6 +49,12 @@ func ValidateAppId(appId string) error { if err != nil { return err } + if len(appNS) > MaxNamespaceLen { + return fmt.Errorf("namespace too long: max %d characters", MaxNamespaceLen) + } + if len(appName) > MaxAppNameLen { + return fmt.Errorf("app name too long: max %d characters", MaxAppNameLen) + } if !namespaceRegex.MatchString(appNS) { return fmt.Errorf("invalid namespace: must match pattern @?[a-z0-9-]+") } diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 47af277974..8755c8ae02 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -362,6 +362,12 @@ func GetWaveAIRateLimitCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (*uctype return resp, err } +// command "listalleditableapps", wshserver.ListAllEditableAppsCommand +func ListAllEditableAppsCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]string, error) { + resp, err := sendRpcRequestCallHelper[[]string](w, "listalleditableapps", nil, opts) + return resp, err +} + // command "message", wshserver.MessageCommand func MessageCommand(w *wshutil.WshRpc, data wshrpc.CommandMessageData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "message", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index bb121e0c67..9e1fb4d559 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -151,6 +151,8 @@ const ( Command_SetRTInfo = "setrtinfo" Command_TermGetScrollbackLines = "termgetscrollbacklines" + + Command_ListAllEditableApps = "listalleditableapps" ) type RespOrErrorUnion[T any] struct { @@ -286,6 +288,9 @@ type WshRpcInterface interface { // terminal TermGetScrollbackLinesCommand(ctx context.Context, data CommandTermGetScrollbackLinesData) (*CommandTermGetScrollbackLinesRtnData, error) + // waveappstore + ListAllEditableAppsCommand(ctx context.Context) ([]string, error) + // proc VDomRenderCommand(ctx context.Context, data vdom.VDomFrontendUpdate) chan RespOrErrorUnion[*vdom.VDomBackendUpdate] VDomUrlRequestCommand(ctx context.Context, data VDomUrlRequestData) chan RespOrErrorUnion[VDomUrlRequestResponse] diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index d4bbf367e9..45748bd0d5 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -40,6 +40,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/util/wavefileutil" "github.com/wavetermdev/waveterm/pkg/waveai" + "github.com/wavetermdev/waveterm/pkg/waveappstore" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wcloud" @@ -890,6 +891,10 @@ func (ws *WshServer) WorkspaceListCommand(ctx context.Context) ([]wshrpc.Workspa return rtn, nil } +func (ws *WshServer) ListAllEditableAppsCommand(ctx context.Context) ([]string, error) { + return waveappstore.ListAllEditableApps() +} + func (ws *WshServer) RecordTEventCommand(ctx context.Context, data telemetrydata.TEvent) error { err := telemetry.RecordTEvent(ctx, &data) if err != nil { From 84044f1c703f416a73fb0b49dd9f8d6ce9462be0 Mon Sep 17 00:00:00 2001 From: sawka Date: Sun, 26 Oct 2025 21:15:32 -0700 Subject: [PATCH 02/13] implement some app file management rpc calls --- frontend/app/store/wshclientapi.ts | 20 ++++++ frontend/builder/builder-app.tsx | 37 ++++++----- frontend/builder/builder-apppanel.tsx | 64 +++++++++++++------ frontend/builder/builder-workspace.tsx | 8 +-- .../builder/store/builderAppPanelModel.ts | 39 +++++++++++ frontend/builder/tabs/builder-codetab.tsx | 18 ++---- frontend/builder/tabs/builder-filestab.tsx | 21 +----- frontend/builder/tabs/builder-previewtab.tsx | 21 +----- frontend/types/gotypes.d.ts | 47 ++++++++++++++ pkg/wshrpc/wshclient/wshclient.go | 24 +++++++ pkg/wshrpc/wshrpctypes.go | 53 ++++++++++++++- pkg/wshrpc/wshserver/wshserver.go | 44 +++++++++++++ 12 files changed, 305 insertions(+), 91 deletions(-) create mode 100644 frontend/builder/store/builderAppPanelModel.ts diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 652a02220a..0e68ef0908 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -112,6 +112,11 @@ class RpcApiType { return client.wshRpcCall("createsubblock", data, opts); } + // command "deleteappfile" [call] + DeleteAppFileCommand(client: WshClient, data: CommandDeleteAppFileData, opts?: RpcOpts): Promise { + return client.wshRpcCall("deleteappfile", data, opts); + } + // command "deleteblock" [call] DeleteBlockCommand(client: WshClient, data: CommandDeleteBlockData, opts?: RpcOpts): Promise { return client.wshRpcCall("deleteblock", data, opts); @@ -297,6 +302,11 @@ class RpcApiType { return client.wshRpcCall("getwaveairatelimit", null, opts); } + // command "listallappfiles" [call] + ListAllAppFilesCommand(client: WshClient, data: CommandListAllAppFilesData, opts?: RpcOpts): Promise { + return client.wshRpcCall("listallappfiles", data, opts); + } + // command "listalleditableapps" [call] ListAllEditableAppsCommand(client: WshClient, opts?: RpcOpts): Promise { return client.wshRpcCall("listalleditableapps", null, opts); @@ -392,6 +402,11 @@ class RpcApiType { return client.wshRpcCall("remotewritefile", data, opts); } + // command "renameappfile" [call] + RenameAppFileCommand(client: WshClient, data: CommandRenameAppFileData, opts?: RpcOpts): Promise { + return client.wshRpcCall("renameappfile", data, opts); + } + // command "resolveids" [call] ResolveIdsCommand(client: WshClient, data: CommandResolveIdsData, opts?: RpcOpts): Promise { return client.wshRpcCall("resolveids", data, opts); @@ -522,6 +537,11 @@ class RpcApiType { return client.wshRpcCall("workspacelist", null, opts); } + // command "writeappfile" [call] + WriteAppFileCommand(client: WshClient, data: CommandWriteAppFileData, opts?: RpcOpts): Promise { + return client.wshRpcCall("writeappfile", data, opts); + } + // command "wshactivity" [call] WshActivityCommand(client: WshClient, data: {[key: string]: number}, opts?: RpcOpts): Promise { return client.wshRpcCall("wshactivity", data, opts); diff --git a/frontend/builder/builder-app.tsx b/frontend/builder/builder-app.tsx index 9400475bdc..af5c7acd28 100644 --- a/frontend/builder/builder-app.tsx +++ b/frontend/builder/builder-app.tsx @@ -5,10 +5,9 @@ import { AppSelectionModal } from "@/builder/app-selection-modal"; import { BuilderWorkspace } from "@/builder/builder-workspace"; import { atoms, globalStore } from "@/store/global"; import { appHandleKeyDown } from "@/store/keymodel"; -import { isBlank } from "@/util/util"; import * as keyutil from "@/util/keyutil"; -import { useAtomValue } from "jotai"; -import { Provider } from "jotai"; +import { isBlank } from "@/util/util"; +import { Provider, useAtomValue } from "jotai"; import { useEffect } from "react"; type BuilderAppProps = { @@ -28,25 +27,33 @@ const BuilderKeyHandlers = () => { return null; }; -export function BuilderApp({ initOpts, onFirstRender }: BuilderAppProps) { +function BuilderAppInner() { const builderAppId = useAtomValue(atoms.builderAppId); - + + return ( +
+ +
+
+ WaveApp Builder{!isBlank(builderAppId) && ` (${builderAppId})`} +
+
+ {isBlank(builderAppId) ? : } +
+ ); +} + +export function BuilderApp({ initOpts, onFirstRender }: BuilderAppProps) { useEffect(() => { onFirstRender(); }, []); return ( -
- -
- {/* Title bar - draggable area */} -
- {isBlank(builderAppId) ? : } -
+
); } diff --git a/frontend/builder/builder-apppanel.tsx b/frontend/builder/builder-apppanel.tsx index 3b7bbabf0b..1640983933 100644 --- a/frontend/builder/builder-apppanel.tsx +++ b/frontend/builder/builder-apppanel.tsx @@ -1,15 +1,14 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { BuilderAppPanelModel, type TabType } from "@/builder/store/builderAppPanelModel"; import { BuilderFocusManager } from "@/builder/store/builderFocusManager"; import { BuilderCodeTab } from "@/builder/tabs/builder-codetab"; import { BuilderFilesTab } from "@/builder/tabs/builder-filestab"; import { BuilderPreviewTab } from "@/builder/tabs/builder-previewtab"; import { cn } from "@/util/util"; import { useAtomValue } from "jotai"; -import { memo, useState } from "react"; - -type TabType = "preview" | "files" | "code"; +import { memo, useRef } from "react"; type TabButtonProps = { label: string; @@ -38,18 +37,39 @@ const TabButton = memo(({ label, tabType, isActive, isAppFocused, onClick }: Tab TabButton.displayName = "TabButton"; const BuilderAppPanel = memo(() => { - const [activeTab, setActiveTab] = useState("preview"); + const model = BuilderAppPanelModel.getInstance(); + const focusElemRef = useRef(null); + const activeTab = useAtomValue(model.activeTab); const focusType = useAtomValue(BuilderFocusManager.getInstance().focusType); const isAppFocused = focusType === "app"; + if (focusElemRef.current) { + model.setFocusElemRef(focusElemRef.current); + } + const handleTabClick = (tab: TabType) => { - setActiveTab(tab); + model.setActiveTab(tab); + BuilderFocusManager.getInstance().setAppFocused(); + model.giveFocus(); + }; + + const handlePanelClick = () => { BuilderFocusManager.getInstance().setAppFocused(); + model.giveFocus(); }; return ( -
-
+
+
+ {}} + /> +
+
{ isAppFocused={isAppFocused} onClick={() => handleTabClick("preview")} /> - handleTabClick("files")} - /> { isAppFocused={isAppFocused} onClick={() => handleTabClick("code")} /> + handleTabClick("files")} + />
-
- {activeTab === "preview" && } - {activeTab === "files" && } - {activeTab === "code" && } +
+
+ +
+
+ +
+
+ +
); @@ -85,4 +111,4 @@ const BuilderAppPanel = memo(() => { BuilderAppPanel.displayName = "BuilderAppPanel"; -export { BuilderAppPanel }; \ No newline at end of file +export { BuilderAppPanel }; diff --git a/frontend/builder/builder-workspace.tsx b/frontend/builder/builder-workspace.tsx index 16f3fd1b59..6b1f10d1fe 100644 --- a/frontend/builder/builder-workspace.tsx +++ b/frontend/builder/builder-workspace.tsx @@ -13,7 +13,7 @@ import { memo, useCallback, useEffect, useState } from "react"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import { debounce } from "throttle-debounce"; -const DEFAULT_LAYOUT = { +const DefaultLayoutPercentages = { chat: 50, app: 80, build: 20, @@ -29,7 +29,7 @@ const BuilderWorkspace = memo(() => { useEffect(() => { const loadLayout = async () => { if (!builderId) { - setLayout(DEFAULT_LAYOUT); + setLayout(DefaultLayoutPercentages); setIsLoading(false); return; } @@ -41,11 +41,11 @@ const BuilderWorkspace = memo(() => { if (rtInfo?.["builder:layout"]) { setLayout(rtInfo["builder:layout"] as Record); } else { - setLayout(DEFAULT_LAYOUT); + setLayout(DefaultLayoutPercentages); } } catch (error) { console.error("Failed to load builder layout:", error); - setLayout(DEFAULT_LAYOUT); + setLayout(DefaultLayoutPercentages); } finally { setIsLoading(false); } diff --git a/frontend/builder/store/builderAppPanelModel.ts b/frontend/builder/store/builderAppPanelModel.ts new file mode 100644 index 0000000000..3061eac87d --- /dev/null +++ b/frontend/builder/store/builderAppPanelModel.ts @@ -0,0 +1,39 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { globalStore } from "@/app/store/jotaiStore"; +import { atom, type PrimitiveAtom } from "jotai"; + +export type TabType = "preview" | "files" | "code"; + +export class BuilderAppPanelModel { + private static instance: BuilderAppPanelModel | null = null; + + activeTab: PrimitiveAtom = atom("preview"); + focusElemRef: { current: HTMLInputElement | null } = { current: null }; + + private constructor() {} + + static getInstance(): BuilderAppPanelModel { + if (!BuilderAppPanelModel.instance) { + BuilderAppPanelModel.instance = new BuilderAppPanelModel(); + } + return BuilderAppPanelModel.instance; + } + + setActiveTab(tab: TabType) { + globalStore.set(this.activeTab, tab); + } + + getActiveTab(): TabType { + return globalStore.get(this.activeTab); + } + + giveFocus() { + this.focusElemRef.current?.focus(); + } + + setFocusElemRef(ref: HTMLInputElement | null) { + this.focusElemRef.current = ref; + } +} \ No newline at end of file diff --git a/frontend/builder/tabs/builder-codetab.tsx b/frontend/builder/tabs/builder-codetab.tsx index 60ed2e4acb..d2ffd8e7c4 100644 --- a/frontend/builder/tabs/builder-codetab.tsx +++ b/frontend/builder/tabs/builder-codetab.tsx @@ -1,23 +1,13 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { BuilderFocusManager } from "@/builder/store/builderFocusManager"; -import { memo, useRef } from "react"; +import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; +import { memo } from "react"; const BuilderCodeTab = memo(() => { - const focusElemRef = useRef(null); - - const handleClick = () => { - focusElemRef.current?.focus(); - BuilderFocusManager.getInstance().setAppFocused(); - }; - return ( -
-
- {}} /> -
-

Code Tab

+
+
); }); diff --git a/frontend/builder/tabs/builder-filestab.tsx b/frontend/builder/tabs/builder-filestab.tsx index b70796f6d8..7596ac912e 100644 --- a/frontend/builder/tabs/builder-filestab.tsx +++ b/frontend/builder/tabs/builder-filestab.tsx @@ -1,28 +1,11 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { BuilderFocusManager } from "@/builder/store/builderFocusManager"; -import { memo, useRef } from "react"; +import { memo } from "react"; const BuilderFilesTab = memo(() => { - const focusElemRef = useRef(null); - - const handleClick = () => { - focusElemRef.current?.focus(); - BuilderFocusManager.getInstance().setAppFocused(); - }; - return ( -
-
- {}} - /> -
+

Files Tab

); diff --git a/frontend/builder/tabs/builder-previewtab.tsx b/frontend/builder/tabs/builder-previewtab.tsx index 0aede8b477..0071e0a3db 100644 --- a/frontend/builder/tabs/builder-previewtab.tsx +++ b/frontend/builder/tabs/builder-previewtab.tsx @@ -1,28 +1,11 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { BuilderFocusManager } from "@/builder/store/builderFocusManager"; -import { memo, useRef } from "react"; +import { memo } from "react"; const BuilderPreviewTab = memo(() => { - const focusElemRef = useRef(null); - - const handleClick = () => { - focusElemRef.current?.focus(); - BuilderFocusManager.getInstance().setAppFocused(); - }; - return ( -
-
- {}} - /> -
+

Preview Tab

); diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 1578d57c66..2745bdd742 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -195,6 +195,12 @@ declare global { blockdef: BlockDef; }; + // wshrpc.CommandDeleteAppFileData + type CommandDeleteAppFileData = { + appid: string; + filename: string; + }; + // wshrpc.CommandDeleteBlockData type CommandDeleteBlockData = { blockid: string; @@ -240,6 +246,22 @@ declare global { chatid: string; }; + // wshrpc.CommandListAllAppFilesData + type CommandListAllAppFilesData = { + appid: string; + }; + + // wshrpc.CommandListAllAppFilesRtnData + type CommandListAllAppFilesRtnData = { + path: string; + absolutepath: string; + parentdir?: string; + entries: DirEntryOut[]; + entrycount: number; + totalentries: number; + truncated?: boolean; + }; + // wshrpc.CommandMessageData type CommandMessageData = { oref: ORef; @@ -269,6 +291,13 @@ declare global { opts?: FileCopyOpts; }; + // wshrpc.CommandRenameAppFileData + type CommandRenameAppFileData = { + appid: string; + fromfilename: string; + tofilename: string; + }; + // wshrpc.CommandResolveIdsData type CommandResolveIdsData = { blockid: string; @@ -354,6 +383,13 @@ declare global { opts?: WebSelectorOpts; }; + // wshrpc.CommandWriteAppFileData + type CommandWriteAppFileData = { + appid: string; + filename: string; + data64: string; + }; + // wconfig.ConfigError type ConfigError = { file: string; @@ -436,6 +472,17 @@ declare global { count: number; }; + // wshrpc.DirEntryOut + type DirEntryOut = { + name: string; + dir?: boolean; + symlink?: boolean; + size?: number; + mode: string; + modified: string; + modifiedtime: string; + }; + // vdom.DomRect type DomRect = { top: number; diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 8755c8ae02..92eba157cb 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -143,6 +143,12 @@ func CreateSubBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandCreateSubBlockD return resp, err } +// command "deleteappfile", wshserver.DeleteAppFileCommand +func DeleteAppFileCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteAppFileData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "deleteappfile", data, opts) + return err +} + // command "deleteblock", wshserver.DeleteBlockCommand func DeleteBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteBlockData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "deleteblock", data, opts) @@ -362,6 +368,12 @@ func GetWaveAIRateLimitCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (*uctype return resp, err } +// command "listallappfiles", wshserver.ListAllAppFilesCommand +func ListAllAppFilesCommand(w *wshutil.WshRpc, data wshrpc.CommandListAllAppFilesData, opts *wshrpc.RpcOpts) (*wshrpc.CommandListAllAppFilesRtnData, error) { + resp, err := sendRpcRequestCallHelper[*wshrpc.CommandListAllAppFilesRtnData](w, "listallappfiles", data, opts) + return resp, err +} + // command "listalleditableapps", wshserver.ListAllEditableAppsCommand func ListAllEditableAppsCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]string, error) { resp, err := sendRpcRequestCallHelper[[]string](w, "listalleditableapps", nil, opts) @@ -472,6 +484,12 @@ func RemoteWriteFileCommand(w *wshutil.WshRpc, data wshrpc.FileData, opts *wshrp return err } +// command "renameappfile", wshserver.RenameAppFileCommand +func RenameAppFileCommand(w *wshutil.WshRpc, data wshrpc.CommandRenameAppFileData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "renameappfile", data, opts) + return err +} + // command "resolveids", wshserver.ResolveIdsCommand func ResolveIdsCommand(w *wshutil.WshRpc, data wshrpc.CommandResolveIdsData, opts *wshrpc.RpcOpts) (wshrpc.CommandResolveIdsRtnData, error) { resp, err := sendRpcRequestCallHelper[wshrpc.CommandResolveIdsRtnData](w, "resolveids", data, opts) @@ -623,6 +641,12 @@ func WorkspaceListCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]wshrpc.Wor return resp, err } +// command "writeappfile", wshserver.WriteAppFileCommand +func WriteAppFileCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteAppFileData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "writeappfile", data, opts) + return err +} + // command "wshactivity", wshserver.WshActivityCommand func WshActivityCommand(w *wshutil.WshRpc, data map[string]int, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "wshactivity", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 9e1fb4d559..f11b917b94 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -152,7 +152,12 @@ const ( Command_TermGetScrollbackLines = "termgetscrollbacklines" + // builder Command_ListAllEditableApps = "listalleditableapps" + Command_ListAllAppFiles = "listallappfiles" + Command_WriteAppFile = "writeappfile" + Command_DeleteAppFile = "deleteappfile" + Command_RenameAppFile = "renameappfile" ) type RespOrErrorUnion[T any] struct { @@ -288,8 +293,12 @@ type WshRpcInterface interface { // terminal TermGetScrollbackLinesCommand(ctx context.Context, data CommandTermGetScrollbackLinesData) (*CommandTermGetScrollbackLinesRtnData, error) - // waveappstore + // builder ListAllEditableAppsCommand(ctx context.Context) ([]string, error) + ListAllAppFilesCommand(ctx context.Context, data CommandListAllAppFilesData) (*CommandListAllAppFilesRtnData, error) + WriteAppFileCommand(ctx context.Context, data CommandWriteAppFileData) error + DeleteAppFileCommand(ctx context.Context, data CommandDeleteAppFileData) error + RenameAppFileCommand(ctx context.Context, data CommandRenameAppFileData) error // proc VDomRenderCommand(ctx context.Context, data vdom.VDomFrontendUpdate) chan RespOrErrorUnion[*vdom.VDomBackendUpdate] @@ -882,3 +891,45 @@ type CommandTermGetScrollbackLinesRtnData struct { Lines []string `json:"lines"` LastUpdated int64 `json:"lastupdated"` } + +// builder +type CommandListAllAppFilesData struct { + AppId string `json:"appid"` +} + +type CommandListAllAppFilesRtnData struct { + Path string `json:"path"` + AbsolutePath string `json:"absolutepath"` + ParentDir string `json:"parentdir,omitempty"` + Entries []DirEntryOut `json:"entries"` + EntryCount int `json:"entrycount"` + TotalEntries int `json:"totalentries"` + Truncated bool `json:"truncated,omitempty"` +} + +type DirEntryOut struct { + Name string `json:"name"` + Dir bool `json:"dir,omitempty"` + Symlink bool `json:"symlink,omitempty"` + Size int64 `json:"size,omitempty"` + Mode string `json:"mode"` + Modified string `json:"modified"` + ModifiedTime string `json:"modifiedtime"` +} + +type CommandWriteAppFileData struct { + AppId string `json:"appid"` + FileName string `json:"filename"` + Data64 string `json:"data64"` +} + +type CommandDeleteAppFileData struct { + AppId string `json:"appid"` + FileName string `json:"filename"` +} + +type CommandRenameAppFileData struct { + AppId string `json:"appid"` + FromFileName string `json:"fromfilename"` + ToFileName string `json:"tofilename"` +} diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 45748bd0d5..51a46deb49 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -895,6 +895,50 @@ func (ws *WshServer) ListAllEditableAppsCommand(ctx context.Context) ([]string, return waveappstore.ListAllEditableApps() } +func (ws *WshServer) ListAllAppFilesCommand(ctx context.Context, data wshrpc.CommandListAllAppFilesData) (*wshrpc.CommandListAllAppFilesRtnData, error) { + result, err := waveappstore.ListAllAppFiles(data.AppId) + if err != nil { + return nil, err + } + entries := make([]wshrpc.DirEntryOut, len(result.Entries)) + for i, entry := range result.Entries { + entries[i] = wshrpc.DirEntryOut{ + Name: entry.Name, + Dir: entry.Dir, + Symlink: entry.Symlink, + Size: entry.Size, + Mode: entry.Mode, + Modified: entry.Modified, + ModifiedTime: entry.ModifiedTime, + } + } + return &wshrpc.CommandListAllAppFilesRtnData{ + Path: result.Path, + AbsolutePath: result.AbsolutePath, + ParentDir: result.ParentDir, + Entries: entries, + EntryCount: result.EntryCount, + TotalEntries: result.TotalEntries, + Truncated: result.Truncated, + }, nil +} + +func (ws *WshServer) WriteAppFileCommand(ctx context.Context, data wshrpc.CommandWriteAppFileData) error { + contents, err := base64.StdEncoding.DecodeString(data.Data64) + if err != nil { + return fmt.Errorf("failed to decode data64: %w", err) + } + return waveappstore.WriteAppFile(data.AppId, data.FileName, contents) +} + +func (ws *WshServer) DeleteAppFileCommand(ctx context.Context, data wshrpc.CommandDeleteAppFileData) error { + return waveappstore.DeleteAppFile(data.AppId, data.FileName) +} + +func (ws *WshServer) RenameAppFileCommand(ctx context.Context, data wshrpc.CommandRenameAppFileData) error { + return waveappstore.RenameAppFile(data.AppId, data.FromFileName, data.ToFileName) +} + func (ws *WshServer) RecordTEventCommand(ctx context.Context, data telemetrydata.TEvent) error { err := telemetry.RecordTEvent(ctx, &data) if err != nil { From 1ab16053a1358984197cb1fc30e224716bae64f6 Mon Sep 17 00:00:00 2001 From: sawka Date: Sun, 26 Oct 2025 21:19:51 -0700 Subject: [PATCH 03/13] implement readappfile --- frontend/app/store/wshclientapi.ts | 5 +++++ frontend/builder/builder-apppanel.tsx | 2 +- frontend/types/gotypes.d.ts | 11 +++++++++++ pkg/waveappstore/waveappstore.go | 23 +++++++++++++++++++++++ pkg/wshrpc/wshclient/wshclient.go | 6 ++++++ pkg/wshrpc/wshrpctypes.go | 11 +++++++++++ pkg/wshrpc/wshserver/wshserver.go | 10 ++++++++++ 7 files changed, 67 insertions(+), 1 deletion(-) diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 0e68ef0908..18fc68fe15 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -327,6 +327,11 @@ class RpcApiType { return client.wshRpcCall("path", data, opts); } + // command "readappfile" [call] + ReadAppFileCommand(client: WshClient, data: CommandReadAppFileData, opts?: RpcOpts): Promise { + return client.wshRpcCall("readappfile", data, opts); + } + // command "recordtevent" [call] RecordTEventCommand(client: WshClient, data: TEvent, opts?: RpcOpts): Promise { return client.wshRpcCall("recordtevent", data, opts); diff --git a/frontend/builder/builder-apppanel.tsx b/frontend/builder/builder-apppanel.tsx index 1640983933..b0a06e1e5c 100644 --- a/frontend/builder/builder-apppanel.tsx +++ b/frontend/builder/builder-apppanel.tsx @@ -86,7 +86,7 @@ const BuilderAppPanel = memo(() => { onClick={() => handleTabClick("code")} /> Date: Sun, 26 Oct 2025 21:35:09 -0700 Subject: [PATCH 04/13] working on hooking up more apppanel stuff --- emain/emain-menu.ts | 2 +- frontend/builder/builder-apppanel.tsx | 78 ++++++++++++------- .../builder/store/builderAppPanelModel.ts | 63 ++++++++++++++- frontend/builder/tabs/builder-codetab.tsx | 46 ++++++++++- 4 files changed, 158 insertions(+), 31 deletions(-) diff --git a/emain/emain-menu.ts b/emain/emain-menu.ts index 20450b55d2..f98a8d7ffd 100644 --- a/emain/emain-menu.ts +++ b/emain/emain-menu.ts @@ -95,7 +95,7 @@ async function getAppMenu( if (isDev) { fileMenu.splice(1, 0, { label: "New WaveApp Builder Window", - click: () => fireAndForget(() => createBuilderWindow("")), + click: () => fireAndForget(() => createBuilderWindow("")), }); } if (numWaveWindows == 0) { diff --git a/frontend/builder/builder-apppanel.tsx b/frontend/builder/builder-apppanel.tsx index b0a06e1e5c..19b3213c9b 100644 --- a/frontend/builder/builder-apppanel.tsx +++ b/frontend/builder/builder-apppanel.tsx @@ -6,9 +6,11 @@ import { BuilderFocusManager } from "@/builder/store/builderFocusManager"; import { BuilderCodeTab } from "@/builder/tabs/builder-codetab"; import { BuilderFilesTab } from "@/builder/tabs/builder-filestab"; import { BuilderPreviewTab } from "@/builder/tabs/builder-previewtab"; +import { ErrorBoundary } from "@/element/errorboundary"; +import { atoms } from "@/store/global"; import { cn } from "@/util/util"; import { useAtomValue } from "jotai"; -import { memo, useRef } from "react"; +import { memo, useCallback, useRef } from "react"; type TabButtonProps = { label: string; @@ -42,6 +44,8 @@ const BuilderAppPanel = memo(() => { const activeTab = useAtomValue(model.activeTab); const focusType = useAtomValue(BuilderFocusManager.getInstance().focusType); const isAppFocused = focusType === "app"; + const saveNeeded = useAtomValue(model.saveNeededAtom); + const builderAppId = useAtomValue(atoms.builderAppId); if (focusElemRef.current) { model.setFocusElemRef(focusElemRef.current); @@ -58,6 +62,12 @@ const BuilderAppPanel = memo(() => { model.giveFocus(); }; + const handleSave = useCallback(() => { + if (builderAppId) { + model.saveAppFile(builderAppId); + } + }, [builderAppId, model]); + return (
@@ -70,39 +80,55 @@ const BuilderAppPanel = memo(() => { />
-
- handleTabClick("preview")} - /> - handleTabClick("code")} - /> - handleTabClick("files")} - /> +
+
+ handleTabClick("preview")} + /> + handleTabClick("code")} + /> + handleTabClick("files")} + /> +
+ {activeTab === "code" && saveNeeded && ( + + )}
- + + +
- + + +
- + + +
diff --git a/frontend/builder/store/builderAppPanelModel.ts b/frontend/builder/store/builderAppPanelModel.ts index 3061eac87d..73a7aa8d1a 100644 --- a/frontend/builder/store/builderAppPanelModel.ts +++ b/frontend/builder/store/builderAppPanelModel.ts @@ -2,7 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { globalStore } from "@/app/store/jotaiStore"; -import { atom, type PrimitiveAtom } from "jotai"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { atom, type Atom, type PrimitiveAtom } from "jotai"; export type TabType = "preview" | "files" | "code"; @@ -10,9 +12,18 @@ export class BuilderAppPanelModel { private static instance: BuilderAppPanelModel | null = null; activeTab: PrimitiveAtom = atom("preview"); + codeContentAtom: PrimitiveAtom = atom(""); + originalContentAtom: PrimitiveAtom = atom(""); + isLoadingAtom: PrimitiveAtom = atom(false); + errorAtom: PrimitiveAtom = atom(""); + saveNeededAtom!: Atom; focusElemRef: { current: HTMLInputElement | null } = { current: null }; - private constructor() {} + private constructor() { + this.saveNeededAtom = atom((get) => { + return get(this.codeContentAtom) !== get(this.originalContentAtom); + }); + } static getInstance(): BuilderAppPanelModel { if (!BuilderAppPanelModel.instance) { @@ -29,6 +40,54 @@ export class BuilderAppPanelModel { return globalStore.get(this.activeTab); } + setCodeContent(content: string) { + globalStore.set(this.codeContentAtom, content); + } + + async loadAppFile(appId: string) { + if (!appId) { + globalStore.set(this.errorAtom, "No app selected"); + globalStore.set(this.isLoadingAtom, false); + return; + } + + try { + globalStore.set(this.isLoadingAtom, true); + globalStore.set(this.errorAtom, ""); + const result = await RpcApi.ReadAppFileCommand(TabRpcClient, { + appid: appId, + filename: "app.go", + }); + const decoded = atob(result.data64); + globalStore.set(this.codeContentAtom, decoded); + globalStore.set(this.originalContentAtom, decoded); + } catch (err) { + console.error("Failed to load app.go:", err); + globalStore.set(this.errorAtom, `Failed to load app.go: ${err.message || "Unknown error"}`); + } finally { + globalStore.set(this.isLoadingAtom, false); + } + } + + async saveAppFile(appId: string) { + if (!appId) return; + + try { + const content = globalStore.get(this.codeContentAtom); + const encoded = btoa(content); + await RpcApi.WriteAppFileCommand(TabRpcClient, { + appid: appId, + filename: "app.go", + data64: encoded, + }); + globalStore.set(this.originalContentAtom, content); + globalStore.set(this.errorAtom, ""); + } catch (err) { + console.error("Failed to save app.go:", err); + globalStore.set(this.errorAtom, `Failed to save app.go: ${err.message || "Unknown error"}`); + } + } + giveFocus() { this.focusElemRef.current?.focus(); } diff --git a/frontend/builder/tabs/builder-codetab.tsx b/frontend/builder/tabs/builder-codetab.tsx index d2ffd8e7c4..0e7badda2b 100644 --- a/frontend/builder/tabs/builder-codetab.tsx +++ b/frontend/builder/tabs/builder-codetab.tsx @@ -2,12 +2,54 @@ // SPDX-License-Identifier: Apache-2.0 import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; -import { memo } from "react"; +import { BuilderAppPanelModel } from "@/builder/store/builderAppPanelModel"; +import { atoms } from "@/store/global"; +import { useAtomValue } from "jotai"; +import { memo, useEffect } from "react"; const BuilderCodeTab = memo(() => { + const model = BuilderAppPanelModel.getInstance(); + const builderAppId = useAtomValue(atoms.builderAppId); + const codeContent = useAtomValue(model.codeContentAtom); + const isLoading = useAtomValue(model.isLoadingAtom); + const error = useAtomValue(model.errorAtom); + + useEffect(() => { + if (builderAppId) { + model.loadAppFile(builderAppId); + } + }, [builderAppId, model]); + + const handleCodeChange = (newText: string) => { + model.setCodeContent(newText); + }; + + if (isLoading) { + return ( +
+
Loading app.go...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + return (
- +
); }); From cc7e5c6672a022485e89481c2a89f26cde353325 Mon Sep 17 00:00:00 2001 From: sawka Date: Sun, 26 Oct 2025 21:41:25 -0700 Subject: [PATCH 05/13] improve builder focus handling --- frontend/builder/builder-apppanel.tsx | 40 +++++++++++-- .../builder/store/builderAppPanelModel.ts | 12 +++- frontend/builder/tabs/builder-codetab.tsx | 8 +++ frontend/builder/utils/builder-focus-utils.ts | 59 +++++++++++++++++++ 4 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 frontend/builder/utils/builder-focus-utils.ts diff --git a/frontend/builder/builder-apppanel.tsx b/frontend/builder/builder-apppanel.tsx index 19b3213c9b..af2e097546 100644 --- a/frontend/builder/builder-apppanel.tsx +++ b/frontend/builder/builder-apppanel.tsx @@ -6,6 +6,7 @@ import { BuilderFocusManager } from "@/builder/store/builderFocusManager"; import { BuilderCodeTab } from "@/builder/tabs/builder-codetab"; import { BuilderFilesTab } from "@/builder/tabs/builder-filestab"; import { BuilderPreviewTab } from "@/builder/tabs/builder-previewtab"; +import { builderAppHasSelection } from "@/builder/utils/builder-focus-utils"; import { ErrorBoundary } from "@/element/errorboundary"; import { atoms } from "@/store/global"; import { cn } from "@/util/util"; @@ -57,10 +58,34 @@ const BuilderAppPanel = memo(() => { model.giveFocus(); }; - const handlePanelClick = () => { - BuilderFocusManager.getInstance().setAppFocused(); - model.giveFocus(); - }; + const handleFocusCapture = useCallback( + (event: React.FocusEvent) => { + BuilderFocusManager.getInstance().setAppFocused(); + }, + [] + ); + + const handlePanelClick = useCallback((e: React.MouseEvent) => { + const target = e.target as HTMLElement; + const isInteractive = target.closest('button, a, input, textarea, select, [role="button"], [tabindex]'); + + if (isInteractive) { + return; + } + + const hasSelection = builderAppHasSelection(); + if (hasSelection) { + BuilderFocusManager.getInstance().setAppFocused(); + return; + } + + setTimeout(() => { + if (!builderAppHasSelection()) { + BuilderFocusManager.getInstance().setAppFocused(); + model.giveFocus(); + } + }, 0); + }, [model]); const handleSave = useCallback(() => { if (builderAppId) { @@ -69,7 +94,12 @@ const BuilderAppPanel = memo(() => { }, [builderAppId, model]); return ( -
+
= atom(""); saveNeededAtom!: Atom; focusElemRef: { current: HTMLInputElement | null } = { current: null }; + monacoEditorRef: { current: any | null } = { current: null }; private constructor() { this.saveNeededAtom = atom((get) => { @@ -89,10 +90,19 @@ export class BuilderAppPanelModel { } giveFocus() { - this.focusElemRef.current?.focus(); + const activeTab = globalStore.get(this.activeTab); + if (activeTab === "code" && this.monacoEditorRef.current) { + this.monacoEditorRef.current.focus(); + } else { + this.focusElemRef.current?.focus(); + } } setFocusElemRef(ref: HTMLInputElement | null) { this.focusElemRef.current = ref; } + + setMonacoEditorRef(ref: any) { + this.monacoEditorRef.current = ref; + } } \ No newline at end of file diff --git a/frontend/builder/tabs/builder-codetab.tsx b/frontend/builder/tabs/builder-codetab.tsx index 0e7badda2b..a2c32ca2fe 100644 --- a/frontend/builder/tabs/builder-codetab.tsx +++ b/frontend/builder/tabs/builder-codetab.tsx @@ -24,6 +24,13 @@ const BuilderCodeTab = memo(() => { model.setCodeContent(newText); }; + const handleEditorMount = (editor: any) => { + model.setMonacoEditorRef(editor); + return () => { + model.setMonacoEditorRef(null); + }; + }; + if (isLoading) { return (
@@ -49,6 +56,7 @@ const BuilderCodeTab = memo(() => { language="go" fileName="app.go" onChange={handleCodeChange} + onMount={handleEditorMount} />
); diff --git a/frontend/builder/utils/builder-focus-utils.ts b/frontend/builder/utils/builder-focus-utils.ts new file mode 100644 index 0000000000..add1987fbe --- /dev/null +++ b/frontend/builder/utils/builder-focus-utils.ts @@ -0,0 +1,59 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +export function findBuilderAppPanel(element: HTMLElement): HTMLElement | null { + let current: HTMLElement = element; + while (current) { + if (current.hasAttribute("data-builder-app-panel")) { + return current; + } + current = current.parentElement; + } + return null; +} + +export function builderAppHasFocusWithin(focusTarget?: Element | null): boolean { + if (focusTarget !== undefined) { + if (focusTarget instanceof HTMLElement) { + return findBuilderAppPanel(focusTarget) != null; + } + return false; + } + + const focused = document.activeElement; + if (focused instanceof HTMLElement) { + const appPanel = findBuilderAppPanel(focused); + if (appPanel) return true; + } + + const sel = document.getSelection(); + if (sel && sel.anchorNode && sel.rangeCount > 0 && !sel.isCollapsed) { + let anchor = sel.anchorNode; + if (anchor instanceof Text) { + anchor = anchor.parentElement; + } + if (anchor instanceof HTMLElement) { + const appPanel = findBuilderAppPanel(anchor); + if (appPanel) return true; + } + } + + return false; +} + +export function builderAppHasSelection(): boolean { + const sel = document.getSelection(); + if (!sel || sel.rangeCount === 0 || sel.isCollapsed) { + return false; + } + + let anchor = sel.anchorNode; + if (anchor instanceof Text) { + anchor = anchor.parentElement; + } + if (anchor instanceof HTMLElement) { + return findBuilderAppPanel(anchor) != null; + } + + return false; +} \ No newline at end of file From 99c0c046529b46c32e869dc0e64552fef4807989 Mon Sep 17 00:00:00 2001 From: sawka Date: Sun, 26 Oct 2025 22:15:23 -0700 Subject: [PATCH 06/13] move tsunami prompts, save/restore appid from rtinfo, code tab save/load working --- .vscode/settings.json | 3 ++- frontend/builder/app-selection-modal.tsx | 17 +++++++++++++++-- frontend/builder/builder-apppanel.tsx | 11 ++++++++--- frontend/builder/store/builderAppPanelModel.ts | 11 ++++++++--- frontend/builder/tabs/builder-codetab.tsx | 13 ++++++++++++- frontend/types/gotypes.d.ts | 2 ++ frontend/wave.ts | 15 +++++++++++++-- .../tsunami}/global-keyboard-handling.md | 0 .../aiusechat/tsunami}/graphing.md | 0 .../prompts => pkg/aiusechat/tsunami}/system.md | 0 pkg/waveobj/objrtinfo.go | 1 + pkg/wshrpc/wshrpctypes.go | 3 ++- pkg/wshrpc/wshserver/wshserver.go | 7 +++++++ 13 files changed, 70 insertions(+), 13 deletions(-) rename {tsunami/prompts => pkg/aiusechat/tsunami}/global-keyboard-handling.md (100%) rename {tsunami/prompts => pkg/aiusechat/tsunami}/graphing.md (100%) rename {tsunami/prompts => pkg/aiusechat/tsunami}/system.md (100%) diff --git a/.vscode/settings.json b/.vscode/settings.json index 07a68e5252..6f732e999e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -61,5 +61,6 @@ "QF1003": false }, "directoryFilters": ["-tsunami/frontend/scaffold"] - } + }, + "tailwindCSS.lint.suggestCanonicalClasses": "ignore" } diff --git a/frontend/builder/app-selection-modal.tsx b/frontend/builder/app-selection-modal.tsx index 2902d61555..7ad5360d68 100644 --- a/frontend/builder/app-selection-modal.tsx +++ b/frontend/builder/app-selection-modal.tsx @@ -5,6 +5,7 @@ import { FlexiModal } from "@/app/modals/modal"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { atoms, globalStore } from "@/store/global"; +import * as WOS from "@/store/wos"; import { useEffect, useState } from "react"; const MaxAppNameLength = 50; @@ -50,11 +51,17 @@ export function AppSelectionModal() { } }; - const handleSelectApp = (appId: string) => { + const handleSelectApp = async (appId: string) => { + const builderId = globalStore.get(atoms.builderId); + const oref = WOS.makeORef("builder", builderId); + await RpcApi.SetRTInfoCommand(TabRpcClient, { + oref, + data: { "builder:appid": appId }, + }); globalStore.set(atoms.builderAppId, appId); }; - const handleCreateNew = () => { + const handleCreateNew = async () => { const trimmedName = newAppName.trim(); if (!trimmedName) { @@ -73,6 +80,12 @@ export function AppSelectionModal() { } const draftAppId = `draft/${trimmedName}`; + const builderId = globalStore.get(atoms.builderId); + const oref = WOS.makeORef("builder", builderId); + await RpcApi.SetRTInfoCommand(TabRpcClient, { + oref, + data: { "builder:appid": draftAppId }, + }); globalStore.set(atoms.builderAppId, draftAppId); }; diff --git a/frontend/builder/builder-apppanel.tsx b/frontend/builder/builder-apppanel.tsx index af2e097546..44d6be68c1 100644 --- a/frontend/builder/builder-apppanel.tsx +++ b/frontend/builder/builder-apppanel.tsx @@ -134,10 +134,15 @@ const BuilderAppPanel = memo(() => { onClick={() => handleTabClick("files")} />
- {activeTab === "code" && saveNeeded && ( + {activeTab === "code" && ( diff --git a/frontend/builder/store/builderAppPanelModel.ts b/frontend/builder/store/builderAppPanelModel.ts index c93ac3fe47..51d98b9b19 100644 --- a/frontend/builder/store/builderAppPanelModel.ts +++ b/frontend/builder/store/builderAppPanelModel.ts @@ -59,9 +59,14 @@ export class BuilderAppPanelModel { appid: appId, filename: "app.go", }); - const decoded = atob(result.data64); - globalStore.set(this.codeContentAtom, decoded); - globalStore.set(this.originalContentAtom, decoded); + if (result.notfound) { + globalStore.set(this.codeContentAtom, ""); + globalStore.set(this.originalContentAtom, ""); + } else { + const decoded = atob(result.data64); + globalStore.set(this.codeContentAtom, decoded); + globalStore.set(this.originalContentAtom, decoded); + } } catch (err) { console.error("Failed to load app.go:", err); globalStore.set(this.errorAtom, `Failed to load app.go: ${err.message || "Unknown error"}`); diff --git a/frontend/builder/tabs/builder-codetab.tsx b/frontend/builder/tabs/builder-codetab.tsx index a2c32ca2fe..f7dfdb15d9 100644 --- a/frontend/builder/tabs/builder-codetab.tsx +++ b/frontend/builder/tabs/builder-codetab.tsx @@ -4,6 +4,7 @@ import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; import { BuilderAppPanelModel } from "@/builder/store/builderAppPanelModel"; import { atoms } from "@/store/global"; +import * as keyutil from "@/util/keyutil"; import { useAtomValue } from "jotai"; import { memo, useEffect } from "react"; @@ -31,6 +32,16 @@ const BuilderCodeTab = memo(() => { }; }; + const handleKeyDown = keyutil.keydownWrapper((waveEvent: WaveKeyboardEvent) => { + if (keyutil.checkKeyPressed(waveEvent, "Cmd:s")) { + if (builderAppId) { + model.saveAppFile(builderAppId); + } + return true; + } + return false; + }); + if (isLoading) { return (
@@ -48,7 +59,7 @@ const BuilderCodeTab = memo(() => { } return ( -
+
(WOS.makeORef("client", initOpts.clientId)); diff --git a/tsunami/prompts/global-keyboard-handling.md b/pkg/aiusechat/tsunami/global-keyboard-handling.md similarity index 100% rename from tsunami/prompts/global-keyboard-handling.md rename to pkg/aiusechat/tsunami/global-keyboard-handling.md diff --git a/tsunami/prompts/graphing.md b/pkg/aiusechat/tsunami/graphing.md similarity index 100% rename from tsunami/prompts/graphing.md rename to pkg/aiusechat/tsunami/graphing.md diff --git a/tsunami/prompts/system.md b/pkg/aiusechat/tsunami/system.md similarity index 100% rename from tsunami/prompts/system.md rename to pkg/aiusechat/tsunami/system.md diff --git a/pkg/waveobj/objrtinfo.go b/pkg/waveobj/objrtinfo.go index 1ce2929de3..655d5a6c8c 100644 --- a/pkg/waveobj/objrtinfo.go +++ b/pkg/waveobj/objrtinfo.go @@ -19,6 +19,7 @@ type ObjRTInfo struct { ShellLastCmdExitCode int `json:"shell:lastcmdexitcode,omitempty"` BuilderLayout map[string]float64 `json:"builder:layout,omitempty"` + BuilderAppId string `json:"builder:appid,omitempty"` WaveAIChatId string `json:"waveai:chatid,omitempty"` } diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 9135dc7266..5b86a47c0b 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -925,7 +925,8 @@ type CommandReadAppFileData struct { } type CommandReadAppFileRtnData struct { - Data64 string `json:"data64"` + Data64 string `json:"data64"` + NotFound bool `json:"notfound,omitempty"` } type CommandWriteAppFileData struct { diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index fb8ffee540..1f0be76a8a 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -9,9 +9,11 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "io/fs" "log" + "os" "path/filepath" "regexp" "strings" @@ -926,6 +928,11 @@ func (ws *WshServer) ListAllAppFilesCommand(ctx context.Context, data wshrpc.Com func (ws *WshServer) ReadAppFileCommand(ctx context.Context, data wshrpc.CommandReadAppFileData) (*wshrpc.CommandReadAppFileRtnData, error) { contents, err := waveappstore.ReadAppFile(data.AppId, data.FileName) if err != nil { + if errors.Is(err, os.ErrNotExist) { + return &wshrpc.CommandReadAppFileRtnData{ + NotFound: true, + }, nil + } return nil, fmt.Errorf("failed to read app file: %w", err) } return &wshrpc.CommandReadAppFileRtnData{ From ed0cf756573e18373cdd65402eca95f993421334 Mon Sep 17 00:00:00 2001 From: sawka Date: Sun, 26 Oct 2025 22:51:08 -0700 Subject: [PATCH 07/13] update builder system prompt --- pkg/aiusechat/tools_builder.go | 12 +++--- pkg/aiusechat/uctypes/usechat-types.go | 1 + pkg/aiusechat/usechat.go | 55 +++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/pkg/aiusechat/tools_builder.go b/pkg/aiusechat/tools_builder.go index 1924b1bf43..64f838ac5f 100644 --- a/pkg/aiusechat/tools_builder.go +++ b/pkg/aiusechat/tools_builder.go @@ -105,7 +105,9 @@ func GetBuilderEditAppFileToolDefinition(appId string) uctypes.ToolDefinition { return uctypes.ToolDefinition{ Name: "builder_edit_app_file", DisplayName: "Edit App File", - Description: fmt.Sprintf("Edit the app.go file for app %s using search and replace", appId), + Description: "Edit the app.go file for this app using precise search and replace. " + + "Each old_str must appear EXACTLY ONCE in the file or the edit will fail. " + + "All edits are applied atomically - if any single edit fails, the entire operation fails and no changes are made.", ToolLogName: "builder:edit_app", Strict: false, InputSchema: map[string]any{ @@ -113,13 +115,13 @@ func GetBuilderEditAppFileToolDefinition(appId string) uctypes.ToolDefinition { "properties": map[string]any{ "edits": map[string]any{ "type": "array", - "description": "Array of edit specifications with old and new strings", + "description": "Array of edit specifications. All edits are applied atomically - if any edit fails, none are applied.", "items": map[string]any{ "type": "object", "properties": map[string]any{ "old_str": map[string]any{ "type": "string", - "description": "The exact string to find and replace", + "description": "The exact string to find and replace. MUST appear exactly once in the file - if it appears zero times or multiple times, the entire edit operation will fail.", }, "new_str": map[string]any{ "type": "string", @@ -127,7 +129,7 @@ func GetBuilderEditAppFileToolDefinition(appId string) uctypes.ToolDefinition { }, "desc": map[string]any{ "type": "string", - "description": "Description of the edit", + "description": "Description of what this edit does", }, }, "required": []string{"old_str", "new_str"}, @@ -193,4 +195,4 @@ func GetBuilderListFilesToolDefinition(appId string) uctypes.ToolDefinition { return uctypes.ApprovalAutoApproved }, } -} \ No newline at end of file +} diff --git a/pkg/aiusechat/uctypes/usechat-types.go b/pkg/aiusechat/uctypes/usechat-types.go index 7f478e945e..0f7032af07 100644 --- a/pkg/aiusechat/uctypes/usechat-types.go +++ b/pkg/aiusechat/uctypes/usechat-types.go @@ -427,6 +427,7 @@ type WaveChatOpts struct { WidgetAccess bool RegisterToolApproval func(string) AllowNativeWebSearch bool + BuilderId string // ephemeral to the step TabState string diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index fc894a7d84..06d85e27bb 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -5,6 +5,7 @@ package aiusechat import ( "context" + _ "embed" "encoding/json" "fmt" "log" @@ -39,6 +40,9 @@ const DefaultAPI = APIType_OpenAI const DefaultAIEndpoint = "https://cfapi.waveterm.dev/api/waveai" const DefaultMaxTokens = 4 * 1024 +//go:embed tsunami/system.md +var tsunamiSystemDoc string + var ( globalRateLimitInfo = &uctypes.RateLimitInfo{Unknown: true} rateLimitLock sync.Mutex @@ -93,6 +97,51 @@ var SystemPromptText_OpenAI = strings.Join([]string{ `You have NO API access to widgets or Wave unless provided via an explicit tool.`, }, " ") +var BuilderSystemPromptText_OpenAI = strings.Join([]string{ + `# Wave AI - WaveApp Builder`, + ``, + `You are Wave AI, a specialized AI assistant designed exclusively to help users build Tsunami framework widgets for Wave Terminal.`, + ``, + `**Core Directives:**`, + `- ONLY respond to requests related to building Tsunami/wave widgets`, + `- Follow the Tsunami framework documentation strictly`, + `- Generate complete, working Go code that compiles and runs in Wave Terminal`, + `- Politely decline requests to write other types of code, answer general questions, or go off-topic`, + `- If a user asks something unrelated, respond: "I'm Wave AI, specialized in building wave widgets for Wave Terminal. I can only help with creating WaveApps. What would you like to build?"`, + ``, + `**Behavior:**`, + `- Be concise and direct. Prefer determinism over speculation.`, + `- Never fabricate data, APIs, or framework features. If unsure, say so and reference the documentation.`, + `- If a brief clarifying question eliminates guesswork, ask it.`, + ``, + `**Attached Files:**`, + `- User-attached text files appear inline as \ncontent\n`, + `- User-attached directories use JSON DirInfo`, + `- When users refer to attached files, use their inline content directly; do NOT attempt to read them again`, + ``, + `**Code Output:**`, + "- Always use fenced Markdown code blocks with language hints.", + `- Try to keep lines under ~100 characters where practical (soft wrap; correctness takes priority)`, + `- Use inline code (single backticks) only for short references like package names, method names, or file paths`, + `- Never comment on or justify formatting choices; just follow these rules`, + ``, + `**Safety:**`, + `- If a widget would perform destructive actions (file deletion, system commands, network requests to sensitive endpoints), warn the user and suggest safer alternatives or guard rails`, + `- Remind users to review generated code before running it in their terminal`, + ``, + `**Your Workflow:**`, + `1. Understand what the user wants to build`, + `2. Reference the Tsunami framework documentation below`, + `3. Use the file editing tools to modify app.go with your implementation`, + `4. Compilation/linting results will be returned after each edit`, + `5. If there are errors, analyze them and use the tools again to fix the issues`, + `6. Continue iterating until the code compiles cleanly and runs successfully`, + `7. Verify the widget follows framework conventions and meets the user's requirements`, + ``, + `-----------`, + ``, +}, "\n") + func getWaveAISettings(premium bool) (*uctypes.AIOptsType, error) { baseUrl := DefaultAIEndpoint if os.Getenv("WAVETERM_WAVEAI_ENDPOINT") != "" { @@ -626,7 +675,11 @@ func WaveAIPostMessageHandler(w http.ResponseWriter, r *http.Request) { AllowNativeWebSearch: true, } if chatOpts.Config.APIType == APIType_OpenAI { - chatOpts.SystemPrompt = []string{SystemPromptText_OpenAI} + if chatOpts.BuilderId != "" { + chatOpts.SystemPrompt = []string{BuilderSystemPromptText_OpenAI + tsunamiSystemDoc} + } else { + chatOpts.SystemPrompt = []string{SystemPromptText_OpenAI} + } } else { chatOpts.SystemPrompt = []string{SystemPromptText} } From 071a73b452fbeaf2cab03ced76c552dc9ed91e8d Mon Sep 17 00:00:00 2001 From: sawka Date: Sun, 26 Oct 2025 23:10:07 -0700 Subject: [PATCH 08/13] hook up new system prompt and tools for builder mode in wave ai... --- frontend/app/aipanel/aipanel.tsx | 1 + pkg/aiusechat/uctypes/usechat-types.go | 1 + pkg/aiusechat/usechat.go | 11 +++++++++++ 3 files changed, 13 insertions(+) diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index 384c99ff78..96b8540726 100644 --- a/frontend/app/aipanel/aipanel.tsx +++ b/frontend/app/aipanel/aipanel.tsx @@ -232,6 +232,7 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => { }; if (windowType === "builder") { body.builderid = globalStore.get(atoms.builderId); + body.builderappid = globalStore.get(atoms.builderAppId); } else { body.tabid = globalStore.get(atoms.staticTabId); } diff --git a/pkg/aiusechat/uctypes/usechat-types.go b/pkg/aiusechat/uctypes/usechat-types.go index 0f7032af07..44607b5dd6 100644 --- a/pkg/aiusechat/uctypes/usechat-types.go +++ b/pkg/aiusechat/uctypes/usechat-types.go @@ -428,6 +428,7 @@ type WaveChatOpts struct { RegisterToolApproval func(string) AllowNativeWebSearch bool BuilderId string + BuilderAppId string // ephemeral to the step TabState string diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index 06d85e27bb..62b0d76e6d 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -621,6 +621,7 @@ func sendAIMetricsTelemetry(ctx context.Context, metrics *uctypes.AIMetrics) { type PostMessageRequest struct { TabId string `json:"tabid,omitempty"` BuilderId string `json:"builderid,omitempty"` + BuilderAppId string `json:"builderappid,omitempty"` ChatID string `json:"chatid"` Msg uctypes.AIMessage `json:"msg"` WidgetAccess bool `json:"widgetaccess,omitempty"` @@ -673,6 +674,8 @@ func WaveAIPostMessageHandler(w http.ResponseWriter, r *http.Request) { WidgetAccess: req.WidgetAccess, RegisterToolApproval: RegisterToolApproval, AllowNativeWebSearch: true, + BuilderId: req.BuilderId, + BuilderAppId: req.BuilderAppId, } if chatOpts.Config.APIType == APIType_OpenAI { if chatOpts.BuilderId != "" { @@ -689,6 +692,14 @@ func WaveAIPostMessageHandler(w http.ResponseWriter, r *http.Request) { return tabState, tabTools, req.TabId, err } + if req.BuilderAppId != "" { + chatOpts.Tools = append(chatOpts.Tools, + GetBuilderWriteAppFileToolDefinition(req.BuilderAppId), + GetBuilderEditAppFileToolDefinition(req.BuilderAppId), + GetBuilderListFilesToolDefinition(req.BuilderAppId), + ) + } + // Validate the message if err := req.Msg.Validate(); err != nil { http.Error(w, fmt.Sprintf("Message validation failed: %v", err), http.StatusInternalServerError) From 29267d3a50f783271bee7a448a02661e8cb2a6c0 Mon Sep 17 00:00:00 2001 From: sawka Date: Sun, 26 Oct 2025 23:31:31 -0700 Subject: [PATCH 09/13] inject tools and app.go context... --- frontend/builder/app-selection-modal.tsx | 22 ++++++++++--- .../anthropic/anthropic-convertmessage.go | 17 ++++++++++ pkg/aiusechat/openai/openai-convertmessage.go | 17 ++++++++++ pkg/aiusechat/uctypes/usechat-types.go | 31 ++++++++++--------- pkg/aiusechat/usechat.go | 29 +++++++++++++++-- 5 files changed, 95 insertions(+), 21 deletions(-) diff --git a/frontend/builder/app-selection-modal.tsx b/frontend/builder/app-selection-modal.tsx index 7ad5360d68..247230b011 100644 --- a/frontend/builder/app-selection-modal.tsx +++ b/frontend/builder/app-selection-modal.tsx @@ -112,7 +112,7 @@ export function AppSelectionModal() { return ( -
+

Select a WaveApp to Edit

{error && ( @@ -134,14 +134,23 @@ export function AppSelectionModal() { onClick={() => handleSelectApp(appId)} className="w-full text-left px-4 py-3 bg-panel hover:bg-hover border border-border rounded transition-colors cursor-pointer" > - {getAppDisplayName(appId)} +
+ + {getAppDisplayName(appId)} +
))}
)} - {apps.length > 0 &&
} + {apps.length > 0 && ( +
+
+ or +
+
+ )}

Create New WaveApp

@@ -156,7 +165,12 @@ export function AppSelectionModal() { validateAppName(value); }} onKeyDown={(e) => { - if (e.key === "Enter" && !e.nativeEvent.isComposing && newAppName.trim() && !inputError) { + if ( + e.key === "Enter" && + !e.nativeEvent.isComposing && + newAppName.trim() && + !inputError + ) { handleCreateNew(); } }} diff --git a/pkg/aiusechat/anthropic/anthropic-convertmessage.go b/pkg/aiusechat/anthropic/anthropic-convertmessage.go index 07e9575024..f49ef3f2fd 100644 --- a/pkg/aiusechat/anthropic/anthropic-convertmessage.go +++ b/pkg/aiusechat/anthropic/anthropic-convertmessage.go @@ -72,6 +72,23 @@ func buildAnthropicHTTPRequest(ctx context.Context, msgs []anthropicInputMessage } } + // inject chatOpts.AppGoFile as a "text" block at the END of the LAST "user" message found (append to Content) + if chatOpts.AppGoFile != "" { + // Find the last "user" message + for i := len(convertedMsgs) - 1; i >= 0; i-- { + if convertedMsgs[i].Role == "user" { + // Create a text block with the AppGoFile content wrapped in XML tag + appGoFileBlock := anthropicMessageContentBlock{ + Type: "text", + Text: "\n" + chatOpts.AppGoFile + "\n", + } + // Append to the Content of this message + convertedMsgs[i].Content = append(convertedMsgs[i].Content, appGoFileBlock) + break + } + } + } + // Build request body reqBody := &anthropicStreamRequest{ Model: opts.Model, diff --git a/pkg/aiusechat/openai/openai-convertmessage.go b/pkg/aiusechat/openai/openai-convertmessage.go index ee61cf71d8..e3505e8938 100644 --- a/pkg/aiusechat/openai/openai-convertmessage.go +++ b/pkg/aiusechat/openai/openai-convertmessage.go @@ -194,6 +194,23 @@ func buildOpenAIHTTPRequest(ctx context.Context, inputs []any, chatOpts uctypes. } } + // Inject chatOpts.AppGoFile as a text block at the end of the last "user" message + if chatOpts.AppGoFile != "" { + // Find the last "user" message + for i := len(inputs) - 1; i >= 0; i-- { + if msg, ok := inputs[i].(OpenAIMessage); ok && msg.Role == "user" { + // Add AppGoFile wrapped in XML tag + appGoFileBlock := OpenAIMessageContent{ + Type: "input_text", + Text: "\n" + chatOpts.AppGoFile + "\n", + } + msg.Content = append(msg.Content, appGoFileBlock) + inputs[i] = msg + break + } + } + } + // Build request body reqBody := &OpenAIRequest{ Model: opts.Model, diff --git a/pkg/aiusechat/uctypes/usechat-types.go b/pkg/aiusechat/uctypes/usechat-types.go index 44607b5dd6..19a1aefa5d 100644 --- a/pkg/aiusechat/uctypes/usechat-types.go +++ b/pkg/aiusechat/uctypes/usechat-types.go @@ -418,22 +418,25 @@ func (m *UIMessage) GetContent() string { } type WaveChatOpts struct { - ChatId string - ClientId string - Config AIOptsType - Tools []ToolDefinition - SystemPrompt []string - TabStateGenerator func() (string, []ToolDefinition, string, error) - WidgetAccess bool - RegisterToolApproval func(string) - AllowNativeWebSearch bool - BuilderId string - BuilderAppId string + ChatId string + ClientId string + Config AIOptsType + Tools []ToolDefinition + SystemPrompt []string + TabStateGenerator func() (string, []ToolDefinition, string, error) + BuilderAppGenerator func() (string, string, error) + WidgetAccess bool + RegisterToolApproval func(string) + AllowNativeWebSearch bool + BuilderId string + BuilderAppId string // ephemeral to the step - TabState string - TabTools []ToolDefinition - TabId string + TabState string + TabTools []ToolDefinition + TabId string + AppGoFile string + AppBuildStatus string } func (opts *WaveChatOpts) GetToolDefinition(toolName string) *ToolDefinition { diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index 62b0d76e6d..b373e15237 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -25,6 +25,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/util/ds" "github.com/wavetermdev/waveterm/pkg/util/logutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/waveappstore" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/web/sse" "github.com/wavetermdev/waveterm/pkg/wps" @@ -75,6 +76,7 @@ var SystemPromptText_OpenAI = strings.Join([]string{ `User-attached directories use the tag JSON DirInfo.`, `If multiple attached files exist, treat each as a separate source file with its own file_name.`, `When the user refers to these files, use their inline content directly; do NOT call any read_text_file or file-access tools to re-read them unless asked.`, + `The current "app.go" file will be provided with the tag \ncontent\n (use this as the basis for your app.go file edits)`, // Output & formatting `When presenting commands or any runnable multi-line code, always use fenced Markdown code blocks.`, @@ -427,6 +429,13 @@ func RunAIChat(ctx context.Context, sseHandler *sse.SSEHandlerCh, chatOpts uctyp chatOpts.TabId = tabId } } + if chatOpts.BuilderAppGenerator != nil { + appGoFile, appBuildStatus, appErr := chatOpts.BuilderAppGenerator() + if appErr == nil { + chatOpts.AppGoFile = appGoFile + chatOpts.AppBuildStatus = appBuildStatus + } + } stopReason, rtnMessage, err := runAIChatStep(ctx, sseHandler, chatOpts, cont) metrics.RequestCount++ if chatOpts.Config.IsPremiumModel() { @@ -687,9 +696,23 @@ func WaveAIPostMessageHandler(w http.ResponseWriter, r *http.Request) { chatOpts.SystemPrompt = []string{SystemPromptText} } - chatOpts.TabStateGenerator = func() (string, []uctypes.ToolDefinition, string, error) { - tabState, tabTools, err := GenerateTabStateAndTools(r.Context(), req.TabId, req.WidgetAccess) - return tabState, tabTools, req.TabId, err + if req.TabId != "" { + chatOpts.TabStateGenerator = func() (string, []uctypes.ToolDefinition, string, error) { + tabState, tabTools, err := GenerateTabStateAndTools(r.Context(), req.TabId, req.WidgetAccess) + return tabState, tabTools, req.TabId, err + } + } + + if req.BuilderAppId != "" { + chatOpts.BuilderAppGenerator = func() (string, string, error) { + appGoFileBytes, err := waveappstore.ReadAppFile(req.BuilderAppId, "app.go") + if err != nil { + return "", "", err + } + appGoFile := string(appGoFileBytes) + appBuildStatus := "" + return appGoFile, appBuildStatus, nil + } } if req.BuilderAppId != "" { From 21051ee7062a3d42c189e16ba74d6ebf1170f65e Mon Sep 17 00:00:00 2001 From: sawka Date: Sun, 26 Oct 2025 23:46:50 -0700 Subject: [PATCH 10/13] subscribe to update events... reload file after ai tool calls... return modtime of file as well... --- frontend/builder/tabs/builder-codetab.tsx | 15 +++++++++++++++ frontend/types/gotypes.d.ts | 1 + pkg/aiusechat/tools_builder.go | 11 +++++++++++ pkg/aiusechat/usechat.go | 4 ++-- pkg/waveappstore/waveappstore.go | 17 +++++++++++++++-- pkg/wps/wpstypes.go | 23 ++++++++++++----------- pkg/wshrpc/wshrpctypes.go | 1 + pkg/wshrpc/wshserver/wshserver.go | 5 +++-- 8 files changed, 60 insertions(+), 17 deletions(-) diff --git a/frontend/builder/tabs/builder-codetab.tsx b/frontend/builder/tabs/builder-codetab.tsx index f7dfdb15d9..31c80826a3 100644 --- a/frontend/builder/tabs/builder-codetab.tsx +++ b/frontend/builder/tabs/builder-codetab.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; +import { waveEventSubscribe } from "@/app/store/wps"; import { BuilderAppPanelModel } from "@/builder/store/builderAppPanelModel"; import { atoms } from "@/store/global"; import * as keyutil from "@/util/keyutil"; @@ -21,6 +22,20 @@ const BuilderCodeTab = memo(() => { } }, [builderAppId, model]); + useEffect(() => { + if (!builderAppId) { + return; + } + const unsubscribe = waveEventSubscribe({ + eventType: "waveapp:appgoupdated", + scope: builderAppId, + handler: () => { + model.loadAppFile(builderAppId); + }, + }); + return unsubscribe; + }, [builderAppId, model]); + const handleCodeChange = (newText: string) => { model.setCodeContent(newText); }; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index b1c800b299..1d215086f4 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -278,6 +278,7 @@ declare global { type CommandReadAppFileRtnData = { data64: string; notfound?: boolean; + modts?: number; }; // wshrpc.CommandRemoteListEntriesData diff --git a/pkg/aiusechat/tools_builder.go b/pkg/aiusechat/tools_builder.go index 64f838ac5f..3dfd082ffc 100644 --- a/pkg/aiusechat/tools_builder.go +++ b/pkg/aiusechat/tools_builder.go @@ -10,6 +10,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/util/fileutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/waveappstore" + "github.com/wavetermdev/waveterm/pkg/wps" ) const BuilderAppFileName = "app.go" @@ -68,6 +69,11 @@ func GetBuilderWriteAppFileToolDefinition(appId string) uctypes.ToolDefinition { return nil, err } + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_WaveAppAppGoUpdated, + Scopes: []string{appId}, + }) + return map[string]any{ "success": true, "message": fmt.Sprintf("Successfully wrote %s", BuilderAppFileName), @@ -157,6 +163,11 @@ func GetBuilderEditAppFileToolDefinition(appId string) uctypes.ToolDefinition { return nil, err } + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_WaveAppAppGoUpdated, + Scopes: []string{appId}, + }) + return map[string]any{ "success": true, "message": fmt.Sprintf("Successfully edited %s with %d changes", BuilderAppFileName, len(params.Edits)), diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index b373e15237..b714426542 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -705,11 +705,11 @@ func WaveAIPostMessageHandler(w http.ResponseWriter, r *http.Request) { if req.BuilderAppId != "" { chatOpts.BuilderAppGenerator = func() (string, string, error) { - appGoFileBytes, err := waveappstore.ReadAppFile(req.BuilderAppId, "app.go") + fileData, err := waveappstore.ReadAppFile(req.BuilderAppId, "app.go") if err != nil { return "", "", err } - appGoFile := string(appGoFileBytes) + appGoFile := string(fileData.Contents) appBuildStatus := "" return appGoFile, appBuildStatus, nil } diff --git a/pkg/waveappstore/waveappstore.go b/pkg/waveappstore/waveappstore.go index 0f0900650a..1da4c59278 100644 --- a/pkg/waveappstore/waveappstore.go +++ b/pkg/waveappstore/waveappstore.go @@ -27,6 +27,11 @@ var ( appNameRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) ) +type FileData struct { + Contents []byte + ModTs int64 +} + func MakeAppId(appNS string, appName string) string { return appNS + "/" + appName } @@ -273,7 +278,7 @@ func WriteAppFile(appId string, fileName string, contents []byte) error { return nil } -func ReadAppFile(appId string, fileName string) ([]byte, error) { +func ReadAppFile(appId string, fileName string) (*FileData, error) { if err := ValidateAppId(appId); err != nil { return nil, fmt.Errorf("invalid appId: %w", err) } @@ -288,12 +293,20 @@ func ReadAppFile(appId string, fileName string) ([]byte, error) { return nil, err } + fileInfo, err := os.Stat(filePath) + if err != nil { + return nil, fmt.Errorf("failed to stat file: %w", err) + } + contents, err := os.ReadFile(filePath) if err != nil { return nil, fmt.Errorf("failed to read file: %w", err) } - return contents, nil + return &FileData{ + Contents: contents, + ModTs: fileInfo.ModTime().UnixMilli(), + }, nil } func DeleteAppFile(appId string, fileName string) error { diff --git a/pkg/wps/wpstypes.go b/pkg/wps/wpstypes.go index b99e7898c6..245f71c0d4 100644 --- a/pkg/wps/wpstypes.go +++ b/pkg/wps/wpstypes.go @@ -6,17 +6,18 @@ package wps import "github.com/wavetermdev/waveterm/pkg/util/utilfn" const ( - Event_BlockClose = "blockclose" - Event_ConnChange = "connchange" - Event_SysInfo = "sysinfo" - Event_ControllerStatus = "controllerstatus" - Event_WaveObjUpdate = "waveobj:update" - Event_BlockFile = "blockfile" - Event_Config = "config" - Event_UserInput = "userinput" - Event_RouteGone = "route:gone" - Event_WorkspaceUpdate = "workspace:update" - Event_WaveAIRateLimit = "waveai:ratelimit" + Event_BlockClose = "blockclose" + Event_ConnChange = "connchange" + Event_SysInfo = "sysinfo" + Event_ControllerStatus = "controllerstatus" + Event_WaveObjUpdate = "waveobj:update" + Event_BlockFile = "blockfile" + Event_Config = "config" + Event_UserInput = "userinput" + Event_RouteGone = "route:gone" + Event_WorkspaceUpdate = "workspace:update" + Event_WaveAIRateLimit = "waveai:ratelimit" + Event_WaveAppAppGoUpdated = "waveapp:appgoupdated" ) type WaveEvent struct { diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 5b86a47c0b..a3e3f02ed4 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -927,6 +927,7 @@ type CommandReadAppFileData struct { type CommandReadAppFileRtnData struct { Data64 string `json:"data64"` NotFound bool `json:"notfound,omitempty"` + ModTs int64 `json:"modts,omitempty"` } type CommandWriteAppFileData struct { diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 1f0be76a8a..8364329efc 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -926,7 +926,7 @@ func (ws *WshServer) ListAllAppFilesCommand(ctx context.Context, data wshrpc.Com } func (ws *WshServer) ReadAppFileCommand(ctx context.Context, data wshrpc.CommandReadAppFileData) (*wshrpc.CommandReadAppFileRtnData, error) { - contents, err := waveappstore.ReadAppFile(data.AppId, data.FileName) + fileData, err := waveappstore.ReadAppFile(data.AppId, data.FileName) if err != nil { if errors.Is(err, os.ErrNotExist) { return &wshrpc.CommandReadAppFileRtnData{ @@ -936,7 +936,8 @@ func (ws *WshServer) ReadAppFileCommand(ctx context.Context, data wshrpc.Command return nil, fmt.Errorf("failed to read app file: %w", err) } return &wshrpc.CommandReadAppFileRtnData{ - Data64: base64.StdEncoding.EncodeToString(contents), + Data64: base64.StdEncoding.EncodeToString(fileData.Contents), + ModTs: fileData.ModTs, }, nil } From 5fdc0ac29a607d3125bd4e8c68996043d8d75327 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 27 Oct 2025 00:27:11 -0700 Subject: [PATCH 11/13] up max output tokens for builder --- pkg/aiusechat/tools_builder.go | 9 --------- pkg/aiusechat/usechat.go | 33 ++++++++++++++++++++++----------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/pkg/aiusechat/tools_builder.go b/pkg/aiusechat/tools_builder.go index 3dfd082ffc..4e36cb2dd4 100644 --- a/pkg/aiusechat/tools_builder.go +++ b/pkg/aiusechat/tools_builder.go @@ -79,9 +79,6 @@ func GetBuilderWriteAppFileToolDefinition(appId string) uctypes.ToolDefinition { "message": fmt.Sprintf("Successfully wrote %s", BuilderAppFileName), }, nil }, - ToolApproval: func(input any) string { - return uctypes.ApprovalNeedsApproval - }, } } @@ -173,9 +170,6 @@ func GetBuilderEditAppFileToolDefinition(appId string) uctypes.ToolDefinition { "message": fmt.Sprintf("Successfully edited %s with %d changes", BuilderAppFileName, len(params.Edits)), }, nil }, - ToolApproval: func(input any) string { - return uctypes.ApprovalNeedsApproval - }, } } @@ -202,8 +196,5 @@ func GetBuilderListFilesToolDefinition(appId string) uctypes.ToolDefinition { return result, nil }, - ToolApproval: func(input any) string { - return uctypes.ApprovalAutoApproved - }, } } diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index b714426542..f470984d35 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -40,6 +40,7 @@ const ( const DefaultAPI = APIType_OpenAI const DefaultAIEndpoint = "https://cfapi.waveterm.dev/api/waveai" const DefaultMaxTokens = 4 * 1024 +const BuilderMaxTokens = 24 * 1024 //go:embed tsunami/system.md var tsunamiSystemDoc string @@ -122,14 +123,19 @@ var BuilderSystemPromptText_OpenAI = strings.Join([]string{ `- When users refer to attached files, use their inline content directly; do NOT attempt to read them again`, ``, `**Code Output:**`, - "- Always use fenced Markdown code blocks with language hints.", - `- Try to keep lines under ~100 characters where practical (soft wrap; correctness takes priority)`, - `- Use inline code (single backticks) only for short references like package names, method names, or file paths`, - `- Never comment on or justify formatting choices; just follow these rules`, + `- Do NOT output code in fenced code blocks or inline code snippets`, + `- The file editing tools handle all code generation—users see the code in the editor pane`, + `- After using a tool to write code, provide a brief summary of what you implemented`, + `- Only use code formatting when explaining specific concepts or showing small examples that are NOT the main app code`, ``, - `**Safety:**`, - `- If a widget would perform destructive actions (file deletion, system commands, network requests to sensitive endpoints), warn the user and suggest safer alternatives or guard rails`, - `- Remind users to review generated code before running it in their terminal`, + `**Safety & Guardrails:**`, + `- Build widgets that perform powerful actions, but include appropriate safeguards:`, + ` - Require explicit user interaction (button clicks, form submissions) before destructive operations`, + ` - Add confirmation dialogs for destructive actions (file deletion, system commands, data modifications)`, + ` - Use visual indicators: red/warning colors, warning icons, clear action labels ("Delete", "Remove", "Overwrite")`, + ` - Never trigger dangerous operations automatically on widget load or in render cycles`, + ` - For bulk operations, preview what will be affected and require confirmation`, + `- Example: A file manager widget should have red "Delete" buttons that show a dialog: "Delete 3 files? [Cancel] [Delete]"`, ``, `**Your Workflow:**`, `1. Understand what the user wants to build`, @@ -144,16 +150,20 @@ var BuilderSystemPromptText_OpenAI = strings.Join([]string{ ``, }, "\n") -func getWaveAISettings(premium bool) (*uctypes.AIOptsType, error) { +func getWaveAISettings(premium bool, builderMode bool) (*uctypes.AIOptsType, error) { baseUrl := DefaultAIEndpoint if os.Getenv("WAVETERM_WAVEAI_ENDPOINT") != "" { baseUrl = os.Getenv("WAVETERM_WAVEAI_ENDPOINT") } + maxTokens := DefaultMaxTokens + if builderMode { + maxTokens = BuilderMaxTokens + } if DefaultAPI == APIType_Anthropic { return &uctypes.AIOptsType{ APIType: APIType_Anthropic, Model: uctypes.DefaultAnthropicModel, - MaxTokens: DefaultMaxTokens, + MaxTokens: maxTokens, ThinkingLevel: uctypes.ThinkingLevelMedium, BaseURL: baseUrl, }, nil @@ -167,7 +177,7 @@ func getWaveAISettings(premium bool) (*uctypes.AIOptsType, error) { return &uctypes.AIOptsType{ APIType: APIType_OpenAI, Model: model, - MaxTokens: DefaultMaxTokens, + MaxTokens: maxTokens, ThinkingLevel: thinkingLevel, BaseURL: baseUrl, }, nil @@ -662,7 +672,8 @@ func WaveAIPostMessageHandler(w http.ResponseWriter, r *http.Request) { // Get WaveAI settings premium := shouldUsePremium() - aiOpts, err := getWaveAISettings(premium) + builderMode := req.BuilderId != "" + aiOpts, err := getWaveAISettings(premium, builderMode) if err != nil { http.Error(w, fmt.Sprintf("WaveAI configuration error: %v", err), http.StatusInternalServerError) return From 22f20046007c13462dcf274c59e6bf93a3fc6abb Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 27 Oct 2025 15:48:03 -0700 Subject: [PATCH 12/13] better multireaderlinebuffer to handle stdout+stderr... --- pkg/blockcontroller/tsunamicontroller.go | 77 ++++++++------------ pkg/utilds/multireaderlinebuffer.go | 90 ++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 47 deletions(-) create mode 100644 pkg/utilds/multireaderlinebuffer.go diff --git a/pkg/blockcontroller/tsunamicontroller.go b/pkg/blockcontroller/tsunamicontroller.go index 8e21cd33b1..a1f90569c9 100644 --- a/pkg/blockcontroller/tsunamicontroller.go +++ b/pkg/blockcontroller/tsunamicontroller.go @@ -28,13 +28,12 @@ import ( ) type TsunamiAppProc struct { - Cmd *exec.Cmd - StdoutBuffer *utilds.ReaderLineBuffer - StderrBuffer *utilds.ReaderLineBuffer // May be nil if stderr was consumed for port detection - StdinWriter io.WriteCloser - Port int // Port the tsunami app is listening on - WaitCh chan struct{} // Channel that gets closed when cmd.Wait() returns - WaitRtn error // Error returned by cmd.Wait() + Cmd *exec.Cmd + LineBuffer *utilds.MultiReaderLineBuffer + StdinWriter io.WriteCloser + Port int // Port the tsunami app is listening on + WaitCh chan struct{} // Channel that gets closed when cmd.Wait() returns + WaitRtn error // Error returned by cmd.Wait() } type TsunamiController struct { @@ -90,30 +89,30 @@ func (c *TsunamiController) fetchAndSetSchemas(port int) { client := &http.Client{ Timeout: 10 * time.Second, } - + resp, err := client.Get(url) if err != nil { log.Printf("TsunamiController: failed to fetch schemas from %s: %v", url, err) return } defer resp.Body.Close() - + if resp.StatusCode != http.StatusOK { log.Printf("TsunamiController: received non-200 status %d from %s", resp.StatusCode, url) return } - + var schemas any if err := json.NewDecoder(resp.Body).Decode(&schemas); err != nil { log.Printf("TsunamiController: failed to decode schemas response: %v", err) return } - + blockRef := waveobj.MakeORef(waveobj.OType_Block, c.blockId) wstore.SetRTInfo(blockRef, map[string]any{ "tsunami:schemas": schemas, }) - + log.Printf("TsunamiController: successfully fetched and cached schemas for block %s", c.blockId) } @@ -380,14 +379,19 @@ func runTsunamiAppBinary(ctx context.Context, appBinPath string, appPath string, appName := build.GetAppName(appPath) - stdoutBuffer := utilds.MakeReaderLineBuffer(stdoutPipe, 1000) - stdoutBuffer.SetLineCallback(func(line string) { - log.Printf("[tsunami:%s] %s\n", appName, line) - }) + lineBuffer := utilds.MakeMultiReaderLineBuffer(1000) + portChan := make(chan int, 1) + portFound := false - stderrBuffer := utilds.MakeReaderLineBuffer(stderrPipe, 1000) - stderrBuffer.SetLineCallback(func(line string) { + lineBuffer.SetLineCallback(func(line string) { log.Printf("[tsunami:%s] %s\n", appName, line) + + if !portFound { + if port := build.ParseTsunamiPort(line); port > 0 { + portFound = true + portChan <- port + } + } }) err = cmd.Start() @@ -398,11 +402,10 @@ func runTsunamiAppBinary(ctx context.Context, appBinPath string, appPath string, // Create wait channel and tsunami proc first waitCh := make(chan struct{}) tsunamiProc := &TsunamiAppProc{ - Cmd: cmd, - StdoutBuffer: stdoutBuffer, - StderrBuffer: stderrBuffer, - StdinWriter: stdinPipe, - WaitCh: waitCh, + Cmd: cmd, + LineBuffer: lineBuffer, + StdinWriter: stdinPipe, + WaitCh: waitCh, } // Start goroutine to handle cmd.Wait() @@ -427,29 +430,12 @@ func runTsunamiAppBinary(ctx context.Context, appBinPath string, appPath string, close(waitCh) }() - go stdoutBuffer.ReadAll() - - // Monitor stderr for port information - portChan := make(chan int, 1) - errChan := make(chan error, 1) - - go func() { - for { - line, err := stderrBuffer.ReadLine() - if err != nil { - errChan <- fmt.Errorf("stderr buffer error: %w", err) - return - } - - port := build.ParseTsunamiPort(line) - if port > 0 { - portChan <- port - return - } - } - }() + // Start reading both stdout and stderr + go lineBuffer.ReadAll(stdoutPipe) + go lineBuffer.ReadAll(stderrPipe) // Wait for either port detection, process death, or context timeout + errChan := make(chan error, 1) go func() { <-tsunamiProc.WaitCh select { @@ -462,9 +448,6 @@ func runTsunamiAppBinary(ctx context.Context, appBinPath string, appPath string, select { case port := <-portChan: - // Start the stderr ReadAll goroutine now that we have the port - go stderrBuffer.ReadAll() - tsunamiProc.Port = port return tsunamiProc, nil case err := <-errChan: diff --git a/pkg/utilds/multireaderlinebuffer.go b/pkg/utilds/multireaderlinebuffer.go new file mode 100644 index 0000000000..657d3649a2 --- /dev/null +++ b/pkg/utilds/multireaderlinebuffer.go @@ -0,0 +1,90 @@ +package utilds + +import ( + "bufio" + "io" + "sync" +) + +type MultiReaderLineBuffer struct { + lock sync.Mutex + lines []string + maxLines int + totalLineCount int + lineCallback func(string) +} + +func MakeMultiReaderLineBuffer(maxLines int) *MultiReaderLineBuffer { + if maxLines <= 0 { + maxLines = 1000 + } + + return &MultiReaderLineBuffer{ + lines: make([]string, 0, maxLines), + maxLines: maxLines, + totalLineCount: 0, + } +} + +// callback is synchronous. will block the consuming of lines and +// guaranteed to run in order. it is also guaranteed only one callback +// will be running at a time (protected by the internal line lock) +func (mrlb *MultiReaderLineBuffer) SetLineCallback(callback func(string)) { + mrlb.lock.Lock() + defer mrlb.lock.Unlock() + mrlb.lineCallback = callback +} + +func (mrlb *MultiReaderLineBuffer) ReadAll(r io.Reader) { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := scanner.Text() + mrlb.addLine(line) + mrlb.callLineCallback(line) + } +} + +func (mrlb *MultiReaderLineBuffer) callLineCallback(line string) { + mrlb.lock.Lock() + defer mrlb.lock.Unlock() + + if mrlb.lineCallback != nil { + mrlb.lineCallback(line) + } +} + +func (mrlb *MultiReaderLineBuffer) addLine(line string) { + mrlb.lock.Lock() + defer mrlb.lock.Unlock() + + mrlb.totalLineCount++ + + if len(mrlb.lines) >= mrlb.maxLines { + mrlb.lines = append(mrlb.lines[1:], line) + } else { + mrlb.lines = append(mrlb.lines, line) + } +} + +func (mrlb *MultiReaderLineBuffer) GetLines() []string { + mrlb.lock.Lock() + defer mrlb.lock.Unlock() + + result := make([]string, len(mrlb.lines)) + copy(result, mrlb.lines) + return result +} + +func (mrlb *MultiReaderLineBuffer) GetLineCount() int { + mrlb.lock.Lock() + defer mrlb.lock.Unlock() + + return len(mrlb.lines) +} + +func (mrlb *MultiReaderLineBuffer) GetTotalLineCount() int { + mrlb.lock.Lock() + defer mrlb.lock.Unlock() + + return mrlb.totalLineCount +} From 2a1ce1b922cdc108facfabfc822034cab274228f Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 27 Oct 2025 16:30:14 -0700 Subject: [PATCH 13/13] fix atob and btoa to use base64ToString and stringToBase64 (or base64ToArray , base64ToArrayBuffer) --- frontend/app/store/global.ts | 4 +-- frontend/app/store/tabrpcclient.ts | 4 +-- frontend/app/view/term/termwrap.ts | 16 +++++----- .../builder/store/builderAppPanelModel.ts | 7 +++-- frontend/util/util.ts | 31 ++++++------------- 5 files changed, 25 insertions(+), 37 deletions(-) diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 8ccffcd4ba..3db4cc14af 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -17,7 +17,7 @@ import { import { getWebServerEndpoint } from "@/util/endpoints"; import { fetch } from "@/util/fetchutil"; import { setPlatform } from "@/util/platformutil"; -import { deepCompareReturnPrev, fireAndForget, getPrefixedSettings, isBlank } from "@/util/util"; +import { base64ToString, deepCompareReturnPrev, fireAndForget, getPrefixedSettings, isBlank } from "@/util/util"; import { atom, Atom, PrimitiveAtom, useAtomValue } from "jotai"; import { globalStore } from "./jotaiStore"; import { modalsModel } from "./modalmodel"; @@ -549,7 +549,7 @@ async function fetchWaveFile( if (fileInfo64 == null) { throw new Error(`missing zone file info for ${zoneId}:${fileName}`); } - const fileInfo = JSON.parse(atob(fileInfo64)); + const fileInfo = JSON.parse(base64ToString(fileInfo64)); const data = await resp.arrayBuffer(); return { data: new Uint8Array(data), fileInfo }; } diff --git a/frontend/app/store/tabrpcclient.ts b/frontend/app/store/tabrpcclient.ts index f0c6da55ce..caab82ac82 100644 --- a/frontend/app/store/tabrpcclient.ts +++ b/frontend/app/store/tabrpcclient.ts @@ -5,7 +5,7 @@ import { WaveAIModel } from "@/app/aipanel/waveai-model"; import { getApi } from "@/app/store/global"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { getLayoutModelForStaticTab } from "@/layout/index"; -import { base64ToArray } from "@/util/util"; +import { base64ToArrayBuffer } from "@/util/util"; import { RpcResponseHelper, WshClient } from "./wshclient"; export class TabClient extends WshClient { @@ -74,7 +74,7 @@ export class TabClient extends WshClient { if (data.files && data.files.length > 0) { for (const fileData of data.files) { - const decodedData = base64ToArray(fileData.data64); + const decodedData = base64ToArrayBuffer(fileData.data64); const blob = new Blob([decodedData], { type: fileData.type }); const file = new File([blob], fileData.name, { type: fileData.type }); await model.addFile(file); diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 932c1b219f..7a58ac7e78 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -8,7 +8,7 @@ import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WOS, atoms, fetchWaveFile, getSettingsKeyAtom, globalStore, openLink } from "@/store/global"; import * as services from "@/store/services"; import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; -import { base64ToArray, fireAndForget } from "@/util/util"; +import { base64ToArray, base64ToString, fireAndForget } from "@/util/util"; import { SearchAddon } from "@xterm/addon-search"; import { SerializeAddon } from "@xterm/addon-serialize"; import { WebLinksAddon } from "@xterm/addon-web-links"; @@ -259,7 +259,7 @@ function handleOsc16162Command(data: string, blockId: string, loaded: boolean, t globalStore.set(termWrap.lastCommandAtom, rtInfo["shell:lastcmd"]); } else { try { - const decodedCmd = atob(cmd.data.cmd64); + const decodedCmd = base64ToString(cmd.data.cmd64); rtInfo["shell:lastcmd"] = decodedCmd; globalStore.set(termWrap.lastCommandAtom, decodedCmd); } catch (e) { @@ -364,9 +364,7 @@ export class TermWrap { this.hasResized = false; this.lastUpdated = Date.now(); this.promptMarkers = []; - this.shellIntegrationStatusAtom = jotai.atom(null) as jotai.PrimitiveAtom< - "ready" | "running-command" | null - >; + this.shellIntegrationStatusAtom = jotai.atom(null) as jotai.PrimitiveAtom<"ready" | "running-command" | null>; this.lastCommandAtom = jotai.atom(null) as jotai.PrimitiveAtom; this.terminal = new Terminal(options); this.fitAddon = new FitAddon(); @@ -460,25 +458,25 @@ export class TermWrap { } this.mainFileSubject = getFileSubject(this.blockId, TermFileName); this.mainFileSubject.subscribe(this.handleNewFileSubjectData.bind(this)); - + try { const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), }); - + if (rtInfo["shell:integration"]) { const shellState = rtInfo["shell:state"] as ShellIntegrationStatus; globalStore.set(this.shellIntegrationStatusAtom, shellState || null); } else { globalStore.set(this.shellIntegrationStatusAtom, null); } - + const lastCmd = rtInfo["shell:lastcmd"]; globalStore.set(this.lastCommandAtom, lastCmd || null); } catch (e) { console.log("Error loading runtime info:", e); } - + try { await this.loadInitialTerminalData(); } finally { diff --git a/frontend/builder/store/builderAppPanelModel.ts b/frontend/builder/store/builderAppPanelModel.ts index 51d98b9b19..c982cfa098 100644 --- a/frontend/builder/store/builderAppPanelModel.ts +++ b/frontend/builder/store/builderAppPanelModel.ts @@ -4,6 +4,7 @@ import { globalStore } from "@/app/store/jotaiStore"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { base64ToString, stringToBase64 } from "@/util/util"; import { atom, type Atom, type PrimitiveAtom } from "jotai"; export type TabType = "preview" | "files" | "code"; @@ -63,7 +64,7 @@ export class BuilderAppPanelModel { globalStore.set(this.codeContentAtom, ""); globalStore.set(this.originalContentAtom, ""); } else { - const decoded = atob(result.data64); + const decoded = base64ToString(result.data64); globalStore.set(this.codeContentAtom, decoded); globalStore.set(this.originalContentAtom, decoded); } @@ -80,7 +81,7 @@ export class BuilderAppPanelModel { try { const content = globalStore.get(this.codeContentAtom); - const encoded = btoa(content); + const encoded = stringToBase64(content); await RpcApi.WriteAppFileCommand(TabRpcClient, { appid: appId, filename: "app.go", @@ -110,4 +111,4 @@ export class BuilderAppPanelModel { setMonacoEditorRef(ref: any) { this.monacoEditorRef.current = ref; } -} \ No newline at end of file +} diff --git a/frontend/util/util.ts b/frontend/util/util.ts index f16da53a7a..f940614712 100644 --- a/frontend/util/util.ts +++ b/frontend/util/util.ts @@ -28,28 +28,16 @@ function stringToBase64(input: string): string { return base64.fromByteArray(stringBytes); } -// browser only (uses atob) -function base64ToArray(b64: string): Uint8Array { - const rawStr = atob(b64); - const rtnArr = new Uint8Array(new ArrayBuffer(rawStr.length)); - for (let i = 0; i < rawStr.length; i++) { - rtnArr[i] = rawStr.charCodeAt(i); - } - return rtnArr; +function base64ToArray(b64: string): Uint8Array { + const cleanB64 = b64.replace(/\s+/g, ""); + return base64.toByteArray(cleanB64); } -function decodeBase64ToBytes(b64: string): Uint8Array { - // Remove whitespace that some generators insert - const clean = b64.replace(/\s+/g, ""); - if (typeof Buffer !== "undefined") { - // Node/Electron main - return new Uint8Array(Buffer.from(clean, "base64")); - } - // Browser - const raw = atob(clean); - const out = new Uint8Array(raw.length); - for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i) & 0xff; - return out; +function base64ToArrayBuffer(b64: string): ArrayBuffer { + const cleanB64 = b64.replace(/\s+/g, ""); + const u8 = base64.toByteArray(cleanB64); // Uint8Array + // Force a plain ArrayBuffer slice (no SharedArrayBuffer, no offset issues) + return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) as ArrayBuffer; } function boundNumber(num: number, min: number, max: number): number { @@ -454,7 +442,7 @@ function parseDataUrl(dataUrl: string): ParsedDataUrl { let buffer: Uint8Array; if (isBase64) { - buffer = decodeBase64ToBytes(data); + buffer = base64ToArray(data); } else { // assume text const decoded = decodeURIComponent(data); @@ -468,6 +456,7 @@ export { atomWithDebounce, atomWithThrottle, base64ToArray, + base64ToArrayBuffer, base64ToString, boundNumber, cn,