From 7280bf48f26c9cf9fd90954b6158335d274abac9 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 19 Feb 2026 13:44:34 -0800 Subject: [PATCH 01/29] update rules --- .roo/rules/rules.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.roo/rules/rules.md b/.roo/rules/rules.md index 9ab5f996d0..89f9d16efc 100644 --- a/.roo/rules/rules.md +++ b/.roo/rules/rules.md @@ -84,8 +84,7 @@ The full API is defined in custom.d.ts as type ElectronApi. - **CRITICAL: Completion format MUST be: "Done: [one-line description]"** - **Keep your Task Completed summaries VERY short** -- **No lengthy pre-completion summaries** - Do not provide detailed explanations of implementation before using attempt_completion -- **No recaps of changes** - Skip explaining what was done before completion +- **No double-summarization** - Put your summary ONLY inside attempt_completion. Do not write a summary in the message body AND then repeat it in attempt_completion. One summary, one place. - **Go directly to completion** - After making changes, proceed directly to attempt_completion without summarizing - The project is currently an un-released POC / MVP. Do not worry about backward compatibility when making changes - With React hooks, always complete all hook calls at the top level before any conditional returns (including jotai hook calls useAtom and useAtomValue); when a user explicitly tells you a function handles null inputs, trust them and stop trying to "protect" it with unnecessary checks or workarounds. From 4c6fd13c7496c322bf0e660d7bbca4102f1c201c Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 19 Feb 2026 16:44:45 -0800 Subject: [PATCH 02/29] first cut at new block-tab based badge system --- cmd/server/main-server.go | 5 + pkg/baseds/baseds.go | 13 ++ pkg/waveobj/wtype.go | 16 ++- pkg/wcore/badge.go | 195 ++++++++++++++++++++++++++++++ pkg/wps/wpstypes.go | 1 + pkg/wshrpc/wshrpctypes.go | 2 + pkg/wshrpc/wshserver/wshserver.go | 5 + 7 files changed, 231 insertions(+), 6 deletions(-) create mode 100644 pkg/wcore/badge.go diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index 1dfcf0d824..c6b8ec99c0 100644 --- a/cmd/server/main-server.go +++ b/cmd/server/main-server.go @@ -572,6 +572,11 @@ func main() { jobcontroller.InitJobController() blockcontroller.InitBlockController() wcore.InitTabIndicatorStore() + err = wcore.InitBadgeStore() + if err != nil { + log.Printf("error initializing badge store: %v\n", err) + return + } go func() { defer func() { panichandler.PanicHandler("GetSystemSummary", recover()) diff --git a/pkg/baseds/baseds.go b/pkg/baseds/baseds.go index 8ede225968..0a037f9884 100644 --- a/pkg/baseds/baseds.go +++ b/pkg/baseds/baseds.go @@ -12,3 +12,16 @@ type RpcInputChType struct { MsgBytes []byte IngressLinkId LinkId } + +type Badge struct { + Icon string `json:"icon"` + Color string `json:"color,omitempty"` + Priority float64 `json:"priority"` +} + +type BadgeEvent struct { + ORef string `json:"oref"` + Persistent bool `json:"persistent,omitempty"` + Clear bool `json:"clear,omitempty"` + Badge *Badge `json:"badge,omitempty"` +} diff --git a/pkg/waveobj/wtype.go b/pkg/waveobj/wtype.go index 0ac9e92eb1..150f24075a 100644 --- a/pkg/waveobj/wtype.go +++ b/pkg/waveobj/wtype.go @@ -7,6 +7,8 @@ import ( "encoding/json" "fmt" "reflect" + + "github.com/wavetermdev/waveterm/pkg/baseds" ) type UpdatesRtnType = []WaveObjUpdate @@ -187,12 +189,13 @@ func (*Workspace) GetOType() string { } type Tab struct { - OID string `json:"oid"` - Version int `json:"version"` - Name string `json:"name"` - LayoutState string `json:"layoutstate"` - BlockIds []string `json:"blockids"` - Meta MetaMapType `json:"meta"` + OID string `json:"oid"` + Version int `json:"version"` + Name string `json:"name"` + LayoutState string `json:"layoutstate"` + BlockIds []string `json:"blockids"` + Meta MetaMapType `json:"meta"` + Badge *baseds.Badge `json:"badge,omitempty"` } func (*Tab) GetOType() string { @@ -292,6 +295,7 @@ type Block struct { Meta MetaMapType `json:"meta"` SubBlockIds []string `json:"subblockids,omitempty"` JobId string `json:"jobid,omitempty"` // if set, the block will render this jobid's pty output + Badge *baseds.Badge `json:"badge,omitempty"` } func (*Block) GetOType() string { diff --git a/pkg/wcore/badge.go b/pkg/wcore/badge.go new file mode 100644 index 0000000000..d02edbe52b --- /dev/null +++ b/pkg/wcore/badge.go @@ -0,0 +1,195 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wcore + +import ( + "context" + "fmt" + "log" + "sync" + "time" + + "github.com/wavetermdev/waveterm/pkg/baseds" + "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" + "github.com/wavetermdev/waveterm/pkg/wstore" +) + +// BadgeStore is a write-through cache for badges. +// Each oref can carry two independent badges: +// - a persistent badge (stored in the DB and survives restarts) +// - a transient badge (in-memory only, cleared on restart) +// +// Values are stored by value (not pointer) to prevent external mutation. +type BadgeStore struct { + lock *sync.Mutex + persistent map[string]baseds.Badge // keyed by oref string + transient map[string]baseds.Badge // keyed by oref string +} + +var globalBadgeStore = &BadgeStore{ + lock: &sync.Mutex{}, + persistent: make(map[string]baseds.Badge), + transient: make(map[string]baseds.Badge), +} + +// InitBadgeStore loads all persisted badges from the DB into the in-memory +// cache and subscribes to incoming badge events. +func InitBadgeStore() error { + log.Printf("initializing badge store\n") + + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + + // Load persisted badges from all tabs. + tabs, err := wstore.DBGetAllObjsByType[*waveobj.Tab](ctx, waveobj.OType_Tab) + if err != nil { + return fmt.Errorf("badge store: error loading tabs from DB: %w", err) + } + for _, tab := range tabs { + if tab.Badge != nil { + oref := waveobj.MakeORef(waveobj.OType_Tab, tab.OID).String() + globalBadgeStore.persistent[oref] = *tab.Badge + } + } + + // Load persisted badges from all blocks. + blocks, err := wstore.DBGetAllObjsByType[*waveobj.Block](ctx, waveobj.OType_Block) + if err != nil { + return fmt.Errorf("badge store: error loading blocks from DB: %w", err) + } + for _, block := range blocks { + if block.Badge != nil { + oref := waveobj.MakeORef(waveobj.OType_Block, block.OID).String() + globalBadgeStore.persistent[oref] = *block.Badge + } + } + + log.Printf("badge store: loaded %d persisted badges\n", len(globalBadgeStore.persistent)) + + // Subscribe to badge events so we can update the cache when events arrive. + rpcClient := wshclient.GetBareRpcClient() + rpcClient.EventListener.On(wps.Event_Badge, handleBadgeEvent) + wshclient.EventSubCommand(rpcClient, wps.SubscriptionRequest{ + Event: wps.Event_Badge, + AllScopes: true, + }, nil) + + return nil +} + +func handleBadgeEvent(event *wps.WaveEvent) { + if event.Event != wps.Event_Badge { + return + } + var data baseds.BadgeEvent + err := utilfn.ReUnmarshal(&data, event.Data) + if err != nil { + log.Printf("badge store: error unmarshaling BadgeEvent: %v\n", err) + return + } + if data.ORef == "" { + log.Printf("badge store: received badge event with empty oref\n") + return + } + + oref, err := waveobj.ParseORef(data.ORef) + if err != nil { + log.Printf("badge store: error parsing oref %q: %v\n", data.ORef, err) + return + } + + setBadge(oref, data.Badge, data.Persistent, data.Clear) +} + +// setBadge updates the appropriate in-memory map and, when persistent, writes +// through to the DB and fires a WaveObjUpdate event so the frontend stays in sync. +func setBadge(oref waveobj.ORef, badge *baseds.Badge, persistent bool, clear bool) { + globalBadgeStore.lock.Lock() + defer globalBadgeStore.lock.Unlock() + + orefStr := oref.String() + + if persistent { + if clear || badge == nil { + delete(globalBadgeStore.persistent, orefStr) + log.Printf("badge store: persistent badge cleared: oref=%s\n", orefStr) + go persistBadge(oref, nil) + } else { + globalBadgeStore.persistent[orefStr] = *badge + log.Printf("badge store: persistent badge set: oref=%s badge=%+v\n", orefStr, *badge) + go persistBadge(oref, badge) + } + } else { + if clear || badge == nil { + delete(globalBadgeStore.transient, orefStr) + log.Printf("badge store: transient badge cleared: oref=%s\n", orefStr) + } else { + globalBadgeStore.transient[orefStr] = *badge + log.Printf("badge store: transient badge set: oref=%s badge=%+v\n", orefStr, *badge) + } + } +} + +// persistBadge writes the badge (or nil to clear) to the appropriate DB object. +func persistBadge(oref waveobj.ORef, badge *baseds.Badge) { + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + + switch oref.OType { + case waveobj.OType_Tab: + err := wstore.DBUpdateFn[*waveobj.Tab](ctx, oref.OID, func(tab *waveobj.Tab) { + tab.Badge = badge + }) + if err != nil { + log.Printf("badge store: error persisting badge for tab %s: %v\n", oref.OID, err) + return + } + log.Printf("badge store: persisted badge for tab %s\n", oref.OID) + SendWaveObjUpdate(oref) + + case waveobj.OType_Block: + err := wstore.DBUpdateFn[*waveobj.Block](ctx, oref.OID, func(block *waveobj.Block) { + block.Badge = badge + }) + if err != nil { + log.Printf("badge store: error persisting badge for block %s: %v\n", oref.OID, err) + return + } + log.Printf("badge store: persisted badge for block %s\n", oref.OID) + SendWaveObjUpdate(oref) + + default: + log.Printf("badge store: unsupported oref type for persistence: %s\n", oref.OType) + } +} + +// GetAllBadges returns a snapshot of all currently active badges as a slice of +// BadgeEvent values. Each entry carries the ORef, the Persistent flag, and the +// Badge itself. An oref that has both a persistent and a transient badge will +// appear twice in the result. +func GetAllBadges() []baseds.BadgeEvent { + globalBadgeStore.lock.Lock() + defer globalBadgeStore.lock.Unlock() + + result := make([]baseds.BadgeEvent, 0, len(globalBadgeStore.persistent)+len(globalBadgeStore.transient)) + for orefStr, badge := range globalBadgeStore.persistent { + b := badge // copy + result = append(result, baseds.BadgeEvent{ + ORef: orefStr, + Persistent: true, + Badge: &b, + }) + } + for orefStr, badge := range globalBadgeStore.transient { + b := badge // copy + result = append(result, baseds.BadgeEvent{ + ORef: orefStr, + Badge: &b, + }) + } + return result +} diff --git a/pkg/wps/wpstypes.go b/pkg/wps/wpstypes.go index 0bf110a7c5..5de5d9fbce 100644 --- a/pkg/wps/wpstypes.go +++ b/pkg/wps/wpstypes.go @@ -27,6 +27,7 @@ const ( Event_AIModeConfig = "waveai:modeconfig" Event_TabIndicator = "tab:indicator" Event_BlockJobStatus = "block:jobstatus" // type: BlockJobStatusData + Event_Badge = "badge" // type: baseds.BadgeEvent ) type WaveEvent struct { diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index eefd7fabd7..e6cb8829bc 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" + "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata" "github.com/wavetermdev/waveterm/pkg/vdom" "github.com/wavetermdev/waveterm/pkg/waveobj" @@ -88,6 +89,7 @@ type WshRpcInterface interface { DisposeSuggestionsCommand(ctx context.Context, widgetId string) error GetTabCommand(ctx context.Context, tabId string) (*waveobj.Tab, error) GetAllTabIndicatorsCommand(ctx context.Context) (map[string]*TabIndicator, error) + GetAllBadgesCommand(ctx context.Context) ([]baseds.BadgeEvent, error) // connection functions ConnStatusCommand(ctx context.Context) ([]ConnStatus, error) diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index ff6a0eae85..2d54b07b8f 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -22,6 +22,7 @@ import ( "github.com/skratchdot/open-golang/open" "github.com/wavetermdev/waveterm/pkg/aiusechat" + "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/blockcontroller" @@ -1411,6 +1412,10 @@ func (ws *WshServer) GetAllTabIndicatorsCommand(ctx context.Context) (map[string return wcore.GetAllTabIndicators(), nil } +func (ws *WshServer) GetAllBadgesCommand(ctx context.Context) ([]baseds.BadgeEvent, error) { + return wcore.GetAllBadges(), nil +} + func (ws *WshServer) GetSecretsCommand(ctx context.Context, names []string) (map[string]string, error) { result := make(map[string]string) for _, name := range names { From a75253fbac631218e3e5c27cecbd0af05a006e89 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 2 Mar 2026 12:11:32 -0800 Subject: [PATCH 03/29] run go generate, fix baseds import --- cmd/generatego/main-generatego.go | 11 ++++++----- frontend/app/store/wshclientapi.ts | 5 +++++ frontend/types/gotypes.d.ts | 18 ++++++++++++++++++ frontend/types/waveevent.d.ts | 5 +++-- pkg/tsgen/tsgenevent.go | 2 ++ pkg/wshrpc/wshclient/wshclient.go | 17 ++++++++++++----- schema/waveai.json | 2 +- 7 files changed, 47 insertions(+), 13 deletions(-) diff --git a/cmd/generatego/main-generatego.go b/cmd/generatego/main-generatego.go index 74767a5bc6..ab7e338439 100644 --- a/cmd/generatego/main-generatego.go +++ b/cmd/generatego/main-generatego.go @@ -24,14 +24,15 @@ func GenerateWshClient() error { fmt.Fprintf(os.Stderr, "generating wshclient file to %s\n", WshClientFileName) var buf strings.Builder gogen.GenerateBoilerplate(&buf, "wshclient", []string{ + "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes", + "github.com/wavetermdev/waveterm/pkg/baseds", "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata", - "github.com/wavetermdev/waveterm/pkg/wshutil", - "github.com/wavetermdev/waveterm/pkg/wshrpc", - "github.com/wavetermdev/waveterm/pkg/wconfig", + "github.com/wavetermdev/waveterm/pkg/vdom", "github.com/wavetermdev/waveterm/pkg/waveobj", + "github.com/wavetermdev/waveterm/pkg/wconfig", "github.com/wavetermdev/waveterm/pkg/wps", - "github.com/wavetermdev/waveterm/pkg/vdom", - "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes", + "github.com/wavetermdev/waveterm/pkg/wshrpc", + "github.com/wavetermdev/waveterm/pkg/wshutil", }) wshDeclMap := wshrpc.GenerateWshCommandDeclMap() for _, key := range utilfn.GetOrderedMapKeys(wshDeclMap) { diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index e246d761ef..f11af2121c 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -317,6 +317,11 @@ class RpcApiType { return client.wshRpcCall("focuswindow", data, opts); } + // command "getallbadges" [call] + GetAllBadgesCommand(client: WshClient, opts?: RpcOpts): Promise { + return client.wshRpcCall("getallbadges", null, opts); + } + // command "getalltabindicators" [call] GetAllTabIndicatorsCommand(client: WshClient, opts?: RpcOpts): Promise<{[key: string]: TabIndicator}> { return client.wshRpcCall("getalltabindicators", null, opts); diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 313f8dbdec..0653a1caf0 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -25,6 +25,7 @@ declare global { "ai:thinkinglevel"?: string; "ai:verbosity"?: string; "ai:endpoint"?: string; + "ai:proxyurl"?: string; "ai:azureapiversion"?: string; "ai:apitoken"?: string; "ai:apitokensecretname"?: string; @@ -107,6 +108,21 @@ declare global { iconcolor: string; }; + // baseds.Badge + type Badge = { + icon: string; + color?: string; + priority: number; + }; + + // baseds.BadgeEvent + type BadgeEvent = { + oref: string; + persistent?: boolean; + clear?: boolean; + badge?: Badge; + }; + // waveobj.Block type Block = WaveObj & { parentoref?: string; @@ -114,6 +130,7 @@ declare global { stickers?: StickerType[]; subblockids?: string[]; jobid?: string; + badge?: Badge; }; // blockcontroller.BlockControllerRuntimeStatus @@ -1559,6 +1576,7 @@ declare global { name: string; layoutstate: string; blockids: string[]; + badge?: Badge; }; // wshrpc.TabIndicator diff --git a/frontend/types/waveevent.d.ts b/frontend/types/waveevent.d.ts index 81eec9bf9d..6eafa1f7e6 100644 --- a/frontend/types/waveevent.d.ts +++ b/frontend/types/waveevent.d.ts @@ -6,7 +6,7 @@ declare global { // wps.WaveEvent - type WaveEventName = "blockclose" | "connchange" | "sysinfo" | "controllerstatus" | "builderstatus" | "builderoutput" | "waveobj:update" | "blockfile" | "config" | "userinput" | "route:down" | "route:up" | "workspace:update" | "waveai:ratelimit" | "waveapp:appgoupdated" | "tsunami:updatemeta" | "waveai:modeconfig" | "tab:indicator" | "block:jobstatus"; + type WaveEventName = "blockclose" | "connchange" | "sysinfo" | "controllerstatus" | "builderstatus" | "builderoutput" | "waveobj:update" | "blockfile" | "config" | "userinput" | "route:down" | "route:up" | "workspace:update" | "waveai:ratelimit" | "waveapp:appgoupdated" | "tsunami:updatemeta" | "waveai:modeconfig" | "tab:indicator" | "block:jobstatus" | "badge"; type WaveEvent = { event: WaveEventName; @@ -33,7 +33,8 @@ declare global { { event: "tsunami:updatemeta"; data?: AppMeta; } | { event: "waveai:modeconfig"; data?: AIModeConfigUpdate; } | { event: "tab:indicator"; data?: TabIndicatorEventData; } | - { event: "block:jobstatus"; data?: BlockJobStatusData; } + { event: "block:jobstatus"; data?: BlockJobStatusData; } | + { event: "badge"; data?: BadgeEvent; } ); } diff --git a/pkg/tsgen/tsgenevent.go b/pkg/tsgen/tsgenevent.go index b232d517c7..b18266782c 100644 --- a/pkg/tsgen/tsgenevent.go +++ b/pkg/tsgen/tsgenevent.go @@ -10,6 +10,7 @@ import ( "strconv" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" + "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/blockcontroller" "github.com/wavetermdev/waveterm/pkg/userinput" "github.com/wavetermdev/waveterm/pkg/waveobj" @@ -40,6 +41,7 @@ var WaveEventDataTypes = map[string]reflect.Type{ wps.Event_AIModeConfig: reflect.TypeOf(wconfig.AIModeConfigUpdate{}), wps.Event_TabIndicator: reflect.TypeOf(wshrpc.TabIndicatorEventData{}), wps.Event_BlockJobStatus: reflect.TypeOf(wshrpc.BlockJobStatusData{}), + wps.Event_Badge: reflect.TypeOf(baseds.BadgeEvent{}), } func getWaveEventDataTSType(eventName string, tsTypesMap map[reflect.Type]string) string { diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 6ac4746d16..85f40d5846 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -6,14 +6,15 @@ package wshclient import ( + "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" + "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata" - "github.com/wavetermdev/waveterm/pkg/wshutil" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wconfig" + "github.com/wavetermdev/waveterm/pkg/vdom" "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wps" - "github.com/wavetermdev/waveterm/pkg/vdom" - "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshutil" ) // command "activity", wshserver.ActivityCommand @@ -386,6 +387,12 @@ func FocusWindowCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) er return err } +// command "getallbadges", wshserver.GetAllBadgesCommand +func GetAllBadgesCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]baseds.BadgeEvent, error) { + resp, err := sendRpcRequestCallHelper[[]baseds.BadgeEvent](w, "getallbadges", nil, opts) + return resp, err +} + // command "getalltabindicators", wshserver.GetAllTabIndicatorsCommand func GetAllTabIndicatorsCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (map[string]*wshrpc.TabIndicator, error) { resp, err := sendRpcRequestCallHelper[map[string]*wshrpc.TabIndicator](w, "getalltabindicators", nil, opts) diff --git a/schema/waveai.json b/schema/waveai.json index 8fc96c1528..7279777a4c 100644 --- a/schema/waveai.json +++ b/schema/waveai.json @@ -113,4 +113,4 @@ "$ref": "#/$defs/AIModeConfigType" }, "type": "object" -} +} \ No newline at end of file From f3d27e5672ff4a7689b270f48854e32bea5ccfc8 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 5 Mar 2026 12:08:20 -0800 Subject: [PATCH 04/29] move indicators to their own file (badge.ts) --- frontend/app/app.tsx | 10 +-- frontend/app/store/badge.ts | 100 +++++++++++++++++++++++++++++ frontend/app/store/global.ts | 100 +---------------------------- frontend/app/tab/tab.tsx | 7 +- frontend/app/view/term/termwrap.ts | 2 +- frontend/wave.ts | 2 +- 6 files changed, 109 insertions(+), 112 deletions(-) create mode 100644 frontend/app/store/badge.ts diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx index 0970b476a1..369389abe3 100644 --- a/frontend/app/app.tsx +++ b/frontend/app/app.tsx @@ -1,19 +1,13 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { clearTabIndicatorFromFocus, getTabIndicatorAtom } from "@/app/store/badge"; import { ClientModel } from "@/app/store/client-model"; import { GlobalModel } from "@/app/store/global-model"; import { getTabModelByTabId, TabModelContext } from "@/app/store/tab-model"; import { Workspace } from "@/app/workspace/workspace"; import { ContextMenuModel } from "@/store/contextmenu"; -import { - atoms, - clearTabIndicatorFromFocus, - createBlock, - getSettingsPrefixAtom, - getTabIndicatorAtom, - globalStore, -} from "@/store/global"; +import { atoms, createBlock, getSettingsPrefixAtom, globalStore } from "@/store/global"; import { appHandleKeyDown, keyboardMouseDownHandler } from "@/store/keymodel"; import { getElemAsStr } from "@/util/focusutil"; import * as keyutil from "@/util/keyutil"; diff --git a/frontend/app/store/badge.ts b/frontend/app/store/badge.ts new file mode 100644 index 0000000000..f8af43e543 --- /dev/null +++ b/frontend/app/store/badge.ts @@ -0,0 +1,100 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { fireAndForget } from "@/util/util"; +import { atom, PrimitiveAtom } from "jotai"; +import { TabIndicatorMap } from "./global-atoms"; +import { globalStore } from "./jotaiStore"; +import * as WOS from "./wos"; + +function getTabIndicatorAtom(tabId: string): PrimitiveAtom { + let rtn = TabIndicatorMap.get(tabId); + if (rtn == null) { + rtn = atom(null) as PrimitiveAtom; + TabIndicatorMap.set(tabId, rtn); + } + return rtn; +} + +function setTabIndicatorInternal(tabId: string, indicator: TabIndicator) { + if (indicator == null) { + const indicatorAtom = getTabIndicatorAtom(tabId); + globalStore.set(indicatorAtom, null); + return; + } + const indicatorAtom = getTabIndicatorAtom(tabId); + const currentIndicator = globalStore.get(indicatorAtom); + if (currentIndicator == null) { + globalStore.set(indicatorAtom, indicator); + return; + } + if (indicator.priority >= currentIndicator.priority) { + if (indicator.clearonfocus && !currentIndicator.clearonfocus) { + indicator.persistentindicator = currentIndicator; + } + globalStore.set(indicatorAtom, indicator); + } +} + +function setTabIndicator(tabId: string, indicator: TabIndicator) { + setTabIndicatorInternal(tabId, indicator); + + const eventData: WaveEvent = { + event: "tab:indicator", + scopes: [WOS.makeORef("tab", tabId)], + data: { + tabid: tabId, + indicator: indicator, + }, + }; + fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); +} + +function clearTabIndicatorFromFocus(tabId: string) { + const indicatorAtom = getTabIndicatorAtom(tabId); + const currentIndicator = globalStore.get(indicatorAtom); + if (currentIndicator == null) { + return; + } + const persistentIndicator = currentIndicator.persistentindicator; + const eventData: WaveEvent = { + event: "tab:indicator", + scopes: [WOS.makeORef("tab", tabId)], + data: { + tabid: tabId, + indicator: persistentIndicator ?? null, + } as TabIndicatorEventData, + }; + fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); +} + +function clearAllTabIndicators() { + for (const [tabId, indicatorAtom] of TabIndicatorMap.entries()) { + const indicator = globalStore.get(indicatorAtom); + if (indicator != null) { + setTabIndicator(tabId, null); + } + } +} + +async function loadTabIndicators() { + const tabIndicators = await RpcApi.GetAllTabIndicatorsCommand(TabRpcClient); + if (tabIndicators == null) { + return; + } + for (const [tabId, indicator] of Object.entries(tabIndicators)) { + const curAtom = getTabIndicatorAtom(tabId); + globalStore.set(curAtom, indicator); + } +} + +export { + clearAllTabIndicators, + clearTabIndicatorFromFocus, + getTabIndicatorAtom, + loadTabIndicators, + setTabIndicator, + setTabIndicatorInternal, +}; diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 628ae03626..dcc3c8d5bd 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -27,19 +27,13 @@ import { isWslConnName, NullAtom, } from "@/util/util"; -import { isPreviewWindow } from "./windowtype"; import { atom, Atom, PrimitiveAtom, useAtomValue } from "jotai"; -import { - atoms, - blockComponentModelMap, - ConnStatusMapAtom, - initGlobalAtoms, - orefAtomCache, - TabIndicatorMap, -} from "./global-atoms"; +import { setTabIndicatorInternal } from "./badge"; +import { atoms, blockComponentModelMap, ConnStatusMapAtom, initGlobalAtoms, orefAtomCache } from "./global-atoms"; import { globalStore } from "./jotaiStore"; import { modalsModel } from "./modalmodel"; import { ClientService, ObjectService } from "./services"; +import { isPreviewWindow } from "./windowtype"; import * as WOS from "./wos"; import { getFileSubject, waveEventSubscribeSingle } from "./wps"; @@ -608,17 +602,6 @@ async function loadConnStatus() { } } -async function loadTabIndicators() { - const tabIndicators = await RpcApi.GetAllTabIndicatorsCommand(TabRpcClient); - if (tabIndicators == null) { - return; - } - for (const [tabId, indicator] of Object.entries(tabIndicators)) { - const curAtom = getTabIndicatorAtom(tabId); - globalStore.set(curAtom, indicator); - } -} - function subscribeToConnEvents() { waveEventSubscribeSingle({ eventType: "connchange", @@ -672,76 +655,6 @@ function getConnStatusAtom(conn: string): PrimitiveAtom { return rtn; } -function getTabIndicatorAtom(tabId: string): PrimitiveAtom { - let rtn = TabIndicatorMap.get(tabId); - if (rtn == null) { - rtn = atom(null) as PrimitiveAtom; - TabIndicatorMap.set(tabId, rtn); - } - return rtn; -} - -function setTabIndicatorInternal(tabId: string, indicator: TabIndicator) { - if (indicator == null) { - const indicatorAtom = getTabIndicatorAtom(tabId); - globalStore.set(indicatorAtom, null); - return; - } - const indicatorAtom = getTabIndicatorAtom(tabId); - const currentIndicator = globalStore.get(indicatorAtom); - if (currentIndicator == null) { - globalStore.set(indicatorAtom, indicator); - return; - } - if (indicator.priority >= currentIndicator.priority) { - if (indicator.clearonfocus && !currentIndicator.clearonfocus) { - indicator.persistentindicator = currentIndicator; - } - globalStore.set(indicatorAtom, indicator); - } -} - -function setTabIndicator(tabId: string, indicator: TabIndicator) { - setTabIndicatorInternal(tabId, indicator); - - const eventData: WaveEvent = { - event: "tab:indicator", - scopes: [WOS.makeORef("tab", tabId)], - data: { - tabid: tabId, - indicator: indicator, - }, - }; - fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); -} - -function clearTabIndicatorFromFocus(tabId: string) { - const indicatorAtom = getTabIndicatorAtom(tabId); - const currentIndicator = globalStore.get(indicatorAtom); - if (currentIndicator == null) { - return; - } - const persistentIndicator = currentIndicator.persistentindicator; - const eventData: WaveEvent = { - event: "tab:indicator", - scopes: [WOS.makeORef("tab", tabId)], - data: { - tabid: tabId, - indicator: persistentIndicator ?? null, - } as TabIndicatorEventData, - }; - fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); -} - -function clearAllTabIndicators() { - for (const [tabId, indicatorAtom] of TabIndicatorMap.entries()) { - const indicator = globalStore.get(indicatorAtom); - if (indicator != null) { - setTabIndicator(tabId, null); - } - } -} - function createTab() { getApi().createTab(); } @@ -758,12 +671,8 @@ function recordTEvent(event: string, props?: TEventProps) { RpcApi.RecordTEventCommand(TabRpcClient, { event, props }, { noresponse: true }); } -export { ConnStatusMapAtom, getAtoms, initGlobalAtoms, orefAtomCache, TabIndicatorMap, blockComponentModelMap } from "./global-atoms"; - export { atoms, - clearAllTabIndicators, - clearTabIndicatorFromFocus, createBlock, createBlockSplitHorizontally, createBlockSplitVertically, @@ -783,7 +692,6 @@ export { getOverrideConfigAtom, getSettingsKeyAtom, getSettingsPrefixAtom, - getTabIndicatorAtom, getUserName, globalPrimaryTabStartup, globalStore, @@ -791,7 +699,6 @@ export { initGlobalWaveEventSubs, isDev, loadConnStatus, - loadTabIndicators, openLink, readAtom, recordTEvent, @@ -801,7 +708,6 @@ export { setActiveTab, setNodeFocus, setPlatform, - setTabIndicator, subscribeToConnEvents, unregisterBlockComponentModel, useBlockAtom, diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index 37a96ca525..76f46a492e 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -2,15 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import { - atoms, clearAllTabIndicators, clearTabIndicatorFromFocus, getTabIndicatorAtom, - globalStore, - recordTEvent, - refocusNode, setTabIndicator, -} from "@/app/store/global"; +} 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"; import { Button } from "@/element/button"; diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 7271c4c3a9..9bd83017df 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { BlockNodeModel } from "@/app/block/blocktypes"; +import { setTabIndicator } from "@/app/store/badge"; import { getFileSubject } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; @@ -13,7 +14,6 @@ import { globalStore, isDev, openLink, - setTabIndicator, WOS, } from "@/store/global"; import * as services from "@/store/services"; diff --git a/frontend/wave.ts b/frontend/wave.ts index c380163071..d73585f474 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -3,6 +3,7 @@ import { App } from "@/app/app"; import { loadMonaco } from "@/app/monaco/monaco-env"; +import { loadTabIndicators } from "@/app/store/badge"; import { GlobalModel } from "@/app/store/global-model"; import { globalRefocus, @@ -25,7 +26,6 @@ import { initGlobal, initGlobalWaveEventSubs, loadConnStatus, - loadTabIndicators, subscribeToConnEvents, } from "@/store/global"; import { activeTabIdAtom } from "@/store/tab-model"; From 95ec7273bad3a486ee1c47d92ee1220d83a5c233 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 5 Mar 2026 12:10:51 -0800 Subject: [PATCH 05/29] move tabindicatormap too --- frontend/app/store/badge.ts | 3 ++- frontend/app/store/global-atoms.ts | 3 +-- frontend/preview/previews/vtabbar.preview.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/app/store/badge.ts b/frontend/app/store/badge.ts index f8af43e543..79616a0e48 100644 --- a/frontend/app/store/badge.ts +++ b/frontend/app/store/badge.ts @@ -5,10 +5,11 @@ import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { fireAndForget } from "@/util/util"; import { atom, PrimitiveAtom } from "jotai"; -import { TabIndicatorMap } from "./global-atoms"; import { globalStore } from "./jotaiStore"; import * as WOS from "./wos"; +const TabIndicatorMap = new Map>(); + function getTabIndicatorAtom(tabId: string): PrimitiveAtom { let rtn = TabIndicatorMap.get(tabId); if (rtn == null) { diff --git a/frontend/app/store/global-atoms.ts b/frontend/app/store/global-atoms.ts index ac36fcec8e..89304aaa58 100644 --- a/frontend/app/store/global-atoms.ts +++ b/frontend/app/store/global-atoms.ts @@ -9,7 +9,6 @@ import * as WOS from "./wos"; let atoms!: GlobalAtomsType; const blockComponentModelMap = new Map(); const ConnStatusMapAtom = atom(new Map>()); -const TabIndicatorMap = new Map>(); const orefAtomCache = new Map>>(); function initGlobalAtoms(initOpts: GlobalInitOptions) { @@ -154,4 +153,4 @@ function getApi(): ElectronApi { return (window as any).api; } -export { atoms, blockComponentModelMap, ConnStatusMapAtom, getAtoms, initGlobalAtoms, orefAtomCache, TabIndicatorMap }; +export { atoms, blockComponentModelMap, ConnStatusMapAtom, getAtoms, initGlobalAtoms, orefAtomCache }; diff --git a/frontend/preview/previews/vtabbar.preview.tsx b/frontend/preview/previews/vtabbar.preview.tsx index b576786749..40ec34325b 100644 --- a/frontend/preview/previews/vtabbar.preview.tsx +++ b/frontend/preview/previews/vtabbar.preview.tsx @@ -6,7 +6,7 @@ import { useState } from "react"; const InitialTabs: VTabItem[] = [ { id: "vtab-1", name: "Terminal" }, - { id: "vtab-2", name: "Build Logs", indicator: { icon: "bell", color: "#f59e0b" } }, + { id: "vtab-2", name: "Build Logs", indicator: { icon: "bell", color: "#f59e0b", priority: 1 } }, { id: "vtab-3", name: "Deploy" }, { id: "vtab-4", name: "Wave AI" }, { id: "vtab-5", name: "A Very Long Tab Name To Show Truncation" }, From ac7f2950a8ef96ccb4b36689e72c461b1a1098d9 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 5 Mar 2026 12:14:49 -0800 Subject: [PATCH 06/29] move subscription to badge.ts, clean up some warnings --- frontend/app/store/badge.ts | 12 +++++++++++- frontend/app/store/global.ts | 25 +++++++++---------------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/frontend/app/store/badge.ts b/frontend/app/store/badge.ts index 79616a0e48..012bbe59f3 100644 --- a/frontend/app/store/badge.ts +++ b/frontend/app/store/badge.ts @@ -7,6 +7,7 @@ import { fireAndForget } from "@/util/util"; import { atom, PrimitiveAtom } from "jotai"; import { globalStore } from "./jotaiStore"; import * as WOS from "./wos"; +import { waveEventSubscribeSingle } from "./wps"; const TabIndicatorMap = new Map>(); @@ -91,11 +92,20 @@ async function loadTabIndicators() { } } +function setupTabIndicatorSubscription() { + waveEventSubscribeSingle({ + eventType: "tab:indicator", + handler: (event) => { + setTabIndicatorInternal(event.data.tabid, event.data.indicator); + }, + }); +} + export { clearAllTabIndicators, clearTabIndicatorFromFocus, getTabIndicatorAtom, loadTabIndicators, setTabIndicator, - setTabIndicatorInternal, + setupTabIndicatorSubscription, }; diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index dcc3c8d5bd..53b05d93bf 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -28,7 +28,7 @@ import { NullAtom, } from "@/util/util"; import { atom, Atom, PrimitiveAtom, useAtomValue } from "jotai"; -import { setTabIndicatorInternal } from "./badge"; +import { setupTabIndicatorSubscription } from "./badge"; import { atoms, blockComponentModelMap, ConnStatusMapAtom, initGlobalAtoms, orefAtomCache } from "./global-atoms"; import { globalStore } from "./jotaiStore"; import { modalsModel } from "./modalmodel"; @@ -37,11 +37,9 @@ import { isPreviewWindow } from "./windowtype"; import * as WOS from "./wos"; import { getFileSubject, waveEventSubscribeSingle } from "./wps"; -let globalEnvironment: "electron" | "renderer"; let globalPrimaryTabStartup: boolean = false; function initGlobal(initOpts: GlobalInitOptions) { - globalEnvironment = initOpts.environment; globalPrimaryTabStartup = initOpts.primaryTabStartup ?? false; setPlatform(initOpts.platform); initGlobalAtoms(initOpts); @@ -99,12 +97,7 @@ function initGlobalWaveEventSubs(initOpts: WaveInitOpts) { globalStore.set(atoms.waveAIRateLimitInfoAtom, event.data); }, }); - waveEventSubscribeSingle({ - eventType: "tab:indicator", - handler: (event) => { - setTabIndicatorInternal(event.data.tabid, event.data.indicator); - }, - }); + setupTabIndicatorSubscription(); } const blockCache = new Map>(); @@ -131,8 +124,8 @@ function getBlockMetaKeyAtom(blockId: string, key: T): return metaAtom; } metaAtom = atom((get) => { - let blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", blockId)); - let blockData = get(blockAtom); + const blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", blockId)); + const blockData = get(blockAtom); return blockData?.meta?.[key]; }); blockCache.set(metaAtomName, metaAtom); @@ -151,8 +144,8 @@ function getOrefMetaKeyAtom(oref: string, key: T): Ato return metaAtom; } metaAtom = atom((get) => { - let objAtom = WOS.getWaveObjectAtom(oref); - let objData = get(objAtom); + const objAtom = WOS.getWaveObjectAtom(oref); + const objData = get(objAtom); return objData?.meta?.[key]; }); orefCache.set(metaAtomName, metaAtom); @@ -165,14 +158,14 @@ function useOrefMetaKeyAtom(oref: string, key: T): Met function getConnConfigKeyAtom(connName: string, key: T): Atom { if (isPreviewWindow()) return NullAtom as Atom; - let connCache = getSingleConnAtomCache(connName); + const connCache = getSingleConnAtomCache(connName); const keyAtomName = "#conn-" + key; let keyAtom = connCache.get(keyAtomName); if (keyAtom != null) { return keyAtom; } keyAtom = atom((get) => { - let fullConfig = get(atoms.fullConfigAtom); + const fullConfig = get(atoms.fullConfigAtom); return fullConfig.connections?.[connName]?.[key]; }); connCache.set(keyAtomName, keyAtom); @@ -612,7 +605,7 @@ function subscribeToConnEvents() { return; } console.log("connstatus update", connStatus); - let curAtom = getConnStatusAtom(connStatus.connection); + const curAtom = getConnStatusAtom(connStatus.connection); globalStore.set(curAtom, connStatus); } catch (e) { console.log("connchange error", e); From e3aa0b87fb343f17e9650f7085cc4b02827ab5fd Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 5 Mar 2026 12:17:14 -0800 Subject: [PATCH 07/29] clean up some warnings --- eslint.config.js | 4 ++-- frontend/wave.ts | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 1c72e5f464..01b1d6a11b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -76,8 +76,8 @@ export default [ "@typescript-eslint/no-unused-vars": [ "warn", { - argsIgnorePattern: "^_$", - varsIgnorePattern: "^_$", + argsIgnorePattern: "^_[a-zA-Z0-9_]*$", + varsIgnorePattern: "^_[a-zA-Z0-9_]*$", }, ], "prefer-const": "warn", diff --git a/frontend/wave.ts b/frontend/wave.ts index d73585f474..7085dbee3f 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -166,12 +166,12 @@ async function initWave(initOpts: WaveInitOpts) { // ensures client/window/workspace are loaded into the cache before rendering try { - const [client, waveWindow, initialTab] = await Promise.all([ + const [_client, waveWindow, initialTab] = await Promise.all([ WOS.loadAndPinWaveObject(WOS.makeORef("client", initOpts.clientId)), WOS.loadAndPinWaveObject(WOS.makeORef("window", initOpts.windowId)), WOS.loadAndPinWaveObject(WOS.makeORef("tab", initOpts.tabId)), ]); - const [ws, layoutState] = await Promise.all([ + const [ws, _layoutState] = await Promise.all([ WOS.loadAndPinWaveObject(WOS.makeORef("workspace", waveWindow.workspaceid)), WOS.reloadWaveObject(WOS.makeORef("layout", initialTab.layoutstate)), ]); @@ -193,7 +193,7 @@ async function initWave(initOpts: WaveInitOpts) { globalStore.set(atoms.waveaiModeConfigAtom, waveaiModeConfig.configs); console.log("Wave First Render"); let firstRenderResolveFn: () => void = null; - let firstRenderPromise = new Promise((resolve) => { + const firstRenderPromise = new Promise((resolve) => { firstRenderResolveFn = resolve; }); const reactElem = createElement(App, { onFirstRender: firstRenderResolveFn }, null); @@ -252,7 +252,7 @@ async function initBuilder(initOpts: BuilderInitOpts) { globalStore.set(atoms.builderAppId, appIdToUse); - const client = await WOS.loadAndPinWaveObject(WOS.makeORef("client", initOpts.clientId)); + const _client = await WOS.loadAndPinWaveObject(WOS.makeORef("client", initOpts.clientId)); registerBuilderGlobalKeys(); registerElectronReinjectKeyHandler(); @@ -265,7 +265,7 @@ async function initBuilder(initOpts: BuilderInitOpts) { console.log("Tsunami Builder First Render"); let firstRenderResolveFn: () => void = null; - let firstRenderPromise = new Promise((resolve) => { + const firstRenderPromise = new Promise((resolve) => { firstRenderResolveFn = resolve; }); const reactElem = createElement(BuilderApp, { initOpts, onFirstRender: firstRenderResolveFn }, null); From 30788d0e8393a299b97939aaec540af5edb018d0 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 5 Mar 2026 12:26:11 -0800 Subject: [PATCH 08/29] setup FE badge store --- frontend/app/store/badge.ts | 81 +++++++++++++++++++++++++++++++++++- frontend/app/store/global.ts | 3 +- frontend/wave.ts | 3 +- 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/frontend/app/store/badge.ts b/frontend/app/store/badge.ts index 012bbe59f3..00392c79aa 100644 --- a/frontend/app/store/badge.ts +++ b/frontend/app/store/badge.ts @@ -4,12 +4,14 @@ import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { fireAndForget } from "@/util/util"; -import { atom, PrimitiveAtom } from "jotai"; +import { atom, Atom, PrimitiveAtom } from "jotai"; import { globalStore } from "./jotaiStore"; import * as WOS from "./wos"; import { waveEventSubscribeSingle } from "./wps"; const TabIndicatorMap = new Map>(); +const PersistentBadgeMap = new Map>(); +const TransientBadgeMap = new Map>(); function getTabIndicatorAtom(tabId: string): PrimitiveAtom { let rtn = TabIndicatorMap.get(tabId); @@ -101,11 +103,88 @@ function setupTabIndicatorSubscription() { }); } +function getBadgeAtom(oref: string): Atom { + const persistentAtom = getPersistentBadgeAtom(oref); + const transientAtom = getTransientBadgeAtom(oref); + return atom((get) => { + const persistent = get(persistentAtom); + const transient = get(transientAtom); + if (persistent == null) { + return transient; + } + if (transient == null) { + return persistent; + } + return transient.priority >= persistent.priority ? transient : persistent; + }); +} + +function getPersistentBadgeAtom(oref: string): PrimitiveAtom { + let rtn = PersistentBadgeMap.get(oref); + if (rtn == null) { + rtn = atom(null) as PrimitiveAtom; + PersistentBadgeMap.set(oref, rtn); + } + return rtn; +} + +function getTransientBadgeAtom(oref: string): PrimitiveAtom { + let rtn = TransientBadgeMap.get(oref); + if (rtn == null) { + rtn = atom(null) as PrimitiveAtom; + TransientBadgeMap.set(oref, rtn); + } + return rtn; +} + +async function loadBadges() { + const badges = await RpcApi.GetAllBadgesCommand(TabRpcClient); + if (badges == null) { + return; + } + for (const badgeEvent of badges) { + if (badgeEvent.oref == null) { + continue; + } + if (badgeEvent.persistent) { + const curAtom = getPersistentBadgeAtom(badgeEvent.oref); + globalStore.set(curAtom, badgeEvent.badge ?? null); + } else { + const curAtom = getTransientBadgeAtom(badgeEvent.oref); + globalStore.set(curAtom, badgeEvent.badge ?? null); + } + } +} + +function setupBadgesSubscription() { + waveEventSubscribeSingle({ + eventType: "badge", + handler: (event) => { + const data = event.data; + if (data?.oref == null) { + return; + } + if (data.persistent) { + const curAtom = getPersistentBadgeAtom(data.oref); + globalStore.set(curAtom, data.clear ? null : (data.badge ?? null)); + } else { + const curAtom = getTransientBadgeAtom(data.oref); + globalStore.set(curAtom, data.clear ? null : (data.badge ?? null)); + } + }, + }); +} + export { clearAllTabIndicators, clearTabIndicatorFromFocus, + getBadgeAtom, + getPersistentBadgeAtom, getTabIndicatorAtom, + getTransientBadgeAtom, + loadBadges, loadTabIndicators, setTabIndicator, + setupBadgesSubscription, setupTabIndicatorSubscription, }; diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 53b05d93bf..5357237bdf 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -28,7 +28,7 @@ import { NullAtom, } from "@/util/util"; import { atom, Atom, PrimitiveAtom, useAtomValue } from "jotai"; -import { setupTabIndicatorSubscription } from "./badge"; +import { setupBadgesSubscription, setupTabIndicatorSubscription } from "./badge"; import { atoms, blockComponentModelMap, ConnStatusMapAtom, initGlobalAtoms, orefAtomCache } from "./global-atoms"; import { globalStore } from "./jotaiStore"; import { modalsModel } from "./modalmodel"; @@ -98,6 +98,7 @@ function initGlobalWaveEventSubs(initOpts: WaveInitOpts) { }, }); setupTabIndicatorSubscription(); + setupBadgesSubscription(); } const blockCache = new Map>(); diff --git a/frontend/wave.ts b/frontend/wave.ts index 7085dbee3f..15cc4ae2fb 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -3,7 +3,7 @@ import { App } from "@/app/app"; import { loadMonaco } from "@/app/monaco/monaco-env"; -import { loadTabIndicators } from "@/app/store/badge"; +import { loadBadges, loadTabIndicators } from "@/app/store/badge"; import { GlobalModel } from "@/app/store/global-model"; import { globalRefocus, @@ -161,6 +161,7 @@ async function initWave(initOpts: WaveInitOpts) { (window as any).TabRpcClient = TabRpcClient; await loadConnStatus(); await loadTabIndicators(); + await loadBadges(); initGlobalWaveEventSubs(initOpts); subscribeToConnEvents(); From a7acdb9cf79163f528b66c953e4ffd4e48ac683d Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 5 Mar 2026 13:37:46 -0800 Subject: [PATCH 09/29] working on badge integration --- frontend/app/store/badge.ts | 110 +++++++++++++++++++++++++++-- frontend/app/tab/tab.tsx | 13 +++- frontend/app/view/term/termwrap.ts | 3 +- frontend/types/gotypes.d.ts | 1 + package-lock.json | 29 ++++++-- package.json | 2 + pkg/baseds/baseds.go | 1 + 7 files changed, 148 insertions(+), 11 deletions(-) diff --git a/frontend/app/store/badge.ts b/frontend/app/store/badge.ts index 00392c79aa..8d1ab2f1fa 100644 --- a/frontend/app/store/badge.ts +++ b/frontend/app/store/badge.ts @@ -4,6 +4,7 @@ import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { fireAndForget } from "@/util/util"; +import { v7 as uuidv7, version as uuidVersion } from "uuid"; import { atom, Atom, PrimitiveAtom } from "jotai"; import { globalStore } from "./jotaiStore"; import * as WOS from "./wos"; @@ -12,6 +13,8 @@ import { waveEventSubscribeSingle } from "./wps"; const TabIndicatorMap = new Map>(); const PersistentBadgeMap = new Map>(); const TransientBadgeMap = new Map>(); +const BlockBadgeAtomCache = new Map>(); +const TabBadgeAtomCache = new Map>(); function getTabIndicatorAtom(tabId: string): PrimitiveAtom { let rtn = TabIndicatorMap.get(tabId); @@ -83,6 +86,45 @@ function clearAllTabIndicators() { } } +function clearBadgeInternal(oref: string, persistent: boolean) { + const eventData: WaveEvent = { + event: "badge", + scopes: [oref], + data: { + oref: oref, + persistent: persistent, + clear: true, + } as BadgeEvent, + }; + fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); +} + +function clearAllBadges(persistent: boolean) { + const map = persistent ? PersistentBadgeMap : TransientBadgeMap; + for (const oref of map.keys()) { + if (globalStore.get(map.get(oref)) != null) { + clearBadgeInternal(oref, persistent); + } + } +} + +function clearBadgesForTab(tabId: string) { + const tabAtom = WOS.getWaveObjectAtom(WOS.makeORef("tab", tabId)); + const tab = globalStore.get(tabAtom); + const blockIds = (tab as Tab)?.blockids ?? []; + for (const blockId of blockIds) { + const oref = WOS.makeORef("block", blockId); + const persistentAtom = PersistentBadgeMap.get(oref); + if (persistentAtom != null && globalStore.get(persistentAtom) != null) { + clearBadgeInternal(oref, true); + } + const transientAtom = TransientBadgeMap.get(oref); + if (transientAtom != null && globalStore.get(transientAtom) != null) { + clearBadgeInternal(oref, false); + } + } +} + async function loadTabIndicators() { const tabIndicators = await RpcApi.GetAllTabIndicatorsCommand(TabRpcClient); if (tabIndicators == null) { @@ -103,10 +145,15 @@ function setupTabIndicatorSubscription() { }); } -function getBadgeAtom(oref: string): Atom { +function getBlockBadgeAtom(blockId: string): Atom { + let rtn = BlockBadgeAtomCache.get(blockId); + if (rtn != null) { + return rtn; + } + const oref = WOS.makeORef("block", blockId); const persistentAtom = getPersistentBadgeAtom(oref); const transientAtom = getTransientBadgeAtom(oref); - return atom((get) => { + rtn = atom((get) => { const persistent = get(persistentAtom); const transient = get(transientAtom); if (persistent == null) { @@ -115,8 +162,41 @@ function getBadgeAtom(oref: string): Atom { if (transient == null) { return persistent; } - return transient.priority >= persistent.priority ? transient : persistent; + if (transient.priority !== persistent.priority) { + return transient.priority > persistent.priority ? transient : persistent; + } + return transient.badgeid >= persistent.badgeid ? transient : persistent; }); + BlockBadgeAtomCache.set(blockId, rtn); + return rtn; +} + +function getTabBadgeAtom(tabId: string): Atom { + let rtn = TabBadgeAtomCache.get(tabId); + if (rtn != null) { + return rtn; + } + const tabAtom = atom((get) => WOS.getObjectValue(WOS.makeORef("tab", tabId), 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)); + if (badge != null) { + badges.push(badge); + } + } + badges.sort((a, b) => { + if (a.priority !== b.priority) { + return b.priority - a.priority; + } + return b.badgeid < a.badgeid ? -1 : b.badgeid > a.badgeid ? 1 : 0; + }); + return badges; + }); + TabBadgeAtomCache.set(tabId, rtn); + return rtn; } function getPersistentBadgeAtom(oref: string): PrimitiveAtom { @@ -156,6 +236,24 @@ async function loadBadges() { } } +function setBadge(blockId: string, badge: Omit & { badgeid?: string }) { + if (!badge.badgeid) { + badge = { ...badge, badgeid: uuidv7() }; + } else if (uuidVersion(badge.badgeid) !== 7) { + throw new Error(`setBadge: badgeid must be a v7 UUID, got version ${uuidVersion(badge.badgeid)}`); + } + const oref = WOS.makeORef("block", blockId); + const eventData: WaveEvent = { + event: "badge", + scopes: [oref], + data: { + oref: oref, + badge: badge, + } as BadgeEvent, + }; + fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); +} + function setupBadgesSubscription() { waveEventSubscribeSingle({ eventType: "badge", @@ -176,14 +274,18 @@ function setupBadgesSubscription() { } export { + clearAllBadges, clearAllTabIndicators, + clearBadgesForTab, clearTabIndicatorFromFocus, - getBadgeAtom, + getBlockBadgeAtom, getPersistentBadgeAtom, + getTabBadgeAtom, getTabIndicatorAtom, getTransientBadgeAtom, loadBadges, loadTabIndicators, + setBadge, setTabIndicator, setupBadgesSubscription, setupTabIndicatorSubscription, diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index 76f46a492e..a56102534d 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -2,7 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { + clearAllBadges, clearAllTabIndicators, + clearBadgesForTab, clearTabIndicatorFromFocus, getTabIndicatorAtom, setTabIndicator, @@ -219,11 +221,18 @@ function buildTabContextMenu( menu.push( { label: "Clear Tab Indicator", - click: () => setTabIndicator(id, null), + click: () => { + setTabIndicator(id, null); + clearBadgesForTab(id); + }, }, { label: "Clear All Indicators", - click: () => clearAllTabIndicators(), + click: () => { + clearAllTabIndicators(); + clearAllBadges(true); + clearAllBadges(false); + }, }, { type: "separator" } ); diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 9bd83017df..c36ec5788a 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { BlockNodeModel } from "@/app/block/blocktypes"; -import { setTabIndicator } from "@/app/store/badge"; +import { setBadge, setTabIndicator } from "@/app/store/badge"; import { getFileSubject } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; @@ -257,6 +257,7 @@ export class TermWrap { if (bellIndicatorEnabled) { const tabId = globalStore.get(atoms.staticTabId); setTabIndicator(tabId, { icon: "bell", color: "#fbbf24", clearonfocus: true, priority: 1 }); + setBadge(this.blockId, { icon: "bell", color: "#fbbf24", priority: 1 }); } return true; }) diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 961d15ef8e..e0a3f72e37 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -110,6 +110,7 @@ declare global { // baseds.Badge type Badge = { + badgeid: string; icon: string; color?: string; priority: number; diff --git a/package-lock.json b/package-lock.json index bdee147e59..8ee9632e7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@table-nav/react": "^0.0.7", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.19", + "@types/uuid": "^10.0.0", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-search": "^0.15.0", "@xterm/addon-serialize": "^0.13.0", @@ -81,6 +82,7 @@ "tinycolor2": "^1.6.0", "unist-util-visit": "^5.1.0", "use-device-pixel-ratio": "^1.1.2", + "uuid": "^13.0.0", "winston": "^3.19.0", "ws": "^8.19.0", "yaml": "^2.7.1" @@ -9976,6 +9978,12 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, "node_modules/@types/verror": { "version": "1.10.11", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", @@ -29333,6 +29341,15 @@ "websocket-driver": "^0.7.4" } }, + "node_modules/sockjs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/socks": { "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", @@ -31915,12 +31932,16 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/v8-compile-cache-lib": { diff --git a/package.json b/package.json index cd75ae81d2..c5c1f02963 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "@table-nav/react": "^0.0.7", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.19", + "@types/uuid": "^10.0.0", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-search": "^0.15.0", "@xterm/addon-serialize": "^0.13.0", @@ -141,6 +142,7 @@ "tinycolor2": "^1.6.0", "unist-util-visit": "^5.1.0", "use-device-pixel-ratio": "^1.1.2", + "uuid": "^13.0.0", "winston": "^3.19.0", "ws": "^8.19.0", "yaml": "^2.7.1" diff --git a/pkg/baseds/baseds.go b/pkg/baseds/baseds.go index 0a037f9884..0ebb06a55d 100644 --- a/pkg/baseds/baseds.go +++ b/pkg/baseds/baseds.go @@ -14,6 +14,7 @@ type RpcInputChType struct { } type Badge struct { + BadgeId string `json:"badgeid"` Icon string `json:"icon"` Color string `json:"color,omitempty"` Priority float64 `json:"priority"` From f980494cdd8f92f223e2600f6c92c4e03e4f9c15 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 5 Mar 2026 14:15:38 -0800 Subject: [PATCH 10/29] add clearall for badge event --- frontend/app/store/badge.ts | 23 +++++++++++++++++------ frontend/types/gotypes.d.ts | 1 + pkg/baseds/baseds.go | 1 + pkg/wcore/badge.go | 34 ++++++++++++++++++++++++++++++++-- 4 files changed, 51 insertions(+), 8 deletions(-) diff --git a/frontend/app/store/badge.ts b/frontend/app/store/badge.ts index 8d1ab2f1fa..59d102cb85 100644 --- a/frontend/app/store/badge.ts +++ b/frontend/app/store/badge.ts @@ -100,12 +100,16 @@ function clearBadgeInternal(oref: string, persistent: boolean) { } function clearAllBadges(persistent: boolean) { - const map = persistent ? PersistentBadgeMap : TransientBadgeMap; - for (const oref of map.keys()) { - if (globalStore.get(map.get(oref)) != null) { - clearBadgeInternal(oref, persistent); - } - } + const eventData: WaveEvent = { + event: "badge", + scopes: [], + data: { + oref: "", + persistent: persistent, + clearall: true, + } as BadgeEvent, + }; + fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); } function clearBadgesForTab(tabId: string) { @@ -259,6 +263,13 @@ function setupBadgesSubscription() { eventType: "badge", handler: (event) => { const data = event.data; + if (data?.clearall) { + const map = data.persistent ? PersistentBadgeMap : TransientBadgeMap; + for (const atom of map.values()) { + globalStore.set(atom, null); + } + return; + } if (data?.oref == null) { return; } diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index e0a3f72e37..28963510a1 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -121,6 +121,7 @@ declare global { oref: string; persistent?: boolean; clear?: boolean; + clearall?: boolean; badge?: Badge; }; diff --git a/pkg/baseds/baseds.go b/pkg/baseds/baseds.go index 0ebb06a55d..dfd09c85b7 100644 --- a/pkg/baseds/baseds.go +++ b/pkg/baseds/baseds.go @@ -24,5 +24,6 @@ type BadgeEvent struct { ORef string `json:"oref"` Persistent bool `json:"persistent,omitempty"` Clear bool `json:"clear,omitempty"` + ClearAll bool `json:"clearall,omitempty"` Badge *Badge `json:"badge,omitempty"` } diff --git a/pkg/wcore/badge.go b/pkg/wcore/badge.go index d02edbe52b..414b94ff32 100644 --- a/pkg/wcore/badge.go +++ b/pkg/wcore/badge.go @@ -91,6 +91,10 @@ func handleBadgeEvent(event *wps.WaveEvent) { log.Printf("badge store: error unmarshaling BadgeEvent: %v\n", err) return } + if data.ClearAll { + clearAllBadges(data.Persistent) + return + } if data.ORef == "" { log.Printf("badge store: received badge event with empty oref\n") return @@ -134,6 +138,32 @@ func setBadge(oref waveobj.ORef, badge *baseds.Badge, persistent bool, clear boo } } +// clearAllBadges removes all badges from the given map (persistent or transient). +// For persistent badges it also clears the DB records. +func clearAllBadges(persistent bool) { + globalBadgeStore.lock.Lock() + defer globalBadgeStore.lock.Unlock() + + if persistent { + orefs := make([]string, 0, len(globalBadgeStore.persistent)) + for orefStr := range globalBadgeStore.persistent { + orefs = append(orefs, orefStr) + } + for _, orefStr := range orefs { + delete(globalBadgeStore.persistent, orefStr) + oref, err := waveobj.ParseORef(orefStr) + if err == nil { + persistBadge(oref, nil) + } + } + log.Printf("badge store: cleared all %d persistent badges\n", len(orefs)) + } else { + count := len(globalBadgeStore.transient) + globalBadgeStore.transient = make(map[string]baseds.Badge) + log.Printf("badge store: cleared all %d transient badges\n", count) + } +} + // persistBadge writes the badge (or nil to clear) to the appropriate DB object. func persistBadge(oref waveobj.ORef, badge *baseds.Badge) { ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) @@ -141,7 +171,7 @@ func persistBadge(oref waveobj.ORef, badge *baseds.Badge) { switch oref.OType { case waveobj.OType_Tab: - err := wstore.DBUpdateFn[*waveobj.Tab](ctx, oref.OID, func(tab *waveobj.Tab) { + err := wstore.DBUpdateFn(ctx, oref.OID, func(tab *waveobj.Tab) { tab.Badge = badge }) if err != nil { @@ -152,7 +182,7 @@ func persistBadge(oref waveobj.ORef, badge *baseds.Badge) { SendWaveObjUpdate(oref) case waveobj.OType_Block: - err := wstore.DBUpdateFn[*waveobj.Block](ctx, oref.OID, func(block *waveobj.Block) { + err := wstore.DBUpdateFn(ctx, oref.OID, func(block *waveobj.Block) { block.Badge = badge }) if err != nil { From 260c767a822783a6784cec9e0af6b0833f54b03d Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 5 Mar 2026 14:23:50 -0800 Subject: [PATCH 11/29] add clearbyid --- frontend/app/store/badge.ts | 31 +++++++++++++++++++++++++------ frontend/types/gotypes.d.ts | 1 + pkg/baseds/baseds.go | 11 ++++++----- pkg/wcore/badge.go | 35 +++++++++++++++++++++++++---------- 4 files changed, 57 insertions(+), 21 deletions(-) diff --git a/frontend/app/store/badge.ts b/frontend/app/store/badge.ts index 59d102cb85..e5b4b7766d 100644 --- a/frontend/app/store/badge.ts +++ b/frontend/app/store/badge.ts @@ -258,6 +258,20 @@ function setBadge(blockId: string, badge: Omit & { badgeid?: s fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); } +function clearBadgeById(blockId: string, badgeId: string, persistent: boolean) { + const oref = WOS.makeORef("block", blockId); + const eventData: WaveEvent = { + event: "badge", + scopes: [oref], + data: { + oref: oref, + persistent: persistent, + clearbyid: badgeId, + } as BadgeEvent, + }; + fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); +} + function setupBadgesSubscription() { waveEventSubscribeSingle({ eventType: "badge", @@ -273,13 +287,17 @@ function setupBadgesSubscription() { if (data?.oref == null) { return; } - if (data.persistent) { - const curAtom = getPersistentBadgeAtom(data.oref); - globalStore.set(curAtom, data.clear ? null : (data.badge ?? null)); - } else { - const curAtom = getTransientBadgeAtom(data.oref); - globalStore.set(curAtom, data.clear ? null : (data.badge ?? null)); + const curAtom = data.persistent + ? getPersistentBadgeAtom(data.oref) + : getTransientBadgeAtom(data.oref); + if (data.clearbyid) { + const existing = globalStore.get(curAtom); + if (existing?.badgeid === data.clearbyid) { + globalStore.set(curAtom, null); + } + return; } + globalStore.set(curAtom, data.clear ? null : (data.badge ?? null)); }, }); } @@ -287,6 +305,7 @@ function setupBadgesSubscription() { export { clearAllBadges, clearAllTabIndicators, + clearBadgeById, clearBadgesForTab, clearTabIndicatorFromFocus, getBlockBadgeAtom, diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 28963510a1..7e56922e9e 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -122,6 +122,7 @@ declare global { persistent?: boolean; clear?: boolean; clearall?: boolean; + clearbyid?: string; badge?: Badge; }; diff --git a/pkg/baseds/baseds.go b/pkg/baseds/baseds.go index dfd09c85b7..a9d24d7e1a 100644 --- a/pkg/baseds/baseds.go +++ b/pkg/baseds/baseds.go @@ -21,9 +21,10 @@ type Badge struct { } type BadgeEvent struct { - ORef string `json:"oref"` - Persistent bool `json:"persistent,omitempty"` - Clear bool `json:"clear,omitempty"` - ClearAll bool `json:"clearall,omitempty"` - Badge *Badge `json:"badge,omitempty"` + ORef string `json:"oref"` + Persistent bool `json:"persistent,omitempty"` + Clear bool `json:"clear,omitempty"` + ClearAll bool `json:"clearall,omitempty"` + ClearById string `json:"clearbyid,omitempty"` + Badge *Badge `json:"badge,omitempty"` } diff --git a/pkg/wcore/badge.go b/pkg/wcore/badge.go index 414b94ff32..ffdc3ad92b 100644 --- a/pkg/wcore/badge.go +++ b/pkg/wcore/badge.go @@ -106,34 +106,49 @@ func handleBadgeEvent(event *wps.WaveEvent) { return } - setBadge(oref, data.Badge, data.Persistent, data.Clear) + setBadge(oref, data) } // setBadge updates the appropriate in-memory map and, when persistent, writes // through to the DB and fires a WaveObjUpdate event so the frontend stays in sync. -func setBadge(oref waveobj.ORef, badge *baseds.Badge, persistent bool, clear bool) { +func setBadge(oref waveobj.ORef, data baseds.BadgeEvent) { globalBadgeStore.lock.Lock() defer globalBadgeStore.lock.Unlock() orefStr := oref.String() - if persistent { - if clear || badge == nil { + shouldClear := data.Clear + if data.ClearById != "" { + m := globalBadgeStore.transient + if data.Persistent { + m = globalBadgeStore.persistent + } + existing, ok := m[orefStr] + if !ok || existing.BadgeId != data.ClearById { + return + } + shouldClear = true + } else if !data.Clear { + shouldClear = data.Badge == nil + } + + if data.Persistent { + if shouldClear { delete(globalBadgeStore.persistent, orefStr) log.Printf("badge store: persistent badge cleared: oref=%s\n", orefStr) go persistBadge(oref, nil) } else { - globalBadgeStore.persistent[orefStr] = *badge - log.Printf("badge store: persistent badge set: oref=%s badge=%+v\n", orefStr, *badge) - go persistBadge(oref, badge) + globalBadgeStore.persistent[orefStr] = *data.Badge + log.Printf("badge store: persistent badge set: oref=%s badge=%+v\n", orefStr, *data.Badge) + go persistBadge(oref, data.Badge) } } else { - if clear || badge == nil { + if shouldClear { delete(globalBadgeStore.transient, orefStr) log.Printf("badge store: transient badge cleared: oref=%s\n", orefStr) } else { - globalBadgeStore.transient[orefStr] = *badge - log.Printf("badge store: transient badge set: oref=%s badge=%+v\n", orefStr, *badge) + globalBadgeStore.transient[orefStr] = *data.Badge + log.Printf("badge store: transient badge set: oref=%s badge=%+v\n", orefStr, *data.Badge) } } } From c448ee70da03e591ad46b82debc8c68f8cf87c9a Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 5 Mar 2026 14:34:27 -0800 Subject: [PATCH 12/29] add badgewatchpid --- emain/emain-menu.ts | 2 +- frontend/app/store/wshclientapi.ts | 5 +++ frontend/types/gotypes.d.ts | 7 ++++ pkg/util/unixutil/unixutil_unix.go | 8 +++++ pkg/util/unixutil/unixutil_windows.go | 4 +++ pkg/wshrpc/wshclient/wshclient.go | 6 ++++ pkg/wshrpc/wshremote/wshremote.go | 49 ++++++++++++++++++++++++++- pkg/wshrpc/wshrpctypes.go | 7 ++++ 8 files changed, 86 insertions(+), 2 deletions(-) diff --git a/emain/emain-menu.ts b/emain/emain-menu.ts index 6685d79087..5878137b96 100644 --- a/emain/emain-menu.ts +++ b/emain/emain-menu.ts @@ -227,7 +227,7 @@ function makeViewMenu( label: "Toggle DevTools", accelerator: devToolsAccel, click: (_, window) => { - let wc = getWindowWebContents(window) ?? webContents; + const wc = getWindowWebContents(window) ?? webContents; wc?.toggleDevTools(); }, }, diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index e55eb1175a..9bae3158e5 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -47,6 +47,11 @@ class RpcApiType { return client.wshRpcCall("authenticatetokenverify", data, opts); } + // command "badgewatchpid" [call] + BadgeWatchPidCommand(client: WshClient, data: CommandBadgeWatchPidData, opts?: RpcOpts): Promise { + return client.wshRpcCall("badgewatchpid", data, opts); + } + // command "blockinfo" [call] BlockInfoCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { return client.wshRpcCall("blockinfo", data, opts); diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 7e56922e9e..e64610056a 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -240,6 +240,13 @@ declare global { token: string; }; + // wshrpc.CommandBadgeWatchPidData + type CommandBadgeWatchPidData = { + pid: number; + oref: ORef; + badgeid: string; + }; + // wshrpc.CommandBlockInputData type CommandBlockInputData = { blockid: string; diff --git a/pkg/util/unixutil/unixutil_unix.go b/pkg/util/unixutil/unixutil_unix.go index 23a9e9e7b0..4a05a713ec 100644 --- a/pkg/util/unixutil/unixutil_unix.go +++ b/pkg/util/unixutil/unixutil_unix.go @@ -68,3 +68,11 @@ func SignalTerm(pid int) error { func SignalHup(pid int) error { return syscall.Kill(pid, syscall.SIGHUP) } + +func IsPidRunning(pid int) bool { + if pid <= 0 { + return false + } + err := syscall.Kill(pid, 0) + return err == nil +} diff --git a/pkg/util/unixutil/unixutil_windows.go b/pkg/util/unixutil/unixutil_windows.go index 15352a0437..5c7f72aba9 100644 --- a/pkg/util/unixutil/unixutil_windows.go +++ b/pkg/util/unixutil/unixutil_windows.go @@ -40,3 +40,7 @@ func SignalTerm(pid int) error { func SignalHup(pid int) error { return nil } + +func IsPidRunning(pid int) bool { + return false +} diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index f8f039e0c2..dfae540119 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -65,6 +65,12 @@ func AuthenticateTokenVerifyCommand(w *wshutil.WshRpc, data wshrpc.CommandAuthen return resp, err } +// command "badgewatchpid", wshserver.BadgeWatchPidCommand +func BadgeWatchPidCommand(w *wshutil.WshRpc, data wshrpc.CommandBadgeWatchPidData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "badgewatchpid", data, opts) + return err +} + // command "blockinfo", wshserver.BlockInfoCommand func BlockInfoCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (*wshrpc.BlockInfoData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.BlockInfoData](w, "blockinfo", data, opts) diff --git a/pkg/wshrpc/wshremote/wshremote.go b/pkg/wshrpc/wshremote/wshremote.go index 530c836263..af0849ebbe 100644 --- a/pkg/wshrpc/wshremote/wshremote.go +++ b/pkg/wshrpc/wshremote/wshremote.go @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wshremote @@ -12,10 +12,16 @@ import ( "os" "path/filepath" "sync" + "time" + "github.com/wavetermdev/waveterm/pkg/baseds" + "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/suggestion" + "github.com/wavetermdev/waveterm/pkg/util/unixutil" "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" ) @@ -131,3 +137,44 @@ func (impl *ServerImpl) getWshPath() (string, error) { } return wshPath, nil } + +func (impl *ServerImpl) BadgeWatchPidCommand(ctx context.Context, data wshrpc.CommandBadgeWatchPidData) error { + if data.Pid <= 0 { + return fmt.Errorf("invalid pid: %d", data.Pid) + } + if data.ORef.IsEmpty() { + return fmt.Errorf("oref is required") + } + if data.BadgeId == "" { + return fmt.Errorf("badgeid is required") + } + go func() { + defer func() { + panichandler.PanicHandler("BadgeWatchPidCommand", recover()) + }() + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if !unixutil.IsPidRunning(data.Pid) { + orefStr := data.ORef.String() + event := wps.WaveEvent{ + Event: wps.Event_Badge, + Scopes: []string{orefStr}, + Data: baseds.BadgeEvent{ + ORef: orefStr, + ClearById: data.BadgeId, + }, + } + wshclient.EventPublishCommand(impl.RpcClient, event, nil) + log.Printf("BadgeWatchPidCommand: pid %d gone, cleared badge %s for oref %s\n", data.Pid, data.BadgeId, orefStr) + return + } + } + } + }() + return nil +} diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 861afa3021..74c87f375f 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -125,6 +125,7 @@ type WshRpcInterface interface { RemoteReconnectToJobManagerCommand(ctx context.Context, data CommandRemoteReconnectToJobManagerData) (*CommandRemoteReconnectToJobManagerRtnData, error) RemoteDisconnectFromJobManagerCommand(ctx context.Context, data CommandRemoteDisconnectFromJobManagerData) error RemoteTerminateJobManagerCommand(ctx context.Context, data CommandRemoteTerminateJobManagerData) error + BadgeWatchPidCommand(ctx context.Context, data CommandBadgeWatchPidData) error // emain WebSelectorCommand(ctx context.Context, data CommandWebSelectorData) ([]string, error) @@ -889,6 +890,12 @@ type TabIndicatorEventData struct { Indicator *TabIndicator `json:"indicator"` } +type CommandBadgeWatchPidData struct { + Pid int `json:"pid"` + ORef waveobj.ORef `json:"oref"` + BadgeId string `json:"badgeid"` +} + type BlockJobStatusData struct { BlockId string `json:"blockid"` JobId string `json:"jobid"` From f30f3947bd8e0ec5622fac1f808f3f33a17e8b7d Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 5 Mar 2026 14:46:08 -0800 Subject: [PATCH 13/29] working on `wsh badge` --- cmd/wsh/cmd/wshcmd-badge.go | 103 ++++++++++++++++++++++++++++++++++++ pkg/baseds/baseds.go | 14 ++--- pkg/wcore/badge.go | 5 +- 3 files changed, 114 insertions(+), 8 deletions(-) create mode 100644 cmd/wsh/cmd/wshcmd-badge.go diff --git a/cmd/wsh/cmd/wshcmd-badge.go b/cmd/wsh/cmd/wshcmd-badge.go new file mode 100644 index 0000000000..f4f1fb3458 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-badge.go @@ -0,0 +1,103 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/baseds" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" +) + +var badgeCmd = &cobra.Command{ + Use: "badge [icon]", + Short: "set or clear a block badge", + Args: cobra.MaximumNArgs(1), + RunE: badgeRun, + PreRunE: preRunSetupRpcClient, +} + +var ( + badgeColor string + badgePriority float64 + badgeClear bool + badgePersistent bool + badgeBeep bool +) + +func init() { + rootCmd.AddCommand(badgeCmd) + badgeCmd.Flags().StringVar(&badgeColor, "color", "", "badge color") + badgeCmd.Flags().Float64Var(&badgePriority, "priority", 0, "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(&badgeBeep, "beep", false, "play system bell sound") +} + +func badgeRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("badge", rtnErr == nil) + }() + + oref, err := resolveBlockArg() + if err != nil { + return fmt.Errorf("resolving block: %v", err) + } + if oref.OType != waveobj.OType_Block && oref.OType != waveobj.OType_Tab { + return fmt.Errorf("badge oref must be a block or tab (got %q)", oref.OType) + } + + var eventData baseds.BadgeEvent + eventData.ORef = oref.String() + eventData.Persistent = badgePersistent + + if badgeClear { + eventData.Clear = true + } else { + icon := "bell" + if len(args) > 0 { + icon = args[0] + } + badgeId, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("generating badge id: %v", err) + } + eventData.Badge = &baseds.Badge{ + BadgeId: badgeId.String(), + Icon: icon, + Color: badgeColor, + Priority: badgePriority, + } + } + + event := wps.WaveEvent{ + Event: wps.Event_Badge, + Scopes: []string{oref.String()}, + Data: eventData, + } + + err = wshclient.EventPublishCommand(RpcClient, event, &wshrpc.RpcOpts{NoResponse: true}) + if err != nil { + return fmt.Errorf("publishing badge event: %v", err) + } + + if badgeBeep { + err = wshclient.ElectronSystemBellCommand(RpcClient, &wshrpc.RpcOpts{Route: "electron"}) + if err != nil { + return fmt.Errorf("playing system bell: %v", err) + } + } + + if badgeClear { + fmt.Printf("badge cleared\n") + } else { + fmt.Printf("badge set\n") + } + return nil +} diff --git a/pkg/baseds/baseds.go b/pkg/baseds/baseds.go index a9d24d7e1a..125f782a47 100644 --- a/pkg/baseds/baseds.go +++ b/pkg/baseds/baseds.go @@ -14,17 +14,17 @@ type RpcInputChType struct { } type Badge struct { - BadgeId string `json:"badgeid"` + BadgeId string `json:"badgeid"` // must be a uuidv7 Icon string `json:"icon"` Color string `json:"color,omitempty"` Priority float64 `json:"priority"` } type BadgeEvent struct { - ORef string `json:"oref"` - Persistent bool `json:"persistent,omitempty"` - Clear bool `json:"clear,omitempty"` - ClearAll bool `json:"clearall,omitempty"` - ClearById string `json:"clearbyid,omitempty"` - Badge *Badge `json:"badge,omitempty"` + ORef string `json:"oref"` + Persistent bool `json:"persistent,omitempty"` + Clear bool `json:"clear,omitempty"` + ClearAll bool `json:"clearall,omitempty"` + ClearById string `json:"clearbyid,omitempty"` + Badge *Badge `json:"badge,omitempty"` } diff --git a/pkg/wcore/badge.go b/pkg/wcore/badge.go index ffdc3ad92b..6463238411 100644 --- a/pkg/wcore/badge.go +++ b/pkg/wcore/badge.go @@ -99,12 +99,15 @@ func handleBadgeEvent(event *wps.WaveEvent) { log.Printf("badge store: received badge event with empty oref\n") return } - oref, err := waveobj.ParseORef(data.ORef) if err != nil { log.Printf("badge store: error parsing oref %q: %v\n", data.ORef, err) return } + if oref.OType != waveobj.OType_Block && oref.OType != waveobj.OType_Tab { + log.Printf("badge store: can only handle block/tab orefs") + return + } setBadge(oref, data) } From a88c3bf5d550873f8369df35f1f679e1459e2095 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 5 Mar 2026 14:50:08 -0800 Subject: [PATCH 14/29] hook up pid watching to wsh badge command --- cmd/wsh/cmd/wshcmd-badge.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/cmd/wsh/cmd/wshcmd-badge.go b/cmd/wsh/cmd/wshcmd-badge.go index f4f1fb3458..465cec8c48 100644 --- a/cmd/wsh/cmd/wshcmd-badge.go +++ b/cmd/wsh/cmd/wshcmd-badge.go @@ -5,6 +5,7 @@ package cmd import ( "fmt" + "runtime" "github.com/google/uuid" "github.com/spf13/cobra" @@ -13,6 +14,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" + "github.com/wavetermdev/waveterm/pkg/wshutil" ) var badgeCmd = &cobra.Command{ @@ -29,6 +31,7 @@ var ( badgeClear bool badgePersistent bool badgeBeep bool + badgePid int ) func init() { @@ -38,6 +41,7 @@ func init() { badgeCmd.Flags().BoolVar(&badgeClear, "clear", false, "clear the badge") badgeCmd.Flags().BoolVar(&badgePersistent, "persistent", false, "make badge persistent (survives restarts)") 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") } func badgeRun(cmd *cobra.Command, args []string) (rtnErr error) { @@ -45,6 +49,10 @@ func badgeRun(cmd *cobra.Command, args []string) (rtnErr error) { sendActivity("badge", rtnErr == nil) }() + if badgePid > 0 && runtime.GOOS == "windows" { + return fmt.Errorf("--pid flag is not supported on Windows") + } + oref, err := resolveBlockArg() if err != nil { return fmt.Errorf("resolving block: %v", err) @@ -94,6 +102,23 @@ func badgeRun(cmd *cobra.Command, args []string) (rtnErr error) { } } + if badgePid > 0 && eventData.Badge != nil { + conn := RpcContext.Conn + if conn == "" { + conn = wshrpc.LocalConnName + } + connRoute := wshutil.MakeConnectionRouteId(conn) + watchData := wshrpc.CommandBadgeWatchPidData{ + Pid: badgePid, + ORef: *oref, + BadgeId: eventData.Badge.BadgeId, + } + err = wshclient.BadgeWatchPidCommand(RpcClient, watchData, &wshrpc.RpcOpts{Route: connRoute}) + if err != nil { + return fmt.Errorf("watching pid: %v", err) + } + } + if badgeClear { fmt.Printf("badge cleared\n") } else { From 8d6f2ad8ff159b5e8b6d5b0d6a62207b2b261710 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 5 Mar 2026 15:22:24 -0800 Subject: [PATCH 15/29] checkpoint on moving from tabiindicators to badges --- frontend/app/app.tsx | 26 +++++++++++++------------- frontend/app/store/badge.ts | 17 ++++++++++++++++- frontend/app/store/keymodel.ts | 2 +- frontend/app/tab/tab.tsx | 28 ++++++++-------------------- frontend/app/view/term/termwrap.ts | 5 +---- 5 files changed, 39 insertions(+), 39 deletions(-) diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx index 369389abe3..501bade53b 100644 --- a/frontend/app/app.tsx +++ b/frontend/app/app.tsx @@ -1,11 +1,12 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { clearTabIndicatorFromFocus, getTabIndicatorAtom } from "@/app/store/badge"; +import { clearTransientBadgesForBlock, getBlockBadgeAtom } from "@/app/store/badge"; import { ClientModel } from "@/app/store/client-model"; import { GlobalModel } from "@/app/store/global-model"; import { getTabModelByTabId, TabModelContext } from "@/app/store/tab-model"; import { Workspace } from "@/app/workspace/workspace"; +import { getLayoutModelForStaticTab } from "@/layout/index"; import { ContextMenuModel } from "@/store/contextmenu"; import { atoms, createBlock, getSettingsPrefixAtom, globalStore } from "@/store/global"; import { appHandleKeyDown, keyboardMouseDownHandler } from "@/store/keymodel"; @@ -209,25 +210,24 @@ const AppKeyHandlers = () => { return null; }; -const TabIndicatorAutoClearing = () => { - const tabId = useAtomValue(atoms.staticTabId); - const indicator = useAtomValue(getTabIndicatorAtom(tabId)); - const documentHasFocus = useAtomValue(atoms.documentHasFocus); +const BadgeAutoClearing = () => { + const layoutModel = getLayoutModelForStaticTab(); + const focusedNode = useAtomValue(layoutModel.focusedNode); + const focusedBlockId = focusedNode?.data?.blockId; + const badge = useAtomValue(getBlockBadgeAtom(focusedBlockId)); useEffect(() => { - if (!indicator || !documentHasFocus || !indicator.clearonfocus) { + if (!focusedBlockId || !badge) { return; } - const timeoutId = setTimeout(() => { - const currentIndicator = globalStore.get(getTabIndicatorAtom(tabId)); - if (globalStore.get(atoms.documentHasFocus) && currentIndicator?.clearonfocus) { - clearTabIndicatorFromFocus(tabId); + const currentFocusedNode = globalStore.get(layoutModel.focusedNode); + if (currentFocusedNode?.data?.blockId === focusedBlockId) { + clearTransientBadgesForBlock(focusedBlockId); } }, 3000); - return () => clearTimeout(timeoutId); - }, [tabId, indicator, documentHasFocus]); + }, [focusedBlockId, badge]); return null; }; @@ -259,7 +259,7 @@ const AppInner = () => { - + diff --git a/frontend/app/store/badge.ts b/frontend/app/store/badge.ts index e5b4b7766d..fa10d7150c 100644 --- a/frontend/app/store/badge.ts +++ b/frontend/app/store/badge.ts @@ -3,7 +3,7 @@ import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { fireAndForget } from "@/util/util"; +import { fireAndForget, NullAtom } from "@/util/util"; import { v7 as uuidv7, version as uuidVersion } from "uuid"; import { atom, Atom, PrimitiveAtom } from "jotai"; import { globalStore } from "./jotaiStore"; @@ -99,6 +99,14 @@ function clearBadgeInternal(oref: string, persistent: boolean) { fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); } +function clearTransientBadgesForBlock(blockId: string) { + const oref = WOS.makeORef("block", blockId); + const transientAtom = TransientBadgeMap.get(oref); + if (transientAtom != null && globalStore.get(transientAtom) != null) { + clearBadgeInternal(oref, false); + } +} + function clearAllBadges(persistent: boolean) { const eventData: WaveEvent = { event: "badge", @@ -150,6 +158,9 @@ function setupTabIndicatorSubscription() { } function getBlockBadgeAtom(blockId: string): Atom { + if (blockId == null) { + return NullAtom as Atom; + } let rtn = BlockBadgeAtomCache.get(blockId); if (rtn != null) { return rtn; @@ -176,6 +187,9 @@ function getBlockBadgeAtom(blockId: string): Atom { } function getTabBadgeAtom(tabId: string): Atom { + if (tabId == null) { + return NullAtom as Atom; + } let rtn = TabBadgeAtomCache.get(tabId); if (rtn != null) { return rtn; @@ -308,6 +322,7 @@ export { clearBadgeById, clearBadgesForTab, clearTabIndicatorFromFocus, + clearTransientBadgesForBlock, getBlockBadgeAtom, getPersistentBadgeAtom, getTabBadgeAtom, diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index aa25448a0a..ac4ab94c42 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -65,7 +65,7 @@ export function keyboardMouseDownHandler(e: MouseEvent) { } } -function getFocusedBlockInStaticTab() { +function getFocusedBlockInStaticTab(): string { const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); return focusedNode.data?.blockId; diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index a56102534d..a6717e3ae0 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -1,14 +1,7 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { - clearAllBadges, - clearAllTabIndicators, - clearBadgesForTab, - clearTabIndicatorFromFocus, - getTabIndicatorAtom, - setTabIndicator, -} from "@/app/store/badge"; +import { clearAllBadges, clearBadgesForTab, 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"; @@ -30,7 +23,7 @@ interface TabVProps { isDragging: boolean; tabWidth: number; isNew: boolean; - indicator?: TabIndicator | null; + indicator?: Badge | null; onClick: () => void; onClose: (event: React.MouseEvent | null) => void; onDragStart: (event: React.MouseEvent) => void; @@ -216,20 +209,18 @@ function buildTabContextMenu( onClose: (event: React.MouseEvent | null) => void ): ContextMenuItem[] { const menu: ContextMenuItem[] = []; - const currentIndicator = globalStore.get(getTabIndicatorAtom(id)); - if (currentIndicator) { + const currentBadges = globalStore.get(getTabBadgeAtom(id)); + if (currentBadges?.length > 0) { menu.push( { - label: "Clear Tab Indicator", + label: "Clear Tab Badges", click: () => { - setTabIndicator(id, null); clearBadgesForTab(id); }, }, { - label: "Clear All Indicators", + label: "Clear All Badges", click: () => { - clearAllTabIndicators(); clearAllBadges(true); clearAllBadges(false); }, @@ -296,7 +287,8 @@ 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 indicator = useAtomValue(getTabIndicatorAtom(id)); + const badges = useAtomValue(getTabBadgeAtom(id)); + const indicator = badges?.[0] ?? null; const loadedRef = useRef(false); const renameRef = useRef<(() => void) | null>(null); @@ -309,10 +301,6 @@ const TabInner = forwardRef((props, ref) => { }, [onLoaded]); const handleTabClick = () => { - const currentIndicator = globalStore.get(getTabIndicatorAtom(id)); - if (currentIndicator?.clearonfocus) { - clearTabIndicatorFromFocus(id); - } onSelect(); }; diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index c36ec5788a..f692765868 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -2,12 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import type { BlockNodeModel } from "@/app/block/blocktypes"; -import { setBadge, setTabIndicator } from "@/app/store/badge"; +import { setBadge } from "@/app/store/badge"; import { getFileSubject } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { - atoms, fetchWaveFile, getOverrideConfigAtom, getSettingsKeyAtom, @@ -255,8 +254,6 @@ export class TermWrap { const bellIndicatorEnabled = globalStore.get(getOverrideConfigAtom(this.blockId, "term:bellindicator")) ?? false; if (bellIndicatorEnabled) { - const tabId = globalStore.get(atoms.staticTabId); - setTabIndicator(tabId, { icon: "bell", color: "#fbbf24", clearonfocus: true, priority: 1 }); setBadge(this.blockId, { icon: "bell", color: "#fbbf24", priority: 1 }); } return true; From 339cd6c3b074188d020848c081d208cab01e979c Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 5 Mar 2026 15:35:14 -0800 Subject: [PATCH 16/29] clear transient tab badges with focus as well --- frontend/app/app.tsx | 25 ++++++++++++++++++++++--- frontend/app/store/badge.ts | 12 ++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx index 501bade53b..7ce267336f 100644 --- a/frontend/app/app.tsx +++ b/frontend/app/app.tsx @@ -1,7 +1,7 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { clearTransientBadgesForBlock, getBlockBadgeAtom } from "@/app/store/badge"; +import { clearTransientBadgeForTab, clearTransientBadgesForBlock, getBlockBadgeAtom, getTransientBadgeAtom } from "@/app/store/badge"; import { ClientModel } from "@/app/store/client-model"; import { GlobalModel } from "@/app/store/global-model"; import { getTabModelByTabId, TabModelContext } from "@/app/store/tab-model"; @@ -211,23 +211,42 @@ const AppKeyHandlers = () => { }; const BadgeAutoClearing = () => { + const tabId = useAtomValue(atoms.staticTabId); + const documentHasFocus = useAtomValue(atoms.documentHasFocus); const layoutModel = getLayoutModelForStaticTab(); const focusedNode = useAtomValue(layoutModel.focusedNode); const focusedBlockId = focusedNode?.data?.blockId; const badge = useAtomValue(getBlockBadgeAtom(focusedBlockId)); + const tabTransientBadge = useAtomValue(getTransientBadgeAtom(tabId != null ? `tab:${tabId}` : null)); useEffect(() => { - if (!focusedBlockId || !badge) { + if (!focusedBlockId || !badge || !documentHasFocus) { return; } const timeoutId = setTimeout(() => { + if (!document.hasFocus()) { + return; + } const currentFocusedNode = globalStore.get(layoutModel.focusedNode); if (currentFocusedNode?.data?.blockId === focusedBlockId) { clearTransientBadgesForBlock(focusedBlockId); } }, 3000); return () => clearTimeout(timeoutId); - }, [focusedBlockId, badge]); + }, [focusedBlockId, badge, documentHasFocus]); + + useEffect(() => { + if (!tabId || !tabTransientBadge || !documentHasFocus) { + return; + } + const timeoutId = setTimeout(() => { + if (!document.hasFocus()) { + return; + } + clearTransientBadgeForTab(tabId); + }, 3000); + return () => clearTimeout(timeoutId); + }, [tabId, tabTransientBadge, documentHasFocus]); return null; }; diff --git a/frontend/app/store/badge.ts b/frontend/app/store/badge.ts index fa10d7150c..93d958f170 100644 --- a/frontend/app/store/badge.ts +++ b/frontend/app/store/badge.ts @@ -107,6 +107,14 @@ function clearTransientBadgesForBlock(blockId: string) { } } +function clearTransientBadgeForTab(tabId: string) { + const oref = WOS.makeORef("tab", tabId); + const transientAtom = TransientBadgeMap.get(oref); + if (transientAtom != null && globalStore.get(transientAtom) != null) { + clearBadgeInternal(oref, false); + } +} + function clearAllBadges(persistent: boolean) { const eventData: WaveEvent = { event: "badge", @@ -227,6 +235,9 @@ function getPersistentBadgeAtom(oref: string): PrimitiveAtom { } function getTransientBadgeAtom(oref: string): PrimitiveAtom { + if (oref == null) { + return NullAtom as PrimitiveAtom; + } let rtn = TransientBadgeMap.get(oref); if (rtn == null) { rtn = atom(null) as PrimitiveAtom; @@ -322,6 +333,7 @@ export { clearBadgeById, clearBadgesForTab, clearTabIndicatorFromFocus, + clearTransientBadgeForTab, clearTransientBadgesForBlock, getBlockBadgeAtom, getPersistentBadgeAtom, From ccf64e63f3a3f5a4954e417387a2e2ad4f046cc2 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 5 Mar 2026 15:44:13 -0800 Subject: [PATCH 17/29] more badge migration --- cmd/wsh/cmd/wshcmd-badge.go | 2 +- cmd/wsh/cmd/wshcmd-tabindicator.go | 44 ++++++++++++++++++------------ eslint.config.js | 5 ++-- frontend/app/app.tsx | 9 ++++-- frontend/app/tab/tab.tsx | 14 +++++----- 5 files changed, 45 insertions(+), 29 deletions(-) diff --git a/cmd/wsh/cmd/wshcmd-badge.go b/cmd/wsh/cmd/wshcmd-badge.go index 465cec8c48..06e3495548 100644 --- a/cmd/wsh/cmd/wshcmd-badge.go +++ b/cmd/wsh/cmd/wshcmd-badge.go @@ -68,7 +68,7 @@ func badgeRun(cmd *cobra.Command, args []string) (rtnErr error) { if badgeClear { eventData.Clear = true } else { - icon := "bell" + icon := "circle-small" if len(args) > 0 { icon = args[0] } diff --git a/cmd/wsh/cmd/wshcmd-tabindicator.go b/cmd/wsh/cmd/wshcmd-tabindicator.go index f103ee9437..5c966f10c9 100644 --- a/cmd/wsh/cmd/wshcmd-tabindicator.go +++ b/cmd/wsh/cmd/wshcmd-tabindicator.go @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd @@ -7,7 +7,9 @@ import ( "fmt" "os" + "github.com/google/uuid" "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" @@ -16,7 +18,7 @@ import ( var tabIndicatorCmd = &cobra.Command{ Use: "tabindicator [icon]", - Short: "set or clear a tab indicator", + Short: "set or clear a tab indicator (deprecated: use 'wsh badge')", Args: cobra.MaximumNArgs(1), RunE: tabIndicatorRun, PreRunE: preRunSetupRpcClient, @@ -46,6 +48,8 @@ func tabIndicatorRun(cmd *cobra.Command, args []string) (rtnErr error) { sendActivity("tabindicator", rtnErr == nil) }() + fmt.Fprintf(os.Stderr, "tabindicator is deprecated, use 'wsh badge' instead\n") + tabId := tabIndicatorTabId if tabId == "" { tabId = os.Getenv("WAVETERM_TABID") @@ -54,34 +58,40 @@ func tabIndicatorRun(cmd *cobra.Command, args []string) (rtnErr error) { return fmt.Errorf("no tab id specified (use --tabid or set WAVETERM_TABID)") } - var indicator *wshrpc.TabIndicator - if !tabIndicatorClear { + oref := waveobj.MakeORef(waveobj.OType_Tab, tabId) + + var eventData baseds.BadgeEvent + eventData.ORef = oref.String() + eventData.Persistent = tabIndicatorPersistent + + if tabIndicatorClear { + eventData.Clear = true + } else { icon := "bell" if len(args) > 0 { icon = args[0] } - indicator = &wshrpc.TabIndicator{ - Icon: icon, - Color: tabIndicatorColor, - Priority: tabIndicatorPriority, - ClearOnFocus: !tabIndicatorPersistent, + badgeId, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("generating badge id: %v", err) + } + eventData.Badge = &baseds.Badge{ + BadgeId: badgeId.String(), + Icon: icon, + Color: tabIndicatorColor, + Priority: tabIndicatorPriority, } - } - - eventData := wshrpc.TabIndicatorEventData{ - TabId: tabId, - Indicator: indicator, } event := wps.WaveEvent{ - Event: wps.Event_TabIndicator, - Scopes: []string{waveobj.MakeORef(waveobj.OType_Tab, tabId).String()}, + Event: wps.Event_Badge, + Scopes: []string{oref.String()}, Data: eventData, } err := wshclient.EventPublishCommand(RpcClient, event, &wshrpc.RpcOpts{NoResponse: true}) if err != nil { - return fmt.Errorf("publishing tab indicator event: %v", err) + return fmt.Errorf("publishing badge event: %v", err) } if tabIndicatorBeep { diff --git a/eslint.config.js b/eslint.config.js index 01b1d6a11b..50fe7ef7c3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -76,8 +76,9 @@ export default [ "@typescript-eslint/no-unused-vars": [ "warn", { - argsIgnorePattern: "^_[a-zA-Z0-9_]*$", - varsIgnorePattern: "^_[a-zA-Z0-9_]*$", + argsIgnorePattern: "^(_[a-zA-Z0-9_]*|e|get)$", + varsIgnorePattern: "^(_[a-zA-Z0-9_]*|dlog|e)$", + caughtErrorsIgnorePattern: "^(_[a-zA-Z0-9_]*|e)$", }, ], "prefer-const": "warn", diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx index 7ce267336f..4ae9d47349 100644 --- a/frontend/app/app.tsx +++ b/frontend/app/app.tsx @@ -1,7 +1,12 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { clearTransientBadgeForTab, clearTransientBadgesForBlock, getBlockBadgeAtom, getTransientBadgeAtom } from "@/app/store/badge"; +import { + clearTransientBadgeForTab, + clearTransientBadgesForBlock, + getBlockBadgeAtom, + getTransientBadgeAtom, +} from "@/app/store/badge"; import { ClientModel } from "@/app/store/client-model"; import { GlobalModel } from "@/app/store/global-model"; import { getTabModelByTabId, TabModelContext } from "@/app/store/tab-model"; @@ -98,7 +103,7 @@ async function handleContextMenu(e: React.MouseEvent) { if (!canPaste && !canCopy && !canCut && !clipboardURL) { return; } - let menu: ContextMenuItem[] = []; + const menu: ContextMenuItem[] = []; if (canCut) { menu.push({ label: "Cut", role: "cut" }); } diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index a6717e3ae0..0878337431 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -23,7 +23,7 @@ interface TabVProps { isDragging: boolean; tabWidth: number; isNew: boolean; - indicator?: Badge | null; + badge?: Badge | null; onClick: () => void; onClose: (event: React.MouseEvent | null) => void; onDragStart: (event: React.MouseEvent) => void; @@ -42,7 +42,7 @@ const TabV = forwardRef((props, ref) => { isDragging, tabWidth, isNew, - indicator, + badge, onClick, onClose, onDragStart, @@ -179,13 +179,13 @@ const TabV = forwardRef((props, ref) => { > {tabName} - {indicator && ( + {badge && (
- +
)}