Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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 @@ -50,6 +50,7 @@ It has a TypeScript/React frontend and a Go backend. They talk together over `ws
- We use **Tailwind v4** to style. Custom stuff is defined in frontend/tailwindsetup.css
- _never_ use cursor-help, or cursor-not-allowed (it looks terrible)
- We have custom CSS setup as well, so it is a hybrid system. For new code we prefer tailwind, and are working to migrate code to all use tailwind.
- For accent buttons, use "bg-accent/80 text-primary rounded hover:bg-accent transition-colors cursor-pointer" (if you do "bg-accent hover:bg-accent/80" it looks weird as on hover the button gets darker instead of lighter)

### RPC System

Expand Down
3 changes: 0 additions & 3 deletions emain/emain-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,6 @@ export async function createBuilderWindow(appId: string): Promise<BuilderWindowT
typedBuilderWindow.builderId = builderId;
typedBuilderWindow.savedInitOpts = initOpts;

console.log("sending builder-init", initOpts);
typedBuilderWindow.webContents.send("builder-init", initOpts);

typedBuilderWindow.on("focus", () => {
focusedBuilderWindow = typedBuilderWindow;
console.log("builder window focused", builderId);
Expand Down
1 change: 1 addition & 0 deletions emain/emain-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ function makeFileMenu(numWaveWindows: number, callbacks: AppMenuCallbacks): Elec
if (isDev) {
fileMenu.splice(1, 0, {
label: "New WaveApp Builder Window",
accelerator: unamePlatform === "darwin" ? "Command+Shift+B" : "Alt+Shift+B",
click: () => fireAndForget(() => createBuilderWindow("")),
});
}
Expand Down
16 changes: 7 additions & 9 deletions frontend/app/aipanel/aimessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,7 @@ const AIThinking = memo(
)}
{message && <span className="text-sm text-gray-400">{message}</span>}
</div>
<div
ref={scrollRef}
className="text-sm text-gray-500 overflow-y-auto h-[3lh] max-w-[600px] pl-9"
>
<div ref={scrollRef} className="text-sm text-gray-500 overflow-y-auto h-[3lh] max-w-[600px] pl-9">
{displayText}
</div>
</div>
Expand Down Expand Up @@ -147,21 +144,22 @@ const isDisplayPart = (part: WaveUIMessagePart): boolean => {
return (
part.type === "text" ||
part.type === "data-tooluse" ||
part.type === "data-toolprogress" ||
(part.type.startsWith("tool-") && "state" in part && part.state === "input-available")
);
};

type MessagePart =
| { type: "single"; part: WaveUIMessagePart }
| { type: "toolgroup"; parts: Array<WaveUIMessagePart & { type: "data-tooluse" }> };
| { type: "toolgroup"; parts: Array<WaveUIMessagePart & { type: "data-tooluse" | "data-toolprogress" }> };

const groupMessageParts = (parts: WaveUIMessagePart[]): MessagePart[] => {
const grouped: MessagePart[] = [];
let currentToolGroup: Array<WaveUIMessagePart & { type: "data-tooluse" }> = [];
let currentToolGroup: Array<WaveUIMessagePart & { type: "data-tooluse" | "data-toolprogress" }> = [];

for (const part of parts) {
if (part.type === "data-tooluse") {
currentToolGroup.push(part as WaveUIMessagePart & { type: "data-tooluse" });
if (part.type === "data-tooluse" || part.type === "data-toolprogress") {
currentToolGroup.push(part as WaveUIMessagePart & { type: "data-tooluse" | "data-toolprogress" });
} else {
if (currentToolGroup.length > 0) {
grouped.push({ type: "toolgroup", parts: currentToolGroup });
Expand Down Expand Up @@ -225,7 +223,7 @@ export const AIMessage = memo(({ message, isStreaming }: AIMessageProps) => {
className={cn(
"px-2 rounded-lg [&>*:first-child]:!mt-0",
message.role === "user"
? "py-2 bg-accent-800 text-white max-w-[calc(100%-20px)]"
? "py-2 bg-accent-800 text-white max-w-[calc(90%-10px)]"
: "min-w-[min(100%,500px)]"
)}
>
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/aipanel/aipanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,7 @@ const AIPanelComponentInner = memo(() => {
className="flex-1 overflow-y-auto p-2 relative"
onContextMenu={(e) => handleWaveAIContextMenu(e, true)}
>
<div className="absolute top-2 right-2 z-10">
<div className="absolute top-2 left-2 z-10">
<ThinkingLevelDropdown />
</div>
{model.inBuilder ? <AIBuilderWelcomeMessage /> : <AIWelcomeMessage />}
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/aipanel/aipanelmessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export const AIPanelMessages = memo(({ messages, status, onContextMenu }: AIPane
className="flex-1 overflow-y-auto p-2 space-y-4 relative"
onContextMenu={onContextMenu}
>
<div className="absolute top-2 right-2 z-10">
<div className="absolute top-2 left-2 z-10">
<ThinkingLevelDropdown />
</div>
{messages.map((message, index) => {
Expand Down
142 changes: 126 additions & 16 deletions frontend/app/aipanel/aitooluse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,69 @@ import { WaveAIModel } from "./waveai-model";
// matches pkg/filebackup/filebackup.go
const BackupRetentionDays = 5;

interface ToolDescLineProps {
text: string;
}

const ToolDescLine = memo(({ text }: ToolDescLineProps) => {
let displayText = text;
if (displayText.startsWith("* ")) {
displayText = "• " + displayText.slice(2);
}

const parts: React.ReactNode[] = [];
let lastIndex = 0;
const regex = /(?<!\w)([+-])(\d+)(?!\w)/g;
let match;

while ((match = regex.exec(displayText)) !== null) {
if (match.index > lastIndex) {
parts.push(displayText.slice(lastIndex, match.index));
}

const sign = match[1];
const number = match[2];
const colorClass = sign === "+" ? "text-green-600" : "text-red-600";
parts.push(
<span key={match.index} className={colorClass}>
{sign}
{number}
</span>
);

lastIndex = match.index + match[0].length;
}

if (lastIndex < displayText.length) {
parts.push(displayText.slice(lastIndex));
}

return <div>{parts.length > 0 ? parts : displayText}</div>;
});

ToolDescLine.displayName = "ToolDescLine";

interface ToolDescProps {
text: string | string[];
className?: string;
}

const ToolDesc = memo(({ text, className }: ToolDescProps) => {
const lines = Array.isArray(text) ? text : text.split("\n");

if (lines.length === 0) return null;

return (
<div className={className}>
{lines.map((line, idx) => (
<ToolDescLine key={idx} text={line} />
))}
</div>
);
});

ToolDesc.displayName = "ToolDesc";

function getEffectiveApprovalStatus(baseApproval: string, isStreaming: boolean): string {
return !isStreaming && baseApproval === "needs-approval" ? "timeout" : baseApproval;
}
Expand Down Expand Up @@ -354,7 +417,7 @@ const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => {
</button>
)}
</div>
{toolData.tooldesc && <div className="text-sm text-gray-400 pl-6">{toolData.tooldesc}</div>}
{toolData.tooldesc && <ToolDesc text={toolData.tooldesc} className="text-sm text-gray-400 pl-6" />}
{(toolData.errormessage || effectiveApproval === "timeout") && (
<div className="text-sm text-red-300 pl-6">{toolData.errormessage || "Not approved"}</div>
)}
Expand All @@ -370,16 +433,49 @@ const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => {

AIToolUse.displayName = "AIToolUse";

interface AIToolProgressProps {
part: WaveUIMessagePart & { type: "data-toolprogress" };
}

const AIToolProgress = memo(({ part }: AIToolProgressProps) => {
const progressData = part.data;

return (
<div className="flex flex-col gap-1 p-2 rounded bg-gray-800 border border-gray-700">
<div className="flex items-center gap-2">
<i className="fa fa-spinner fa-spin text-gray-400"></i>
<div className="font-semibold">{progressData.toolname}</div>
</div>
{progressData.statuslines && progressData.statuslines.length > 0 && (
<ToolDesc text={progressData.statuslines} className="text-sm text-gray-400 pl-6 space-y-0.5" />
)}
</div>
);
});

AIToolProgress.displayName = "AIToolProgress";

interface AIToolUseGroupProps {
parts: Array<WaveUIMessagePart & { type: "data-tooluse" }>;
parts: Array<WaveUIMessagePart & { type: "data-tooluse" | "data-toolprogress" }>;
isStreaming: boolean;
}

type ToolGroupItem =
| { type: "batch"; parts: Array<WaveUIMessagePart & { type: "data-tooluse" }> }
| { type: "single"; part: WaveUIMessagePart & { type: "data-tooluse" } };
| { type: "single"; part: WaveUIMessagePart & { type: "data-tooluse" } }
| { type: "progress"; part: WaveUIMessagePart & { type: "data-toolprogress" } };

export const AIToolUseGroup = memo(({ parts, isStreaming }: AIToolUseGroupProps) => {
const tooluseParts = parts.filter((p) => p.type === "data-tooluse") as Array<
WaveUIMessagePart & { type: "data-tooluse" }
>;
const toolprogressParts = parts.filter((p) => p.type === "data-toolprogress") as Array<
WaveUIMessagePart & { type: "data-toolprogress" }
>;

const tooluseCallIds = new Set(tooluseParts.map((p) => p.data.toolcallid));
const filteredProgressParts = toolprogressParts.filter((p) => !tooluseCallIds.has(p.data.toolcallid));

const isFileOp = (part: WaveUIMessagePart & { type: "data-tooluse" }) => {
const toolName = part.data?.toolname;
return toolName === "read_text_file" || toolName === "read_dir";
Expand All @@ -392,7 +488,7 @@ export const AIToolUseGroup = memo(({ parts, isStreaming }: AIToolUseGroupProps)
const readFileNeedsApproval: Array<WaveUIMessagePart & { type: "data-tooluse" }> = [];
const readFileOther: Array<WaveUIMessagePart & { type: "data-tooluse" }> = [];

for (const part of parts) {
for (const part of tooluseParts) {
if (isFileOp(part)) {
if (needsApproval(part)) {
readFileNeedsApproval.push(part);
Expand All @@ -406,7 +502,7 @@ export const AIToolUseGroup = memo(({ parts, isStreaming }: AIToolUseGroupProps)
let addedApprovalBatch = false;
let addedOtherBatch = false;

for (const part of parts) {
for (const part of tooluseParts) {
const isFileOpPart = isFileOp(part);
const partNeedsApproval = needsApproval(part);

Expand All @@ -425,19 +521,33 @@ export const AIToolUseGroup = memo(({ parts, isStreaming }: AIToolUseGroupProps)
}
}

filteredProgressParts.forEach((part) => {
groupedItems.push({ type: "progress", part });
});

return (
<>
{groupedItems.map((item, idx) =>
item.type === "batch" ? (
<div key={idx} className="mt-2">
<AIToolUseBatch parts={item.parts} isStreaming={isStreaming} />
</div>
) : (
<div key={idx} className="mt-2">
<AIToolUse part={item.part} isStreaming={isStreaming} />
</div>
)
)}
{groupedItems.map((item, idx) => {
if (item.type === "batch") {
return (
<div key={idx} className="mt-2">
<AIToolUseBatch parts={item.parts} isStreaming={isStreaming} />
</div>
);
} else if (item.type === "progress") {
return (
<div key={idx} className="mt-2">
<AIToolProgress part={item.part} />
</div>
);
} else {
return (
<div key={idx} className="mt-2">
<AIToolUse part={item.part} isStreaming={isStreaming} />
</div>
);
}
})}
</>
);
});
Expand Down
6 changes: 6 additions & 0 deletions frontend/app/aipanel/aitypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ type WaveUIDataTypes = {
writebackupfilename?: string;
inputfilename?: string;
};

toolprogress: {
toolcallid: string;
toolname: string;
statuslines: string[];
};
};

export type WaveUIMessage = UIMessage<unknown, WaveUIDataTypes, {}>;
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/aipanel/thinkingmode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export const ThinkingLevelDropdown = memo(() => {
{isOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
<div className="absolute top-full right-0 mt-1 bg-gray-800 border border-gray-600 rounded shadow-lg z-50 min-w-[280px]">
<div className="absolute top-full left-0 mt-1 bg-gray-800 border border-gray-600 rounded shadow-lg z-50 min-w-[280px]">
{(Object.keys(ThinkingModeData) as ThinkingMode[]).map((mode, index) => {
const metadata = ThinkingModeData[mode];
const isFirst = index === 0;
Expand Down
2 changes: 2 additions & 0 deletions frontend/app/modals/modalregistry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { MessageModal } from "@/app/modals/messagemodal";
import { NewInstallOnboardingModal } from "@/app/onboarding/onboarding";
import { UpgradeOnboardingModal } from "@/app/onboarding/onboarding-upgrade";
import { PublishAppModal } from "@/builder/builder-apppanel";
import { AboutModal } from "./about";
import { UserInputModal } from "./userinputmodal";

Expand All @@ -13,6 +14,7 @@ const modalRegistry: { [key: string]: React.ComponentType<any> } = {
[UserInputModal.displayName || "UserInputModal"]: UserInputModal,
[AboutModal.displayName || "AboutModal"]: AboutModal,
[MessageModal.displayName || "MessageModal"]: MessageModal,
[PublishAppModal.displayName || "PublishAppModal"]: PublishAppModal,
};

export const getModalComponent = (key: string): React.ComponentType<any> | undefined => {
Expand Down
10 changes: 10 additions & 0 deletions frontend/app/store/wshclientapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,11 @@ class RpcApiType {
return client.wshRpcCall("path", data, opts);
}

// command "publishapp" [call]
PublishAppCommand(client: WshClient, data: CommandPublishAppData, opts?: RpcOpts): Promise<CommandPublishAppRtnData> {
return client.wshRpcCall("publishapp", data, opts);
}

// command "readappfile" [call]
ReadAppFileCommand(client: WshClient, data: CommandReadAppFileData, opts?: RpcOpts): Promise<CommandReadAppFileRtnData> {
return client.wshRpcCall("readappfile", data, opts);
Expand Down Expand Up @@ -622,6 +627,11 @@ class RpcApiType {
return client.wshRpcCall("writeappfile", data, opts);
}

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

// command "writetempfile" [call]
WriteTempFileCommand(client: WshClient, data: CommandWriteTempFileData, opts?: RpcOpts): Promise<string> {
return client.wshRpcCall("writetempfile", data, opts);
Expand Down
7 changes: 4 additions & 3 deletions frontend/app/view/tsunami/tsunami.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -235,12 +235,13 @@ const TsunamiView = memo((props: ViewComponentProps<TsunamiViewModel>) => {
}, [domReady, model]);

const appPath = blockData?.meta?.["tsunami:apppath"];
const appId = blockData?.meta?.["tsunami:appid"];
const controller = blockData?.meta?.controller;

// Check for configuration errors
const errors = [];
if (!appPath) {
errors.push("App path must be set (tsunami:apppath)");
if (!appPath && !appId) {
errors.push("App path or app ID must be set (tsunami:apppath or tsunami:appid)");
}
if (controller !== "tsunami") {
errors.push("Invalid controller (must be 'tsunami')");
Expand Down Expand Up @@ -283,7 +284,7 @@ const TsunamiView = memo((props: ViewComponentProps<TsunamiViewModel>) => {
return (
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
<h1 className="text-4xl font-bold text-main-text-color">Tsunami</h1>
{appPath && <div className="text-sm text-main-text-color opacity-70">{appPath}</div>}
{(appPath || appId) && <div className="text-sm text-main-text-color opacity-70">{appPath || appId}</div>}
{isNotRunning && !isRestarting && (
<button
onClick={() => model.forceRestartController()}
Expand Down
2 changes: 2 additions & 0 deletions frontend/builder/app-selection-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export function AppSelectionModal() {
data: { "builder:appid": appId },
});
globalStore.set(atoms.builderAppId, appId);
document.title = `WaveApp Builder (${appId})`;
};

const handleCreateNew = async (appName: string) => {
Expand All @@ -138,6 +139,7 @@ export function AppSelectionModal() {
data: { "builder:appid": draftAppId },
});
globalStore.set(atoms.builderAppId, draftAppId);
document.title = `WaveApp Builder (${draftAppId})`;
};

const isDraftApp = (appId: string) => {
Expand Down
Loading
Loading