Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f9d006b
Initial plan
Copilot Mar 10, 2026
dcd00f3
feat: add tabbar preview env
Copilot Mar 10, 2026
fa1bf06
chore: polish tabbar preview
Copilot Mar 10, 2026
ab95a25
remove objectservice.settabname with an rpc call. use waveenv to rep…
sawka Mar 11, 2026
70a2da3
improve default mocking for waveobjs
sawka Mar 11, 2026
41f419a
fix useWaveObjectValue and also mock contextmenu
sawka Mar 11, 2026
90f4e22
mock Tab component directly (instead of TabV), and get badge mocking …
sawka Mar 11, 2026
9e74132
move UpdateTabIds to rpc, and mock
sawka Mar 11, 2026
2bb1f28
Merge remote-tracking branch 'origin/main' into copilot/create-waveen…
sawka Mar 11, 2026
8659220
generic set
sawka Mar 11, 2026
24a4d7c
workspaceid atom
sawka Mar 11, 2026
f87b8c1
error boundary
sawka Mar 11, 2026
1360492
better way to mock singleton or static keyed models
sawka Mar 11, 2026
099eb0f
env for workspaceswitcher
sawka Mar 11, 2026
61eac7f
update services for mocking
sawka Mar 11, 2026
7be80be
waveenv mock out services. update workspaceswitcher. stop subscript…
sawka Mar 11, 2026
dafe23a
tabbar preview working again
sawka Mar 11, 2026
8d06b03
re-add platform switcher
sawka Mar 11, 2026
b6436e9
fix long name editing bug
sawka Mar 11, 2026
1ce3728
working on the layout, still no scrollbar
sawka Mar 11, 2026
42f4b4a
fix tab scrolling (import overlay scrollbars css)
sawka Mar 11, 2026
e9fd7be
add show menu bar option
sawka Mar 11, 2026
d006371
update styling for some of the icons, fix gaps, simplify, more tailwind
sawka Mar 11, 2026
8e31098
minor unrelated fix
sawka Mar 11, 2026
5d752b5
fix warning and typing error
sawka Mar 11, 2026
c39646a
fix issue with margins in width calcs
sawka Mar 11, 2026
61bd781
use waveenv for updatebanner, add hasConfigErrors atom
sawka Mar 11, 2026
19b7a42
fix re-layout deps to include all the button toggles. remove crazy u…
sawka Mar 11, 2026
4e08aa1
make overrides properly async
sawka Mar 11, 2026
3675696
dont depend on full settings atom. showmenubar also should cause a r…
sawka Mar 11, 2026
ed4576f
fix unrelated streamdown errors
sawka Mar 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .kilocode/skills/add-rpc/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ RPC commands in Wave Terminal follow these conventions:

- **Method names** must end with `Command`
- **First parameter** must be `context.Context`
- **Second parameter** (optional) is the command data structure
- **Remaining parameters** are a regular Go parameter list (zero or more typed args)
- **Return values** can be either just an error, or one return value plus an error
- **Streaming commands** return a channel instead of a direct value

