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/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/global.ts b/frontend/app/store/global.ts index 29311d51e9..146721ec6d 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -17,7 +17,7 @@ import { import { getWebServerEndpoint } from "@/util/endpoints"; import { fetch } from "@/util/fetchutil"; import { setPlatform } from "@/util/platformutil"; -import { deepCompareReturnPrev, getPrefixedSettings, isBlank } from "@/util/util"; +import { deepCompareReturnPrev, fireAndForget, getPrefixedSettings, isBlank } from "@/util/util"; import { atom, Atom, PrimitiveAtom, useAtomValue } from "jotai"; import { globalStore } from "./jotaiStore"; import { modalsModel } from "./modalmodel"; @@ -481,12 +481,12 @@ 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); - setTimeout(async () => { - await ObjectService.DeleteBlock(blockId); + setTimeout(() => { + fireAndForget(() => ObjectService.DeleteBlock(blockId)); }, 300); const targetNodeId = layoutModel.getNodeByBlockId(blockId)?.id; if (targetNodeId == null) { @@ -496,7 +496,7 @@ async function replaceBlock(blockId: string, blockDef: BlockDef): Promise(tabORef); const tabData = globalStore.get(tabAtom); - if (tabData == null) { + 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, { meta: { view: "launcher" } }, false); + setTimeout(() => WaveAIModel.getInstance().focusInput(), 50); + return; + } + } + const layoutModel = getLayoutModelForStaticTab(); + const node = layoutModel.getNodeByBlockId(blockId); + if (node) { + fireAndForget(() => layoutModel.closeNode(node.id)); + } +} + +function genericClose() { + const focusType = focusManager.getFocusType(); + if (focusType === "waveai") { + WorkspaceLayoutModel.getInstance().setAIPanelVisible(false); return; } - if (ws.pinnedtabids?.includes(tabId) && tabData.blockids?.length == 1) { - // don't allow closing the last block in a pinned tab + if (isStaticTabPinned() && getStaticTabBlockCount() === 1) { + TabBarModel.getInstance().jiggleActivePinnedTab(); return; } - if (tabData.blockids == null || tabData.blockids.length == 0) { - // close tab - getApi().closeTab(ws.oid, tabId); - deleteLayoutModelForTab(tabId); + + const workspaceLayoutModel = WorkspaceLayoutModel.getInstance(); + const isAIPanelOpen = workspaceLayoutModel.getAIPanelVisible(); + if (isAIPanelOpen && getStaticTabBlockCount() === 1) { + const aiModel = WaveAIModel.getInstance(); + const shouldSwitchToAI = !aiModel.isChatEmpty || aiModel.hasNonEmptyInput(); + if (shouldSwitchToAI) { + const layoutModel = getLayoutModelForStaticTab(); + const focusedNode = globalStore.get(layoutModel.focusedNode); + if (focusedNode) { + replaceBlock(focusedNode.data.blockId, { meta: { view: "launcher" } }, false); + setTimeout(() => WaveAIModel.getInstance().focusInput(), 50); + return; + } + } + } + const blockCount = getStaticTabBlockCount(); + if (blockCount === 0) { + simpleCloseStaticTab(); return; } const layoutModel = getLayoutModelForStaticTab(); @@ -427,16 +485,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", () => { @@ -468,11 +521,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", () => { @@ -604,4 +661,5 @@ export { registerGlobalKeys, tryReinjectKey, unsetControlShift, + uxCloseBlock, }; 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 ? (