Skip to content
Open
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
62 changes: 62 additions & 0 deletions cmd/wsh/cmd/wshcmd-done.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

package cmd

import (
"fmt"
"os"

"github.com/spf13/cobra"
"github.com/wavetermdev/waveterm/pkg/wps"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
)

var doneExitCode int
var doneTitle string
var doneMessage string

var doneCmd = &cobra.Command{
Use: "done [-t title] [-m message] [-e exitcode]",
Short: "Signal that a command has finished (triggers notification sound, highlight, and OS notification for background blocks)",
Args: cobra.MaximumNArgs(1),
RunE: doneRun,
PreRunE: preRunSetupRpcClient,
}

func init() {
doneCmd.Flags().IntVarP(&doneExitCode, "exitcode", "e", 0, "exit code of the completed command")
doneCmd.Flags().StringVarP(&doneTitle, "title", "t", "", "notification title (default: Command Finished)")
doneCmd.Flags().StringVarP(&doneMessage, "message", "m", "", "notification message")
rootCmd.AddCommand(doneCmd)
}

func doneRun(cmd *cobra.Command, args []string) (rtnErr error) {
defer func() {
sendActivity("done", rtnErr == nil)
}()
blockId := os.Getenv("WAVETERM_BLOCKID")
if blockId == "" {
return fmt.Errorf("WAVETERM_BLOCKID not set, must be run inside a Wave terminal block")
}

if doneMessage == "" && len(args) > 0 {
doneMessage = args[0]
}

err := wshclient.EventPublishCommand(RpcClient, wps.WaveEvent{
Event: wps.Event_BlockDone,
Scopes: []string{fmt.Sprintf("block:%s", blockId)},
Data: wshrpc.BlockDoneEventData{
BlockId: blockId,
ExitCode: doneExitCode,
Title: doneTitle,
Message: doneMessage,
},
}, &wshrpc.RpcOpts{NoResponse: true})
if err != nil {
return fmt.Errorf("done command: %w", err)
}
return nil
}
26 changes: 26 additions & 0 deletions emain/emain-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,32 @@ export function initIpcHandlers() {
bw.destroy();
});

