Skip to content

Commit 7ac2f25

Browse files
authored
big improvements to waveapp builder (#2550)
* build manifest * working on secrets injection (secretstore + secret-bindings.json) * tool progress indicators * build output and errors injected as the result of the edit calls so AI gets instant feedback on edits * change edits to not be atomic (allows AI to make better progress) * updated binary location for waveapps * publish button * new partial json parser (for sending incremental tool progress indication) * updated tsunami view to use new embedded scaffold + config vars * lots of work on cleaning up the output so it is more useful to users + AI agents * fix builder init flow
1 parent 48c6b95 commit 7ac2f25

35 files changed

+1197
-218
lines changed

.roo/rules/rules.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ It has a TypeScript/React frontend and a Go backend. They talk together over `ws
5050
- We use **Tailwind v4** to style. Custom stuff is defined in frontend/tailwindsetup.css
5151
- _never_ use cursor-help, or cursor-not-allowed (it looks terrible)
5252
- 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.
53+
- 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)
5354

5455
### RPC System
5556

emain/emain-builder.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,6 @@ export async function createBuilderWindow(appId: string): Promise<BuilderWindowT
7878
typedBuilderWindow.builderId = builderId;
7979
typedBuilderWindow.savedInitOpts = initOpts;
8080

81-
console.log("sending builder-init", initOpts);
82-
typedBuilderWindow.webContents.send("builder-init", initOpts);
83-
8481
typedBuilderWindow.on("focus", () => {
8582
focusedBuilderWindow = typedBuilderWindow;
8683
console.log("builder window focused", builderId);

emain/emain-menu.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ function makeFileMenu(numWaveWindows: number, callbacks: AppMenuCallbacks): Elec
127127
if (isDev) {
128128
fileMenu.splice(1, 0, {
129129
label: "New WaveApp Builder Window",
130+
accelerator: unamePlatform === "darwin" ? "Command+Shift+B" : "Alt+Shift+B",
130131
click: () => fireAndForget(() => createBuilderWindow("")),
131132
});
132133
}

frontend/app/aipanel/aimessage.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,7 @@ const AIThinking = memo(
4949
)}
5050
{message && <span className="text-sm text-gray-400">{message}</span>}
5151
</div>
52-
<div
53-
ref={scrollRef}
54-
className="text-sm text-gray-500 overflow-y-auto h-[3lh] max-w-[600px] pl-9"
55-
>
52+
<div ref={scrollRef} className="text-sm text-gray-500 overflow-y-auto h-[3lh] max-w-[600px] pl-9">
5653
{displayText}
5754
</div>
5855
</div>
@@ -147,21 +144,22 @@ const isDisplayPart = (part: WaveUIMessagePart): boolean => {
147144
return (
148145
part.type === "text" ||
149146
part.type === "data-tooluse" ||
147+
part.type === "data-toolprogress" ||
150148
(part.type.startsWith("tool-") && "state" in part && part.state === "input-available")
151149
);
152150
};
153151

154152
type MessagePart =
155153
| { type: "single"; part: WaveUIMessagePart }
156-
| { type: "toolgroup"; parts: Array<WaveUIMessagePart & { type: "data-tooluse" }> };
154+
| { type: "toolgroup"; parts: Array<WaveUIMessagePart & { type: "data-tooluse" | "data-toolprogress" }> };
157155

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

162160
for (const part of parts) {
163-
if (part.type === "data-tooluse") {
164-
currentToolGroup.push(part as WaveUIMessagePart & { type: "data-tooluse" });
161+
if (part.type === "data-tooluse" || part.type === "data-toolprogress") {
162+
currentToolGroup.push(part as WaveUIMessagePart & { type: "data-tooluse" | "data-toolprogress" });
165163
} else {
166164
if (currentToolGroup.length > 0) {
167165
grouped.push({ type: "toolgroup", parts: currentToolGroup });
@@ -225,7 +223,7 @@ export const AIMessage = memo(({ message, isStreaming }: AIMessageProps) => {
225223
className={cn(
226224
"px-2 rounded-lg [&>*:first-child]:!mt-0",
227225
message.role === "user"
228-
? "py-2 bg-accent-800 text-white max-w-[calc(100%-20px)]"
226+
? "py-2 bg-accent-800 text-white max-w-[calc(90%-10px)]"
229227
: "min-w-[min(100%,500px)]"
230228
)}
231229
>

frontend/app/aipanel/aipanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -497,7 +497,7 @@ const AIPanelComponentInner = memo(() => {
497497
className="flex-1 overflow-y-auto p-2 relative"
498498
onContextMenu={(e) => handleWaveAIContextMenu(e, true)}
499499
>
500-
<div className="absolute top-2 right-2 z-10">
500+
<div className="absolute top-2 left-2 z-10">
501501
<ThinkingLevelDropdown />
502502
</div>
503503
{model.inBuilder ? <AIBuilderWelcomeMessage /> : <AIWelcomeMessage />}

frontend/app/aipanel/aipanelmessages.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export const AIPanelMessages = memo(({ messages, status, onContextMenu }: AIPane
4747
className="flex-1 overflow-y-auto p-2 space-y-4 relative"
4848
onContextMenu={onContextMenu}
4949
>
50-
<div className="absolute top-2 right-2 z-10">
50+
<div className="absolute top-2 left-2 z-10">
5151
<ThinkingLevelDropdown />
5252
</div>
5353
{messages.map((message, index) => {

frontend/app/aipanel/aitooluse.tsx

Lines changed: 126 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,69 @@ import { WaveAIModel } from "./waveai-model";
1313
// matches pkg/filebackup/filebackup.go
1414
const BackupRetentionDays = 5;
1515

16+
interface ToolDescLineProps {
17+
text: string;
18+
}
19+
20+
const ToolDescLine = memo(({ text }: ToolDescLineProps) => {
21+
let displayText = text;
22+
if (displayText.startsWith("* ")) {
23+
displayText = "• " + displayText.slice(2);
24+
}
25+
26+
const parts: React.ReactNode[] = [];
27+
let lastIndex = 0;
28+
const regex = /(?<!\w)([+-])(\d+)(?!\w)/g;
29+
let match;
30+
31+
while ((match = regex.exec(displayText)) !== null) {
32+
if (match.index > lastIndex) {
33+
parts.push(displayText.slice(lastIndex, match.index));
34+
}
35+
36+
const sign = match[1];
37+
const number = match[2];
38+
const colorClass = sign === "+" ? "text-green-600" : "text-red-600";
39+
parts.push(
40+
<span key={match.index} className={colorClass}>
41+
{sign}
42+
{number}
43+
</span>
44+
);
45+
46+
lastIndex = match.index + match[0].length;
47+
}
48+
49+
if (lastIndex < displayText.length) {
50+
parts.push(displayText.slice(lastIndex));
51+
}
52+
53+
return <div>{parts.length > 0 ? parts : displayText}</div>;
54+
});
55+
56+
ToolDescLine.displayName = "ToolDescLine";
57+
58+
interface ToolDescProps {
59+
text: string | string[];
60+
className?: string;
61+
}
62+
63+
const ToolDesc = memo(({ text, className }: ToolDescProps) => {
64+
const lines = Array.isArray(text) ? text : text.split("\n");
65+
66+
if (lines.length === 0) return null;
67+
68+
return (
69+
<div className={className}>
70+
{lines.map((line, idx) => (
71+
<ToolDescLine key={idx} text={line} />
72+
))}
73+
</div>
74+
);
75+
});
76+
77+
ToolDesc.displayName = "ToolDesc";
78+
1679
function getEffectiveApprovalStatus(baseApproval: string, isStreaming: boolean): string {
1780
return !isStreaming && baseApproval === "needs-approval" ? "timeout" : baseApproval;
1881
}
@@ -354,7 +417,7 @@ const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => {
354417
</button>
355418
)}
356419
</div>
357-
{toolData.tooldesc && <div className="text-sm text-gray-400 pl-6">{toolData.tooldesc}</div>}
420+
{toolData.tooldesc && <ToolDesc text={toolData.tooldesc} className="text-sm text-gray-400 pl-6" />}
358421
{(toolData.errormessage || effectiveApproval === "timeout") && (
359422
<div className="text-sm text-red-300 pl-6">{toolData.errormessage || "Not approved"}</div>
360423
)}
@@ -370,16 +433,49 @@ const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => {
370433

371434
AIToolUse.displayName = "AIToolUse";
372435

436+
interface AIToolProgressProps {
437+
part: WaveUIMessagePart & { type: "data-toolprogress" };
438+
}
439+
440+
const AIToolProgress = memo(({ part }: AIToolProgressProps) => {
441+
const progressData = part.data;
442+
443+
return (
444+
<div className="flex flex-col gap-1 p-2 rounded bg-gray-800 border border-gray-700">
445+
<div className="flex items-center gap-2">
446+
<i className="fa fa-spinner fa-spin text-gray-400"></i>
447+
<div className="font-semibold">{progressData.toolname}</div>
448+
</div>
449+
{progressData.statuslines && progressData.statuslines.length > 0 && (
450+
<ToolDesc text={progressData.statuslines} className="text-sm text-gray-400 pl-6 space-y-0.5" />
451+
)}
452+
</div>
453+
);
454+
});
455+
456+
AIToolProgress.displayName = "AIToolProgress";
457+
373458
interface AIToolUseGroupProps {
374-
parts: Array<WaveUIMessagePart & { type: "data-tooluse" }>;
459+
parts: Array<WaveUIMessagePart & { type: "data-tooluse" | "data-toolprogress" }>;
375460
isStreaming: boolean;
376461
}
377462

378463
type ToolGroupItem =
379464
| { type: "batch"; parts: Array<WaveUIMessagePart & { type: "data-tooluse" }> }
380-
| { type: "single"; part: WaveUIMessagePart & { type: "data-tooluse" } };
465+
| { type: "single"; part: WaveUIMessagePart & { type: "data-tooluse" } }
466+
| { type: "progress"; part: WaveUIMessagePart & { type: "data-toolprogress" } };
381467

382468
export const AIToolUseGroup = memo(({ parts, isStreaming }: AIToolUseGroupProps) => {
469+
const tooluseParts = parts.filter((p) => p.type === "data-tooluse") as Array<
470+
WaveUIMessagePart & { type: "data-tooluse" }
471+
>;
472+
const toolprogressParts = parts.filter((p) => p.type === "data-toolprogress") as Array<
473+
WaveUIMessagePart & { type: "data-toolprogress" }
474+
>;
475+
476+
const tooluseCallIds = new Set(tooluseParts.map((p) => p.data.toolcallid));
477+
const filteredProgressParts = toolprogressParts.filter((p) => !tooluseCallIds.has(p.data.toolcallid));
478+
383479
const isFileOp = (part: WaveUIMessagePart & { type: "data-tooluse" }) => {
384480
const toolName = part.data?.toolname;
385481
return toolName === "read_text_file" || toolName === "read_dir";
@@ -392,7 +488,7 @@ export const AIToolUseGroup = memo(({ parts, isStreaming }: AIToolUseGroupProps)
392488
const readFileNeedsApproval: Array<WaveUIMessagePart & { type: "data-tooluse" }> = [];
393489
const readFileOther: Array<WaveUIMessagePart & { type: "data-tooluse" }> = [];
394490

395-
for (const part of parts) {
491+
for (const part of tooluseParts) {
396492
if (isFileOp(part)) {
397493
if (needsApproval(part)) {
398494
readFileNeedsApproval.push(part);
@@ -406,7 +502,7 @@ export const AIToolUseGroup = memo(({ parts, isStreaming }: AIToolUseGroupProps)
406502
let addedApprovalBatch = false;
407503
let addedOtherBatch = false;
408504

409-
for (const part of parts) {
505+
for (const part of tooluseParts) {
410506
const isFileOpPart = isFileOp(part);
411507
const partNeedsApproval = needsApproval(part);
412508

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

524+
filteredProgressParts.forEach((part) => {
525+
groupedItems.push({ type: "progress", part });
526+
});
527+
428528
return (
429529
<>
430-
{groupedItems.map((item, idx) =>
431-
item.type === "batch" ? (
432-
<div key={idx} className="mt-2">
433-
<AIToolUseBatch parts={item.parts} isStreaming={isStreaming} />
434-
</div>
435-
) : (
436-
<div key={idx} className="mt-2">
437-
<AIToolUse part={item.part} isStreaming={isStreaming} />
438-
</div>
439-
)
440-
)}
530+
{groupedItems.map((item, idx) => {
531+
if (item.type === "batch") {
532+
return (
533+
<div key={idx} className="mt-2">
534+
<AIToolUseBatch parts={item.parts} isStreaming={isStreaming} />
535+
</div>
536+
);
537+
} else if (item.type === "progress") {
538+
return (
539+
<div key={idx} className="mt-2">
540+
<AIToolProgress part={item.part} />
541+
</div>
542+
);
543+
} else {
544+
return (
545+
<div key={idx} className="mt-2">
546+
<AIToolUse part={item.part} isStreaming={isStreaming} />
547+
</div>
548+
);
549+
}
550+
})}
441551
</>
442552
);
443553
});

frontend/app/aipanel/aitypes.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ type WaveUIDataTypes = {
2424
writebackupfilename?: string;
2525
inputfilename?: string;
2626
};
27+
28+
toolprogress: {
29+
toolcallid: string;
30+
toolname: string;
31+
statuslines: string[];
32+
};
2733
};
2834

2935
export type WaveUIMessage = UIMessage<unknown, WaveUIDataTypes, {}>;

frontend/app/aipanel/thinkingmode.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export const ThinkingLevelDropdown = memo(() => {
7575
{isOpen && (
7676
<>
7777
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
78-
<div className="absolute top-full right-0 mt-1 bg-gray-800 border border-gray-600 rounded shadow-lg z-50 min-w-[280px]">
78+
<div className="absolute top-full left-0 mt-1 bg-gray-800 border border-gray-600 rounded shadow-lg z-50 min-w-[280px]">
7979
{(Object.keys(ThinkingModeData) as ThinkingMode[]).map((mode, index) => {
8080
const metadata = ThinkingModeData[mode];
8181
const isFirst = index === 0;

frontend/app/modals/modalregistry.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { MessageModal } from "@/app/modals/messagemodal";
55
import { NewInstallOnboardingModal } from "@/app/onboarding/onboarding";
66
import { UpgradeOnboardingModal } from "@/app/onboarding/onboarding-upgrade";
7+
import { PublishAppModal } from "@/builder/builder-apppanel";
78
import { AboutModal } from "./about";
89
import { UserInputModal } from "./userinputmodal";
910

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

1820
export const getModalComponent = (key: string): React.ComponentType<any> | undefined => {

0 commit comments

Comments
 (0)