Skip to content

Commit 2463e54

Browse files
authored
Make Block Close / Cmd-W more consistent (#2417)
Don't allow tabs with active Wave AI sessions to get closed when we close the last block. Have Cmd-W close Wave AI if it is focused (rather than a random node). Also fixes some lurking bugs with the pinned tab functionality (and adds some nice visual feedback when we try to close a pinned tab).
1 parent fa19d7c commit 2463e54

File tree

11 files changed

+204
-39
lines changed

11 files changed

+204
-39
lines changed

frontend/app/aipanel/aipanel.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,7 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => {
354354
// sendMessage uses UIMessageParts
355355
sendMessage({ parts: uiMessageParts });
356356

357+
model.isChatEmpty = false;
357358
globalStore.set(model.inputAtom, "");
358359
model.clearFiles();
359360

frontend/app/aipanel/waveai-model.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export class WaveAIModel {
3434
containerWidth: jotai.PrimitiveAtom<number> = jotai.atom(0);
3535
codeBlockMaxWidth!: jotai.Atom<number>;
3636
inputAtom: jotai.PrimitiveAtom<string> = jotai.atom("");
37+
isChatEmpty: boolean = true;
3738

3839
private constructor() {
3940
const tabId = globalStore.get(atoms.staticTabId);
@@ -127,6 +128,7 @@ export class WaveAIModel {
127128

128129
clearChat() {
129130
this.clearFiles();
131+
this.isChatEmpty = true;
130132
const newChatId = crypto.randomUUID();
131133
globalStore.set(this.chatId, newChatId);
132134

@@ -166,6 +168,11 @@ export class WaveAIModel {
166168
}
167169
}
168170

171+
hasNonEmptyInput(): boolean {
172+
const input = globalStore.get(this.inputAtom);
173+
return input != null && input.trim().length > 0;
174+
}
175+
169176
setModel(model: string) {
170177
const tabId = globalStore.get(atoms.staticTabId);
171178
RpcApi.SetMetaCommand(TabRpcClient, {
@@ -186,7 +193,9 @@ export class WaveAIModel {
186193
const chatId = globalStore.get(this.chatId);
187194
try {
188195
const chatData = await RpcApi.GetWaveAIChatCommand(TabRpcClient, { chatid: chatId });
189-
return chatData?.messages ?? [];
196+
const messages = chatData?.messages ?? [];
197+
this.isChatEmpty = messages.length === 0;
198+
return messages;
190199
} catch (error) {
191200
console.error("Failed to load chat:", error);
192201
this.setError("Failed to load chat. Starting new chat...");
@@ -200,6 +209,7 @@ export class WaveAIModel {
200209
meta: { "waveai:chatid": newChatId },
201210
});
202211

212+
this.isChatEmpty = true;
203213
return [];
204214
}
205215
}

frontend/app/block/blockframe.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
useBlockAtom,
1717
WOS,
1818
} from "@/app/store/global";
19+
import { uxCloseBlock } from "@/app/store/keymodel";
1920
import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
2021
import { RpcApi } from "@/app/store/wshclientapi";
2122
import { TabRpcClient } from "@/app/store/wshrpcutil";
@@ -40,8 +41,7 @@ function handleHeaderContextMenu(
4041
blockData: Block,
4142
viewModel: ViewModel,
4243
magnified: boolean,
43-
onMagnifyToggle: () => void,
44-
onClose: () => void
44+
onMagnifyToggle: () => void
4545
) {
4646
e.preventDefault();
4747
e.stopPropagation();
@@ -77,7 +77,7 @@ function handleHeaderContextMenu(
7777
{ type: "separator" },
7878
{
7979
label: "Close Block",
80-
click: onClose,
80+
click: () => uxCloseBlock(blockData.oid),
8181
}
8282
);
8383
ContextMenuModel.showContextMenu(menu, e);
@@ -152,7 +152,7 @@ function computeEndIcons(
152152
elemtype: "iconbutton",
153153
icon: "xmark-large",
154154
title: "Close",
155-
click: nodeModel.onClose,
155+
click: () => uxCloseBlock(nodeModel.blockId),
156156
};
157157
endIconsElem.push(<IconButton key="close" decl={closeDecl} className="block-frame-default-close" />);
158158
return endIconsElem;
@@ -200,7 +200,7 @@ const BlockFrame_Header = ({
200200

201201
const onContextMenu = React.useCallback(
202202
(e: React.MouseEvent<HTMLDivElement>) => {
203-
handleHeaderContextMenu(e, blockData, viewModel, magnified, nodeModel.toggleMagnify, nodeModel.onClose);
203+
handleHeaderContextMenu(e, blockData, viewModel, magnified, nodeModel.toggleMagnify);
204204
},
205205
[magnified]
206206
);

frontend/app/store/global.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
import { getWebServerEndpoint } from "@/util/endpoints";
1818
import { fetch } from "@/util/fetchutil";
1919
import { setPlatform } from "@/util/platformutil";
20-
import { deepCompareReturnPrev, getPrefixedSettings, isBlank } from "@/util/util";
20+
import { deepCompareReturnPrev, fireAndForget, getPrefixedSettings, isBlank } from "@/util/util";
2121
import { atom, Atom, PrimitiveAtom, useAtomValue } from "jotai";
2222
import { globalStore } from "./jotaiStore";
2323
import { modalsModel } from "./modalmodel";
@@ -481,12 +481,12 @@ async function createBlock(blockDef: BlockDef, magnified = false, ephemeral = fa
481481
return blockId;
482482
}
483483

484-
async function replaceBlock(blockId: string, blockDef: BlockDef): Promise<string> {
484+
async function replaceBlock(blockId: string, blockDef: BlockDef, focus: boolean): Promise<string> {
485485
const layoutModel = getLayoutModelForStaticTab();
486486
const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } };
487487
const newBlockId = await ObjectService.CreateBlock(blockDef, rtOpts);
488-
setTimeout(async () => {
489-
await ObjectService.DeleteBlock(blockId);
488+
setTimeout(() => {
489+
fireAndForget(() => ObjectService.DeleteBlock(blockId));
490490
}, 300);
491491
const targetNodeId = layoutModel.getNodeByBlockId(blockId)?.id;
492492
if (targetNodeId == null) {
@@ -496,7 +496,7 @@ async function replaceBlock(blockId: string, blockDef: BlockDef): Promise<string
496496
type: LayoutTreeActionType.ReplaceNode,
497497
targetNodeId: targetNodeId,
498498
newNode: newLayoutNode(undefined, undefined, undefined, { blockId: newBlockId }),
499-
focused: true,
499+
focused: focus,
500500
};
501501
layoutModel.treeReducer(replaceNodeAction);
502502
return newBlockId;

frontend/app/store/keymodel.ts

Lines changed: 80 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import { WaveAIModel } from "@/app/aipanel/waveai-model";
5+
import { focusManager } from "@/app/store/focusManager";
56
import {
67
atoms,
78
createBlock,
@@ -18,7 +19,7 @@ import {
1819
replaceBlock,
1920
WOS,
2021
} from "@/app/store/global";
21-
import { focusManager } from "@/app/store/focusManager";
22+
import { TabBarModel } from "@/app/tab/tabbar-model";
2223
import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
2324
import { deleteLayoutModelForTab, getLayoutModelForStaticTab, NavigateDirection } from "@/layout/index";
2425
import * as keyutil from "@/util/keyutil";
@@ -104,23 +105,80 @@ function shouldDispatchToBlock(e: WaveKeyboardEvent): boolean {
104105
return true;
105106
}
106107

107-
function genericClose() {
108-
const ws = globalStore.get(atoms.workspace);
108+
function getStaticTabBlockCount(): number {
109109
const tabId = globalStore.get(atoms.staticTabId);
110110
const tabORef = WOS.makeORef("tab", tabId);
111111
const tabAtom = WOS.getWaveObjectAtom<Tab>(tabORef);
112112
const tabData = globalStore.get(tabAtom);
113-
if (tabData == null) {
113+
return tabData?.blockids?.length ?? 0;
114+
}
115+
116+
function isStaticTabPinned(): boolean {
117+
const ws = globalStore.get(atoms.workspace);
118+
const tabId = globalStore.get(atoms.staticTabId);
119+
return ws.pinnedtabids?.includes(tabId) ?? false;
120+
}
121+
122+
function simpleCloseStaticTab() {
123+
const ws = globalStore.get(atoms.workspace);
124+
const tabId = globalStore.get(atoms.staticTabId);
125+
getApi().closeTab(ws.oid, tabId);
126+
deleteLayoutModelForTab(tabId);
127+
}
128+
129+
function uxCloseBlock(blockId: string) {
130+
if (isStaticTabPinned() && getStaticTabBlockCount() === 1) {
131+
TabBarModel.getInstance().jiggleActivePinnedTab();
132+
return;
133+
}
134+
135+
const workspaceLayoutModel = WorkspaceLayoutModel.getInstance();
136+
const isAIPanelOpen = workspaceLayoutModel.getAIPanelVisible();
137+
if (isAIPanelOpen && getStaticTabBlockCount() === 1) {
138+
const aiModel = WaveAIModel.getInstance();
139+
const shouldSwitchToAI = !aiModel.isChatEmpty || aiModel.hasNonEmptyInput();
140+
if (shouldSwitchToAI) {
141+
replaceBlock(blockId, { meta: { view: "launcher" } }, false);
142+
setTimeout(() => WaveAIModel.getInstance().focusInput(), 50);
143+
return;
144+
}
145+
}
146+
const layoutModel = getLayoutModelForStaticTab();
147+
const node = layoutModel.getNodeByBlockId(blockId);
148+
if (node) {
149+
fireAndForget(() => layoutModel.closeNode(node.id));
150+
}
151+
}
152+
153+
function genericClose() {
154+
const focusType = focusManager.getFocusType();
155+
if (focusType === "waveai") {
156+
WorkspaceLayoutModel.getInstance().setAIPanelVisible(false);
114157
return;
115158
}
116-
if (ws.pinnedtabids?.includes(tabId) && tabData.blockids?.length == 1) {
117-
// don't allow closing the last block in a pinned tab
159+
if (isStaticTabPinned() && getStaticTabBlockCount() === 1) {
160+
TabBarModel.getInstance().jiggleActivePinnedTab();
118161
return;
119162
}
120-
if (tabData.blockids == null || tabData.blockids.length == 0) {
121-
// close tab
122-
getApi().closeTab(ws.oid, tabId);
123-
deleteLayoutModelForTab(tabId);
163+
164+
const workspaceLayoutModel = WorkspaceLayoutModel.getInstance();
165+
const isAIPanelOpen = workspaceLayoutModel.getAIPanelVisible();
166+
if (isAIPanelOpen && getStaticTabBlockCount() === 1) {
167+
const aiModel = WaveAIModel.getInstance();
168+
const shouldSwitchToAI = !aiModel.isChatEmpty || aiModel.hasNonEmptyInput();
169+
if (shouldSwitchToAI) {
170+
const layoutModel = getLayoutModelForStaticTab();
171+
const focusedNode = globalStore.get(layoutModel.focusedNode);
172+
if (focusedNode) {
173+
replaceBlock(focusedNode.data.blockId, { meta: { view: "launcher" } }, false);
174+
setTimeout(() => WaveAIModel.getInstance().focusInput(), 50);
175+
return;
176+
}
177+
}
178+
}
179+
const blockCount = getStaticTabBlockCount();
180+
if (blockCount === 0) {
181+
simpleCloseStaticTab();
124182
return;
125183
}
126184
const layoutModel = getLayoutModelForStaticTab();
@@ -427,16 +485,11 @@ function registerGlobalKeys() {
427485
return true;
428486
});
429487
globalKeyMap.set("Cmd:Shift:w", () => {
430-
const tabId = globalStore.get(atoms.staticTabId);
431-
const ws = globalStore.get(atoms.workspace);
432-
if (ws.pinnedtabids?.includes(tabId)) {
433-
// switch to first unpinned tab if it exists (for close spamming)
434-
if (ws.tabids != null && ws.tabids.length > 0) {
435-
getApi().setActiveTab(ws.tabids[0]);
436-
}
488+
if (isStaticTabPinned()) {
489+
TabBarModel.getInstance().jiggleActivePinnedTab();
437490
return true;
438491
}
439-
getApi().closeTab(ws.oid, tabId);
492+
simpleCloseStaticTab();
440493
return true;
441494
});
442495
globalKeyMap.set("Cmd:m", () => {
@@ -468,11 +521,15 @@ function registerGlobalKeys() {
468521
if (blockId == null) {
469522
return true;
470523
}
471-
replaceBlock(blockId, {
472-
meta: {
473-
view: "launcher",
524+
replaceBlock(
525+
blockId,
526+
{
527+
meta: {
528+
view: "launcher",
529+
},
474530
},
475-
});
531+
true
532+
);
476533
return true;
477534
});
478535
globalKeyMap.set("Cmd:g", () => {
@@ -604,4 +661,5 @@ export {
604661
registerGlobalKeys,
605662
tryReinjectKey,
606663
unsetControlShift,
664+
uxCloseBlock,
607665
};

frontend/app/tab/tab.scss

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,54 @@ body.nohover .tab.active .close {
141141
.tab.new-tab {
142142
animation: expandWidthAndFadeIn 0.1s forwards;
143143
}
144+
145+
@keyframes jigglePinIcon {
146+
0% {
147+
transform: rotate(0deg);
148+
color: inherit;
149+
}
150+
10% {
151+
transform: rotate(-30deg);
152+
color: rgb(255, 193, 7);
153+
}
154+
20% {
155+
transform: rotate(30deg);
156+
color: rgb(255, 193, 7);
157+
}
158+
30% {
159+
transform: rotate(-30deg);
160+
color: rgb(255, 193, 7);
161+
}
162+
40% {
163+
transform: rotate(30deg);
164+
color: rgb(255, 193, 7);
165+
}
166+
50% {
167+
transform: rotate(-15deg);
168+
color: rgb(255, 193, 7);
169+
}
170+
60% {
171+
transform: rotate(15deg);
172+
color: rgb(255, 193, 7);
173+
}
174+
70% {
175+
transform: rotate(-15deg);
176+
color: rgb(255, 193, 7);
177+
}
178+
80% {
179+
transform: rotate(15deg);
180+
color: rgb(255, 193, 7);
181+
}
182+
90% {
183+
transform: rotate(0deg);
184+
color: rgb(255, 193, 7);
185+
}
186+
100% {
187+
transform: rotate(0deg);
188+
color: inherit;
189+
}
190+
}
191+
192+
.pin.jiggling i {
193+
animation: jigglePinIcon 0.5s ease-in-out;
194+
}

frontend/app/tab/tab.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import { Button } from "@/element/button";
88
import { ContextMenuModel } from "@/store/contextmenu";
99
import { fireAndForget } from "@/util/util";
1010
import clsx from "clsx";
11+
import { useAtomValue } from "jotai";
1112
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
1213
import { ObjectService } from "../store/services";
1314
import { makeORef, useWaveObjectValue } from "../store/wos";
15+
import { TabBarModel } from "./tabbar-model";
1416
import "./tab.scss";
1517

1618
interface TabProps {
@@ -51,6 +53,9 @@ const Tab = memo(
5153
const [tabData, _] = useWaveObjectValue<Tab>(makeORef("tab", id));
5254
const [originalName, setOriginalName] = useState("");
5355
const [isEditable, setIsEditable] = useState(false);
56+
const [isJiggling, setIsJiggling] = useState(false);
57+
58+
const jiggleTrigger = useAtomValue(TabBarModel.getInstance().jigglePinAtom);
5459

5560
const editableRef = useRef<HTMLDivElement>(null);
5661
const editableTimeoutRef = useRef<NodeJS.Timeout>(null);
@@ -141,6 +146,16 @@ const Tab = memo(
141146
}
142147
}, [isNew, tabWidth]);
143148

149+
useEffect(() => {
150+
if (active && isPinned && jiggleTrigger > 0) {
151+
setIsJiggling(true);
152+
const timeout = setTimeout(() => {
153+
setIsJiggling(false);
154+
}, 500);
155+
return () => clearTimeout(timeout);
156+
}
157+
}, [jiggleTrigger, active, isPinned]);
158+
144159
// Prevent drag from being triggered on mousedown
145160
const handleMouseDownOnClose = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
146161
event.stopPropagation();
@@ -224,7 +239,7 @@ const Tab = memo(
224239
</div>
225240
{isPinned ? (
226241
<Button
227-
className="ghost grey pin"
242+
className={clsx("ghost grey pin", { jiggling: isJiggling })}
228243
onClick={(e) => {
229244
e.stopPropagation();
230245
onPinChange();

0 commit comments

Comments
 (0)