Skip to content
Merged
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
115 changes: 115 additions & 0 deletions .kilocode/skills/waveenv/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
---
name: waveenv
description: Guide for creating WaveEnv narrowings in Wave Terminal. Use when writing a named subset type of WaveEnv for a component tree, documenting environmental dependencies, or enabling mock environments for preview/test server usage.
---

# WaveEnv Narrowing Skill

## Purpose

A WaveEnv narrowing creates a _named subset type_ of `WaveEnv` that:

1. Documents exactly which parts of the environment a component tree actually uses.
2. Forms a type contract so callers and tests know what to provide.
3. Enables mocking in the preview/test server — you only need to implement what's listed.

## When To Create One

Create a narrowing whenever you are writing a component (or group of components) that you want to test in the preview server, or when you want to make the environmental dependencies of a component tree explicit.

## Core Principle: Only Include What You Use

**Only list the fields, methods, atoms, and keys that the component tree actually accesses.** If you don't call `wos`, don't include `wos`. If you only call one RPC command, only list that one command. The narrowing is a precise dependency declaration — not a copy of `WaveEnv`.

## File Location

- **Separate file** (preferred for shared/complex envs): name it `<feature>env.ts` next to the component, e.g. `frontend/app/block/blockenv.ts`.
- **Inline** (acceptable for small, single-file components): export the type directly from the component file, e.g. `WidgetsEnv` in `frontend/app/workspace/widgets.tsx`.

## Imports Required

```ts
import {
BlockMetaKeyAtomFnType, // only if you use getBlockMetaKeyAtom
ConnConfigKeyAtomFnType, // only if you use getConnConfigKeyAtom
SettingsKeyAtomFnType, // only if you use getSettingsKeyAtom
WaveEnv,
WaveEnvSubset,
} from "@/app/waveenv/waveenv";
```

## The Shape

```ts
export type MyEnv = WaveEnvSubset<{
// --- Simple WaveEnv properties ---
// Copy the type verbatim from WaveEnv with WaveEnv["key"] syntax.
isDev: WaveEnv["isDev"];
createBlock: WaveEnv["createBlock"];
showContextMenu: WaveEnv["showContextMenu"];
platform: WaveEnv["platform"];

// --- electron: list only the methods you call ---
electron: {
openExternal: WaveEnv["electron"]["openExternal"];
};

// --- rpc: list only the commands you call ---
rpc: {
ActivityCommand: WaveEnv["rpc"]["ActivityCommand"];
ConnEnsureCommand: WaveEnv["rpc"]["ConnEnsureCommand"];
};

// --- atoms: list only the atoms you read ---
atoms: {
modalOpen: WaveEnv["atoms"]["modalOpen"];
fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"];
};

// --- wos: always take the whole thing, no sub-typing needed ---
wos: WaveEnv["wos"];

// --- key-parameterized atom factories: enumerate the keys you use ---
getSettingsKeyAtom: SettingsKeyAtomFnType<"app:focusfollowscursor" | "window:magnifiedblockopacity">;
getBlockMetaKeyAtom: BlockMetaKeyAtomFnType<"view" | "frame:title" | "connection">;
getConnConfigKeyAtom: ConnConfigKeyAtomFnType<"conn:wshenabled">;

// --- other atom helpers: copy verbatim ---
getConnStatusAtom: WaveEnv["getConnStatusAtom"];
getLocalHostDisplayNameAtom: WaveEnv["getLocalHostDisplayNameAtom"];
}>;
```

### Rules for Each Section

| Section | Pattern | Notes |
| -------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
| `electron` | `electron: { method: WaveEnv["electron"]["method"]; }` | List every method called; omit the rest. |
| `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**. |
| `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. |
| All other `WaveEnv` fields | `WaveEnv["fieldName"]` | Copy type verbatim. |

## Using the Narrowed Type in Components

```ts
import { useWaveEnv } from "@/app/waveenv/waveenv";
import { MyEnv } from "./myenv";

const MyComponent = memo(() => {
const env = useWaveEnv<MyEnv>();
// TypeScript now enforces you only access what's in MyEnv.
const val = useAtomValue(env.getSettingsKeyAtom("app:focusfollowscursor"));
...
});
```

The generic parameter on `useWaveEnv<MyEnv>()` casts the context to your narrowed type. The real production `WaveEnv` satisfies every narrowing; mock envs only need to implement the listed subset.

## Real Examples

