Skip to content

Commit 84f1cee

Browse files
committed
complete the "restore file" feature for Wave AI write file tools...
1 parent 478c8c4 commit 84f1cee

11 files changed

Lines changed: 163 additions & 25 deletions

File tree

.roo/rules/rules.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ It has a TypeScript/React frontend and a Go backend. They talk together over `ws
3535
- Import the "cn" function from "@/util/util" to do classname / clsx class merge (it uses twMerge underneath)
3636
- For element variants use class-variance-authority
3737
- Do NOT create private fields in classes (they are impossible to inspect)
38+
- Use PascalCase for global consts at the top of files
3839
- **Component Practices**:
3940
- Make sure to add cursor-pointer to buttons/links and clickable items
4041
- NEVER use cursor-help (it looks terrible)
@@ -48,6 +49,12 @@ It has a TypeScript/React frontend and a Go backend. They talk together over `ws
4849
- _never_ use cursor-help, or cursor-not-allowed (it looks terrible)
4950
- 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.
5051

52+
### RPC System
53+
54+
To define a new RPC call, add the new definition to `pkg/wshrpc/wshrpctypes.go` including any input/output data that is required. After modifying wshrpctypes.go run `task generate` to generate the client APIs.
55+
56+
For normal "server" RPCs (where a frontend client is calling the main server) you should implement the RPC call in `pkg/wshrpc/wshserver.go`.
57+
5158
### Code Generation
5259

5360
- **TypeScript Types**: TypeScript types are automatically generated from Go types. After modifying Go types in `pkg/wshrpc/wshrpctypes.go`, run `task generate` to update the TypeScript type definitions in `frontend/types/gotypes.d.ts`.

frontend/app/aipanel/aitooluse.tsx

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import { memo, useEffect, useRef, useState } from "react";
99
import { WaveUIMessagePart } from "./aitypes";
1010
import { WaveAIModel } from "./waveai-model";
1111

