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
1 change: 1 addition & 0 deletions docs/docs/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ wsh editconfig
| autoupdate:installonquit | bool | whether to automatically install updates on quit (requires app restart) |
| autoupdate:channel | string | the auto update channel "latest" (stable builds), or "beta" (updated more frequently) (requires app restart) |
| tab:preset | string | a "bg@" preset to automatically apply to new tabs. e.g. `bg@green`. should match the preset key |
| tab:confirmclose | bool | if set to true, a confirmation dialog will be shown before closing a tab (defaults to false) |
| widget:showhelp | bool | whether to show help/tips widgets in right sidebar |
| window:transparent | bool | set to true to enable window transparency (cannot be combined with `window:blur`) (macOS and Windows only, requires app restart, see [note on Windows compatibility](https://www.electronjs.org/docs/latest/tutorial/custom-window-styles#limitations)) |
| window:blur | bool | set to enable window background blurring (cannot be combined with `window:transparent`) (macOS and Windows only, requires app restart, see [note on Windows compatibility](https://www.electronjs.org/docs/latest/tutorial/custom-window-styles#limitations)) |
Expand Down
20 changes: 16 additions & 4 deletions emain/emain-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -728,15 +728,27 @@ ipcMain.on("set-waveai-open", (event, isOpen: boolean) => {
}
});

ipcMain.on("close-tab", async (event, workspaceId, tabId) => {
ipcMain.handle("close-tab", async (event, workspaceId: string, tabId: string, confirmClose: boolean) => {
const ww = getWaveWindowByWorkspaceId(workspaceId);
if (ww == null) {
console.log(`close-tab: no window found for workspace ws=${workspaceId} tab=${tabId}`);
return;
return false;
}
if (confirmClose) {
const choice = dialog.showMessageBoxSync(ww, {
type: "question",
defaultId: 1, // Enter activates "Close Tab"
cancelId: 0, // Esc activates "Cancel"
buttons: ["Cancel", "Close Tab"],
title: "Confirm",
message: "Are you sure you want to close this tab?",
});
Comment on lines +738 to +745
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 | 🟡 Minor

defaultId: 1 makes the destructive action the Enter-key default — inconsistent with other dialogs.

Every other confirmation dialog in this file omits defaultId (Line 303 window-close, Line 790 delete-workspace), letting it default to index 0 ("Cancel") — the safe option. Here defaultId: 1 explicitly maps Enter to "Close Tab", the destructive action.

🛡️ Proposed fix — make Cancel the Enter-key default, keep Escape as Cancel
         const choice = dialog.showMessageBoxSync(ww, {
             type: "question",
-            defaultId: 1, // Enter activates "Close Tab"
             cancelId: 0, // Esc activates "Cancel"
             buttons: ["Cancel", "Close Tab"],
📝 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
const choice = dialog.showMessageBoxSync(ww, {
type: "question",
defaultId: 1, // Enter activates "Close Tab"
cancelId: 0, // Esc activates "Cancel"
buttons: ["Cancel", "Close Tab"],
title: "Confirm",
message: "Are you sure you want to close this tab?",
});
const choice = dialog.showMessageBoxSync(ww, {
type: "question",
cancelId: 0, // Esc activates "Cancel"
buttons: ["Cancel", "Close Tab"],
title: "Confirm",
message: "Are you sure you want to close this tab?",
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@emain/emain-window.ts` around lines 738 - 745, The dialog created by
dialog.showMessageBoxSync currently sets defaultId: 1, making Enter activate the
destructive "Close Tab" button; change this to make "Cancel" the Enter-key
default by removing the defaultId property or setting defaultId: 0 in the
dialog.showMessageBoxSync call, keep cancelId: 0 and the buttons ["Cancel",
"Close Tab"] unchanged so Escape still maps to Cancel.

if (choice === 0) {
return false;
}
}
await ww.queueCloseTab(tabId);
event.returnValue = true;
return null;
return true;
Comment on lines 750 to +751
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

return true doesn't reflect whether the tab was actually closed.

queueCloseTab (via _queueActionInternal) resolves in two ways that don't guarantee close success:

  1. Queue non-empty (actionQueue.length >= 1 when called): _queueActionInternal pushes the entry and returns without awaiting processActionQueue. So queueCloseTab resolves before the close even runs.
  2. Silent failure in processActionQueue: when WorkspaceService.CloseTab returns null, processActionQueue does an early return (not throw), so the awaited promise resolves normally with no indication of failure.

In both cases the IPC handler returns true. The frontend's handleCloseTab then calls deleteLayoutModelForTab(tabId) for a tab that wasn't actually closed. When the workspace subscription pushes the next update, the tab re-appears but its in-memory layout model has already been evicted.

A minimal fix is to have queueCloseTab / processActionQueue surface success/failure — or, at minimum, defer return true only when the queue was empty and processing completed without early-exit. Alternatively, skip deleting the layout model here and rely solely on the workspace-update subscription to trigger cleanup.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@emain/emain-window.ts` around lines 748 - 749, queueCloseTab currently
returns true before the close is guaranteed to have completed (via
_queueActionInternal/processActionQueue) and hides failures when
WorkspaceService.CloseTab returns null; change the flow so processActionQueue
returns a boolean success flag (true only when the close action executed and
WorkspaceService.CloseTab returned a non-null success), propagate that boolean
through _queueActionInternal to queueCloseTab, and have handleCloseTab call
deleteLayoutModelForTab(tabId) only when the propagated success is true;
alternatively, if you prefer not to change return values broadly, make
queueCloseTab await completion of processing when the queue was empty and only
return true then (and return false on the early-exit/null result from
WorkspaceService.CloseTab) so callers see actual close success.

});

ipcMain.on("switch-workspace", (event, workspaceId) => {
Expand Down
2 changes: 1 addition & 1 deletion emain/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ contextBridge.exposeInMainWorld("api", {
deleteWorkspace: (workspaceId) => ipcRenderer.send("delete-workspace", workspaceId),
setActiveTab: (tabId) => ipcRenderer.send("set-active-tab", tabId),
createTab: () => ipcRenderer.send("create-tab"),
closeTab: (workspaceId, tabId) => ipcRenderer.send("close-tab", workspaceId, tabId),
closeTab: (workspaceId, tabId, confirmClose) => ipcRenderer.invoke("close-tab", workspaceId, tabId, confirmClose),
setWindowInitStatus: (status) => ipcRenderer.send("set-window-init-status", status),
onWaveInit: (callback) => ipcRenderer.on("wave-init", (_event, initOpts) => callback(initOpts)),
onBuilderInit: (callback) => ipcRenderer.on("builder-init", (_event, initOpts) => callback(initOpts)),
Expand Down
27 changes: 25 additions & 2 deletions frontend/app/store/keymodel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,17 @@ function getStaticTabBlockCount(): number {
function simpleCloseStaticTab() {
const ws = globalStore.get(atoms.workspace);
const tabId = globalStore.get(atoms.staticTabId);
getApi().closeTab(ws.oid, tabId);
deleteLayoutModelForTab(tabId);
const confirmClose = globalStore.get(getSettingsKeyAtom("tab:confirmclose")) ?? false;
getApi()
.closeTab(ws.oid, tabId, confirmClose)
.then((didClose) => {
if (didClose) {
deleteLayoutModelForTab(tabId);
}
})
.catch((e) => {
console.log("error closing tab", e);
});
Copy link
Contributor

Choose a reason for hiding this comment

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

WARNING: Missing error handling for promise rejection

The closeTab promise should have a .catch() handler to handle potential IPC communication failures. If the Electron IPC call fails, this will result in an unhandled promise rejection.

Consider adding:

.catch((error) => {
    console.error("Failed to close tab:", error);
});

}

function uxCloseBlock(blockId: string) {
Expand All @@ -151,6 +160,13 @@ function uxCloseBlock(blockId: string) {
const blockData = globalStore.get(blockAtom);
const isAIFileDiff = blockData?.meta?.view === "aifilediff";

// If this is the last block, closing it will close the tab — route through simpleCloseStaticTab
// so the tab:confirmclose setting is respected.
if (getStaticTabBlockCount() === 1) {
simpleCloseStaticTab();
return;
}

const layoutModel = getLayoutModelForStaticTab();
const node = layoutModel.getNodeByBlockId(blockId);
if (node) {
Expand Down Expand Up @@ -190,6 +206,13 @@ function genericClose() {
return;
}

// If this is the last block, closing it will close the tab — route through simpleCloseStaticTab
// so the tab:confirmclose setting is respected.
if (blockCount === 1) {
simpleCloseStaticTab();
return;
}

const layoutModel = getLayoutModelForStaticTab();
const focusedNode = globalStore.get(layoutModel.focusedNode);
const blockId = focusedNode?.data?.blockId;
Expand Down
15 changes: 12 additions & 3 deletions frontend/app/tab/tabbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -592,9 +592,18 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
const handleCloseTab = (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null, tabId: string) => {
event?.stopPropagation();
const ws = globalStore.get(atoms.workspace);
getApi().closeTab(ws.oid, tabId);
tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.3s ease");
deleteLayoutModelForTab(tabId);
const confirmClose = globalStore.get(getSettingsKeyAtom("tab:confirmclose")) ?? false;
getApi()
.closeTab(ws.oid, tabId, confirmClose)
.then((didClose) => {
if (didClose) {
tabsWrapperRef.current?.style.setProperty("--tabs-wrapper-transition", "width 0.3s ease");
deleteLayoutModelForTab(tabId);
}
})
.catch((e) => {
console.log("error closing tab", e);
});
Copy link
Contributor

Choose a reason for hiding this comment

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

WARNING: Missing error handling for promise rejection

The closeTab promise should have a .catch() handler to handle potential IPC communication failures. If the Electron IPC call fails, this will result in an unhandled promise rejection.

Consider adding:

.catch((error) => {
    console.error("Failed to close tab:", error);
});

};

const handleTabLoaded = useCallback((tabId: string) => {
Expand Down
2 changes: 1 addition & 1 deletion frontend/types/custom.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ declare global {
deleteWorkspace: (workspaceId: string) => void; // delete-workspace
setActiveTab: (tabId: string) => void; // set-active-tab
createTab: () => void; // create-tab
closeTab: (workspaceId: string, tabId: string) => void; // close-tab
closeTab: (workspaceId: string, tabId: string, confirmClose: boolean) => Promise<boolean>; // close-tab
setWindowInitStatus: (status: "ready" | "wave-ready") => void; // set-window-init-status
onWaveInit: (callback: (initOpts: WaveInitOpts) => void) => void; // wave-init
onBuilderInit: (callback: (initOpts: BuilderInitOpts) => void) => void; // builder-init
Expand Down
1 change: 1 addition & 0 deletions frontend/types/gotypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1306,6 +1306,7 @@ declare global {
"markdown:fixedfontsize"?: number;
"preview:showhiddenfiles"?: boolean;
"tab:preset"?: string;
"tab:confirmclose"?: boolean;
"widget:*"?: boolean;
"widget:showhelp"?: boolean;
"window:*"?: boolean;
Expand Down
1 change: 1 addition & 0 deletions pkg/wconfig/metaconsts.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ const (
ConfigKey_PreviewShowHiddenFiles = "preview:showhiddenfiles"

ConfigKey_TabPreset = "tab:preset"
ConfigKey_TabConfirmClose = "tab:confirmclose"

ConfigKey_WidgetClear = "widget:*"
ConfigKey_WidgetShowHelp = "widget:showhelp"
Expand Down
3 changes: 2 additions & 1 deletion pkg/wconfig/settingsconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,8 @@ type SettingsType struct {

PreviewShowHiddenFiles *bool `json:"preview:showhiddenfiles,omitempty"`

TabPreset string `json:"tab:preset,omitempty"`
TabPreset string `json:"tab:preset,omitempty"`
TabConfirmClose bool `json:"tab:confirmclose,omitempty"`

WidgetClear bool `json:"widget:*,omitempty"`
WidgetShowHelp *bool `json:"widget:showhelp,omitempty"`
Expand Down
3 changes: 3 additions & 0 deletions schema/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,9 @@
"tab:preset": {
"type": "string"
},
"tab:confirmclose": {
"type": "boolean"
},
"widget:*": {
"type": "boolean"
},
Expand Down
Loading