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 frontend/app/aipanel/aipanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
12 changes: 11 additions & 1 deletion frontend/app/aipanel/waveai-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export class WaveAIModel {
containerWidth: jotai.PrimitiveAtom<number> = jotai.atom(0);
codeBlockMaxWidth!: jotai.Atom<number>;
inputAtom: jotai.PrimitiveAtom<string> = jotai.atom("");
isChatEmpty: boolean = true;

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

clearChat() {
this.clearFiles();
this.isChatEmpty = true;
const newChatId = crypto.randomUUID();
globalStore.set(this.chatId, newChatId);

Expand Down Expand Up @@ -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, {
Expand All @@ -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...");
Expand All @@ -200,6 +209,7 @@ export class WaveAIModel {
meta: { "waveai:chatid": newChatId },
});

this.isChatEmpty = true;
return [];
}
}
Expand Down
10 changes: 5 additions & 5 deletions frontend/app/block/blockframe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -40,8 +41,7 @@ function handleHeaderContextMenu(
blockData: Block,
viewModel: ViewModel,
magnified: boolean,
onMagnifyToggle: () => void,
onClose: () => void
onMagnifyToggle: () => void
) {
e.preventDefault();
e.stopPropagation();
Expand Down Expand Up @@ -77,7 +77,7 @@ function handleHeaderContextMenu(
{ type: "separator" },
{
label: "Close Block",
click: onClose,
click: () => uxCloseBlock(blockData.oid),
}
);
ContextMenuModel.showContextMenu(menu, e);
Expand Down Expand Up @@ -152,7 +152,7 @@ function computeEndIcons(
elemtype: "iconbutton",
icon: "xmark-large",
title: "Close",
click: nodeModel.onClose,
click: () => uxCloseBlock(nodeModel.blockId),
};
endIconsElem.push(<IconButton key="close" decl={closeDecl} className="block-frame-default-close" />);
return endIconsElem;
Expand Down Expand Up @@ -200,7 +200,7 @@ const BlockFrame_Header = ({

const onContextMenu = React.useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
handleHeaderContextMenu(e, blockData, viewModel, magnified, nodeModel.toggleMagnify, nodeModel.onClose);
handleHeaderContextMenu(e, blockData, viewModel, magnified, nodeModel.toggleMagnify);
},
[magnified]
);
Expand Down
10 changes: 5 additions & 5 deletions frontend/app/store/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -481,12 +481,12 @@ async function createBlock(blockDef: BlockDef, magnified = false, ephemeral = fa
return blockId;
}

async function replaceBlock(blockId: string, blockDef: BlockDef): Promise<string> {
async function replaceBlock(blockId: string, blockDef: BlockDef, focus: boolean): Promise<string> {
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) {
Expand All @@ -496,7 +496,7 @@ async function replaceBlock(blockId: string, blockDef: BlockDef): Promise<string
type: LayoutTreeActionType.ReplaceNode,
targetNodeId: targetNodeId,
newNode: newLayoutNode(undefined, undefined, undefined, { blockId: newBlockId }),
focused: true,
focused: focus,
};
layoutModel.treeReducer(replaceNodeAction);
return newBlockId;
Expand Down
102 changes: 80 additions & 22 deletions frontend/app/store/keymodel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,7 +19,7 @@ import {
replaceBlock,
WOS,
} from "@/app/store/global";
import { focusManager } from "@/app/store/focusManager";
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";
Expand Down Expand Up @@ -104,23 +105,80 @@ function shouldDispatchToBlock(e: WaveKeyboardEvent): boolean {
return true;
}

function genericClose() {
const ws = globalStore.get(atoms.workspace);
function getStaticTabBlockCount(): number {
const tabId = globalStore.get(atoms.staticTabId);
const tabORef = WOS.makeORef("tab", tabId);
const tabAtom = WOS.getWaveObjectAtom<Tab>(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();
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -604,4 +661,5 @@ export {
registerGlobalKeys,
tryReinjectKey,
unsetControlShift,
uxCloseBlock,
};
51 changes: 51 additions & 0 deletions frontend/app/tab/tab.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
17 changes: 16 additions & 1 deletion frontend/app/tab/tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -51,6 +53,9 @@ const Tab = memo(
const [tabData, _] = useWaveObjectValue<Tab>(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<HTMLDivElement>(null);
const editableTimeoutRef = useRef<NodeJS.Timeout>(null);
Expand Down Expand Up @@ -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<HTMLButtonElement, MouseEvent>) => {
event.stopPropagation();
Expand Down Expand Up @@ -224,7 +239,7 @@ const Tab = memo(
</div>
{isPinned ? (
<Button
className="ghost grey pin"
className={clsx("ghost grey pin", { jiggling: isJiggling })}
onClick={(e) => {
e.stopPropagation();
onPinChange();
Expand Down
Loading
Loading