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
2 changes: 1 addition & 1 deletion frontend/app/store/wshclientapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ class RpcApiType {
}

// command "listalleditableapps" [call]
ListAllEditableAppsCommand(client: WshClient, opts?: RpcOpts): Promise<string[]> {
ListAllEditableAppsCommand(client: WshClient, opts?: RpcOpts): Promise<AppInfo[]> {
return client.wshRpcCall("listalleditableapps", null, opts);
}

Expand Down
186 changes: 97 additions & 89 deletions frontend/builder/app-selection-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,16 @@ 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 { formatRelativeTime } from "@/util/util";
import { useEffect, useState } from "react";

const MaxAppNameLength = 50;
const AppNameRegex = /^[a-zA-Z0-9_-]+$/;

export function AppSelectionModal() {
const [apps, setApps] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
function CreateNewWaveApp({ onCreateApp }: { onCreateApp: (appName: string) => Promise<void> }) {
const [newAppName, setNewAppName] = useState("");
const [error, setError] = useState("");
const [inputError, setInputError] = useState("");

useEffect(() => {
loadApps();
}, []);
const [isCreating, setIsCreating] = useState(false);

const validateAppName = (name: string) => {
if (!name.trim()) {
Expand All @@ -39,10 +34,83 @@ export function AppSelectionModal() {
return true;
};

const handleCreate = async () => {
const trimmedName = newAppName.trim();
if (!validateAppName(trimmedName)) {
return;
}

setIsCreating(true);
try {
await onCreateApp(trimmedName);
} finally {
setIsCreating(false);
}
};

return (
<div className="min-h-[80px]">
<h3 className="text-base font-medium mb-1 text-muted-foreground">Create New WaveApp</h3>
<div className="relative">
<div className="flex w-full">
<input
type="text"
value={newAppName}
onChange={(e) => {
const value = e.target.value;
setNewAppName(value);
validateAppName(value);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.nativeEvent.isComposing && newAppName.trim() && !inputError) {
handleCreate();
}
}}
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
disabled={isCreating}
/>
<button
onClick={handleCreate}
disabled={!newAppName.trim() || !!inputError || isCreating}
className={`px-4 py-2 rounded-r transition-colors font-medium whitespace-nowrap ${
!newAppName.trim() || inputError || isCreating
? "bg-panel border border-l-0 border-border text-muted cursor-not-allowed"
: "bg-accent text-black hover:bg-accent-hover cursor-pointer"
}`}
>
Create
</button>
</div>
{inputError && (
<div className="absolute left-0 top-full mt-1 text-xs text-error flex items-center gap-1.5 whitespace-nowrap">
<i className="fa-solid fa-circle-exclamation"></i>
<span>{inputError}</span>
</div>
)}
</div>
</div>
);
}

export function AppSelectionModal() {
const [apps, setApps] = useState<AppInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");

useEffect(() => {
loadApps();
}, []);

const loadApps = async () => {
try {
const appList = await RpcApi.ListAllEditableAppsCommand(TabRpcClient);
setApps(appList || []);
const sortedApps = (appList || []).sort((a, b) => b.modtime - a.modtime);
setApps(sortedApps);
} catch (err) {
console.error("Failed to load apps:", err);
setError("Failed to load apps");
Expand All @@ -61,25 +129,8 @@ export function AppSelectionModal() {
globalStore.set(atoms.builderAppId, appId);
};

const handleCreateNew = async () => {
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}`;
const handleCreateNew = async (appName: string) => {
const draftAppId = `draft/${appName}`;
const builderId = globalStore.get(atoms.builderId);
const oref = WOS.makeORef("builder", builderId);
await RpcApi.SetRTInfoCommand(TabRpcClient, {
Expand Down Expand Up @@ -111,9 +162,9 @@ export function AppSelectionModal() {
}

return (
<FlexiModal className="min-w-[600px] w-[600px] max-h-[80vh] overflow-y-auto">
<FlexiModal className="min-w-[600px] w-[600px] max-h-[90vh] overflow-y-auto">
<div className="w-full px-2 pt-0 pb-4">
<h2 className="text-2xl mb-6">Select a WaveApp to Edit</h2>
<h2 className="text-2xl mb-2">Select a WaveApp to Edit</h2>

{error && (
<div className="mb-6 px-4 py-3 bg-panel rounded">
Expand All @@ -125,18 +176,23 @@ export function AppSelectionModal() {
)}

{apps.length > 0 && (
<div className="mb-6">
<h3 className="text-base font-medium mb-3 text-muted-foreground">Existing WaveApps</h3>
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{apps.map((appId) => (
<div className="mb-2">
<h3 className="text-base font-medium mb-1 text-muted-foreground">Existing WaveApps</h3>
<div className="space-y-2 max-h-[220px] overflow-y-auto">
{apps.map((appInfo) => (
<button
key={appId}
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"
key={appInfo.appid}
onClick={() => handleSelectApp(appInfo.appid)}
className="w-full text-left px-4 py-1.5 bg-panel hover:bg-hover border border-border rounded transition-colors cursor-pointer"
>
<div className="flex items-center gap-3">
<i className="fa-solid fa-cube"></i>
<span>{getAppDisplayName(appId)}</span>
<i className="fa-solid fa-cube self-center"></i>
<div className="flex flex-col">
<span>{getAppDisplayName(appInfo.appid)}</span>
<span className="text-[11px] text-muted mt-0.5">
Last updated: {formatRelativeTime(appInfo.modtime)}
</span>
Comment on lines +192 to +194
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard against zero modtime before rendering.

GetAppModTime returns 0 when an app doesn’t have an app.go yet (fresh drafts, deleted entrypoints, etc.), so this prints “Last updated: 55 years ago”. Please treat modtime <= 0 as “Never” (or hide the line) before calling formatRelativeTime.

Apply this diff to handle the sentinel:

-                                            <span className="text-[11px] text-muted mt-0.5">
-                                                Last updated: {formatRelativeTime(appInfo.modtime)}
-                                            </span>
+                                            <span className="text-[11px] text-muted mt-0.5">
+                                                Last updated:{" "}
+                                                {appInfo.modtime > 0 ? formatRelativeTime(appInfo.modtime) : "Never"}
+                                            </span>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<span className="text-[11px] text-muted mt-0.5">
Last updated: {formatRelativeTime(appInfo.modtime)}
</span>
<span className="text-[11px] text-muted mt-0.5">
Last updated:{" "}
{appInfo.modtime > 0 ? formatRelativeTime(appInfo.modtime) : "Never"}
</span>
🤖 Prompt for AI Agents
In frontend/builder/app-selection-modal.tsx around lines 192 to 194, guard
against appInfo.modtime being the sentinel 0 (or negative) before calling
formatRelativeTime: check if appInfo.modtime <= 0 and in that case render "Last
updated: Never" (or omit the entire line) instead of calling formatRelativeTime;
otherwise call formatRelativeTime(appInfo.modtime) as before.

</div>
</div>
</button>
))}
Expand All @@ -145,62 +201,14 @@ export function AppSelectionModal() {
)}

{apps.length > 0 && (
<div className="flex items-center gap-4 my-6">
<div className="flex items-center gap-4 my-2">
<div className="flex-1 border-t border-border"></div>
<span className="text-muted-foreground text-sm">or</span>
<div className="flex-1 border-t border-border"></div>
</div>
)}

<div className="min-h-[80px]">
<h3 className="text-base font-medium mb-4 text-muted-foreground">Create New WaveApp</h3>
<div className="relative">
<div className="flex w-full">
<input
type="text"
value={newAppName}
onChange={(e) => {
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
/>
<button
onClick={handleCreateNew}
disabled={!newAppName.trim() || !!inputError}
className={`px-4 py-2 rounded-r transition-colors font-medium whitespace-nowrap ${
!newAppName.trim() || inputError
? "bg-panel border border-l-0 border-border text-muted cursor-not-allowed"
: "bg-accent text-black hover:bg-accent-hover cursor-pointer"
}`}
>
Create
</button>
</div>
{inputError && (
<div className="absolute left-0 top-full mt-1 text-xs text-error flex items-center gap-1.5 whitespace-nowrap">
<i className="fa-solid fa-circle-exclamation"></i>
<span>{inputError}</span>
</div>
)}
</div>
</div>
<CreateNewWaveApp onCreateApp={handleCreateNew} />
</div>
</FlexiModal>
);
Expand Down
24 changes: 21 additions & 3 deletions frontend/builder/builder-buildpanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { WaveAIModel } from "@/app/aipanel/waveai-model";
import { ContextMenuModel } from "@/app/store/contextmenu";
import { globalStore } from "@/app/store/jotaiStore";
import { BuilderBuildPanelModel } from "@/builder/store/builder-buildpanel-model";
import { useAtomValue } from "jotai";
import { memo, useCallback, useEffect, useRef } from "react";
Expand Down Expand Up @@ -35,6 +36,7 @@ function handleBuildPanelContextMenu(e: React.MouseEvent, selectedText: string):
const BuilderBuildPanel = memo(() => {
const model = BuilderBuildPanelModel.getInstance();
const outputLines = useAtomValue(model.outputLines);
const showDebug = useAtomValue(model.showDebug);
const scrollRef = useRef<HTMLDivElement>(null);
const preRef = useRef<HTMLPreElement>(null);

Expand Down Expand Up @@ -71,10 +73,25 @@ const BuilderBuildPanel = memo(() => {
handleBuildPanelContextMenu(e, selectedText);
}, []);

const handleDebugToggle = useCallback(() => {
globalStore.set(model.showDebug, !showDebug);
}, [model, showDebug]);

const filteredLines = showDebug ? outputLines : outputLines.filter((line) => !line.startsWith("[debug]"));

return (
<div className="w-full h-full flex flex-col bg-black">
<div className="flex-shrink-0 px-3 py-2 border-b border-gray-700">
<div className="flex-shrink-0 px-3 py-2 border-b border-gray-700 flex items-center justify-between">
<span className="text-sm font-semibold text-gray-300">Build Output</span>
<label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={showDebug}
onChange={handleDebugToggle}
className="cursor-pointer"
/>
Debug
</label>
</div>
<div ref={scrollRef} className="flex-1 overflow-y-auto overflow-x-auto p-2">
<pre
Expand All @@ -83,10 +100,11 @@ const BuilderBuildPanel = memo(() => {
onMouseUp={handleMouseUp}
onContextMenu={handleContextMenu}
>
{outputLines.length === 0 ? (
{/* this comment fixes JSX blank line in pre tag */}
{filteredLines.length === 0 ? (
<span className="text-secondary">Waiting for output...</span>
) : (
outputLines.join("\n")
filteredLines.join("\n")
)}
</pre>
</div>
Expand Down
1 change: 1 addition & 0 deletions frontend/builder/store/builder-buildpanel-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export class BuilderBuildPanelModel {
private static instance: BuilderBuildPanelModel | null = null;

outputLines: PrimitiveAtom<string[]> = atom<string[]>([]);
showDebug: PrimitiveAtom<boolean> = atom<boolean>(false);
outputUnsubFn: (() => void) | null = null;
initialized = false;

Expand Down
6 changes: 6 additions & 0 deletions frontend/types/gotypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ declare global {
message?: string;
};

// wshrpc.AppInfo
type AppInfo = {
appid: string;
modtime: number;
};

// waveobj.Block
type Block = WaveObj & {
parentoref?: string;
Expand Down
24 changes: 24 additions & 0 deletions frontend/util/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,29 @@ function parseDataUrl(dataUrl: string): ParsedDataUrl {
return { mimeType, buffer };
}

function formatRelativeTime(timestamp: number): string {
if (!timestamp) {
return "never";
}
const now = Date.now();
const diffInSeconds = Math.floor((now - timestamp) / 1000);
const diffInMinutes = Math.floor(diffInSeconds / 60);
const diffInHours = Math.floor(diffInMinutes / 60);
const diffInDays = Math.floor(diffInHours / 24);

if (diffInMinutes <= 0) {
return "Just now";
} else if (diffInMinutes < 60) {
return `${diffInMinutes} min${diffInMinutes !== 1 ? "s" : ""} ago`;
} else if (diffInHours < 24) {
return `${diffInHours} hr${diffInHours !== 1 ? "s" : ""} ago`;
} else if (diffInDays < 7) {
return `${diffInDays} day${diffInDays !== 1 ? "s" : ""} ago`;
} else {
return new Date(timestamp).toLocaleDateString();
}
}

export {
atomWithDebounce,
atomWithThrottle,
Expand All @@ -464,6 +487,7 @@ export {
deepCompareReturnPrev,
escapeBytes,
fireAndForget,
formatRelativeTime,
getPrefixedSettings,
getPromiseState,
getPromiseValue,
Expand Down
Loading
Loading