diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000000..bc74bcbeb6 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,10 @@ +linters: + disable: + - unused + - unusedfunc + - unusedparams +issues: + exclude-rules: + - linters: + - unused + text: "unused parameter" diff --git a/.roo/rules/rules.md b/.roo/rules/rules.md index 7f895aab5c..a4a693aea5 100644 --- a/.roo/rules/rules.md +++ b/.roo/rules/rules.md @@ -39,11 +39,12 @@ It has a TypeScript/React frontend and a Go backend. They talk together over `ws - NEVER use cursor-help (it looks terrible) - useAtom() and useAtomValue() are react HOOKS, so they must be called at the component level not inline in JSX - If you use React.memo(), make sure to add a displayName for the component +- In general, when writing functions, we prefer _early returns_ rather than putting the majority of a function inside of an if block. ### Styling - We use **Tailwind v4** to style. Custom stuff is defined in frontend/tailwindsetup.css -- _never_ use cursor-help (it looks terrible) +- _never_ use cursor-help, or cursor-not-allowed (it looks terrible) - We have custom CSS setup as well, so it is a hybrid system. For new code we prefer tailwind, and are working to migrate code to all use tailwind. ### Code Generation @@ -78,6 +79,7 @@ These files provide step-by-step instructions, code examples, and best practices - 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. - **Match response length to question complexity** - For simple, direct questions in Ask mode (especially those that can be answered in 1-2 sentences), provide equally brief answers. Save detailed explanations for complex topics or when explicitly requested. - **CRITICAL** - useAtomValue and useAtom are React HOOKS. They cannot be used inline in JSX code, they must appear at the top of a component in the hooks area of the react code. +- for simple functions, we prefer `if (!cond) { return }; functionality;` pattern overn `if (cond) { functionality }` because it produces less indentation and is easier to follow. ### Strict Comment Rules @@ -98,6 +100,72 @@ These files provide step-by-step instructions, code examples, and best practices - **When in doubt, leave it out**. No comment is better than a redundant comment. - **Never add comments explaining code changes** - The code should speak for itself, and version control tracks changes. The one exception to this rule is if it is a very unobvious implementation. Something that someone would typically implement in a different (wrong) way. Then the comment helps us remember WHY we changed it to a less obvious implementation. +### Jotai Model Pattern (our rules) + +- **Atoms live on the model.** +- **Simple atoms:** define as **field initializers**. +- **Atoms that depend on values/other atoms:** create in the **constructor**. +- Models **never use React hooks**; they use `globalStore.get/set`. +- It’s fine to call model methods from **event handlers** or **`useEffect`**. + +```ts +// model/MyModel.ts +import { atom, type PrimitiveAtom } from "jotai"; +import { globalStore } from "@/app/store/jotaiStore"; + +export class MyModel { + // simple atoms (field init) + statusAtom = atom<"idle" | "running" | "error">("idle"); + outputAtom = atom(""); + + // ctor-built atoms (need types) + lengthAtom!: PrimitiveAtom; // read-only derived via atom(get=>...) + thresholdedAtom!: PrimitiveAtom; + + constructor(initialThreshold = 20) { + this.lengthAtom = atom((get) => get(this.outputAtom).length); + this.thresholdedAtom = atom((get) => get(this.lengthAtom) > initialThreshold); + } + + async doWork() { + globalStore.set(this.statusAtom, "running"); + try { + for await (const chunk of this.stream()) { + globalStore.set(this.outputAtom, (prev) => prev + chunk); + } + globalStore.set(this.statusAtom, "idle"); + } catch { + globalStore.set(this.statusAtom, "error"); + } + } + + private async *stream() { + /* ... */ + } +} +``` + +```tsx +// component usage (events & effects OK) +import { useAtomValue } from "jotai"; + +function Panel({ model }: { model: MyModel }) { + const status = useAtomValue(model.statusAtom); + const isBig = useAtomValue(model.thresholdedAtom); + + const onClick = () => model.doWork(); + // useEffect(() => { model.doWork() }, [model]) + + return ( +
+ {status} • {String(isBig)} +
+ ); +} +``` + +**Remember:** atoms on the model, simple-as-fields, ctor for dependent/derived, updates via `globalStore.set/get`. + ### Tool Use Do NOT use write_to_file unless it is a new file or very short. Always prefer to use replace_in_file. Often your diffs fail when a file may be out of date in your cache vs the actual on-disk format. You should RE-READ the file and try to create diffs again if your diffs fail rather than fall back to write_to_file. If you feel like your ONLY option is to use write_to_file please ask first. diff --git a/Taskfile.yml b/Taskfile.yml index 6dd064e10d..7588bff6b5 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -26,8 +26,9 @@ tasks: - docsite:build:embedded - build:backend env: - WCLOUD_ENDPOINT: "https://api-dev.waveterm.dev/central" - WCLOUD_WS_ENDPOINT: "wss://wsapi-dev.waveterm.dev/" + WAVETERM_ENVFILE: "{{.ROOT_DIR}}/.env" + WCLOUD_ENDPOINT: "https://api-dev.waveterm.dev" + WCLOUD_WS_ENDPOINT: "wss://wsapi-dev.waveterm.dev" electron:start: desc: Run the Electron application directly. @@ -39,6 +40,7 @@ tasks: - docsite:build:embedded - build:backend env: + WAVETERM_ENVFILE: "{{.ROOT_DIR}}/.env" WCLOUD_ENDPOINT: "https://api-dev.waveterm.dev" WCLOUD_WS_ENDPOINT: "wss://wsapi-dev.waveterm.dev" @@ -49,6 +51,7 @@ tasks: - npm:install - build:backend:quickdev env: + WAVETERM_ENVFILE: "{{.ROOT_DIR}}/.env" WCLOUD_ENDPOINT: "https://api-dev.waveterm.dev/central" WCLOUD_WS_ENDPOINT: "wss://wsapi-dev.waveterm.dev/" @@ -134,6 +137,12 @@ tasks: - docsite:build:embedded - build:backend + build:frontend:dev: + desc: Build the frontend in development mode. + cmd: npm run build:dev + deps: + - npm:install + build:backend: desc: Build the wavesrv and wsh components. cmds: @@ -144,6 +153,14 @@ tasks: desc: Build only the wavesrv component for quickdev (arm64 macOS only, no generate, no wsh). cmds: - task: build:server:quickdev + sources: + - go.mod + - go.sum + - pkg/**/*.go + - cmd/**/*.go + - tsunami/go.mod + - tsunami/go.sum + - tsunami/**/*.go build:schema: desc: Build the schema for configuration. @@ -189,8 +206,6 @@ tasks: desc: Build the wavesrv component for quickdev (arm64 macOS only, no generate). platforms: [darwin] cmds: - - cmd: "{{.RM}} dist/bin/wavesrv*" - ignore_error: true - task: build:server:internal vars: ARCHS: arm64 diff --git a/aiprompts/aisdk-streaming.md b/aiprompts/aisdk-streaming.md index 57ab550ba1..ad53103aab 100644 --- a/aiprompts/aisdk-streaming.md +++ b/aiprompts/aisdk-streaming.md @@ -1,185 +1,288 @@ -Data Stream Protocol +## Data Stream Protocol + A data stream follows a special protocol that the AI SDK provides to send information to the frontend. The data stream protocol uses Server-Sent Events (SSE) format for improved standardization, keep-alive through ping, reconnect capabilities, and better cache handling. -When you provide data streams from a custom backend, you need to set the x-vercel-ai-ui-message-stream header to v1. + + When you provide data streams from a custom backend, you need to set the + `x-vercel-ai-ui-message-stream` header to `v1`. + The following stream parts are currently supported: -Message Start Part +### Message Start Part + Indicates the beginning of a new message with metadata. Format: Server-Sent Event with JSON object Example: +``` data: {"type":"start","messageId":"..."} -Text Parts + +``` + +### Text Parts + Text content is streamed using a start/delta/end pattern with unique IDs for each text block. -Text Start Part +#### Text Start Part + Indicates the beginning of a text block. Format: Server-Sent Event with JSON object Example: +``` data: {"type":"text-start","id":"msg_68679a454370819ca74c8eb3d04379630dd1afb72306ca5d"} -Text Delta Part + +``` + +#### Text Delta Part + Contains incremental text content for the text block. Format: Server-Sent Event with JSON object Example: +``` data: {"type":"text-delta","id":"msg_68679a454370819ca74c8eb3d04379630dd1afb72306ca5d","delta":"Hello"} -Text End Part + +``` + +#### Text End Part + Indicates the completion of a text block. Format: Server-Sent Event with JSON object Example: +``` data: {"type":"text-end","id":"msg_68679a454370819ca74c8eb3d04379630dd1afb72306ca5d"} -Reasoning Parts + +``` + +### Reasoning Parts + Reasoning content is streamed using a start/delta/end pattern with unique IDs for each reasoning block. -Reasoning Start Part +#### Reasoning Start Part + Indicates the beginning of a reasoning block. Format: Server-Sent Event with JSON object Example: +``` data: {"type":"reasoning-start","id":"reasoning_123"} -Reasoning Delta Part + +``` + +#### Reasoning Delta Part + Contains incremental reasoning content for the reasoning block. Format: Server-Sent Event with JSON object Example: +``` data: {"type":"reasoning-delta","id":"reasoning_123","delta":"This is some reasoning"} -Reasoning End Part + +``` + +#### Reasoning End Part + Indicates the completion of a reasoning block. Format: Server-Sent Event with JSON object Example: +``` data: {"type":"reasoning-end","id":"reasoning_123"} -Source Parts + +``` + +### Source Parts + Source parts provide references to external content sources. -Source URL Part +#### Source URL Part + References to external URLs. Format: Server-Sent Event with JSON object Example: +``` data: {"type":"source-url","sourceId":"https://example.com","url":"https://example.com"} -Source Document Part + +``` + +#### Source Document Part + References to documents or files. Format: Server-Sent Event with JSON object Example: +``` data: {"type":"source-document","sourceId":"https://example.com","mediaType":"file","title":"Title"} -File Part + +``` + +### File Part + The file parts contain references to files with their media type. Format: Server-Sent Event with JSON object Example: +``` data: {"type":"file","url":"https://example.com/file.png","mediaType":"image/png"} -Data Parts + +``` + +### Data Parts + Custom data parts allow streaming of arbitrary structured data with type-specific handling. Format: Server-Sent Event with JSON object where the type includes a custom suffix Example: +``` data: {"type":"data-weather","data":{"location":"SF","temperature":100}} -The data-\* type pattern allows you to define custom data types that your frontend can handle specifically. -Error Part +``` + +The `data-*` type pattern allows you to define custom data types that your frontend can handle specifically. + +### Error Part + The error parts are appended to the message as they are received. Format: Server-Sent Event with JSON object Example: +``` data: {"type":"error","errorText":"error message"} -Tool Input Start Part + +``` + +### Tool Input Start Part + Indicates the beginning of tool input streaming. Format: Server-Sent Event with JSON object Example: +``` data: {"type":"tool-input-start","toolCallId":"call_fJdQDqnXeGxTmr4E3YPSR7Ar","toolName":"getWeatherInformation"} -Tool Input Delta Part + +``` + +### Tool Input Delta Part + Incremental chunks of tool input as it's being generated. Format: Server-Sent Event with JSON object Example: +``` data: {"type":"tool-input-delta","toolCallId":"call_fJdQDqnXeGxTmr4E3YPSR7Ar","inputTextDelta":"San Francisco"} -Tool Input Available Part + +``` + +### Tool Input Available Part + Indicates that tool input is complete and ready for execution. Format: Server-Sent Event with JSON object Example: +``` data: {"type":"tool-input-available","toolCallId":"call_fJdQDqnXeGxTmr4E3YPSR7Ar","toolName":"getWeatherInformation","input":{"city":"San Francisco"}} -Tool Output Available Part + +``` + +### Tool Output Available Part + Contains the result of tool execution. Format: Server-Sent Event with JSON object Example: +``` data: {"type":"tool-output-available","toolCallId":"call_fJdQDqnXeGxTmr4E3YPSR7Ar","output":{"city":"San Francisco","weather":"sunny"}} -Start Step Part + +``` + +### Start Step Part + A part indicating the start of a step. Format: Server-Sent Event with JSON object Example: +``` data: {"type":"start-step"} -Finish Step Part + +``` + +### Finish Step Part + A part indicating that a step (i.e., one LLM API call in the backend) has been completed. -This part is necessary to correctly process multiple stitched assistant calls, e.g. when calling tools in the backend, and using steps in useChat at the same time. +This part is necessary to correctly process multiple stitched assistant calls, e.g. when calling tools in the backend, and using steps in `useChat` at the same time. Format: Server-Sent Event with JSON object Example: +``` data: {"type":"finish-step"} -Finish Message Part + +``` + +### Finish Message Part + A part indicating the completion of a message. Format: Server-Sent Event with JSON object Example: +``` data: {"type":"finish"} -Stream Termination -The stream ends with a special [DONE] marker. -Format: Server-Sent Event with literal [DONE] +``` + +### Stream Termination + +The stream ends with a special `[DONE]` marker. + +Format: Server-Sent Event with literal `[DONE]` Example: +``` data: [DONE] -The data stream protocol is supported by useChat and useCompletion on the frontend and used by default. useCompletion only supports the text and data stream parts. -On the backend, you can use toUIMessageStreamResponse() from the streamText result object to return a streaming HTTP response. +``` diff --git a/aiprompts/aisdk-uimessage-type.md b/aiprompts/aisdk-uimessage-type.md new file mode 100644 index 0000000000..9cabf1e460 --- /dev/null +++ b/aiprompts/aisdk-uimessage-type.md @@ -0,0 +1,237 @@ +# `UIMessage` + +`UIMessage` serves as the source of truth for your application's state, representing the complete message history including metadata, data parts, and all contextual information. In contrast to `ModelMessage`, which represents the state or context passed to the model, `UIMessage` contains the full application state needed for UI rendering and client-side functionality. + +## Type Safety + +`UIMessage` is designed to be type-safe and accepts three generic parameters to ensure proper typing throughout your application: + +1. **`METADATA`** - Custom metadata type for additional message information +2. **`DATA_PARTS`** - Custom data part types for structured data components +3. **`TOOLS`** - Tool definitions for type-safe tool interactions + +## Creating Your Own UIMessage Type + +Here's an example of how to create a custom typed UIMessage for your application: + +```typescript +import { InferUITools, ToolSet, UIMessage, tool } from "ai"; +import z from "zod"; + +const metadataSchema = z.object({ + someMetadata: z.string().datetime(), +}); + +type MyMetadata = z.infer; + +const dataPartSchema = z.object({ + someDataPart: z.object({}), + anotherDataPart: z.object({}), +}); + +type MyDataPart = z.infer; + +const tools = { + someTool: tool({}), +} satisfies ToolSet; + +type MyTools = InferUITools; + +export type MyUIMessage = UIMessage; +``` + +## `UIMessage` Interface + +```typescript +interface UIMessage { + /** + * A unique identifier for the message. + */ + id: string; + + /** + * The role of the message. + */ + role: "system" | "user" | "assistant"; + + /** + * The metadata of the message. + */ + metadata?: METADATA; + + /** + * The parts of the message. Use this for rendering the message in the UI. + */ + parts: Array>; +} +``` + +## `UIMessagePart` Types + +### `TextUIPart` + +A text part of a message. + +```typescript +type TextUIPart = { + type: "text"; + /** + * The text content. + */ + text: string; + /** + * The state of the text part. + */ + state?: "streaming" | "done"; +}; +``` + +### `ReasoningUIPart` + +A reasoning part of a message. + +```typescript +type ReasoningUIPart = { + type: "reasoning"; + /** + * The reasoning text. + */ + text: string; + /** + * The state of the reasoning part. + */ + state?: "streaming" | "done"; + /** + * The provider metadata. + */ + providerMetadata?: Record; +}; +``` + +### `ToolUIPart` + +A tool part of a message that represents tool invocations and their results. + + + The type is based on the name of the tool (e.g., `tool-someTool` for a tool + named `someTool`). + + +```typescript +type ToolUIPart = ValueOf<{ + [NAME in keyof TOOLS & string]: { + type: `tool-${NAME}`; + toolCallId: string; + } & ( + | { + state: "input-streaming"; + input: DeepPartial | undefined; + providerExecuted?: boolean; + output?: never; + errorText?: never; + } + | { + state: "input-available"; + input: TOOLS[NAME]["input"]; + providerExecuted?: boolean; + output?: never; + errorText?: never; + } + | { + state: "output-available"; + input: TOOLS[NAME]["input"]; + output: TOOLS[NAME]["output"]; + errorText?: never; + providerExecuted?: boolean; + } + | { + state: "output-error"; + input: TOOLS[NAME]["input"]; + output?: never; + errorText: string; + providerExecuted?: boolean; + } + ); +}>; +``` + +### `SourceUrlUIPart` + +A source URL part of a message. + +```typescript +type SourceUrlUIPart = { + type: "source-url"; + sourceId: string; + url: string; + title?: string; + providerMetadata?: Record; +}; +``` + +### `SourceDocumentUIPart` + +A document source part of a message. + +```typescript +type SourceDocumentUIPart = { + type: "source-document"; + sourceId: string; + mediaType: string; + title: string; + filename?: string; + providerMetadata?: Record; +}; +``` + +### `FileUIPart` + +A file part of a message. + +```typescript +type FileUIPart = { + type: "file"; + /** + * IANA media type of the file. + */ + mediaType: string; + /** + * Optional filename of the file. + */ + filename?: string; + /** + * The URL of the file. + * It can either be a URL to a hosted file or a Data URL. + */ + url: string; +}; +``` + +### `DataUIPart` + +A data part of a message for custom data types. + + + The type is based on the name of the data part (e.g., `data-someDataPart` for + a data part named `someDataPart`). + + +```typescript +type DataUIPart = ValueOf<{ + [NAME in keyof DATA_TYPES & string]: { + type: `data-${NAME}`; + id?: string; + data: DATA_TYPES[NAME]; + }; +}>; +``` + +### `StepStartUIPart` + +A step boundary part of a message. + +```typescript +type StepStartUIPart = { + type: "step-start"; +}; +``` diff --git a/aiprompts/focus-layout.md b/aiprompts/focus-layout.md new file mode 100644 index 0000000000..7056b5ad3e --- /dev/null +++ b/aiprompts/focus-layout.md @@ -0,0 +1,174 @@ +# Wave Terminal Focus System - Layout State Flow + +This document explains how focus state changes in the layout system propagate through the application to update both the visual focus ring and physical DOM focus. + +## Overview + +When layout operations modify focus state, a straightforward chain of updates occurs: +1. **Visual feedback** - The focus ring updates immediately +2. **Physical DOM focus** - The terminal (or other view) receives actual browser focus + +The system uses local atoms as the source of truth with async persistence to the backend. + +## The Flow + +### 1. Setting Focus in Layout Operations + +Throughout [`layoutTree.ts`](../frontend/layout/lib/layoutTree.ts), operations directly mutate `layoutState.focusedNodeId`: + +```typescript +// Example from insertNode +if (action.magnified) { + layoutState.magnifiedNodeId = action.node.id; + layoutState.focusedNodeId = action.node.id; +} +if (action.focused) { + layoutState.focusedNodeId = action.node.id; +} +``` + +This happens in ~10 places: insertNode, insertNodeAtIndex, deleteNode, focusNode, magnifyNodeToggle, etc. + +### 2. Committing to Local Atom + +The [`LayoutModel.treeReducer()`](../frontend/layout/lib/layoutModel.ts:547) commits changes: + +```typescript +treeReducer(action: LayoutTreeAction, setState = true): boolean { + // Mutate tree state + focusNode(this.treeState, action); + + if (setState) { + this.updateTree(); // Compute leafOrder, etc. + this.setter(this.localTreeStateAtom, { ...this.treeState }); // Sync update + this.persistToBackend(); // Async persistence + } +} +``` + +The key is `{ ...this.treeState }` creates a new object reference, triggering Jotai reactivity. + +### 3. Derived Atoms Recalculate + +Each block's `NodeModel` has an `isFocused` atom: + +```typescript +isFocused: atom((get) => { + const treeState = get(this.localTreeStateAtom); + const isFocused = treeState.focusedNodeId === nodeid; + const waveAIFocused = get(atoms.waveAIFocusedAtom); + return isFocused && !waveAIFocused; +}) +``` + +When `localTreeStateAtom` updates, all `isFocused` atoms recalculate. Only the matching node returns `true`. + +### 4. React Components Re-render + +**Visual Focus Ring** - Components subscribe to `isFocused`: + +```typescript +const isFocused = useAtomValue(nodeModel.isFocused); +``` + +CSS classes update immediately, showing the focus ring. + +**Physical DOM Focus** - Two-step effect chain: + +```typescript +// Step 1: isFocused → blockClicked +useLayoutEffect(() => { + setBlockClicked(isFocused); +}, [isFocused]); + +// Step 2: blockClicked → physical focus +useLayoutEffect(() => { + if (!blockClicked) return; + setBlockClicked(false); + const focusWithin = focusedBlockId() == nodeModel.blockId; + if (!focusWithin) { + setFocusTarget(); // Calls viewModel.giveFocus() + } +}, [blockClicked, isFocused]); +``` + +The terminal's `giveFocus()` method grants actual browser focus: + +```typescript +giveFocus(): boolean { + if (termMode == "term" && this.termRef?.current?.terminal) { + this.termRef.current.terminal.focus(); + return true; + } + return false; +} +``` + +### 5. Background Persistence + +While the UI updates synchronously, persistence happens asynchronously: + +```typescript +private persistToBackend() { + // Debounced (100ms) to avoid excessive writes + setTimeout(() => { + waveObj.rootnode = this.treeState.rootNode; + waveObj.focusednodeid = this.treeState.focusedNodeId; + waveObj.magnifiednodeid = this.treeState.magnifiedNodeId; + waveObj.leaforder = this.treeState.leafOrder; + this.setter(this.waveObjectAtom, waveObj); + }, 100); +} +``` + +The WaveObject is used purely for persistence (tab restore, uncaching). + +## The Complete Chain + +``` +User action + ↓ +layoutState.focusedNodeId = nodeId + ↓ +setter(localTreeStateAtom, { ...treeState }) + ↓ +isFocused atoms recalculate + ↓ +React re-renders + ↓ +┌────────────────────┬────────────────────┐ +│ Visual Ring │ Physical Focus │ +│ (immediate CSS) │ (2-step effect) │ +└────────────────────┴────────────────────┘ + ↓ +persistToBackend() (async, debounced) +``` + +## Key Points + +1. **Local atoms** - `localTreeStateAtom` is the source of truth during runtime +2. **Synchronous updates** - UI changes happen immediately in one React tick +3. **Async persistence** - Backend writes are fire-and-forget with debouncing +4. **Two-step focus** - Separates visual (instant) from physical (coordinated) DOM focus +5. **View delegation** - Each view implements `giveFocus()` for custom focus behavior + +## User-Initiated Focus + +When a user clicks a block: + +1. **`onFocusCapture`** (mousedown) → calls `nodeModel.focusNode()` → visual focus ring appears +2. **`onClick`** → sets `blockClicked = true` → two-step effect chain → physical DOM focus + +This ensures visual feedback is instant while protecting selections. + +## Backend Actions + +On initialization or backend updates, queued actions are processed: + +```typescript +if (initialState.pendingBackendActions?.length) { + fireAndForget(() => this.processPendingBackendActions()); +} +``` + +Backend can queue layout operations (create blocks, etc.) via `PendingBackendActions`. \ No newline at end of file diff --git a/aiprompts/focus.md b/aiprompts/focus.md new file mode 100644 index 0000000000..e07f674da6 --- /dev/null +++ b/aiprompts/focus.md @@ -0,0 +1,236 @@ +# Wave Terminal Focus System + +This document explains how the focus system works in Wave Terminal, particularly for terminal blocks. + +## Overview + +Wave Terminal uses a multi-layered focus system that coordinates between: +- **Layout Focus State**: Jotai atoms tracking which block is focused (`nodeModel.isFocused`) +- **Visual Focus Ring**: CSS styling showing the focused block +- **DOM Focus**: Actual browser focus on interactive elements +- **View-Specific Focus**: Custom focus handling by view models (e.g., XTerm terminal focus) + +## Focus Flow on Block Click + +When you click on a terminal block, this sequence occurs: + +### 1. Click Handler Setup +[`frontend/app/block/block.tsx:219-223`](frontend/app/block/block.tsx:219-223) + +```typescript +const blockModel: BlockComponentModel2 = { + onClick: setBlockClickedTrue, + onFocusCapture: handleChildFocus, + blockRef: blockRef, +}; +``` + +### 2. Click Triggers State Change +[`frontend/app/block/block.tsx:165-167`](frontend/app/block/block.tsx:165-167) + +When clicked, `setBlockClickedTrue` sets the `blockClicked` state to true. + +### 3. useLayoutEffect Responds +[`frontend/app/block/block.tsx:151-163`](frontend/app/block/block.tsx:151-163) + +```typescript +useLayoutEffect(() => { + if (!blockClicked) { + return; + } + setBlockClicked(false); + const focusWithin = focusedBlockId() == nodeModel.blockId; + if (!focusWithin) { + setFocusTarget(); + } + if (!isFocused) { + nodeModel.focusNode(); + } +}, [blockClicked, isFocused]); +``` + +### 4. Focus Target Decision +[`frontend/app/block/block.tsx:211-217`](frontend/app/block/block.tsx:211-217) + +```typescript +const setFocusTarget = useCallback(() => { + const ok = viewModel?.giveFocus?.(); + if (ok) { + return; + } + focusElemRef.current?.focus({ preventScroll: true }); +}, []); +``` + +The `setFocusTarget` function: +1. First attempts to call the view model's `giveFocus()` method +2. If that succeeds (returns true), we're done +3. Otherwise, falls back to focusing a dummy input element + +### 5. Terminal-Specific Focus +[`frontend/app/view/term/term.tsx:414-427`](frontend/app/view/term/term.tsx:414-427) + +```typescript +giveFocus(): boolean { + if (this.searchAtoms && globalStore.get(this.searchAtoms.isOpen)) { + return true; + } + let termMode = globalStore.get(this.termMode); + if (termMode == "term") { + if (this.termRef?.current?.terminal) { + this.termRef.current.terminal.focus(); + return true; + } + } + return false; +} +``` + +The terminal's `giveFocus()` calls XTerm's `terminal.focus()` to grant actual DOM focus. + +## Selection Protection + +A critical feature is that text selections are preserved when clicking within the same block. + +### The Protection Mechanism +[`frontend/app/block/block.tsx:156-158`](frontend/app/block/block.tsx:156-158) + +```typescript +const focusWithin = focusedBlockId() == nodeModel.blockId; +if (!focusWithin) { + setFocusTarget(); +} +``` + +The key is [`focusedBlockId()`](frontend/util/focusutil.ts:48-70) which checks: + +1. **Active Element**: Is there a focused DOM element within this block? +2. **Selection**: Is there a text selection within this block? + +```typescript +export function focusedBlockId(): string { + const focused = document.activeElement; + if (focused instanceof HTMLElement) { + const blockId = findBlockId(focused); + if (blockId) { + return blockId; + } + } + const sel = document.getSelection(); + if (sel && sel.anchorNode && sel.rangeCount > 0 && !sel.isCollapsed) { + let anchor = sel.anchorNode; + if (anchor instanceof Text) { + anchor = anchor.parentElement; + } + if (anchor instanceof HTMLElement) { + const blockId = findBlockId(anchor); + if (blockId) { + return blockId; + } + } + } + return null; +} +``` + +**When making a text selection within a block:** +- `focusWithin` returns true (selection exists in the block) +- `setFocusTarget()` is **skipped** +- Selection is preserved +- Only `nodeModel.focusNode()` is called to update layout state + +## Visual Focus vs DOM Focus + +There's an important separation between visual focus (the focus ring) and actual DOM focus. + +### Visual Focus (Immediate) +[`frontend/app/block/block.tsx:200-209`](frontend/app/block/block.tsx:200-209) + +```typescript +const handleChildFocus = useCallback( + (event: React.FocusEvent) => { + if (!isFocused) { + nodeModel.focusNode(); // Updates layout state immediately + } + }, + [isFocused] +); +``` + +This `onFocusCapture` handler fires on **mousedown** (capture phase), immediately updating the visual focus ring. + +### DOM Focus (On Click Complete) + +The actual DOM focus via `giveFocus()` only happens after click completion, through the onClick → useLayoutEffect path. + +### Selection Example: Two Terminals + +When making a selection in terminal 2 while terminal 1 is focused: + +1. **Mousedown** → `onFocusCapture` fires → `nodeModel.focusNode()` updates focus ring + - Terminal 2 now shows the focus ring + - Layout state updated +2. **Drag** → Selection is made in terminal 2 +3. **Mouseup** → Selection completes +4. **Click handler** → `onClick` fires → `setBlockClickedTrue` → triggers useLayoutEffect +5. **useLayoutEffect** → Checks `focusWithin` (now true because selection exists) +6. **Protected** → Skips `setFocusTarget()`, preserving the selection + +**Result:** Focus ring updates immediately, but DOM focus is only granted after the selection is made, and is protected by the `focusWithin` check. + +## Terminal-Specific Focus Events + +The terminal view has three useEffects that call `giveFocus()`: + +### 1. Search Close +[`frontend/app/view/term/term.tsx:970-974`](frontend/app/view/term/term.tsx:970-974) + +When the search panel closes, focus returns to the terminal. + +### 2. Terminal Recreation +[`frontend/app/view/term/term.tsx:1035-1038`](frontend/app/view/term/term.tsx:1035-1038) + +When a terminal is recreated while focused (e.g., settings change), focus is restored. + +### 3. Mode Switch +[`frontend/app/view/term/term.tsx:1046-1052`](frontend/app/view/term/term.tsx:1046-1052) + +When switching from vdom mode back to term mode, the terminal receives focus. + +## Key Components + +### Block Component +[`frontend/app/block/block.tsx`](frontend/app/block/block.tsx) +- Manages the BlockFull component +- Handles click and focus capture events +- Coordinates between layout focus and DOM focus + +### BlockNodeModel +[`frontend/app/block/blocktypes.ts:7-12`](frontend/app/block/blocktypes.ts:7-12) +```typescript +export interface BlockNodeModel { + blockId: string; + isFocused: Atom; + onClose: () => void; + focusNode: () => void; +} +``` + +### ViewModel Interface +View models can implement `giveFocus(): boolean` to handle focus in a view-specific way. + +### Focus Utilities +[`frontend/util/focusutil.ts`](frontend/util/focusutil.ts) +- `focusedBlockId()`: Determines which block has focus or selection +- `hasSelection()`: Checks if there's an active text selection +- `findBlockId()`: Traverses DOM to find containing block + +## Summary + +The focus system elegantly separates concerns: +- **Visual feedback** updates immediately on mousedown +- **DOM focus** is deferred until after user interaction completes +- **Selections are protected** by checking focus state before granting focus +- **View-specific focus** is delegated to view models via `giveFocus()` + +This design allows for responsive UI (immediate focus ring updates) while preventing disruption of user interactions like text selection. \ No newline at end of file diff --git a/aiprompts/openai-streaming-text.md b/aiprompts/openai-streaming-text.md new file mode 100644 index 0000000000..7ca9214bf9 --- /dev/null +++ b/aiprompts/openai-streaming-text.md @@ -0,0 +1,74 @@ +For **just text streaming**, you only need to handle these 3 core events: + +## Essential Events + +### 1. `response.created` + +```json +{ + "type": "response.created", + "response": { + "id": "resp_abc123", + "created_at": 1640995200, + "model": "gpt-5" + } +} +``` + +**Purpose**: Initialize response tracking (like Anthropic's `message_start`) + +### 2. `response.output_text.delta` + +```json +{ + "type": "response.output_text.delta", + "item_id": "msg_abc123", + "delta": "Hello, how can I" +} +``` + +**Purpose**: Stream text chunks (like Anthropic's `text_delta`) + +### 3. `response.completed` + +```json +{ + "type": "response.completed", + "response": { + "usage": { + "input_tokens": 100, + "output_tokens": 200 + } + } +} +``` + +**Purpose**: Finalize response (like Anthropic's `message_stop`) + +## Optional but Recommended + +### 4. `error` + +```json +{ + "type": "error", + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" +} +``` + +**Purpose**: Handle errors gracefully + +--- + +That's it for basic text streaming! You can ignore all the `response.output_item.added/done`, tool calling, reasoning, and annotation events if you just want simple text responses. + +Your Go implementation would be: + +1. Parse SSE stream +2. Switch on `event.type` +3. Handle these 4 event types +4. Accumulate text from `delta` fields +5. Emit to your existing SSE handler + +Much simpler than the full implementation. diff --git a/aiprompts/openai-streaming.md b/aiprompts/openai-streaming.md new file mode 100644 index 0000000000..fddff13086 --- /dev/null +++ b/aiprompts/openai-streaming.md @@ -0,0 +1,357 @@ +# OpenAI Responses API SSE Events Documentation + +This document outlines the Server-Sent Events (SSE) format used by OpenAI's Responses API for streaming chat completions, based on the Vercel AI SDK implementation. + +## Core Event Types + +### Response Lifecycle Events + +#### `response.created` + +Emitted when a new response begins. + +```json +{ + "type": "response.created", + "response": { + "id": "resp_abc123", + "created_at": 1640995200, + "model": "gpt-5", + "service_tier": "default" + } +} +``` + +#### `response.completed` + +Emitted when the response completes successfully. + +```json +{ + "type": "response.completed", + "response": { + "incomplete_details": null, + "usage": { + "input_tokens": 100, + "input_tokens_details": { + "cached_tokens": 50 + }, + "output_tokens": 200, + "output_tokens_details": { + "reasoning_tokens": 150 + } + }, + "service_tier": "default" + } +} +``` + +#### `response.incomplete` + +Emitted when the response is incomplete (e.g., due to length limits). + +```json +{ + "type": "response.incomplete", + "response": { + "incomplete_details": { + "reason": "max_tokens" + }, + "usage": { + "input_tokens": 100, + "output_tokens": 4000 + } + } +} +``` + +### Content Block Events + +#### `response.output_item.added` + +Emitted when a new output item (content block) is added. + +```json +{ + "type": "response.output_item.added", + "output_index": 0, + "item": { + "type": "message", + "id": "msg_abc123" + } +} +``` + +Item types can be: + +- `message` - Text content +- `reasoning` - Reasoning/thinking content +- `function_call` - Tool call +- `web_search_call` - Web search tool call +- `computer_call` - Computer use tool call +- `file_search_call` - File search tool call +- `image_generation_call` - Image generation tool call +- `code_interpreter_call` - Code interpreter tool call + +#### `response.output_item.done` + +Emitted when an output item is completed. + +```json +{ + "type": "response.output_item.done", + "output_index": 0, + "item": { + "type": "message", + "id": "msg_abc123" + } +} +``` + +For function calls, includes the complete arguments: + +```json +{ + "type": "response.output_item.done", + "output_index": 1, + "item": { + "type": "function_call", + "id": "call_abc123", + "call_id": "call_abc123", + "name": "get_weather", + "arguments": "{\"location\": \"San Francisco\"}", + "status": "completed" + } +} +``` + +### Text Streaming Events + +#### `response.output_text.delta` + +Emitted for incremental text content. + +```json +{ + "type": "response.output_text.delta", + "item_id": "msg_abc123", + "delta": "Hello, how can I", + "logprobs": [ + { + "token": "Hello", + "logprob": -0.1, + "top_logprobs": [ + { + "token": "Hello", + "logprob": -0.1 + }, + { + "token": "Hi", + "logprob": -2.3 + } + ] + } + ] +} +``` + +### Tool Call Events + +#### `response.function_call_arguments.delta` + +Emitted for streaming function call arguments. + +```json +{ + "type": "response.function_call_arguments.delta", + "item_id": "call_abc123", + "output_index": 1, + "delta": "\"location\": \"San" +} +``` + +### Reasoning Events + +#### `response.reasoning_summary_part.added` + +Emitted when a new reasoning summary part is added. + +```json +{ + "type": "response.reasoning_summary_part.added", + "item_id": "reasoning_abc123", + "summary_index": 0 +} +``` + +#### `response.reasoning_summary_text.delta` + +Emitted for incremental reasoning text. + +```json +{ + "type": "response.reasoning_summary_text.delta", + "item_id": "reasoning_abc123", + "summary_index": 0, + "delta": "Let me think about this step by step..." +} +``` + +### Annotation Events + +#### `response.output_text.annotation.added` + +Emitted when citations or annotations are added to text. + +```json +{ + "type": "response.output_text.annotation.added", + "annotation": { + "type": "url_citation", + "url": "https://example.com/article", + "title": "Example Article" + } +} +``` + +Or for file citations: + +```json +{ + "type": "response.output_text.annotation.added", + "annotation": { + "type": "file_citation", + "file_id": "file_abc123", + "filename": "document.pdf", + "quote": "This is the relevant quote", + "start_index": 100, + "end_index": 150 + } +} +``` + +### Error Events + +#### `error` + +Emitted when an error occurs. + +```json +{ + "type": "error", + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded. Please try again later.", + "param": null, + "sequence_number": 5 +} +``` + +## Built-in Tool Call Schemas + +### Web Search Call + +```json +{ + "type": "web_search_call", + "id": "search_abc123", + "status": "completed", + "action": { + "type": "search", + "query": "OpenAI API documentation" + } +} +``` + +### File Search Call + +```json +{ + "type": "file_search_call", + "id": "search_abc123", + "queries": ["OpenAI pricing", "API limits"], + "results": [ + { + "attributes": {}, + "file_id": "file_abc123", + "filename": "pricing.pdf", + "score": 0.85, + "text": "OpenAI API pricing starts at..." + } + ] +} +``` + +### Code Interpreter Call + +```json +{ + "type": "code_interpreter_call", + "id": "code_abc123", + "code": "print('Hello, world!')", + "container_id": "container_123", + "outputs": [ + { + "type": "logs", + "logs": "Hello, world!\n" + } + ] +} +``` + +### Image Generation Call + +```json +{ + "type": "image_generation_call", + "id": "img_abc123", + "result": "https://example.com/generated-image.png" +} +``` + +### Computer Use Call + +```json +{ + "type": "computer_call", + "id": "computer_abc123", + "status": "completed" +} +``` + +## Event Processing Flow + +1. **Response Start**: `response.created` → Initialize response tracking +2. **Content Blocks**: `response.output_item.added` → Start tracking content block +3. **Streaming Content**: + - `response.output_text.delta` → Accumulate text + - `response.function_call_arguments.delta` → Accumulate tool arguments + - `response.reasoning_summary_text.delta` → Accumulate reasoning +4. **Content Complete**: `response.output_item.done` → Finalize content block +5. **Response End**: `response.completed`/`response.incomplete` → Finalize response + +## Key Differences from Anthropic + +| Aspect | OpenAI Responses API | Anthropic Messages API | +| -------------- | ---------------------------------------- | ------------------------------------------------ | +| Text streaming | `response.output_text.delta` | `content_block_delta` (type: `text_delta`) | +| Tool arguments | `response.function_call_arguments.delta` | `content_block_delta` (type: `input_json_delta`) | +| Reasoning | `response.reasoning_summary_text.delta` | `content_block_delta` (type: `thinking_delta`) | +| Block tracking | `output_index` | `index` | +| Response start | `response.created` | `message_start` | +| Response end | `response.completed` | `message_stop` | + +## Error Handling + +- Parse each SSE event with proper JSON validation +- Handle unknown event types gracefully (forward as-is or ignore) +- Track `sequence_number` for error events to maintain order +- Use `output_index` to correlate events with specific content blocks +- Handle partial JSON in tool argument deltas (accumulate until complete) + +## Implementation Notes + +- Events may arrive out of order; use `output_index` and `item_id` for correlation +- Multiple reasoning summary parts can exist; track by `summary_index` +- Tool calls can be provider-executed (built-in tools) or require client execution +- Logprobs are optional and only included when requested +- Usage tokens are only available in completion events diff --git a/aiprompts/tailwind-container-queries.md b/aiprompts/tailwind-container-queries.md new file mode 100644 index 0000000000..007cc080cf --- /dev/null +++ b/aiprompts/tailwind-container-queries.md @@ -0,0 +1,38 @@ +### Tailwind v4 Container Queries (Quick Overview) + +- **Viewport breakpoints**: `sm:`, `md:`, `lg:`, etc. → respond to **screen size**. +- **Container queries**: `@sm:`, `@md:`, etc. → respond to **parent element size**. + +#### Enable + +No plugin needed in **v4** (built-in). +In v3: install `@tailwindcss/container-queries`. + +#### Usage + +```html + +``` + +- `@container` marks the parent. +- `@sm:` / `@md:` refer to **container width**, not viewport. + +#### Notes + +- Based on native CSS container queries (well supported in modern browsers). +- Breakpoints for container queries reuse Tailwind’s `sm`, `md`, `lg`, etc. scales. +- Safe for modern webapps; no IE/legacy support. + +we have special breakpoints set up for panels: + + --container-xs: 300px; + --container-xxs: 200px; + --container-tiny: 120px; + +since often sm, md, and lg are too big for panels. + +so to use you'd do: + +@xs:ml-4 diff --git a/aiprompts/waveai-focus-updates.md b/aiprompts/waveai-focus-updates.md new file mode 100644 index 0000000000..b9550c73b0 --- /dev/null +++ b/aiprompts/waveai-focus-updates.md @@ -0,0 +1,742 @@ +# Wave Terminal Focus System - Wave AI Integration + +## Problem + +Wave AI focus handling is fragile compared to blocks: + +1. Only watches textarea focus/blur, missing the multi-phase handling that blocks have +2. Selection handling breaks - selecting text causes blur → focus reverts to layout +3. Focus ring flashing - clicking Wave AI briefly shows focus ring on layout +4. Window blur sensitivity - `window.blur()` incorrectly assumes user wants to leave Wave AI +5. No capture phase - missing the immediate visual feedback that blocks get + +## Solution Overview + +Extend the block focus system pattern to Wave AI: + +- Multi-phase handling (capture + click) +- Selection protection +- Focus manager coordination +- View delegation + +## Architecture + +```mermaid +graph TB + User[User Interaction] + FM[Focus Manager] + Layout[Layout System] + WaveAI[Wave AI Panel] + + User -->|click/key| FM + FM -->|node focus| Layout + FM -->|waveai focus| WaveAI + Layout -->|request focus back| FM + WaveAI -->|request focus back| FM + + FM -->|focusType atom| State[Global State] + Layout -.->|checks| State + WaveAI -.->|checks| State +``` + +## Focus Manager Enhancements + +**File**: [`frontend/app/store/focusManager.ts`](frontend/app/store/focusManager.ts) + +Add selection-aware focus methods: + +```typescript +class FocusManager { + // Existing + focusType: PrimitiveAtom<"node" | "waveai">; // Single source of truth + blockFocusAtom: Atom; + + // NEW: Selection-aware focus checking + waveAIFocusWithin(): boolean; + nodeFocusWithin(): boolean; + + // NEW: Focus transitions (INTENTIONALLY not defensive) + requestNodeFocus(): void; // from Wave AI → node (BREAKS selections - that's the point!) + requestWaveAIFocus(): void; // from node → Wave AI + + // NEW: Get current focus type + getFocusType(): FocusStrType; + + // ENHANCED: Smart refocus based on focusType + refocusNode(): void; // already handles both types +} +``` + +**Critical Design Decision: `requestNodeFocus()` is NOT defensive** + +When `requestNodeFocus()` is called (e.g., Cmd+n, explicit focus change), it MUST take focus even if there's a selection in Wave AI. This is intentional - the user explicitly requested a focus change. Losing the selection is the correct behavior. + +**Focus Manager as Source of Truth** + +The `focusType` atom is the single source of truth. The old `waveAIFocusedAtom` will be kept in sync during migration but should eventually be removed. All components should read `focusManager.focusType` directly (via `useAtomValue`) to determine focus ring state - this ensures synchronized, reactive focus ring updates. + +## Wave AI Focus Utilities + +**New File**: [`frontend/app/aipanel/waveai-focus-utils.ts`](frontend/app/aipanel/waveai-focus-utils.ts) + +Similar to [`focusutil.ts`](frontend/util/focusutil.ts) but for Wave AI: + +```typescript +// Find if element is within Wave AI panel +export function findWaveAIPanel(element: HTMLElement): HTMLElement | null { + let current: HTMLElement = element; + while (current) { + if (current.hasAttribute("data-waveai-panel")) { + return current; + } + current = current.parentElement; + } + return null; +} + +// Check if Wave AI panel has focus or selection (like focusedBlockId()) +export function waveAIHasFocusWithin(): boolean { + // Check if activeElement is within Wave AI panel + const focused = document.activeElement; + if (focused instanceof HTMLElement) { + const waveAIPanel = findWaveAIPanel(focused); + if (waveAIPanel) return true; + } + + // Check if selection is within Wave AI panel + const sel = document.getSelection(); + if (sel && sel.anchorNode && sel.rangeCount > 0 && !sel.isCollapsed) { + let anchor = sel.anchorNode; + if (anchor instanceof Text) { + anchor = anchor.parentElement; + } + if (anchor instanceof HTMLElement) { + const waveAIPanel = findWaveAIPanel(anchor); + if (waveAIPanel) return true; + } + } + + return false; +} + +// Check if there's an active selection in Wave AI +export function waveAIHasSelection(): boolean { + const sel = document.getSelection(); + if (!sel || sel.rangeCount === 0 || sel.isCollapsed) { + return false; + } + + let anchor = sel.anchorNode; + if (anchor instanceof Text) { + anchor = anchor.parentElement; + } + if (anchor instanceof HTMLElement) { + return findWaveAIPanel(anchor) != null; + } + + return false; +} +``` + +## Wave AI Panel Integration + +**File**: [`frontend/app/aipanel/aipanel.tsx`](frontend/app/aipanel/aipanel.tsx) + +Add capture phase and selection protection: + +```typescript +// ADD: Capture phase handler (like blocks) +const handleFocusCapture = useCallback((event: React.FocusEvent) => { + console.log("Wave AI focus capture", getElemAsStr(event.target)); + focusManager.requestWaveAIFocus(); // Sets visual state immediately +}, []); + +// MODIFY: Click handler with selection protection +const handleClick = (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + const isInteractive = target.closest('button, a, input, textarea, select, [role="button"], [tabindex]'); + + if (isInteractive) { + return; + } + + // NEW: Check for selection protection + const hasSelection = waveAIHasSelection(); + if (hasSelection) { + // Just update visual focus, don't move DOM focus + focusManager.requestWaveAIFocus(); + return; + } + + // No selection, safe to move DOM focus + setTimeout(() => { + if (!waveAIHasSelection()) { // Double-check after timeout + model.focusInput(); + } + }, 0); +}; + +// Add data attribute and onFocusCapture to the div +
+``` + +## Wave AI Input Focus Handling + +**File**: [`frontend/app/aipanel/aipanelinput.tsx`](frontend/app/aipanel/aipanelinput.tsx) + +Smart blur handling: + +```typescript +// MODIFY: handleFocus - advisory only +const handleFocus = useCallback(() => { + focusManager.requestWaveAIFocus(); +}, []); + +// MODIFY: handleBlur - simplified with waveAIHasFocusWithin() +const handleBlur = useCallback((e: React.FocusEvent) => { + // Window blur - preserve state + if (e.relatedTarget === null) { + return; + } + + // Still within Wave AI (focus or selection) - don't revert + if (waveAIHasFocusWithin()) { + return; + } + + // Focus truly leaving Wave AI, revert to node focus + focusManager.requestNodeFocus(); +}, []); +``` + +**Note:** `waveAIHasFocusWithin()` checks both: + +1. If `relatedTarget` is within Wave AI panel (handles context menus, buttons) +2. If there's an active selection in Wave AI (handles text selection clicks) + +This combines both checks from the original implementation into a single utility call. + +## Block Focus Integration + +**File**: [`frontend/app/block/block.tsx`](frontend/app/block/block.tsx) + +**No changes needed in block.tsx** - the block code works perfectly as-is! + +**How it works:** + +When a block child gets focus (input field, terminal click, tab navigation): + +``` +1. handleChildFocus fires (capture phase) + ↓ +2. nodeModel.focusNode() + ↓ +3. layoutModel.focusNode(nodeId) + ↓ +4. treeReducer(FocusNodeAction) + ↓ +5. focusManager.requestNodeFocus() (see Layout Focus Coordination section) + ↓ +6. Updates localTreeStateAtom (synchronous) + ↓ +7. isFocused recalculates (sees focusType = "node") + ↓ +8. Two-step effect grants physical DOM focus +``` + +The focus manager update happens automatically in the treeReducer for all focus-claiming operations. + +## Layout Focus Integration + +**File**: [`frontend/layout/lib/layoutModel.ts`](frontend/layout/lib/layoutModel.ts) + +The `isFocused` atom already checks Wave AI state: + +```typescript +isFocused: atom((get) => { + const treeState = get(this.localTreeStateAtom); + const isFocused = treeState.focusedNodeId === nodeid; + const waveAIFocused = get(atoms.waveAIFocusedAtom); + return isFocused && !waveAIFocused; +}); +``` + +**Update to use focus manager:** + +```typescript +isFocused: atom((get) => { + const treeState = get(this.localTreeStateAtom); + const isFocused = treeState.focusedNodeId === nodeid; + const focusType = get(focusManager.focusType); + return isFocused && focusType === "node"; +}); +``` + +This single change coordinates the entire system: + +- Layout can set `focusedNodeId` freely +- The reactive chain runs normally +- But `isFocused` returns `false` if focus manager says "waveai" +- Block's two-step effect doesn't run +- Physical DOM focus stays with Wave AI + +## Layout Focus Coordination + +**File**: [`frontend/layout/lib/layoutModel.ts`](frontend/layout/lib/layoutModel.ts) + +**Critical Integration**: When layout operations claim focus, they must update the focus manager synchronously. + +```typescript +treeReducer(action: LayoutTreeAction, setState = true): boolean { + // Process the action (mutates this.treeState) + switch (action.type) { + case LayoutTreeActionType.InsertNode: + insertNode(this.treeState, action); + // If inserting with focus, claim focus from Wave AI + if ((action as LayoutTreeInsertNodeAction).focused) { + focusManager.requestNodeFocus(); + } + break; + + case LayoutTreeActionType.InsertNodeAtIndex: + insertNodeAtIndex(this.treeState, action); + if ((action as LayoutTreeInsertNodeAtIndexAction).focused) { + focusManager.requestNodeFocus(); + } + break; + + case LayoutTreeActionType.FocusNode: + focusNode(this.treeState, action); + // Explicit focus change always claims focus + focusManager.requestNodeFocus(); + break; + + case LayoutTreeActionType.MagnifyNodeToggle: + magnifyNodeToggle(this.treeState, action); + // Magnifying also focuses the node + focusManager.requestNodeFocus(); + break; + + // ... other cases don't affect focus + } + + if (setState) { + this.updateTree(); + this.setter(this.localTreeStateAtom, { ...this.treeState }); + this.persistToBackend(); + } + + return true; +} +``` + +**Why This Works:** + +1. `focusManager.requestNodeFocus()` updates `focusType` synchronously +2. Called BEFORE atoms commit (still in same function) +3. When `localTreeStateAtom` commits, `isFocused` sees the new `focusType` +4. Both updates happen in same tick → React sees consistent state +5. No race conditions, no flash + +**Order of Operations:** + +``` +Cmd+n pressed + ↓ +treeReducer() executes + ↓ +1. insertNode() mutates layoutState.focusedNodeId +2. focusManager.requestNodeFocus() updates focusType +3. setter(localTreeStateAtom) commits tree state + ↓ +[All synchronous - single call stack] + ↓ +React re-renders with both updates applied + ↓ +isFocused sees: focusedNodeId = newNode AND focusType = "node" + ↓ +Two-step effect grants physical focus +``` + +## Keyboard Navigation Integration + +**File**: [`frontend/app/store/keymodel.ts`](frontend/app/store/keymodel.ts) + +Use focus manager instead of direct atom checks: + +```typescript +function switchBlockInDirection(tabId: string, direction: NavigateDirection) { + const layoutModel = getLayoutModelForTabById(tabId); + const focusType = focusManager.getFocusType(); + + if (direction === NavigateDirection.Left) { + const numBlocks = globalStore.get(layoutModel.numLeafs); + if (focusType === "waveai") { + return; + } + if (numBlocks === 1) { + focusManager.requestWaveAIFocus(); + return; + } + } + + // For right navigation, switch from Wave AI to blocks + if (direction === NavigateDirection.Right && focusType === "waveai") { + focusManager.requestNodeFocus(); + return; + } + + // Rest of navigation logic... +} +``` + +## Focus Flow + +### Complete Flow (Single Tick, No Flash) + +``` +User presses Cmd+n + ↓ +treeReducer() called + ↓ +1. insertNode(focused: true) - SYNCHRONOUS + - layoutState.focusedNodeId = newNode + ↓ +2. setter(localTreeStateAtom, { ...treeState }) - SYNCHRONOUS + - Atom updated immediately + ↓ +3. persistToBackend() - ASYNC (fire-and-forget) + ↓ +[All in same tick - no intermediate renders] + ↓ +React re-renders (batched update) + ↓ +isFocused recalculates: + - get(localTreeStateAtom) → focusedNodeId = newNode ✓ + - get(focusType) → checks current focus type + - Returns TRUE if focusType === "node" + ↓ +useLayoutEffect #1: setBlockClicked(true) + ↓ +useLayoutEffect #2: setFocusTarget() + ↓ +Physical DOM focus granted ✓ +``` + +**Why there's no flash:** + +- Local atoms update synchronously +- React batches the updates +- Everything sees consistent state in one render + +## Edge Cases + +### 1. Window Blur (⌘+Tab to other app) + +- Textarea loses focus, triggers `handleBlur` +- `relatedTarget` is null → detected as window blur +- Focus state preserved + +### 2. Selection in Wave AI + +- User selects text +- Clicks elsewhere in Wave AI +- `waveAIHasSelection()` returns true +- Only visual focus updates, no DOM focus change +- Selection preserved + +### 3. Copy/Paste Context Menu + +- Right-click causes blur +- `relatedTarget` within Wave AI panel +- `handleBlur` detects this, doesn't revert focus + +### 4. Modal Dialogs + +- Modal opens, steals focus +- Modal closes → `globalRefocus()` +- Focus manager restores correct focus based on `focusType` + +## Implementation Steps + +### 1. Focus Manager Foundation + +- Implement enhanced `focusManager.ts` with new methods +- Create `waveai-focus-utils.ts` with selection utilities +- Add data attributes to Wave AI panel + +### 2. Wave AI Integration + +- Add `onFocusCapture` to Wave AI panel +- Update `handleBlur` with simplified `waveAIHasFocusWithin()` check +- Update `handleClick` with selection awareness +- Components read `focusManager.focusType` directly via `useAtomValue` for focus ring display + +### 3. Layout Integration + +- Update `isFocused` atom to check `focusManager.focusType` +- Add `focusManager.requestNodeFocus()` calls in `treeReducer` for focus-claiming operations +- Update keyboard navigation to use `focusManager.getFocusType()` + +### 4. Testing + +- Test all transitions and edge cases +- Verify selection protection works +- Confirm no focus ring flashing +- Verify focus rings are synchronized through focus manager + +## Files to Create/Modify + +### New Files + +- `frontend/app/aipanel/waveai-focus-utils.ts` - Focus utilities for Wave AI + +### Modified Files + +- [`frontend/app/store/focusManager.ts`](frontend/app/store/focusManager.ts) - Enhanced with new methods +- [`frontend/app/aipanel/aipanel.tsx`](frontend/app/aipanel/aipanel.tsx) - Add capture phase, improve click handler +- [`frontend/app/aipanel/aipanelinput.tsx`](frontend/app/aipanel/aipanelinput.tsx) - Smart blur handling +- [`frontend/layout/lib/layoutModel.ts`](frontend/layout/lib/layoutModel.ts) - Update isFocused atom AND add focus manager calls in treeReducer +- [`frontend/app/store/keymodel.ts`](frontend/app/store/keymodel.ts) - Use focus manager for navigation + +## Testing Checklist + +- [ ] Select text in Wave AI, click elsewhere in Wave AI → selection preserved +- [ ] Click Wave AI panel (not input) → focus moves to Wave AI +- [ ] Click block while in Wave AI (no selection) → focus moves to block +- [ ] Press Left arrow in single block → Wave AI focused +- [ ] Press Right arrow in Wave AI → block focused +- [ ] Window blur (⌘+Tab) → focus state preserved +- [ ] Open context menu in Wave AI → doesn't lose focus +- [ ] Modal opens/closes → focus restores correctly + +## Benefits + +1. **Selection protection** - Wave AI selections preserved like blocks +2. **No focus flash** - Capture phase provides immediate visual feedback +3. **Robust blur handling** - Smart detection of where focus is going +4. **Unified model** - Single source of truth simplifies reasoning +5. **Simple reactivity** - Everything updates synchronously in one tick +6. **No timing issues** - Local atoms eliminate race conditions + +## Phased Implementation Approach + +The changes can be broken into safe, independently testable phases. Each phase can be shipped and tested before proceeding to the next. + +### Phase 1: Foundation (Non-Breaking, Fully Testable) + +**Add focus manager methods WITHOUT changing existing code** + +```typescript +// In focusManager.ts - ADD these methods +class FocusManager { + // NEW methods that ALSO update the old waveAIFocusedAtom during migration + requestWaveAIFocus(): void { + globalStore.set(this.focusType, "waveai"); + globalStore.set(atoms.waveAIFocusedAtom, true); // ← Keep old atom in sync during migration! + } + + requestNodeFocus(): void { + // NO defensive checks - when called, we TAKE focus (selections may be lost) + globalStore.set(this.focusType, "node"); + globalStore.set(atoms.waveAIFocusedAtom, false); // ← Keep old atom in sync during migration! + } + + getFocusType(): FocusStrType { + return globalStore.get(this.focusType); + } + + waveAIFocusWithin(): boolean { + return waveAIHasFocusWithin(); + } + + nodeFocusWithin(): boolean { + return focusedBlockId() != null; + } +} +``` + +**Why this is safe:** + +- Doesn't change any existing code +- Focus manager updates BOTH new `focusType` AND old `waveAIFocusedAtom` during migration +- Everything keeps working exactly as before +- Can test focus manager methods in isolation +- Components can read `focusType` directly via `useAtomValue` for reactive updates +- No user-visible changes + +**Testing:** + +- Call the new methods manually in console +- Verify both atoms update correctly +- Verify existing focus behavior unchanged + +--- + +### Phase 2: Wave AI Improvements (Testable in Isolation) + +**Add utilities and improve Wave AI focus handling** + +1. Create `waveai-focus-utils.ts` with selection checking utilities +2. Update `aipanel.tsx`: + - Add `data-waveai-panel` attribute + - Add `onFocusCapture` handler + - Improve click handler with selection protection + - Call `focusManager.requestWaveAIFocus()` instead of setting atom directly +3. Update `aipanelinput.tsx`: + - Smart blur handling with selection checks + - Call `focusManager.requestNodeFocus()` instead of setting atom directly + +**Why this is safe:** + +- Wave AI now uses focus manager, but focus manager keeps old atom in sync +- Blocks still read `waveAIFocusedAtom` directly - still works! +- Can test Wave AI selection protection independently +- If there's a bug, only Wave AI is affected +- Blocks remain completely unchanged + +**Testing:** + +- Wave AI selection preservation when clicking within panel +- Wave AI blur handling (window blur, context menus, etc.) +- Verify blocks still work normally (unchanged) +- Test transitions between Wave AI and blocks + +**User-visible improvements:** + +- Wave AI text selections no longer lost when clicking in panel +- No focus ring flashing +- Better window blur handling + +--- + +### Phase 3: Layout isFocused Migration (Single Critical Change) + +**Update isFocused atom to use focus manager** + +```typescript +// In layoutModel.ts - CHANGE isFocused atom +isFocused: atom((get) => { + const treeState = get(this.localTreeStateAtom); + const isFocused = treeState.focusedNodeId === nodeid; + const focusType = get(focusManager.focusType); // ← Use focus manager + return isFocused && focusType === "node"; +}); +``` + +**Why this is safe:** + +- Focus manager already keeps `waveAIFocusedAtom` in sync (Phase 1) +- Wave AI already uses focus manager (Phase 2) +- Blocks read the new `focusType` but it's always consistent with old atom +- Should be completely transparent +- Single file change - easy to revert if issues + +**Testing:** + +- Focus transitions between blocks still work +- Wave AI → block transitions work +- Block → Wave AI transitions work +- Keyboard navigation still works +- All existing functionality preserved + +**No user-visible changes** - just internal refactoring + +--- + +### Phase 4: Layout Focus Coordination (Completes the System) + +**Add focus manager calls to treeReducer** + +```typescript +// In layoutModel.ts treeReducer - ADD focus manager calls +case LayoutTreeActionType.FocusNode: + focusNode(this.treeState, action); + focusManager.requestNodeFocus(); // ← NEW + break; + +case LayoutTreeActionType.InsertNode: + insertNode(this.treeState, action); + if ((action as LayoutTreeInsertNodeAction).focused) { + focusManager.requestNodeFocus(); // ← NEW + } + break; + +case LayoutTreeActionType.MagnifyNodeToggle: + magnifyNodeToggle(this.treeState, action); + focusManager.requestNodeFocus(); // ← NEW + break; +``` + +**Why this is safe:** + +- Just makes explicit what was already happening via Wave AI's blur handler +- Ensures focus manager is updated even when layout programmatically changes focus +- Makes the system more robust +- Small, focused changes in one file + +**Testing:** + +- Cmd+n creates new block with correct focus +- Magnify toggle works correctly +- Programmatic focus changes work +- Focus stays consistent during rapid operations + +**User-visible improvements:** + +- More robust focus handling during programmatic layout changes +- Edge cases with rapid focus changes handled better + +--- + +### Phase 5: Keyboard Nav & Cleanup (Optional Polish) + +**Use focus manager in keyboard navigation, remove old atom usage** + +1. Update `keymodel.ts` to use `focusManager.getFocusType()` +2. Remove direct `atoms.waveAIFocusedAtom` usage throughout codebase +3. (Optional) Stop syncing `waveAIFocusedAtom` in focus manager - can be deprecated + +**Why this is safe:** + +- Everything already using focus manager under the hood +- Just cleanup/optimization +- Can be done incrementally + +**Testing:** + +- Keyboard navigation between blocks +- Left/Right arrow to/from Wave AI +- All keyboard shortcuts still work + +--- + +## Key Insight: Dual Atom Sync + +**Phase 1 is the enabler**: By having the focus manager update BOTH the new `focusType` atom AND the old `waveAIFocusedAtom`, we create a safe transition period where: + +- New code can use focus manager +- Old code continues reading the old atom +- Everything stays consistent +- Each phase is independently testable +- Can ship and test after each phase + +This dual-sync approach eliminates the "all or nothing" problem. You can stop at any phase and have a working, tested system. + +## Testing Between Phases + +After each phase, you can ship and test: + +- **Phase 1** → No user-visible changes, foundation in place +- **Phase 2** → Wave AI improvements only, blocks unchanged +- **Phase 3** → Complete system working with new architecture +- **Phase 4** → More robust edge case handling +- **Phase 5** → Code cleanup and optimization + +Each phase builds on the previous one but can be independently verified. diff --git a/aiprompts/wps-events.md b/aiprompts/wps-events.md new file mode 100644 index 0000000000..773ff8f209 --- /dev/null +++ b/aiprompts/wps-events.md @@ -0,0 +1,293 @@ +# WPS Events Guide + +## Overview + +WPS (Wave PubSub) is Wave Terminal's publish-subscribe event system that enables different parts of the application to communicate asynchronously. The system uses a broker pattern to route events from publishers to subscribers based on event types and scopes. + +## Key Files + +- [`pkg/wps/wpstypes.go`](../pkg/wps/wpstypes.go) - Event type constants and data structures +- [`pkg/wps/wps.go`](../pkg/wps/wps.go) - Broker implementation and core logic +- [`pkg/wcore/wcore.go`](../pkg/wcore/wcore.go) - Example usage patterns + +## Event Structure + +Events in WPS have the following structure: + +```go +type WaveEvent struct { + Event string `json:"event"` // Event type constant + Scopes []string `json:"scopes,omitempty"` // Optional scopes for targeted delivery + Sender string `json:"sender,omitempty"` // Optional sender identifier + Persist int `json:"persist,omitempty"` // Number of events to persist in history + Data any `json:"data,omitempty"` // Event payload +} +``` + +## Adding a New Event Type + +### Step 1: Define the Event Constant + +Add your event type constant to [`pkg/wps/wpstypes.go`](../pkg/wps/wpstypes.go:8-19): + +```go +const ( + Event_BlockClose = "blockclose" + Event_ConnChange = "connchange" + // ... other events ... + Event_YourNewEvent = "your:newevent" // Use colon notation for namespacing +) +``` + +**Naming Convention:** +- Use descriptive PascalCase for the constant name with `Event_` prefix +- Use lowercase with colons for the string value (e.g., "namespace:eventname") +- Group related events with the same namespace prefix + +### Step 2: Define Event Data Structure (Optional) + +If your event carries structured data, define a type for it: + +```go +type YourEventData struct { + Field1 string `json:"field1"` + Field2 int `json:"field2"` +} +``` + +### Step 3: Expose Type to Frontend (If Needed) + +If your event data type isn't already exposed via an RPC call, you need to add it to [`pkg/tsgen/tsgen.go`](../pkg/tsgen/tsgen.go:29-56) so TypeScript types are generated: + +```go +// add extra types to generate here +var ExtraTypes = []any{ + waveobj.ORef{}, + // ... other types ... + uctypes.RateLimitInfo{}, // Example: already added + YourEventData{}, // Add your new type here +} +``` + +Then run code generation: + +```bash +task generate +``` + +This will update [`frontend/types/gotypes.d.ts`](../frontend/types/gotypes.d.ts) with TypeScript definitions for your type, ensuring type safety in the frontend when handling these events. + +## Publishing Events + +### Basic Publishing + +To publish an event, use the global broker: + +```go +import "github.com/wavetermdev/waveterm/pkg/wps" + +wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_YourNewEvent, + Data: yourData, +}) +``` + +### Publishing with Scopes + +Scopes allow targeted event delivery. Subscribers can filter events by scope: + +```go +wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_WaveObjUpdate, + Scopes: []string{oref.String()}, // Target specific object + Data: updateData, +}) +``` + +### Publishing in a Goroutine + +To avoid blocking the caller, publish events asynchronously: + +```go +go func() { + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_YourNewEvent, + Data: data, + }) +}() +``` + +**When to use goroutines:** +- When publishing from performance-critical code paths +- When the event is informational and doesn't need immediate delivery +- When publishing from code that holds locks (to prevent deadlocks) + +### Event Persistence + +Events can be persisted in memory for late subscribers: + +```go +wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_YourNewEvent, + Persist: 100, // Keep last 100 events + Data: data, +}) +``` + +## Complete Example: Rate Limit Updates + +This example shows how rate limit information is published when AI chat responses include rate limit headers. + +### 1. Define the Event Type + +In [`pkg/wps/wpstypes.go`](../pkg/wps/wpstypes.go:19): + +```go +const ( + // ... other events ... + Event_WaveAIRateLimit = "waveai:ratelimit" +) +``` + +### 2. Publish the Event + +In [`pkg/aiusechat/usechat.go`](../pkg/aiusechat/usechat.go:94-108): + +```go +import "github.com/wavetermdev/waveterm/pkg/wps" + +func updateRateLimit(info *uctypes.RateLimitInfo) { + if info == nil { + return + } + rateLimitLock.Lock() + defer rateLimitLock.Unlock() + globalRateLimitInfo = info + + // Publish event in goroutine to avoid blocking + go func() { + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_WaveAIRateLimit, + Data: info, // RateLimitInfo struct + }) + }() +} +``` + +### 3. Subscribe to the Event (Frontend) + +In the frontend, subscribe to events via WebSocket: + +```typescript +// Subscribe to rate limit updates +const subscription = { + event: "waveai:ratelimit", + allscopes: true // Receive all rate limit events +}; +``` + +## Subscribing to Events + +### From Go Code + +```go +// Subscribe to all events of a type +wps.Broker.Subscribe(routeId, wps.SubscriptionRequest{ + Event: wps.Event_YourNewEvent, + AllScopes: true, +}) + +// Subscribe to specific scopes +wps.Broker.Subscribe(routeId, wps.SubscriptionRequest{ + Event: wps.Event_WaveObjUpdate, + Scopes: []string{"workspace:123"}, +}) + +// Unsubscribe +wps.Broker.Unsubscribe(routeId, wps.Event_YourNewEvent) +``` + +### Scope Matching + +Scopes support wildcard matching: +- `*` matches a single scope segment +- `**` matches multiple scope segments + +```go +// Subscribe to all workspace events +wps.Broker.Subscribe(routeId, wps.SubscriptionRequest{ + Event: wps.Event_WaveObjUpdate, + Scopes: []string{"workspace:*"}, +}) +``` + +## Best Practices + +1. **Use Namespaces**: Prefix event names with a namespace (e.g., `waveai:`, `workspace:`, `block:`) + +2. **Don't Block**: Use goroutines when publishing from performance-critical code or while holding locks + +3. **Type-Safe Data**: Define struct types for event data rather than using maps + +4. **Scope Wisely**: Use scopes to limit event delivery and reduce unnecessary processing + +5. **Document Events**: Add comments explaining when events are fired and what data they carry + +6. **Consider Persistence**: Use `Persist` for events that late subscribers might need (like status updates) + +## Common Event Patterns + +### Status Updates + +```go +wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_ControllerStatus, + Scopes: []string{blockId}, + Persist: 1, // Keep only latest status + Data: statusData, +}) +``` + +### Object Updates + +```go +wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_WaveObjUpdate, + Scopes: []string{oref.String()}, + Data: waveobj.WaveObjUpdate{ + UpdateType: waveobj.UpdateType_Update, + OType: obj.GetOType(), + OID: waveobj.GetOID(obj), + Obj: obj, + }, +}) +``` + +### Batch Updates + +```go +// Helper function for multiple updates +func (b *BrokerType) SendUpdateEvents(updates waveobj.UpdatesRtnType) { + for _, update := range updates { + b.Publish(WaveEvent{ + Event: Event_WaveObjUpdate, + Scopes: []string{waveobj.MakeORef(update.OType, update.OID).String()}, + Data: update, + }) + } +} +``` + +## Debugging + +To debug event flow: + +1. Check broker subscription map: `wps.Broker.SubMap` +2. View persisted events: `wps.Broker.ReadEventHistory(eventType, scope, maxItems)` +3. Add logging in publish/subscribe methods +4. Monitor WebSocket traffic in browser dev tools + +## Related Documentation + +- [Configuration System](config-system.md) - Uses WPS events for config updates +- [Wave AI Architecture](waveai-architecture.md) - AI-related events \ No newline at end of file diff --git a/cmd/generatego/main-generatego.go b/cmd/generatego/main-generatego.go index 0fa012354e..05c3abea9f 100644 --- a/cmd/generatego/main-generatego.go +++ b/cmd/generatego/main-generatego.go @@ -32,6 +32,7 @@ func GenerateWshClient() error { "github.com/wavetermdev/waveterm/pkg/wps", "github.com/wavetermdev/waveterm/pkg/vdom", "github.com/wavetermdev/waveterm/pkg/util/iochan/iochantypes", + "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes", }) wshDeclMap := wshrpc.GenerateWshCommandDeclMap() for _, key := range utilfn.GetOrderedMapKeys(wshDeclMap) { diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index 4e682edb95..2f2395e1ac 100644 --- a/cmd/server/main-server.go +++ b/cmd/server/main-server.go @@ -13,6 +13,7 @@ import ( "sync" "time" + "github.com/joho/godotenv" "github.com/wavetermdev/waveterm/pkg/authkey" "github.com/wavetermdev/waveterm/pkg/blockcontroller" "github.com/wavetermdev/waveterm/pkg/blocklogger" @@ -53,6 +54,14 @@ const TelemetryCountsInterval = 1 * time.Hour var shutdownOnce sync.Once +func init() { + envFilePath := os.Getenv("WAVETERM_ENVFILE") + if envFilePath != "" { + log.Printf("applying env file: %s\n", envFilePath) + _ = godotenv.Load(envFilePath) + } +} + func doShutdown(reason string) { shutdownOnce.Do(func() { log.Printf("shutting down: %s\n", reason) diff --git a/cmd/testai/main-testai.go b/cmd/testai/main-testai.go index 74af5889d5..225ff33caf 100644 --- a/cmd/testai/main-testai.go +++ b/cmd/testai/main-testai.go @@ -14,13 +14,20 @@ import ( "os" "time" - "github.com/wavetermdev/waveterm/pkg/waveai" - "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/aiusechat" + "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" + "github.com/wavetermdev/waveterm/pkg/web/sse" ) //go:embed testschema.json var testSchemaJSON string +const ( + DefaultAnthropicModel = "claude-sonnet-4-5" + DefaultOpenAIModel = "gpt-5" +) + // TestResponseWriter implements http.ResponseWriter and additional interfaces for testing type TestResponseWriter struct { header http.Header @@ -58,7 +65,7 @@ func (w *TestResponseWriter) SetReadDeadline(deadline time.Time) error { return nil } -func getToolDefinitions() []waveai.ToolDefinition { +func getToolDefinitions() []uctypes.ToolDefinition { var schemas map[string]any if err := json.Unmarshal([]byte(testSchemaJSON), &schemas); err != nil { log.Printf("Error parsing schema: %v\n", err) @@ -75,7 +82,7 @@ func getToolDefinitions() []waveai.ToolDefinition { configSchema = map[string]any{"type": "object"} } - return []waveai.ToolDefinition{ + return []uctypes.ToolDefinition{ { Name: "get_config", Description: "Get the current GitHub Actions Monitor configuration settings including repository, workflow, polling interval, and max workflow runs", @@ -98,126 +105,184 @@ func getToolDefinitions() []waveai.ToolDefinition { } } -func testOpenAI(ctx context.Context, model, message string, tools []waveai.ToolDefinition) { - apiKey := os.Getenv("OPENAI_API_KEY") +func testOpenAI(ctx context.Context, model, message string, tools []uctypes.ToolDefinition) { + apiKey := os.Getenv("OPENAI_APIKEY") if apiKey == "" { - fmt.Println("Error: OPENAI_API_KEY environment variable not set") + fmt.Println("Error: OPENAI_APIKEY environment variable not set") os.Exit(1) } - opts := &wshrpc.WaveAIOptsType{ - APIToken: apiKey, - Model: model, - MaxTokens: 1000, + opts := &uctypes.AIOptsType{ + APIType: aiusechat.APIType_OpenAI, + APIToken: apiKey, + Model: model, + MaxTokens: 4096, + ThinkingLevel: uctypes.ThinkingLevelMedium, } - messages := []waveai.UseChatMessage{ - { - Role: "user", - Content: message, + // Generate a chat ID + chatID := uuid.New().String() + + // Convert to AIMessage format for WaveAIPostMessageWrap + aiMessage := &uctypes.AIMessage{ + MessageId: uuid.New().String(), + Parts: []uctypes.AIMessagePart{ + { + Type: uctypes.AIMessagePartTypeText, + Text: message, + }, }, } - fmt.Printf("Testing OpenAI streaming with model: %s\n", model) + fmt.Printf("Testing OpenAI streaming with WaveAIPostMessageWrap, model: %s\n", model) fmt.Printf("Message: %s\n", message) + fmt.Printf("Chat ID: %s\n", chatID) fmt.Println("---") testWriter := &TestResponseWriter{} - sseHandler := waveai.MakeSSEHandlerCh(testWriter, ctx) - - err := sseHandler.SetupSSE() - if err != nil { - fmt.Printf("Error setting up SSE: %v\n", err) - return - } + sseHandler := sse.MakeSSEHandlerCh(testWriter, ctx) defer sseHandler.Close() - stopReason, err := waveai.StreamOpenAIToUseChat(ctx, sseHandler, opts, messages, tools) + chatOpts := uctypes.WaveChatOpts{ + ChatId: chatID, + ClientId: uuid.New().String(), + Config: *opts, + Tools: tools, + } + err := aiusechat.WaveAIPostMessageWrap(ctx, sseHandler, aiMessage, chatOpts) if err != nil { fmt.Printf("OpenAI streaming error: %v\n", err) } - if stopReason != nil { - fmt.Printf("Stop reason: %+v\n", stopReason) - } } -func testAnthropic(ctx context.Context, model, message string, tools []waveai.ToolDefinition) { - apiKey := os.Getenv("ANTHROPIC_API_KEY") +func testAnthropic(ctx context.Context, model, message string, tools []uctypes.ToolDefinition) { + apiKey := os.Getenv("ANTHROPIC_APIKEY") if apiKey == "" { - fmt.Println("Error: ANTHROPIC_API_KEY environment variable not set") + fmt.Println("Error: ANTHROPIC_APIKEY environment variable not set") os.Exit(1) } - opts := &wshrpc.WaveAIOptsType{ - APIToken: apiKey, - Model: model, - MaxTokens: 1000, + opts := &uctypes.AIOptsType{ + APIType: aiusechat.APIType_Anthropic, + APIToken: apiKey, + Model: model, + MaxTokens: 4096, + ThinkingLevel: uctypes.ThinkingLevelMedium, } - messages := []waveai.UseChatMessage{ - { - Role: "user", - Content: message, + // Generate a chat ID + chatID := uuid.New().String() + + // Convert to AIMessage format for WaveAIPostMessageWrap + aiMessage := &uctypes.AIMessage{ + MessageId: uuid.New().String(), + Parts: []uctypes.AIMessagePart{ + { + Type: uctypes.AIMessagePartTypeText, + Text: message, + }, }, } - fmt.Printf("Testing Anthropic streaming with model: %s\n", model) + fmt.Printf("Testing Anthropic streaming with WaveAIPostMessageWrap, model: %s\n", model) fmt.Printf("Message: %s\n", message) + fmt.Printf("Chat ID: %s\n", chatID) fmt.Println("---") testWriter := &TestResponseWriter{} - sseHandler := waveai.MakeSSEHandlerCh(testWriter, ctx) - - err := sseHandler.SetupSSE() - if err != nil { - fmt.Printf("Error setting up SSE: %v\n", err) - return - } + sseHandler := sse.MakeSSEHandlerCh(testWriter, ctx) defer sseHandler.Close() - stopReason, err := waveai.StreamAnthropicResponses(ctx, sseHandler, opts, messages, tools) + chatOpts := uctypes.WaveChatOpts{ + ChatId: chatID, + ClientId: uuid.New().String(), + Config: *opts, + Tools: tools, + } + err := aiusechat.WaveAIPostMessageWrap(ctx, sseHandler, aiMessage, chatOpts) if err != nil { fmt.Printf("Anthropic streaming error: %v\n", err) } - if stopReason != nil { - fmt.Printf("Stop reason: %+v\n", stopReason) - } +} + +func testT1(ctx context.Context) { + tool := aiusechat.GetAdderToolDefinition() + tools := []uctypes.ToolDefinition{tool} + testAnthropic(ctx, DefaultAnthropicModel, "what is 2+2, use the provider adder tool", tools) +} + +func testT2(ctx context.Context) { + tool := aiusechat.GetAdderToolDefinition() + tools := []uctypes.ToolDefinition{tool} + testOpenAI(ctx, DefaultOpenAIModel, "what is 2+2+8, use the provider adder tool", tools) +} + +func printUsage() { + fmt.Println("Usage: go run main-testai.go [--anthropic] [--tools] [--model ] [message]") + fmt.Println("Examples:") + fmt.Println(" go run main-testai.go 'What is 2+2?'") + fmt.Println(" go run main-testai.go --model o4-mini 'What is 2+2?'") + fmt.Println(" go run main-testai.go --anthropic 'What is 2+2?'") + fmt.Println(" go run main-testai.go --anthropic --model claude-3-5-sonnet-20241022 'What is 2+2?'") + fmt.Println(" go run main-testai.go --tools 'Help me configure GitHub Actions monitoring'") + fmt.Println("") + fmt.Println("Default models:") + fmt.Printf(" OpenAI: %s\n", DefaultOpenAIModel) + fmt.Printf(" Anthropic: %s\n", DefaultAnthropicModel) + fmt.Println("") + fmt.Println("Environment variables:") + fmt.Println(" OPENAI_APIKEY (for OpenAI models)") + fmt.Println(" ANTHROPIC_APIKEY (for Anthropic models)") } func main() { - var anthropic, tools bool + var anthropic, tools, help, t1, t2 bool + var model string flag.BoolVar(&anthropic, "anthropic", false, "Use Anthropic API instead of OpenAI") flag.BoolVar(&tools, "tools", false, "Enable GitHub Actions Monitor tools for testing") + flag.StringVar(&model, "model", "", fmt.Sprintf("AI model to use (defaults: %s for OpenAI, %s for Anthropic)", DefaultOpenAIModel, DefaultAnthropicModel)) + flag.BoolVar(&help, "help", false, "Show usage information") + flag.BoolVar(&t1, "t1", false, fmt.Sprintf("Run preset T1 test (%s with 'what is 2+2')", DefaultAnthropicModel)) + flag.BoolVar(&t2, "t2", false, fmt.Sprintf("Run preset T2 test (%s with 'what is 2+2')", DefaultOpenAIModel)) flag.Parse() - args := flag.Args() - if len(args) < 1 { - fmt.Println("Usage: go run main-testai.go [--anthropic] [--tools] [message]") - fmt.Println("Examples:") - fmt.Println(" go run main-testai.go o4-mini 'What is 2+2?'") - fmt.Println(" go run main-testai.go --anthropic claude-3-5-sonnet-20241022 'What is 2+2?'") - fmt.Println(" go run main-testai.go --tools o4-mini 'Help me configure GitHub Actions monitoring'") - fmt.Println("") - fmt.Println("Environment variables:") - fmt.Println(" OPENAI_API_KEY (for OpenAI models)") - fmt.Println(" ANTHROPIC_API_KEY (for Anthropic models)") - os.Exit(1) + if help { + printUsage() + os.Exit(0) } - model := args[0] + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + if t1 { + testT1(ctx) + return + } + if t2 { + testT2(ctx) + return + } + + // Set default model based on API type if not provided + if model == "" { + if anthropic { + model = DefaultAnthropicModel + } else { + model = DefaultOpenAIModel + } + } + + args := flag.Args() message := "What is 2+2?" - if len(args) > 1 { - message = args[1] + if len(args) > 0 { + message = args[0] } - var toolDefs []waveai.ToolDefinition + var toolDefs []uctypes.ToolDefinition if tools { toolDefs = getToolDefinitions() } - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - if anthropic { testAnthropic(ctx, model, message, toolDefs) } else { diff --git a/cmd/testopenai/main-testopenai.go b/cmd/testopenai/main-testopenai.go new file mode 100644 index 0000000000..7017407b47 --- /dev/null +++ b/cmd/testopenai/main-testopenai.go @@ -0,0 +1,164 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/wavetermdev/waveterm/pkg/aiusechat" + "github.com/wavetermdev/waveterm/pkg/aiusechat/openai" +) + +func makeOpenAIRequest(ctx context.Context, apiKey, model, message string, tools bool) error { + reqBody := openai.OpenAIRequest{ + Model: model, + Input: []any{ + openai.OpenAIMessage{ + Role: "user", + Content: []openai.OpenAIMessageContent{ + { + Type: "input_text", + Text: message, + }, + }, + }, + }, + Stream: true, + StreamOptions: &openai.StreamOptionsType{IncludeObfuscation: false}, + Reasoning: &openai.ReasoningType{Effort: "medium"}, + } + if tools { + reqBody.Tools = []openai.OpenAIRequestTool{ + openai.ConvertToolDefinitionToOpenAI(aiusechat.GetAdderToolDefinition()), + } + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("error marshaling request: %v", err) + } + + // Pretty print the request JSON for debugging + prettyJSON, err := json.MarshalIndent(reqBody, "", " ") + if err == nil { + fmt.Printf("Request JSON:\n%s\n", string(prettyJSON)) + } + + req, err := http.NewRequestWithContext(ctx, "POST", "https://api.openai.com/v1/responses", bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("error creating request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Accept", "text/event-stream") + + client := &http.Client{ + Timeout: 60 * time.Second, + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("error making request: %v", err) + } + defer resp.Body.Close() + + fmt.Printf("Response Status: %s\n", resp.Status) + fmt.Printf("Response Headers:\n") + for name, values := range resp.Header { + for _, value := range values { + fmt.Printf(" %s: %s\n", name, value) + } + } + fmt.Println("---") + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body)) + } + + return processSSEStream(resp.Body) +} + +func processSSEStream(reader io.Reader) error { + scanner := bufio.NewScanner(reader) + + fmt.Println("SSE Stream:") + fmt.Println("---") + + for scanner.Scan() { + line := scanner.Text() + fmt.Println(line) + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("error reading stream: %v", err) + } + + return nil +} + +func printUsage() { + fmt.Println("Usage: go run main-testopenai.go [--model ] [--tools] [message]") + fmt.Println("Examples:") + fmt.Println(" go run main-testopenai.go 'Stream me a limerick about gophers coding in Go.'") + fmt.Println(" go run main-testopenai.go --model gpt-4 'What is 2+2?'") + fmt.Println(" go run main-testopenai.go --tools 'What is 2+2? Use the adder tool.'") + fmt.Println("") + fmt.Println("Default model: gpt-5-mini") + fmt.Println("") + fmt.Println("Environment variables:") + fmt.Println(" OPENAI_APIKEY (required)") +} + +func main() { + var model string + var showHelp bool + var tools bool + + flag.StringVar(&model, "model", "gpt-5-mini", "OpenAI model to use") + flag.BoolVar(&showHelp, "help", false, "Show usage information") + flag.BoolVar(&tools, "tools", false, "Enable tools for testing") + flag.Parse() + + if showHelp { + printUsage() + os.Exit(0) + } + + apiKey := os.Getenv("OPENAI_APIKEY") + if apiKey == "" { + fmt.Println("Error: OPENAI_APIKEY environment variable not set") + printUsage() + os.Exit(1) + } + + args := flag.Args() + message := "Stream me a limerick about gophers coding in Go." + if len(args) > 0 { + message = args[0] + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + fmt.Printf("Testing OpenAI Responses API\n") + fmt.Printf("Model: %s\n", model) + fmt.Printf("Message: %s\n", message) + fmt.Println("===") + + if err := makeOpenAIRequest(ctx, apiKey, model, message, tools); err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/wsh/cmd/wshcmd-ai.go b/cmd/wsh/cmd/wshcmd-ai.go index 2f64a8fe56..15648f297b 100644 --- a/cmd/wsh/cmd/wshcmd-ai.go +++ b/cmd/wsh/cmd/wshcmd-ai.go @@ -100,6 +100,7 @@ func aiRun(cmd *cobra.Command, args []string) (rtnErr error) { waveobj.MetaKey_View: "waveai", }, }, + Focused: true, } newORef, err := wshclient.CreateBlockCommand(RpcClient, *data, &wshrpc.RpcOpts{Timeout: 2000}) diff --git a/cmd/wsh/cmd/wshcmd-createblock.go b/cmd/wsh/cmd/wshcmd-createblock.go index d967156726..05ed221194 100644 --- a/cmd/wsh/cmd/wshcmd-createblock.go +++ b/cmd/wsh/cmd/wshcmd-createblock.go @@ -44,6 +44,7 @@ func createBlockRun(cmd *cobra.Command, args []string) error { Meta: meta, }, Magnified: createBlockMagnified, + Focused: true, } oref, err := wshclient.CreateBlockCommand(RpcClient, data, nil) if err != nil { diff --git a/cmd/wsh/cmd/wshcmd-editconfig.go b/cmd/wsh/cmd/wshcmd-editconfig.go index ac5dadb137..19785f5d0d 100644 --- a/cmd/wsh/cmd/wshcmd-editconfig.go +++ b/cmd/wsh/cmd/wshcmd-editconfig.go @@ -52,6 +52,7 @@ func editConfigRun(cmd *cobra.Command, args []string) (rtnErr error) { waveobj.MetaKey_Edit: true, }, }, + Focused: true, } _, err = RpcClient.SendRpcRequest(wshrpc.Command_CreateBlock, wshCmd, &wshrpc.RpcOpts{Timeout: 2000}) diff --git a/cmd/wsh/cmd/wshcmd-editor.go b/cmd/wsh/cmd/wshcmd-editor.go index 3d6e8c7a31..670011c7a0 100644 --- a/cmd/wsh/cmd/wshcmd-editor.go +++ b/cmd/wsh/cmd/wshcmd-editor.go @@ -63,6 +63,7 @@ func editorRun(cmd *cobra.Command, args []string) (rtnErr error) { }, }, Magnified: editMagnified, + Focused: true, } if RpcContext.Conn != "" { wshCmd.BlockDef.Meta[waveobj.MetaKey_Connection] = RpcContext.Conn diff --git a/cmd/wsh/cmd/wshcmd-launch.go b/cmd/wsh/cmd/wshcmd-launch.go index 6da60417e7..679854e72f 100644 --- a/cmd/wsh/cmd/wshcmd-launch.go +++ b/cmd/wsh/cmd/wshcmd-launch.go @@ -52,6 +52,7 @@ func launchRun(cmd *cobra.Command, args []string) (rtnErr error) { createBlockData := wshrpc.CommandCreateBlockData{ BlockDef: &widget.BlockDef, Magnified: magnifyBlock || widget.Magnified, + Focused: true, } // Create the block diff --git a/cmd/wsh/cmd/wshcmd-run.go b/cmd/wsh/cmd/wshcmd-run.go index ae7099ad82..783e87fd6b 100644 --- a/cmd/wsh/cmd/wshcmd-run.go +++ b/cmd/wsh/cmd/wshcmd-run.go @@ -143,6 +143,7 @@ func runRun(cmd *cobra.Command, args []string) (rtnErr error) { }, }, Magnified: magnified, + Focused: true, } oref, err := wshclient.CreateBlockCommand(RpcClient, createBlockData, nil) diff --git a/cmd/wsh/cmd/wshcmd-ssh.go b/cmd/wsh/cmd/wshcmd-ssh.go index 8ab560d8e2..ec011ddd2c 100644 --- a/cmd/wsh/cmd/wshcmd-ssh.go +++ b/cmd/wsh/cmd/wshcmd-ssh.go @@ -67,6 +67,7 @@ func sshRun(cmd *cobra.Command, args []string) (rtnErr error) { BlockDef: &waveobj.BlockDef{ Meta: createMeta, }, + Focused: true, } oref, err := wshclient.CreateBlockCommand(RpcClient, createBlockData, nil) if err != nil { @@ -81,12 +82,25 @@ func sshRun(cmd *cobra.Command, args []string) (rtnErr error) { ORef: waveobj.MakeORef(waveobj.OType_Block, blockId), Meta: map[string]any{ waveobj.MetaKey_Connection: sshArg, + waveobj.MetaKey_CmdCwd: nil, }, } err := wshclient.SetMetaCommand(RpcClient, data, nil) if err != nil { return fmt.Errorf("setting connection in block: %w", err) } + + // Clear the cmd:hascurcwd rtinfo field + rtInfoData := wshrpc.CommandSetRTInfoData{ + ORef: waveobj.MakeORef(waveobj.OType_Block, blockId), + Data: map[string]any{ + "cmd:hascurcwd": nil, + }, + } + err = wshclient.SetRTInfoCommand(RpcClient, rtInfoData, nil) + if err != nil { + return fmt.Errorf("setting RTInfo in block: %w", err) + } WriteStderr("switched connection to %q\n", sshArg) return nil } diff --git a/cmd/wsh/cmd/wshcmd-term.go b/cmd/wsh/cmd/wshcmd-term.go index a05d1d8e12..ec1ad6a8c5 100644 --- a/cmd/wsh/cmd/wshcmd-term.go +++ b/cmd/wsh/cmd/wshcmd-term.go @@ -68,6 +68,7 @@ func termRun(cmd *cobra.Command, args []string) (rtnErr error) { Meta: createMeta, }, Magnified: termMagnified, + Focused: true, } oref, err := wshclient.CreateBlockCommand(RpcClient, createBlockData, nil) if err != nil { diff --git a/cmd/wsh/cmd/wshcmd-view.go b/cmd/wsh/cmd/wshcmd-view.go index a2f8f86394..ccba3a3d9c 100644 --- a/cmd/wsh/cmd/wshcmd-view.go +++ b/cmd/wsh/cmd/wshcmd-view.go @@ -64,6 +64,7 @@ func viewRun(cmd *cobra.Command, args []string) (rtnErr error) { }, }, Magnified: viewMagnified, + Focused: true, } } else { absFile, err := filepath.Abs(fileArg) @@ -89,6 +90,7 @@ func viewRun(cmd *cobra.Command, args []string) (rtnErr error) { }, }, Magnified: viewMagnified, + Focused: true, } if cmdName == "edit" { wshCmd.BlockDef.Meta[waveobj.MetaKey_Edit] = true diff --git a/cmd/wsh/cmd/wshcmd-web.go b/cmd/wsh/cmd/wshcmd-web.go index b01bc35e6f..9ad3fee486 100644 --- a/cmd/wsh/cmd/wshcmd-web.go +++ b/cmd/wsh/cmd/wshcmd-web.go @@ -119,6 +119,7 @@ func webOpenRun(cmd *cobra.Command, args []string) (rtnErr error) { }, }, Magnified: webOpenMagnified, + Focused: true, } if replaceBlockORef != nil { wshCmd.TargetBlockId = replaceBlockORef.OID diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index a5d694d8f6..5f016d70d9 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -63,6 +63,7 @@ wsh editconfig | editor:minimapenabled | bool | set to false to disable editor minimap | | editor:stickyscrollenabled | bool | enables monaco editor's stickyScroll feature (pinning headers of current context, e.g. class names, method names, etc.), defaults to false | | editor:wordwrap | bool | set to true to enable word wrapping in the editor (defaults to false) | +| editor:fontsize | float64 | set the font size for the editor (defaults to 12px) | | preview:showhiddenfiles | bool | set to false to disable showing hidden files in the directory preview (defaults to true) | | markdown:fontsize | float64 | font size for the normal text when rendering markdown in preview. headers are scaled up from this size, (default 14px) | | markdown:fixedfontsize | float64 | font size for the code blocks when rendering markdown in preview (default is 12px) | diff --git a/electron.vite.config.ts b/electron.vite.config.ts index acbf7c667d..1b16581370 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -13,6 +13,66 @@ import tsconfigPaths from "vite-tsconfig-paths"; const CHROME = "chrome140"; const NODE = "node22"; +// for debugging +// target is like -- path.resolve(__dirname, "frontend/app/workspace/workspace-layout-model.ts"); +function whoImportsTarget(target: string) { + return { + name: "who-imports-target", + buildEnd() { + // Build reverse graph: child -> [importers...] + const parents = new Map(); + for (const id of (this as any).getModuleIds()) { + const info = (this as any).getModuleInfo(id); + if (!info) continue; + for (const child of [...info.importedIds, ...info.dynamicallyImportedIds]) { + const arr = parents.get(child) ?? []; + arr.push(id); + parents.set(child, arr); + } + } + + // Walk upward from TARGET and print paths to entries + const entries = [...parents.keys()].filter((id) => { + const m = (this as any).getModuleInfo(id); + return m?.isEntry; + }); + + const seen = new Set(); + const stack: string[] = []; + const dfs = (node: string) => { + if (seen.has(node)) return; + seen.add(node); + stack.push(node); + const ps = parents.get(node) || []; + if (ps.length === 0) { + // hit a root (likely main entry or plugin virtual) + console.log("\nImporter chain:"); + stack + .slice() + .reverse() + .forEach((s) => console.log(" ↳", s)); + } else { + for (const p of ps) dfs(p); + } + stack.pop(); + }; + + if (!parents.has(target)) { + console.log(`[who-imports] TARGET not in MAIN graph: ${target}`); + } else { + dfs(target); + } + }, + async resolveId(id: any, importer: any) { + const r = await (this as any).resolve(id, importer, { skipSelf: true }); + if (r?.id === target) { + console.log(`[resolve] ${importer} -> ${id} -> ${r.id}`); + } + return null; + }, + }; +} + export default defineConfig({ main: { root: ".", diff --git a/emain/emain-tabview.ts b/emain/emain-tabview.ts index e45946aeda..0c7d651b4f 100644 --- a/emain/emain-tabview.ts +++ b/emain/emain-tabview.ts @@ -35,6 +35,7 @@ export function getWaveTabViewByWebContentsId(webContentsId: number): WaveTabVie export class WaveTabView extends WebContentsView { waveWindowId: string; // this will be set for any tabviews that are initialized. (unset for the hot spare) isActiveTab: boolean; + isWaveAIOpen: boolean; private _waveTabId: string; // always set, WaveTabViews are unique per tab lastUsedTs: number; // ts milliseconds createdTs: number; // ts milliseconds @@ -58,6 +59,7 @@ export class WaveTabView extends WebContentsView { }, }); this.createdTs = Date.now(); + this.isWaveAIOpen = false; this.savedInitOpts = null; this.initPromise = new Promise((resolve, _) => { this.initResolve = resolve; @@ -83,6 +85,9 @@ export class WaveTabView extends WebContentsView { removeWaveTabView(this.waveTabId); this.isDestroyed = true; }); + this.webContents.on("zoom-changed", (_event, zoomDirection) => { + this.webContents.send("zoom-factor-change", this.webContents.getZoomFactor()); + }); this.setBackgroundColor(computeBgColor(fullConfig)); } diff --git a/emain/emain-window.ts b/emain/emain-window.ts index d11acc12dc..c008e6d370 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -26,6 +26,9 @@ export type WindowOpts = { unamePlatform: string; }; +const MIN_WINDOW_WIDTH = 600; +const MIN_WINDOW_HEIGHT = 350; + export const waveWindowMap = new Map(); // waveWindowId -> WaveBrowserWindow // on blur we do not set this to null (but on destroy we do), so this tracks the *last* focused window @@ -123,6 +126,10 @@ export class WaveBrowserWindow extends BaseWindow { winHeight = 1200; } } + // Ensure dimensions meet minimum requirements + winWidth = Math.max(winWidth, MIN_WINDOW_WIDTH); + winHeight = Math.max(winHeight, MIN_WINDOW_HEIGHT); + let winBounds = { x: winPosX, y: winPosY, @@ -148,8 +155,8 @@ export class WaveBrowserWindow extends BaseWindow { y: winBounds.y, width: winBounds.width, height: winBounds.height, - minWidth: 400, - minHeight: 300, + minWidth: MIN_WINDOW_WIDTH, + minHeight: MIN_WINDOW_HEIGHT, icon: opts.unamePlatform == "linux" ? path.join(getElectronAppBasePath(), "public/logos/wave-logo-dark.png") @@ -667,6 +674,13 @@ ipcMain.on("create-tab", async (event, opts) => { return null; }); +ipcMain.on("set-waveai-open", (event, isOpen: boolean) => { + const tabView = getWaveTabViewByWebContentsId(event.sender.id); + if (tabView) { + tabView.isWaveAIOpen = isOpen; + } +}); + ipcMain.on("close-tab", async (event, workspaceId, tabId) => { const ww = getWaveWindowByWorkspaceId(workspaceId); if (ww == null) { diff --git a/emain/emain-wsh.ts b/emain/emain-wsh.ts index fe84e030e1..ecf34bc406 100644 --- a/emain/emain-wsh.ts +++ b/emain/emain-wsh.ts @@ -2,10 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { WindowService } from "@/app/store/services"; +import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; import { RpcApi } from "@/app/store/wshclientapi"; import { Notification } from "electron"; import { getResolvedUpdateChannel } from "emain/updater"; -import { RpcResponseHelper, WshClient } from "../frontend/app/store/wshclient"; import { getWebContentsByBlockId, webGetSelector } from "./emain-web"; import { createBrowserWindow, getWaveWindowById, getWaveWindowByWorkspaceId } from "./emain-window"; import { unamePlatform } from "./platform"; diff --git a/emain/emain.ts b/emain/emain.ts index 1748d352b9..995b355d85 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -12,7 +12,7 @@ import { PNG } from "pngjs"; import { sprintf } from "sprintf-js"; import { Readable } from "stream"; import * as services from "../frontend/app/store/services"; -import { initElectronWshrpc, shutdownWshrpc } from "../frontend/app/store/wshrpcutil"; +import { initElectronWshrpc, shutdownWshrpc } from "../frontend/app/store/wshrpcutil-base"; import { getWebServerEndpoint } from "../frontend/util/endpoints"; import * as keyutil from "../frontend/util/keyutil"; import { fireAndForget, sleep } from "../frontend/util/util"; @@ -25,7 +25,7 @@ import { setForceQuit, setGlobalIsQuitting, setGlobalIsStarting, - setWasActive, +setWasActive, setWasInFg, } from "./emain-activity"; import { ensureHotSpareTab, getWaveTabViewByWebContentsId, setMaxTabCacheSize } from "./emain-tabview"; @@ -280,6 +280,10 @@ electron.ipcMain.on("get-about-modal-details", (event) => { event.returnValue = getWaveVersion() as AboutModalDetails; }); +electron.ipcMain.on("get-zoom-factor", (event) => { + event.returnValue = event.sender.getZoomFactor(); +}); + const hasBeforeInputRegisteredMap = new Map(); electron.ipcMain.on("webview-focus", (event: Electron.IpcMainEvent, focusedId: number) => { @@ -508,6 +512,10 @@ function logActiveState() { fireAndForget(async () => { const astate = getActivityState(); const activity: ActivityUpdate = { openminutes: 1 }; + const ww = focusedWaveWindow; + const activeTabView = ww?.activeTabView; + const isWaveAIOpen = activeTabView?.isWaveAIOpen ?? false; + if (astate.wasInFg) { activity.fgminutes = 1; } @@ -515,25 +523,33 @@ function logActiveState() { activity.activeminutes = 1; } activity.displays = getActivityDisplays(); + + const props: TEventProps = { + "activity:activeminutes": activity.activeminutes, + "activity:fgminutes": activity.fgminutes, + "activity:openminutes": activity.openminutes, + }; + + if (astate.wasActive && isWaveAIOpen) { + props["activity:waveaiactiveminutes"] = 1; + } + if (astate.wasInFg && isWaveAIOpen) { + props["activity:waveaifgminutes"] = 1; + } + try { await RpcApi.ActivityCommand(ElectronWshClient, activity, { noresponse: true }); await RpcApi.RecordTEventCommand( ElectronWshClient, { event: "app:activity", - props: { - "activity:activeminutes": activity.activeminutes, - "activity:fgminutes": activity.fgminutes, - "activity:openminutes": activity.openminutes, - }, + props, }, { noresponse: true } ); } catch (e) { console.log("error logging active state", e); } finally { - // for next iteration - const ww = focusedWaveWindow; setWasInFg(ww?.isFocused() ?? false); setWasActive(false); } diff --git a/emain/menu.ts b/emain/menu.ts index ed10cb9ab7..5453c41ee2 100644 --- a/emain/menu.ts +++ b/emain/menu.ts @@ -215,7 +215,11 @@ async function getAppMenu( label: "Reset Zoom", accelerator: "CommandOrControl+0", click: (_, window) => { - getWindowWebContents(window ?? ww)?.setZoomFactor(1); + const wc = getWindowWebContents(window ?? ww); + if (wc) { + wc.setZoomFactor(1); + wc.send("zoom-factor-change", 1); + } }, }, { @@ -226,7 +230,9 @@ async function getAppMenu( if (wc == null) { return; } - wc.setZoomFactor(Math.min(5, wc.getZoomFactor() + 0.2)); + const newZoom = Math.min(5, wc.getZoomFactor() + 0.2); + wc.setZoomFactor(newZoom); + wc.send("zoom-factor-change", newZoom); }, }, { @@ -237,7 +243,9 @@ async function getAppMenu( if (wc == null) { return; } - wc.setZoomFactor(Math.min(5, wc.getZoomFactor() + 0.2)); + const newZoom = Math.min(5, wc.getZoomFactor() + 0.2); + wc.setZoomFactor(newZoom); + wc.send("zoom-factor-change", newZoom); }, visible: false, acceleratorWorksWhenHidden: true, @@ -250,7 +258,9 @@ async function getAppMenu( if (wc == null) { return; } - wc.setZoomFactor(Math.max(0.2, wc.getZoomFactor() - 0.2)); + const newZoom = Math.max(0.2, wc.getZoomFactor() - 0.2); + wc.setZoomFactor(newZoom); + wc.send("zoom-factor-change", newZoom); }, }, { @@ -261,7 +271,9 @@ async function getAppMenu( if (wc == null) { return; } - wc.setZoomFactor(Math.max(0.2, wc.getZoomFactor() - 0.2)); + const newZoom = Math.max(0.2, wc.getZoomFactor() - 0.2); + wc.setZoomFactor(newZoom); + wc.send("zoom-factor-change", newZoom); }, visible: false, acceleratorWorksWhenHidden: true, diff --git a/emain/preload.ts b/emain/preload.ts index 0c0633fdfe..812279a481 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -15,6 +15,7 @@ contextBridge.exposeInMainWorld("api", { getAboutModalDetails: () => ipcRenderer.sendSync("get-about-modal-details"), getDocsiteUrl: () => ipcRenderer.sendSync("get-docsite-url"), getWebviewPreload: () => ipcRenderer.sendSync("get-webview-preload"), + getZoomFactor: () => ipcRenderer.sendSync("get-zoom-factor"), openNewWindow: () => ipcRenderer.send("open-new-window"), showContextMenu: (workspaceId, menu) => ipcRenderer.send("contextmenu-show", workspaceId, menu), onContextMenuClick: (callback) => ipcRenderer.on("contextmenu-click", (_event, id) => callback(id)), @@ -29,6 +30,8 @@ contextBridge.exposeInMainWorld("api", { getEnv: (varName) => ipcRenderer.sendSync("get-env", varName), onFullScreenChange: (callback) => ipcRenderer.on("fullscreen-change", (_event, isFullScreen) => callback(isFullScreen)), + onZoomFactorChange: (callback) => + ipcRenderer.on("zoom-factor-change", (_event, zoomFactor) => callback(zoomFactor)), onUpdaterStatusChange: (callback) => ipcRenderer.on("app-update-status", (_event, status) => callback(status)), getUpdaterStatus: () => ipcRenderer.sendSync("get-app-update-status"), getUpdaterChannel: () => ipcRenderer.sendSync("get-updater-channel"), @@ -53,6 +56,7 @@ contextBridge.exposeInMainWorld("api", { openNativePath: (filePath: string) => ipcRenderer.send("open-native-path", filePath), captureScreenshot: (rect: Rectangle) => ipcRenderer.invoke("capture-screenshot", rect), setKeyboardChordMode: () => ipcRenderer.send("set-keyboard-chord-mode"), + setWaveAIOpen: (isOpen: boolean) => ipcRenderer.send("set-waveai-open", isOpen), }); // Custom event for "new-window" diff --git a/frontend/app/aipanel/ai-utils.ts b/frontend/app/aipanel/ai-utils.ts new file mode 100644 index 0000000000..cdaaef9507 --- /dev/null +++ b/frontend/app/aipanel/ai-utils.ts @@ -0,0 +1,341 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +export const isAcceptableFile = (file: File): boolean => { + const acceptableTypes = [ + // Images + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/svg+xml', + // PDFs + 'application/pdf', + // Text files + 'text/plain', + 'text/markdown', + 'text/html', + 'text/css', + 'text/javascript', + 'text/typescript', + // Application types for code files + 'application/javascript', + 'application/typescript', + 'application/json', + 'application/xml', + ]; + + if (acceptableTypes.includes(file.type)) { + return true; + } + + // Check file extensions for files without proper MIME types + const extension = file.name.split('.').pop()?.toLowerCase(); + const acceptableExtensions = [ + 'txt', 'md', 'js', 'jsx', 'ts', 'tsx', 'go', 'py', 'java', 'c', 'cpp', 'h', 'hpp', + 'html', 'css', 'scss', 'sass', 'json', 'xml', 'yaml', 'yml', 'sh', 'bat', 'sql', + 'php', 'rb', 'rs', 'swift', 'kt', 'cs', 'vb', 'r', 'scala', 'clj', 'ex', 'exs' + ]; + + return extension ? acceptableExtensions.includes(extension) : false; +}; + +export const getFileIcon = (fileName: string, fileType: string): string => { + if (fileType.startsWith('image/')) { + return 'fa-image'; + } + + if (fileType === 'application/pdf') { + return 'fa-file-pdf'; + } + + // Check file extensions for code files + const ext = fileName.split('.').pop()?.toLowerCase(); + switch (ext) { + case 'js': + case 'jsx': + case 'ts': + case 'tsx': + return 'fa-file-code'; + case 'go': + return 'fa-file-code'; + case 'py': + return 'fa-file-code'; + case 'java': + case 'c': + case 'cpp': + case 'h': + case 'hpp': + return 'fa-file-code'; + case 'html': + case 'css': + case 'scss': + case 'sass': + return 'fa-file-code'; + case 'json': + case 'xml': + case 'yaml': + case 'yml': + return 'fa-file-code'; + case 'md': + case 'txt': + return 'fa-file-text'; + default: + return 'fa-file'; + } +}; + +export const formatFileSize = (bytes: number): string => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; +}; + +// Normalize MIME type for AI processing +export const normalizeMimeType = (file: File): string => { + const fileType = file.type; + + // Images keep their real mimetype + if (fileType.startsWith('image/')) { + return fileType; + } + + // PDFs keep their mimetype + if (fileType === 'application/pdf') { + return fileType; + } + + // Everything else (code files, markdown, text, etc.) becomes text/plain + return 'text/plain'; +}; + +// Helper function to read file as base64 for AIMessage +export const readFileAsBase64 = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + // Remove data URL prefix to get just base64 + const base64 = result.split(',')[1]; + resolve(base64); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); +}; + +// Helper function to create data URL for UIMessage +export const createDataUrl = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); +}; + +export interface FileSizeError { + fileName: string; + fileSize: number; + maxSize: number; + fileType: 'text' | 'pdf' | 'image'; +} + +export const validateFileSize = (file: File): FileSizeError | null => { + const TEXT_FILE_LIMIT = 200 * 1024; // 200KB + const PDF_LIMIT = 5 * 1024 * 1024; // 5MB + const IMAGE_LIMIT = 10 * 1024 * 1024; // 10MB + + if (file.type.startsWith('image/')) { + if (file.size > IMAGE_LIMIT) { + return { + fileName: file.name, + fileSize: file.size, + maxSize: IMAGE_LIMIT, + fileType: 'image' + }; + } + } else if (file.type === 'application/pdf') { + if (file.size > PDF_LIMIT) { + return { + fileName: file.name, + fileSize: file.size, + maxSize: PDF_LIMIT, + fileType: 'pdf' + }; + } + } else { + if (file.size > TEXT_FILE_LIMIT) { + return { + fileName: file.name, + fileSize: file.size, + maxSize: TEXT_FILE_LIMIT, + fileType: 'text' + }; + } + } + + return null; +}; + +export const formatFileSizeError = (error: FileSizeError): string => { + const typeLabel = error.fileType === 'image' ? 'Image' : error.fileType === 'pdf' ? 'PDF' : 'Text file'; + return `${typeLabel} "${error.fileName}" is too large (${formatFileSize(error.fileSize)}). Maximum size is ${formatFileSize(error.maxSize)}.`; +}; + +/** + * Resize an image to have a maximum edge of 4096px and convert to WebP format + * Returns the optimized image if it's smaller than the original, otherwise returns the original + */ +export const resizeImage = async (file: File): Promise => { + // Only process actual image files (not SVG) + if (!file.type.startsWith('image/') || file.type === 'image/svg+xml') { + return file; + } + + const MAX_EDGE = 4096; + const WEBP_QUALITY = 0.8; + + return new Promise((resolve) => { + const img = new Image(); + const url = URL.createObjectURL(file); + + img.onload = async () => { + URL.revokeObjectURL(url); + + let { width, height } = img; + + // Check if resizing is needed + if (width <= MAX_EDGE && height <= MAX_EDGE) { + // Image is already small enough, just try WebP conversion + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + ctx?.drawImage(img, 0, 0); + + canvas.toBlob( + (blob) => { + if (blob && blob.size < file.size) { + const webpFile = new File([blob], file.name.replace(/\.[^.]+$/, '.webp'), { + type: 'image/webp', + }); + console.log(`Image resized (no dimension change): ${file.name} - Original: ${formatFileSize(file.size)}, WebP: ${formatFileSize(blob.size)}`); + resolve(webpFile); + } else { + console.log(`Image kept original (WebP not smaller): ${file.name} - ${formatFileSize(file.size)}`); + resolve(file); + } + }, + 'image/webp', + WEBP_QUALITY + ); + return; + } + + // Calculate new dimensions while maintaining aspect ratio + if (width > height) { + height = Math.round((height * MAX_EDGE) / width); + width = MAX_EDGE; + } else { + width = Math.round((width * MAX_EDGE) / height); + height = MAX_EDGE; + } + + // Create canvas and resize + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + ctx?.drawImage(img, 0, 0, width, height); + + // Convert to WebP + canvas.toBlob( + (blob) => { + if (blob && blob.size < file.size) { + const webpFile = new File([blob], file.name.replace(/\.[^.]+$/, '.webp'), { + type: 'image/webp', + }); + console.log(`Image resized: ${file.name} (${img.width}x${img.height} → ${width}x${height}) - Original: ${formatFileSize(file.size)}, WebP: ${formatFileSize(blob.size)}`); + resolve(webpFile); + } else { + console.log(`Image kept original (WebP not smaller): ${file.name} (${img.width}x${img.height} → ${width}x${height}) - ${formatFileSize(file.size)}`); + resolve(file); + } + }, + 'image/webp', + WEBP_QUALITY + ); + }; + + img.onerror = () => { + URL.revokeObjectURL(url); + resolve(file); + }; + + img.src = url; + }); +}; + +/** + * Create a 128x128 preview data URL for an image file + */ +export const createImagePreview = async (file: File): Promise => { + if (!file.type.startsWith('image/') || file.type === 'image/svg+xml') { + return null; + } + + const PREVIEW_SIZE = 128; + const WEBP_QUALITY = 0.8; + + return new Promise((resolve) => { + const img = new Image(); + const url = URL.createObjectURL(file); + + img.onload = () => { + URL.revokeObjectURL(url); + + let { width, height } = img; + + if (width > height) { + height = Math.round((height * PREVIEW_SIZE) / width); + width = PREVIEW_SIZE; + } else { + width = Math.round((width * PREVIEW_SIZE) / height); + height = PREVIEW_SIZE; + } + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + ctx?.drawImage(img, 0, 0, width, height); + + canvas.toBlob( + (blob) => { + if (blob) { + const reader = new FileReader(); + reader.onloadend = () => { + resolve(reader.result as string); + }; + reader.readAsDataURL(blob); + } else { + resolve(null); + } + }, + 'image/webp', + WEBP_QUALITY + ); + }; + + img.onerror = () => { + URL.revokeObjectURL(url); + resolve(null); + }; + + img.src = url; + }); +}; \ No newline at end of file diff --git a/frontend/app/aipanel/aidroppedfiles.tsx b/frontend/app/aipanel/aidroppedfiles.tsx new file mode 100644 index 0000000000..e87038d91c --- /dev/null +++ b/frontend/app/aipanel/aidroppedfiles.tsx @@ -0,0 +1,62 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { cn } from "@/util/util"; +import { useAtomValue } from "jotai"; +import { memo } from "react"; +import { formatFileSize, getFileIcon } from "./ai-utils"; +import type { WaveAIModel } from "./waveai-model"; + +interface AIDroppedFilesProps { + model: WaveAIModel; +} + +export const AIDroppedFiles = memo(({ model }: AIDroppedFilesProps) => { + const droppedFiles = useAtomValue(model.droppedFiles); + + if (droppedFiles.length === 0) { + return null; + } + + return ( +
+
+ {droppedFiles.map((file) => ( +
+ + +
+ {file.previewUrl ? ( +
+ {file.name} +
+ ) : ( +
+ +
+ )} + +
+ {file.name} +
+
{formatFileSize(file.size)}
+
+
+ ))} +
+
+ ); +}); + +AIDroppedFiles.displayName = "AIDroppedFiles"; diff --git a/frontend/app/aipanel/aimessage.tsx b/frontend/app/aipanel/aimessage.tsx new file mode 100644 index 0000000000..f241bd7c0a --- /dev/null +++ b/frontend/app/aipanel/aimessage.tsx @@ -0,0 +1,172 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { cn } from "@/util/util"; +import { memo } from "react"; +import { Streamdown } from "streamdown"; +import { getFileIcon } from "./ai-utils"; +import { WaveUIMessage, WaveUIMessagePart } from "./aitypes"; + +const AIThinking = memo(() => ( +
+
+ + + +
+ AI is thinking... +
+)); + +AIThinking.displayName = "AIThinking"; + +interface UserMessageFilesProps { + fileParts: Array; +} + +const UserMessageFiles = memo(({ fileParts }: UserMessageFilesProps) => { + if (fileParts.length === 0) return null; + + return ( +
+
+ {fileParts.map((file, index) => ( +
+
+
+ {file.data?.previewurl ? ( + {file.data?.filename + ) : ( + + )} +
+
+ {file.data?.filename || "File"} +
+
+
+ ))} +
+
+ ); +}); + +UserMessageFiles.displayName = "UserMessageFiles"; + +interface AIMessagePartProps { + part: WaveUIMessagePart; + role: string; + isStreaming: boolean; +} + +const AIMessagePart = memo(({ part, role, isStreaming }: AIMessagePartProps) => { + if (part.type === "text") { + const content = part.text ?? ""; + + if (role === "user") { + return
{content}
; + } else { + return ( + + {content} + + ); + } + } + + if (part.type.startsWith("tool-") && "state" in part && part.state === "input-available") { + const toolName = part.type.substring(5); // Remove "tool-" prefix + return
Calling tool {toolName}
; + } + + return null; +}); + +AIMessagePart.displayName = "AIMessagePart"; + +interface AIMessageProps { + message: WaveUIMessage; + isStreaming: boolean; +} + +const isDisplayPart = (part: WaveUIMessagePart): boolean => { + return ( + part.type === "text" || (part.type.startsWith("tool-") && "state" in part && part.state === "input-available") + ); +}; + +export const AIMessage = memo(({ message, isStreaming }: AIMessageProps) => { + const parts = message.parts || []; + const displayParts = parts.filter(isDisplayPart); + const fileParts = parts.filter( + (part): part is WaveUIMessagePart & { type: "data-userfile" } => part.type === "data-userfile" + ); + const hasContent = displayParts.length > 0 && displayParts.some((part) => + (part.type === "text" && part.text) || part.type.startsWith("tool-") + ); + + const showThinkingOnly = !hasContent && isStreaming && message.role === "assistant"; + const showThinkingInline = hasContent && isStreaming && message.role === "assistant"; + + return ( +
+
+ {showThinkingOnly ? ( + + ) : !hasContent && !isStreaming ? ( +
(no text content)
+ ) : ( + <> + {displayParts.map((part, index: number) => ( +
0 && "mt-2")}> + +
+ ))} + {showThinkingInline && ( +
+ +
+ )} + + )} + + {message.role === "user" && } +
+
+ ); +}); + +AIMessage.displayName = "AIMessage"; diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx new file mode 100644 index 0000000000..a5d30d8440 --- /dev/null +++ b/frontend/app/aipanel/aipanel.tsx @@ -0,0 +1,382 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { WaveUIMessagePart } from "@/app/aipanel/aitypes"; +import { waveAIHasSelection } from "@/app/aipanel/waveai-focus-utils"; +import { ErrorBoundary } from "@/app/element/errorboundary"; +import { focusManager } from "@/app/store/focusManager"; +import { atoms, getSettingsKeyAtom } from "@/app/store/global"; +import { globalStore } from "@/app/store/jotaiStore"; +import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; +import { getWebServerEndpoint } from "@/util/endpoints"; +import { checkKeyPressed, keydownWrapper } from "@/util/keyutil"; +import { cn } from "@/util/util"; +import { useChat } from "@ai-sdk/react"; +import { DefaultChatTransport } from "ai"; +import * as jotai from "jotai"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { createDataUrl, formatFileSizeError, isAcceptableFile, normalizeMimeType, validateFileSize } from "./ai-utils"; +import { AIDroppedFiles } from "./aidroppedfiles"; +import { AIPanelHeader } from "./aipanelheader"; +import { AIPanelInput, type AIPanelInputRef } from "./aipanelinput"; +import { AIPanelMessages } from "./aipanelmessages"; +import { AIRateLimitStrip } from "./airatelimitstrip"; +import { TelemetryRequiredMessage } from "./telemetryrequired"; +import { WaveAIModel, type DroppedFile } from "./waveai-model"; + +interface AIPanelProps { + className?: string; + onClose?: () => void; +} + +const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => { + const [input, setInput] = useState(""); + const [isDragOver, setIsDragOver] = useState(false); + const [isLoadingChat, setIsLoadingChat] = useState(true); + const model = WaveAIModel.getInstance(); + const errorMessage = jotai.useAtomValue(model.errorMessage); + const realMessageRef = useRef(null); + const inputRef = useRef(null); + const isLayoutMode = jotai.useAtomValue(atoms.controlShiftDelayAtom); + const showOverlayBlockNums = jotai.useAtomValue(getSettingsKeyAtom("app:showoverlayblocknums")) ?? true; + const focusType = jotai.useAtomValue(focusManager.focusType); + const isFocused = focusType === "waveai"; + const telemetryEnabled = jotai.useAtomValue(getSettingsKeyAtom("telemetry:enabled")) ?? false; + const isPanelVisible = jotai.useAtomValue(WorkspaceLayoutModel.getInstance().panelVisibleAtom); + + const { messages, sendMessage, status, setMessages, error } = useChat({ + transport: new DefaultChatTransport({ + api: `${getWebServerEndpoint()}/api/post-chat-message`, + prepareSendMessagesRequest: (opts) => { + const msg = realMessageRef.current; + realMessageRef.current = null; + return { + body: { + msg, + chatid: globalStore.get(model.chatId), + widgetaccess: globalStore.get(model.widgetAccessAtom), + tabid: globalStore.get(atoms.staticTabId), + }, + }; + }, + }), + onError: (error) => { + console.error("AI Chat error:", error); + model.setError(error.message || "An error occurred"); + setMessages((prevMessages) => { + if (prevMessages.length > 0 && prevMessages[prevMessages.length - 1].role === "user") { + return prevMessages.slice(0, -1); + } + return prevMessages; + }); + }, + }); + + // console.log("AICHAT messages", messages); + + const clearChat = () => { + model.clearChat(); + setMessages([]); + }; + + const handleKeyDown = (waveEvent: WaveKeyboardEvent): boolean => { + if (checkKeyPressed(waveEvent, "Cmd:k")) { + clearChat(); + return true; + } + return false; + }; + + useEffect(() => { + const keyHandler = keydownWrapper(handleKeyDown); + document.addEventListener("keydown", keyHandler); + return () => { + document.removeEventListener("keydown", keyHandler); + }; + }, []); + + useEffect(() => { + model.registerInputRef(inputRef); + }, [model]); + + useEffect(() => { + const loadMessages = async () => { + const messages = await model.loadChat(); + setMessages(messages as any); + setIsLoadingChat(false); + }; + loadMessages(); + }, [model, setMessages]); + + useEffect(() => { + model.ensureRateLimitSet(); + }, [model]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!input.trim() || status !== "ready" || isLoadingChat) return; + + if (input.trim() === "/clear" || input.trim() === "/new") { + clearChat(); + setInput(""); + return; + } + + model.clearError(); + + const droppedFiles = globalStore.get(model.droppedFiles) as DroppedFile[]; + + // Prepare AI message parts (for backend) + const aiMessageParts: AIMessagePart[] = [{ type: "text", text: input.trim() }]; + + // Prepare UI message parts (for frontend display) + const uiMessageParts: WaveUIMessagePart[] = []; + + if (input.trim()) { + uiMessageParts.push({ type: "text", text: input.trim() }); + } + + // Process files + for (const droppedFile of droppedFiles) { + const normalizedMimeType = normalizeMimeType(droppedFile.file); + const dataUrl = await createDataUrl(droppedFile.file); + + // For AI message (backend) - use data URL + aiMessageParts.push({ + type: "file", + filename: droppedFile.name, + mimetype: normalizedMimeType, + url: dataUrl, + size: droppedFile.file.size, + previewurl: droppedFile.previewUrl, + }); + + uiMessageParts.push({ + type: "data-userfile", + data: { + filename: droppedFile.name, + mimetype: normalizedMimeType, + size: droppedFile.file.size, + previewurl: droppedFile.previewUrl, + }, + }); + } + + // realMessage uses AIMessageParts + const realMessage: AIMessage = { + messageid: crypto.randomUUID(), + parts: aiMessageParts, + }; + realMessageRef.current = realMessage; + + // sendMessage uses UIMessageParts + sendMessage({ parts: uiMessageParts }); + + setInput(""); + model.clearFiles(); + + // Keep focus on input after submission + setTimeout(() => { + console.log("trying to reset focus", inputRef.current); + inputRef.current?.focus(); + }, 100); + }; + + const hasFilesDragged = (dataTransfer: DataTransfer): boolean => { + // Check if the drag operation contains files by looking at the types + return dataTransfer.types.includes("Files"); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const hasFiles = hasFilesDragged(e.dataTransfer); + if (hasFiles && !isDragOver) { + setIsDragOver(true); + } else if (!hasFiles && isDragOver) { + setIsDragOver(false); + } + }; + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (hasFilesDragged(e.dataTransfer)) { + setIsDragOver(true); + } + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Only set drag over to false if we're actually leaving the drop zone + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const x = e.clientX; + const y = e.clientY; + + if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) { + setIsDragOver(false); + } + }; + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + + const files = Array.from(e.dataTransfer.files); + const acceptableFiles = files.filter(isAcceptableFile); + + for (const file of acceptableFiles) { + const sizeError = validateFileSize(file); + if (sizeError) { + model.setError(formatFileSizeError(sizeError)); + return; + } + await model.addFile(file); + } + + if (acceptableFiles.length < files.length) { + const rejectedCount = files.length - acceptableFiles.length; + const rejectedFiles = files.filter((f) => !isAcceptableFile(f)); + const fileNames = rejectedFiles.map((f) => f.name).join(", "); + model.setError( + `${rejectedCount} file${rejectedCount > 1 ? "s" : ""} rejected (unsupported type): ${fileNames}. Supported: images, PDFs, and text/code files.` + ); + } + }; + + const handleFocusCapture = useCallback((event: React.FocusEvent) => { + // console.log("Wave AI focus capture", getElemAsStr(event.target)); + focusManager.requestWaveAIFocus(); + }, []); + + const handleClick = (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + const isInteractive = target.closest('button, a, input, textarea, select, [role="button"], [tabindex]'); + + if (isInteractive) { + return; + } + + const hasSelection = waveAIHasSelection(); + if (hasSelection) { + focusManager.requestWaveAIFocus(); + return; + } + + setTimeout(() => { + if (!waveAIHasSelection()) { + model.focusInput(); + } + }, 0); + }; + + const showBlockMask = isLayoutMode && showOverlayBlockNums; + + return ( +
+ {isDragOver && ( +
+
+ +
Drop files here
+
Images, PDFs, and text/code files supported
+
+
+ )} + {showBlockMask && ( +
+
+
0
+
+
+ )} + + + +
+ {!telemetryEnabled ? ( + + ) : ( + <> + + {errorMessage && ( +
+ +
{errorMessage}
+
+ )} + + + + )} +
+
+ ); +}); + +AIPanelComponentInner.displayName = "AIPanelInner"; + +const AIPanelComponent = ({ className, onClose }: AIPanelProps) => { + return ( + + + + ); +}; + +AIPanelComponent.displayName = "AIPanel"; + +export { AIPanelComponent as AIPanel }; diff --git a/frontend/app/aipanel/aipanelheader.tsx b/frontend/app/aipanel/aipanelheader.tsx new file mode 100644 index 0000000000..6d2d63c557 --- /dev/null +++ b/frontend/app/aipanel/aipanelheader.tsx @@ -0,0 +1,111 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { ContextMenuModel } from "@/app/store/contextmenu"; +import { useAtom, useAtomValue } from "jotai"; +import { memo } from "react"; +import { WaveAIModel } from "./waveai-model"; + +interface AIPanelHeaderProps { + onClose?: () => void; + model: WaveAIModel; + onClearChat?: () => void; +} + +export const AIPanelHeader = memo(({ onClose, model, onClearChat }: AIPanelHeaderProps) => { + const widgetAccess = useAtomValue(model.widgetAccessAtom); + const currentModel = useAtomValue(model.modelAtom); + + const modelOptions = [ + { value: "gpt-5", label: "GPT-5" }, + { value: "gpt-5-mini", label: "GPT-5 Mini" }, + { value: "claude-sonnet-4-5", label: "Claude Sonnet 4.5" }, + ]; + + const getModelLabel = (modelValue: string): string => { + const option = modelOptions.find((opt) => opt.value === modelValue); + return option?.label ?? modelValue; + }; + + const handleKebabClick = (e: React.MouseEvent) => { + const menu: ContextMenuItem[] = [ + { + label: "New Chat", + click: () => { + onClearChat?.(); + }, + }, + { type: "separator" }, + { + label: `Model (${getModelLabel(currentModel)})`, + submenu: modelOptions.map((option) => ({ + label: option.label, + type: currentModel === option.value ? "checkbox" : undefined, + checked: currentModel === option.value, + click: () => { + model.setModel(option.value); + }, + })), + }, + { type: "separator" }, + { + label: "Hide Wave AI", + click: () => { + onClose?.(); + }, + }, + ]; + ContextMenuModel.showContextMenu(menu, e); + }; + + return ( +
+

+ + Wave AI +

+ +
+
+ Context + Widget Context + +
+ + +
+
+ ); +}); + +AIPanelHeader.displayName = "AIPanelHeader"; diff --git a/frontend/app/aipanel/aipanelinput.tsx b/frontend/app/aipanel/aipanelinput.tsx new file mode 100644 index 0000000000..23bcac3238 --- /dev/null +++ b/frontend/app/aipanel/aipanelinput.tsx @@ -0,0 +1,175 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { formatFileSizeError, isAcceptableFile, validateFileSize } from "@/app/aipanel/ai-utils"; +import { waveAIHasFocusWithin } from "@/app/aipanel/waveai-focus-utils"; +import { type WaveAIModel } from "@/app/aipanel/waveai-model"; +import { atoms, globalStore } from "@/app/store/global"; +import { focusManager } from "@/app/store/focusManager"; +import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; +import { cn } from "@/util/util"; +import { useAtomValue } from "jotai"; +import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef } from "react"; + +interface AIPanelInputProps { + input: string; + setInput: (value: string) => void; + onSubmit: (e: React.FormEvent) => void; + status: string; + model: WaveAIModel; +} + +export interface AIPanelInputRef { + focus: () => void; + resize: () => void; +} + +export const AIPanelInput = memo( + forwardRef(({ input, setInput, onSubmit, status, model }, ref) => { + const focusType = useAtomValue(focusManager.focusType); + const isFocused = focusType === "waveai"; + const textareaRef = useRef(null); + const fileInputRef = useRef(null); + const isPanelOpen = useAtomValue(WorkspaceLayoutModel.getInstance().panelVisibleAtom); + + const resizeTextarea = useCallback(() => { + const textarea = textareaRef.current; + if (!textarea) return; + + textarea.style.height = "auto"; + const scrollHeight = textarea.scrollHeight; + const maxHeight = 6 * 24; + textarea.style.height = `${Math.min(scrollHeight, maxHeight)}px`; + }, []); + + useImperativeHandle(ref, () => ({ + focus: () => { + textareaRef.current?.focus(); + }, + resize: resizeTextarea, + })); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + onSubmit(e as any); + } + }; + + const handleFocus = useCallback(() => { + focusManager.requestWaveAIFocus(); + }, []); + + const handleBlur = useCallback((e: React.FocusEvent) => { + if (e.relatedTarget === null) { + return; + } + + if (waveAIHasFocusWithin()) { + return; + } + + focusManager.requestNodeFocus(); + }, []); + + useEffect(() => { + resizeTextarea(); + }, [input, resizeTextarea]); + + useEffect(() => { + if (isPanelOpen) { + resizeTextarea(); + } + }, [isPanelOpen, resizeTextarea]); + + const handleUploadClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + const acceptableFiles = files.filter(isAcceptableFile); + + for (const file of acceptableFiles) { + const sizeError = validateFileSize(file); + if (sizeError) { + model.setError(formatFileSizeError(sizeError)); + if (e.target) { + e.target.value = ""; + } + return; + } + await model.addFile(file); + } + + if (acceptableFiles.length < files.length) { + console.warn( + `${files.length - acceptableFiles.length} files were rejected due to unsupported file types` + ); + } + + if (e.target) { + e.target.value = ""; + } + }; + + return ( +
+ +
+
+