- `BlockEnv` in `frontend/app/block/blockenv.ts` — complex narrowing with all section types, in a separate file.
- `WidgetsEnv` in `frontend/app/workspace/widgets.tsx` — smaller narrowing defined inline in the component file.
10 changes: 5 additions & 5 deletions frontend/app/aipanel/aipanel.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
// Copyright 2025, Command Line Inc.
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { handleWaveAIContextMenu } from "@/app/aipanel/aipanel-contextmenu";
import { waveAIHasSelection } from "@/app/aipanel/waveai-focus-utils";
import { ErrorBoundary } from "@/app/element/errorboundary";
import { atoms, getSettingsKeyAtom } from "@/app/store/global";
import { globalStore } from "@/app/store/jotaiStore";
import { useTabModelMaybe } from "@/app/store/tab-model";
import { isBuilderWindow } from "@/app/store/windowtype";
import { maybeUseTabModel } from "@/app/store/tab-model";
import { checkKeyPressed, keydownWrapper } from "@/util/keyutil";
import { isMacOS, isWindows } from "@/util/platformutil";
import { cn } from "@/util/util";
Expand Down Expand Up @@ -257,7 +257,7 @@ const AIPanelComponentInner = memo(() => {
const focusFollowsCursorMode = jotai.useAtomValue(getSettingsKeyAtom("app:focusfollowscursor")) ?? "off";
const telemetryEnabled = jotai.useAtomValue(getSettingsKeyAtom("telemetry:enabled")) ?? false;
const isPanelVisible = jotai.useAtomValue(model.getPanelVisibleAtom());
const tabModel = maybeUseTabModel();
const tabModel = useTabModelMaybe();
const defaultMode = jotai.useAtomValue(getSettingsKeyAtom("waveai:defaultmode")) ?? "waveai@balanced";
const aiModeConfigs = jotai.useAtomValue(model.aiModeConfigs);

Expand All @@ -268,7 +268,7 @@ const AIPanelComponentInner = memo(() => {
const { messages, sendMessage, status, setMessages, error, stop } = useChat<WaveUIMessage>({
transport: new DefaultChatTransport({
api: model.getUseChatEndpointUrl(),
prepareSendMessagesRequest: (opts) => {
prepareSendMessagesRequest: (_opts) => {
const msg = model.getAndClearMessage();
const body: any = {
msg,
Expand Down Expand Up @@ -503,7 +503,7 @@ const AIPanelComponentInner = memo(() => {
}, [drop]);

const handleFocusCapture = useCallback(
(event: React.FocusEvent) => {
(_event: React.FocusEvent) => {
// console.log("Wave AI focus capture", getElemAsStr(event.target));
model.requestWaveAIFocus();
},
Expand Down
100 changes: 54 additions & 46 deletions frontend/app/block/block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,8 @@ import { ErrorBoundary } from "@/element/errorboundary";
import { CenteredDiv } from "@/element/quickelems";
import { useDebouncedNodeInnerRect } from "@/layout/index";
import { counterInc } from "@/store/counters";
import {
atoms,
getBlockComponentModel,
getSettingsKeyAtom,
registerBlockComponentModel,
unregisterBlockComponentModel,
} from "@/store/global";
import { getWaveObjectAtom, makeORef, useWaveObjectValue } from "@/store/wos";
import { getBlockComponentModel, registerBlockComponentModel, unregisterBlockComponentModel } from "@/store/global";
import { makeORef } from "@/store/wos";
import { focusedBlockId, getElemAsStr } from "@/util/focusutil";
import { isBlank, useAtomValueSafe } from "@/util/util";
import { HelpViewModel } from "@/view/helpview/helpview";
Expand All @@ -42,6 +36,7 @@ import { memo, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRe
import { QuickTipsViewModel } from "../view/quicktipsview/quicktipsview";
import { WaveConfigViewModel } from "../view/waveconfig/waveconfig-model";
import "./block.scss";
import { BlockEnv } from "./blockenv";
import { BlockFrame } from "./blockframe";
import { blockViewToIcon, blockViewToName } from "./blockutil";

Expand Down Expand Up @@ -71,7 +66,7 @@ function makeViewModel(
if (ctor != null) {
return new ctor({ blockId, nodeModel, tabModel, waveEnv });
}
return makeDefaultViewModel(blockId, blockView);
return makeDefaultViewModel(blockView);
}

function getViewElem(
Expand All @@ -91,18 +86,11 @@ function getViewElem(
return <VC key={blockId} blockId={blockId} blockRef={blockRef} contentRef={contentRef} model={viewModel} />;
}

function makeDefaultViewModel(blockId: string, viewType: string): ViewModel {
const blockDataAtom = getWaveObjectAtom<Block>(makeORef("block", blockId));
function makeDefaultViewModel(viewType: string): ViewModel {
const viewModel: ViewModel = {
viewType: viewType,
viewIcon: atom((get) => {
const blockData = get(blockDataAtom);
return blockViewToIcon(blockData?.meta?.view);
}),
viewName: atom((get) => {
const blockData = get(blockDataAtom);
return blockViewToName(blockData?.meta?.view);
}),
viewIcon: atom(blockViewToIcon(viewType)),
viewName: atom(blockViewToName(viewType)),
preIconButton: atom(null),
endIconButtons: atom(null),
viewComponent: null,
Expand All @@ -111,8 +99,9 @@ function makeDefaultViewModel(blockId: string, viewType: string): ViewModel {
}

const BlockPreview = memo(({ nodeModel, viewModel }: FullBlockProps) => {
const [blockData] = useWaveObjectValue<Block>(makeORef("block", nodeModel.blockId));
if (!blockData) {
const waveEnv = useWaveEnv<BlockEnv>();
const blockIsNull = useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef("block", nodeModel.blockId)));
if (blockIsNull) {
return null;
}
return (
Expand All @@ -127,15 +116,17 @@ const BlockPreview = memo(({ nodeModel, viewModel }: FullBlockProps) => {
});

const BlockSubBlock = memo(({ nodeModel, viewModel }: FullSubBlockProps) => {
const [blockData] = useWaveObjectValue<Block>(makeORef("block", nodeModel.blockId));
const waveEnv = useWaveEnv<BlockEnv>();
const blockIsNull = useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef("block", nodeModel.blockId)));
const blockView = useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "view")) ?? "";
const blockRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const viewElem = useMemo(
() => getViewElem(nodeModel.blockId, blockRef, contentRef, blockData?.meta?.view, viewModel),
[nodeModel.blockId, blockData?.meta?.view, viewModel]
() => getViewElem(nodeModel.blockId, blockRef, contentRef, blockView, viewModel),
[nodeModel.blockId, blockView, viewModel]
);
const noPadding = useAtomValueSafe(viewModel.noPadding);
if (!blockData) {
if (blockIsNull) {
return null;
}
return (
Expand All @@ -149,18 +140,19 @@ const BlockSubBlock = memo(({ nodeModel, viewModel }: FullSubBlockProps) => {

const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => {
counterInc("render-BlockFull");
const waveEnv = useWaveEnv<BlockEnv>();
const focusElemRef = useRef<HTMLInputElement>(null);
const blockRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const [blockClicked, setBlockClicked] = useState(false);
const [blockData] = useWaveObjectValue<Block>(makeORef("block", nodeModel.blockId));
const blockView = useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "view")) ?? "";
const isFocused = useAtomValue(nodeModel.isFocused);
const disablePointerEvents = useAtomValue(nodeModel.disablePointerEvents);
const isResizing = useAtomValue(nodeModel.isResizing);
const isMagnified = useAtomValue(nodeModel.isMagnified);
const anyMagnified = useAtomValue(nodeModel.anyMagnified);
const modalOpen = useAtomValue(atoms.modalOpen);
const focusFollowsCursorMode = useAtomValue(getSettingsKeyAtom("app:focusfollowscursor")) ?? "off";
const modalOpen = useAtomValue(waveEnv.atoms.modalOpen);
const focusFollowsCursorMode = useAtomValue(waveEnv.getSettingsKeyAtom("app:focusfollowscursor")) ?? "off";
const innerRect = useDebouncedNodeInnerRect(nodeModel);
const noPadding = useAtomValueSafe(viewModel.noPadding);

Expand Down Expand Up @@ -213,8 +205,8 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => {
}, [innerRect, disablePointerEvents, blockContentOffset]);

const viewElem = useMemo(
() => getViewElem(nodeModel.blockId, blockRef, contentRef, blockData?.meta?.view, viewModel),
[nodeModel.blockId, blockData?.meta?.view, viewModel]
() => getViewElem(nodeModel.blockId, blockRef, contentRef, blockView, viewModel),
[nodeModel.blockId, blockView, viewModel]
);

const handleChildFocus = useCallback(
Expand All @@ -240,7 +232,7 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => {
(event: React.PointerEvent<HTMLDivElement>) => {
const focusFollowsCursorEnabled =
focusFollowsCursorMode === "on" ||
(focusFollowsCursorMode === "term" && blockData?.meta?.view === "term");
(focusFollowsCursorMode === "term" && blockView === "term");
if (!focusFollowsCursorEnabled || event.pointerType === "touch" || event.buttons > 0) {
return;
}
Expand All @@ -257,7 +249,7 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => {
},
[
focusFollowsCursorMode,
blockData?.meta?.view,
blockView,
modalOpen,
disablePointerEvents,
isResizing,
Expand Down Expand Up @@ -311,16 +303,16 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => {
);
});

const Block = memo((props: BlockProps) => {
const BlockInner = memo((props: BlockProps & { viewType: string }) => {
counterInc("render-Block");
counterInc("render-Block-" + props.nodeModel?.blockId?.substring(0, 8));
const tabModel = useTabModel();
const waveEnv = useWaveEnv();
const [blockData, loading] = useWaveObjectValue<Block>(makeORef("block", props.nodeModel.blockId));
const bcm = getBlockComponentModel(props.nodeModel.blockId);
let viewModel = bcm?.viewModel;
if (viewModel == null || viewModel.viewType != blockData?.meta?.view) {
viewModel = makeViewModel(props.nodeModel.blockId, blockData?.meta?.view, props.nodeModel, tabModel, waveEnv);
if (viewModel == null) {
// viewModel gets the full waveEnv
viewModel = makeViewModel(props.nodeModel.blockId, props.viewType, props.nodeModel, tabModel, waveEnv);
registerBlockComponentModel(props.nodeModel.blockId, { viewModel });
}
useEffect(() => {
Expand All @@ -329,25 +321,33 @@ const Block = memo((props: BlockProps) => {
viewModel?.dispose?.();
};
}, []);
if (loading || isBlank(props.nodeModel.blockId) || blockData == null) {
return null;
}
if (props.preview) {
return <BlockPreview {...props} viewModel={viewModel} />;
}
return <BlockFull {...props} viewModel={viewModel} />;
});
BlockInner.displayName = "BlockInner";

const SubBlock = memo((props: SubBlockProps) => {
const Block = memo((props: BlockProps) => {
const waveEnv = useWaveEnv<BlockEnv>();
const isNull = useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef("block", props.nodeModel.blockId)));
const viewType = useAtomValue(waveEnv.getBlockMetaKeyAtom(props.nodeModel.blockId, "view")) ?? "";
if (isNull || isBlank(props.nodeModel.blockId)) {
return null;
}
return <BlockInner key={props.nodeModel.blockId + ":" + viewType} {...props} viewType={viewType} />;
});

const SubBlockInner = memo((props: SubBlockProps & { viewType: string }) => {
counterInc("render-Block");
counterInc("render-Block-" + props.nodeModel?.blockId?.substring(0, 8));
counterInc("render-Block-" + props.nodeModel.blockId?.substring(0, 8));
const tabModel = useTabModel();
const waveEnv = useWaveEnv();
const [blockData, loading] = useWaveObjectValue<Block>(makeORef("block", props.nodeModel.blockId));
const bcm = getBlockComponentModel(props.nodeModel.blockId);
let viewModel = bcm?.viewModel;
if (viewModel == null || viewModel.viewType != blockData?.meta?.view) {
viewModel = makeViewModel(props.nodeModel.blockId, blockData?.meta?.view, props.nodeModel, tabModel, waveEnv);
if (viewModel == null) {
// viewModel gets the full waveEnv
viewModel = makeViewModel(props.nodeModel.blockId, props.viewType, props.nodeModel, tabModel, waveEnv);
registerBlockComponentModel(props.nodeModel.blockId, { viewModel });
}
useEffect(() => {
Expand All @@ -356,10 +356,18 @@ const SubBlock = memo((props: SubBlockProps) => {
viewModel?.dispose?.();
};
}, []);
if (loading || isBlank(props.nodeModel.blockId) || blockData == null) {
return <BlockSubBlock {...props} viewModel={viewModel} />;
});
SubBlockInner.displayName = "SubBlockInner";

const SubBlock = memo((props: SubBlockProps) => {
const waveEnv = useWaveEnv<BlockEnv>();
const isNull = useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef("block", props.nodeModel.blockId)));
const viewType = useAtomValue(waveEnv.getBlockMetaKeyAtom(props.nodeModel.blockId, "view")) ?? "";
if (isNull || isBlank(props.nodeModel.blockId)) {
return null;
}
return <BlockSubBlock {...props} viewModel={viewModel} />;
return <SubBlockInner key={props.nodeModel.blockId + ":" + viewType} {...props} viewType={viewType} />;
});

export { Block, SubBlock };
Loading
Loading