Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
7280bf4
update rules
sawka Feb 19, 2026
4c6fd13
first cut at new block-tab based badge system
sawka Feb 20, 2026
51489eb
Merge remote-tracking branch 'origin/main' into sawka/block-indicators
sawka Mar 2, 2026
a75253f
run go generate, fix baseds import
sawka Mar 2, 2026
f7fda6a
Merge remote-tracking branch 'origin/main' into sawka/block-indicators
sawka Mar 5, 2026
f3d27e5
move indicators to their own file (badge.ts)
sawka Mar 5, 2026
95ec727
move tabindicatormap too
sawka Mar 5, 2026
ac7f295
move subscription to badge.ts, clean up some warnings
sawka Mar 5, 2026
e3aa0b8
clean up some warnings
sawka Mar 5, 2026
30788d0
setup FE badge store
sawka Mar 5, 2026
a7acdb9
working on badge integration
sawka Mar 5, 2026
f980494
add clearall for badge event
sawka Mar 5, 2026
260c767
add clearbyid
sawka Mar 5, 2026
c448ee7
add badgewatchpid
sawka Mar 5, 2026
f30f394
working on `wsh badge`
sawka Mar 5, 2026
a88c3bf
hook up pid watching to wsh badge command
sawka Mar 5, 2026
8d6f2ad
checkpoint on moving from tabiindicators to badges
sawka Mar 5, 2026
339cd6c
clear transient tab badges with focus as well
sawka Mar 5, 2026
ccf64e6
more badge migration
sawka Mar 5, 2026
498e0d9
remove tabindicators (backend+frontend), more badges
sawka Mar 6, 2026
09fed4e
getting the badges to show... up to 3 on a tab...
sawka Mar 6, 2026
2fb15c4
add flag color
sawka Mar 6, 2026
1806574
add context menu to flag tab...
sawka Mar 6, 2026
144db86
remove badge persistence
sawka Mar 6, 2026
820f535
focus should not clear pidlinked badges
sawka Mar 6, 2026
897f2d4
update tab bar, change flag to be a flag, resort badges
sawka Mar 6, 2026
c737c6e
Merge remote-tracking branch 'origin/main' into sawka/block-indicators
sawka Mar 6, 2026
e50de18
clean up some scss
sawka Mar 6, 2026
ddc9cff
remove ::after psudo element, just render the dividers in react
sawka Mar 6, 2026
9d1007d
dont use ctx in long running poller
sawka Mar 9, 2026
2518d31
fix nits
sawka Mar 9, 2026
dc2315d
fix nit
sawka Mar 9, 2026
64f6d4f
merge main
sawka Mar 9, 2026
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
Prev Previous commit
Next Next commit
getting the badges to show... up to 3 on a tab...
  • Loading branch information
