From da798d76f4dcedf0f8d31fa08c75b1f61903a078 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 9 Oct 2025 21:53:20 -0700 Subject: [PATCH 1/7] Cmd-W will close Wave AI, track empty chat state in Wave AI --- frontend/app/aipanel/aipanel.tsx | 1 + frontend/app/aipanel/waveai-model.tsx | 12 +++++++++++- frontend/app/store/keymodel.ts | 5 +++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index 146f0c72f6..ba5881c991 100644 --- a/frontend/app/aipanel/aipanel.tsx +++ b/frontend/app/aipanel/aipanel.tsx @@ -354,6 +354,7 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => { // sendMessage uses UIMessageParts sendMessage({ parts: uiMessageParts }); + model.isChatEmpty = false; globalStore.set(model.inputAtom, ""); model.clearFiles(); diff --git a/frontend/app/aipanel/waveai-model.tsx b/frontend/app/aipanel/waveai-model.tsx index 9d3fe063ac..8b17c24103 100644 --- a/frontend/app/aipanel/waveai-model.tsx +++ b/frontend/app/aipanel/waveai-model.tsx @@ -34,6 +34,7 @@ export class WaveAIModel { containerWidth: jotai.PrimitiveAtom = jotai.atom(0); codeBlockMaxWidth!: jotai.Atom; inputAtom: jotai.PrimitiveAtom = jotai.atom(""); + isChatEmpty: boolean = true; private constructor() { const tabId = globalStore.get(atoms.staticTabId); @@ -127,6 +128,7 @@ export class WaveAIModel { clearChat() { this.clearFiles(); + this.isChatEmpty = true; const newChatId = crypto.randomUUID(); globalStore.set(this.chatId, newChatId); @@ -166,6 +168,11 @@ export class WaveAIModel { } } + hasNonEmptyInput(): boolean { + const input = globalStore.get(this.inputAtom); + return input != null && input.trim().length > 0; + } + setModel(model: string) { const tabId = globalStore.get(atoms.staticTabId); RpcApi.SetMetaCommand(TabRpcClient, { @@ -186,7 +193,9 @@ export class WaveAIModel { const chatId = globalStore.get(this.chatId); try { const chatData = await RpcApi.GetWaveAIChatCommand(TabRpcClient, { chatid: chatId }); - return chatData?.messages ?? []; + const messages = chatData?.messages ?? []; + this.isChatEmpty = messages.length === 0; + return messages; } catch (error) { console.error("Failed to load chat:", error); this.setError("Failed to load chat. Starting new chat..."); @@ -200,6 +209,7 @@ export class WaveAIModel { meta: { "waveai:chatid": newChatId }, }); + this.isChatEmpty = true; return []; } } diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index 4b1e652efe..b3985e74f1 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -105,6 +105,11 @@ function shouldDispatchToBlock(e: WaveKeyboardEvent): boolean { } function genericClose() { + const focusType = focusManager.getFocusType(); + if (focusType === "waveai") { + WorkspaceLayoutModel.getInstance().setAIPanelVisible(false); + return; + } const ws = globalStore.get(atoms.workspace); const tabId = globalStore.get(atoms.staticTabId); const tabORef = WOS.makeORef("tab", tabId); From 6c2f815bbd8b261690b2a3e3e71c4fbf7b3b57e6 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 9 Oct 2025 22:12:52 -0700 Subject: [PATCH 2/7] re-wire the close actions to call into the keymodel (uxCloseBlock) instead of directly calling nodeModel.onClose --- frontend/app/block/blockframe.tsx | 10 +++++----- frontend/app/store/keymodel.ts | 13 +++++++++++-- frontend/layout/lib/layoutModel.ts | 4 ++-- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index 812bf8f325..7e788b5cfd 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -16,6 +16,7 @@ import { useBlockAtom, WOS, } from "@/app/store/global"; +import { uxCloseBlock } from "@/app/store/keymodel"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; @@ -40,8 +41,7 @@ function handleHeaderContextMenu( blockData: Block, viewModel: ViewModel, magnified: boolean, - onMagnifyToggle: () => void, - onClose: () => void + onMagnifyToggle: () => void ) { e.preventDefault(); e.stopPropagation(); @@ -77,7 +77,7 @@ function handleHeaderContextMenu( { type: "separator" }, { label: "Close Block", - click: onClose, + click: () => uxCloseBlock(blockData.oid), } ); ContextMenuModel.showContextMenu(menu, e); @@ -152,7 +152,7 @@ function computeEndIcons( elemtype: "iconbutton", icon: "xmark-large", title: "Close", - click: nodeModel.onClose, + click: () => uxCloseBlock(nodeModel.blockId), }; endIconsElem.push(); return endIconsElem; @@ -200,7 +200,7 @@ const BlockFrame_Header = ({ const onContextMenu = React.useCallback( (e: React.MouseEvent) => { - handleHeaderContextMenu(e, blockData, viewModel, magnified, nodeModel.toggleMagnify, nodeModel.onClose); + handleHeaderContextMenu(e, blockData, viewModel, magnified, nodeModel.toggleMagnify); }, [magnified] ); diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index b3985e74f1..a7ee6888de 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { WaveAIModel } from "@/app/aipanel/waveai-model"; +import { focusManager } from "@/app/store/focusManager"; import { atoms, createBlock, @@ -18,7 +19,6 @@ import { replaceBlock, WOS, } from "@/app/store/global"; -import { focusManager } from "@/app/store/focusManager"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { deleteLayoutModelForTab, getLayoutModelForStaticTab, NavigateDirection } from "@/layout/index"; import * as keyutil from "@/util/keyutil"; @@ -104,6 +104,14 @@ function shouldDispatchToBlock(e: WaveKeyboardEvent): boolean { return true; } +function uxCloseBlock(blockId: string) { + const layoutModel = getLayoutModelForStaticTab(); + const node = layoutModel.getNodeByBlockId(blockId); + if (node) { + fireAndForget(() => layoutModel.closeNode(node.id)); + } +} + function genericClose() { const focusType = focusManager.getFocusType(); if (focusType === "waveai") { @@ -122,7 +130,7 @@ function genericClose() { // don't allow closing the last block in a pinned tab return; } - if (tabData.blockids == null || tabData.blockids.length == 0) { +if (tabData.blockids == null || tabData.blockids.length == 0) { // close tab getApi().closeTab(ws.oid, tabId); deleteLayoutModelForTab(tabId); @@ -609,4 +617,5 @@ export { registerGlobalKeys, tryReinjectKey, unsetControlShift, + uxCloseBlock, }; diff --git a/frontend/layout/lib/layoutModel.ts b/frontend/layout/lib/layoutModel.ts index 8fadbe097e..c031022afd 100644 --- a/frontend/layout/lib/layoutModel.ts +++ b/frontend/layout/lib/layoutModel.ts @@ -1,8 +1,8 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { atoms, getSettingsKeyAtom } from "@/app/store/global"; import { focusManager } from "@/app/store/focusManager"; +import { getSettingsKeyAtom } from "@/app/store/global"; import { atomWithThrottle, boundNumber, fireAndForget } from "@/util/util"; import { Atom, atom, Getter, PrimitiveAtom, Setter } from "jotai"; import { splitAtom } from "jotai/utils"; @@ -1051,7 +1051,7 @@ export class LayoutModel { animationTimeS: this.animationTimeS, ready: this.ready, disablePointerEvents: this.activeDrag, - onClose: () => fireAndForget(() => this.closeNode(nodeid)), + onClose: () => fireAndForget(() => this.closeNode(nodeid)), // no longer used (instead we use keymodel uxCloseBlock) toggleMagnify: () => this.magnifyNodeToggle(nodeid), focusNode: () => this.focusNode(nodeid), dragHandleRef: createRef(), From 9eca86fb2afee0c74a37c6b0e72ed44cf0382a6a Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 9 Oct 2025 22:45:36 -0700 Subject: [PATCH 3/7] fix ugly backend bug which would cause us to lose the workspace if we closed the last pinned tab --- frontend/app/store/keymodel.ts | 2 +- pkg/wcore/workspace.go | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index a7ee6888de..b468c48a7c 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -130,7 +130,7 @@ function genericClose() { // don't allow closing the last block in a pinned tab return; } -if (tabData.blockids == null || tabData.blockids.length == 0) { + if (tabData.blockids == null || tabData.blockids.length == 0) { // close tab getApi().closeTab(ws.oid, tabId); deleteLayoutModelForTab(tabId); diff --git a/pkg/wcore/workspace.go b/pkg/wcore/workspace.go index 6f4253316b..385255378e 100644 --- a/pkg/wcore/workspace.go +++ b/pkg/wcore/workspace.go @@ -309,8 +309,12 @@ func DeleteTab(ctx context.Context, workspaceId string, tabId string, recursive // if the tab is active, determine new active tab newActiveTabId := ws.ActiveTabId if ws.ActiveTabId == tabId { - if len(ws.TabIds) > 0 && tabIdx != -1 { - newActiveTabId = ws.TabIds[max(0, min(tabIdx-1, len(ws.TabIds)-1))] + if len(ws.TabIds) > 0 { + if tabIdx != -1 { + newActiveTabId = ws.TabIds[max(0, min(tabIdx-1, len(ws.TabIds)-1))] + } else { + newActiveTabId = ws.TabIds[0] + } } else if len(ws.PinnedTabIds) > 0 { newActiveTabId = ws.PinnedTabIds[0] } else { From 937c9ba8f388a3e5b22b829ef00cef4db26eac15 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 9 Oct 2025 23:23:48 -0700 Subject: [PATCH 4/7] working on uxCloseBlock. pass a focus param to replaceBlock --- frontend/app/store/global.ts | 4 +-- frontend/app/store/keymodel.ts | 39 ++++++++++++++++++++++--- frontend/app/view/launcher/launcher.tsx | 2 +- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 29311d51e9..a32ff79ae7 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -481,7 +481,7 @@ async function createBlock(blockDef: BlockDef, magnified = false, ephemeral = fa return blockId; } -async function replaceBlock(blockId: string, blockDef: BlockDef): Promise { +async function replaceBlock(blockId: string, blockDef: BlockDef, focus: boolean): Promise { const layoutModel = getLayoutModelForStaticTab(); const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } }; const newBlockId = await ObjectService.CreateBlock(blockDef, rtOpts); @@ -496,7 +496,7 @@ async function replaceBlock(blockId: string, blockDef: BlockDef): Promise(tabORef); + const tabData = globalStore.get(tabAtom); + + if (isAIPanelOpen && tabData?.blockids?.length === 1) { + const aiModel = WaveAIModel.getInstance(); + const shouldSwitchToAI = !aiModel.isChatEmpty || aiModel.hasNonEmptyInput(); + + if (shouldSwitchToAI) { + replaceBlock( + blockId, + { + meta: { + view: "launcher", + }, + }, + false + ); + setTimeout(() => WaveAIModel.getInstance().focusInput(), 50); + return; + } + } + const layoutModel = getLayoutModelForStaticTab(); const node = layoutModel.getNodeByBlockId(blockId); if (node) { @@ -481,11 +508,15 @@ function registerGlobalKeys() { if (blockId == null) { return true; } - replaceBlock(blockId, { - meta: { - view: "launcher", + replaceBlock( + blockId, + { + meta: { + view: "launcher", + }, }, - }); + true + ); return true; }); globalKeyMap.set("Cmd:g", () => { diff --git a/frontend/app/view/launcher/launcher.tsx b/frontend/app/view/launcher/launcher.tsx index ceb87b32c1..cdde41e19d 100644 --- a/frontend/app/view/launcher/launcher.tsx +++ b/frontend/app/view/launcher/launcher.tsx @@ -124,7 +124,7 @@ export class LauncherViewModel implements ViewModel { async handleWidgetSelect(widget: WidgetConfigType) { try { - await replaceBlock(this.blockId, widget.blockdef); + await replaceBlock(this.blockId, widget.blockdef, true); } catch (error) { console.error("Error replacing block:", error); } From 6683e4e7fc505e2b008588919f71615a39cb99c4 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 9 Oct 2025 23:26:03 -0700 Subject: [PATCH 5/7] helper functions for tab/block funcs. make pinned close block more consistent. add a jiggle effect to show why we didn't actually close a block or tab --- frontend/app/store/keymodel.ts | 68 ++++++++++++++++++-------------- frontend/app/tab/tab.scss | 51 ++++++++++++++++++++++++ frontend/app/tab/tab.tsx | 17 +++++++- frontend/app/tab/tabbar-model.ts | 26 ++++++++++++ 4 files changed, 131 insertions(+), 31 deletions(-) create mode 100644 frontend/app/tab/tabbar-model.ts diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index 8ff9916a54..5f9f133312 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -19,6 +19,7 @@ import { replaceBlock, WOS, } from "@/app/store/global"; +import { TabBarModel } from "@/app/tab/tabbar-model"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { deleteLayoutModelForTab, getLayoutModelForStaticTab, NavigateDirection } from "@/layout/index"; import * as keyutil from "@/util/keyutil"; @@ -104,19 +105,40 @@ function shouldDispatchToBlock(e: WaveKeyboardEvent): boolean { return true; } -function uxCloseBlock(blockId: string) { - const workspaceLayoutModel = WorkspaceLayoutModel.getInstance(); - const isAIPanelOpen = workspaceLayoutModel.getAIPanelVisible(); - +function getStaticTabBlockCount(): number { const tabId = globalStore.get(atoms.staticTabId); const tabORef = WOS.makeORef("tab", tabId); const tabAtom = WOS.getWaveObjectAtom(tabORef); const tabData = globalStore.get(tabAtom); - - if (isAIPanelOpen && tabData?.blockids?.length === 1) { + return tabData?.blockids?.length ?? 0; +} + +function isStaticTabPinned(): boolean { + const ws = globalStore.get(atoms.workspace); + const tabId = globalStore.get(atoms.staticTabId); + return ws.pinnedtabids?.includes(tabId) ?? false; +} + +function simpleCloseStaticTab() { + const ws = globalStore.get(atoms.workspace); + const tabId = globalStore.get(atoms.staticTabId); + getApi().closeTab(ws.oid, tabId); + deleteLayoutModelForTab(tabId); +} + +function uxCloseBlock(blockId: string) { + if (isStaticTabPinned() && getStaticTabBlockCount() === 1) { + TabBarModel.getInstance().jiggleActivePinnedTab(); + return; + } + + const workspaceLayoutModel = WorkspaceLayoutModel.getInstance(); + const isAIPanelOpen = workspaceLayoutModel.getAIPanelVisible(); + + if (isAIPanelOpen && getStaticTabBlockCount() === 1) { const aiModel = WaveAIModel.getInstance(); const shouldSwitchToAI = !aiModel.isChatEmpty || aiModel.hasNonEmptyInput(); - + if (shouldSwitchToAI) { replaceBlock( blockId, @@ -131,7 +153,7 @@ function uxCloseBlock(blockId: string) { return; } } - + const layoutModel = getLayoutModelForStaticTab(); const node = layoutModel.getNodeByBlockId(blockId); if (node) { @@ -145,22 +167,13 @@ function genericClose() { WorkspaceLayoutModel.getInstance().setAIPanelVisible(false); return; } - const ws = globalStore.get(atoms.workspace); - const tabId = globalStore.get(atoms.staticTabId); - const tabORef = WOS.makeORef("tab", tabId); - const tabAtom = WOS.getWaveObjectAtom(tabORef); - const tabData = globalStore.get(tabAtom); - if (tabData == null) { + if (isStaticTabPinned() && getStaticTabBlockCount() === 1) { + TabBarModel.getInstance().jiggleActivePinnedTab(); return; } - if (ws.pinnedtabids?.includes(tabId) && tabData.blockids?.length == 1) { - // don't allow closing the last block in a pinned tab - return; - } - if (tabData.blockids == null || tabData.blockids.length == 0) { - // close tab - getApi().closeTab(ws.oid, tabId); - deleteLayoutModelForTab(tabId); + const blockCount = getStaticTabBlockCount(); + if (blockCount === 0) { + simpleCloseStaticTab(); return; } const layoutModel = getLayoutModelForStaticTab(); @@ -467,16 +480,11 @@ function registerGlobalKeys() { return true; }); globalKeyMap.set("Cmd:Shift:w", () => { - const tabId = globalStore.get(atoms.staticTabId); - const ws = globalStore.get(atoms.workspace); - if (ws.pinnedtabids?.includes(tabId)) { - // switch to first unpinned tab if it exists (for close spamming) - if (ws.tabids != null && ws.tabids.length > 0) { - getApi().setActiveTab(ws.tabids[0]); - } + if (isStaticTabPinned()) { + TabBarModel.getInstance().jiggleActivePinnedTab(); return true; } - getApi().closeTab(ws.oid, tabId); + simpleCloseStaticTab(); return true; }); globalKeyMap.set("Cmd:m", () => { diff --git a/frontend/app/tab/tab.scss b/frontend/app/tab/tab.scss index 2afac47fbd..4b33a48f92 100644 --- a/frontend/app/tab/tab.scss +++ b/frontend/app/tab/tab.scss @@ -141,3 +141,54 @@ body.nohover .tab.active .close { .tab.new-tab { animation: expandWidthAndFadeIn 0.1s forwards; } + +@keyframes jigglePinIcon { + 0% { + transform: rotate(0deg); + color: inherit; + } + 10% { + transform: rotate(-30deg); + color: rgb(255, 193, 7); + } + 20% { + transform: rotate(30deg); + color: rgb(255, 193, 7); + } + 30% { + transform: rotate(-30deg); + color: rgb(255, 193, 7); + } + 40% { + transform: rotate(30deg); + color: rgb(255, 193, 7); + } + 50% { + transform: rotate(-15deg); + color: rgb(255, 193, 7); + } + 60% { + transform: rotate(15deg); + color: rgb(255, 193, 7); + } + 70% { + transform: rotate(-15deg); + color: rgb(255, 193, 7); + } + 80% { + transform: rotate(15deg); + color: rgb(255, 193, 7); + } + 90% { + transform: rotate(0deg); + color: rgb(255, 193, 7); + } + 100% { + transform: rotate(0deg); + color: inherit; + } +} + +.pin.jiggling i { + animation: jigglePinIcon 0.5s ease-in-out; +} diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index 156d740b0a..26e807a8ef 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -8,9 +8,11 @@ import { Button } from "@/element/button"; import { ContextMenuModel } from "@/store/contextmenu"; import { fireAndForget } from "@/util/util"; import clsx from "clsx"; +import { useAtomValue } from "jotai"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; import { ObjectService } from "../store/services"; import { makeORef, useWaveObjectValue } from "../store/wos"; +import { TabBarModel } from "./tabbar-model"; import "./tab.scss"; interface TabProps { @@ -51,6 +53,9 @@ const Tab = memo( const [tabData, _] = useWaveObjectValue(makeORef("tab", id)); const [originalName, setOriginalName] = useState(""); const [isEditable, setIsEditable] = useState(false); + const [isJiggling, setIsJiggling] = useState(false); + + const jiggleTrigger = useAtomValue(TabBarModel.getInstance().jigglePinAtom); const editableRef = useRef(null); const editableTimeoutRef = useRef(null); @@ -141,6 +146,16 @@ const Tab = memo( } }, [isNew, tabWidth]); + useEffect(() => { + if (active && isPinned && jiggleTrigger > 0) { + setIsJiggling(true); + const timeout = setTimeout(() => { + setIsJiggling(false); + }, 500); + return () => clearTimeout(timeout); + } + }, [jiggleTrigger, active, isPinned]); + // Prevent drag from being triggered on mousedown const handleMouseDownOnClose = (event: React.MouseEvent) => { event.stopPropagation(); @@ -224,7 +239,7 @@ const Tab = memo( {isPinned ? (