Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .roo/rules/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ It has a TypeScript/React frontend and a Go backend. They talk together over `ws
- Use all lowercase filenames (except where case is actually important like Taskfile.yml)
- Import the "cn" function from "@/util/util" to do classname / clsx class merge (it uses twMerge underneath)
- For element variants use class-variance-authority
- Do NOT create private fields in classes (they are impossible to inspect)
- **Component Practices**:
- Make sure to add cursor-pointer to buttons/links and clickable items
- NEVER use cursor-help (it looks terrible)
Expand Down
5 changes: 5 additions & 0 deletions cmd/server/main-server.go
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,11 @@ func main() {
log.Printf("error ensuring wave presets dir: %v\n", err)
return
}
err = wavebase.EnsureWaveCachesDir()
if err != nil {
log.Printf("error ensuring wave caches dir: %v\n", err)
return
}
waveLock, err := wavebase.AcquireWaveLock()
if err != nil {
log.Printf("error acquiring wave lock (another instance of Wave is likely running): %v\n", err)
Expand Down
1 change: 1 addition & 0 deletions emain/emain-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export async function createBuilderWindow(appId: string): Promise<BuilderWindowT
if (focusedBuilderWindow === typedBuilderWindow) {
focusedBuilderWindow = null;
}
RpcApi.DeleteBuilderCommand(ElectronWshClient, builderId, { noresponse: true });
setTimeout(() => globalEvents.emit("windows-updated"), 50);
});

Expand Down
2 changes: 1 addition & 1 deletion frontend/app/aipanel/waveai-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import * as WOS from "@/app/store/wos";
import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
import { BuilderFocusManager } from "@/builder/store/builderFocusManager";
import { BuilderFocusManager } from "@/builder/store/builder-focusmanager";
import { getWebServerEndpoint } from "@/util/endpoints";
import { ChatStatus } from "ai";
import * as jotai from "jotai";
Expand Down
20 changes: 20 additions & 0 deletions frontend/app/store/wshclientapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ class RpcApiType {
return client.wshRpcCall("deleteblock", data, opts);
}

// command "deletebuilder" [call]
DeleteBuilderCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> {
return client.wshRpcCall("deletebuilder", data, opts);
}

// command "deletesubblock" [call]
DeleteSubBlockCommand(client: WshClient, data: CommandDeleteBlockData, opts?: RpcOpts): Promise<void> {
return client.wshRpcCall("deletesubblock", data, opts);
Expand Down Expand Up @@ -262,6 +267,16 @@ class RpcApiType {
return client.wshRpcCall("focuswindow", data, opts);
}

// command "getbuilderoutput" [call]
GetBuilderOutputCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<string[]> {
return client.wshRpcCall("getbuilderoutput", data, opts);
}

// command "getbuilderstatus" [call]
GetBuilderStatusCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<BuilderStatusData> {
return client.wshRpcCall("getbuilderstatus", data, opts);
}

// command "getfullconfig" [call]
GetFullConfigCommand(client: WshClient, opts?: RpcOpts): Promise<FullConfigType> {
return client.wshRpcCall("getfullconfig", null, opts);
Expand Down Expand Up @@ -462,6 +477,11 @@ class RpcApiType {
return client.wshRpcCall("setview", data, opts);
}

// command "startbuilder" [call]
StartBuilderCommand(client: WshClient, data: CommandStartBuilderData, opts?: RpcOpts): Promise<void> {
return client.wshRpcCall("startbuilder", data, opts);
}

// command "streamcpudata" [responsestream]
StreamCpuDataCommand(client: WshClient, data: CpuDataRequest, opts?: RpcOpts): AsyncGenerator<TimeSeriesData, void, boolean> {
return client.wshRpcStream("streamcpudata", data, opts);
Expand Down
180 changes: 145 additions & 35 deletions frontend/builder/builder-apppanel.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,115 @@
// 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 { BuilderAppPanelModel, type TabType } from "@/builder/store/builder-apppanel-model";
import { BuilderFocusManager } from "@/builder/store/builder-focusmanager";
import { BuilderCodeTab } from "@/builder/tabs/builder-codetab";
import { BuilderEnvTab } from "@/builder/tabs/builder-envtab";
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";
import { useAtomValue } from "jotai";
import { memo, useCallback, useRef } from "react";
import { memo, useCallback, useEffect, useRef } from "react";

const StatusDot = memo(() => {
const model = BuilderAppPanelModel.getInstance();
const builderStatus = useAtomValue(model.builderStatusAtom);

const getStatusDotColor = (status: string | null | undefined): string => {
if (!status) return "bg-gray-500";
switch (status) {
case "init":
case "stopped":
return "bg-gray-500";
case "building":
return "bg-warning";
case "running":
return "bg-success";
case "error":
return "bg-error";
default:
return "bg-gray-500";
}
};

const statusDotColor = getStatusDotColor(builderStatus?.status);

return <span className={cn("w-2 h-2 rounded-full", statusDotColor)} />;
});

StatusDot.displayName = "StatusDot";

type TabButtonProps = {
label: string;
tabType: TabType;
isActive: boolean;
isAppFocused: boolean;
onClick: () => void;
showStatusDot?: boolean;
};

const TabButton = memo(({ label, tabType, isActive, isAppFocused, onClick }: TabButtonProps) => {
const TabButton = memo(({ label, tabType, isActive, isAppFocused, onClick, showStatusDot }: TabButtonProps) => {
return (
<button
className={cn(
"px-4 py-2 text-sm font-medium transition-colors cursor-pointer",
isActive
? `text-main-text border-b-2 ${isAppFocused ? "border-accent" : "border-gray-500"}`
: "text-gray-500 hover:text-secondary border-b-2 border-transparent"
? `text-primary border-b-2 ${isAppFocused ? "border-accent" : "border-gray-500"}`
: "text-secondary hover:text-primary border-b-2 border-transparent"
)}
onClick={onClick}
>
{label}
<span className="flex items-center gap-2">
{showStatusDot && <StatusDot />}
{label}
</span>
</button>
);
});

TabButton.displayName = "TabButton";

const ErrorStrip = memo(() => {
const model = BuilderAppPanelModel.getInstance();
const errorMsg = useAtomValue(model.errorAtom);

if (!errorMsg) return null;
return (
<div className="shrink-0 bg-error/10 border-b border-error/30 px-4 py-2 flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1 min-w-0">
<i className="fa fa-triangle-exclamation text-error text-sm" />
<span className="text-error text-sm flex-1 truncate">{errorMsg}</span>
</div>
<button
onClick={() => model.clearError()}
className="shrink-0 text-error hover:text-error/80 transition-colors cursor-pointer"
aria-label="Close error"
>
<i className="fa fa-xmark-large text-sm" />
</button>
</div>
);
});

ErrorStrip.displayName = "ErrorStrip";

const BuilderAppPanel = memo(() => {
const model = BuilderAppPanelModel.getInstance();
const focusElemRef = useRef<HTMLInputElement>(null);
const activeTab = useAtomValue(model.activeTab);
const focusType = useAtomValue(BuilderFocusManager.getInstance().focusType);
const isAppFocused = focusType === "app";
const saveNeeded = useAtomValue(model.saveNeededAtom);
const envSaveNeeded = useAtomValue(model.envVarsDirtyAtom);
const builderAppId = useAtomValue(atoms.builderAppId);
const builderId = useAtomValue(atoms.builderId);

useEffect(() => {
model.initialize();
}, []);

if (focusElemRef.current) {
model.setFocusElemRef(focusElemRef.current);
Expand All @@ -58,44 +121,54 @@ const BuilderAppPanel = memo(() => {
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]');
const handleFocusCapture = useCallback((event: React.FocusEvent) => {
BuilderFocusManager.getInstance().setAppFocused();
}, []);

if (isInteractive) {
return;
}
const handlePanelClick = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement;
const isInteractive = target.closest('button, a, input, textarea, select, [role="button"], [tabindex]');

const hasSelection = builderAppHasSelection();
if (hasSelection) {
BuilderFocusManager.getInstance().setAppFocused();
return;
}
if (isInteractive) {
return;
}

setTimeout(() => {
if (!builderAppHasSelection()) {
const hasSelection = builderAppHasSelection();
if (hasSelection) {
BuilderFocusManager.getInstance().setAppFocused();
model.giveFocus();
return;
}
}, 0);
}, [model]);

setTimeout(() => {
if (!builderAppHasSelection()) {
BuilderFocusManager.getInstance().setAppFocused();
model.giveFocus();
}
}, 0);
},
[model]
);

const handleSave = useCallback(() => {
if (builderAppId) {
model.saveAppFile(builderAppId);
}
}, [builderAppId, model]);

const handleEnvSave = useCallback(() => {
if (builderId) {
model.saveEnvVars(builderId);
}
}, [builderId, model]);

const handleRestart = useCallback(() => {
model.restartBuilder();
}, [model]);

return (
<div
className="w-full h-full flex flex-col border-b border-border"
className="w-full h-full flex flex-col border-b-3 border-border shadow-[0_2px_4px_rgba(0,0,0,0.1)]"
data-builder-app-panel="true"
onClick={handlePanelClick}
onFocusCapture={handleFocusCapture}
Expand All @@ -118,6 +191,7 @@ const BuilderAppPanel = memo(() => {
isActive={activeTab === "preview"}
isAppFocused={isAppFocused}
onClick={() => handleTabClick("preview")}
showStatusDot={true}
/>
<TabButton
label="Code"
Expand All @@ -126,14 +200,31 @@ const BuilderAppPanel = memo(() => {
isAppFocused={isAppFocused}
onClick={() => handleTabClick("code")}
/>
{false && (
<TabButton
label="Static Files"
tabType="files"
isActive={activeTab === "files"}
isAppFocused={isAppFocused}
onClick={() => handleTabClick("files")}
/>
)}
<TabButton
label="Static Files"
tabType="files"
isActive={activeTab === "files"}
label="Env"
tabType="env"
isActive={activeTab === "env"}
isAppFocused={isAppFocused}
onClick={() => handleTabClick("files")}
onClick={() => handleTabClick("env")}
/>
</div>
{activeTab === "preview" && (
<button
className="mr-4 px-3 py-1 text-sm font-medium rounded transition-colors bg-accent/80 text-white hover:bg-accent cursor-pointer"
onClick={handleRestart}
>
Restart App
</button>
)}
{activeTab === "code" && (
<button
className={cn(
Expand All @@ -147,8 +238,22 @@ const BuilderAppPanel = memo(() => {
Save
</button>
)}
{activeTab === "env" && (
<button
className={cn(
"mr-4 px-3 py-1 text-sm font-medium rounded transition-colors",
envSaveNeeded
? "bg-accent text-white hover:opacity-80 cursor-pointer"
: "bg-gray-600 text-gray-400 cursor-default"
)}
onClick={envSaveNeeded ? handleEnvSave : undefined}
>
Save
</button>
)}
</div>
</div>
<ErrorStrip />
<div className="flex-1 overflow-auto py-1">
<div className="w-full h-full" style={{ display: activeTab === "preview" ? "block" : "none" }}>
<ErrorBoundary>
Expand All @@ -165,6 +270,11 @@ const BuilderAppPanel = memo(() => {
<BuilderFilesTab />
</ErrorBoundary>
</div>
<div className="w-full h-full" style={{ display: activeTab === "env" ? "block" : "none" }}>
<ErrorBoundary>
<BuilderEnvTab />
</ErrorBoundary>
</div>
</div>
</div>
);
Expand Down
Loading
Loading