From 344b9d03a6b9148ada6c482643fcf8fb76fa2e9f Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 23 Feb 2026 13:17:20 -0800 Subject: [PATCH 01/13] tab preview checkpoint (issues with context menu, fixing in separate pr) --- frontend/app/tab/tab.tsx | 156 +++++++++++++++++++++- frontend/preview/previews/tab.preview.tsx | 32 +++++ 2 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 frontend/preview/previews/tab.preview.tsx diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index d82c1ce11a..17a64d5537 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -23,6 +23,160 @@ import { ObjectService } from "../store/services"; import { makeORef, useWaveObjectValue } from "../store/wos"; import "./tab.scss"; +interface TabVProps { + tabId: string; + tabName: string; + active: boolean; + isBeforeActive: boolean; + isDragging: boolean; + tabWidth: number; + isNew: boolean; + indicator?: TabIndicator | null; + onClick: () => void; + onClose: (event: React.MouseEvent | null) => void; + onDragStart: (event: React.MouseEvent) => void; + onContextMenu: (e: React.MouseEvent) => void; + onRename: (newName: string) => void; +} + +const TabV = forwardRef((props, ref) => { + const { tabId, tabName, active, isBeforeActive, isDragging, tabWidth, isNew, indicator, onClick, onClose, onDragStart, onContextMenu, onRename } = props; + const [originalName, setOriginalName] = useState(tabName); + const [isEditable, setIsEditable] = useState(false); + + const editableRef = useRef(null); + const editableTimeoutRef = useRef(null); + const tabRef = useRef(null); + + useImperativeHandle(ref, () => tabRef.current as HTMLDivElement); + + useEffect(() => { + setOriginalName(tabName); + }, [tabName]); + + useEffect(() => { + return () => { + if (editableTimeoutRef.current) { + clearTimeout(editableTimeoutRef.current); + } + }; + }, []); + + const selectEditableText = useCallback(() => { + if (!editableRef.current) { + return; + } + editableRef.current.focus(); + const range = document.createRange(); + const selection = window.getSelection(); + range.selectNodeContents(editableRef.current); + selection.removeAllRanges(); + selection.addRange(range); + }, []); + + const handleRenameTab: React.MouseEventHandler = (event) => { + event?.stopPropagation(); + setIsEditable(true); + editableTimeoutRef.current = setTimeout(() => { + selectEditableText(); + }, 50); + }; + + const handleBlur = () => { + let newText = editableRef.current.innerText.trim(); + newText = newText || originalName; + editableRef.current.innerText = newText; + setIsEditable(false); + onRename(newText); + }; + + const handleKeyDown: React.KeyboardEventHandler = (event) => { + if ((event.metaKey || event.ctrlKey) && event.key === "a") { + event.preventDefault(); + selectEditableText(); + return; + } + const curLen = Array.from(editableRef.current.innerText).length; + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + if (editableRef.current.innerText.trim() === "") { + editableRef.current.innerText = originalName; + } + editableRef.current.blur(); + } else if (event.key === "Escape") { + editableRef.current.innerText = originalName; + editableRef.current.blur(); + event.preventDefault(); + event.stopPropagation(); + } else if (curLen >= 14 && !["Backspace", "Delete", "ArrowLeft", "ArrowRight"].includes(event.key)) { + event.preventDefault(); + event.stopPropagation(); + } + }; + + useEffect(() => { + if (tabRef.current && isNew) { + const initialWidth = `${(tabWidth / 3) * 2}px`; + tabRef.current.style.setProperty("--initial-tab-width", initialWidth); + tabRef.current.style.setProperty("--final-tab-width", `${tabWidth}px`); + } + }, [isNew, tabWidth]); + + const handleMouseDownOnClose = (event: React.MouseEvent) => { + event.stopPropagation(); + }; + + return ( +
+
+
+ {tabName} +
+ {indicator && ( +
+ +
+ )} + +
+
+ ); +}); + +TabV.displayName = "TabV"; + interface TabProps { id: string; active: boolean; @@ -262,4 +416,4 @@ const TabInner = forwardRef((props, ref) => { const Tab = memo(TabInner); Tab.displayName = "Tab"; -export { Tab }; +export { Tab, TabV }; diff --git a/frontend/preview/previews/tab.preview.tsx b/frontend/preview/previews/tab.preview.tsx new file mode 100644 index 0000000000..dc70a36d04 --- /dev/null +++ b/frontend/preview/previews/tab.preview.tsx @@ -0,0 +1,32 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { TabV } from "@/app/tab/tab"; +import { useState } from "react"; + +export function TabPreview() { + const [tabName, setTabName] = useState("My Tab"); + + return ( +
+ console.log("click")} + onClose={() => console.log("close")} + onDragStart={() => {}} + onContextMenu={() => {}} + onRename={(newName) => { + console.log("rename", newName); + setTabName(newName); + }} + /> +
+ ); +} From 90d69c7e89c0e1c9cf99505b03fe7f11f33f4cef Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 23 Feb 2026 19:00:55 -0800 Subject: [PATCH 02/13] update skill to use getInstance() --- .kilocode/skills/context-menu/SKILL.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.kilocode/skills/context-menu/SKILL.md b/.kilocode/skills/context-menu/SKILL.md index aa78f56ca3..dda3b7b985 100644 --- a/.kilocode/skills/context-menu/SKILL.md +++ b/.kilocode/skills/context-menu/SKILL.md @@ -40,7 +40,7 @@ import { ContextMenuModel } from "@/app/store/contextmenu"; To display the context menu, call: ```ts -ContextMenuModel.showContextMenu(menu, event); +ContextMenuModel.getInstance().showContextMenu(menu, event); ``` - **menu**: An array of `ContextMenuItem`. @@ -75,7 +75,7 @@ const menu: ContextMenuItem[] = [ }, ]; -ContextMenuModel.showContextMenu(menu, e); +ContextMenuModel.getInstance().showContextMenu(menu, e); ``` --- @@ -111,7 +111,7 @@ const menu: ContextMenuItem[] = [ }, ]; -ContextMenuModel.showContextMenu(menu, e); +ContextMenuModel.getInstance().showContextMenu(menu, e); ``` --- @@ -143,7 +143,7 @@ Open a configuration file (e.g., `widgets.json`) in preview mode: - **Actions**: Use `click` for actions; use `submenu` for nested options. - **Separators**: Use `type: "separator"` to group items. - **Toggles**: Use `type: "checkbox"` or `"radio"` with the `checked` property. -- **Displaying**: Use `ContextMenuModel.showContextMenu(menu, event)` to render the menu. +- **Displaying**: Use `ContextMenuModel.getInstance().showContextMenu(menu, event)` to render the menu. ## Common Use Cases From 686c1ea5069ddac9e9dbd51f871543c3367561e9 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 23 Feb 2026 19:01:18 -0800 Subject: [PATCH 03/13] refactor the contextmenu out of the main component --- frontend/app/tab/tab.tsx | 122 +++++++++++++++++++++------------------ 1 file changed, 65 insertions(+), 57 deletions(-) diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index de94cd64b2..3bede3dda7 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -177,6 +177,70 @@ const TabV = forwardRef((props, ref) => { TabV.displayName = "TabV"; +function buildTabContextMenu( + id: string, + handleRenameTab: React.MouseEventHandler, + onClose: (event: React.MouseEvent | null) => void +): ContextMenuItem[] { + const menu: ContextMenuItem[] = []; + const currentIndicator = globalStore.get(getTabIndicatorAtom(id)); + if (currentIndicator) { + menu.push( + { + label: "Clear Tab Indicator", + click: () => setTabIndicator(id, null), + }, + { + label: "Clear All Indicators", + click: () => clearAllTabIndicators(), + }, + { type: "separator" } + ); + } + menu.push( + { label: "Rename Tab", click: () => handleRenameTab(null) }, + { + label: "Copy TabId", + click: () => fireAndForget(() => navigator.clipboard.writeText(id)), + }, + { type: "separator" } + ); + const fullConfig = globalStore.get(atoms.fullConfigAtom); + const bgPresets: string[] = []; + for (const key in fullConfig?.presets ?? {}) { + if (key.startsWith("bg@")) { + bgPresets.push(key); + } + } + bgPresets.sort((a, b) => { + const aOrder = fullConfig.presets[a]["display:order"] ?? 0; + const bOrder = fullConfig.presets[b]["display:order"] ?? 0; + return aOrder - bOrder; + }); + if (bgPresets.length > 0) { + const submenu: ContextMenuItem[] = []; + const oref = makeORef("tab", id); + for (const presetName of bgPresets) { + const preset = fullConfig.presets[presetName]; + if (preset == null) { + continue; + } + submenu.push({ + label: preset["display:name"] ?? presetName, + click: () => + fireAndForget(async () => { + await ObjectService.UpdateObjectMeta(oref, preset); + RpcApi.ActivityCommand(TabRpcClient, { settabtheme: 1 }, { noresponse: true }); + recordTEvent("action:settabtheme"); + }), + }); + } + menu.push({ label: "Backgrounds", type: "submenu", submenu }, { type: "separator" }); + } + menu.push({ label: "Close Tab", click: () => onClose(null) }); + return menu; +} + interface TabProps { id: string; active: boolean; @@ -304,63 +368,7 @@ const TabInner = forwardRef((props, ref) => { const handleContextMenu = useCallback( (e: React.MouseEvent) => { - e.preventDefault(); - let menu: ContextMenuItem[] = []; - const currentIndicator = globalStore.get(getTabIndicatorAtom(id)); - if (currentIndicator) { - menu.push( - { - label: "Clear Tab Indicator", - click: () => setTabIndicator(id, null), - }, - { - label: "Clear All Indicators", - click: () => clearAllTabIndicators(), - }, - { type: "separator" } - ); - } - menu.push( - { label: "Rename Tab", click: () => handleRenameTab(null) }, - { - label: "Copy TabId", - click: () => fireAndForget(() => navigator.clipboard.writeText(id)), - }, - { type: "separator" } - ); - const fullConfig = globalStore.get(atoms.fullConfigAtom); - const bgPresets: string[] = []; - for (const key in fullConfig?.presets ?? {}) { - if (key.startsWith("bg@")) { - bgPresets.push(key); - } - } - bgPresets.sort((a, b) => { - const aOrder = fullConfig.presets[a]["display:order"] ?? 0; - const bOrder = fullConfig.presets[b]["display:order"] ?? 0; - return aOrder - bOrder; - }); - if (bgPresets.length > 0) { - const submenu: ContextMenuItem[] = []; - const oref = makeORef("tab", id); - for (const presetName of bgPresets) { - const preset = fullConfig.presets[presetName]; - if (preset == null) { - continue; - } - submenu.push({ - label: preset["display:name"] ?? presetName, - click: () => - fireAndForget(async () => { - await ObjectService.UpdateObjectMeta(oref, preset); - RpcApi.ActivityCommand(TabRpcClient, { settabtheme: 1 }, { noresponse: true }); - recordTEvent("action:settabtheme"); - }), - }); - } - menu.push({ label: "Backgrounds", type: "submenu", submenu }, { type: "separator" }); - } - menu.push({ label: "Close Tab", click: () => onClose(null) }); + const menu = buildTabContextMenu(id, handleRenameTab, onClose); ContextMenuModel.getInstance().showContextMenu(menu, e); }, [handleRenameTab, id, onClose] From 7c5aea99c5801a2fa5473504a84fb8b620fabd66 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 23 Feb 2026 19:20:31 -0800 Subject: [PATCH 04/13] fix fonts in preview, fix tab in preview --- frontend/preview/preview.tsx | 24 ++++--- frontend/preview/previews/tab.preview.tsx | 87 +++++++++++++++++------ 2 files changed, 80 insertions(+), 31 deletions(-) diff --git a/frontend/preview/preview.tsx b/frontend/preview/preview.tsx index d9a5a27cb7..c006e7e53f 100644 --- a/frontend/preview/preview.tsx +++ b/frontend/preview/preview.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import Logo from "@/app/asset/logo.svg"; +import { loadFonts } from "@/util/fontutil"; import React, { lazy, Suspense } from "react"; import { createRoot } from "react-dom/client"; @@ -45,17 +46,19 @@ function PreviewIndex() {
-
+

Available previews:

- {Object.keys(previews).map((name) => ( - - {name} - - ))} +
+ {Object.keys(previews).map((name) => ( + + {name} + + ))} +
); @@ -113,5 +116,6 @@ function PreviewApp() { return ; } +loadFonts(); const root = createRoot(document.getElementById("main")!); root.render(); diff --git a/frontend/preview/previews/tab.preview.tsx b/frontend/preview/previews/tab.preview.tsx index dc70a36d04..8be52ec772 100644 --- a/frontend/preview/previews/tab.preview.tsx +++ b/frontend/preview/previews/tab.preview.tsx @@ -2,31 +2,76 @@ // SPDX-License-Identifier: Apache-2.0 import { TabV } from "@/app/tab/tab"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; + +const TAB_WIDTH = 130; +const TAB_HEIGHT = 26; + +interface PreviewTabEntry { + tabId: string; + tabName: string; + active: boolean; + isBeforeActive: boolean; +} + +const tabDefs: PreviewTabEntry[] = [ + { tabId: "preview-tab-1", tabName: "Terminal", active: false, isBeforeActive: true }, + { tabId: "preview-tab-2", tabName: "My Tab", active: true, isBeforeActive: false }, + { tabId: "preview-tab-3", tabName: "T3", active: false, isBeforeActive: false }, +]; export function TabPreview() { - const [tabName, setTabName] = useState("My Tab"); + const [tabNames, setTabNames] = useState>( + Object.fromEntries(tabDefs.map((t) => [t.tabId, t.tabName])) + ); + const [activeTabId, setActiveTabId] = useState( + tabDefs.find((t) => t.active)?.tabId ?? tabDefs[0].tabId + ); + const tabRefs = useRef>({}); + + // The real tabbar imperatively sets opacity: 1 and transform after calculating + // tab positions. Tabs start at opacity: 0 in CSS, so we mirror that here. + useEffect(() => { + tabDefs.forEach((tab, index) => { + const el = tabRefs.current[tab.tabId]; + if (el) { + el.style.opacity = "1"; + el.style.transform = `translate3d(${index * TAB_WIDTH}px, 0, 0)`; + } + }); + }, []); return ( -
- console.log("click")} - onClose={() => console.log("close")} - onDragStart={() => {}} - onContextMenu={() => {}} - onRename={(newName) => { - console.log("rename", newName); - setTabName(newName); - }} - /> +
+ {tabDefs.map((tab, index) => { + const activeIndex = tabDefs.findIndex((t) => t.tabId === activeTabId); + const isActive = tab.tabId === activeTabId; + const isBeforeActive = index === activeIndex - 1; + return ( + { + tabRefs.current[tab.tabId] = el; + }} + tabId={tab.tabId} + tabName={tabNames[tab.tabId]} + active={isActive} + isBeforeActive={isBeforeActive} + isDragging={false} + tabWidth={TAB_WIDTH} + isNew={false} + indicator={null} + onClick={() => setActiveTabId(tab.tabId)} + onClose={() => console.log("close", tab.tabId)} + onDragStart={() => {}} + onContextMenu={() => {}} + onRename={(newName) => { + console.log("rename", tab.tabId, newName); + setTabNames((prev) => ({ ...prev, [tab.tabId]: newName })); + }} + /> + ); + })}
); } From 65856008fa52a4f25b2ffcf11f507e54e7d739b3 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 24 Feb 2026 10:17:08 -0800 Subject: [PATCH 05/13] update --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 63394f7e16..630ff9ec18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33496,7 +33496,7 @@ "@tailwindcss/vite": "^4.1.18", "@types/react": "^19", "@types/react-dom": "^19", - "@vitejs/plugin-react-swc": "4.2.3", + "@vitejs/plugin-react-swc": "^4.2.3", "tailwindcss": "^4.1.18", "typescript": "^5.9.3", "vite": "^6.4.1" From 447baa13e5d437548538838d344c74affdff1ee7 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 24 Feb 2026 20:34:08 -0800 Subject: [PATCH 06/13] add preventDefault back in --- frontend/app/tab/tab.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index 3bede3dda7..919d5a9afa 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -40,7 +40,21 @@ interface TabVProps { } const TabV = forwardRef((props, ref) => { - const { tabId, tabName, active, isBeforeActive, isDragging, tabWidth, isNew, indicator, onClick, onClose, onDragStart, onContextMenu, onRename } = props; + const { + tabId, + tabName, + active, + isBeforeActive, + isDragging, + tabWidth, + isNew, + indicator, + onClick, + onClose, + onDragStart, + onContextMenu, + onRename, + } = props; const [originalName, setOriginalName] = useState(tabName); const [isEditable, setIsEditable] = useState(false); @@ -368,6 +382,7 @@ const TabInner = forwardRef((props, ref) => { const handleContextMenu = useCallback( (e: React.MouseEvent) => { + e.preventDefault(); const menu = buildTabContextMenu(id, handleRenameTab, onClose); ContextMenuModel.getInstance().showContextMenu(menu, e); }, From e44362f336a27290285cbd346c1d33aac411c562 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 24 Feb 2026 20:39:00 -0800 Subject: [PATCH 07/13] fix more nits --- frontend/app/tab/tab.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index 919d5a9afa..061b175c12 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -83,6 +83,9 @@ const TabV = forwardRef((props, ref) => { editableRef.current.focus(); const range = document.createRange(); const selection = window.getSelection(); + if (!selection) { + return; + } range.selectNodeContents(editableRef.current); selection.removeAllRanges(); selection.addRange(range); @@ -97,6 +100,7 @@ const TabV = forwardRef((props, ref) => { }; const handleBlur = () => { + if (!editableRef.current) return; let newText = editableRef.current.innerText.trim(); newText = newText || originalName; editableRef.current.innerText = newText; @@ -110,6 +114,7 @@ const TabV = forwardRef((props, ref) => { selectEditableText(); return; } + if (!editableRef.current) return; const curLen = Array.from(editableRef.current.innerText).length; if (event.key === "Enter") { event.preventDefault(); @@ -304,6 +309,9 @@ const TabInner = forwardRef((props, ref) => { editableRef.current.focus(); const range = document.createRange(); const selection = window.getSelection(); + if (!selection) { + return; + } range.selectNodeContents(editableRef.current); selection.removeAllRanges(); selection.addRange(range); @@ -318,6 +326,7 @@ const TabInner = forwardRef((props, ref) => { }; const handleBlur = () => { + if (!editableRef.current) return; let newText = editableRef.current.innerText.trim(); newText = newText || originalName; editableRef.current.innerText = newText; @@ -332,6 +341,7 @@ const TabInner = forwardRef((props, ref) => { selectEditableText(); return; } + if (!editableRef.current) return; // this counts glyphs, not characters const curLen = Array.from(editableRef.current.innerText).length; if (event.key === "Enter") { From b299312eedb9708440fade4f586a214e2fe5af62 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 24 Feb 2026 20:50:20 -0800 Subject: [PATCH 08/13] actually swap in TabV --- frontend/app/tab/tab.tsx | 184 +++++++++------------------------------ 1 file changed, 42 insertions(+), 142 deletions(-) diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index 061b175c12..dfd0b6c702 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -37,6 +37,8 @@ interface TabVProps { onDragStart: (event: React.MouseEvent) => void; onContextMenu: (e: React.MouseEvent) => void; onRename: (newName: string) => void; + /** Optional ref that TabV populates with a startRename() function for external callers */ + renameRef?: React.RefObject<(() => void) | null>; } const TabV = forwardRef((props, ref) => { @@ -54,6 +56,7 @@ const TabV = forwardRef((props, ref) => { onDragStart, onContextMenu, onRename, + renameRef, } = props; const [originalName, setOriginalName] = useState(tabName); const [isEditable, setIsEditable] = useState(false); @@ -91,14 +94,23 @@ const TabV = forwardRef((props, ref) => { selection.addRange(range); }, []); - const handleRenameTab: React.MouseEventHandler = (event) => { - event?.stopPropagation(); + const startRename = useCallback(() => { setIsEditable(true); editableTimeoutRef.current = setTimeout(() => { selectEditableText(); }, 50); + }, [selectEditableText]); + + const handleRenameTab: React.MouseEventHandler = (event) => { + event?.stopPropagation(); + startRename(); }; + // Expose startRename to external callers (e.g. context menu in TabInner) + if (renameRef != null) { + (renameRef as React.MutableRefObject<(() => void) | null>).current = startRename; + } + const handleBlur = () => { if (!editableRef.current) return; let newText = editableRef.current.innerText.trim(); @@ -198,7 +210,7 @@ TabV.displayName = "TabV"; function buildTabContextMenu( id: string, - handleRenameTab: React.MouseEventHandler, + renameRef: React.RefObject<(() => void) | null>, onClose: (event: React.MouseEvent | null) => void ): ContextMenuItem[] { const menu: ContextMenuItem[] = []; @@ -217,7 +229,7 @@ function buildTabContextMenu( ); } menu.push( - { label: "Rename Tab", click: () => handleRenameTab(null) }, + { label: "Rename Tab", click: () => renameRef.current?.() }, { label: "Copy TabId", click: () => fireAndForget(() => navigator.clipboard.writeText(id)), @@ -277,90 +289,10 @@ interface TabProps { const TabInner = forwardRef((props, ref) => { const { id, active, isBeforeActive, isDragging, tabWidth, isNew, onLoaded, onSelect, onClose, onDragStart } = props; const [tabData, _] = useWaveObjectValue(makeORef("tab", id)); - const [originalName, setOriginalName] = useState(""); - const [isEditable, setIsEditable] = useState(false); const indicator = useAtomValue(getTabIndicatorAtom(id)); - const editableRef = useRef(null); - const editableTimeoutRef = useRef(null); const loadedRef = useRef(false); - const tabRef = useRef(null); - - useImperativeHandle(ref, () => tabRef.current as HTMLDivElement); - - useEffect(() => { - if (tabData?.name) { - setOriginalName(tabData.name); - } - }, [tabData]); - - useEffect(() => { - return () => { - if (editableTimeoutRef.current) { - clearTimeout(editableTimeoutRef.current); - } - }; - }, []); - - const selectEditableText = useCallback(() => { - if (!editableRef.current) { - return; - } - editableRef.current.focus(); - const range = document.createRange(); - const selection = window.getSelection(); - if (!selection) { - return; - } - range.selectNodeContents(editableRef.current); - selection.removeAllRanges(); - selection.addRange(range); - }, []); - - const handleRenameTab: React.MouseEventHandler = (event) => { - event?.stopPropagation(); - setIsEditable(true); - editableTimeoutRef.current = setTimeout(() => { - selectEditableText(); - }, 50); - }; - - const handleBlur = () => { - if (!editableRef.current) return; - let newText = editableRef.current.innerText.trim(); - newText = newText || originalName; - editableRef.current.innerText = newText; - setIsEditable(false); - fireAndForget(() => ObjectService.UpdateTabName(id, newText)); - setTimeout(() => refocusNode(null), 10); - }; - - const handleKeyDown: React.KeyboardEventHandler = (event) => { - if ((event.metaKey || event.ctrlKey) && event.key === "a") { - event.preventDefault(); - selectEditableText(); - return; - } - if (!editableRef.current) return; - // this counts glyphs, not characters - const curLen = Array.from(editableRef.current.innerText).length; - if (event.key === "Enter") { - event.preventDefault(); - event.stopPropagation(); - if (editableRef.current.innerText.trim() === "") { - editableRef.current.innerText = originalName; - } - editableRef.current.blur(); - } else if (event.key === "Escape") { - editableRef.current.innerText = originalName; - editableRef.current.blur(); - event.preventDefault(); - event.stopPropagation(); - } else if (curLen >= 14 && !["Backspace", "Delete", "ArrowLeft", "ArrowRight"].includes(event.key)) { - event.preventDefault(); - event.stopPropagation(); - } - }; + const renameRef = useRef<(() => void) | null>(null); useEffect(() => { if (!loadedRef.current) { @@ -369,19 +301,6 @@ const TabInner = forwardRef((props, ref) => { } }, [onLoaded]); - useEffect(() => { - if (tabRef.current && isNew) { - const initialWidth = `${(tabWidth / 3) * 2}px`; - tabRef.current.style.setProperty("--initial-tab-width", initialWidth); - tabRef.current.style.setProperty("--final-tab-width", `${tabWidth}px`); - } - }, [isNew, tabWidth]); - - // Prevent drag from being triggered on mousedown - const handleMouseDownOnClose = (event: React.MouseEvent) => { - event.stopPropagation(); - }; - const handleTabClick = () => { const currentIndicator = globalStore.get(getTabIndicatorAtom(id)); if (currentIndicator?.clearonfocus) { @@ -393,57 +312,38 @@ const TabInner = forwardRef((props, ref) => { const handleContextMenu = useCallback( (e: React.MouseEvent) => { e.preventDefault(); - const menu = buildTabContextMenu(id, handleRenameTab, onClose); + const menu = buildTabContextMenu(id, renameRef, onClose); ContextMenuModel.getInstance().showContextMenu(menu, e); }, - [handleRenameTab, id, onClose] + [id, onClose] + ); + + const handleRename = useCallback( + (newName: string) => { + fireAndForget(() => ObjectService.UpdateTabName(id, newName)); + setTimeout(() => refocusNode(null), 10); + }, + [id] ); return ( -
-
-
- {tabData?.name} -
- {indicator && ( -
- -
- )} - -
-
+ onRename={handleRename} + renameRef={renameRef} + /> ); }); const Tab = memo(TabInner); From 9dc6a04783af4bb76540adba0f24ee6441e8d3ed Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 24 Feb 2026 21:03:44 -0800 Subject: [PATCH 09/13] add useCallback --- frontend/app/tab/tab.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index dfd0b6c702..dc9a0c12b8 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -101,10 +101,13 @@ const TabV = forwardRef((props, ref) => { }, 50); }, [selectEditableText]); - const handleRenameTab: React.MouseEventHandler = (event) => { - event?.stopPropagation(); - startRename(); - }; + const handleRenameTab: React.MouseEventHandler = useCallback( + (event) => { + event?.stopPropagation(); + startRename(); + }, + [startRename] + ); // Expose startRename to external callers (e.g. context menu in TabInner) if (renameRef != null) { From 1ef041834346b2380ce8ec54eacb2b3a60706658 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 24 Feb 2026 21:03:52 -0800 Subject: [PATCH 10/13] fix tab naming scheme --- pkg/wcore/workspace.go | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/pkg/wcore/workspace.go b/pkg/wcore/workspace.go index f61306d897..b070d31107 100644 --- a/pkg/wcore/workspace.go +++ b/pkg/wcore/workspace.go @@ -7,6 +7,8 @@ import ( "context" "fmt" "log" + "regexp" + "strconv" "time" "github.com/google/uuid" @@ -195,6 +197,30 @@ func getTabPresetMeta() (waveobj.MetaMapType, error) { return presetMeta, nil } +var tabNameRe = regexp.MustCompile(`^T(\d+)$`) + +// getNextTabName returns the next auto-generated tab name (e.g. "T3") given a +// slice of existing tab names. It filters to names matching T[N] where N is a +// positive integer, finds the maximum N, and returns T[max+1]. If no matching +// names exist it returns "T1". +func getNextTabName(tabNames []string) string { + maxNum := 0 + for _, name := range tabNames { + m := tabNameRe.FindStringSubmatch(name) + if m == nil { + continue + } + n, err := strconv.Atoi(m[1]) + if err != nil || n <= 0 { + continue + } + if n > maxNum { + maxNum = n + } + } + return "T" + strconv.Itoa(maxNum+1) +} + // returns tabid func CreateTab(ctx context.Context, workspaceId string, tabName string, activateTab bool, isInitialLaunch bool) (string, error) { if tabName == "" { @@ -202,7 +228,15 @@ func CreateTab(ctx context.Context, workspaceId string, tabName string, activate if err != nil { return "", fmt.Errorf("workspace %s not found: %w", workspaceId, err) } - tabName = "T" + fmt.Sprint(len(ws.TabIds)+1) + tabNames := make([]string, 0, len(ws.TabIds)) + for _, tabId := range ws.TabIds { + tab, err := wstore.DBMustGet[*waveobj.Tab](ctx, tabId) + if err != nil || tab == nil { + continue + } + tabNames = append(tabNames, tab.Name) + } + tabName = getNextTabName(tabNames) } tab, err := createTabObj(ctx, workspaceId, tabName, nil) From 51e7327cd2eb2770e1b45de395cd351b448d1915 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 24 Feb 2026 21:04:39 -0800 Subject: [PATCH 11/13] add mutablerefobject to rules --- .roo/rules/rules.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.roo/rules/rules.md b/.roo/rules/rules.md index 6518187a1c..466c9e06d3 100644 --- a/.roo/rules/rules.md +++ b/.roo/rules/rules.md @@ -93,6 +93,7 @@ The full API is defined in custom.d.ts as type ElectronApi. - **CRITICAL** - useAtomValue and useAtom are React HOOKS. They cannot be used inline in JSX code, they must appear at the top of a component in the hooks area of the react code. - for simple functions, we prefer `if (!cond) { return }; functionality;` pattern overn `if (cond) { functionality }` because it produces less indentation and is easier to follow. - It is now 2026, so if you write new files, or update files use 2026 for the copyright year +- React.MutableRefObject is deprecated, just use React.RefObject now (current is now always mutable) ### Strict Comment Rules From a69e72dda8e74db72ce2e03313539491d082b838 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 24 Feb 2026 21:08:11 -0800 Subject: [PATCH 12/13] remove mutablerefobject cast --- frontend/app/tab/tab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index dc9a0c12b8..abc73d3509 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -111,7 +111,7 @@ const TabV = forwardRef((props, ref) => { // Expose startRename to external callers (e.g. context menu in TabInner) if (renameRef != null) { - (renameRef as React.MutableRefObject<(() => void) | null>).current = startRename; + renameRef.current = startRename; } const handleBlur = () => { From d69e4d4d79124f5934c57e33d6c86cf0ccdf195a Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 24 Feb 2026 21:42:57 -0800 Subject: [PATCH 13/13] fix nit --- frontend/app/tab/tab.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index abc73d3509..37a96ca525 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -242,7 +242,7 @@ function buildTabContextMenu( const fullConfig = globalStore.get(atoms.fullConfigAtom); const bgPresets: string[] = []; for (const key in fullConfig?.presets ?? {}) { - if (key.startsWith("bg@")) { + if (key.startsWith("bg@") && fullConfig.presets[key] != null) { bgPresets.push(key); } } @@ -255,10 +255,8 @@ function buildTabContextMenu( const submenu: ContextMenuItem[] = []; const oref = makeORef("tab", id); for (const presetName of bgPresets) { + // preset cannot be null (filtered above) const preset = fullConfig.presets[presetName]; - if (preset == null) { - continue; - } submenu.push({ label: preset["display:name"] ?? presetName, click: () =>