Skip to content

Commit 6683e4e

Browse files
committed
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
1 parent 937c9ba commit 6683e4e

File tree

4 files changed

+131
-31
lines changed

4 files changed

+131
-31
lines changed

frontend/app/store/keymodel.ts

Lines changed: 38 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
replaceBlock,
2020
WOS,
2121
} from "@/app/store/global";
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,19 +105,40 @@ function shouldDispatchToBlock(e: WaveKeyboardEvent): boolean {
104105
return true;
105106
}
106107

107-
function uxCloseBlock(blockId: string) {
108-
const workspaceLayoutModel = WorkspaceLayoutModel.getInstance();
109-
const isAIPanelOpen = workspaceLayoutModel.getAIPanelVisible();
110-
108+
function getStaticTabBlockCount(): number {
111109
const tabId = globalStore.get(atoms.staticTabId);
112110
const tabORef = WOS.makeORef("tab", tabId);
113111
const tabAtom = WOS.getWaveObjectAtom<Tab>(tabORef);
114112
const tabData = globalStore.get(tabAtom);
115-
116-
if (isAIPanelOpen && tabData?.blockids?.length === 1) {
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+
138+
if (isAIPanelOpen && getStaticTabBlockCount() === 1) {
117139
const aiModel = WaveAIModel.getInstance();
118140
const shouldSwitchToAI = !aiModel.isChatEmpty || aiModel.hasNonEmptyInput();
119-
141+
120142
if (shouldSwitchToAI) {
121143
replaceBlock(
122144
blockId,
@@ -131,7 +153,7 @@ function uxCloseBlock(blockId: string) {
131153
return;
132154
}
133155
}
134-
156+
135157
const layoutModel = getLayoutModelForStaticTab();
136158
const node = layoutModel.getNodeByBlockId(blockId);
137159
if (node) {
@@ -145,22 +167,13 @@ function genericClose() {
145167
WorkspaceLayoutModel.getInstance().setAIPanelVisible(false);
146168
return;
147169
}
148-
const ws = globalStore.get(atoms.workspace);
149-
const tabId = globalStore.get(atoms.staticTabId);
150-
const tabORef = WOS.makeORef("tab", tabId);
151-
const tabAtom = WOS.getWaveObjectAtom<Tab>(tabORef);
152-
const tabData = globalStore.get(tabAtom);
153-
if (tabData == null) {
170+
if (isStaticTabPinned() && getStaticTabBlockCount() === 1) {
171+
TabBarModel.getInstance().jiggleActivePinnedTab();
154172
return;
155173
}
156-
if (ws.pinnedtabids?.includes(tabId) && tabData.blockids?.length == 1) {
157-
// don't allow closing the last block in a pinned tab
158-
return;
159-
}
160-
if (tabData.blockids == null || tabData.blockids.length == 0) {
161-
// close tab
162-
getApi().closeTab(ws.oid, tabId);
163-
deleteLayoutModelForTab(tabId);
174+
const blockCount = getStaticTabBlockCount();
175+
if (blockCount === 0) {
176+
simpleCloseStaticTab();
164177
return;
165178
}
166179
const layoutModel = getLayoutModelForStaticTab();
@@ -467,16 +480,11 @@ function registerGlobalKeys() {
467480
return true;
468481
});
469482
globalKeyMap.set("Cmd:Shift:w", () => {
470-
const tabId = globalStore.get(atoms.staticTabId);
471-
const ws = globalStore.get(atoms.workspace);
472-
if (ws.pinnedtabids?.includes(tabId)) {
473-
// switch to first unpinned tab if it exists (for close spamming)
474-
if (ws.tabids != null && ws.tabids.length > 0) {
475-
getApi().setActiveTab(ws.tabids[0]);
476-
}
483+
if (isStaticTabPinned()) {
484+
TabBarModel.getInstance().jiggleActivePinnedTab();
477485
return true;
478486
}
479-
getApi().closeTab(ws.oid, tabId);
487+
simpleCloseStaticTab();
480488
return true;
481489
});
482490
globalKeyMap.set("Cmd:m", () => {

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();

frontend/app/tab/tabbar-model.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright 2025, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { atom, type PrimitiveAtom } from "jotai";
5+
import { globalStore } from "@/app/store/jotaiStore";
6+
7+
export class TabBarModel {
8+
private static instance: TabBarModel | null = null;
9+
10+
jigglePinAtom: PrimitiveAtom<number> = atom(0);
11+
12+
private constructor() {
13+
// Empty for now
14+
}
15+
16+
static getInstance(): TabBarModel {
17+
if (!TabBarModel.instance) {
18+
TabBarModel.instance = new TabBarModel();
19+
}
20+
return TabBarModel.instance;
21+
}
22+
23+
jiggleActivePinnedTab() {
24+
globalStore.set(this.jigglePinAtom, (prev) => prev + 1);
25+
}
26+
}

0 commit comments

Comments
 (0)