Expand All @@ -49,7 +49,7 @@ type WshRpcInterface interface {

- Method name must end with `Command`
- First parameter must be `ctx context.Context`
- Optional second parameter for input data
- Remaining parameters are a regular Go parameter list (zero or more)
- Return either `error` or `(ReturnType, error)`
- For streaming, return `chan RespOrErrorUnion[T]`

Expand Down
15 changes: 15 additions & 0 deletions .kilocode/skills/waveenv/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ export type MyEnv = WaveEnvSubset<{
// --- wos: always take the whole thing, no sub-typing needed ---
wos: WaveEnv["wos"];

// --- services: list only the services you call; no method-level narrowing ---
services: {
block: WaveEnv["services"]["block"];
workspace: WaveEnv["services"]["workspace"];
};

// --- key-parameterized atom factories: enumerate the keys you use ---
getSettingsKeyAtom: SettingsKeyAtomFnType<"app:focusfollowscursor" | "window:magnifiedblockopacity">;
getBlockMetaKeyAtom: BlockMetaKeyAtomFnType<"view" | "frame:title" | "connection">;
Expand All @@ -80,6 +86,14 @@ export type MyEnv = WaveEnvSubset<{
}>;
```

### Automatically Included Fields

Every `WaveEnvSubset<T>` automatically includes the mock fields — you never need to declare them:

- `isMock: boolean`
- `mockSetWaveObj: <T extends WaveObj>(oref: string, obj: T) => void`
- `mockModels?: Map<any, any>`

### Rules for Each Section

| Section | Pattern | Notes |
Expand All @@ -88,6 +102,7 @@ export type MyEnv = WaveEnvSubset<{
| `rpc` | `rpc: { Cmd: WaveEnv["rpc"]["Cmd"]; }` | List every RPC command called; omit the rest. |
| `atoms` | `atoms: { atom: WaveEnv["atoms"]["atom"]; }` | List every atom read; omit the rest. |
| `wos` | `wos: WaveEnv["wos"]` | Take the whole `wos` object (no sub-typing needed), but **only add it if `wos` is actually used**. |
| `services` | `services: { svc: WaveEnv["services"]["svc"]; }` | List each service used; take the whole service object (no method-level narrowing). |
| `getSettingsKeyAtom` | `SettingsKeyAtomFnType<"key1" \| "key2">` | Union all settings keys accessed. |
| `getBlockMetaKeyAtom` | `BlockMetaKeyAtomFnType<"key1" \| "key2">` | Union all block meta keys accessed. |
| `getConnConfigKeyAtom` | `ConnConfigKeyAtomFnType<"key1">` | Union all conn config keys accessed. |
Expand Down
25 changes: 24 additions & 1 deletion cmd/generatets/main-generatets.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,37 @@ func generateServicesFile(tsTypesMap map[reflect.Type]string) error {
fmt.Fprintf(&buf, "// Copyright 2026, Command Line Inc.\n")
fmt.Fprintf(&buf, "// SPDX-License-Identifier: Apache-2.0\n\n")
fmt.Fprintf(&buf, "// generated by cmd/generate/main-generatets.go\n\n")
fmt.Fprintf(&buf, "import * as WOS from \"./wos\";\n\n")
fmt.Fprintf(&buf, "import * as WOS from \"./wos\";\n")
fmt.Fprintf(&buf, "import type { WaveEnv } from \"@/app/waveenv/waveenv\";\n\n")
fmt.Fprintf(&buf, "function callBackendService(waveEnv: WaveEnv, service: string, method: string, args: any[], noUIContext?: boolean): Promise<any> {\n")
fmt.Fprintf(&buf, " if (waveEnv != null) {\n")
fmt.Fprintf(&buf, " return waveEnv.callBackendService(service, method, args, noUIContext)\n")
fmt.Fprintf(&buf, " }\n")
fmt.Fprintf(&buf, " return WOS.callBackendService(service, method, args, noUIContext);\n")
fmt.Fprintf(&buf, "}\n\n")
orderedKeys := utilfn.GetOrderedMapKeys(service.ServiceMap)
for _, serviceName := range orderedKeys {
serviceObj := service.ServiceMap[serviceName]
svcStr := tsgen.GenerateServiceClass(serviceName, serviceObj, tsTypesMap)
fmt.Fprint(&buf, svcStr)
fmt.Fprint(&buf, "\n")
}
fmt.Fprintf(&buf, "export const AllServiceTypes = {\n")
for _, serviceName := range orderedKeys {
serviceObj := service.ServiceMap[serviceName]
serviceType := reflect.TypeOf(serviceObj)
tsServiceName := serviceType.Elem().Name()
fmt.Fprintf(&buf, " %q: %sType,\n", serviceName, tsServiceName)
}
fmt.Fprintf(&buf, "};\n\n")
fmt.Fprintf(&buf, "export const AllServiceImpls = {\n")
for _, serviceName := range orderedKeys {
serviceObj := service.ServiceMap[serviceName]
serviceType := reflect.TypeOf(serviceObj)
tsServiceName := serviceType.Elem().Name()
fmt.Fprintf(&buf, " %q: %s,\n", serviceName, tsServiceName)
}
fmt.Fprintf(&buf, "};\n")
written, err := utilfn.WriteFileIfDifferent(fileName, buf.Bytes())
if !written {
fmt.Fprintf(os.Stderr, "no changes to %s\n", fileName)
Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export default [
{
files: ["frontend/app/store/services.ts"],
rules: {
"@typescript-eslint/no-unused-vars": "off",
"prefer-rest-params": "off",
},
},
Expand Down
11 changes: 6 additions & 5 deletions frontend/app/element/streamdown.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2025, Command Line Inc.
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { CopyButton } from "@/app/element/copybutton";
Expand Down Expand Up @@ -314,11 +314,12 @@ export const WaveStreamdown = ({
table: false,
mermaid: true,
}}
mermaidConfig={{
theme: "dark",
darkMode: true,
mermaid={{
config: {
theme: "dark",
darkMode: true,
},
}}
defaultOrigin="http://localhost"
components={components}
>
{text}
Expand Down
63 changes: 45 additions & 18 deletions frontend/app/store/badge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,42 @@

import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv";
import { fireAndForget, NullAtom } from "@/util/util";
import { atom, Atom, PrimitiveAtom } from "jotai";
import { v7 as uuidv7, version as uuidVersion } from "uuid";
import { globalStore } from "./jotaiStore";
import * as WOS from "./wos";
import { waveEventSubscribeSingle } from "./wps";

export type BadgeEnv = WaveEnvSubset<{
rpc: {
EventPublishCommand: WaveEnv["rpc"]["EventPublishCommand"];
};
}>;

export type LoadBadgesEnv = WaveEnvSubset<{
rpc: {
GetAllBadgesCommand: WaveEnv["rpc"]["GetAllBadgesCommand"];
};
}>;

export type TabBadgesEnv = WaveEnvSubset<{
wos: WaveEnv["wos"];
}>;

const BadgeMap = new Map<string, PrimitiveAtom<Badge>>();
const TabBadgeAtomCache = new Map<string, Atom<Badge[]>>();

function clearBadgeInternal(oref: string) {
function publishBadgeEvent(eventData: WaveEvent, env?: BadgeEnv) {
if (env != null) {
fireAndForget(() => env.rpc.EventPublishCommand(TabRpcClient, eventData));
} else {
fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData));
}
}

function clearBadgeInternal(oref: string, env?: BadgeEnv) {
const eventData: WaveEvent = {
event: "badge",
scopes: [oref],
Expand All @@ -22,28 +47,28 @@ function clearBadgeInternal(oref: string) {
clear: true,
} as BadgeEvent,
};
fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData));
publishBadgeEvent(eventData, env);
}

function clearBadgesForBlockOnFocus(blockId: string) {
function clearBadgesForBlockOnFocus(blockId: string, env?: BadgeEnv) {
const oref = WOS.makeORef("block", blockId);
const badgeAtom = BadgeMap.get(oref);
const badge = badgeAtom != null ? globalStore.get(badgeAtom) : null;
if (badge != null && !badge.pidlinked) {
clearBadgeInternal(oref);
clearBadgeInternal(oref, env);
}
}

function clearBadgesForTabOnFocus(tabId: string) {
function clearBadgesForTabOnFocus(tabId: string, env?: BadgeEnv) {
const oref = WOS.makeORef("tab", tabId);
const badgeAtom = BadgeMap.get(oref);
const badge = badgeAtom != null ? globalStore.get(badgeAtom) : null;
if (badge != null && !badge.pidlinked) {
clearBadgeInternal(oref);
clearBadgeInternal(oref, env);
}
}

function clearAllBadges() {
function clearAllBadges(env?: BadgeEnv) {
const eventData: WaveEvent = {
event: "badge",
scopes: [],
Expand All @@ -52,18 +77,18 @@ function clearAllBadges() {
clearall: true,
} as BadgeEvent,
};
fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData));
publishBadgeEvent(eventData, env);
}

function clearBadgesForTab(tabId: string) {
function clearBadgesForTab(tabId: string, env?: BadgeEnv) {
const tabAtom = WOS.getWaveObjectAtom<Tab>(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 badgeAtom = BadgeMap.get(oref);
if (badgeAtom != null && globalStore.get(badgeAtom) != null) {
clearBadgeInternal(oref);
clearBadgeInternal(oref, env);
}
}
}
Expand All @@ -88,7 +113,7 @@ function getBlockBadgeAtom(blockId: string): Atom<Badge> {
return getBadgeAtom(oref);
}

function getTabBadgeAtom(tabId: string): Atom<Badge[]> {
function getTabBadgeAtom(tabId: string, env?: TabBadgesEnv): Atom<Badge[]> {
if (tabId == null) {
return NullAtom as Atom<Badge[]>;
}
Expand All @@ -98,7 +123,8 @@ function getTabBadgeAtom(tabId: string): Atom<Badge[]> {
}
const tabOref = WOS.makeORef("tab", tabId);
const tabBadgeAtom = getBadgeAtom(tabOref);
const tabAtom = atom((get) => WOS.getObjectValue<Tab>(tabOref, get));
const tabAtom =
env != null ? env.wos.getWaveObjectAtom<Tab>(tabOref) : WOS.getWaveObjectAtom<Tab>(tabOref);
rtn = atom((get) => {
const tab = get(tabAtom);
const blockIds = tab?.blockids ?? [];
Expand All @@ -119,8 +145,9 @@ function getTabBadgeAtom(tabId: string): Atom<Badge[]> {
return rtn;
}

async function loadBadges() {
const badges = await RpcApi.GetAllBadgesCommand(TabRpcClient);
async function loadBadges(env?: LoadBadgesEnv) {
const rpc = env != null ? env.rpc : RpcApi;
const badges = await rpc.GetAllBadgesCommand(TabRpcClient);
if (badges == null) {
return;
}
Expand All @@ -133,7 +160,7 @@ async function loadBadges() {
}
}

function setBadge(blockId: string, badge: Omit<Badge, "badgeid"> & { badgeid?: string }) {
function setBadge(blockId: string, badge: Omit<Badge, "badgeid"> & { badgeid?: string }, env?: BadgeEnv) {
if (!badge.badgeid) {
badge = { ...badge, badgeid: uuidv7() };
} else if (uuidVersion(badge.badgeid) !== 7) {
Expand All @@ -148,10 +175,10 @@ function setBadge(blockId: string, badge: Omit<Badge, "badgeid"> & { badgeid?: s
badge: badge,
} as BadgeEvent,
};
fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData));
publishBadgeEvent(eventData, env);
}

function clearBadgeById(blockId: string, badgeId: string) {
function clearBadgeById(blockId: string, badgeId: string, env?: BadgeEnv) {
const oref = WOS.makeORef("block", blockId);
const eventData: WaveEvent = {
event: "badge",
Expand All @@ -161,7 +188,7 @@ function clearBadgeById(blockId: string, badgeId: string) {
clearbyid: badgeId,
} as BadgeEvent,
};
fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData));
publishBadgeEvent(eventData, env);
}

function setupBadgesSubscription() {
Expand Down
6 changes: 3 additions & 3 deletions frontend/app/store/contextmenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,11 @@ class ContextMenuModel {
this.activeOpts = opts;
const electronMenuItems = this._convertAndRegisterMenu(menu);

const workspace = globalStore.get(atoms.workspace);
const workspaceId = globalStore.get(atoms.workspaceId);
let oid: string;

if (workspace != null) {
oid = workspace.oid;
if (workspaceId != null) {
oid = workspaceId;
} else {
oid = globalStore.get(atoms.builderId);
}
Expand Down
16 changes: 13 additions & 3 deletions frontend/app/store/global-atoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,16 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
console.log("failed to initialize zoomFactorAtom", e);
}

const workspaceAtom: Atom<Workspace> = atom((get) => {
const workspaceIdAtom: Atom<string> = atom((get) => {
const windowData = WOS.getObjectValue<WaveWindow>(WOS.makeORef("window", get(windowIdAtom)), get);
if (windowData == null) {
return windowData?.workspaceid ?? null;
});
const workspaceAtom: Atom<Workspace> = atom((get) => {
const workspaceId = get(workspaceIdAtom);
if (workspaceId == null) {
return null;
}
return WOS.getObjectValue(WOS.makeORef("workspace", windowData.workspaceid), get);
return WOS.getObjectValue(WOS.makeORef("workspace", workspaceId), get);
});
const fullConfigAtom = atom(null) as PrimitiveAtom<FullConfigType>;
const waveaiModeConfigAtom = atom(null) as PrimitiveAtom<Record<string, AIModeConfigType>>;
Expand All @@ -67,6 +71,10 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
}
return false;
}) as Atom<boolean>;
const hasConfigErrors = atom((get) => {
const fullConfig = get(fullConfigAtom);
return fullConfig?.configerrors != null && fullConfig.configerrors.length > 0;
}) as Atom<boolean>;
// this is *the* tab that this tabview represents. it should never change.
const staticTabIdAtom: Atom<string> = atom(initOpts.tabId);
const controlShiftDelayAtom = atom(false);
Expand Down Expand Up @@ -123,11 +131,13 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
builderId: builderIdAtom,
builderAppId: builderAppIdAtom,
uiContext: uiContextAtom,
workspaceId: workspaceIdAtom,
workspace: workspaceAtom,
fullConfigAtom,
waveaiModeConfigAtom,
settingsAtom,
hasCustomAIPresetsAtom,
hasConfigErrors,
staticTabId: staticTabIdAtom,
isFullScreen: isFullScreenAtom,
zoomFactorAtom,
Expand Down
1 change: 1 addition & 0 deletions frontend/app/store/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,7 @@ function getAllBlockComponentModels(): BlockComponentModel[] {

function getFocusedBlockId(): string {
const layoutModel = getLayoutModelForStaticTab();
if (layoutModel?.focusedNode == null) return null;
const focusedLayoutNode = globalStore.get(layoutModel.focusedNode);
return focusedLayoutNode?.data?.blockId;
}
Expand Down
8 changes: 4 additions & 4 deletions frontend/app/store/keymodel.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2025, Command Line Inc.
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { WaveAIModel } from "@/app/aipanel/waveai-model";
Expand Down Expand Up @@ -129,11 +129,11 @@ function getStaticTabBlockCount(): number {
}

function simpleCloseStaticTab() {
const ws = globalStore.get(atoms.workspace);
const workspaceId = globalStore.get(atoms.workspaceId);
const tabId = globalStore.get(atoms.staticTabId);
const confirmClose = globalStore.get(getSettingsKeyAtom("tab:confirmclose")) ?? false;
getApi()
.closeTab(ws.oid, tabId, confirmClose)
.closeTab(workspaceId, tabId, confirmClose)
.then((didClose) => {
if (didClose) {
deleteLayoutModelForTab(tabId);
Expand Down Expand Up @@ -490,7 +490,7 @@ function tryReinjectKey(event: WaveKeyboardEvent): boolean {
function countTermBlocks(): number {
const allBCMs = getAllBlockComponentModels();
let count = 0;
let gsGetBound = globalStore.get.bind(globalStore);
const gsGetBound = globalStore.get.bind(globalStore);
for (const bcm of allBCMs) {
const viewModel = bcm.viewModel;
if (viewModel.viewType == "term" && viewModel.isBasicTerm?.(gsGetBound)) {
Expand Down
Loading
Loading