Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/server/main-server.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/joho/godotenv"
"github.com/wavetermdev/waveterm/pkg/aiusechat"
"github.com/wavetermdev/waveterm/pkg/authkey"
"github.com/wavetermdev/waveterm/pkg/agenttracker"
"github.com/wavetermdev/waveterm/pkg/blockcontroller"
"github.com/wavetermdev/waveterm/pkg/blocklogger"
"github.com/wavetermdev/waveterm/pkg/filebackup"
Expand Down Expand Up @@ -575,6 +576,7 @@ func main() {
blocklogger.InitBlockLogger()
jobcontroller.InitJobController()
blockcontroller.InitBlockController()
agenttracker.InitAgentTracker()
err = wcore.InitBadgeStore()
if err != nil {
log.Printf("error initializing badge store: %v\n", err)
Expand Down
15 changes: 15 additions & 0 deletions emain/emain-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,21 @@ function makeViewMenu(
},
},
{ type: "separator" },
{
label: "Toggle Wave AI Panel",
accelerator: "CommandOrControl+Shift+A",
click: (_, window) => {
(getWindowWebContents(window) ?? webContents)?.send("menu-item-toggle-panel", "waveai");
},
},
{
label: "Toggle Agents Panel",
accelerator: "CommandOrControl+Shift+G",
click: (_, window) => {
(getWindowWebContents(window) ?? webContents)?.send("menu-item-toggle-panel", "agents");
},
},
{ type: "separator" },
{
label: "Reset Zoom",
accelerator: "CommandOrControl+0",
Expand Down
1 change: 1 addition & 0 deletions emain/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ contextBridge.exposeInMainWorld("api", {
getUpdaterChannel: () => ipcRenderer.sendSync("get-updater-channel"),
installAppUpdate: () => ipcRenderer.send("install-app-update"),
onMenuItemAbout: (callback) => ipcRenderer.on("menu-item-about", callback),
onMenuItemTogglePanel: (callback) => ipcRenderer.on("menu-item-toggle-panel", (_event, panel) => callback(panel)),
updateWindowControlsOverlay: (rect) => ipcRenderer.send("update-window-controls-overlay", rect),
onReinjectKey: (callback) => ipcRenderer.on("reinject-key", (_event, waveEvent) => callback(waveEvent)),
setWebviewFocus: (focused: number) => ipcRenderer.send("webview-focus", focused),
Expand Down
142 changes: 142 additions & 0 deletions frontend/app/agentspanel/agentspanel-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { globalStore } from "@/app/store/jotaiStore";
import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { getLayoutModelForStaticTab } from "@/layout/index";
import { atoms, getApi } from "@/store/global";
import { waveEventSubscribeSingle } from "@/store/wps";
import * as jotai from "jotai";

const MinPanelWidth = 260;
const MaxPanelWidth = 640;
const DefaultPanelWidth = 340;
const WidthStorageKey = "agentspanel:width";
const RefreshTickMs = 30000;
// delay before focusing a block after switching tabs (tab content needs to mount)
const CrossTabFocusDelayMs = 350;

export const AgentStatus_Attention = "attention";
export const AgentStatus_Working = "working";
export const AgentStatus_Idle = "idle";
export const AgentStatus_Ended = "ended";

function loadStoredWidth(): number {
const raw = localStorage.getItem(WidthStorageKey);
const parsed = parseInt(raw);
if (isNaN(parsed)) {
return DefaultPanelWidth;
}
return Math.max(MinPanelWidth, Math.min(parsed, MaxPanelWidth));
}

export class AgentsPanelModel {
private static instance: AgentsPanelModel = null;

visibleAtom = jotai.atom(false) as jotai.PrimitiveAtom<boolean>;
sessionsAtom = jotai.atom<AgentSessionInfo[]>([]) as jotai.PrimitiveAtom<AgentSessionInfo[]>;
nowAtom = jotai.atom(Date.now()) as jotai.PrimitiveAtom<number>;
// workspace oids known to THIS instance; sessions from other Wave instances
// (e.g. prod vs dev sharing the same hooks file) are shown but not navigable
knownWorkspaceIdsAtom = jotai.atom<string[]>([]) as jotai.PrimitiveAtom<string[]>;
widthAtom: jotai.PrimitiveAtom<number>;
attentionCountAtom!: jotai.Atom<number>;

private constructor() {
this.widthAtom = jotai.atom(loadStoredWidth());
this.attentionCountAtom = jotai.atom((get) => {
return get(this.sessionsAtom).filter((s) => s.status === AgentStatus_Attention).length;
});
waveEventSubscribeSingle({
eventType: "agenttracker:update",
handler: () => this.refresh(),
});
setInterval(() => {
globalStore.set(this.nowAtom, Date.now());
}, RefreshTickMs);
this.refresh();
}

static getInstance(): AgentsPanelModel {
if (!AgentsPanelModel.instance) {
AgentsPanelModel.instance = new AgentsPanelModel();
}
return AgentsPanelModel.instance;
}

async refresh() {
try {
const sessions = await RpcApi.AgentTrackerListCommand(TabRpcClient);
globalStore.set(this.sessionsAtom, sessions ?? []);
} catch (e) {
console.log("agentspanel: error fetching sessions", e);
}
try {
const workspaces = await RpcApi.WorkspaceListCommand(TabRpcClient);
globalStore.set(
this.knownWorkspaceIdsAtom,
(workspaces ?? []).map((w) => w.workspacedata?.oid).filter((oid) => oid != null)
);
} catch (e) {
console.log("agentspanel: error fetching workspace list", e);
}
}

getVisible(): boolean {
return globalStore.get(this.visibleAtom);
}

setVisible(visible: boolean) {
globalStore.set(this.visibleAtom, visible);
if (visible) {
this.refresh();
}
}

toggle() {
this.setVisible(!this.getVisible());
}

setWidth(width: number) {
const clamped = Math.max(MinPanelWidth, Math.min(width, MaxPanelWidth));
globalStore.set(this.widthAtom, clamped);
localStorage.setItem(WidthStorageKey, String(clamped));
}

canFocusSession(session: AgentSessionInfo): boolean {
if (!session.blockid || !session.tabid) {
return false;
}
return globalStore.get(this.knownWorkspaceIdsAtom).includes(session.workspaceid);
}

focusSession(session: AgentSessionInfo) {
if (!this.canFocusSession(session)) {
return;
}
const workspace = globalStore.get(atoms.workspace);
if (session.workspaceid !== workspace?.oid) {
// workspace switch either swaps this window's workspace or focuses the
// window that already owns it; tab/block focus after that is racy, so
// the user clicks again once the workspace is up
getApi().switchWorkspace(session.workspaceid);
return;
}
const currentTabId = globalStore.get(atoms.staticTabId);
const sameTab = session.tabid === currentTabId;
if (!sameTab) {
getApi().setActiveTab(session.tabid);
}
setTimeout(
() => {
const layoutModel = getLayoutModelForStaticTab();
const node = layoutModel?.getNodeByBlockId(session.blockid);
if (node) {
layoutModel.focusNode(node.id);
}
},
sameTab ? 0 : CrossTabFocusDelayMs
);
}
}
Loading