sawka committed Mar 6, 2026
commit 09fed4e998161339183e62201c3f28273368b414
21 changes: 14 additions & 7 deletions cmd/wsh/cmd/wshcmd-badge.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@ var (
func init() {
rootCmd.AddCommand(badgeCmd)
badgeCmd.Flags().StringVar(&badgeColor, "color", "", "badge color")
badgeCmd.Flags().Float64Var(&badgePriority, "priority", 0, "badge priority")
badgeCmd.Flags().Float64Var(&badgePriority, "priority", 10, "badge priority")
badgeCmd.Flags().BoolVar(&badgeClear, "clear", false, "clear the badge")
badgeCmd.Flags().BoolVar(&badgePersistent, "persistent", false, "make badge persistent (survives restarts)")
badgeCmd.Flags().BoolVar(&badgePersistent, "persistent", false, "make badge persistent (survives restarts, default priority 5)")
badgeCmd.Flags().BoolVar(&badgeBeep, "beep", false, "play system bell sound")
badgeCmd.Flags().IntVar(&badgePid, "pid", 0, "watch a pid and automatically clear the badge when it exits")
badgeCmd.Flags().IntVar(&badgePid, "pid", 0, "watch a pid and automatically clear the badge when it exits (sets persistent, default priority 5)")
}

func badgeRun(cmd *cobra.Command, args []string) (rtnErr error) {
Expand All @@ -52,6 +52,12 @@ func badgeRun(cmd *cobra.Command, args []string) (rtnErr error) {
if badgePid > 0 && runtime.GOOS == "windows" {
return fmt.Errorf("--pid flag is not supported on Windows")
}
if badgePid > 0 {
badgePersistent = true
}
if badgePersistent && !cmd.Flags().Changed("priority") {
badgePriority = 5
}

oref, err := resolveBlockArg()
if err != nil {
Expand All @@ -77,10 +83,11 @@ func badgeRun(cmd *cobra.Command, args []string) (rtnErr error) {
return fmt.Errorf("generating badge id: %v", err)
}
eventData.Badge = &baseds.Badge{
BadgeId: badgeId.String(),
Icon: icon,
Color: badgeColor,
Priority: badgePriority,
BadgeId: badgeId.String(),
Icon: icon,
Color: badgeColor,
Priority: badgePriority,
PidLinked: badgePid > 0,
}
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/wsh/cmd/wshcmd-tabindicator.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func init() {
rootCmd.AddCommand(tabIndicatorCmd)
tabIndicatorCmd.Flags().StringVar(&tabIndicatorTabId, "tabid", "", "tab id (defaults to WAVETERM_TABID)")
tabIndicatorCmd.Flags().StringVar(&tabIndicatorColor, "color", "", "indicator color")
tabIndicatorCmd.Flags().Float64Var(&tabIndicatorPriority, "priority", 0, "indicator priority")
tabIndicatorCmd.Flags().Float64Var(&tabIndicatorPriority, "priority", 10, "indicator priority")
tabIndicatorCmd.Flags().BoolVar(&tabIndicatorClear, "clear", false, "clear the indicator")
tabIndicatorCmd.Flags().BoolVar(&tabIndicatorPersistent, "persistent", false, "make indicator persistent (don't clear on focus)")
tabIndicatorCmd.Flags().BoolVar(&tabIndicatorBeep, "beep", false, "play system bell sound")
Expand Down
22 changes: 18 additions & 4 deletions frontend/app/app.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2025, Command Line Inc.
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import {
Expand All @@ -23,7 +23,7 @@ import clsx from "clsx";
import debug from "debug";
import { Provider, useAtomValue } from "jotai";
import "overlayscrollbars/overlayscrollbars.css";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { AppBackground } from "./app-bg";
Expand Down Expand Up @@ -223,11 +223,21 @@ const BadgeAutoClearing = () => {
const focusedBlockId = focusedNode?.data?.blockId;
const badge = useAtomValue(getBlockBadgeAtom(focusedBlockId));
const tabTransientBadge = useAtomValue(getTransientBadgeAtom(tabId != null ? `tab:${tabId}` : null));
const prevFocusedBlockIdRef = useRef<string>(null);
const prevDocHasFocusRef = useRef<boolean>(false);
const prevTabDocHasFocusRef = useRef<boolean>(false);

useEffect(() => {
if (!focusedBlockId || !badge || !documentHasFocus) {
prevFocusedBlockIdRef.current = focusedBlockId;
prevDocHasFocusRef.current = documentHasFocus;
return;
}
const focusSwitched =
prevFocusedBlockIdRef.current !== focusedBlockId || prevDocHasFocusRef.current !== documentHasFocus;
prevFocusedBlockIdRef.current = focusedBlockId;
prevDocHasFocusRef.current = documentHasFocus;
const delay = focusSwitched ? 1000 : 3000;
const timeoutId = setTimeout(() => {
if (!document.hasFocus()) {
return;
Expand All @@ -236,20 +246,24 @@ const BadgeAutoClearing = () => {
if (currentFocusedNode?.data?.blockId === focusedBlockId) {
clearTransientBadgesForBlock(focusedBlockId);
}
}, 3000);
}, delay);
return () => clearTimeout(timeoutId);
}, [focusedBlockId, badge, documentHasFocus]);

useEffect(() => {
if (!tabId || !tabTransientBadge || !documentHasFocus) {
prevTabDocHasFocusRef.current = documentHasFocus;
return;
}
const focusSwitched = prevTabDocHasFocusRef.current !== documentHasFocus;
prevTabDocHasFocusRef.current = documentHasFocus;
const delay = focusSwitched ? 1000 : 3000;
const timeoutId = setTimeout(() => {
if (!document.hasFocus()) {
return;
}
clearTransientBadgeForTab(tabId);
}, 3000);
}, delay);
return () => clearTimeout(timeoutId);
}, [tabId, tabTransientBadge, documentHasFocus]);

Expand Down
36 changes: 22 additions & 14 deletions frontend/app/store/badge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { fireAndForget, NullAtom } from "@/util/util";
import { v7 as uuidv7, version as uuidVersion } from "uuid";
import { atom, Atom, PrimitiveAtom } from "jotai";
import { v7 as uuidv7, version as uuidVersion } from "uuid";
import { globalStore } from "./jotaiStore";
import * as WOS from "./wos";
import { waveEventSubscribeSingle } from "./wps";

const PersistentBadgeMap = new Map<string, PrimitiveAtom<Badge>>();
const TransientBadgeMap = new Map<string, PrimitiveAtom<Badge>>();
const BlockBadgeAtomCache = new Map<string, Atom<Badge>>();
const CombinedBadgeMap = new Map<string, Atom<Badge>>();
const TabBadgeAtomCache = new Map<string, Atom<Badge[]>>();

function clearBadgeInternal(oref: string, persistent: boolean) {
Expand Down Expand Up @@ -74,15 +74,11 @@ function clearBadgesForTab(tabId: string) {
}
}

function getBlockBadgeAtom(blockId: string): Atom<Badge> {
if (blockId == null) {
return NullAtom as Atom<Badge>;
}
let rtn = BlockBadgeAtomCache.get(blockId);
function getCombinedBadgeAtom(oref: string): Atom<Badge> {
let rtn = CombinedBadgeMap.get(oref);
if (rtn != null) {
return rtn;
}
const oref = WOS.makeORef("block", blockId);
const persistentAtom = getPersistentBadgeAtom(oref);
const transientAtom = getTransientBadgeAtom(oref);
rtn = atom((get) => {
Expand All @@ -99,10 +95,18 @@ function getBlockBadgeAtom(blockId: string): Atom<Badge> {
}
return transient.badgeid >= persistent.badgeid ? transient : persistent;
});
BlockBadgeAtomCache.set(blockId, rtn);
CombinedBadgeMap.set(oref, rtn);
return rtn;
}

function getBlockBadgeAtom(blockId: string): Atom<Badge> {
if (blockId == null) {
return NullAtom as Atom<Badge>;
}
const oref = WOS.makeORef("block", blockId);
return getCombinedBadgeAtom(oref);
}

function getTabBadgeAtom(tabId: string): Atom<Badge[]> {
if (tabId == null) {
return NullAtom as Atom<Badge[]>;
Expand All @@ -111,17 +115,23 @@ function getTabBadgeAtom(tabId: string): Atom<Badge[]> {
if (rtn != null) {
return rtn;
}
const tabAtom = atom((get) => WOS.getObjectValue<Tab>(WOS.makeORef("tab", tabId), get));
const tabOref = WOS.makeORef("tab", tabId);
const tabTransientAtom = getTransientBadgeAtom(tabOref);
const tabAtom = atom((get) => WOS.getObjectValue<Tab>(tabOref, get));
rtn = atom((get) => {
const tab = get(tabAtom);
const blockIds = tab?.blockids ?? [];
const badges: Badge[] = [];
for (const blockId of blockIds) {
const badge = get(getBlockBadgeAtom(blockId));
const badge = get(getCombinedBadgeAtom(WOS.makeORef("block", blockId)));
if (badge != null) {
badges.push(badge);
}
}
const tabBadge = get(tabTransientAtom);
if (tabBadge != null) {
badges.push(tabBadge);
}
badges.sort((a, b) => {
if (a.priority !== b.priority) {
return b.priority - a.priority;
Expand Down Expand Up @@ -221,9 +231,7 @@ function setupBadgesSubscription() {
if (data?.oref == null) {
return;
}
const curAtom = data.persistent
? getPersistentBadgeAtom(data.oref)
: getTransientBadgeAtom(data.oref);
const curAtom = data.persistent ? getPersistentBadgeAtom(data.oref) : getTransientBadgeAtom(data.oref);
if (data.clearbyid) {
const existing = globalStore.get(curAtom);
if (existing?.badgeid === data.clearbyid) {
Expand Down
67 changes: 37 additions & 30 deletions frontend/app/tab/tab.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright 2025, Command Line Inc.
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { clearAllBadges, clearBadgesForTab, getTabBadgeAtom } from "@/app/store/badge";
import { getTabBadgeAtom } from "@/app/store/badge";
import { atoms, globalStore, recordTEvent, refocusNode } from "@/app/store/global";
import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
Expand All @@ -23,7 +23,7 @@ interface TabVProps {
isDragging: boolean;
tabWidth: number;
isNew: boolean;
badge?: Badge | null;
badges?: Badge[] | null;
onClick: () => void;
onClose: (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null) => void;
onDragStart: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
Expand All @@ -33,6 +33,37 @@ interface TabVProps {
renameRef?: React.RefObject<(() => void) | null>;
}

interface TabBadgesProps {
badges?: Badge[] | null;
}

function TabBadges({ badges }: TabBadgesProps) {
if (!badges?.[0]) {
return null;
}
const firstBadge = badges[0];
const extraBadges = badges.slice(1, 3);
return (
<div className="tab-indicator pointer-events-none flex items-center">
<i
className={makeIconClass(firstBadge.icon, true, { defaultIcon: "circle-small" }) + " text-[12px]"}
style={{ color: firstBadge.color || "#fbbf24" }}
/>
{extraBadges.length > 0 && (
<div className="flex flex-col items-center justify-center gap-[2px] ml-[2px]">
{extraBadges.map((badge, idx) => (
<div
key={idx}
className="w-[4px] h-[4px] rounded-full"
style={{ backgroundColor: badge.color || "#fbbf24" }}
/>
))}
</div>
)}
</div>
);
}

const TabV = forwardRef<HTMLDivElement, TabVProps>((props, ref) => {
const {
tabId,
Expand All @@ -42,7 +73,7 @@ const TabV = forwardRef<HTMLDivElement, TabVProps>((props, ref) => {
isDragging,
tabWidth,
isNew,
badge,
badges,
onClick,
onClose,
onDragStart,
Expand Down Expand Up @@ -179,11 +210,7 @@ const TabV = forwardRef<HTMLDivElement, TabVProps>((props, ref) => {
>
{tabName}
</div>
{badge && (
<div className="tab-indicator pointer-events-none" style={{ color: badge.color || "#fbbf24" }}>
<i className={makeIconClass(badge.icon, true, { defaultIcon: "circle-small" })} />
</div>
)}
<TabBadges badges={badges} />
<Button
className="ghost grey close"
onClick={onClose}
Expand All @@ -205,25 +232,6 @@ function buildTabContextMenu(
onClose: (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null) => void
): ContextMenuItem[] {
const menu: ContextMenuItem[] = [];
const currentBadges = globalStore.get(getTabBadgeAtom(id));
if (currentBadges?.length > 0) {
menu.push(
{
label: "Clear Tab Badges",
click: () => {
clearBadgesForTab(id);
},
},
{
label: "Clear All Badges",
click: () => {
clearAllBadges(true);
clearAllBadges(false);
},
},
{ type: "separator" }
);
}
menu.push(
{ label: "Rename Tab", click: () => renameRef.current?.() },
{
Expand Down Expand Up @@ -284,7 +292,6 @@ const TabInner = forwardRef<HTMLDivElement, TabProps>((props, ref) => {
const { id, active, isBeforeActive, isDragging, tabWidth, isNew, onLoaded, onSelect, onClose, onDragStart } = props;
const [tabData, _] = useWaveObjectValue<Tab>(makeORef("tab", id));
const badges = useAtomValue(getTabBadgeAtom(id));
const badge = badges?.[0] ?? null;

const loadedRef = useRef(false);
const renameRef = useRef<(() => void) | null>(null);
Expand Down Expand Up @@ -327,7 +334,7 @@ const TabInner = forwardRef<HTMLDivElement, TabProps>((props, ref) => {
isDragging={isDragging}
tabWidth={tabWidth}
isNew={isNew}
badge={badge}
badges={badges}
onClick={handleTabClick}
onClose={onClose}
onDragStart={onDragStart}
Expand Down
Loading