electron.ipcMain.on(
"show-completion-notification",
(event, tabId: string, blockId: string, title: string, body: string) => {
const senderWcId = event.sender.id;
const { Notification: ElectronNotification } = electron;
if (ElectronNotification == null || !ElectronNotification.isSupported()) {
return;
}
const notification = new ElectronNotification({ title, body, silent: false });
notification.on("click", () => {
const ww = getWaveWindowByWebContentsId(senderWcId);
if (ww == null) return;
if (ww.isMinimized()) {
ww.restore();
}
ww.focus();
ww.setActiveTab(tabId, false);
const tabView = ww.allLoadedTabViews.get(tabId);
if (tabView) {
tabView.webContents.send("focus-block", blockId);
}
Comment on lines +523 to +527
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Await tab switch completion before sending focus-block.

Line 523 triggers an async tab switch, but Lines 524-527 execute immediately. If the tab isn’t loaded yet, notification click won’t focus the target block.

Suggested fix
-            notification.on("click", () => {
+            notification.on("click", async () => {
                 const ww = getWaveWindowByWebContentsId(senderWcId);
                 if (ww == null) return;
                 if (ww.isMinimized()) {
                     ww.restore();
                 }
                 ww.focus();
-                ww.setActiveTab(tabId, false);
+                try {
+                    await ww.setActiveTab(tabId, false);
+                } catch (err) {
+                    console.error("Failed to activate tab from completion notification:", err);
+                    return;
+                }
                 const tabView = ww.allLoadedTabViews.get(tabId);
-                if (tabView) {
-                    tabView.webContents.send("focus-block", blockId);
-                }
+                tabView?.webContents.send("focus-block", blockId);
             });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@emain/emain-ipc.ts` around lines 523 - 527, The code calls
ww.setActiveTab(tabId, false) synchronously then immediately reads
ww.allLoadedTabViews and sends "focus-block", which races with the async tab
activation/load; update the flow to wait for the tab switch/load to complete
before sending the message — either await the promise returned by
ww.setActiveTab(tabId, false) if it returns one, or attach a one-time listener
for the tab-ready event emitted by the window manager (e.g. a "tab-activated" /
"tab-loaded" event) and then retrieve ww.allLoadedTabViews.get(tabId) and call
tabView.webContents.send("focus-block", blockId) only after that completion
signal; reference ww.setActiveTab, ww.allLoadedTabViews, and
tabView.webContents.send("focus-block", ...) when making the change.

});
notification.show();
}
);

electron.ipcMain.on("do-refresh", (event) => {
event.sender.reloadIgnoringCache();
});
Expand Down
4 changes: 4 additions & 0 deletions emain/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ contextBridge.exposeInMainWorld("api", {
getPathForFile: (file: File): string => webUtils.getPathForFile(file),
saveTextFile: (fileName: string, content: string) => ipcRenderer.invoke("save-text-file", fileName, content),
setIsActive: () => ipcRenderer.invoke("set-is-active"),
showCompletionNotification: (tabId, blockId, title, body) =>
ipcRenderer.send("show-completion-notification", tabId, blockId, title, body),
onFocusBlock: (callback) =>
ipcRenderer.on("focus-block", (_event, blockId) => callback(blockId)),
Comment on lines +76 to +79
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the main-process IPC handler for show-completion-notification to confirm any length/sanitization handling.
rg -nP -C3 'show-completion-notification' --type=ts

Repository: wavetermdev/waveterm

Length of output: 1764


🏁 Script executed:

sed -n '507,530p' emain/emain-ipc.ts

Repository: wavetermdev/waveterm

Length of output: 1108


🏁 Script executed:

rg -nP 'showCompletionNotification\(' --type=ts

Repository: wavetermdev/waveterm

Length of output: 202


🏁 Script executed:

sed -n '610,635p' frontend/app/view/term/term-model.ts

Repository: wavetermdev/waveterm

Length of output: 1212


🏁 Script executed:

sed -n '570,625p' frontend/app/view/term/term-model.ts

Repository: wavetermdev/waveterm

Length of output: 2277


Consistent with existing preload patterns; one heads-up on downstream handling.

The wiring is correct and matches the conventions used by neighboring API methods (sync send for fire-and-forget, ipcRenderer.on for subscriptions).

One thing to double-check on the main side: the body argument carries arbitrary terminal output (from getLastTerminalLine() in term-model.ts). The handler in emain-ipc.ts currently passes this directly to Electron's Notification constructor without sanitization or length checks. A runaway command could output very long strings or problematic content that then flows into the OS notification subsystem—consider truncating or stripping the body before constructing the Notification.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@emain/preload.ts` around lines 76 - 79, The preload exposes
showCompletionNotification(tabId, blockId, title, body) which forwards the raw
body (often from getLastTerminalLine()) to the main IPC handler that constructs
an OS Notification; update the main-side "show-completion-notification" handler
(the listener in emain-ipc.ts) to sanitize and truncate the body before calling
new Notification — strip control/ANSI sequences, collapse excessive whitespace,
enforce a safe max length (e.g., ~200 chars) and provide a short fallback like
"[output trimmed]" if truncated or empty; keep the preload's
showCompletionNotification unchanged but document that bodies are clipped by the
main handler.

});

// Custom event for "new-window"
Expand Down
33 changes: 29 additions & 4 deletions frontend/app/block/block-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ export interface BlockHighlightType {
export class BlockModel {
private static instance: BlockModel | null = null;
private blockHighlightAtomCache = new Map<string, jotai.Atom<BlockHighlightType | null>>();
private completionHighlightAtomCache = new Map<string, jotai.Atom<number | null>>();

blockHighlightAtom: jotai.PrimitiveAtom<BlockHighlightType> = jotai.atom(null) as jotai.PrimitiveAtom<BlockHighlightType>;
completionHighlightAtom: jotai.PrimitiveAtom<Map<string, number>> = jotai.atom(new Map<string, number>());

private constructor() {
// Empty for now
}
private constructor() {}

getBlockHighlightAtom(blockId: string): jotai.Atom<BlockHighlightType | null> {
let atom = this.blockHighlightAtomCache.get(blockId);
Expand All @@ -38,6 +38,31 @@ export class BlockModel {
globalStore.set(this.blockHighlightAtom, highlight);
}

getCompletionHighlightAtom(blockId: string): jotai.Atom<number | null> {
let atom = this.completionHighlightAtomCache.get(blockId);
if (!atom) {
atom = jotai.atom((get) => {
const map = get(this.completionHighlightAtom);
return map.get(blockId) ?? null;
});
this.completionHighlightAtomCache.set(blockId, atom);
}
return atom;
}

setCompletionHighlight(blockId: string, exitCode: number) {
const currentMap = new Map(globalStore.get(this.completionHighlightAtom));
currentMap.set(blockId, exitCode);
globalStore.set(this.completionHighlightAtom, currentMap);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

clearCompletionHighlight(blockId: string) {
const currentMap = new Map(globalStore.get(this.completionHighlightAtom));
if (!currentMap.has(blockId)) return;
currentMap.delete(blockId);
globalStore.set(this.completionHighlightAtom, currentMap);
}

static getInstance(): BlockModel {
if (!BlockModel.instance) {
BlockModel.instance = new BlockModel();
Expand All @@ -48,4 +73,4 @@ export class BlockModel {
static resetInstance(): void {
BlockModel.instance = null;
}
}
}
4 changes: 4 additions & 0 deletions frontend/app/block/block.scss
Original file line number Diff line number Diff line change
Expand Up @@ -444,5 +444,9 @@
}
}
}

.block-mask.completion-highlight {
transition: border-color 0.3s ease-out, box-shadow 0.3s ease-out;
}
}
}
14 changes: 12 additions & 2 deletions frontend/app/block/blockframe-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { uxCloseBlock } from "@/app/store/keymodel";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { useWaveEnv } from "@/app/waveenv/waveenv";
import { IconButton } from "@/element/iconbutton";
import { NodeModel } from "@/layout/index";
import { NodeModel, getLayoutModelForStaticTab } from "@/layout/index";
import * as util from "@/util/util";
import { cn, makeIconClass } from "@/util/util";
import * as jotai from "jotai";
Expand Down Expand Up @@ -281,7 +281,17 @@ const BlockFrame_Header = ({
/>
)}
{useTermHeader && badge && (
<div className="pointer-events-none flex items-center px-1" style={{ color: badge.color || "#fbbf24" }}>
<div
className="flex cursor-pointer items-center px-1"
style={{ color: badge.color || "#fbbf24" }}
onClick={() => {
const layoutModel = getLayoutModelForStaticTab();
const node = layoutModel?.getNodeByBlockId(nodeModel.blockId);
if (node?.id) {
layoutModel.focusNode(node.id);
}
}}
>
<i className={makeIconClass(badge.icon, true, { defaultIcon: "circle-small" })} />
</div>
)}
Expand Down
23 changes: 22 additions & 1 deletion frontend/app/block/blockframe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { BlockFrame_Header } from "@/app/block/blockframe-header";
import { blockViewToIcon, getViewIconElem, useTabBackground } from "@/app/block/blockutil";
import { ConnStatusOverlay } from "@/app/block/connstatusoverlay";
import { ChangeConnectionBlockModal } from "@/app/modals/conntypeahead";
import { clearBadgesForBlockOnFocus } from "@/app/store/badge";
import { getBlockComponentModel, globalStore, useBlockAtom } from "@/app/store/global";
import { useTabModel } from "@/app/store/tab-model";
import { TabRpcClient } from "@/app/store/wshrpcutil";
Expand All @@ -32,6 +33,9 @@ const BlockMask = React.memo(({ nodeModel }: { nodeModel: NodeModel }) => {
const isLayoutMode = jotai.useAtomValue(waveEnv.atoms.controlShiftDelayAtom);
const showOverlayBlockNums = jotai.useAtomValue(waveEnv.getSettingsKeyAtom("app:showoverlayblocknums")) ?? true;
const blockHighlight = jotai.useAtomValue(BlockModel.getInstance().getBlockHighlightAtom(nodeModel.blockId));
const completionHighlight = jotai.useAtomValue(
BlockModel.getInstance().getCompletionHighlightAtom(nodeModel.blockId)
);
const frameActiveBorderColor = jotai.useAtomValue(
waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "frame:activebordercolor")
);
Expand Down Expand Up @@ -63,6 +67,19 @@ const BlockMask = React.memo(({ nodeModel }: { nodeModel: NodeModel }) => {
style.borderColor = "rgb(59, 130, 246)";
}

if (completionHighlight != null && !isFocused) {
const highlightColor = completionHighlight === 0 ? "rgb(59, 130, 246)" : "rgb(239, 68, 68)";
style.borderColor = highlightColor;
style.boxShadow = `0 0 8px 2px ${highlightColor}`;
}

React.useEffect(() => {
if (isFocused && completionHighlight != null) {
BlockModel.getInstance().clearCompletionHighlight(nodeModel.blockId);
clearBadgesForBlockOnFocus(nodeModel.blockId);
}
}, [isFocused, completionHighlight]);

let innerElem = null;
if (isLayoutMode && showOverlayBlockNums) {
showBlockMask = true;
Expand All @@ -83,7 +100,11 @@ const BlockMask = React.memo(({ nodeModel }: { nodeModel: NodeModel }) => {

return (
<div
className={clsx("block-mask", { "show-block-mask": showBlockMask, "bg-blue-500/10": blockHighlight })}
className={clsx("block-mask", {
"show-block-mask": showBlockMask,
"bg-blue-500/10": blockHighlight,
"completion-highlight": completionHighlight != null && !isFocused,
})}
style={style}
>
{innerElem}
Expand Down
82 changes: 82 additions & 0 deletions frontend/app/view/term/term-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
// SPDX-License-Identifier: Apache-2.0

import { WaveAIModel } from "@/app/aipanel/waveai-model";
import { BlockModel } from "@/app/block/block-model";
import { BlockNodeModel } from "@/app/block/blocktypes";
import { appHandleKeyDown } from "@/app/store/keymodel";
import { FocusManager } from "@/app/store/focusManager";
import { modalsModel } from "@/app/store/modalmodel";
import { setBadge } from "@/app/store/badge";
import type { TabModel } from "@/app/store/tab-model";
import { waveEventSubscribeSingle } from "@/app/store/wps";
import { RpcApi } from "@/app/store/wshclientapi";
Expand All @@ -14,6 +17,7 @@ import { TermClaudeIcon, TerminalView } from "@/app/view/term/term";
import { TermWshClient } from "@/app/view/term/term-wsh";
import { VDomModel } from "@/app/view/vdom/vdom-model";
import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
import { getLayoutModelForStaticTab } from "@/layout/lib/layoutModelHooks";
import {
atoms,
createBlock,
Expand Down Expand Up @@ -73,6 +77,7 @@ export class TermViewModel implements ViewModel {
shellProcFullStatus: jotai.PrimitiveAtom<BlockControllerRuntimeStatus>;
shellProcStatus: jotai.Atom<string>;
shellProcStatusUnsubFn: () => void;
blockDoneUnsubFn: () => void;
blockJobStatusAtom: jotai.PrimitiveAtom<BlockJobStatusData>;
blockJobStatusVersionTs: number;
blockJobStatusUnsubFn: () => void;
Expand Down Expand Up @@ -346,6 +351,13 @@ export class TermViewModel implements ViewModel {
this.updateShellProcStatus(event.data);
},
});
this.blockDoneUnsubFn = waveEventSubscribeSingle({
eventType: "block:done",
scope: WOS.makeORef("block", blockId),
handler: (event) => {
this.handleBlockDoneEvent(event.data);
},
});
this.shellProcStatus = jotai.atom((get) => {
const fullStatus = get(this.shellProcFullStatus);
return fullStatus?.shellprocstatus ?? "init";
Expand Down Expand Up @@ -565,6 +577,75 @@ export class TermViewModel implements ViewModel {
}
}

getLastTerminalLine(): string {
const term = this.termRef.current?.terminal;
if (term == null) return "";
const buf = term.buffer.active;
const start = Math.max(0, buf.length - 200);
for (let i = buf.length - 1; i >= start; i--) {
const line = buf.getLine(i);
if (line == null) continue;
const text = line.translateToString(true).trim();
if (text.length > 0) return text;
}
return "";
}

handleBlockDoneEvent(data: BlockDoneEventData) {
if (data == null || data.blockid !== this.blockId) {
return;
}
const exitCode = data.exitcode ?? 0;
const title = data.title || (exitCode === 0 ? "Command Finished" : "Command Failed");
let body = data.message;
if (!body) {
body = this.getLastTerminalLine() || `exit code ${exitCode}`;
}
this.triggerCompletionNotifications(exitCode, title, body);
}

triggerCompletionNotifications(exitCode: number, title: string, notifyBody?: string) {
const focusManager = FocusManager.getInstance();
const focusedBlockId = globalStore.get(focusManager.blockFocusAtom);
if (focusedBlockId === this.blockId) {
return;
}

const doneSoundEnabled = globalStore.get(getOverrideConfigAtom(this.blockId, "term:donesound")) ?? true;
if (doneSoundEnabled) {
fireAndForget(() =>
RpcApi.ElectronSystemBellCommand(TabRpcClient, { route: "electron" })
);
}

const doneNotifyEnabled = globalStore.get(getOverrideConfigAtom(this.blockId, "term:donenotify")) ?? true;
if (doneNotifyEnabled) {
const body = notifyBody || `exit code ${exitCode}`;
getApi().showCompletionNotification(this.tabModel.tabId, this.blockId, title, body);
}

const doneAutoFocusEnabled = globalStore.get(getOverrideConfigAtom(this.blockId, "term:doneautofocus")) ?? false;
if (doneAutoFocusEnabled) {
getApi().setActiveTab(this.tabModel.tabId);
setTimeout(() => {
const layoutModel = getLayoutModelForStaticTab();
const node = layoutModel?.getNodeByBlockId(this.blockId);
if (node?.id) {
layoutModel.focusNode(node.id);
}
}, 150);
}

BlockModel.getInstance().setCompletionHighlight(this.blockId, exitCode);

setBadge(this.blockId, {
badgeid: `done-${this.blockId}`,
icon: "bell",
color: exitCode === 0 ? "#3b82f6" : "#ef4444",
priority: 5,
});
}
Comment on lines +607 to +647
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify wsh done CLI semantics for -t and -m flags and how empty values are handled.
fd -t f 'wshcmd-done.go' | xargs -I{} cat {}

Repository: wavetermdev/waveterm

Length of output: 1805


🏁 Script executed:

rg -n "block:done" --type ts --type tsx -B 5 -A 15

Repository: wavetermdev/waveterm

Length of output: 92


🏁 Script executed:

rg "block:done" -B 5 -A 15 --type ts

Repository: wavetermdev/waveterm

Length of output: 3736


🏁 Script executed:

rg "handleBlockDoneEvent" -B 2 -A 20 --type ts

Repository: wavetermdev/waveterm

Length of output: 3691


🏁 Script executed:

rg "termThemeNameAtom|termTransparencyAtom" -B 3 -A 3 --type ts

Repository: wavetermdev/waveterm

Length of output: 3998


🏁 Script executed:

rg "useBlockAtom" -B 2 -A 10 --type ts | head -60

Repository: wavetermdev/waveterm

Length of output: 4784


🏁 Script executed:

rg "getLayoutModelForStaticTab" -B 3 -A 8 --type ts

Repository: wavetermdev/waveterm

Length of output: 35136


🏁 Script executed:

cat -n frontend/app/view/term/term-model.ts | sed -n '593,610p'

Repository: wavetermdev/waveterm

Length of output: 978


🏁 Script executed:

rg "setActiveTab" -B 2 -A 5 --type ts | head -40

Repository: wavetermdev/waveterm

Length of output: 2425


🏁 Script executed:

rg "staticTabId" -B 2 -A 2 --type ts | head -50

Repository: wavetermdev/waveterm

Length of output: 3569


🏁 Script executed:

rg "onFocusBlock" -B 5 -A 10 --type ts

Repository: wavetermdev/waveterm

Length of output: 4387


A few concerns in triggerCompletionNotifications.

  • getOverrideConfigAtom(this.blockId, "term:donesound" | "term:donenotify" | "term:doneautofocus") is invoked three separate times per completion event (lines 613, 620, 626). getOverrideConfigAtom constructs a derived atom on each call; the established pattern elsewhere in this file (see termThemeNameAtom, termTransparencyAtom) is to cache via useBlockAtom. For an event handler the impact is small, but consider hoisting these reads or using readAtom consistently.
  • The autofocus block (lines 627-636) calls getApi().setActiveTab(this.tabModel.tabId) and then schedules getLayoutModelForStaticTab().focusNode(...) 150 ms later. getLayoutModelForStaticTab() resolves against the currently active static tab, so this is racy across tabs (the tab switch may not yet have propagated, especially with reinit). The IPC onFocusBlock handler in wave.ts already does block focus correctly via the main process and is used for notification clicks; consider routing autofocus through the same mechanism (e.g. emit a focus-block IPC after setActiveTab) to keep both paths consistent and avoid the timing hack.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/app/view/term/term-model.ts` around lines 606 - 646,
triggerCompletionNotifications is repeatedly constructing derived atoms and
using a racy setTimeout-based autofocus; hoist/cache the three
getOverrideConfigAtom reads (term:donesound, term:donenotify,
term:doneautofocus) into local variables by reading the atoms once (use the same
pattern as termThemeNameAtom/termTransparencyAtom via readAtom or useBlockAtom)
before branching, and replace the setActiveTab + setTimeout +
getLayoutModelForStaticTab.focusNode logic with a single IPC-based focus action:
call getApi().setActiveTab(this.tabModel.tabId) and then emit the same
focus-block IPC used by wave.ts's onFocusBlock handler to focus this.blockId (so
focus is performed by the main/process handler rather than relying on timing).


getVDomModel(): VDomModel {
const vdomBlockId = globalStore.get(this.vdomBlockId);
if (!vdomBlockId) {
Expand Down Expand Up @@ -592,6 +673,7 @@ export class TermViewModel implements ViewModel {
dispose() {
DefaultRouter.unregisterRoute(makeFeBlockRouteId(this.blockId));
this.shellProcStatusUnsubFn?.();
this.blockDoneUnsubFn?.();
this.blockJobStatusUnsubFn?.();
this.termBPMUnsubFn?.();
this.termCursorUnsubFn?.();
Expand Down
2 changes: 2 additions & 0 deletions frontend/preview/mock/preview-electron-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ const previewElectronApi: ElectronApi = {
doRefresh: () => {},
saveTextFile: (_fileName: string, _content: string) => Promise.resolve(false),
setIsActive: async () => {},
showCompletionNotification: (_tabId: string, _blockId: string, _title: string, _body: string) => {},
onFocusBlock: (_callback: (blockId: string) => void) => {},
};

function installPreviewElectronApi() {
Expand Down
2 changes: 2 additions & 0 deletions frontend/types/custom.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ declare global {
getPathForFile: (file: File) => string; // webUtils.getPathForFile
saveTextFile: (fileName: string, content: string) => Promise<boolean>; // save-text-file
setIsActive: () => Promise<void>; // set-is-active
showCompletionNotification: (tabId: string, blockId: string, title: string, body: string) => void; // show-completion-notification
onFocusBlock: (callback: (blockId: string) => void) => void; // focus-block
};

type ElectronContextMenuItem = {
Expand Down
Loading