12+
// matches pkg/filebackup/filebackup.go
13+
const BackupRetentionDays = 5;
14+
1215
function getEffectiveApprovalStatus(baseApproval: string, isStreaming: boolean): string {
1316
return !isStreaming && baseApproval === "needs-approval" ? "timeout" : baseApproval;
1417
}
@@ -136,6 +139,8 @@ interface RestoreBackupModalProps {
136139
const RestoreBackupModal = memo(({ part }: RestoreBackupModalProps) => {
137140
const model = WaveAIModel.getInstance();
138141
const toolData = part.data;
142+
const status = useAtomValue(model.restoreBackupStatus);
143+
const error = useAtomValue(model.restoreBackupError);
139144

140145
const formatTimestamp = (ts: number) => {
141146
if (!ts) return "";
@@ -144,21 +149,57 @@ const RestoreBackupModal = memo(({ part }: RestoreBackupModalProps) => {
144149
};
145150

146151
const handleConfirm = () => {
147-
model.restoreBackup(toolData.toolcallid, toolData.inputfilename);
152+
model.restoreBackup(toolData.toolcallid, toolData.writebackupfilename, toolData.inputfilename);
148153
};
149154

150155
const handleCancel = () => {
151156
model.closeRestoreBackupModal();
152157
};
153158

159+
const handleClose = () => {
160+
model.closeRestoreBackupModal();
161+
};
162+
163+
if (status === "success") {
164+
return (
165+
<Modal className="restore-backup-modal pb-5 pr-5" onClose={handleClose} onOk={handleClose} okLabel="Close">
166+
<div className="flex flex-col gap-4 pt-4 pb-4 max-w-xl">
167+
<div className="font-semibold text-lg text-green-500">Backup Successfully Restored</div>
168+
<div className="text-sm text-gray-300 leading-relaxed">
169+
The file <span className="font-mono text-white break-all">{toolData.inputfilename}</span> has
170+
been restored to its previous state.
171+
</div>
172+
</div>
173+
</Modal>
174+
);
175+
}
176+
177+
if (status === "error") {
178+
return (
179+
<Modal className="restore-backup-modal pb-5 pr-5" onClose={handleClose} onOk={handleClose} okLabel="Close">
180+
<div className="flex flex-col gap-4 pt-4 pb-4 max-w-xl">
181+
<div className="font-semibold text-lg text-red-500">Failed to Restore Backup</div>
182+
<div className="text-sm text-gray-300 leading-relaxed">
183+
An error occurred while restoring the backup:
184+
</div>
185+
<div className="text-sm text-red-400 font-mono bg-gray-800 p-3 rounded break-all">{error}</div>
186+
</div>
187+
</Modal>
188+
);
189+
}
190+
191+
const isProcessing = status === "processing";
192+
154193
return (
155194
<Modal
156195
className="restore-backup-modal pb-5 pr-5"
157196
onClose={handleCancel}
158197
onCancel={handleCancel}
159198
onOk={handleConfirm}
160-
okLabel="Confirm Restore"
199+
okLabel={isProcessing ? "Restoring..." : "Confirm Restore"}
161200
cancelLabel="Cancel"
201+
okDisabled={isProcessing}
202+
cancelDisabled={isProcessing}
162203
>
163204
<div className="flex flex-col gap-4 pt-4 pb-4 max-w-xl">
164205
<div className="font-semibold text-lg">Restore File Backup</div>
@@ -277,16 +318,20 @@ const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => {
277318
<span className="font-bold">{statusIcon}</span>
278319
<div className="font-semibold">{toolData.toolname}</div>
279320
<div className="flex-1" />
280-
{isFileWriteTool && toolData.inputfilename && toolData.writebackupfilename && (
281-
<button
282-
onClick={() => model.openRestoreBackupModal(toolData.toolcallid)}
283-
className="flex-shrink-0 px-1.5 py-0.5 border border-gray-600 hover:border-gray-500 hover:bg-gray-700 rounded cursor-pointer transition-colors flex items-center gap-1 text-gray-400"
284-
title="Restore backup file"
285-
>
286-
<span className="text-xs">Restore Backup</span>
287-
<i className="fa fa-clock-rotate-left text-xs"></i>
288-
</button>
289-
)}
321+
{isFileWriteTool &&
322+
toolData.inputfilename &&
323+
toolData.writebackupfilename &&
324+
toolData.runts &&
325+
Date.now() - toolData.runts < BackupRetentionDays * 24 * 60 * 60 * 1000 && (
326+
<button
327+
onClick={() => model.openRestoreBackupModal(toolData.toolcallid)}
328+
className="flex-shrink-0 px-1.5 py-0.5 border border-gray-600 hover:border-gray-500 hover:bg-gray-700 rounded cursor-pointer transition-colors flex items-center gap-1 text-gray-400"
329+
title="Restore backup file"
330+
>
331+
<span className="text-xs">Revert File</span>
332+
<i className="fa fa-clock-rotate-left text-xs"></i>
333+
</button>
334+
)}
290335
{isFileWriteTool && toolData.inputfilename && (
291336
<button
292337
onClick={handleOpenDiff}

frontend/app/aipanel/waveai-model.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ export class WaveAIModel {
5757
isWaveAIFocusedAtom!: jotai.Atom<boolean>;
5858
panelVisibleAtom!: jotai.Atom<boolean>;
5959
restoreBackupModalToolCallId: jotai.PrimitiveAtom<string | null> = jotai.atom(null) as jotai.PrimitiveAtom<string | null>;
60+
restoreBackupStatus: jotai.PrimitiveAtom<"idle" | "processing" | "success" | "error"> = jotai.atom("idle");
61+
restoreBackupError: jotai.PrimitiveAtom<string> = jotai.atom(null) as jotai.PrimitiveAtom<string>;
6062

6163
private constructor(orefContext: ORef, inBuilder: boolean) {
6264
this.orefContext = orefContext;
@@ -460,10 +462,25 @@ export class WaveAIModel {
460462

461463
closeRestoreBackupModal() {
462464
globalStore.set(this.restoreBackupModalToolCallId, null);
465+
globalStore.set(this.restoreBackupStatus, "idle");
466+
globalStore.set(this.restoreBackupError, null);
463467
}
464468

465-
async restoreBackup(toolcallid: string, filename: string) {
466-
console.log("Restore backup called for:", { toolcallid, filename });
467-
this.closeRestoreBackupModal();
469+
async restoreBackup(toolcallid: string, backupFilePath: string, restoreToFileName: string) {
470+
globalStore.set(this.restoreBackupStatus, "processing");
471+
globalStore.set(this.restoreBackupError, null);
472+
try {
473+
await RpcApi.FileRestoreBackupCommand(TabRpcClient, {
474+
backupfilepath: backupFilePath,
475+
restoretofilename: restoreToFileName,
476+
});
477+
console.log("Backup restored successfully:", { toolcallid, backupFilePath, restoreToFileName });
478+
globalStore.set(this.restoreBackupStatus, "success");
479+
} catch (error) {
480+
console.error("Failed to restore backup:", error);
481+
const errorMsg = error?.message || String(error);
482+
globalStore.set(this.restoreBackupError, errorMsg);
483+
globalStore.set(this.restoreBackupStatus, "error");
484+
}
468485
}
469486
}

frontend/app/modals/modal.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@ interface ModalProps {
1818
onOk?: () => void;
1919
onCancel?: () => void;
2020
onClose?: () => void;
21+
okDisabled?: boolean;
22+
cancelDisabled?: boolean;
2123
}
2224

2325
const Modal = forwardRef<HTMLDivElement, ModalProps>(
24-
({ children, className, cancelLabel, okLabel, onCancel, onOk, onClose, onClickBackdrop }: ModalProps, ref) => {
26+
({ children, className, cancelLabel, okLabel, onCancel, onOk, onClose, onClickBackdrop, okDisabled, cancelDisabled }: ModalProps, ref) => {
2527
const renderBackdrop = (onClick) => <div className="modal-backdrop" onClick={onClick}></div>;
2628

2729
const renderFooter = () => {
@@ -39,7 +41,7 @@ const Modal = forwardRef<HTMLDivElement, ModalProps>(
3941
<ModalContent>{children}</ModalContent>
4042
</div>
4143
{renderFooter() && (
42-
<ModalFooter onCancel={onCancel} onOk={onOk} cancelLabel={cancelLabel} okLabel={okLabel} />
44+
<ModalFooter onCancel={onCancel} onOk={onOk} cancelLabel={cancelLabel} okLabel={okLabel} okDisabled={okDisabled} cancelDisabled={cancelDisabled} />
4345
)}
4446
</div>
4547
</div>
@@ -62,17 +64,19 @@ interface ModalFooterProps {
6264
cancelLabel?: string;
6365
onOk?: () => void;
6466
onCancel?: () => void;
67+
okDisabled?: boolean;
68+
cancelDisabled?: boolean;
6569
}
6670

67-
const ModalFooter = ({ onCancel, onOk, cancelLabel = "Cancel", okLabel = "Ok" }: ModalFooterProps) => {
71+
const ModalFooter = ({ onCancel, onOk, cancelLabel = "Cancel", okLabel = "Ok", okDisabled, cancelDisabled }: ModalFooterProps) => {
6872
return (
6973
<footer className="modal-footer">
7074
{onCancel && (
71-
<Button className="grey ghost" onClick={onCancel}>
75+
<Button className="grey ghost" onClick={onCancel} disabled={cancelDisabled}>
7276
{cancelLabel}
7377
</Button>
7478
)}
75-
{onOk && <Button onClick={onOk}>{okLabel}</Button>}
79+
{onOk && <Button onClick={onOk} disabled={okDisabled}>{okLabel}</Button>}
7680
</footer>
7781
);
7882
};

frontend/app/store/wshclientapi.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,11 @@ class RpcApiType {
247247
return client.wshRpcStream("filereadstream", data, opts);
248248
}
249249

250+
// command "filerestorebackup" [call]
251+
FileRestoreBackupCommand(client: WshClient, data: CommandFileRestoreBackupData, opts?: RpcOpts): Promise<void> {
252+
return client.wshRpcCall("filerestorebackup", data, opts);
253+
}
254+
250255
// command "filesharecapability" [call]
251256
FileShareCapabilityCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<FileShareCapability> {
252257
return client.wshRpcCall("filesharecapability", data, opts);

frontend/types/gotypes.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,12 @@ declare global {
240240
opts?: FileCopyOpts;
241241
};
242242

243+
// wshrpc.CommandFileRestoreBackupData
244+
type CommandFileRestoreBackupData = {
245+
backupfilepath: string;
246+
restoretofilename: string;
247+
};
248+
243249
// wshrpc.CommandGetMetaData
244250
type CommandGetMetaData = {
245251
oref: ORef;

pkg/aiusechat/usechat.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,9 @@ var SystemPromptText_OpenAI = strings.Join([]string{
9292
`If a request would execute dangerous or destructive actions, warn briefly and provide a safer alternative.`,
9393
`If output is very long, prefer a brief summary plus a copy-ready fenced block or offer a follow-up chunking strategy.`,
9494

95-
`You cannot yet execute shell commands, run code in the terminal, or directly edit files on disk. You also cannot read remote files.`,
96-
`If the user asks you to do one of those things, say that these features arent available yet AND make sure to say that they are coming soon (stay tuned for updates).`,
97-
`Instead, show them exactly what command or code they could copy-paste to perform it manually.`,
95+
`You can write and edit local files on disk using available tools, but you cannot execute shell commands, run code in the terminal, or read/write remote files.`,
96+
`If the user asks you to execute commands or run code, or deal with remote files say that these features aren't available yet AND make sure to say that they are coming soon (stay tuned for updates).`,
97+
`Instead, show them exactly what command or code they could copy-paste to run manually.`,
9898

9999
// Final reminder
100100
`You have NO API access to widgets or Wave unless provided via an explicit tool.`,
@@ -349,7 +349,7 @@ func processToolCallInternal(toolCall uctypes.WaveToolCall, chatOpts uctypes.Wav
349349
_ = sseHandler.AiMsgData("data-tooluse", toolCall.ID, *toolCall.ToolUseData)
350350
updateToolUseDataInChat(chatOpts, toolCall.ID, toolCall.ToolUseData)
351351
}
352-
352+
353353
toolCall.ToolUseData.RunTs = time.Now().UnixMilli()
354354
result := ResolveToolCall(toolCall, chatOpts)
355355

pkg/filebackup/filebackup.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,42 @@ func MakeFileBackup(absFilePath string) (string, error) {
8787
return backupPath, nil
8888
}
8989

90+
func RestoreBackup(backupFilePath string, restoreToFileName string) error {
91+
backupData, err := os.ReadFile(backupFilePath)
92+
if err != nil {
93+
return fmt.Errorf("failed to read backup file: %w", err)
94+
}
95+
96+
metadataPath := backupFilePath[:len(backupFilePath)-4] + ".json"
97+
metadataData, err := os.ReadFile(metadataPath)
98+
if err != nil {
99+
return fmt.Errorf("failed to read backup metadata: %w", err)
100+
}
101+
102+
var metadata BackupMetadata
103+
err = json.Unmarshal(metadataData, &metadata)
104+
if err != nil {
105+
return fmt.Errorf("failed to unmarshal backup metadata: %w", err)
106+
}
107+
108+
if metadata.FullPath != restoreToFileName {
109+
return fmt.Errorf("backup metadata mismatch: expected %s, got %s", restoreToFileName, metadata.FullPath)
110+
}
111+
112+
var perm os.FileMode
113+
_, err = fmt.Sscanf(metadata.Perm, "%o", &perm)
114+
if err != nil {
115+
return fmt.Errorf("failed to parse file permissions: %w", err)
116+
}
117+
118+
err = os.WriteFile(restoreToFileName, backupData, perm)
119+
if err != nil {
120+
return fmt.Errorf("failed to restore file: %w", err)
121+
}
122+
123+
return nil
124+
}
125+
90126
func CleanupOldBackups() error {
91127
backupBaseDir := filepath.Join(wavebase.GetWaveCachesDir(), "waveai-backups")
92128

pkg/wshrpc/wshclient/wshclient.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,12 @@ func FileReadStreamCommand(w *wshutil.WshRpc, data wshrpc.FileData, opts *wshrpc
303303
return sendRpcRequestResponseStreamHelper[wshrpc.FileData](w, "filereadstream", data, opts)
304304
}
305305

306+
// command "filerestorebackup", wshserver.FileRestoreBackupCommand
307+
func FileRestoreBackupCommand(w *wshutil.WshRpc, data wshrpc.CommandFileRestoreBackupData, opts *wshrpc.RpcOpts) error {
308+
_, err := sendRpcRequestCallHelper[any](w, "filerestorebackup", data, opts)
309+
return err
310+
}
311+
306312
// command "filesharecapability", wshserver.FileShareCapabilityCommand
307313
func FileShareCapabilityCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (wshrpc.FileShareCapability, error) {
308314
resp, err := sendRpcRequestCallHelper[wshrpc.FileShareCapability](w, "filesharecapability", data, opts)

pkg/wshrpc/wshrpctypes.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ const (
8282
Command_FileAppendIJson = "fileappendijson"
8383
Command_FileJoin = "filejoin"
8484
Command_FileShareCapability = "filesharecapability"
85+
Command_FileRestoreBackup = "filerestorebackup"
8586

8687
Command_EventPublish = "eventpublish"
8788
Command_EventRecv = "eventrecv"
@@ -210,6 +211,7 @@ type WshRpcInterface interface {
210211
FileListStreamCommand(ctx context.Context, data FileListData) <-chan RespOrErrorUnion[CommandRemoteListEntriesRtnData]
211212

212213
FileShareCapabilityCommand(ctx context.Context, path string) (FileShareCapability, error)
214+
FileRestoreBackupCommand(ctx context.Context, data CommandFileRestoreBackupData) error
213215
EventPublishCommand(ctx context.Context, data wps.WaveEvent) error
214216
EventSubCommand(ctx context.Context, data wps.SubscriptionRequest) error
215217
EventUnsubCommand(ctx context.Context, data string) error
@@ -597,6 +599,11 @@ type CommandFileCopyData struct {
597599
Opts *FileCopyOpts `json:"opts,omitempty"`
598600
}
599601

602+
type CommandFileRestoreBackupData struct {
603+
BackupFilePath string `json:"backupfilepath"`
604+
RestoreToFileName string `json:"restoretofilename"`
605+
}
606+
600607
type CommandRemoteStreamTarData struct {
601608
Path string `json:"path"`
602609
Opts *FileCopyOpts `json:"opts,omitempty"`

0 commit comments

Comments
 (0)