From 7be8c338dbca54302e8e331bfe8f4d31d70b8653 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Fri, 27 Feb 2026 23:16:31 -0800 Subject: [PATCH 01/41] Add Weather Dashboard V2 planning prompt Detailed prompt for planning and implementing a V2 weather dashboard that demonstrates Hoist's persistence layer as a declarative DSL for LLM-driven dashboard generation. Covers objectives, widget design, inter-widget wiring, JSON/LLM harnesses, deployment strategy, and output requirements. Co-Authored-By: Claude Opus 4.6 --- docs/planning/weather-v2/PROMPT.md | 390 +++++++++++++++++++++++++++++ 1 file changed, 390 insertions(+) create mode 100644 docs/planning/weather-v2/PROMPT.md diff --git a/docs/planning/weather-v2/PROMPT.md b/docs/planning/weather-v2/PROMPT.md new file mode 100644 index 000000000..d6cff8c1b --- /dev/null +++ b/docs/planning/weather-v2/PROMPT.md @@ -0,0 +1,390 @@ +# Weather Dashboard V2 (Toolbox Demo) — Hoist-native, DSL-grade, LLM-driven + +## Context + +### What V1 is + +The Toolbox project already has a V1 "Weather Dashboard" example app, entirely AI-generated. It lives at: + +**Client** — `client-app/src/examples/weather/` +- `AppModel.ts` / `AppComponent.ts` — top-level app shell with city selector dropdown in the app bar. +- `WeatherDashModel.ts` — central model: holds `selectedCity`, fetches data, owns a `DashCanvasModel`. +- `Types.ts` — typed response interfaces (`CurrentWeatherResponse`, `ForecastResponse`, `ForecastItem`). +- `Icons.ts`, `Weather.scss` — icons and styles. +- `widgets/` — six widget files, each a model+component pair: + - `CurrentConditionsWidget` — Highcharts solid gauge + details card. + - `TempForecastWidget` — spline chart (temp + feels-like over 5 days). + - `PrecipForecastWidget` — dual-axis column chart (probability + volume). + - `WindForecastWidget` — spline chart (speed + gusts). + - `HumidityPressureWidget` — dual-axis spline chart. + - `ConditionsSummaryWidget` — grid with daily highs/lows, conditions, humidity, wind. + +**Server** — Grails backend: +- `WeatherController.groovy` — two endpoints: `weather/current`, `weather/forecast`. `@AccessAll`. +- `WeatherService.groovy` — calls OpenWeatherMap API (`/data/2.5/weather`, `/data/2.5/forecast`), caches results (10 min current, 30 min forecast). API key from Hoist `Config`. + +**Entry point** — `client-app/src/apps/weather.ts`, registered in `ExamplesTabModel.ts`. + +V1 uses `DashCanvasModel` for a draggable/resizable widget grid and `ViewManagerModel` for save/restore of named layouts. All widgets consume a single `selectedCity` from the app bar — no inter-widget wiring, no user-configurable widget settings, no JSON-level spec editing. + +### What V2 is + +V2 is not about faster data or realtime. It is a demo-quality proof that **Hoist dashboards + persistable state can function as a declarative DSL** that an LLM can generate safely and correctly, producing JSON that hydrates into a real, interactive dashboard at runtime. + +The enterprise-dashboard story: "business user describes what they want; agent produces schema-valid dashboard spec; Hoist renders it; the dashboard remains fully persistable, shareable, and editable." + +### V1/V2 coexistence + +V2 is built **alongside** V1, not on top of it. V1 stays unchanged so we can do an A/B comparison: "We started here, then we did this huge upgrade." + +- V2 gets its own directory: `client-app/src/examples/weatherv2/` (or similar), its own app entry point, and its own nav registration in `ExamplesTabModel.ts`. +- **Copy V1 client code as a starting point.** No need to deduplicate widgets or models across V1 and V2 — they are independent example apps. +- **Exception: the weather data surface.** `WeatherService.groovy` and `WeatherController.groovy` should be shared (or extracted to a common location) so both apps hit the same server-side endpoints. If V2 needs richer data (e.g., hourly breakdowns, derived metrics), extend the shared service — V1 can simply ignore the new fields. + +--- + +## Primary objectives (in priority order) + +1. **Hoist fidelity.** The code must read like a real Hoist app written by Hoist maintainers. Conform to established patterns, naming conventions, model/component conventions, state persistence patterns, and style guidelines. Avoid "off-reservation" architecture. + + Before writing any code, use the **hoist-react MCP server** to study the framework: + - `hoist-search-docs` — keyword search across all framework docs (filterable by category: package, concept, conventions). + - `hoist-search-symbols` / `hoist-get-symbol` / `hoist-get-members` — TypeScript API introspection (classes, interfaces, types, signatures, JSDoc). + - `hoist-list-docs` — browse the full doc catalog. + + The **full hoist-react source** is also checked out locally at `../hoist-react/` and is up-to-date with the version used by this project. When you need to go deeper than the docs — e.g., reading the actual `Persistable` interface, `PersistenceProvider` implementation, or `DashCanvasModel` serialization logic — read the source directly. + + Key docs to read (by doc ID): + - `desktop/cmp/dash` — Dashboard system: DashContainerModel, DashCanvasModel, DashViewSpec, widget lifecycle. **Start here.** + - `persistence` — Hoist's persistence system: PersistenceProvider, backing stores, read/write/clear lifecycle. + - `cmp/viewmanager` — ViewManagerModel: save/load named state bundles, JsonBlob persistence, sharing. + - `core` — Foundation: HoistModel, component/model conventions, @managed, services. + - `conventions` — Coding style: imports, naming, class structure, component patterns, exports. + - `lifecycle-models-and-services` — Model/service lifecycles, load/refresh patterns. + - `cmp/layout` — Flexbox layout containers (relevant to dashboard arrangement). + + Key source files to read directly: + - `../hoist-react/core/persist/Persistable.ts` — the `Persistable` interface and `PersistableState` class. + - `../hoist-react/core/persist/PersistenceProvider.ts` — how persistence reads/writes/binds to models. + - `../hoist-react/desktop/cmp/dash/canvas/DashCanvasModel.ts` — how the dashboard serializes its state graph. + +2. **Dashboard-as-DSL.** The "DSL" is **not** a new format — it is Hoist's native persisted state. Hoist already has deep, nested persistence: `DashCanvasModel` serializes its layout and views via `getPersistableState()`, each view carries nested state from its own model hierarchy, and those models in turn persist their children (grids persist column config, charts persist series selection, etc.). This entire JSON graph is already produced and consumed by real Hoist code. **We use it as-is.** + + The innovation is a **meta-layer** — a machine-readable description of what that persisted state graph *looks like* for each widget type. This is what the LLM needs: not a new spec format, but a schema that says "for a widget of type `forecastChart`, the persisted state has shape `{series: string[], chartType: 'line'|'bar', ...}`" etc. Without this, the LLM can't produce valid state. + + **The ambition** is to derive this meta-layer by introspecting the existing persistence machinery. Models already implement the `Persistable` interface (`getPersistableState()` / `setPersistableState()`). Properties are decorated with `@bindable` and `@managed`. If we can walk the model hierarchy and its persistence annotations, we can auto-generate an accurate schema for each widget type. Study how `PersistableState`, `PersistenceProvider`, and the `persistWith` config work — the hooks for introspection may already exist. + + **The pragmatic starting point** is a manually maintained schema per widget type — each widget declares the shape of its persistable state via a structured object or JSON Schema fragment. This is fine initially and may be the right long-term answer for the subset of state that's meant to be LLM-authored (not all persistable state needs to be exposed). The key is designing the interface cleanly enough that it *could* be auto-generated later. + + Don't let this drive excessive complexity. A hard-coded schema that accurately describes the current widget set's persistable state is a perfectly valid deliverable. But the plan should identify where auto-generation could plug in. + +3. **Inter-widget wiring.** This is the primary technical goal after the DSL itself. Widgets must be composable: some emit values (e.g., selected city), others consume them via explicit bindings. This wiring must be part of persisted state. The widget set exists to *serve* the wiring story — every widget should justify itself by enabling interesting, plausible compositions. Think about multiplicity (one city chooser driving five display widgets), specificity (a units widget narrowing what a chart shows), and small multiples (two instances of the same widget type, each bound to a different city). + +4. **Compelling widget set.** The widget count matters less than the *compositional range*. We don't need a dozen widgets — we need a set where every type enables interesting interrelations with others using the data we have available. Add or refine widgets where they create new wiring possibilities; don't add widgets just to pad the catalog. The planning agent is free to use its imagination here, but should avoid elaborate technology integrations (e.g., deep map APIs) that distract from the core story. Widget development is a **parallel workstream** to the schema/wiring/LLM pipeline — it can proceed incrementally without blocking the primary goals. + +5. **Two harnesses:** + - **JSON harness** — a text editor panel that can view/edit the full dashboard spec JSON, validate it, and hydrate it into a live dashboard. + - **LLM harness** — an embedded chat UI that takes natural-language requests and produces updated dashboard specs iteratively, then applies them. + +6. **End-to-end LLM pipeline (tracer bullet).** The JSON harness alone is valuable — a human can copy specs back and forth between an LLM and the dashboard. But the real win is a deployed, working pipeline: user types a request → server proxies to an LLM provider → response validates and hydrates into a live dashboard. This is what makes the demo a success. Plan for this end-to-end path from the start, even if it's wired up last. + +--- + +## Non-goals / guardrails + +- Do not chase realtime/push messaging as a core theme. Periodic refresh is secondary. +- Do not build a bespoke widget framework unrelated to Hoist. Extend/compose Hoist dashboard conventions. +- Do not allow unbounded code execution via LLM. The LLM outputs JSON specs only, validated against schema. +- Keep scope ambitious but demo-shippable. A coherent, polished V2 beats a sprawling science fair. +- Do not modify V1. It stays as-is for A/B comparison. + +--- + +## Additional resources + +- **Jobsite sample app** — checked out locally at `../jobsite/`. This is a strong reference for widget design and formulation patterns in a real Hoist app. Study its dashboard and component architecture when planning V2 widgets. +- **Local Toolbox instance** — Toolbox is running locally and viewable in Chrome. Use browser automation tools to inspect the running V1 weather app, test changes, and verify behavior. Refresh as needed. +- **hoist-react source** — at `../hoist-react/`, up-to-date. Read source directly when docs aren't enough. +- **hoist-core docs** — at `../hoist-core/docs/`. Consult before planning any Grails server-side work. + +--- + +## Operating mode + +**Work autonomously.** Research, plan, formulate, reformulate, test, and polish a full implementation plan independently. Then proceed to execution. + +- **Checkpoint frequently.** Commit work-in-progress to this branch as often as needed — it's been set up for you. Use commits as save points. +- **Keep a progress log.** Write a `docs/planning/weather-v2/PROGRESS.md` file and update it as you complete tasks, hit decisions, or change direction. This is your running journal. +- **Proceed to execution.** Once the plan is solid, begin implementing. Don't wait for approval — the plan documents are the contract. If you hit a genuine ambiguity that could go badly either way, note it in the progress log and make your best call. +- **This is an AI technology excellence demo in every respect.** The quality of the planning, the code, and the autonomous execution are all part of what we're showing off. Go for it. + +--- + +## Planning agent tasks + +### 1) Read + assimilate Hoist patterns (mandatory first step) + +Use the **hoist-react MCP server** (see objective #1 above for tool names). Read documentation for: +- Dashboard components and widget patterns (`desktop/cmp/dash`) +- State persistence / serialization / hydration (`persistence`) +- Model organization, MobX usage, component conventions (`core`, `conventions`) +- View management (`cmp/viewmanager`) +- Existing example apps in this repo, especially the V1 weather app (see file paths in Context above) + +Identify "house style" expectations: file layout, naming, class patterns, validation approaches, how Hoist thinks about models/views/stores. + +**Deliverable:** a short "Hoist conventions checklist" the implementation will follow (used as a rubric during development). + +--- + +### 2) Document and schematize the existing persistence format + +The DSL is Hoist's native persisted state — not a new format. The task here is to **understand, document, and schematize** what the existing persistence machinery already produces. + +**Start by studying the actual persistence output:** +- `DashCanvasModel` implements `Persistable` and serializes to `PersistableState<{state: DashCanvasItemState[]}>`. Each item in that array represents a widget instance with layout coordinates and a nested `state` object from the widget's own model. +- Widget models (e.g., V1's `TempForecastModel`) may themselves contain `ChartModel`, `GridModel`, etc., each with their own persistable state. +- Read the `persistence` doc via MCP. Read the `Persistable` interface and `PersistenceProvider` source in `../hoist-react/core/persist/`. Examine how V1's `WeatherDashModel` uses `persistWith` today. + +**Produce:** +- A documented description of the persisted state shape for the V2 dashboard — what `DashCanvasModel.getPersistableState()` returns, what each widget's nested state looks like, and how the full JSON graph is structured. +- A formal JSON Schema (or equivalent) for this state, at least for the top-level dashboard structure and the per-widget-type state shapes. +- A semantic validation layer for things schema can't catch (e.g., references to widget IDs that don't exist, cycles in wiring, incompatible types). +- A versioning/migration approach for the persisted state (even if only v1 → v2). This matters for "persisted state as product." + +The goal: a human with a JSON editor, or an LLM with the schema, should be able to author valid persisted state that `DashCanvasModel.setPersistableState()` will accept and hydrate into a working dashboard. + +--- + +### 3) Design the widget schema interface + +Each widget type needs a structured, machine-readable description of its configuration surface — the **widget schema**. This is what the LLM consumes to produce valid specs, and what the JSON harness validates against. + +**What it must describe per widget type:** +- Widget type name (string literal). +- Configurable properties: name, type, constraints (enums, ranges, required vs optional), defaults. +- Input bindings the widget accepts (name, expected type, default-when-unbound). +- Output values the widget emits (name, type). +- Any display modes or layout hints. + +**Design the interface itself** — how does a widget declare this? Options include: +- A static `configSchema` property on the widget model class (a structured object or JSON Schema fragment). +- A TypeScript interface per widget type, with a convention for extracting it. +- A registry that maps widget type names to schema definitions. + +**Starting point:** a manually maintained schema per widget is fine. Each widget declares its own interface as a structured object. This is the V1 deliverable. + +**Stretch goal (plan for, don't over-build):** auto-generation by introspecting Hoist's persistence graph. Hoist models already declare persistable state — dashboards, grids, charts, forms all have serialization boundaries. If the widget schema could be derived from these declarations (or a curated subset), it would stay accurate automatically as widgets evolve. Identify where this could plug in and what Hoist extension points would enable it, but don't let it block the initial implementation. + +**Deliverable:** the widget schema interface design, with at least 2-3 example widget schemas showing the full range (a simple input widget, a complex chart widget, a meta/inspector widget). + +--- + +### 4) Design the widget IO/wiring model + +This is core to the demo. We want a clean, not-too-clever formalism that's easy to understand and easy for an LLM to generate correctly. + +**Required capabilities:** +- Widgets can **emit outputs** with a stable name (e.g., `selectedCity`), a type (e.g., `CityId | string | number | DateRange`), and optional metadata (label, description). +- Widgets can **declare inputs** they accept, with type constraints and default-value behavior when unbound. +- In persisted state, a widget can **bind an input** to: + - Another widget's output: `{fromWidgetId, outputName}` + - A constant literal: `{const: ...}` + - Optionally a "dashboard variable" (if you introduce global vars) + +**Constraints:** +- Validate that bindings refer to existing widget IDs + output names. +- Validate type compatibility. +- Prevent cycles (start with "no cycles"; define explicit support later if needed). +- Runtime resolution via a dependency graph where upstream changes propagate to downstream widgets. Must be MobX-friendly and Hoist-idiomatic. + +Note: V1 has no wiring — `selectedCity` is a single value on `WeatherDashModel` that all widgets read directly. V2 replaces this with the general IO model above. + +**Deliverable:** a crisp "Widget IO Spec" section in the plan, with example JSON fragments. + +--- + +### 5) Widget set: compositional range over raw count + +V1 has six widgets (current conditions, temp forecast, precip forecast, wind, humidity/pressure, 5-day summary). V2's widget set should be driven by **compositional range**, not raw count. + +**Guiding principles:** +- Every widget must justify itself by enabling interesting wiring relationships with other widgets. +- Each widget should have a few clear config knobs, optional display modes, and thoughtful defaults so the LLM can succeed without specifying everything. +- Think about the compositions: one input widget driving many display widgets, small multiples (same widget type bound to different inputs), cascading filters, side-by-side comparisons. +- Avoid elaborate technology integrations (deep map APIs, complex third-party libraries) that distract from the core schema/wiring story. +- Widget development is a **parallel workstream** — it can proceed incrementally without blocking the DSL spec, wiring model, or harness work. + +**Suggested widget types** (use your judgment — add, remove, or recombine): + +*Input/control widgets (emit values that drive other widgets):* +- **City Chooser:** emits `selectedCity`. Config: allowed cities list, default city, search/filter UI, display style. +- **Date Range Chooser:** emits `dateRange`. Config: preset ranges, min/max, granularity. +- **Units / Preferences:** emits `units` (metric|imperial), maybe time format. + +*Display widgets (consume inputs, render data):* +- **Current Conditions card:** config: visible fields, compact vs detailed, icon style. +- **Forecast chart:** config: hours vs days, series selection (temp/precip/wind), chart type. +- **Precipitation detail:** config: accumulation vs probability, time bucket, threshold highlights. +- **Wind widget:** config: gusts vs sustained, direction rose vs numeric. + +*Stretch / high-demo-value widgets:* +- **Markdown content widget:** renders arbitrary markdown via Hoist's `RenderedMarkdown` component (see Toolbox docs examples for usage). Useful as a generic display surface — static content, instructions, annotations. A good building block for compositions. +- **LLM-generated forecast summary:** a widget that calls an LLM to produce a natural-language weather analysis from the current data. This is a **technology stretch goal** — distinct from the dashboard-designer LLM harness. It would make the demo significantly more compelling ("the LLM doesn't just *build* the dashboard, it *fills in* content too"). Fine to stub initially with a template, but plan the architecture for a real LLM call. +- **Debug / Inspector widget:** shows resolved inputs/outputs + wiring graph for the current dashboard. Makes the IO story legible to humans and customers during demos. + +**Deliverable:** a widget catalog (table): type, purpose, inputs, outputs, config keys, default behavior. + +--- + +### 6) JSON harness UI (manual editing → live hydration) + +A page that makes the DSL tangible. + +**Required UX:** +- A JSON editor panel with syntax highlighting, formatting, and validation feedback. +- Schema validation errors shown with paths (e.g., `widgets[3].config.foo`). +- An "Apply" control (or auto-apply with debounce, but only if stable). +- On apply: validate → if valid, hydrate dashboard; if invalid, keep last known-good config and show errors. + +**Additional must-haves:** +- "Load example specs" dropdown — several curated examples demonstrating wiring + layout. +- "Export current spec" button (copy/download). +- "Snapshot" concept: capture current dashboard state/spec as a named sample. + +**Deliverable:** planned UI layout + interaction flow. + +--- + +### 7) LLM harness UI (chat → JSON spec → live dashboard) + +The headline demo: "Describe a dashboard; agent builds it." + +**Phased delivery:** +- **Phase A (unblocked):** The JSON harness (task #6) ships first. With it, a human can act as the relay — copy the current spec into an external LLM, describe changes in natural language, paste the result back, and apply it. This is immediately useful for validating the schema and prompt strategy without any server-side LLM integration. +- **Phase B (requires LLM integration):** The in-app chat harness connects to an LLM backend — either client-side with a local API key or via a Grails proxy (see task #10 for the decision). The user types a request, the LLM responds with a spec/patch, the app validates and applies it. This is the real demo. + +Design the chat UI and prompt protocol now (Phase B), but recognize that Phase A is the unblocked path for early validation. + +**Functional requirements:** +- Embedded chat panel in-app. +- The assistant receives: the **widget schema** (see objective #2 and task #3 — the structured description of all available widget types, their configs, and IO bindings), the current dashboard spec (if iterating), and clear instructions to output only JSON spec (or JSON patch) in a machine-parseable format. The quality of this schema directly determines the LLM's accuracy — it's the most important input to the system prompt. +- The assistant returns: either a full dashboard spec or a patch/diff to apply to the current spec. +- The app: validates output → applies if valid → if invalid, shows errors and optionally asks the assistant to repair. + +**Strong recommendation — incremental edit protocol:** +- Maintain a "current spec." +- Ask the LLM for a patch-like result (JSON Patch, or a simple `{op, path, value}` list). +- Apply patch → validate → commit. +- This reduces failure rate and supports iterative conversation. + +**Safety/robustness:** +- Hard validation gate: invalid specs never hydrate. +- Rate limiting / token limits / timeouts (enforced server-side via the proxy). +- Clear system prompt boundaries: no code execution, no network calls via LLM output. +- Logging: capture prompts + outputs for debugging (sanitized). + +**Deliverable:** proposed chat protocol, prompt strategy, and application flow. + +--- + +### 8) Backend / data model + +The weather data is the vehicle, not the destination. The demo's success hinges on widget composition, state serialization, and LLM integration — not on rich meteorological data. Even simple weather data is sufficient to tell that story and exercise all the interesting technical challenges. + +**Design for data-source independence.** Weather is the first domain, but a likely follow-up is swapping the data layer for financial/marketplace data while keeping the core (persistence, wiring, LLM integration) intact. The widget data contract should be clean enough that this kind of swap is straightforward — widgets consume a typed data interface, not raw API responses. + +**Starting point:** V1's `WeatherService.groovy` calls OpenWeatherMap's free tier (`/data/2.5/weather` and `/data/2.5/forecast`) with caching (10 min current, 30 min forecast). `WeatherController.groovy` exposes `weather/current` and `weather/forecast`. Response types are defined in `Types.ts` on the client. + +**Constraints and guidance:** +- Stay on the **OpenWeatherMap free tier** for now. The free `/forecast` endpoint returns 5-day/3-hour data — enough to populate charts, grids, and condition displays. If research turns up a low-cost paid option that meaningfully improves the demo, flag it, but don't depend on it. +- Derive what you can from the existing data: daily highs/lows, feels-like, precipitation accumulation can all be computed client-side or server-side from the 3-hour forecast intervals. +- A **normalized, typed data contract** that widgets consume (not raw API passthrough) will make the eventual domain swap cleaner. + +**Deterministic mock mode:** consider a stable dataset option so LLM-driven layouts aren't confounded by flaky API responses or changing weather. Useful for demos and testing. + +Remember: changes to `WeatherService` and `WeatherController` affect both V1 and V2. Extend additively — don't break V1's existing endpoints. + +**Deliverable:** a proposed data contract used by widgets (typed), and which endpoints supply it. + +--- + +### 9) Persistence story (Hoist-native) + +This demo only works if it uses Hoist persistence patterns convincingly. + +**Starting point:** V1 uses `DashCanvasModel` with `persistWith: {viewManagerModel}`, backed by localStorage. Read the `persistence` and `cmp/viewmanager` docs via MCP to understand the full machinery before designing V2's approach. + +**Decide:** +- What is persisted? The entire spec? Spec + derived runtime state? Per-user vs shared named dashboards? +- How does the DSL spec map to Hoist's existing persistence model? Is the spec *the* persisted state, or a layer above it? +- Migration story: how we evolve spec versions safely. +- "View manager / save views" angle: V1 already uses ViewManagerModel — does V2 deepen this, or does the DSL spec replace it? + +**Deliverable:** persistence architecture section with concrete state boundaries. + +--- + +### 10) LLM provider integration + deployment (customer-trial-ready) + +This is the tracer bullet that makes the demo real. Research and recommend an approach with clear rationale. + +**Strong preference: keep this client-side if possible.** In an ideal world, the LLM integration is purely a TypeScript/client-side concern — no Grails changes needed. This would make the project simpler, faster to iterate on, and easier for other developers to run locally. Evaluate whether a client-side-only approach is viable, e.g.: +- Client-side LLM SDK calls with a user-provided or locally-configured API key (fine for internal POC / local dev). +- A lightweight browser-based key management scheme (e.g., user pastes their own key, stored in localStorage — acceptable for a dev-facing POC, not for production). + +**For deployed Toolbox:** Toolbox is on the public internet, requires registration, but is low-volume — the audience is limited to trusted clients, interested customers, and candidates. It is not a high-abuse-risk environment, but API keys still shouldn't be shipped in the client bundle. If a server-side proxy is needed for production, the Toolbox **Grails backend** is available. Adding a new controller + service to relay LLM calls is a natural fit. + +Before planning any server-side work, consult the **hoist-core documentation** at `../hoist-core/docs/`: +- `http-client.md` — Hoist's server-side HTTP client (for making outbound calls to LLM APIs). +- `request-flow.md` — How requests flow through the Hoist server stack. +- `configuration.md` — Hoist Config system (for storing API keys, model selection, rate limits). +- `authentication.md` / `authorization.md` — How Toolbox authenticates users (relevant for per-user rate limiting). + +**Questions to answer:** +- Which LLM provider(s) to support? (Anthropic, OpenAI, etc.) Recommend a primary + a fallback. +- Can we keep it client-side only? What are the tradeoffs vs a Grails proxy? +- API key management: for local POC, a user-provided key is fine. For deployed Toolbox, where does the key live? Hoist Config? Environment variable? +- Rate limiting / cost controls: given the low-volume, trusted audience, how lightweight can this be? +- Latency budget: what's acceptable for a chat-style interaction? + +**Phasing:** +1. **JSON harness (unblocked):** a human relays specs between an external LLM and the dashboard manually. Validates the schema and prompt strategy with zero integration work. +2. **Client-side POC:** wire up LLM calls directly from TypeScript with a locally-configured API key. Good enough for internal demos and developer walkthroughs. +3. **Deployed proxy (if needed):** Grails controller + service for production Toolbox. Only build this if client-side-only isn't viable for the deployed environment. + +**Deliverable:** a decision memo — recommended LLM provider, client-side vs server-side architecture decision with rationale, key management approach, and implementation plan for whichever path is chosen. + +--- + +## Output requirements + +### Where to write + +Write all planning output to **`docs/planning/weather-v2/`** — the same directory as this prompt. Use separate files for distinct artifacts so they're easy to navigate and reference during implementation. + +### Required files + +1. **`PLAN.md`** — The master plan. Implementation phases with milestones, dependencies, and sequencing. (e.g., V2 scaffolding → DSL spec → widgets → wiring → JSON harness → LLM harness → polish.) Include the V2 project scaffolding (new directory, entry point, nav registration) as the first phase. This is the primary deliverable — it should be detailed enough that an implementation agent can pick up any phase and execute it. + +2. **`ROADMAP.md`** — A concise phase-by-phase overview with estimated scope and key decision points. Higher-level than the plan — useful for stakeholder communication and progress tracking. + +3. **`TASKS.md`** — A structured task list / checklist derived from the plan. Organized by phase, with clear done-criteria for each task. This is the day-to-day execution tracking artifact. + +4. **`WIDGET-CATALOG.md`** — The widget catalog: type, purpose, inputs, outputs, config keys, default behavior. Include at least 5 example widget JSON fragments. + +5. **`WIDGET-SCHEMA.md`** — The widget schema interface design: how widgets declare their configuration surface, example schemas for 2-3 widget types across the complexity range, and a discussion of the auto-generation stretch goal (what Hoist extension points would enable it, what a manually maintained version looks like in the meantime). + +6. **`DSL-SPEC.md`** — The dashboard spec schema (formal JSON Schema or equivalent), validation strategy, migration/versioning approach, and example full dashboard specs. + +7. **`WIRING-DESIGN.md`** — Inter-widget IO/wiring design: the formalism, runtime propagation model, validation rules, and example JSON fragments. + +8. **`RISKS.md`** — Risk list with mitigations (LLM invalid output rates, schema drift, wiring complexity, UI confusion, etc.). + +9. **`DEMO-SCRIPTS.md`** — 3–5 "customer wow" scenarios written as exact chat prompts + expected resulting dashboards. + +10. **`DEPLOYMENT-MEMO.md`** — Deployment research memo with 2–3 viable options, pros/cons, and recommended path. + +Additional files are fine if they help organize the output (e.g., a Hoist conventions checklist, UI wireframe descriptions). Use your judgment. + +**Planning style:** Be opinionated. Where there are multiple valid approaches, pick one, justify it briefly, and move on. Do not leave decisions open-ended or hedge with "we could also..." alternatives unless there is a genuine tradeoff the implementer needs to decide at build time. From f6a5ee78590624aab81d68eaceba8d8fa4586d0c Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Fri, 27 Feb 2026 23:33:51 -0800 Subject: [PATCH 02/41] Add Weather Dashboard V2 planning artifacts Complete planning phase for V2: 12 documents covering architecture, wiring design, widget catalog and schemas, DSL spec, deployment memo, risk register, demo scripts, implementation plan, roadmap, and task checklist. Key decisions: native Hoist persisted state as DSL, MobX-based wiring model, thin Grails LLM proxy, 9-widget catalog focused on compositional range. Co-Authored-By: Claude Opus 4.6 --- docs/planning/weather-v2/DEMO-SCRIPTS.md | 106 ++++ docs/planning/weather-v2/DEPLOYMENT-MEMO.md | 242 ++++++++ docs/planning/weather-v2/DSL-SPEC.md | 392 +++++++++++++ docs/planning/weather-v2/HOIST-CONVENTIONS.md | 89 +++ docs/planning/weather-v2/PLAN.md | 550 ++++++++++++++++++ docs/planning/weather-v2/PROGRESS.md | 31 + docs/planning/weather-v2/RISKS.md | 147 +++++ docs/planning/weather-v2/ROADMAP.md | 94 +++ docs/planning/weather-v2/TASKS.md | 88 +++ docs/planning/weather-v2/WIDGET-CATALOG.md | 348 +++++++++++ docs/planning/weather-v2/WIDGET-SCHEMA.md | 298 ++++++++++ docs/planning/weather-v2/WIRING-DESIGN.md | 376 ++++++++++++ 12 files changed, 2761 insertions(+) create mode 100644 docs/planning/weather-v2/DEMO-SCRIPTS.md create mode 100644 docs/planning/weather-v2/DEPLOYMENT-MEMO.md create mode 100644 docs/planning/weather-v2/DSL-SPEC.md create mode 100644 docs/planning/weather-v2/HOIST-CONVENTIONS.md create mode 100644 docs/planning/weather-v2/PLAN.md create mode 100644 docs/planning/weather-v2/PROGRESS.md create mode 100644 docs/planning/weather-v2/RISKS.md create mode 100644 docs/planning/weather-v2/ROADMAP.md create mode 100644 docs/planning/weather-v2/TASKS.md create mode 100644 docs/planning/weather-v2/WIDGET-CATALOG.md create mode 100644 docs/planning/weather-v2/WIDGET-SCHEMA.md create mode 100644 docs/planning/weather-v2/WIRING-DESIGN.md diff --git a/docs/planning/weather-v2/DEMO-SCRIPTS.md b/docs/planning/weather-v2/DEMO-SCRIPTS.md new file mode 100644 index 000000000..e41267faf --- /dev/null +++ b/docs/planning/weather-v2/DEMO-SCRIPTS.md @@ -0,0 +1,106 @@ +# Demo Scripts — Customer "Wow" Scenarios + +These are exact chat prompts for the LLM harness, with expected resulting dashboards. Each demonstrates a key V2 capability. + +--- + +## Demo 1: "Build me a dashboard from scratch" + +**Prompt:** +> Build me a weather dashboard for New York. I want to see current conditions, a temperature forecast chart, and precipitation data. + +**Expected Result:** + +Dashboard with 4 widgets: +- City Chooser (top-left, 3×2) — pre-set to "New York" +- Current Conditions (top-right, 4×5) — bound to city chooser, showing temperature gauge + details +- Forecast Chart (middle-left, 6×5) — bound to city chooser, series: temp + feelsLike, line chart +- Precipitation Chart (middle-right, 6×5) — bound to city chooser, showing probability + volume + +All display widgets wired to the city chooser. User can immediately switch cities and see all widgets update. + +**What it demonstrates:** LLM understands widget types, creates appropriate bindings, produces valid layout. + +--- + +## Demo 2: "Compare two cities" + +**Prompt (starting from Demo 1 result):** +> I want to compare New York and London side by side. Add a second city chooser for London and duplicate the current conditions and forecast chart for the London side. + +**Expected Result:** + +Dashboard with 7 widgets, organized in two columns: +- Left column: City Chooser A ("New York") + Current Conditions A + Forecast Chart A +- Right column: City Chooser B ("London") + Current Conditions B + Forecast Chart B +- Bottom: Precipitation chart (still bound to City A) + +The two City Choosers are independent. Conditions and Forecast widgets in the left column bind to Chooser A; right column binds to Chooser B. Changing either chooser updates only its column. + +**What it demonstrates:** Multiple instances of the same widget type, independent binding graphs, small multiples pattern. + +--- + +## Demo 3: "Add units and customize" + +**Prompt (starting from Demo 2 result):** +> Add a units toggle set to metric. Connect it to all the display widgets. Also add a title bar that says "Global Weather Comparison Dashboard." + +**Expected Result:** + +Dashboard gains: +- Units Toggle widget (top center, 3×2) — set to "metric" +- Markdown Content widget (full width at top, 12×2) — renders "# Global Weather Comparison Dashboard" +- All display widgets gain a `units` binding pointing to the Units Toggle +- Temperature values now show in Celsius, wind in m/s + +**What it demonstrates:** Iterative editing — LLM modifies existing spec, adds new widgets, updates bindings without breaking existing wiring. Also shows utility widgets (markdown, units toggle). + +--- + +## Demo 4: "Show me the internals" + +**Prompt (starting from any dashboard):** +> Add a Dashboard Inspector widget on the right side so I can see how the widgets are wired together. + +**Expected Result:** + +Dashboard Inspector widget (right column, 4×8) appears showing: +- List of all widget instances with their IDs and types +- For each widget: its input bindings (resolved to current values) and output values +- Validation status (all green if spec is valid) + +The inspector updates live as the user interacts — changing city in a chooser shows the new value propagating through bindings. + +**What it demonstrates:** The IO/wiring model is transparent and debuggable. Customers can see exactly how data flows through the dashboard. + +--- + +## Demo 5: "The JSON is the API" + +**Scenario:** Demo the JSON harness directly — no LLM. + +1. **Start with empty dashboard.** Click "Load Example" → select "City Comparison" preset. +2. **Dashboard hydrates** with the two-city comparison layout. +3. **Click "View Spec"** — JSON editor panel shows the full dashboard spec. +4. **Edit the JSON directly:** Change `"selectedCity": "New York"` to `"selectedCity": "Tokyo"`. Click "Apply." +5. **Dashboard updates** — left column now shows Tokyo weather. +6. **Add a widget via JSON:** Copy a `forecastChart` widget instance, paste it with a new layout position and different series config (`["humidity", "pressure"]`). Apply. +7. **New chart appears** in the dashboard showing humidity and pressure data. +8. **Export spec** — click "Copy Spec" to clipboard. This is the full, portable dashboard definition. + +**Pitch:** "This JSON spec is the entire dashboard — layout, widgets, configuration, and wiring. Any Hoist application can hydrate it. An LLM can generate it. A human can edit it. It's persisted, shareable, and versioned. This is dashboards as code." + +**What it demonstrates:** The spec is tangible, human-readable, and round-trips perfectly between JSON and live dashboard. The JSON harness is useful even without LLM integration. + +--- + +## Presentation Flow (Recommended Order) + +1. **Demo 5 first** — establish that the JSON spec is real and tangible. +2. **Demo 1** — show the LLM can build a dashboard from natural language. +3. **Demo 2** — show iterative editing and compositional power. +4. **Demo 3** — show the breadth of widget types and wiring flexibility. +5. **Demo 4** — pull back the curtain on the wiring model. + +This progression goes: "here's the format" → "an LLM can write it" → "it handles complex compositions" → "and it's all transparent." diff --git a/docs/planning/weather-v2/DEPLOYMENT-MEMO.md b/docs/planning/weather-v2/DEPLOYMENT-MEMO.md new file mode 100644 index 000000000..cee74614f --- /dev/null +++ b/docs/planning/weather-v2/DEPLOYMENT-MEMO.md @@ -0,0 +1,242 @@ +# Deployment & LLM Integration Memo + +## Decision Summary + +| Aspect | Decision | Rationale | +|--------|----------|-----------| +| **Primary LLM provider** | Anthropic (Claude) | Best structured output quality, strong JSON adherence, good at following schemas | +| **Fallback provider** | OpenAI (GPT-4o) | Widely available, proven JSON mode | +| **Phase A: JSON harness** | No LLM integration | Human relays specs via external LLM. Validates schema + prompt strategy with zero integration work | +| **Phase B: Client-side POC** | Direct Anthropic API calls from TypeScript | User-provided API key in localStorage. Good for local dev and internal demos | +| **Phase C: Deployed proxy** | Grails controller + service | Server-stored API key via Hoist Config (pwd type). Required for public Toolbox deployment | +| **Recommended path** | Build Phase B first, add Phase C for production | Client-side is faster to iterate; server proxy is a straightforward addition when needed | + +## Architecture Options Evaluated + +### Option 1: Client-Side Only (Phase B) + +**How it works:** TypeScript code calls the Anthropic Messages API directly from the browser using the Anthropic JS SDK (`@anthropic-ai/sdk`). The API key is provided by the user and stored in localStorage. + +**Pros:** +- No Grails changes — purely a client-side feature. +- Fast iteration cycle — change prompts, test immediately. +- Works for any developer with their own API key. + +**Cons:** +- API key in the browser is unacceptable for production. +- CORS: Anthropic's API does not support browser-originated requests without a proxy. This is a **blocker** for truly client-side-only calls. +- No rate limiting, cost control, or audit logging. + +**CORS Reality Check:** The Anthropic API does not set `Access-Control-Allow-Origin` headers for browser requests. Direct client-side calls will fail due to CORS. This means even Phase B needs *some* form of proxy. Options: +- A simple CORS proxy (e.g., a small Express server or Cloudflare Worker) — adds infra complexity. +- Use the Grails backend as the proxy from the start — the path of least resistance. + +**Verdict:** Client-side-only is not viable for browser-originated calls due to CORS. However, the *prompt logic and response handling* should be client-side TypeScript. The server just relays. + +### Option 2: Grails Proxy (Phase C) + +**How it works:** A new `LlmController` + `LlmService` in the Grails backend. The client sends a structured request (prompt + current spec + widget schemas). The server calls the Anthropic API, validates the response, and returns the result. + +**Pros:** +- API key stored securely in Hoist Config (pwd-encrypted, not in client bundle). +- Natural rate limiting via Hoist's request pipeline. +- Audit logging of prompts + responses for debugging. +- Works for deployed Toolbox — the production path. +- Reuses Hoist's `JSONClient` for outbound HTTP. + +**Cons:** +- Requires Grails changes (controller + service + config). +- Adds a network hop and server-side latency. +- Server must be running for LLM features to work. + +**Verdict:** This is the right production architecture. The Grails backend is already running for weather data — adding an LLM proxy is a natural extension. + +### Option 3: Hybrid — Thin Proxy, Client-Side Logic + +**How it works:** The Grails proxy is intentionally thin — it just relays requests to the LLM provider with the API key injected. All prompt construction, response parsing, validation, and retry logic lives in client-side TypeScript. + +**Pros:** +- Keeps the "intelligence" client-side where iteration is fast. +- Server changes are minimal and stable (no prompt logic on server). +- API key is secure; everything else is in the client. + +**Cons:** +- Slightly more client-side complexity. +- Full prompt text travels over the wire (not a real concern for internal/demo use). + +**Verdict:** This is the recommended approach. It combines the security of server-side key management with the iteration speed of client-side prompt engineering. + +## Recommended Architecture: Thin Grails Proxy + +### Server Side + +**LlmController.groovy:** +```groovy +@AccessAll +class LlmController extends BaseController { + def llmService + + def generate() { + def body = parseRequestJSON() + renderJSON(llmService.generate(body.messages, body.config)) + } +} +``` + +**LlmService.groovy:** +```groovy +class LlmService extends BaseService { + static clearCachesConfigs = ['llmApiKey', 'llmProvider'] + + def configService + private JSONClient _client + + Map generate(List messages, Map config = [:]) { + def provider = configService.getString('llmProvider', 'anthropic') + def apiKey = configService.getPwd('llmApiKey') + def model = config.model ?: configService.getString('llmModel', 'claude-sonnet-4-20250514') + def maxTokens = config.maxTokens ?: configService.getInt('llmMaxTokens', 4096) + + if (!apiKey || apiKey == 'UNCONFIGURED') { + throw new DataNotAvailableException( + 'LLM API key not configured. Set "llmApiKey" in Admin console.' + ) + } + + def payload = buildPayload(provider, messages, model, maxTokens) + def url = getApiUrl(provider) + + def post = new HttpPost(url) + post.setHeader('Authorization', "Bearer ${apiKey}") + // Anthropic uses x-api-key header and anthropic-version header + if (provider == 'anthropic') { + post.setHeader('x-api-key', apiKey) + post.setHeader('anthropic-version', '2023-06-01') + post.removeHeaders('Authorization') + } + post.setEntity(new StringEntity(JSONSerializer.serialize(payload))) + + return client.executeAsMap(post) + } + + void clearCaches() { + _client = null + super.clearCaches() + } +} +``` + +**Hoist Config entries:** +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `llmApiKey` | pwd | `UNCONFIGURED` | Anthropic API key | +| `llmProvider` | string | `anthropic` | Provider: `anthropic` or `openai` | +| `llmModel` | string | `claude-sonnet-4-20250514` | Model identifier | +| `llmMaxTokens` | int | `4096` | Max response tokens | + +### Client Side + +**LlmService.ts** (client-side TypeScript service or utility): +```typescript +class LlmChatService { + /** Send a prompt to the LLM proxy and return the response. */ + async generateAsync( + systemPrompt: string, + messages: ChatMessage[], + config?: {model?: string; maxTokens?: number} + ): Promise { + const response = await XH.postJson({ + url: 'llm/generate', + body: { + messages: [ + {role: 'system', content: systemPrompt}, + ...messages + ], + config + } + }); + return this.parseResponse(response); + } + + /** Build the system prompt with widget schemas and current spec. */ + buildSystemPrompt(currentSpec?: DashSpec): string { + const schemaText = widgetRegistry.generateLLMPrompt(); + const specText = currentSpec ? JSON.stringify(currentSpec, null, 2) : 'No dashboard yet.'; + return SYSTEM_PROMPT_TEMPLATE + .replace('{{WIDGET_SCHEMAS}}', schemaText) + .replace('{{CURRENT_SPEC}}', specText); + } +} +``` + +## Prompt Strategy + +### System Prompt Structure + +``` +You are a dashboard builder assistant. You create and modify weather dashboard specifications. + +## Dashboard Spec Format +The dashboard is defined as a JSON object with a "state" array of widget instances. +Each widget has: viewSpecId, layout {x, y, w, h}, optional title, and state (config + bindings). + +## Grid System +The dashboard uses a 12-column grid. Widgets are positioned by column (x) and row (y), +with width (w) in columns and height (h) in rows. + +## Available Widget Types +{{WIDGET_SCHEMAS}} + +## Wiring +Widgets communicate via bindings. Display widgets can bind their inputs to +the outputs of input widgets using: {"fromWidget": "", "output": ""} +Or use constant values: {"const": ""} + +Widget instance IDs are assigned in order: first instance of type X gets ID "X", +second gets "X_2", etc. + +## Current Dashboard +{{CURRENT_SPEC}} + +## Instructions +- Respond with ONLY a valid JSON dashboard spec. No explanation, no markdown fences. +- When modifying an existing dashboard, preserve widgets the user didn't mention. +- Always include valid bindings for display widgets. +- Use sensible defaults for unspecified config properties. +- Keep layouts non-overlapping and well-organized. +``` + +### Edit Protocol + +For iterative edits, the LLM receives the current spec and a user instruction. It returns a complete updated spec (not a patch). This is simpler and more reliable than JSON Patch: +- Full spec is easier for the LLM to produce correctly. +- Full spec can be validated as a unit. +- The client diffs old vs new for display purposes if needed. + +**Why not JSON Patch:** JSON Patch (`{op, path, value}` arrays) is harder for LLMs to produce correctly — path references are error-prone, operations must be ordered, and partial patches can leave the spec in an invalid intermediate state. Full-spec replacement is more reliable. + +## Rate Limiting & Cost Controls + +Given the low-volume, trusted audience (internal users, select customers, candidates): + +- **Per-user rate limit:** 20 requests per hour, enforced server-side via simple in-memory counter. Sufficient to prevent accidental runaway loops. +- **Max tokens per request:** Capped at 4096 (configurable via Hoist Config). Dashboard specs are typically < 2000 tokens. +- **Monthly cost estimate:** At ~20 users making ~10 requests/day at ~$0.01/request = ~$60/month. Well within acceptable demo budget. +- **No caching:** Each request is unique (user's specific dashboard + instruction). No caching value. + +## Latency Budget + +- **Acceptable:** 3-8 seconds for a full spec generation. +- **Target:** < 5 seconds for typical requests. +- **Anthropic Claude Sonnet:** Typical response time is 2-5 seconds for structured output of this size. Meets target. +- **UI:** Show a loading indicator with "Generating dashboard..." message. Stream response if possible (Anthropic supports streaming). + +## Phasing Summary + +| Phase | What | Blocked By | Effort | +|-------|------|------------|--------| +| **A: JSON Harness** | Manual copy-paste to external LLM | Nothing | Low — UI only | +| **B: Server Proxy** | Grails LlmController + LlmService + Config | Grails server running | Medium — new controller/service | +| **C: Chat Harness** | In-app chat UI + client-side prompt logic | Phase B | Medium — UI + prompt engineering | + +Phase A enables immediate validation of the schema and prompt strategy. Phase B adds the server infrastructure. Phase C wires it into a polished in-app experience. diff --git a/docs/planning/weather-v2/DSL-SPEC.md b/docs/planning/weather-v2/DSL-SPEC.md new file mode 100644 index 000000000..677bbe87d --- /dev/null +++ b/docs/planning/weather-v2/DSL-SPEC.md @@ -0,0 +1,392 @@ +# Dashboard Spec Schema (DSL) + +## Core Principle + +The V2 "DSL" is **Hoist's native persisted state** — the JSON produced by `DashCanvasModel.getPersistableState()`. We don't invent a new format. We document the existing format, add wiring conventions to widget `viewState`, and provide a JSON Schema for validation. + +## State Structure + +`DashCanvasModel` persists as: + +```typescript +PersistableState<{state: DashCanvasItemState[]}> +``` + +Each item in the `state` array represents a widget instance: + +```typescript +interface DashCanvasItemState { + viewSpecId: string; // Widget type (maps to viewSpec.id) + layout: {x: number; y: number; w: number; h: number}; // Grid position + title?: string; // Custom display title (overrides viewSpec.title) + state?: Record; // Widget-specific persisted state +} +``` + +The V2 addition: widget `state` objects follow a convention where `bindings` is a reserved key for input wiring (see WIRING-DESIGN.md). All other keys are widget-specific config. + +## Full Dashboard Spec Shape + +```json +{ + "state": [ + { + "viewSpecId": "cityChooser", + "layout": {"x": 0, "y": 0, "w": 3, "h": 2}, + "title": "Select City", + "state": { + "selectedCity": "New York", + "enableSearch": true + } + }, + { + "viewSpecId": "forecastChart", + "layout": {"x": 3, "y": 0, "w": 9, "h": 5}, + "title": "Temperature Forecast", + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"}, + "units": {"const": "imperial"} + }, + "series": ["temp", "feelsLike"], + "chartType": "line" + } + } + ] +} +``` + +## JSON Schema + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "WeatherDashboardV2Spec", + "description": "Dashboard specification for Weather Dashboard V2. This is the persisted state format for DashCanvasModel.", + "type": "object", + "required": ["state"], + "additionalProperties": false, + "properties": { + "version": { + "type": "integer", + "description": "Spec version number for migration support.", + "default": 1, + "const": 1 + }, + "state": { + "type": "array", + "description": "Array of widget instances with their layout and configuration.", + "items": {"$ref": "#/$defs/widgetInstance"} + } + }, + "$defs": { + "widgetInstance": { + "type": "object", + "required": ["viewSpecId", "layout"], + "additionalProperties": false, + "properties": { + "viewSpecId": { + "type": "string", + "enum": [ + "cityChooser", "unitsToggle", "currentConditions", + "forecastChart", "precipChart", "windChart", + "summaryGrid", "markdownContent", "dashInspector" + ], + "description": "Widget type identifier." + }, + "layout": {"$ref": "#/$defs/layout"}, + "title": { + "type": "string", + "description": "Custom display title. Overrides the widget type's default title." + }, + "state": { + "type": "object", + "description": "Widget-specific configuration and bindings.", + "properties": { + "bindings": {"$ref": "#/$defs/bindingsMap"} + }, + "additionalProperties": true + } + } + }, + "layout": { + "type": "object", + "required": ["x", "y", "w", "h"], + "additionalProperties": false, + "properties": { + "x": {"type": "integer", "minimum": 0, "maximum": 11, "description": "Column position (0-indexed, max 11 for 12-col grid)."}, + "y": {"type": "integer", "minimum": 0, "description": "Row position (0-indexed)."}, + "w": {"type": "integer", "minimum": 1, "maximum": 12, "description": "Width in columns."}, + "h": {"type": "integer", "minimum": 1, "description": "Height in rows."} + } + }, + "bindingsMap": { + "type": "object", + "description": "Map of input name → binding spec. Keys are input names declared by the widget type.", + "additionalProperties": {"$ref": "#/$defs/bindingSpec"} + }, + "bindingSpec": { + "oneOf": [ + { + "type": "object", + "required": ["fromWidget", "output"], + "additionalProperties": false, + "properties": { + "fromWidget": {"type": "string", "description": "Instance ID of the source widget."}, + "output": {"type": "string", "description": "Name of the output on the source widget."} + }, + "description": "Bind to another widget's output." + }, + { + "type": "object", + "required": ["const"], + "additionalProperties": false, + "properties": { + "const": {"description": "A constant literal value."} + }, + "description": "Bind to a constant value." + } + ] + } + } +} +``` + +## Widget Instance ID Assignment + +`DashCanvasModel` assigns instance IDs automatically when loading state: the first instance of a viewSpec gets `viewSpecId` as its ID, subsequent instances get `viewSpecId_2`, `viewSpecId_3`, etc. + +This means **binding references use these generated IDs**. For a spec with two `cityChooser` instances: +- First: ID = `"cityChooser"` +- Second: ID = `"cityChooser_2"` + +Bindings in other widgets use these IDs: `{"fromWidget": "cityChooser_2", "output": "selectedCity"}`. + +**For LLM authoring:** The LLM must understand this ID assignment rule. The system prompt will explain: "Widget instance IDs are assigned in order of appearance in the `state` array. The first instance of type X gets ID `X`, the second gets `X_2`, etc. Use these IDs in bindings." + +## Validation Pipeline + +Validation runs in three stages before a spec is hydrated: + +### Stage 1: Structural (JSON Schema) + +Standard JSON Schema validation against the schema above. Catches: +- Missing required fields (`viewSpecId`, `layout`). +- Invalid `viewSpecId` values. +- Layout values out of bounds. +- Malformed binding specs. + +**Tool:** Use a JSON Schema validator library (e.g., `ajv`). + +### Stage 2: Semantic (Custom Validation) + +Post-schema checks that require knowledge of widget types: + +1. **Layout overlap detection.** Two widgets occupying the same grid cells. (Warning, not error — DashCanvas handles overlap via compaction.) +2. **Layout bounds.** Widget extends beyond column 12 (`x + w > 12`). (Error.) +3. **Config property validation.** For each widget, validate its `state` properties against the widget's `WidgetMeta.config`: + - Unknown config properties → warning. + - Invalid enum values → error. + - Type mismatches (string where number expected) → error. + - Missing required config → error (with default fallback). +4. **Binding input validation.** For each binding in a widget's `bindings` map: + - Input name must be declared in the widget's `WidgetMeta.inputs` → error if not. + - Required inputs without bindings → warning. + +### Stage 3: Referential (Graph Validation) + +Validates the wiring graph: + +1. **Dangling widget references.** `fromWidget` must reference a widget instance that exists in the spec. Compute expected IDs from the spec's widget order. +2. **Dangling output references.** `output` must be a declared output of the referenced widget type. +3. **Type compatibility.** The output's declared type must match the input's declared type. +4. **Cycle detection.** Build a directed graph (widget → widget via bindings). Run topological sort — reject if cycle detected. + +### Validation Output + +```typescript +interface ValidationResult { + valid: boolean; // true if no errors (warnings OK) + errors: ValidationMessage[]; // Must fix before hydration + warnings: ValidationMessage[]; // Informational, spec will work +} + +interface ValidationMessage { + level: 'error' | 'warning'; + path: string; // e.g., "state[2].state.bindings.city" + code: string; // e.g., "DANGLING_WIDGET_REF" + message: string; // Human-readable description +} +``` + +## Versioning & Migration + +### Version Field + +Specs carry a `version` field (default `1`). When the spec format changes: +1. Bump the version number. +2. Write a migration function: `migrateV1toV2(spec) → spec`. +3. On load, check version and apply migrations in sequence. + +### Migration Strategy + +Migrations are deterministic transformations applied in order: + +```typescript +const migrations: Record DashSpec> = { + // v1 → v2: example migration + 2: (spec) => ({ + ...spec, + version: 2, + state: spec.state.map(w => ({ + ...w, + state: { + ...w.state, + // v2 renames 'metric' to 'displayMetric' in precipChart + ...(w.viewSpecId === 'precipChart' && w.state?.metric + ? {displayMetric: w.state.metric, metric: undefined} + : {}) + } + })) + }) +}; + +function migrateSpec(spec: DashSpec): DashSpec { + let current = spec; + const target = CURRENT_VERSION; + while ((current.version ?? 1) < target) { + const next = (current.version ?? 1) + 1; + current = migrations[next](current); + } + return current; +} +``` + +For V1 (initial release): no migrations needed. The framework is in place for future evolution. + +## Hydration Flow + +``` +JSON string + ↓ parse +DashSpec object + ↓ migrateSpec() +DashSpec (current version) + ↓ validateSpec() +ValidationResult + ↓ if valid +DashCanvasModel.setPersistableState({state: spec.state}) + ↓ +Widgets instantiated, bindings resolved, data loaded +``` + +In the JSON harness, invalid specs show errors and keep the previous dashboard state. In the LLM harness, invalid specs trigger a repair prompt back to the LLM. + +## Example Full Dashboard Specs + +### Minimal: Single City Overview + +```json +{ + "version": 1, + "state": [ + { + "viewSpecId": "cityChooser", + "layout": {"x": 0, "y": 0, "w": 3, "h": 2}, + "state": {"selectedCity": "New York"} + }, + { + "viewSpecId": "currentConditions", + "layout": {"x": 3, "y": 0, "w": 4, "h": 5}, + "state": { + "bindings": {"city": {"fromWidget": "cityChooser", "output": "selectedCity"}} + } + }, + { + "viewSpecId": "forecastChart", + "layout": {"x": 7, "y": 0, "w": 5, "h": 5}, + "state": { + "bindings": {"city": {"fromWidget": "cityChooser", "output": "selectedCity"}}, + "series": ["temp"], + "chartType": "line" + } + } + ] +} +``` + +### Full: Comprehensive Dashboard + +```json +{ + "version": 1, + "state": [ + { + "viewSpecId": "cityChooser", + "layout": {"x": 0, "y": 0, "w": 3, "h": 2}, + "state": {"selectedCity": "New York"} + }, + { + "viewSpecId": "unitsToggle", + "layout": {"x": 3, "y": 0, "w": 3, "h": 2}, + "state": {"units": "imperial"} + }, + { + "viewSpecId": "currentConditions", + "layout": {"x": 6, "y": 0, "w": 6, "h": 5}, + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"}, + "units": {"fromWidget": "unitsToggle", "output": "units"} + }, + "displayMode": "detailed" + } + }, + { + "viewSpecId": "forecastChart", + "layout": {"x": 0, "y": 2, "w": 6, "h": 5}, + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"}, + "units": {"fromWidget": "unitsToggle", "output": "units"} + }, + "series": ["temp", "feelsLike"], + "chartType": "line" + } + }, + { + "viewSpecId": "precipChart", + "layout": {"x": 0, "y": 7, "w": 6, "h": 5}, + "state": { + "bindings": {"city": {"fromWidget": "cityChooser", "output": "selectedCity"}}, + "metric": "both" + } + }, + { + "viewSpecId": "windChart", + "layout": {"x": 6, "y": 5, "w": 6, "h": 5}, + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"}, + "units": {"fromWidget": "unitsToggle", "output": "units"} + }, + "showGusts": true + } + }, + { + "viewSpecId": "summaryGrid", + "layout": {"x": 6, "y": 10, "w": 6, "h": 5}, + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"}, + "units": {"fromWidget": "unitsToggle", "output": "units"} + } + } + } + ] +} +``` + +### Comparison: Two Cities Side by Side + +See WIRING-DESIGN.md for the full multi-city comparison example. diff --git a/docs/planning/weather-v2/HOIST-CONVENTIONS.md b/docs/planning/weather-v2/HOIST-CONVENTIONS.md new file mode 100644 index 000000000..57f94640b --- /dev/null +++ b/docs/planning/weather-v2/HOIST-CONVENTIONS.md @@ -0,0 +1,89 @@ +# Hoist Conventions Checklist — Weather Dashboard V2 + +This checklist codifies the "house style" expectations for V2 implementation. Every file written must pass these checks. + +## File & Directory Organization + +- [ ] V2 lives at `client-app/src/examples/weatherv2/` — completely separate from V1. +- [ ] Entry point at `client-app/src/apps/weatherv2.ts` using `XH.renderApp()`. +- [ ] Registered in `ExamplesTabModel.ts` alongside (not replacing) V1. +- [ ] One model class per file. Component may share the file if small, otherwise separate. +- [ ] Widget files live in `weatherv2/widgets/` — one file per widget (model + component together for simple widgets, split for complex ones). +- [ ] Shared types in `weatherv2/Types.ts`. Wiring/schema infrastructure in `weatherv2/dash/`. + +## Model Conventions + +- [ ] Models extend `HoistModel`. Call `makeObservable(this)` in every constructor that adds `@observable`, `@bindable`, or `@computed`. +- [ ] Use `@bindable` for properties that have corresponding UI inputs (auto-generates setter). +- [ ] Use `@observable.ref` for object/array references that are replaced wholesale. +- [ ] Use `@managed` on child models the class creates and owns. +- [ ] Use `@persist` + `persistWith` for properties that should survive page reload. +- [ ] Use `@lookup(() => DashViewModel)` in widget models to find parent view model in `onLinked()`. +- [ ] Defer `persistWith` and `markPersist()` calls to `onLinked()` (after context available). +- [ ] Implement `doLoadAsync(loadSpec)` for data loading — never call it directly, use `loadAsync()` / `refreshAsync()`. +- [ ] Check `loadSpec.isStale` after every await to avoid stale data writes. +- [ ] Use `addReaction()` for derived behavior (not manual subscriptions). + +## Component Conventions + +- [ ] Components use `hoistCmp.factory()` — no JSX. +- [ ] Declare model relationship: `creates(ModelClass)` (owns) or `uses(ModelClass)` (receives). +- [ ] Use Hoist element factories: `panel()`, `box()`, `vbox()`, `hbox()`, `grid()`, `button()`, etc. +- [ ] No JSX syntax anywhere. All UI via element factory functions. +- [ ] Keep render functions focused on structure — logic belongs in models. + +## Class Member Ordering + +1. `declare config` / static properties +2. Readonly / immutable properties +3. `@managed` children +4. `@observable` / `@bindable` state +5. `@computed` getters +6. Constructor +7. Lifecycle hooks (`onLinked`, `doLoadAsync`) +8. Public `@action` methods +9. Private implementation + +## Import Ordering + +1. External libraries (`lodash`, `react`) +2. `@xh/hoist` packages (grouped by subpackage) +3. Relative imports (local project files) + +## Code Style + +- [ ] Prettier: 100-char width, single quotes, no trailing commas, 4-space indent. +- [ ] No bracket spacing: `{foo}` not `{ foo }`. +- [ ] Arrow parens: avoid when possible (`x => x`). +- [ ] Semicolons: always. +- [ ] Destructure from model at top of methods: `const {selectedCity, forecast} = this;` + +## Dashboard / DashCanvas Conventions + +- [ ] `DashCanvasModel` configured with `viewSpecs` array and `persistWith: {viewManagerModel}`. +- [ ] Each `viewSpec` has: `id`, `title`, `icon`, `content` (factory function), `width`, `height`. +- [ ] Widget content factories use `creates(WidgetModel)` to own their model. +- [ ] Widget models use `@lookup(() => DashViewModel)` and `persistWith: {dashViewModel}` in `onLinked()`. +- [ ] Widget-specific persistable state uses `markPersist('propertyName')`. + +## Persistence Conventions + +- [ ] Use `DashViewProvider` for widget-internal state (via `dashViewModel`). +- [ ] Use `ViewManagerModel` for named dashboard layouts. +- [ ] Never mix persistence providers — one `persistWith` per model. +- [ ] Debounce writes (framework default 250ms is fine). + +## Data Fetching + +- [ ] Use `XH.fetchJson()` / `XH.postJson()` for server calls. +- [ ] Pass `loadSpec` to fetch calls for stale-load protection. +- [ ] Wrap observable mutations in `runInAction()` when outside `@action` methods. +- [ ] Handle errors with `XH.handleException(e)` in load methods. + +## Naming + +- [ ] Models: `PascalCase` ending in `Model` (e.g., `CityChooserModel`). +- [ ] Components: `camelCase` factory function (e.g., `cityChooserWidget`). +- [ ] Files: `PascalCase` matching primary export (e.g., `CityChooserWidget.ts`). +- [ ] CSS classes: `kebab-case` with BEM-like nesting (e.g., `weather-v2-current-conditions__detail`). +- [ ] Prefix all V2 CSS classes with `weather-v2-` to avoid collisions. diff --git a/docs/planning/weather-v2/PLAN.md b/docs/planning/weather-v2/PLAN.md new file mode 100644 index 000000000..2b834fd9f --- /dev/null +++ b/docs/planning/weather-v2/PLAN.md @@ -0,0 +1,550 @@ +# Weather Dashboard V2 — Master Implementation Plan + +## Architecture Overview + +V2 is a new Hoist example app at `client-app/src/examples/weatherv2/` that demonstrates: +1. **Dashboard-as-DSL:** Hoist's native persisted state as a machine-readable dashboard spec. +2. **Inter-widget wiring:** Typed, declarative bindings between widget instances. +3. **LLM-driven generation:** Natural language → validated JSON spec → live dashboard. + +The app is built alongside V1 (no modifications to V1). Server-side weather endpoints are shared. + +### Key Architecture Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Spec format | Hoist native `DashCanvasModel` persisted state | Not a new format — extends existing Hoist persistence | +| Wiring storage | `bindings` key in each widget's `viewState` | Flows through existing persistence pipeline unchanged | +| Widget metadata | Static `meta` property on widget model classes | Simple, explicit, co-located, auto-generation-ready | +| Data access | Shared `WeatherDataModel` with per-city caching | Avoids duplicate API calls across widgets | +| LLM integration | Thin Grails proxy + client-side prompt logic | CORS requires server proxy; prompt iteration stays client-side | +| LLM edit protocol | Full-spec replacement (not JSON Patch) | More reliable for LLM output; validates as a unit | + +--- + +## Phase 1: V2 Scaffolding + +**Goal:** New app shell that loads, renders, and persists — the foundation everything else builds on. + +### 1.1 Create directory structure + +``` +client-app/src/examples/weatherv2/ +├── AppModel.ts — Root app model (creates ViewManagerModel + V2DashModel) +├── AppComponent.ts — App shell with appBar + dashCanvas +├── Types.ts — Weather data types (copy from V1, extend) +├── Icons.ts — Icon definitions (copy from V1) +├── WeatherV2.scss — V2-specific styles +├── dash/ +│ ├── WeatherV2DashModel.ts — Central model: owns DashCanvasModel + WiringModel +│ ├── WiringModel.ts — Runtime wiring coordinator +│ ├── WeatherWidgetModel.ts — Base class for all V2 widget models +│ ├── WidgetRegistry.ts — Widget type registry + schema access +│ └── validation.ts — Spec validation pipeline +└── widgets/ + └── (widget files — Phase 3+) +``` + +### 1.2 Entry point and registration + +- Create `client-app/src/apps/weatherv2.ts` — calls `XH.renderApp()` with V2's `AppModel` and `AppComponent`. +- Register in `ExamplesTabModel.ts` as a new entry alongside V1. + +### 1.3 App shell + +- `AppModel.ts`: Creates `ViewManagerModel` (type: `'weatherDashboardV2'`) and `WeatherV2DashModel`. +- `AppComponent.ts`: `panel` with `appBar` (city selector dropdown for backward compat during development) + `dashCanvas`. +- `WeatherV2DashModel.ts`: Owns `@managed dashCanvasModel: DashCanvasModel` with empty `viewSpecs` initially, `persistWith: {viewManagerModel}`. + +### 1.4 Smoke test + +- App loads at `http://localhost:3000/weatherv2`. +- Empty dashboard canvas renders. +- View manager controls appear in app bar. +- No errors in console. + +**Dependencies:** None. +**Estimated scope:** Small — mostly scaffolding and copy-from-V1. + +--- + +## Phase 2: Wiring Infrastructure + +**Goal:** The `WiringModel`, `WeatherWidgetModel` base class, and `WidgetRegistry` are functional. Widget models can publish outputs and resolve inputs. No widgets yet — just the plumbing. + +### 2.1 WiringModel + +Implement `WiringModel` as described in WIRING-DESIGN.md: +- Observable outputs map: `widgetId → {outputName → value}`. +- `publishOutput(widgetId, outputName, value)` — `@action` that updates the map. +- `resolveBinding(binding: BindingSpec)` — reads from the outputs map. +- `removeWidget(widgetId)` — cleans up on widget removal. + +### 2.2 WeatherWidgetModel base class + +Abstract base class for all V2 widget models: +- `@lookup(() => DashViewModel) viewModel` — links to parent DashCanvasViewModel. +- `resolveInput(inputName)` — reads binding from `viewModel.viewState.bindings`, resolves via `WiringModel`. +- `publishOutput(name, value)` — delegates to `WiringModel`. +- Standard `onLinked()` sets up `persistWith: {dashViewModel: this.viewModel}`. +- Access to `WiringModel` via `AppModel.instance.weatherV2DashModel.wiringModel`. + +### 2.3 WidgetRegistry + +- Singleton `WidgetRegistry` with `register(meta)`, `get(id)`, `getAll()`. +- `generateLLMPrompt()` — produces structured text description of all widgets. +- `generateDashboardSchema()` — produces JSON Schema (or returns the static one). + +### 2.4 TypeScript types + +Define in `dash/types.ts`: +- `WidgetMeta`, `InputDef`, `OutputDef`, `ConfigPropertyDef` +- `BindingSpec` union type +- `DashSpec` (the full dashboard spec shape) +- `ValidationResult`, `ValidationMessage` + +### 2.5 Unit validation + +Basic validation pipeline (full pipeline in Phase 5): +- Structural: check `viewSpecId` against registry. +- Referential: check `fromWidget` references resolve. +- Cycle detection: topological sort of binding graph. + +**Dependencies:** Phase 1. +**Estimated scope:** Medium — core infrastructure, no UI. + +--- + +## Phase 3: Initial Widget Set + +**Goal:** 5 core widgets — enough to demo the wiring story. One input widget (City Chooser) and four display widgets. + +### 3.1 WeatherDataModel + +Shared data provider that caches weather API responses per city: +- `ensureDataLoaded(city: string): Promise` — fetches if not cached. +- `getWeatherData(city: string): WeatherData | null` — synchronous read from cache. +- Observable cache so widgets react when data arrives. +- Owned by `WeatherV2DashModel`, accessed via `AppModel.instance`. + +Normalized data types: +```typescript +interface WeatherData { + city: string; + current: NormalizedCurrent; + forecast: NormalizedForecastEntry[]; + fetchedAt: number; +} +``` + +### 3.2 City Chooser widget + +- `CityChooserModel extends WeatherWidgetModel` with `@bindable @persist selectedCity`. +- Publishes `selectedCity` output on change. +- Component: Hoist `select` input with `enableFilter`. +- Static `meta` declares output, config (selectedCity, cities, enableSearch). + +### 3.3 Current Conditions widget + +- `CurrentConditionsModel extends WeatherWidgetModel`. +- Resolves `city` input, loads weather data from `WeatherDataModel`. +- Renders temperature gauge (Highcharts solid gauge) + conditions details. +- Config: `showFeelsLike`, `showHumidity`, `showWind`, `displayMode`. +- Adapted from V1's `CurrentConditionsWidget` but using wired inputs instead of parent model. + +### 3.4 Forecast Chart widget + +- `ForecastChartModel extends WeatherWidgetModel`. +- Resolves `city` and `units` inputs, loads forecast data. +- Configurable series (`temp`, `feelsLike`, `humidity`, `pressure`) and chart type (`line`, `area`, `column`). +- Adapted from V1's `TempForecastWidget` with expanded series support. + +### 3.5 Precipitation Chart widget + +- `PrecipChartModel extends WeatherWidgetModel`. +- Resolves `city` input. +- Dual-axis chart: probability (%) and volume (mm). +- Config: `metric` (probability/volume/both), `showThresholds`. +- Adapted from V1's `PrecipForecastWidget`. + +### 3.6 5-Day Summary Grid widget + +- `SummaryGridModel extends WeatherWidgetModel`. +- Resolves `city` and `units` inputs. +- Grid with daily high/low, conditions, humidity, wind. +- Config: `visibleColumns`. +- Adapted from V1's `ConditionsSummaryWidget`. + +### 3.7 Wire up DashCanvasModel viewSpecs + +Register all 5 widgets as viewSpecs on `DashCanvasModel`. Set up `initialState` with a default layout: city chooser top-left, display widgets filling the rest. + +### 3.8 End-to-end test + +- App loads with default dashboard. +- City Chooser appears. Selecting a new city updates all display widgets. +- Dashboard layout is drag-resizable. +- Layout persists across page reload. +- View Manager save/load works. + +**Dependencies:** Phase 2. +**Estimated scope:** Large — 5 widget implementations + data model. + +--- + +## Phase 4: Remaining Widgets + Polish + +**Goal:** Complete the widget set (units toggle, wind chart, markdown, inspector) and refine the initial widgets. + +### 4.1 Units Toggle widget + +- `UnitsToggleModel extends WeatherWidgetModel`. +- `@bindable @persist units: 'imperial' | 'metric'`. +- Publishes `units` output. +- Component: Hoist `buttonGroupInput` or `switchInput`. + +### 4.2 Wind Chart widget + +- `WindChartModel extends WeatherWidgetModel`. +- Resolves `city` and `units` inputs. +- Chart: wind speed + optional gusts. +- Config: `showGusts`, `chartType`. + +### 4.3 Markdown Content widget + +- `MarkdownContentModel extends WeatherWidgetModel`. +- `@bindable @persist content: string`. +- Renders via Hoist's markdown rendering (or a simple `div` with `dangerouslySetInnerHTML` from a markdown library). +- No data inputs — pure config-driven. + +### 4.4 Dashboard Inspector widget + +- `DashInspectorModel extends WeatherWidgetModel`. +- Reads from `WiringModel` outputs and `DashCanvasModel` viewModels. +- Renders a tree/list of all widgets with their bindings and current values. +- Updates reactively. + +### 4.5 Update viewSpecs and default layout + +Add all new widgets to `DashCanvasModel.viewSpecs`. Update `initialState` with a comprehensive default dashboard. + +### 4.6 Widget refinements + +- Ensure all widgets handle missing data gracefully (loading mask, placeholder). +- Ensure `units` input works in all applicable widgets (temperature conversion, wind speed). +- Consistent styling across all widgets. + +**Dependencies:** Phase 3. +**Estimated scope:** Medium — 4 additional widgets, incremental. + +--- + +## Phase 5: Spec Validation + JSON Harness + +**Goal:** Full validation pipeline and the JSON editor UI. A human can manually edit dashboard specs. + +### 5.1 Full validation pipeline + +Implement the three-stage validation from DSL-SPEC.md: +1. **Structural:** JSON Schema validation using `ajv` library. +2. **Semantic:** Config property type checking, enum validation, required fields. +3. **Referential:** Binding reference validation, type compatibility, cycle detection. + +Output: `ValidationResult` with errors and warnings, each with JSON path and message. + +### 5.2 JSON Schema generation + +The `WidgetRegistry` produces a complete JSON Schema for the dashboard spec, including per-widget-type `state` schemas derived from `WidgetMeta.config`. + +### 5.3 JSON Harness UI + +New component within the V2 app — a split-pane view: +- **Left panel:** JSON editor with syntax highlighting. Use a code editor component (CodeMirror or Monaco via a lightweight wrapper — evaluate what's simplest to integrate). +- **Right panel:** Live dashboard canvas. +- **Controls:** Apply button, validation status indicator, "Load Example" dropdown, "Copy Spec" button. + +If a standalone code editor is too complex to integrate, a `textarea` with monospace font and basic formatting is acceptable for V1. The key is the validate → apply → hydrate flow. + +### 5.4 Example specs + +3-5 curated example specs accessible from the "Load Example" dropdown: +1. "Basic Weather" — single city, conditions + forecast. +2. "Full Dashboard" — all widget types, single city. +3. "City Comparison" — two cities side by side. +4. "Minimal" — just a city chooser and one chart. +5. "Annotated" — markdown header + display widgets + inspector. + +Store as JSON files or inline constants. + +### 5.5 Spec application flow + +``` +User clicks "Apply" (or auto-apply on valid change) + → Parse JSON + → Run migration (version check) + → Run validation pipeline + → If errors: show errors in UI, keep current dashboard + → If valid: call DashCanvasModel.setPersistableState({state: spec.state}) + → Dashboard hydrates with new widgets +``` + +### 5.6 Spec export + +"Copy Spec" button reads `DashCanvasModel.getPersistableState()` and copies formatted JSON to clipboard. + +**Dependencies:** Phase 4 (full widget set for meaningful examples). +**Estimated scope:** Medium-Large — validation engine + editor UI. + +--- + +## Phase 6: LLM Integration + +**Goal:** End-to-end LLM pipeline — user types a request, dashboard updates. + +### 6.1 Server-side proxy + +- `LlmController.groovy` — single `generate` endpoint, `@AccessAll`. +- `LlmService.groovy` — calls Anthropic Messages API via `JSONClient`. +- Hoist Config entries: `llmApiKey` (pwd), `llmProvider`, `llmModel`, `llmMaxTokens`. +- Error handling: API key not configured → helpful error message. +- Basic rate limiting: per-user counter, 20 req/hour. + +### 6.2 Client-side LLM service + +`LlmChatService` utility: +- `generateAsync(systemPrompt, messages, config)` → calls `/llm/generate` endpoint. +- `buildSystemPrompt(currentSpec?)` → assembles prompt from widget schemas + current spec. +- `parseSpecFromResponse(response)` → extracts JSON spec from LLM text response. + +### 6.3 System prompt + +Assemble from: +- Dashboard spec format description. +- Grid system rules (12 columns, layout constraints). +- Full widget catalog (generated from `WidgetRegistry.generateLLMPrompt()`). +- Wiring rules (binding format, ID assignment). +- Current dashboard spec (if editing). +- Instructions: output only JSON, preserve unmentioned widgets, use sensible defaults. + +### 6.4 Chat harness UI + +Embedded chat panel in the V2 app: +- **Layout:** Chat panel on the left or bottom, dashboard on the right/top. Toggle visibility. +- **Chat display:** Message history (user + assistant). +- **Input:** Text field + send button. +- **Flow:** + 1. User types request. + 2. Client builds system prompt with current spec + widget schemas. + 3. Client sends to `/llm/generate`. + 4. Response received → extract JSON spec. + 5. Validate spec. + 6. If valid: hydrate dashboard, show success in chat. + 7. If invalid: show errors in chat, optionally send repair prompt. + +### 6.5 Error recovery + +- If LLM response isn't valid JSON: show "couldn't parse response" in chat with the raw text. +- If JSON parses but fails validation: show validation errors in chat. Optionally auto-retry with: "Your response had errors: [list]. Please fix and regenerate." +- Max 2 retries before giving up and showing errors to user. + +### 6.6 Streaming (stretch) + +If time permits, stream the LLM response for better perceived latency. Show partial response in chat, parse JSON on completion. + +**Dependencies:** Phase 5 (validation pipeline), Grails server running. +**Estimated scope:** Large — server + client + prompt engineering. + +--- + +## Phase 7: Polish & Demo Prep + +**Goal:** Demo-ready quality. Everything works end-to-end, looks polished, handles edge cases. + +### 7.1 Default dashboard experience + +- On first load (no persisted state), show a compelling default dashboard. +- Default includes: city chooser, units toggle, current conditions, forecast chart, precipitation chart, summary grid. +- Well-laid-out, no gaps, good proportions. + +### 7.2 Error states and loading + +- All widgets show Hoist loading masks during data fetch. +- Graceful handling when weather API returns errors (error message in widget, not app crash). +- Graceful handling when LLM API is unavailable (clear error in chat, JSON harness still works). +- Empty-state messages when no data available. + +### 7.3 Styling and theming + +- Works in both light and dark Hoist themes. +- Consistent color palette across charts. +- Clean, professional look suitable for customer demos. +- Responsive: widgets look good at various sizes (DashCanvas handles this, but verify). + +### 7.4 Curated example specs + +Finalize the 5 example specs. Ensure each one: +- Validates successfully. +- Hydrates into a well-laid-out dashboard. +- Demonstrates a distinct V2 capability. + +### 7.5 Demo scripts + +Walk through the 5 demo scripts from DEMO-SCRIPTS.md. Verify each prompt produces the expected result with the current LLM provider and system prompt. + +### 7.6 Changelog entry + +Add a `CHANGELOG.md` entry for V2 under the current SNAPSHOT version. + +### 7.7 Mock data mode (stretch) + +If time permits, implement the deterministic mock data mode described in RISKS.md R7. + +**Dependencies:** Phase 6. +**Estimated scope:** Medium — mostly refinement and testing. + +--- + +## Phase Dependency Graph + +``` +Phase 1: Scaffolding + ↓ +Phase 2: Wiring Infrastructure + ↓ +Phase 3: Initial Widgets (5) + ↓ +Phase 4: Remaining Widgets (4) + Polish + ↓ +Phase 5: Validation + JSON Harness + ↓ +Phase 6: LLM Integration + ↓ +Phase 7: Polish & Demo Prep +``` + +Phases are strictly sequential — each builds on the prior. Within a phase, sub-tasks can often be parallelized (e.g., multiple widgets in Phase 3). + +## Data Architecture + +### WeatherDataModel + +Central data cache, owned by `WeatherV2DashModel`: + +```typescript +class WeatherDataModel extends HoistModel { + // Observable map: city → WeatherData + @observable.ref private _cache = new Map(); + + /** Get cached data for a city (synchronous, may return null). */ + getData(city: string): WeatherData | null { + return this._cache.get(city) ?? null; + } + + /** Ensure data is loaded for a city. Fetches if not cached or stale. */ + async ensureDataAsync(city: string): Promise { + const cached = this._cache.get(city); + if (cached && !this.isStale(cached)) return cached; + + const [current, forecast] = await Promise.all([ + XH.fetchJson({url: 'weather/current', params: {city}}), + XH.fetchJson({url: 'weather/forecast', params: {city}}) + ]); + + const data = this.normalize(city, current, forecast); + runInAction(() => { + this._cache = new Map(this._cache).set(city, data); + }); + return data; + } + + private isStale(data: WeatherData): boolean { + return Date.now() - data.fetchedAt > 5 * 60 * 1000; // 5 min client-side staleness + } + + private normalize(city, current, forecast): WeatherData { + // Transform raw API responses into NormalizedCurrent + NormalizedForecastEntry[] + } +} +``` + +### Normalized Data Types + +```typescript +interface WeatherData { + city: string; + current: NormalizedCurrent; + forecast: NormalizedForecastEntry[]; + fetchedAt: number; +} + +interface NormalizedCurrent { + temp: number; // Fahrenheit (raw from API, convert to metric in widget) + feelsLike: number; + humidity: number; // Percentage + pressure: number; // hPa + windSpeed: number; // mph + windGust?: number; + conditions: string; // "Clear", "Rain", etc. + description: string; // "light rain", "clear sky", etc. + iconCode: string; // OpenWeatherMap icon code +} + +interface NormalizedForecastEntry { + dt: number; // Unix timestamp (ms) + temp: number; + feelsLike: number; + tempMin: number; + tempMax: number; + humidity: number; + pressure: number; + windSpeed: number; + windGust?: number; + precipProbability: number; // 0-100 + precipVolume: number; // mm per 3h + conditions: string; + description: string; + iconCode: string; +} +``` + +This normalized contract: +- Uses consistent naming (camelCase, not snake_case from API). +- Keeps raw units from the API (imperial — the API returns imperial when requested). +- Widgets handle unit conversion display-side based on their `units` input. +- Is domain-independent enough that swapping weather for financial data would mean changing the interface, not the widget architecture. + +## Persistence Architecture + +### What's Persisted + +| What | Where | How | +|------|-------|-----| +| Dashboard layout + widget config + wiring | ViewManager (JsonBlob, server) | `DashCanvasModel.persistWith = {viewManagerModel}` | +| Widget-specific state (series selection, etc.) | Nested in DashCanvas state | `markPersist()` via `DashViewProvider` | +| Last selected city (on each CityChooser) | Nested in DashCanvas state | `markPersist('selectedCity')` | +| User's preferred units | Nested in DashCanvas state | `markPersist('units')` | + +### Persistence Flow + +``` +User changes widget config + → @persist property updates + → DashViewProvider writes to DashViewModel.viewState + → DashCanvasModel's state computed changes + → PersistenceProvider (ViewManagerProvider) detects change + → Debounced write to ViewManagerModel + → ViewManagerModel persists to JsonBlob (server) +``` + +### Named Views + +Users can save named dashboard layouts via ViewManager: +- "My Dashboard" — personal layout. +- "City Comparison" — shared layout. +- "Default" — the initial built-in layout. + +This is identical to V1's approach. The V2 innovation is that the saved state includes wiring (bindings), so a shared view preserves the full widget composition. + +### Spec ↔ Persisted State Relationship + +The "DSL spec" IS the persisted state. `DashCanvasModel.getPersistableState()` returns exactly the JSON that the JSON harness displays. `setPersistableState()` accepts exactly the JSON that the LLM generates. There's no translation layer — the spec format and the persistence format are one and the same. diff --git a/docs/planning/weather-v2/PROGRESS.md b/docs/planning/weather-v2/PROGRESS.md new file mode 100644 index 000000000..3054cf048 --- /dev/null +++ b/docs/planning/weather-v2/PROGRESS.md @@ -0,0 +1,31 @@ +# Progress Log — Weather Dashboard V2 + +## 2026-02-27 + +### Planning Phase Complete + +All 12 planning documents produced: +- **HOIST-CONVENTIONS.md** — House style checklist for V2 implementation. +- **WIRING-DESIGN.md** — Inter-widget IO model: MobX-based publish/resolve through WiringModel. +- **WIDGET-SCHEMA.md** — WidgetMeta interface design with static `meta` per widget class. +- **WIDGET-CATALOG.md** — 9 widgets: 2 input, 5 display, 2 utility. Focused on compositional range. +- **DSL-SPEC.md** — Dashboard spec = Hoist native persisted state. JSON Schema + 3-stage validation. +- **DEPLOYMENT-MEMO.md** — Thin Grails proxy (CORS requirement), Anthropic Claude primary provider. +- **RISKS.md** — 10 risks identified with mitigations. +- **DEMO-SCRIPTS.md** — 5 customer wow scenarios with exact prompts. +- **PLAN.md** — 7-phase master plan with full task breakdown. +- **ROADMAP.md** — Phase overview with key decision points. +- **TASKS.md** — Structured checklist (~55 tasks across 7 phases). + +### Key Design Decisions Made + +1. **Spec format = Hoist native state.** No new DSL. The `DashCanvasModel.getPersistableState()` output IS the spec. Wiring added via `bindings` key in widget viewState. +2. **WiringModel as MobX coordinator.** Observable output map. Widgets publish/resolve through it. Changes propagate via standard MobX reactivity. +3. **WeatherWidgetModel base class.** All widgets extend it. Provides `resolveInput()`, `publishOutput()`, standard `onLinked()` persistence setup. +4. **Shared WeatherDataModel.** Per-city caching. Widgets don't fetch directly — they request data from the shared model. Avoids duplicate API calls. +5. **Full-spec replacement for LLM edits.** Not JSON Patch. Simpler, more reliable, validates as a unit. +6. **Grails proxy for LLM.** CORS prevents direct browser→Anthropic calls. Thin proxy adds API key server-side. + +### Beginning Execution — Phase 1 + +Moving to implementation. Starting with Phase 1 (V2 Scaffolding). diff --git a/docs/planning/weather-v2/RISKS.md b/docs/planning/weather-v2/RISKS.md new file mode 100644 index 000000000..23b489f49 --- /dev/null +++ b/docs/planning/weather-v2/RISKS.md @@ -0,0 +1,147 @@ +# Risk Register — Weather Dashboard V2 + +## High Priority + +### R1: LLM Produces Invalid Specs + +**Risk:** The LLM generates JSON that fails validation — malformed structure, dangling widget references, invalid config values, layout overlaps. + +**Likelihood:** High (expected during initial development, moderate in production). + +**Impact:** Dashboard fails to hydrate. User sees error instead of result. + +**Mitigations:** +- Hard validation gate — invalid specs never reach `setPersistableState()`. +- Detailed error messages with JSON paths guide the LLM (or human) to fix issues. +- System prompt includes the full JSON Schema and explicit examples. +- LLM repair loop: on validation failure, send errors back to the LLM with "fix this" instruction (max 2 retries). +- Full-spec replacement (not patches) — easier for the LLM to get right. +- Fallback: the JSON harness always works for manual editing. + +### R2: Wiring Complexity Confuses Users + +**Risk:** The binding model (fromWidget/output references, instance IDs) is too abstract for non-technical users or for LLMs to handle reliably with many widgets. + +**Likelihood:** Medium. + +**Impact:** Users can't build interesting dashboards; the demo falls flat. + +**Mitigations:** +- Keep the widget set small (9 types) with clear input/output contracts. +- Provide 3-5 curated example specs that users can start from and modify. +- The Dashboard Inspector widget makes bindings visible and debuggable. +- LLM harness abstracts wiring — users say "show me New York weather" not "bind city input to cityChooser output." +- Design sensible defaults — display widgets work with no bindings (using default city). + +### R3: DashCanvasModel Instance ID Instability + +**Risk:** DashCanvasModel generates instance IDs based on widget order. If the LLM reorders widgets in a spec update, IDs shift and bindings break silently. + +**Likelihood:** Medium. + +**Impact:** Bindings reference wrong widgets or become dangling. Dashboard behaves unexpectedly. + +**Mitigations:** +- System prompt instructs the LLM to maintain widget order when editing. +- Validation catches dangling references before hydration. +- Consider a pre-processing step that assigns stable IDs from widget titles or explicit labels (stretch goal). +- Document the ID assignment rule clearly in the LLM prompt. + +## Medium Priority + +### R4: Schema Drift Between Widget Code and Meta + +**Risk:** Widget models evolve (new config properties, changed defaults) but the static `meta` object isn't updated. Schema and reality diverge. + +**Likelihood:** Medium (grows over time). + +**Impact:** LLM generates specs with stale config options; validation passes but widget ignores unknown config. + +**Mitigations:** +- Co-locate `meta` with the widget model class — change the code, update the meta in the same file. +- Add a dev-time test that instantiates each widget and verifies its `meta.config` keys match its actual `@persist`/`@bindable` properties. +- Plan for auto-generation (see WIDGET-SCHEMA.md stretch goal) to eliminate manual sync. + +### R5: Performance with Many Widgets + +**Risk:** A dashboard with 15+ widgets, each with bindings, creates a dense MobX reaction graph. Changes to a shared output (e.g., city) trigger many simultaneous reloads. + +**Likelihood:** Low (typical dashboards have 5-10 widgets). + +**Impact:** UI jank, slow response to input changes. + +**Mitigations:** +- Data caching: `WeatherDataModel` caches API responses by city. Multiple widgets requesting the same city share one cached response. +- Debounce: Widget reload reactions are debounced (300ms). +- MobX is efficient — computed invalidation is O(1) per dependency edge. +- DashCanvas only renders visible widgets (react-grid-layout handles virtualization for scrolled-out widgets). + +### R6: OpenWeatherMap API Limitations + +**Risk:** Free tier rate limits (60 calls/min, 1M calls/month) may be hit during demos with many cities. API outages affect the demo. + +**Likelihood:** Low (server-side caching mitigates). + +**Impact:** Weather data unavailable; display widgets show errors. + +**Mitigations:** +- Server-side caching (10 min current, 30 min forecast) — already in V1. +- Extend caching to V2's multi-city pattern: cache per city, not per request. +- Consider a deterministic mock data mode for demo stability (see R7). +- 25 cities × 2 endpoints = 50 unique cache entries — well within free tier. + +### R7: Demo Instability from Live Data + +**Risk:** Live weather data changes between demo rehearsal and presentation. Dashboard looks different than expected. API returns errors during live demo. + +**Likelihood:** Medium. + +**Impact:** Demo doesn't match rehearsed script. Embarrassing during customer presentations. + +**Mitigations:** +- **Mock data mode:** Server-side flag (Hoist Config `weatherMockMode: boolean`) that returns stable, pre-recorded weather data. Data is realistic and covers interesting conditions (rain, wind, temperature variation). +- Mock data checked into the repo as JSON fixtures. +- Toggle via Admin console — no code change needed to switch. +- Live mode remains the default; mock mode is opt-in for demos. + +## Low Priority + +### R8: V1/V2 Server-Side Conflicts + +**Risk:** Extending `WeatherService.groovy` for V2 (new data formats, additional endpoints) breaks V1's existing endpoints. + +**Likelihood:** Low (V2 additions are additive). + +**Impact:** V1 stops working, breaking the A/B comparison story. + +**Mitigations:** +- New endpoints only — don't modify existing `weather/current` or `weather/forecast`. +- V2 can use the same endpoints (the data shape is sufficient) or add new ones (e.g., `weather/v2/forecast` with normalized output). +- Test V1 after any server changes. + +### R9: Persistence Provider Conflicts + +**Risk:** V2's wiring data (`bindings` in viewState) interferes with Hoist's persistence providers or causes unexpected behavior when ViewManager saves/loads views. + +**Likelihood:** Low. `viewState` is an opaque `PlainObject` — Hoist doesn't inspect its contents. + +**Impact:** Saved views lose wiring information or behave differently on reload. + +**Mitigations:** +- `bindings` is just another key in `viewState` — treated identically to any other widget state by Hoist's persistence pipeline. +- Test save/load/share cycles explicitly for dashboards with wiring. +- Verify that ViewManager round-trips preserve the full `viewState` including nested `bindings`. + +### R10: LLM Cost Overrun + +**Risk:** Users (or automated tests) make excessive LLM calls, running up API costs. + +**Likelihood:** Low (small, trusted user base). + +**Impact:** Unexpected Anthropic API bill. + +**Mitigations:** +- Per-user rate limit (20 req/hour) enforced server-side. +- Max tokens capped at 4096 per request. +- Monitor via Anthropic dashboard; set billing alerts. +- API key stored in Hoist Config — can be rotated or disabled instantly via Admin console. diff --git a/docs/planning/weather-v2/ROADMAP.md b/docs/planning/weather-v2/ROADMAP.md new file mode 100644 index 000000000..7d588f49b --- /dev/null +++ b/docs/planning/weather-v2/ROADMAP.md @@ -0,0 +1,94 @@ +# Weather Dashboard V2 — Roadmap + +## Phase Overview + +| Phase | Name | Key Deliverable | Scope | Dependencies | +|-------|------|----------------|-------|-------------| +| 1 | **Scaffolding** | App loads at `/weatherv2` with empty DashCanvas | Small | None | +| 2 | **Wiring Infrastructure** | WiringModel, WeatherWidgetModel base, WidgetRegistry | Medium | Phase 1 | +| 3 | **Initial Widgets** | 5 widgets (city chooser + 4 display), WeatherDataModel | Large | Phase 2 | +| 4 | **Remaining Widgets** | 4 more widgets (units, wind, markdown, inspector) | Medium | Phase 3 | +| 5 | **Validation + JSON Harness** | Spec validation pipeline + JSON editor UI | Medium-Large | Phase 4 | +| 6 | **LLM Integration** | Grails proxy + chat harness + prompt engineering | Large | Phase 5 | +| 7 | **Polish & Demo Prep** | Demo-quality UX, example specs, changelog | Medium | Phase 6 | + +## Key Decision Points + +| Decision | Phase | Options | Chosen | +|----------|-------|---------|--------| +| Spec format | Design | New format vs. native Hoist state | Native Hoist state | +| Wiring storage | Design | Separate wiring layer vs. in viewState | In viewState (`bindings` key) | +| Data access | 3 | Per-widget fetch vs. shared cache | Shared `WeatherDataModel` | +| JSON editor | 5 | CodeMirror/Monaco vs. textarea | Start with textarea, upgrade if time | +| LLM provider | 6 | Anthropic vs. OpenAI | Anthropic (Claude) primary | +| LLM protocol | 6 | Full-spec replacement vs. JSON Patch | Full-spec replacement | +| LLM proxy | 6 | Client-side vs. Grails proxy | Grails proxy (CORS requirement) | + +## Phase Details + +### Phase 1: Scaffolding +- New directory: `client-app/src/examples/weatherv2/` +- Entry point: `apps/weatherv2.ts` +- Nav registration in `ExamplesTabModel.ts` +- Empty `AppModel` + `AppComponent` + `WeatherV2DashModel` +- **Done when:** App loads at `/weatherv2` with empty DashCanvas and ViewManager controls. + +### Phase 2: Wiring Infrastructure +- `WiringModel` — observable output map, publish/resolve/remove +- `WeatherWidgetModel` — base class with `resolveInput()` / `publishOutput()` +- `WidgetRegistry` — register, lookup, prompt generation +- TypeScript type definitions for all spec/wiring types +- Basic validation skeleton +- **Done when:** A test widget can publish an output and another test widget can resolve it reactively. + +### Phase 3: Initial Widgets +- `WeatherDataModel` — shared per-city data cache with normalized types +- `CityChooserWidget` — dropdown, publishes `selectedCity` +- `CurrentConditionsWidget` — temp gauge + details, consumes `city` + `units` +- `ForecastChartWidget` — multi-series chart, consumes `city` + `units` +- `PrecipChartWidget` — precip probability + volume, consumes `city` +- `SummaryGridWidget` — daily overview grid, consumes `city` + `units` +- Default dashboard layout with all 5 widgets wired together +- **Done when:** Changing the city in the chooser updates all display widgets. Layout persists. + +### Phase 4: Remaining Widgets +- `UnitsToggleWidget` — imperial/metric toggle, publishes `units` +- `WindChartWidget` — wind speed + gusts chart, consumes `city` + `units` +- `MarkdownContentWidget` — static rich-text display +- `DashInspectorWidget` — debug view of wiring graph + values +- Update default layout to include all 9 widgets +- **Done when:** Full widget set functional. Inspector shows live binding values. + +### Phase 5: Validation + JSON Harness +- Full 3-stage validation pipeline (structural, semantic, referential) +- JSON Schema generation from WidgetRegistry +- JSON editor panel (split-pane: editor + live dashboard) +- Apply/validate/hydrate flow +- 5 curated example specs +- Export (copy spec to clipboard) +- **Done when:** User can paste a JSON spec, see validation errors or a live dashboard. + +### Phase 6: LLM Integration +- `LlmController.groovy` + `LlmService.groovy` — thin proxy to Anthropic API +- Hoist Config entries for API key, model, limits +- Client-side `LlmChatService` — prompt assembly + response parsing +- Chat harness UI (embedded panel with message history) +- System prompt with widget schemas + spec format + examples +- Error recovery (validation errors → retry prompt) +- **Done when:** User types "build me a weather dashboard" and it appears. + +### Phase 7: Polish & Demo Prep +- Default dashboard looks great out of the box +- Error states and loading masks +- Light/dark theme support +- Walk through all 5 demo scripts successfully +- Changelog entry +- Mock data mode (stretch) +- **Done when:** Ready to demo to customers. + +## Risk Summary + +See RISKS.md for full details. Top 3: +1. **LLM produces invalid specs** — mitigated by hard validation gate + repair loop. +2. **Wiring complexity confuses users** — mitigated by curated examples + LLM abstraction. +3. **Instance ID instability** — mitigated by ordering rules + validation. diff --git a/docs/planning/weather-v2/TASKS.md b/docs/planning/weather-v2/TASKS.md new file mode 100644 index 000000000..d851ded37 --- /dev/null +++ b/docs/planning/weather-v2/TASKS.md @@ -0,0 +1,88 @@ +# Tasks — Weather Dashboard V2 + +## Phase 1: Scaffolding + +- [ ] **1.1** Create `client-app/src/examples/weatherv2/` directory structure +- [ ] **1.2** Create `apps/weatherv2.ts` entry point with `XH.renderApp()` +- [ ] **1.3** Create `BaseAppModel` extension (`AppModel.ts`) with `ViewManagerModel` setup +- [ ] **1.4** Create `AppComponent.ts` with `appBar` + empty `dashCanvas` +- [ ] **1.5** Create `WeatherV2DashModel.ts` with empty `DashCanvasModel` + `persistWith` +- [ ] **1.6** Copy `Types.ts` and `Icons.ts` from V1 +- [ ] **1.7** Create `WeatherV2.scss` with base styles +- [ ] **1.8** Register V2 in `ExamplesTabModel.ts` +- [ ] **1.9** Verify app loads at `/weatherv2` with empty canvas and ViewManager + +## Phase 2: Wiring Infrastructure + +- [ ] **2.1** Create `dash/types.ts` with `WidgetMeta`, `BindingSpec`, `InputDef`, `OutputDef`, `DashSpec`, `ValidationResult` types +- [ ] **2.2** Implement `dash/WiringModel.ts` — observable output map, publishOutput, resolveBinding, removeWidget +- [ ] **2.3** Implement `dash/WeatherWidgetModel.ts` — base class with resolveInput, publishOutput, onLinked persistence setup +- [ ] **2.4** Implement `dash/WidgetRegistry.ts` — register, get, getAll, generateLLMPrompt +- [ ] **2.5** Implement basic `dash/validation.ts` — structural checks (known viewSpecIds), referential checks (binding targets exist) +- [ ] **2.6** Wire WiringModel into WeatherV2DashModel + +## Phase 3: Initial Widgets + +- [ ] **3.1** Implement `WeatherDataModel.ts` — per-city cache, ensureDataAsync, normalize, observable cache map +- [ ] **3.2** Define `NormalizedCurrent` and `NormalizedForecastEntry` types in `Types.ts` +- [ ] **3.3** Implement `widgets/CityChooserWidget.ts` — model + component, publishes selectedCity +- [ ] **3.4** Implement `widgets/CurrentConditionsWidget.ts` — temp gauge + details, consumes city + units +- [ ] **3.5** Implement `widgets/ForecastChartWidget.ts` — multi-series line chart, consumes city + units +- [ ] **3.6** Implement `widgets/PrecipChartWidget.ts` — dual-axis precip chart, consumes city +- [ ] **3.7** Implement `widgets/SummaryGridWidget.ts` — daily overview grid, consumes city + units +- [ ] **3.8** Register all 5 widgets in WidgetRegistry with static `meta` +- [ ] **3.9** Add viewSpecs to DashCanvasModel for all 5 widgets +- [ ] **3.10** Set up default initialState layout +- [ ] **3.11** End-to-end test: city change propagates to all display widgets via wiring + +## Phase 4: Remaining Widgets + +- [ ] **4.1** Implement `widgets/UnitsToggleWidget.ts` — imperial/metric toggle, publishes units +- [ ] **4.2** Implement `widgets/WindChartWidget.ts` — wind speed + gusts chart, consumes city + units +- [ ] **4.3** Implement `widgets/MarkdownContentWidget.ts` — markdown renderer, config-driven +- [ ] **4.4** Implement `widgets/DashInspectorWidget.ts` — wiring graph visualization +- [ ] **4.5** Register all 4 new widgets in WidgetRegistry +- [ ] **4.6** Add viewSpecs to DashCanvasModel +- [ ] **4.7** Update default initialState layout to include key widgets +- [ ] **4.8** Verify units toggle drives unit conversion in all applicable widgets +- [ ] **4.9** Verify inspector shows live binding values and updates reactively + +## Phase 5: Validation + JSON Harness + +- [ ] **5.1** Add `ajv` dependency for JSON Schema validation +- [ ] **5.2** Implement full structural validation (JSON Schema against spec) +- [ ] **5.3** Implement semantic validation (config types, enums, required fields) +- [ ] **5.4** Implement referential validation (binding targets, output existence, type compat, cycle detection) +- [ ] **5.5** Implement spec version checking and migration framework +- [ ] **5.6** Create JSON harness panel component — split pane with editor + dashboard +- [ ] **5.7** Implement Apply button flow: parse → migrate → validate → hydrate +- [ ] **5.8** Implement validation error display with JSON paths +- [ ] **5.9** Implement "Copy Spec" export (getPersistableState → clipboard) +- [ ] **5.10** Create 5 curated example specs (basic, full, comparison, minimal, annotated) +- [ ] **5.11** Implement "Load Example" dropdown +- [ ] **5.12** End-to-end test: paste a spec, see it hydrate; paste invalid spec, see errors + +## Phase 6: LLM Integration + +- [ ] **6.1** Create `LlmController.groovy` with `generate` endpoint +- [ ] **6.2** Create `LlmService.groovy` — Anthropic API calls via JSONClient +- [ ] **6.3** Add Hoist Config entries (llmApiKey, llmProvider, llmModel, llmMaxTokens) +- [ ] **6.4** Implement server-side rate limiting (per-user, 20 req/hour) +- [ ] **6.5** Build client-side `LlmChatService` — prompt assembly, response parsing +- [ ] **6.6** Write system prompt template with widget schemas + spec format + rules +- [ ] **6.7** Create chat harness UI component — message history + input + send +- [ ] **6.8** Implement chat flow: user message → build prompt → call proxy → parse spec → validate → hydrate +- [ ] **6.9** Implement error recovery — validation failures shown in chat, optional retry +- [ ] **6.10** Test with all 5 demo scripts from DEMO-SCRIPTS.md + +## Phase 7: Polish & Demo Prep + +- [ ] **7.1** Finalize default dashboard layout and initial experience +- [ ] **7.2** Add loading masks and error states to all widgets +- [ ] **7.3** Verify light/dark theme support +- [ ] **7.4** Review and finalize all 5 example specs +- [ ] **7.5** Walk through demo scripts end-to-end, fix issues +- [ ] **7.6** Add CHANGELOG.md entry for V2 +- [ ] **7.7** Final code review pass — conventions checklist, style, naming +- [ ] **7.8** (Stretch) Implement deterministic mock data mode +- [ ] **7.9** (Stretch) LLM response streaming for better perceived latency diff --git a/docs/planning/weather-v2/WIDGET-CATALOG.md b/docs/planning/weather-v2/WIDGET-CATALOG.md new file mode 100644 index 000000000..6f308509e --- /dev/null +++ b/docs/planning/weather-v2/WIDGET-CATALOG.md @@ -0,0 +1,348 @@ +# Widget Catalog — Weather Dashboard V2 + +## Summary Table + +| # | Type ID | Title | Category | Inputs | Outputs | Key Config | Purpose | +|---|---------|-------|----------|--------|---------|-----------|---------| +| 1 | `cityChooser` | City Chooser | input | — | `selectedCity` (string) | selectedCity, cities, enableSearch | Primary city selection | +| 2 | `unitsToggle` | Units Toggle | input | — | `units` (string) | units | Switch imperial/metric | +| 3 | `currentConditions` | Current Conditions | display | city, units | — | showFeelsLike, showDetails, displayMode | Current temp gauge + conditions | +| 4 | `forecastChart` | Forecast Chart | display | city, units | — | series, chartType, showLegend | Multi-series forecast line chart | +| 5 | `precipChart` | Precipitation | display | city | — | metric, showThresholds | Precip probability + volume | +| 6 | `windChart` | Wind | display | city, units | — | showGusts, chartType | Wind speed/gusts over time | +| 7 | `summaryGrid` | 5-Day Summary | display | city, units | — | visibleColumns | Tabular daily overview | +| 8 | `markdownContent` | Markdown Content | utility | — | — | content | Static rich-text display | +| 9 | `dashInspector` | Dashboard Inspector | utility | — | — | showBindings, showOutputValues | Debug: wiring graph + values | + +--- + +## 1. City Chooser (`cityChooser`) + +**Purpose:** Dropdown selector for city. The primary input widget — most display widgets bind their `city` input to this widget's output. + +**Outputs:** +- `selectedCity` (string) — The currently selected city name. + +**Config:** +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `selectedCity` | string | `"New York"` | Initially selected city | +| `cities` | string[] | *(full default list)* | Available cities. Omit for all 25 defaults | +| `enableSearch` | boolean | `true` | Type-ahead filtering in dropdown | + +**Default size:** 3×2 (compact — fits in a sidebar column) + +**Wiring examples:** +- Single chooser driving 5 display widgets — the basic dashboard. +- Two choosers side-by-side, each driving its own column of widgets — city comparison. +- Chooser with constrained `cities` list — regional focus. + +**Example state:** +```json +{ + "viewSpecId": "cityChooser", + "layout": {"x": 0, "y": 0, "w": 3, "h": 2}, + "title": "Select City", + "state": { + "selectedCity": "San Francisco", + "cities": ["San Francisco", "Los Angeles", "Seattle", "Portland"] + } +} +``` + +--- + +## 2. Units Toggle (`unitsToggle`) + +**Purpose:** Toggle between imperial and metric units. Display widgets that accept a `units` input will adapt their display accordingly. + +**Outputs:** +- `units` (string) — `"imperial"` or `"metric"`. + +**Config:** +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `units` | enum | `"imperial"` | Initial unit system. Values: `imperial`, `metric` | + +**Default size:** 3×2 + +**Wiring examples:** +- One toggle driving all display widgets — global unit preference. +- No toggle present — widgets default to imperial. +- Const binding `{"const": "metric"}` — hardcoded unit without a toggle widget. + +**Example state:** +```json +{ + "viewSpecId": "unitsToggle", + "layout": {"x": 9, "y": 0, "w": 3, "h": 2}, + "state": {"units": "metric"} +} +``` + +--- + +## 3. Current Conditions (`currentConditions`) + +**Purpose:** Current weather snapshot — temperature gauge, conditions icon, description, and key details (feels-like, humidity, wind). + +**Inputs:** +| Input | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `city` | string | yes | `"New York"` | City to display | +| `units` | string | no | `"imperial"` | Unit system | + +**Config:** +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `showFeelsLike` | boolean | `true` | Show feels-like temperature | +| `showHumidity` | boolean | `true` | Show humidity percentage | +| `showWind` | boolean | `true` | Show wind speed | +| `displayMode` | enum | `"detailed"` | `"detailed"` (gauge + details) or `"compact"` (summary only) | + +**Default size:** 4×5 + +**Example state:** +```json +{ + "viewSpecId": "currentConditions", + "layout": {"x": 0, "y": 2, "w": 4, "h": 5}, + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"}, + "units": {"fromWidget": "unitsToggle", "output": "units"} + }, + "showFeelsLike": true, + "showHumidity": true, + "displayMode": "detailed" + } +} +``` + +--- + +## 4. Forecast Chart (`forecastChart`) + +**Purpose:** Multi-series line/area/column chart showing forecast data over the 5-day window. The primary charting widget — highly configurable series selection and chart types. + +**Inputs:** +| Input | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `city` | string | yes | `"New York"` | City to show forecast for | +| `units` | string | no | `"imperial"` | Unit system | + +**Config:** +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `series` | string[] | `["temp", "feelsLike"]` | Series to show. Options: `temp`, `feelsLike`, `humidity`, `pressure` | +| `chartType` | enum | `"line"` | `"line"`, `"area"`, `"column"` | +| `showLegend` | boolean | `true` | Show chart legend | +| `showTooltip` | boolean | `true` | Show tooltips on hover | + +**Default size:** 8×5 + +**Wiring examples:** +- Bound to city chooser — standard forecast view. +- Two instances side-by-side, each bound to a different city — comparison. +- Configured with `series: ["humidity", "pressure"]` — repurposed as humidity/pressure chart. + +**Example state:** +```json +{ + "viewSpecId": "forecastChart", + "layout": {"x": 4, "y": 0, "w": 8, "h": 5}, + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"}, + "units": {"fromWidget": "unitsToggle", "output": "units"} + }, + "series": ["temp", "feelsLike"], + "chartType": "line", + "showLegend": true + } +} +``` + +--- + +## 5. Precipitation Chart (`precipChart`) + +**Purpose:** Precipitation probability and/or volume over the forecast period. Dual-axis column chart. + +**Inputs:** +| Input | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `city` | string | yes | `"New York"` | City to show precipitation for | + +**Config:** +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `metric` | enum | `"both"` | `"probability"`, `"volume"`, `"both"` | +| `showThresholds` | boolean | `false` | Highlight high-probability periods | + +**Default size:** 6×5 + +**Example state:** +```json +{ + "viewSpecId": "precipChart", + "layout": {"x": 0, "y": 5, "w": 6, "h": 5}, + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"} + }, + "metric": "both", + "showThresholds": true + } +} +``` + +--- + +## 6. Wind Chart (`windChart`) + +**Purpose:** Wind speed and gusts over the forecast period. + +**Inputs:** +| Input | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `city` | string | yes | `"New York"` | City to show wind data for | +| `units` | string | no | `"imperial"` | Unit system (mph vs m/s) | + +**Config:** +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `showGusts` | boolean | `true` | Show gust data alongside sustained | +| `chartType` | enum | `"line"` | `"line"`, `"area"` | + +**Default size:** 6×5 + +**Example state:** +```json +{ + "viewSpecId": "windChart", + "layout": {"x": 6, "y": 5, "w": 6, "h": 5}, + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"}, + "units": {"fromWidget": "unitsToggle", "output": "units"} + }, + "showGusts": true, + "chartType": "line" + } +} +``` + +--- + +## 7. 5-Day Summary Grid (`summaryGrid`) + +**Purpose:** Tabular daily overview — one row per day with high/low, conditions, humidity, wind. + +**Inputs:** +| Input | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `city` | string | yes | `"New York"` | City to summarize | +| `units` | string | no | `"imperial"` | Unit system | + +**Config:** +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `visibleColumns` | string[] | `["date","icon","conditions","high","low","humidity","wind"]` | Columns to display | + +**Default size:** 6×5 + +**Example state:** +```json +{ + "viewSpecId": "summaryGrid", + "layout": {"x": 0, "y": 10, "w": 12, "h": 5}, + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"}, + "units": {"fromWidget": "unitsToggle", "output": "units"} + }, + "visibleColumns": ["date", "conditions", "high", "low"] + } +} +``` + +--- + +## 8. Markdown Content (`markdownContent`) + +**Purpose:** Static rich-text display using Hoist's Markdown renderer. Useful for dashboard titles, instructions, annotations, or any static content. No data inputs — purely content-driven. + +**Config:** +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `content` | string | `"# Welcome\nEdit this widget's content."` | Markdown text to render | + +**Default size:** 4×3 + +**Wiring examples:** +- Dashboard header with title and instructions. +- Annotation panel explaining what the dashboard shows. +- Placeholder for LLM-generated content (static initially, could be dynamic later). + +**Example state:** +```json +{ + "viewSpecId": "markdownContent", + "layout": {"x": 0, "y": 0, "w": 12, "h": 2}, + "state": { + "content": "## Weather Comparison Dashboard\nComparing current conditions and forecasts between two cities. Use the city selectors to change locations." + } +} +``` + +--- + +## 9. Dashboard Inspector (`dashInspector`) + +**Purpose:** Debug/demo utility that visualizes the wiring graph, shows resolved input/output values, and displays validation status. Makes the IO story visible and legible during demos. + +**Config:** +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `showBindings` | boolean | `true` | Show binding details per widget | +| `showOutputValues` | boolean | `true` | Show current output values | +| `showValidation` | boolean | `true` | Show validation errors/warnings | +| `compactMode` | boolean | `false` | Compact single-line display | + +**Default size:** 4×6 + +**Implementation notes:** +- Reads from `WiringModel` outputs map and `DashCanvasModel` view state. +- Uses a simple tree/list display — not a visual graph (keep it implementable). +- Shows each widget: ID, type, bound inputs (resolved values), published outputs (current values), validation status. +- Updates reactively as bindings and values change. + +**Example state:** +```json +{ + "viewSpecId": "dashInspector", + "layout": {"x": 8, "y": 0, "w": 4, "h": 10}, + "state": { + "showBindings": true, + "showOutputValues": true, + "showValidation": true + } +} +``` + +--- + +## Compositional Range + +The 9-widget set enables these key compositions: + +| Composition | Widgets Used | What It Demonstrates | +|-------------|-------------|---------------------| +| **Basic weather dashboard** | 1 city chooser + 5 display widgets | Single input driving multiple outputs | +| **City comparison** | 2 city choosers + 2×(conditions + forecast) | Small multiples, independent bindings | +| **Metric/imperial toggle** | Units toggle + all display widgets | Global preference via shared binding | +| **Focused views** | Chooser + single chart (configured differently) | Config knobs change widget behavior | +| **Annotated dashboard** | Markdown + display widgets | Static content alongside live data | +| **Debuggable dashboard** | Any layout + inspector | Wiring visibility for demos | +| **Constant bindings** | Display widgets with `const` bindings | No input widgets needed, static city | diff --git a/docs/planning/weather-v2/WIDGET-SCHEMA.md b/docs/planning/weather-v2/WIDGET-SCHEMA.md new file mode 100644 index 000000000..e09ecc8ff --- /dev/null +++ b/docs/planning/weather-v2/WIDGET-SCHEMA.md @@ -0,0 +1,298 @@ +# Widget Schema Interface Design + +## Purpose + +Each widget type needs a machine-readable description of its configuration surface — its **schema**. This is what: +- The **LLM** consumes to produce valid dashboard specs. +- The **JSON harness** validates against. +- The **Dashboard Inspector** widget displays for debugging. + +## Design Decision: Static `meta` Property + +Each widget model class declares a static `meta` property of type `WidgetMeta`. This is manually maintained per widget. The registry collects all `meta` objects and exposes them for validation and LLM prompt generation. + +**Why this approach:** +- Simple, explicit, co-located with the widget code. +- No runtime introspection magic — easy to debug. +- Clean enough to auto-generate later (the `meta` shape is stable; the source could change from hand-written to generated). +- JSON Schema fragments are embedded directly — no translation layer. + +## WidgetMeta Interface + +```typescript +interface WidgetMeta { + /** Widget type ID — matches the DashCanvasViewSpec.id. */ + id: string; + + /** Human-readable name. */ + title: string; + + /** Short description of what this widget does. */ + description: string; + + /** Category for grouping in menus/docs. */ + category: 'input' | 'display' | 'utility'; + + /** Inputs this widget accepts (consumed via bindings). */ + inputs: InputDef[]; + + /** Outputs this widget publishes (available for binding by other widgets). */ + outputs: OutputDef[]; + + /** Configurable properties (persisted in viewState). */ + config: Record; + + /** Default layout dimensions (columns × rows). */ + defaultSize: {w: number; h: number}; + + /** Sizing constraints. */ + minSize?: {w?: number; h?: number}; + maxSize?: {w?: number; h?: number}; +} + +interface InputDef { + name: string; + type: string; + required?: boolean; + default?: any; + description: string; +} + +interface OutputDef { + name: string; + type: string; + description: string; +} + +interface ConfigPropertyDef { + type: 'string' | 'number' | 'boolean' | 'enum' | 'string[]'; + description: string; + default?: any; + enum?: string[]; // For type: 'enum' + min?: number; // For type: 'number' + max?: number; // For type: 'number' + required?: boolean; +} +``` + +## Widget Registry + +A singleton registry collects all widget schemas and provides lookup: + +```typescript +class WidgetRegistry { + private _schemas = new Map(); + + register(meta: WidgetMeta) { + this._schemas.set(meta.id, meta); + } + + get(id: string): WidgetMeta | undefined { + return this._schemas.get(id); + } + + getAll(): WidgetMeta[] { + return Array.from(this._schemas.values()); + } + + /** Generate a JSON Schema for the full dashboard spec. */ + generateDashboardSchema(): object { /* see DSL-SPEC.md */ } + + /** Generate a prompt-friendly text description of all widget types. */ + generateLLMPrompt(): string { /* see LLM harness section */ } +} +``` + +Registration happens at module load time — each widget file calls `widgetRegistry.register(MyWidgetModel.meta)`. + +## Example Schemas + +### 1. City Chooser (Simple Input Widget) + +```typescript +static meta: WidgetMeta = { + id: 'cityChooser', + title: 'City Chooser', + description: 'Dropdown selector that emits the selected city name. Other widgets bind to this to display data for the chosen city.', + category: 'input', + inputs: [], + outputs: [ + {name: 'selectedCity', type: 'string', description: 'The currently selected city name.'} + ], + config: { + selectedCity: { + type: 'string', + description: 'Initially selected city.', + default: 'New York' + }, + cities: { + type: 'string[]', + description: 'List of available cities. If omitted, uses the full default city list.', + required: false + }, + enableSearch: { + type: 'boolean', + description: 'Enable type-ahead filtering in the dropdown.', + default: true + } + }, + defaultSize: {w: 3, h: 2}, + minSize: {w: 2, h: 1} +}; +``` + +### 2. Forecast Chart (Complex Display Widget) + +```typescript +static meta: WidgetMeta = { + id: 'forecastChart', + title: 'Forecast Chart', + description: 'Multi-series line/area chart showing forecast data over time. Configurable series selection and chart type.', + category: 'display', + inputs: [ + {name: 'city', type: 'string', required: true, default: 'New York', description: 'City to show forecast for.'}, + {name: 'units', type: 'string', required: false, default: 'imperial', description: 'Unit system: "imperial" or "metric".'} + ], + outputs: [], + config: { + series: { + type: 'string[]', + description: 'Which data series to display. Options: "temp", "feelsLike", "humidity", "pressure".', + default: ['temp', 'feelsLike'] + }, + chartType: { + type: 'enum', + description: 'Chart rendering style.', + enum: ['line', 'area', 'column'], + default: 'line' + }, + showLegend: { + type: 'boolean', + description: 'Show chart legend.', + default: true + }, + showTooltip: { + type: 'boolean', + description: 'Show data tooltips on hover.', + default: true + } + }, + defaultSize: {w: 8, h: 5}, + minSize: {w: 4, h: 3} +}; +``` + +### 3. Dashboard Inspector (Utility Widget) + +```typescript +static meta: WidgetMeta = { + id: 'dashInspector', + title: 'Dashboard Inspector', + description: 'Debug utility that shows the current wiring graph, resolved input/output values, and validation status for all widgets in the dashboard.', + category: 'utility', + inputs: [], + outputs: [], + config: { + showBindings: { + type: 'boolean', + description: 'Show binding details for each widget.', + default: true + }, + showOutputValues: { + type: 'boolean', + description: 'Show current resolved output values.', + default: true + }, + showValidation: { + type: 'boolean', + description: 'Show validation status (errors/warnings).', + default: true + }, + compactMode: { + type: 'boolean', + description: 'Use compact single-line display per widget.', + default: false + } + }, + defaultSize: {w: 4, h: 6}, + minSize: {w: 3, h: 4} +}; +``` + +## LLM Prompt Generation + +The registry generates a structured text description for the LLM system prompt: + +``` +## Available Widget Types + +### cityChooser — City Chooser [input] +Dropdown selector that emits the selected city name. +Outputs: selectedCity (string) — The currently selected city name. +Config: + - selectedCity (string, default: "New York") — Initially selected city. + - cities (string[], optional) — List of available cities. + - enableSearch (boolean, default: true) — Enable type-ahead filtering. +Default size: 3×2 + +### forecastChart — Forecast Chart [display] +Multi-series line/area chart showing forecast data over time. +Inputs: + - city (string, required, default: "New York") — City to show forecast for. + - units (string, optional, default: "imperial") — Unit system. +Outputs: (none) +Config: + - series (string[], default: ["temp","feelsLike"]) — Data series to display. Options: "temp", "feelsLike", "humidity", "pressure". + - chartType (enum: line|area|column, default: "line") — Chart rendering style. + - showLegend (boolean, default: true) — Show chart legend. +Default size: 8×5 + +... +``` + +This is included in the LLM system prompt along with the dashboard spec schema (DSL-SPEC.md) and example specs (DEMO-SCRIPTS.md). + +## Auto-Generation Stretch Goal + +### Current State of Hoist Introspection + +Hoist models already declare their persistence surface: +- `@persist` decorator marks properties that persist. +- `@bindable` decorator marks properties with auto-setters. +- `persistWith` + `markPersist()` establish the persistence path. +- `getPersistableState()` / `setPersistableState()` are the serialization boundary. + +### What's Missing for Auto-Generation + +1. **Type information at runtime.** TypeScript types are erased. We'd need to either: + - Use decorators that capture type info (e.g., `@persist({type: 'string', default: 'New York'})`). + - Use a TypeScript transformer to extract type metadata at build time. + - Or accept that auto-generation reads the source, not the runtime. + +2. **Semantic metadata.** Persistence knows *what* is persisted, but not *what it means*. A property named `chartType` could be anything — the schema needs descriptions, enums, constraints that aren't in the persistence layer. + +3. **Input/output declarations.** Hoist's persistence has no concept of inter-widget IO. This is a V2 addition, so it can't be derived from existing Hoist code. + +### Viable Auto-Generation Path + +A build-time tool that: +1. Reads widget model source files. +2. Extracts `@persist` and `@bindable` decorated properties with their TypeScript types. +3. Merges with hand-maintained `meta.description`, `meta.inputs`, `meta.outputs` (the parts that can't be inferred). +4. Produces `WidgetMeta` objects. + +This could use the TypeScript compiler API to walk ASTs. The manually maintained parts would shrink to just descriptions and IO declarations — config properties would be auto-derived. + +### Recommendation + +Start with fully manual `meta` objects. They're small (10-20 lines per widget), accurate, and co-located with the code. Plan for auto-generation by keeping the `WidgetMeta` interface stable and the config property definitions simple. A future PR could add a build-time script that generates the config portion from TypeScript types, leaving descriptions and IO declarations manual. + +## Validation + +The schema enables three levels of validation: + +1. **Structural.** Does the widget state match the expected shape? Are all required config properties present? Are enum values valid? +2. **Referential.** Do all bindings reference existing widget IDs and output names? +3. **Semantic.** Are bound types compatible? Is the dependency graph acyclic? + +See DSL-SPEC.md for the full validation pipeline. diff --git a/docs/planning/weather-v2/WIRING-DESIGN.md b/docs/planning/weather-v2/WIRING-DESIGN.md new file mode 100644 index 000000000..3678cfc4f --- /dev/null +++ b/docs/planning/weather-v2/WIRING-DESIGN.md @@ -0,0 +1,376 @@ +# Widget IO / Wiring Design + +## Overview + +V2's wiring model lets widgets communicate through typed, declarative bindings. Input widgets (e.g., City Chooser) **publish outputs**; display widgets (e.g., Forecast Chart) **consume inputs** via explicit bindings. Bindings are part of persisted state, so the wiring graph survives save/load and can be authored by an LLM. + +## Design Principles + +1. **MobX-native.** Outputs are observables. Input resolution is computed. Changes propagate reactively. +2. **Persisted.** Bindings live in each widget's `viewState` — they flow through Hoist's existing persistence pipeline without modification. +3. **LLM-friendly.** Simple JSON structure, no magic. An LLM can generate valid bindings from a schema. +4. **Validated.** Dangling references, type mismatches, and cycles are caught before hydration. +5. **Hoist-idiomatic.** Uses `@lookup`, `addReaction`, `persistWith`, `DashViewModel` — no off-reservation patterns. + +## Core Concepts + +### Outputs + +A widget **publishes** named output values. Each output has a name, a type, and a current value. + +```typescript +// CityChooserModel publishes selectedCity +this.publishOutput('selectedCity', this.selectedCity); +``` + +Outputs are registered in the `WiringModel`'s observable map. When the value changes, downstream consumers react automatically via MobX. + +### Inputs + +A widget **declares** named inputs it can consume. Each input has a name, a type, a default value (used when unbound), and an optional `required` flag. + +```typescript +// CurrentConditionsModel declares it needs a city +static meta = { + inputs: [{name: 'city', type: 'string', required: true, default: 'New York'}] +}; +``` + +### Bindings + +A **binding** connects a widget's input to a source. Bindings live in the widget's persisted `viewState` under a `bindings` key. + +Three binding types: + +| Type | JSON Shape | Meaning | +|------|-----------|---------| +| Widget output | `{"fromWidget": "city1", "output": "selectedCity"}` | Read another widget's output | +| Constant | `{"const": "New York"}` | Static value | +| Unbound | *(key absent)* | Use input's default value | + +### Widget Instance IDs + +Every widget instance in a dashboard has a unique ID, generated by `DashCanvasModel` (format: `specId` for the first instance, `specId_2`, `specId_3`, etc.). Bindings reference these instance IDs. + +When the LLM generates a spec, it doesn't set IDs directly — the IDs are assigned by `DashCanvasModel.loadState()`. Instead, the LLM uses **positional references** or the spec assigns stable IDs via a pre-processing step (see DSL-SPEC.md). + +**Decision:** We add a pre-processing step that assigns stable `id` fields to each widget in the spec before hydration. This lets LLM-authored specs use meaningful IDs like `"city1"` in bindings. + +## WiringModel + +The `WiringModel` is the runtime coordinator. It lives on `WeatherV2DashModel` alongside `DashCanvasModel`. + +```typescript +class WiringModel extends HoistModel { + // Observable map: widgetInstanceId → {outputName → value} + @observable.ref + private _outputs = new Map>(); + + /** Publish a named output from a widget instance. */ + @action + publishOutput(widgetId: string, outputName: string, value: any) { + const widgetOutputs = this._outputs.get(widgetId) ?? new Map(); + widgetOutputs.set(outputName, value); + this._outputs.set(widgetId, new Map(widgetOutputs)); + // Replace the outer map to trigger MobX reactions + this._outputs = new Map(this._outputs); + } + + /** Resolve a binding to its current value. Returns undefined if unresolvable. */ + resolveBinding(binding: BindingSpec): any { + if (!binding) return undefined; + if ('const' in binding) return binding.const; + if ('fromWidget' in binding) { + return this._outputs.get(binding.fromWidget)?.get(binding.output); + } + return undefined; + } + + /** Unregister all outputs for a widget (called on widget removal). */ + @action + removeWidget(widgetId: string) { + this._outputs.delete(widgetId); + this._outputs = new Map(this._outputs); + } +} +``` + +### Access Pattern + +Widgets access the wiring model through the app singleton — the same pattern V1 uses for `WeatherDashModel`: + +```typescript +// In a widget model +get wiringModel(): WiringModel { + return AppModel.instance.weatherV2DashModel.wiringModel; +} +``` + +This is intentionally simple and mirrors V1's `AppModel.instance.weatherDashModel` pattern used by all existing widgets. + +## WeatherWidgetModel Base Class + +All V2 widget models extend a common base that provides wiring convenience methods: + +```typescript +abstract class WeatherWidgetModel extends HoistModel { + @lookup(() => DashViewModel) viewModel: DashViewModel; + + /** Override in subclass with widget metadata. */ + static meta: WidgetMeta; + + override onLinked() { + super.onLinked(); + this.persistWith = {dashViewModel: this.viewModel}; + } + + /** Resolve an input binding to its current value. */ + protected resolveInput(inputName: string): T | undefined { + const bindings = this.viewModel.viewState?.bindings ?? {}; + const binding = bindings[inputName]; + if (!binding) { + // Return default from meta + const inputDef = (this.constructor as any).meta?.inputs + ?.find(i => i.name === inputName); + return inputDef?.default; + } + return this.wiringModel.resolveBinding(binding); + } + + /** Publish an output value. */ + protected publishOutput(name: string, value: any) { + this.wiringModel.publishOutput(this.viewModel.id, name, value); + } + + private get wiringModel(): WiringModel { + return AppModel.instance.weatherV2DashModel.wiringModel; + } +} +``` + +## Persisted State Shape + +Bindings are stored in each widget's `viewState` under a `bindings` key. This is standard Hoist `DashViewProvider` persistence — no special handling needed. + +```json +{ + "state": [ + { + "viewSpecId": "cityChooser", + "layout": {"x": 0, "y": 0, "w": 3, "h": 2}, + "title": "City Selector", + "state": { + "selectedCity": "New York" + } + }, + { + "viewSpecId": "currentConditions", + "layout": {"x": 3, "y": 0, "w": 9, "h": 5}, + "title": "Current Weather", + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"} + }, + "showFeelsLike": true, + "showHumidity": true + } + }, + { + "viewSpecId": "forecastChart", + "layout": {"x": 0, "y": 5, "w": 12, "h": 5}, + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"} + }, + "series": ["temp", "feelsLike"], + "chartType": "line" + } + } + ] +} +``` + +## Runtime Propagation + +1. **Widget mount:** Widget model's `onLinked()` fires → calls `persistWith = {dashViewModel}` → persistence provider reads stored `viewState` (including bindings). +2. **Output registration:** Widget model's `onLinked()` sets up a reaction that publishes its output values to the `WiringModel` whenever they change. +3. **Input resolution:** Consuming widgets use `resolveInput('city')` in computed properties or reactions. Since `resolveInput` reads from `WiringModel._outputs` (an observable map), MobX tracks the dependency automatically. +4. **Change propagation:** When City Chooser changes `selectedCity` → reaction publishes to `WiringModel` → MobX invalidates all computed properties / reactions that called `resolveInput('city')` on that widget → downstream widgets re-render or reload. + +### Example: City Chooser → Forecast Chart + +``` +CityChooserModel.selectedCity changes ("New York" → "London") + ↓ (reaction in CityChooserModel.onLinked) +WiringModel.publishOutput("cityChooser", "selectedCity", "London") + ↓ (MobX observable change) +ForecastChartModel.city (computed via resolveInput) invalidated + ↓ (reaction tracking this.city) +ForecastChartModel.loadAsync() → fetches London forecast + ↓ +Chart re-renders with London data +``` + +## Validation Rules + +### Static Validation (at spec load / JSON harness apply time) + +1. **Dangling widget references.** Every `fromWidget` in a binding must reference a widget instance ID that exists in the spec. +2. **Dangling output references.** Every `output` in a binding must be a declared output of the referenced widget's type. +3. **Type compatibility.** The declared type of the output must be assignable to the declared type of the input. (For V1: simple string equality. Stretch: subtype checking.) +4. **No cycles.** The binding graph must be a DAG. Detect via topological sort — if it fails, there's a cycle. +5. **Required inputs.** Inputs marked `required: true` should have a binding (warning, not hard error — defaults provide fallback). + +### Runtime Validation + +1. **Missing output value.** If a widget hasn't published its output yet (e.g., still loading), downstream inputs resolve to `undefined` → widget uses its default. +2. **Widget removal.** When a widget is removed from the canvas, `WiringModel.removeWidget()` clears its outputs. Downstream widgets fall back to defaults. Bindings referencing the removed widget become dangling — this is acceptable at runtime (widget shows default state) but should warn in the JSON harness. + +## Cycle Prevention + +The binding graph is a DAG by construction: each binding points *from* one widget *to* another widget's output. Cycles would mean A's input depends on B's output, and B's input depends on A's output (directly or transitively). + +**Detection:** Before hydrating a spec, build a directed graph of widget dependencies from bindings. Run a topological sort. If it fails, reject the spec with an error identifying the cycle. + +**Runtime:** Cycles can't form at runtime because bindings are static (persisted) — they only change when a new spec is applied. No need for runtime cycle detection. + +## Type System + +Keep it simple. V1 types: + +| Type | Values | Used by | +|------|--------|---------| +| `string` | City names, text | City Chooser output, Markdown config | +| `units` | `'imperial' \| 'metric'` | Units Toggle output | +| `number` | Numeric values | Future extensibility | +| `boolean` | true/false | Future extensibility | + +Type checking is string-based equality for V1. The output's declared type must match the input's declared type exactly. This is intentionally restrictive — better to be strict and relax later than to be loose and break things. + +## Binding Spec Types + +```typescript +type BindingSpec = + | {fromWidget: string; output: string} // Reference to another widget's output + | {const: any}; // Literal constant value + +interface InputDef { + name: string; // Input name (e.g., 'city') + type: string; // Expected type (e.g., 'string') + required?: boolean; // Whether binding is required (default false) + default?: any; // Value when unbound (default undefined) + description?: string; // Human/LLM-readable description +} + +interface OutputDef { + name: string; // Output name (e.g., 'selectedCity') + type: string; // Value type (e.g., 'string') + description?: string; // Human/LLM-readable description +} +``` + +## Full Example: Multi-City Comparison Dashboard + +This example demonstrates the wiring model's compositional power — two city choosers driving independent sets of display widgets: + +```json +{ + "state": [ + { + "viewSpecId": "cityChooser", + "layout": {"x": 0, "y": 0, "w": 3, "h": 2}, + "title": "City A", + "state": {"selectedCity": "New York"} + }, + { + "viewSpecId": "cityChooser", + "layout": {"x": 6, "y": 0, "w": 3, "h": 2}, + "title": "City B", + "state": {"selectedCity": "London"} + }, + { + "viewSpecId": "unitsToggle", + "layout": {"x": 9, "y": 0, "w": 3, "h": 2}, + "state": {"units": "imperial"} + }, + { + "viewSpecId": "currentConditions", + "layout": {"x": 0, "y": 2, "w": 6, "h": 4}, + "title": "New York Conditions", + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"}, + "units": {"fromWidget": "unitsToggle", "output": "units"} + } + } + }, + { + "viewSpecId": "currentConditions", + "layout": {"x": 6, "y": 2, "w": 6, "h": 4}, + "title": "London Conditions", + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser_2", "output": "selectedCity"}, + "units": {"fromWidget": "unitsToggle", "output": "units"} + } + } + }, + { + "viewSpecId": "forecastChart", + "layout": {"x": 0, "y": 6, "w": 6, "h": 5}, + "title": "New York Forecast", + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"}, + "units": {"fromWidget": "unitsToggle", "output": "units"} + }, + "series": ["temp", "feelsLike"], + "chartType": "line" + } + }, + { + "viewSpecId": "forecastChart", + "layout": {"x": 6, "y": 6, "w": 6, "h": 5}, + "title": "London Forecast", + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser_2", "output": "selectedCity"}, + "units": {"fromWidget": "unitsToggle", "output": "units"} + }, + "series": ["temp", "feelsLike"], + "chartType": "line" + } + } + ] +} +``` + +In this dashboard: +- Two City Chooser instances (`cityChooser` and `cityChooser_2`) each emit `selectedCity`. +- One Units Toggle (`unitsToggle`) emits `units` shared by all display widgets. +- Two Current Conditions and two Forecast Charts, each bound to a different city chooser but the same units toggle. +- Changing "City A" updates the left column; changing "City B" updates the right column. Changing units updates everything. + +## Stretch: Dashboard Variables + +A future enhancement could add dashboard-level variables — named values that any widget can bind to, managed by the dashboard model rather than emitted by a widget: + +```json +{ + "variables": { + "defaultCity": {"type": "string", "value": "New York"} + }, + "state": [ + { + "viewSpecId": "currentConditions", + "state": { + "bindings": { + "city": {"fromVariable": "defaultCity"} + } + } + } + ] +} +``` + +This is not in V1 scope but the `BindingSpec` type can be extended trivially. Noted for future consideration. From 5a87fa564432c9c40ff8a1ec883ba1b124b71257 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Fri, 27 Feb 2026 23:37:10 -0800 Subject: [PATCH 03/41] Add Weather V2 app scaffolding with wiring infrastructure Phase 1+2: New app at /weatherv2 with empty DashCanvas, ViewManager, and the core wiring infrastructure. Includes WiringModel (MobX-based pub/sub for inter-widget communication), WeatherWidgetModel (base class for all V2 widgets), WidgetRegistry (schema registry for validation and LLM prompts), WeatherDataModel (shared per-city data cache), and TypeScript types for the full spec/wiring/schema system. Co-Authored-By: Claude Opus 4.6 --- client-app/src/apps/weatherv2.ts | 20 +++ .../desktop/tabs/examples/ExamplesTabModel.ts | 14 ++ .../src/examples/weatherv2/AppComponent.ts | 29 ++++ client-app/src/examples/weatherv2/AppModel.ts | 32 +++++ client-app/src/examples/weatherv2/Icons.ts | 17 +++ client-app/src/examples/weatherv2/Types.ts | 76 +++++++++++ .../src/examples/weatherv2/WeatherV2.scss | 36 +++++ .../weatherv2/dash/WeatherDataModel.ts | 126 ++++++++++++++++++ .../weatherv2/dash/WeatherV2DashModel.ts | 37 +++++ .../weatherv2/dash/WeatherWidgetModel.ts | 73 ++++++++++ .../examples/weatherv2/dash/WidgetRegistry.ts | 94 +++++++++++++ .../examples/weatherv2/dash/WiringModel.ts | 61 +++++++++ .../src/examples/weatherv2/dash/types.ts | 93 +++++++++++++ 13 files changed, 708 insertions(+) create mode 100644 client-app/src/apps/weatherv2.ts create mode 100644 client-app/src/examples/weatherv2/AppComponent.ts create mode 100644 client-app/src/examples/weatherv2/AppModel.ts create mode 100644 client-app/src/examples/weatherv2/Icons.ts create mode 100644 client-app/src/examples/weatherv2/Types.ts create mode 100644 client-app/src/examples/weatherv2/WeatherV2.scss create mode 100644 client-app/src/examples/weatherv2/dash/WeatherDataModel.ts create mode 100644 client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts create mode 100644 client-app/src/examples/weatherv2/dash/WeatherWidgetModel.ts create mode 100644 client-app/src/examples/weatherv2/dash/WidgetRegistry.ts create mode 100644 client-app/src/examples/weatherv2/dash/WiringModel.ts create mode 100644 client-app/src/examples/weatherv2/dash/types.ts diff --git a/client-app/src/apps/weatherv2.ts b/client-app/src/apps/weatherv2.ts new file mode 100644 index 000000000..5ae704e4c --- /dev/null +++ b/client-app/src/apps/weatherv2.ts @@ -0,0 +1,20 @@ +import '../Bootstrap'; + +import {XH} from '@xh/hoist/core'; +import {AppContainer} from '@xh/hoist/desktop/appcontainer'; +import {AppComponent} from '../examples/weatherv2/AppComponent'; +import {AppModel} from '../examples/weatherv2/AppModel'; +import {AuthModel} from '../core/AuthModel'; + +XH.renderApp({ + clientAppCode: 'weatherv2', + clientAppName: 'XH Weather V2', + componentClass: AppComponent, + modelClass: AppModel, + containerClass: AppContainer, + authModelClass: AuthModel, + isMobileApp: false, + enableLogout: true, + showBrowserContextMenu: true, + checkAccess: () => true +}); diff --git a/client-app/src/desktop/tabs/examples/ExamplesTabModel.ts b/client-app/src/desktop/tabs/examples/ExamplesTabModel.ts index a468953ac..0e4e316ad 100644 --- a/client-app/src/desktop/tabs/examples/ExamplesTabModel.ts +++ b/client-app/src/desktop/tabs/examples/ExamplesTabModel.ts @@ -47,6 +47,20 @@ export class ExamplesTabModel extends HoistModel { ) ] }, + { + title: 'Weather V2', + icon: Icon.sun(), + path: 'weatherv2', + srcPath: 'weatherv2', + text: [ + p( + 'Weather Dashboard V2 — a declarative, wirable dashboard with inter-widget bindings and LLM-driven spec generation.' + ), + p( + "Demonstrates dashboard-as-DSL: Hoist's native persisted state as a machine-readable spec that an LLM can generate and validate." + ) + ] + }, { title: 'Contact', icon: Icon.users(), diff --git a/client-app/src/examples/weatherv2/AppComponent.ts b/client-app/src/examples/weatherv2/AppComponent.ts new file mode 100644 index 000000000..b58a92365 --- /dev/null +++ b/client-app/src/examples/weatherv2/AppComponent.ts @@ -0,0 +1,29 @@ +import {hoistCmp, uses} from '@xh/hoist/core'; +import {appBar, appBarSeparator} from '@xh/hoist/desktop/cmp/appbar'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {dashCanvas} from '@xh/hoist/desktop/cmp/dash'; +import {viewManager} from '@xh/hoist/desktop/cmp/viewmanager'; +import {Icon} from '@xh/hoist/icon'; +import {AppModel} from './AppModel'; +import '../../core/Toolbox.scss'; +import './WeatherV2.scss'; + +export const AppComponent = hoistCmp({ + displayName: 'App', + model: uses(AppModel), + + render({model}) { + const {weatherV2DashModel} = model; + return panel({ + tbar: appBar({ + icon: Icon.sun({size: '2x', prefix: 'fal'}), + title: 'Weather V2', + leftItems: [viewManager()], + rightItems: [appBarSeparator()], + appMenuButtonProps: {hideLogoutItem: false} + }), + item: dashCanvas({model: weatherV2DashModel.dashCanvasModel}), + className: 'weather-v2-app' + }); + } +}); diff --git a/client-app/src/examples/weatherv2/AppModel.ts b/client-app/src/examples/weatherv2/AppModel.ts new file mode 100644 index 000000000..cf3eb2f3c --- /dev/null +++ b/client-app/src/examples/weatherv2/AppModel.ts @@ -0,0 +1,32 @@ +import {managed, XH} from '@xh/hoist/core'; +import {ViewManagerModel} from '@xh/hoist/cmp/viewmanager'; +import { + autoRefreshAppOption, + themeAppOption, + sizingModeAppOption +} from '@xh/hoist/desktop/cmp/appOption'; +import {BaseAppModel} from '../../BaseAppModel'; +import {WeatherV2DashModel} from './dash/WeatherV2DashModel'; + +export class AppModel extends BaseAppModel { + static instance: AppModel; + @managed weatherV2DashModel: WeatherV2DashModel; + @managed weatherViewManager: ViewManagerModel; + + override async initAsync() { + await super.initAsync(); + + this.weatherViewManager = await ViewManagerModel.createAsync({ + type: 'weatherDashboardV2', + typeDisplayName: 'Layout', + enableDefault: true, + manageGlobal: XH.getUser().isHoistAdmin + }); + + this.weatherV2DashModel = new WeatherV2DashModel(this.weatherViewManager); + } + + override getAppOptions() { + return [themeAppOption(), sizingModeAppOption(), autoRefreshAppOption()]; + } +} diff --git a/client-app/src/examples/weatherv2/Icons.ts b/client-app/src/examples/weatherv2/Icons.ts new file mode 100644 index 000000000..5670303ec --- /dev/null +++ b/client-app/src/examples/weatherv2/Icons.ts @@ -0,0 +1,17 @@ +import {library} from '@fortawesome/fontawesome-svg-core'; +import { + faCloudRain, + faDropletPercent, + faCalendarDays, + faTemperatureHalf, + faWind +} from '@fortawesome/pro-regular-svg-icons'; +import {Icon} from '@xh/hoist/icon'; + +library.add(faCloudRain, faDropletPercent, faCalendarDays, faTemperatureHalf, faWind); + +export const temperatureIcon = (opts = {}) => Icon.icon({iconName: 'temperature-half', ...opts}); +export const cloudRainIcon = (opts = {}) => Icon.icon({iconName: 'cloud-rain', ...opts}); +export const windIcon = (opts = {}) => Icon.icon({iconName: 'wind', ...opts}); +export const dropletPercentIcon = (opts = {}) => Icon.icon({iconName: 'droplet-percent', ...opts}); +export const calendarDaysIcon = (opts = {}) => Icon.icon({iconName: 'calendar-days', ...opts}); diff --git a/client-app/src/examples/weatherv2/Types.ts b/client-app/src/examples/weatherv2/Types.ts new file mode 100644 index 000000000..0e76e315d --- /dev/null +++ b/client-app/src/examples/weatherv2/Types.ts @@ -0,0 +1,76 @@ +/** + * Normalized weather data types for V2. + * Widgets consume these types — not raw API responses. + */ + +/** Cached weather data for a single city. */ +export interface WeatherData { + city: string; + current: NormalizedCurrent; + forecast: NormalizedForecastEntry[]; + fetchedAt: number; +} + +/** Normalized current conditions. */ +export interface NormalizedCurrent { + temp: number; + feelsLike: number; + humidity: number; + pressure: number; + windSpeed: number; + windGust?: number; + conditions: string; + description: string; + iconCode: string; +} + +/** Normalized forecast entry (3-hour interval). */ +export interface NormalizedForecastEntry { + dt: number; + temp: number; + feelsLike: number; + tempMin: number; + tempMax: number; + humidity: number; + pressure: number; + windSpeed: number; + windGust?: number; + precipProbability: number; + precipVolume: number; + conditions: string; + description: string; + iconCode: string; +} + +/** Raw API response types — used only for parsing server responses. */ +export interface CurrentWeatherResponse { + weather: WeatherCondition[]; + main: {temp: number; feels_like: number; humidity: number; pressure: number}; + wind: {speed: number; gust?: number}; +} + +export interface ForecastResponse { + list: ForecastItem[]; +} + +export interface ForecastItem { + dt: number; + main: { + temp: number; + feels_like: number; + humidity: number; + pressure: number; + temp_max: number; + temp_min: number; + }; + weather: WeatherCondition[]; + wind: {speed: number; gust?: number}; + rain?: {'3h': number}; + pop: number; +} + +export interface WeatherCondition { + main: string; + description: string; + icon: string; +} diff --git a/client-app/src/examples/weatherv2/WeatherV2.scss b/client-app/src/examples/weatherv2/WeatherV2.scss new file mode 100644 index 000000000..290a8caf9 --- /dev/null +++ b/client-app/src/examples/weatherv2/WeatherV2.scss @@ -0,0 +1,36 @@ +.weather-v2-app { + height: 100%; +} + +.weather-v2-current-conditions { + height: 100%; + + &__gauge { + flex: 1; + min-height: 0; + width: 100%; + max-width: 400px; + } + + &__details { + padding: 4px 12px 8px; + align-items: center; + justify-content: center; + gap: 8px; + } + + &__icon { + width: 48px; + height: 48px; + } + + &__text { + font-size: 12px; + gap: 2px; + } + + &__description { + font-weight: 600; + font-size: 13px; + } +} diff --git a/client-app/src/examples/weatherv2/dash/WeatherDataModel.ts b/client-app/src/examples/weatherv2/dash/WeatherDataModel.ts new file mode 100644 index 000000000..409747db1 --- /dev/null +++ b/client-app/src/examples/weatherv2/dash/WeatherDataModel.ts @@ -0,0 +1,126 @@ +import {HoistModel, LoadSpec, XH} from '@xh/hoist/core'; +import {action, makeObservable, observable} from '@xh/hoist/mobx'; +import { + WeatherData, + NormalizedCurrent, + NormalizedForecastEntry, + CurrentWeatherResponse, + ForecastResponse +} from '../Types'; + +/** Client-side staleness threshold — 5 minutes. */ +const STALE_MS = 5 * 60 * 1000; + +/** + * Shared data provider for V2 weather widgets. + * + * Maintains a per-city cache of normalized weather data. Widgets call + * `ensureDataAsync(city)` to trigger a fetch (if not cached or stale), + * then read the observable cache reactively. + */ +export class WeatherDataModel extends HoistModel { + /** Observable cache: city → WeatherData. Replace the map reference to trigger reactions. */ + @observable.ref + private _cache = new Map(); + + constructor() { + super(); + makeObservable(this); + } + + /** Get cached data for a city (synchronous, may return null if not yet loaded). */ + getData(city: string): WeatherData | null { + return this._cache.get(city) ?? null; + } + + /** + * Ensure data is loaded for a city. Fetches from server if not cached or stale. + * Returns the cached data (which is also observable via `getData`). + */ + async ensureDataAsync(city: string, loadSpec?: LoadSpec): Promise { + const cached = this._cache.get(city); + if (cached && !this.isStale(cached)) return cached; + + const [currentRaw, forecastRaw] = await Promise.all([ + XH.fetchJson({url: 'weather/current', params: {city}, loadSpec}), + XH.fetchJson({url: 'weather/forecast', params: {city}, loadSpec}) + ]); + + if (loadSpec?.isStale) return cached ?? this.emptyData(city); + + const data = this.normalize(city, currentRaw, forecastRaw); + this.updateCache(city, data); + return data; + } + + @action + private updateCache(city: string, data: WeatherData) { + const next = new Map(this._cache); + next.set(city, data); + this._cache = next; + } + + private isStale(data: WeatherData): boolean { + return Date.now() - data.fetchedAt > STALE_MS; + } + + private normalize( + city: string, + current: CurrentWeatherResponse, + forecast: ForecastResponse + ): WeatherData { + const normalizedCurrent: NormalizedCurrent = { + temp: current.main.temp, + feelsLike: current.main.feels_like, + humidity: current.main.humidity, + pressure: current.main.pressure, + windSpeed: current.wind.speed, + windGust: current.wind.gust, + conditions: current.weather?.[0]?.main ?? 'Unknown', + description: current.weather?.[0]?.description ?? '', + iconCode: current.weather?.[0]?.icon ?? '01d' + }; + + const normalizedForecast: NormalizedForecastEntry[] = (forecast.list ?? []).map(item => ({ + dt: item.dt * 1000, + temp: item.main.temp, + feelsLike: item.main.feels_like, + tempMin: item.main.temp_min, + tempMax: item.main.temp_max, + humidity: item.main.humidity, + pressure: item.main.pressure, + windSpeed: item.wind.speed, + windGust: item.wind.gust, + precipProbability: Math.round(item.pop * 100), + precipVolume: item.rain?.['3h'] ?? 0, + conditions: item.weather?.[0]?.main ?? 'Unknown', + description: item.weather?.[0]?.description ?? '', + iconCode: item.weather?.[0]?.icon ?? '01d' + })); + + return { + city, + current: normalizedCurrent, + forecast: normalizedForecast, + fetchedAt: Date.now() + }; + } + + private emptyData(city: string): WeatherData { + return { + city, + current: { + temp: 0, + feelsLike: 0, + humidity: 0, + pressure: 0, + windSpeed: 0, + conditions: 'Unknown', + description: '', + iconCode: '01d' + }, + forecast: [], + fetchedAt: 0 + }; + } +} diff --git a/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts b/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts new file mode 100644 index 000000000..94769d770 --- /dev/null +++ b/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts @@ -0,0 +1,37 @@ +import {HoistModel, managed} from '@xh/hoist/core'; +import {ViewManagerModel} from '@xh/hoist/cmp/viewmanager'; +import {DashCanvasModel} from '@xh/hoist/desktop/cmp/dash'; +import {makeObservable} from '@xh/hoist/mobx'; +import {WiringModel} from './WiringModel'; +import {WeatherDataModel} from './WeatherDataModel'; + +/** + * Central model for the Weather V2 dashboard. + * + * Owns the DashCanvasModel (layout + widgets), the WiringModel (inter-widget + * communication), and the WeatherDataModel (shared data cache). + */ +export class WeatherV2DashModel extends HoistModel { + @managed wiringModel: WiringModel; + @managed weatherDataModel: WeatherDataModel; + @managed dashCanvasModel: DashCanvasModel; + + viewManagerModel: ViewManagerModel; + + constructor(viewManagerModel: ViewManagerModel) { + super(); + makeObservable(this); + + this.viewManagerModel = viewManagerModel; + this.wiringModel = new WiringModel(); + this.weatherDataModel = new WeatherDataModel(); + + // DashCanvasModel is initialized with no viewSpecs or initialState yet. + // Widgets will be registered in Phase 3 as they're implemented. + this.dashCanvasModel = new DashCanvasModel({ + persistWith: {viewManagerModel}, + viewSpecs: [], + initialState: [] + }); + } +} diff --git a/client-app/src/examples/weatherv2/dash/WeatherWidgetModel.ts b/client-app/src/examples/weatherv2/dash/WeatherWidgetModel.ts new file mode 100644 index 000000000..7897e71bc --- /dev/null +++ b/client-app/src/examples/weatherv2/dash/WeatherWidgetModel.ts @@ -0,0 +1,73 @@ +import {HoistModel, lookup} from '@xh/hoist/core'; +import {DashViewModel} from '@xh/hoist/desktop/cmp/dash'; +import {WidgetMeta, BindingSpec} from './types'; +import {WiringModel} from './WiringModel'; + +/** + * Abstract base class for all V2 weather dashboard widget models. + * + * Provides: + * - Access to the parent DashViewModel (for persistence). + * - Input resolution via the WiringModel. + * - Output publication via the WiringModel. + * + * Subclasses must define a static `meta: WidgetMeta` property + * and register it with the WidgetRegistry. + */ +export abstract class WeatherWidgetModel extends HoistModel { + /** Static metadata — override in every subclass. */ + static meta: WidgetMeta; + + @lookup(() => DashViewModel) + viewModel: DashViewModel; + + override onLinked() { + super.onLinked(); + this.persistWith = {dashViewModel: this.viewModel}; + } + + //-------------------------------------------------- + // Input Resolution + //-------------------------------------------------- + + /** + * Resolve a named input to its current value. + * Reads the binding from persisted viewState and resolves it + * through the WiringModel. Returns the input's default value + * if no binding is defined. + */ + protected resolveInput(inputName: string): T | undefined { + const bindings = this.viewModel.viewState?.bindings; + const binding: BindingSpec | undefined = bindings?.[inputName]; + + if (binding) { + const resolved = this.wiringModel.resolveBinding(binding); + if (resolved !== undefined) return resolved as T; + } + + // Fall back to declared default + const meta = (this.constructor as any).meta as WidgetMeta; + const inputDef = meta?.inputs?.find(i => i.name === inputName); + return inputDef?.default as T; + } + + //-------------------------------------------------- + // Output Publication + //-------------------------------------------------- + + /** Publish a named output value to the wiring model. */ + protected publishOutput(name: string, value: any) { + this.wiringModel.publishOutput(this.viewModel.id, name, value); + } + + //-------------------------------------------------- + // Internal + //-------------------------------------------------- + + /** Access to the shared WiringModel via AppModel singleton. */ + private get wiringModel(): WiringModel { + // Dynamic import to avoid circular dependency — AppModel imports widgets which import this. + const {AppModel} = require('../AppModel'); + return AppModel.instance.weatherV2DashModel.wiringModel; + } +} diff --git a/client-app/src/examples/weatherv2/dash/WidgetRegistry.ts b/client-app/src/examples/weatherv2/dash/WidgetRegistry.ts new file mode 100644 index 000000000..3fd9d9f33 --- /dev/null +++ b/client-app/src/examples/weatherv2/dash/WidgetRegistry.ts @@ -0,0 +1,94 @@ +import {WidgetMeta} from './types'; + +/** + * Singleton registry of all V2 widget type schemas. + * Widget models register their static `meta` on module load. + * Used for validation, LLM prompt generation, and inspector display. + */ +class WidgetRegistryImpl { + private _schemas = new Map(); + + /** Register a widget type schema. */ + register(meta: WidgetMeta) { + this._schemas.set(meta.id, meta); + } + + /** Get schema for a widget type. */ + get(id: string): WidgetMeta | undefined { + return this._schemas.get(id); + } + + /** Get all registered widget schemas. */ + getAll(): WidgetMeta[] { + return Array.from(this._schemas.values()); + } + + /** Get all registered widget type IDs. */ + getIds(): string[] { + return Array.from(this._schemas.keys()); + } + + /** Check if a widget type is registered. */ + has(id: string): boolean { + return this._schemas.has(id); + } + + /** + * Generate a structured text description of all widget types + * for inclusion in the LLM system prompt. + */ + generateLLMPrompt(): string { + const widgets = this.getAll(); + if (!widgets.length) return 'No widget types registered.'; + + return widgets + .map(meta => { + const lines = [ + `### ${meta.id} — ${meta.title} [${meta.category}]`, + meta.description + ]; + + if (meta.inputs.length) { + lines.push('Inputs:'); + for (const input of meta.inputs) { + const req = input.required ? 'required' : 'optional'; + const def = + input.default !== undefined + ? `, default: ${JSON.stringify(input.default)}` + : ''; + lines.push( + ` - ${input.name} (${input.type}, ${req}${def}) — ${input.description}` + ); + } + } + + if (meta.outputs.length) { + lines.push('Outputs:'); + for (const output of meta.outputs) { + lines.push(` - ${output.name} (${output.type}) — ${output.description}`); + } + } + + const configEntries = Object.entries(meta.config); + if (configEntries.length) { + lines.push('Config:'); + for (const [key, def] of configEntries) { + const typeStr = + def.type === 'enum' ? `enum: ${def.enum?.join('|')}` : def.type; + const defStr = + def.default !== undefined + ? `, default: ${JSON.stringify(def.default)}` + : ''; + lines.push(` - ${key} (${typeStr}${defStr}) — ${def.description}`); + } + } + + lines.push(`Default size: ${meta.defaultSize.w}×${meta.defaultSize.h}`); + return lines.join('\n'); + }) + .join('\n\n'); + } +} + +/** Singleton widget registry instance. */ +export const widgetRegistry = new WidgetRegistryImpl(); diff --git a/client-app/src/examples/weatherv2/dash/WiringModel.ts b/client-app/src/examples/weatherv2/dash/WiringModel.ts new file mode 100644 index 000000000..66bf679ed --- /dev/null +++ b/client-app/src/examples/weatherv2/dash/WiringModel.ts @@ -0,0 +1,61 @@ +import {HoistModel} from '@xh/hoist/core'; +import {action, makeObservable, observable} from '@xh/hoist/mobx'; +import {BindingSpec} from './types'; + +/** + * Runtime wiring coordinator for the V2 dashboard. + * + * Manages an observable map of widget outputs. Widgets publish outputs here; + * other widgets resolve their input bindings by reading from this map. + * MobX reactivity ensures changes propagate automatically. + */ +export class WiringModel extends HoistModel { + /** Map: widgetInstanceId → {outputName → current value} */ + @observable.ref + private _outputs = new Map>(); + + constructor() { + super(); + makeObservable(this); + } + + /** Publish a named output value from a widget instance. */ + @action + publishOutput(widgetId: string, outputName: string, value: any) { + const widgetOutputs = new Map(this._outputs.get(widgetId) ?? new Map()); + widgetOutputs.set(outputName, value); + const next = new Map(this._outputs); + next.set(widgetId, widgetOutputs); + this._outputs = next; + } + + /** Resolve a binding spec to its current value. */ + resolveBinding(binding: BindingSpec): any { + if (!binding) return undefined; + if ('const' in binding) return binding.const; + if ('fromWidget' in binding) { + return this._outputs.get(binding.fromWidget)?.get(binding.output); + } + return undefined; + } + + /** Get all current output values for a widget. */ + getOutputs(widgetId: string): Map | undefined { + return this._outputs.get(widgetId); + } + + /** Get all outputs for all widgets — used by the inspector. */ + get allOutputs(): Map> { + return this._outputs; + } + + /** Remove all outputs for a widget (called on widget removal). */ + @action + removeWidget(widgetId: string) { + if (this._outputs.has(widgetId)) { + const next = new Map(this._outputs); + next.delete(widgetId); + this._outputs = next; + } + } +} diff --git a/client-app/src/examples/weatherv2/dash/types.ts b/client-app/src/examples/weatherv2/dash/types.ts new file mode 100644 index 000000000..9fdfaaef4 --- /dev/null +++ b/client-app/src/examples/weatherv2/dash/types.ts @@ -0,0 +1,93 @@ +/** + * Type definitions for the V2 dashboard wiring, widget schema, and spec system. + */ + +//-------------------------------------------------- +// Widget Schema Types +//-------------------------------------------------- + +/** Full metadata for a widget type — schema, inputs, outputs, config. */ +export interface WidgetMeta { + id: string; + title: string; + description: string; + category: 'input' | 'display' | 'utility'; + inputs: InputDef[]; + outputs: OutputDef[]; + config: Record; + defaultSize: {w: number; h: number}; + minSize?: {w?: number; h?: number}; + maxSize?: {w?: number; h?: number}; +} + +/** Declared input a widget accepts. */ +export interface InputDef { + name: string; + type: string; + required?: boolean; + default?: any; + description: string; +} + +/** Declared output a widget publishes. */ +export interface OutputDef { + name: string; + type: string; + description: string; +} + +/** Config property definition within a widget schema. */ +export interface ConfigPropertyDef { + type: 'string' | 'number' | 'boolean' | 'enum' | 'string[]'; + description: string; + default?: any; + enum?: string[]; + min?: number; + max?: number; + required?: boolean; +} + +//-------------------------------------------------- +// Wiring / Binding Types +//-------------------------------------------------- + +/** A binding connects a widget input to a source. */ +export type BindingSpec = {fromWidget: string; output: string} | {const: any}; + +/** Map of input name → binding spec for a widget instance. */ +export type BindingsMap = Record; + +//-------------------------------------------------- +// Dashboard Spec Types +//-------------------------------------------------- + +/** The full dashboard spec — matches DashCanvasModel's persisted state. */ +export interface DashSpec { + version?: number; + state: DashWidgetState[]; +} + +/** State for a single widget instance in the spec. */ +export interface DashWidgetState { + viewSpecId: string; + layout: {x: number; y: number; w: number; h: number}; + title?: string; + state?: Record; +} + +//-------------------------------------------------- +// Validation Types +//-------------------------------------------------- + +export interface ValidationResult { + valid: boolean; + errors: ValidationMessage[]; + warnings: ValidationMessage[]; +} + +export interface ValidationMessage { + level: 'error' | 'warning'; + path: string; + code: string; + message: string; +} From 760d56c7a757e5e450df1668c2e1cdcf9135155e Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Fri, 27 Feb 2026 23:45:10 -0800 Subject: [PATCH 04/41] Implement Phase 3 initial widget set with wiring Five core widgets for Weather V2: CityChooser (input, publishes selectedCity), CurrentConditions (temp gauge + details), ForecastChart (multi-series configurable chart), PrecipChart (dual-axis probability + volume), and SummaryGrid (daily tabular overview). All display widgets consume city input via the wiring model. Includes WeatherDataModel for shared per-city data caching, unit conversion utilities, and a default dashboard layout with all widgets wired to the city chooser. Co-Authored-By: Claude Opus 4.6 --- .../weatherv2/dash/WeatherV2DashModel.ts | 104 ++++++- .../src/examples/weatherv2/dash/unitUtils.ts | 44 +++ .../weatherv2/widgets/CityChooserWidget.ts | 117 ++++++++ .../widgets/CurrentConditionsWidget.ts | 260 ++++++++++++++++++ .../weatherv2/widgets/ForecastChartWidget.ts | 216 +++++++++++++++ .../weatherv2/widgets/PrecipChartWidget.ts | 206 ++++++++++++++ .../weatherv2/widgets/SummaryGridWidget.ts | 215 +++++++++++++++ 7 files changed, 1158 insertions(+), 4 deletions(-) create mode 100644 client-app/src/examples/weatherv2/dash/unitUtils.ts create mode 100644 client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts create mode 100644 client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts create mode 100644 client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts create mode 100644 client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts create mode 100644 client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts diff --git a/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts b/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts index 94769d770..2bea564bc 100644 --- a/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts +++ b/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts @@ -1,10 +1,18 @@ import {HoistModel, managed} from '@xh/hoist/core'; import {ViewManagerModel} from '@xh/hoist/cmp/viewmanager'; import {DashCanvasModel} from '@xh/hoist/desktop/cmp/dash'; +import {Icon} from '@xh/hoist/icon'; import {makeObservable} from '@xh/hoist/mobx'; import {WiringModel} from './WiringModel'; import {WeatherDataModel} from './WeatherDataModel'; +import {temperatureIcon, cloudRainIcon, calendarDaysIcon} from '../Icons'; +import {cityChooserWidget} from '../widgets/CityChooserWidget'; +import {currentConditionsWidget} from '../widgets/CurrentConditionsWidget'; +import {forecastChartWidget} from '../widgets/ForecastChartWidget'; +import {precipChartWidget} from '../widgets/PrecipChartWidget'; +import {summaryGridWidget} from '../widgets/SummaryGridWidget'; + /** * Central model for the Weather V2 dashboard. * @@ -26,12 +34,100 @@ export class WeatherV2DashModel extends HoistModel { this.wiringModel = new WiringModel(); this.weatherDataModel = new WeatherDataModel(); - // DashCanvasModel is initialized with no viewSpecs or initialState yet. - // Widgets will be registered in Phase 3 as they're implemented. this.dashCanvasModel = new DashCanvasModel({ persistWith: {viewManagerModel}, - viewSpecs: [], - initialState: [] + viewSpecs: [ + { + id: 'cityChooser', + title: 'City Chooser', + icon: Icon.globe(), + content: cityChooserWidget, + unique: false, + width: 3, + height: 2 + }, + { + id: 'currentConditions', + title: 'Current Conditions', + icon: Icon.sun(), + content: currentConditionsWidget, + unique: false, + width: 4, + height: 5 + }, + { + id: 'forecastChart', + title: 'Forecast Chart', + icon: temperatureIcon(), + content: forecastChartWidget, + unique: false, + width: 8, + height: 5 + }, + { + id: 'precipChart', + title: 'Precipitation', + icon: cloudRainIcon(), + content: precipChartWidget, + unique: false, + width: 6, + height: 5 + }, + { + id: 'summaryGrid', + title: '5-Day Summary', + icon: calendarDaysIcon(), + content: summaryGridWidget, + unique: false, + width: 6, + height: 5 + } + ], + initialState: [ + { + viewSpecId: 'cityChooser', + layout: {x: 0, y: 0, w: 3, h: 2}, + state: {selectedCity: 'New York'} + }, + { + viewSpecId: 'currentConditions', + layout: {x: 3, y: 0, w: 4, h: 5}, + state: { + bindings: { + city: {fromWidget: 'cityChooser', output: 'selectedCity'} + } + } + }, + { + viewSpecId: 'forecastChart', + layout: {x: 7, y: 0, w: 5, h: 5}, + state: { + bindings: { + city: {fromWidget: 'cityChooser', output: 'selectedCity'} + }, + series: ['temp', 'feelsLike'], + chartType: 'line' + } + }, + { + viewSpecId: 'precipChart', + layout: {x: 0, y: 5, w: 6, h: 5}, + state: { + bindings: { + city: {fromWidget: 'cityChooser', output: 'selectedCity'} + } + } + }, + { + viewSpecId: 'summaryGrid', + layout: {x: 6, y: 5, w: 6, h: 5}, + state: { + bindings: { + city: {fromWidget: 'cityChooser', output: 'selectedCity'} + } + } + } + ] }); } } diff --git a/client-app/src/examples/weatherv2/dash/unitUtils.ts b/client-app/src/examples/weatherv2/dash/unitUtils.ts new file mode 100644 index 000000000..55c1d8fde --- /dev/null +++ b/client-app/src/examples/weatherv2/dash/unitUtils.ts @@ -0,0 +1,44 @@ +/** + * Unit conversion utilities. Server returns imperial (°F, mph). + * Display widgets convert to metric when their `units` input is 'metric'. + */ + +/** Convert Fahrenheit to Celsius. */ +export function toMetricTemp(f: number): number { + return Math.round(((f - 32) * 5) / 9); +} + +/** Convert mph to m/s. */ +export function toMetricWind(mph: number): number { + return Math.round(mph * 0.44704 * 10) / 10; +} + +/** Format temperature with unit suffix. */ +export function fmtTemp(f: number, units: string = 'imperial'): string { + return units === 'metric' ? `${toMetricTemp(f)}°C` : `${Math.round(f)}°F`; +} + +/** Format wind speed with unit suffix. */ +export function fmtWind(mph: number, units: string = 'imperial'): string { + return units === 'metric' ? `${toMetricWind(mph)} m/s` : `${Math.round(mph)} mph`; +} + +/** Get temperature unit label. */ +export function tempUnit(units: string = 'imperial'): string { + return units === 'metric' ? '°C' : '°F'; +} + +/** Get wind unit label. */ +export function windUnit(units: string = 'imperial'): string { + return units === 'metric' ? 'm/s' : 'mph'; +} + +/** Convert temperature value for charting (no rounding). */ +export function convertTemp(f: number, units: string = 'imperial'): number { + return units === 'metric' ? ((f - 32) * 5) / 9 : f; +} + +/** Convert wind value for charting (no rounding). */ +export function convertWind(mph: number, units: string = 'imperial'): number { + return units === 'metric' ? mph * 0.44704 : mph; +} diff --git a/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts b/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts new file mode 100644 index 000000000..ec6ad555c --- /dev/null +++ b/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts @@ -0,0 +1,117 @@ +import {hoistCmp, creates} from '@xh/hoist/core'; +import {select} from '@xh/hoist/desktop/cmp/input'; +import {box} from '@xh/hoist/cmp/layout'; +import {bindable, makeObservable} from '@xh/hoist/mobx'; +import {WeatherWidgetModel} from '../dash/WeatherWidgetModel'; +import {widgetRegistry} from '../dash/WidgetRegistry'; +import {WidgetMeta} from '../dash/types'; + +export const CITIES = [ + 'Atlanta', + 'Austin', + 'Boston', + 'Chicago', + 'Dallas', + 'Denver', + 'Houston', + 'Las Vegas', + 'London', + 'Los Angeles', + 'Miami', + 'Minneapolis', + 'Nashville', + 'New York', + 'Paris', + 'Philadelphia', + 'Phoenix', + 'Portland', + 'San Antonio', + 'San Diego', + 'San Francisco', + 'Seattle', + 'Sydney', + 'Tokyo', + 'Toronto' +]; + +//-------------------------------------------------- +// Model +//-------------------------------------------------- +export class CityChooserModel extends WeatherWidgetModel { + static override meta: WidgetMeta = { + id: 'cityChooser', + title: 'City Chooser', + description: + 'Dropdown selector that emits the selected city name. Other widgets bind to this to display data for the chosen city.', + category: 'input', + inputs: [], + outputs: [ + {name: 'selectedCity', type: 'string', description: 'The currently selected city name.'} + ], + config: { + selectedCity: { + type: 'string', + description: 'Initially selected city.', + default: 'New York' + }, + enableSearch: { + type: 'boolean', + description: 'Enable type-ahead filtering in the dropdown.', + default: true + } + }, + defaultSize: {w: 3, h: 2}, + minSize: {w: 2, h: 1} + }; + + @bindable selectedCity: string = 'New York'; + + constructor() { + super(); + makeObservable(this); + } + + override onLinked() { + super.onLinked(); + this.markPersist('selectedCity'); + + // Publish output whenever city changes + this.addReaction({ + track: () => this.selectedCity, + run: city => this.publishOutput('selectedCity', city), + fireImmediately: true + }); + } + + get cities(): string[] { + return this.viewModel.viewState?.cities ?? CITIES; + } + + get enableSearch(): boolean { + return this.viewModel.viewState?.enableSearch ?? true; + } +} + +widgetRegistry.register(CityChooserModel.meta); + +//-------------------------------------------------- +// Component +//-------------------------------------------------- +export const cityChooserWidget = hoistCmp.factory({ + displayName: 'CityChooserWidget', + model: creates(CityChooserModel), + + render({model}) { + return box({ + padding: 8, + alignItems: 'center', + justifyContent: 'center', + item: select({ + bind: 'selectedCity', + options: model.cities, + enableFilter: model.enableSearch, + width: '100%' + }) + }); + } +}); diff --git a/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts new file mode 100644 index 000000000..025ff4761 --- /dev/null +++ b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts @@ -0,0 +1,260 @@ +import {chart, ChartModel} from '@xh/hoist/cmp/chart'; +import {div, hbox, img, vbox} from '@xh/hoist/cmp/layout'; +import {creates, hoistCmp, LoadSpec, managed} from '@xh/hoist/core'; +import {computed, makeObservable} from '@xh/hoist/mobx'; +import {WeatherWidgetModel} from '../dash/WeatherWidgetModel'; +import {widgetRegistry} from '../dash/WidgetRegistry'; +import {fmtTemp, fmtWind} from '../dash/unitUtils'; +import {WidgetMeta} from '../dash/types'; +import {WeatherData} from '../Types'; +import '../WeatherV2.scss'; + +//-------------------------------------------------- +// Model +//-------------------------------------------------- +export class CurrentConditionsModel extends WeatherWidgetModel { + static override meta: WidgetMeta = { + id: 'currentConditions', + title: 'Current Conditions', + description: + 'Current weather snapshot — temperature gauge, conditions icon, and key details.', + category: 'display', + inputs: [ + { + name: 'city', + type: 'string', + required: true, + default: 'New York', + description: 'City to display.' + }, + { + name: 'units', + type: 'string', + required: false, + default: 'imperial', + description: 'Unit system: "imperial" or "metric".' + } + ], + outputs: [], + config: { + showFeelsLike: { + type: 'boolean', + description: 'Show feels-like temperature.', + default: true + }, + showHumidity: { + type: 'boolean', + description: 'Show humidity percentage.', + default: true + }, + showWind: {type: 'boolean', description: 'Show wind speed.', default: true} + }, + defaultSize: {w: 4, h: 5}, + minSize: {w: 3, h: 3} + }; + + @managed chartModel: ChartModel; + + constructor() { + super(); + makeObservable(this); + } + + @computed + get city(): string { + return this.resolveInput('city') ?? 'New York'; + } + + @computed + get units(): string { + return this.resolveInput('units') ?? 'imperial'; + } + + get showFeelsLike(): boolean { + return this.viewModel.viewState?.showFeelsLike ?? true; + } + + get showHumidity(): boolean { + return this.viewModel.viewState?.showHumidity ?? true; + } + + get showWind(): boolean { + return this.viewModel.viewState?.showWind ?? true; + } + + override onLinked() { + super.onLinked(); + + this.chartModel = this.createChartModel(); + + this.addReaction({ + track: () => this.city, + run: () => this.loadAsync(), + fireImmediately: true + }); + + // Update chart when data or units change + this.addReaction({ + track: () => [this.weatherData, this.units], + run: () => this.updateChart() + }); + } + + override async doLoadAsync(loadSpec: LoadSpec) { + const {city} = this; + if (!city) return; + try { + const {AppModel} = require('../AppModel'); + await AppModel.instance.weatherV2DashModel.weatherDataModel.ensureDataAsync( + city, + loadSpec + ); + } catch (e) { + // Data model handles caching — errors will show via lastLoadException + } + } + + @computed + get weatherData(): WeatherData | null { + const {AppModel} = require('../AppModel'); + return AppModel.instance.weatherV2DashModel.weatherDataModel.getData(this.city); + } + + private createChartModel(): ChartModel { + return new ChartModel({ + highchartsConfig: { + chart: {type: 'solidgauge'}, + title: {text: null}, + pane: { + center: ['50%', '70%'], + size: '130%', + startAngle: -90, + endAngle: 90, + background: { + innerRadius: '60%', + outerRadius: '100%', + shape: 'arc', + borderWidth: 0, + backgroundColor: '#eeeeee' + } + }, + yAxis: { + min: this.units === 'metric' ? -30 : -20, + max: this.units === 'metric' ? 50 : 120, + lineWidth: 0, + tickWidth: 0, + minorTickInterval: null, + tickAmount: 2, + labels: {y: 16, style: {fontSize: '12px'}}, + stops: [ + [0.15, '#2196F3'], + [0.4, '#f7931c'], + [0.6, '#FF9800'], + [0.85, '#F44336'] + ] + }, + tooltip: {enabled: false}, + credits: {enabled: false}, + plotOptions: { + solidgauge: { + dataLabels: { + y: -25, + borderWidth: 0, + useHTML: true + } + } + }, + series: [{name: 'Temperature', data: [0], innerRadius: '60%'}] + } + }); + } + + private updateChart() { + const data = this.weatherData; + if (!data?.current) return; + + const {units} = this, + temp = data.current.temp, + displayTemp = fmtTemp(temp, units); + + const chartTemp = units === 'metric' ? Math.round(((temp - 32) * 5) / 9) : Math.round(temp); + + this.chartModel.setSeries([ + { + name: 'Temperature', + data: [chartTemp], + innerRadius: '60%', + dataLabels: { + format: `
${displayTemp}
` + } + } + ]); + + // Update gauge range for units + const yAxis = units === 'metric' ? {min: -30, max: 50} : {min: -20, max: 120}; + this.chartModel.setHighchartsConfig({yAxis}); + } +} + +widgetRegistry.register(CurrentConditionsModel.meta); + +//-------------------------------------------------- +// Component +//-------------------------------------------------- +export const currentConditionsWidget = hoistCmp.factory({ + displayName: 'CurrentConditionsWidget', + model: creates(CurrentConditionsModel), + + render({model}) { + const data = model.weatherData; + if (!data?.current) return null; + + const {current} = data, + {units} = model; + + const details = []; + if (model.showFeelsLike && current.feelsLike != null) { + details.push(`Feels like ${fmtTemp(current.feelsLike, units)}`); + } + if (model.showHumidity && current.humidity != null) { + details.push(`Humidity: ${current.humidity}%`); + } + if (model.showWind && current.windSpeed != null) { + details.push(`Wind: ${fmtWind(current.windSpeed, units)}`); + } + + const description = + current.description.charAt(0).toUpperCase() + current.description.slice(1); + + return vbox({ + className: 'weather-v2-current-conditions', + alignItems: 'center', + flex: 1, + items: [ + chart({className: 'weather-v2-current-conditions__gauge'}), + hbox({ + className: 'weather-v2-current-conditions__details', + items: [ + current.iconCode + ? img({ + className: 'weather-v2-current-conditions__icon', + src: `https://openweathermap.org/img/wn/${current.iconCode}@2x.png`, + alt: description + }) + : null, + vbox({ + className: 'weather-v2-current-conditions__text', + items: [ + div({ + className: 'weather-v2-current-conditions__description', + item: description + }), + div({item: details.join(' · ')}) + ] + }) + ] + }) + ] + }); + } +}); diff --git a/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts new file mode 100644 index 000000000..621466528 --- /dev/null +++ b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts @@ -0,0 +1,216 @@ +import {chart, ChartModel} from '@xh/hoist/cmp/chart'; +import {creates, hoistCmp, LoadSpec, managed} from '@xh/hoist/core'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {fmtDate} from '@xh/hoist/format'; +import {computed, makeObservable} from '@xh/hoist/mobx'; +import {WeatherWidgetModel} from '../dash/WeatherWidgetModel'; +import {widgetRegistry} from '../dash/WidgetRegistry'; +import {convertTemp, tempUnit} from '../dash/unitUtils'; +import {WidgetMeta} from '../dash/types'; +import {WeatherData} from '../Types'; + +//-------------------------------------------------- +// Model +//-------------------------------------------------- +export class ForecastChartModel extends WeatherWidgetModel { + static override meta: WidgetMeta = { + id: 'forecastChart', + title: 'Forecast Chart', + description: + 'Multi-series line/area/column chart showing forecast data over time. Configurable series selection and chart type.', + category: 'display', + inputs: [ + { + name: 'city', + type: 'string', + required: true, + default: 'New York', + description: 'City to show forecast for.' + }, + { + name: 'units', + type: 'string', + required: false, + default: 'imperial', + description: 'Unit system: "imperial" or "metric".' + } + ], + outputs: [], + config: { + series: { + type: 'string[]', + description: + 'Data series to display. Options: "temp", "feelsLike", "humidity", "pressure".', + default: ['temp', 'feelsLike'] + }, + chartType: { + type: 'enum', + description: 'Chart rendering style.', + enum: ['line', 'area', 'column'], + default: 'line' + }, + showLegend: {type: 'boolean', description: 'Show chart legend.', default: true} + }, + defaultSize: {w: 8, h: 5}, + minSize: {w: 4, h: 3} + }; + + @managed chartModel: ChartModel; + + constructor() { + super(); + makeObservable(this); + } + + @computed get city(): string { + return this.resolveInput('city') ?? 'New York'; + } + + @computed get units(): string { + return this.resolveInput('units') ?? 'imperial'; + } + + get activeSeries(): string[] { + return this.viewModel.viewState?.series ?? ['temp', 'feelsLike']; + } + + get chartType(): string { + return this.viewModel.viewState?.chartType ?? 'line'; + } + + get showLegend(): boolean { + return this.viewModel.viewState?.showLegend ?? true; + } + + override onLinked() { + super.onLinked(); + this.chartModel = this.createChartModel(); + + this.addReaction({ + track: () => this.city, + run: () => this.loadAsync(), + fireImmediately: true + }); + + this.addReaction({ + track: () => [this.weatherData, this.units, this.activeSeries, this.chartType], + run: () => this.updateChart() + }); + } + + override async doLoadAsync(loadSpec: LoadSpec) { + const {city} = this; + if (!city) return; + try { + const {AppModel} = require('../AppModel'); + await AppModel.instance.weatherV2DashModel.weatherDataModel.ensureDataAsync( + city, + loadSpec + ); + } catch (e) { + // Handled by WeatherDataModel + } + } + + @computed get weatherData(): WeatherData | null { + const {AppModel} = require('../AppModel'); + return AppModel.instance.weatherV2DashModel.weatherDataModel.getData(this.city); + } + + private createChartModel(): ChartModel { + return new ChartModel({ + highchartsConfig: { + chart: {zoomType: 'x'}, + title: {text: null}, + xAxis: { + type: 'datetime', + labels: { + formatter: function () { + return fmtDate(this.value, {fmt: 'ddd ha'}); + } + } + }, + yAxis: { + title: {text: null}, + labels: {format: '{value}'} + }, + tooltip: { + shared: true, + xDateFormat: '%A %b %e, %l:%M %p' + }, + legend: {enabled: this.showLegend}, + credits: {enabled: false} + } + }); + } + + private updateChart() { + const data = this.weatherData; + if (!data?.forecast?.length) return; + + const {forecast} = data, + {units, activeSeries, chartType} = this, + tUnit = tempUnit(units), + seriesData = []; + + const seriesConfig: Record = { + temp: { + name: 'Temperature', + color: '#7cb5ec', + suffix: tUnit, + convert: (v: number) => convertTemp(v, units) + }, + feelsLike: { + name: 'Feels Like', + color: '#f45b5b', + suffix: tUnit, + convert: (v: number) => convertTemp(v, units), + dashStyle: 'ShortDash' + }, + humidity: {name: 'Humidity', color: '#90ed7d', suffix: '%', convert: (v: number) => v}, + pressure: { + name: 'Pressure', + color: '#8085e9', + suffix: ' hPa', + convert: (v: number) => v + } + }; + + for (const key of activeSeries) { + const cfg = seriesConfig[key]; + if (!cfg) continue; + + const points = forecast.map(entry => [ + entry.dt, + Math.round(cfg.convert(entry[key] ?? entry.temp) * 10) / 10 + ]); + + seriesData.push({ + name: cfg.name, + type: chartType === 'line' ? 'spline' : chartType, + data: points, + color: cfg.color, + lineWidth: 2, + marker: {enabled: false}, + dashStyle: cfg.dashStyle, + tooltip: {valueSuffix: cfg.suffix} + }); + } + + this.chartModel.setSeries(seriesData); + } +} + +widgetRegistry.register(ForecastChartModel.meta); + +//-------------------------------------------------- +// Component +//-------------------------------------------------- +export const forecastChartWidget = hoistCmp.factory({ + displayName: 'ForecastChartWidget', + model: creates(ForecastChartModel), + + render() { + return panel({item: chart()}); + } +}); diff --git a/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts new file mode 100644 index 000000000..86ec29387 --- /dev/null +++ b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts @@ -0,0 +1,206 @@ +import {chart, ChartModel} from '@xh/hoist/cmp/chart'; +import {placeholder} from '@xh/hoist/cmp/layout'; +import {creates, hoistCmp, LoadSpec, managed} from '@xh/hoist/core'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {fmtDate} from '@xh/hoist/format'; +import {Icon} from '@xh/hoist/icon'; +import {bindable, computed, makeObservable} from '@xh/hoist/mobx'; +import {WeatherWidgetModel} from '../dash/WeatherWidgetModel'; +import {widgetRegistry} from '../dash/WidgetRegistry'; +import {WidgetMeta} from '../dash/types'; +import {WeatherData} from '../Types'; + +//-------------------------------------------------- +// Model +//-------------------------------------------------- +export class PrecipChartModel extends WeatherWidgetModel { + static override meta: WidgetMeta = { + id: 'precipChart', + title: 'Precipitation', + description: + 'Precipitation probability and volume over the forecast period. Dual-axis column chart.', + category: 'display', + inputs: [ + { + name: 'city', + type: 'string', + required: true, + default: 'New York', + description: 'City to show precipitation for.' + } + ], + outputs: [], + config: { + metric: { + type: 'enum', + description: 'What to display.', + enum: ['probability', 'volume', 'both'], + default: 'both' + }, + showThresholds: { + type: 'boolean', + description: 'Highlight high-probability periods.', + default: false + } + }, + defaultSize: {w: 6, h: 5}, + minSize: {w: 4, h: 3} + }; + + @managed chartModel: ChartModel; + @bindable hasData: boolean = false; + + constructor() { + super(); + makeObservable(this); + } + + @computed get city(): string { + return this.resolveInput('city') ?? 'New York'; + } + + get displayMetric(): string { + return this.viewModel.viewState?.metric ?? 'both'; + } + + override onLinked() { + super.onLinked(); + this.chartModel = this.createChartModel(); + + this.addReaction({ + track: () => this.city, + run: () => this.loadAsync(), + fireImmediately: true + }); + + this.addReaction({ + track: () => [this.weatherData, this.displayMetric], + run: () => this.updateChart() + }); + } + + override async doLoadAsync(loadSpec: LoadSpec) { + const {city} = this; + if (!city) return; + try { + const {AppModel} = require('../AppModel'); + await AppModel.instance.weatherV2DashModel.weatherDataModel.ensureDataAsync( + city, + loadSpec + ); + } catch (e) { + // Handled by WeatherDataModel + } + } + + @computed get weatherData(): WeatherData | null { + const {AppModel} = require('../AppModel'); + return AppModel.instance.weatherV2DashModel.weatherDataModel.getData(this.city); + } + + private createChartModel(): ChartModel { + return new ChartModel({ + highchartsConfig: { + chart: {zoomType: 'x'}, + title: {text: null}, + xAxis: { + type: 'datetime', + labels: { + formatter: function () { + return fmtDate(this.value, {fmt: 'ddd ha'}); + } + } + }, + yAxis: [ + { + title: {text: 'Probability (%)'}, + max: 100, + min: 0, + labels: {format: '{value}%'} + }, + { + title: {text: 'Volume (mm)'}, + min: 0, + opposite: true, + labels: {format: '{value} mm'} + } + ], + tooltip: { + shared: true, + xDateFormat: '%A %b %e, %l:%M %p' + }, + legend: {enabled: true}, + credits: {enabled: false} + } + }); + } + + private updateChart() { + const data = this.weatherData; + if (!data?.forecast?.length) return; + + const {forecast} = data, + {displayMetric} = this, + probData = [], + volumeData = []; + + let anyPrecip = false; + + for (const entry of forecast) { + const time = entry.dt; + if (entry.precipProbability > 0 || entry.precipVolume > 0) anyPrecip = true; + probData.push([time, entry.precipProbability]); + volumeData.push([time, Math.round(entry.precipVolume * 10) / 10]); + } + + this.hasData = anyPrecip; + + if (!anyPrecip) return; + + const series = []; + if (displayMetric === 'probability' || displayMetric === 'both') { + series.push({ + name: 'Probability', + type: 'column', + data: probData, + yAxis: 0, + color: 'rgba(135, 175, 220, 0.6)', + borderWidth: 0, + tooltip: {valueSuffix: '%'} + }); + } + if (displayMetric === 'volume' || displayMetric === 'both') { + series.push({ + name: 'Rain Volume', + type: 'column', + data: volumeData, + yAxis: 1, + color: '#2171b5', + borderWidth: 0, + tooltip: {valueSuffix: ' mm'} + }); + } + + this.chartModel.setSeries(series); + } +} + +widgetRegistry.register(PrecipChartModel.meta); + +//-------------------------------------------------- +// Component +//-------------------------------------------------- +export const precipChartWidget = hoistCmp.factory({ + displayName: 'PrecipChartWidget', + model: creates(PrecipChartModel), + + render({model}) { + return panel({ + item: model.hasData + ? chart() + : placeholder({ + items: [Icon.sun(), 'No precipitation expected in the forecast period'] + }) + }); + } +}); diff --git a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts new file mode 100644 index 000000000..90e821de5 --- /dev/null +++ b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts @@ -0,0 +1,215 @@ +import {grid, GridModel} from '@xh/hoist/cmp/grid'; +import {img} from '@xh/hoist/cmp/layout'; +import {creates, hoistCmp, LoadSpec, managed} from '@xh/hoist/core'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {computed, makeObservable} from '@xh/hoist/mobx'; +import {groupBy} from 'lodash'; +import {WeatherWidgetModel} from '../dash/WeatherWidgetModel'; +import {widgetRegistry} from '../dash/WidgetRegistry'; +import {fmtTemp, fmtWind} from '../dash/unitUtils'; +import {WidgetMeta} from '../dash/types'; +import {WeatherData} from '../Types'; + +//-------------------------------------------------- +// Model +//-------------------------------------------------- +export class SummaryGridModel extends WeatherWidgetModel { + static override meta: WidgetMeta = { + id: 'summaryGrid', + title: '5-Day Summary', + description: + 'Tabular daily overview — one row per day with high/low, conditions, humidity, wind.', + category: 'display', + inputs: [ + { + name: 'city', + type: 'string', + required: true, + default: 'New York', + description: 'City to summarize.' + }, + { + name: 'units', + type: 'string', + required: false, + default: 'imperial', + description: 'Unit system.' + } + ], + outputs: [], + config: { + visibleColumns: { + type: 'string[]', + description: 'Columns to display.', + default: ['date', 'icon', 'conditions', 'high', 'low', 'humidity', 'wind'] + } + }, + defaultSize: {w: 6, h: 5}, + minSize: {w: 4, h: 3} + }; + + @managed gridModel: GridModel; + + constructor() { + super(); + makeObservable(this); + } + + @computed get city(): string { + return this.resolveInput('city') ?? 'New York'; + } + + @computed get units(): string { + return this.resolveInput('units') ?? 'imperial'; + } + + override onLinked() { + super.onLinked(); + this.gridModel = this.createGridModel(); + + this.addReaction({ + track: () => this.city, + run: () => this.loadAsync(), + fireImmediately: true + }); + + this.addReaction({ + track: () => [this.weatherData, this.units], + run: () => this.updateGrid() + }); + } + + override async doLoadAsync(loadSpec: LoadSpec) { + const {city} = this; + if (!city) return; + try { + const {AppModel} = require('../AppModel'); + await AppModel.instance.weatherV2DashModel.weatherDataModel.ensureDataAsync( + city, + loadSpec + ); + } catch (e) { + // Handled by WeatherDataModel + } + } + + @computed get weatherData(): WeatherData | null { + const {AppModel} = require('../AppModel'); + return AppModel.instance.weatherV2DashModel.weatherDataModel.getData(this.city); + } + + private createGridModel(): GridModel { + const {units} = this; + return new GridModel({ + sortBy: 'date', + emptyText: 'No forecast data available.', + columns: [ + { + field: 'date', + headerName: 'Day', + width: 120, + renderer: v => { + const d = new Date(v); + return d.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric' + }); + } + }, + { + field: 'icon', + headerName: '', + width: 50, + rendererIsComplex: true, + renderer: (v, {record}) => { + return img({ + src: `https://openweathermap.org/img/wn/${v}.png`, + alt: record.data.conditions, + width: 32, + height: 32 + }); + }, + exportValue: (_v, {record}) => record.data.conditions + }, + {field: 'conditions', headerName: 'Conditions', flex: 1}, + { + field: 'high', + headerName: 'High', + width: 70, + align: 'right', + renderer: v => fmtTemp(v, units) + }, + { + field: 'low', + headerName: 'Low', + width: 70, + align: 'right', + renderer: v => fmtTemp(v, units) + }, + { + field: 'humidity', + headerName: 'Humidity', + width: 80, + align: 'right', + renderer: v => `${v}%` + }, + { + field: 'wind', + headerName: 'Wind', + width: 80, + align: 'right', + renderer: v => fmtWind(v, units) + } + ] + }); + } + + private updateGrid() { + const data = this.weatherData; + if (!data?.forecast?.length) { + this.gridModel.clear(); + return; + } + + const byDay = groupBy(data.forecast, entry => { + const d = new Date(entry.dt); + return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; + }); + + const rows = Object.entries(byDay).map(([_key, dayItems], idx) => { + const highs = dayItems.map(it => it.tempMax), + lows = dayItems.map(it => it.tempMin), + humidities = dayItems.map(it => it.humidity), + winds = dayItems.map(it => it.windSpeed), + midItem = dayItems[Math.floor(dayItems.length / 2)]; + + return { + id: idx, + date: dayItems[0].dt, + icon: midItem.iconCode, + conditions: midItem.conditions, + high: Math.round(Math.max(...highs)), + low: Math.round(Math.min(...lows)), + humidity: Math.round(humidities.reduce((a, b) => a + b, 0) / humidities.length), + wind: Math.round(winds.reduce((a, b) => a + b, 0) / winds.length) + }; + }); + + this.gridModel.loadData(rows); + } +} + +widgetRegistry.register(SummaryGridModel.meta); + +//-------------------------------------------------- +// Component +//-------------------------------------------------- +export const summaryGridWidget = hoistCmp.factory({ + displayName: 'SummaryGridWidget', + model: creates(SummaryGridModel), + + render() { + return panel({item: grid()}); + } +}); From 9188172c0eefa551251acf28116735d7f948ed98 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Fri, 27 Feb 2026 23:54:34 -0800 Subject: [PATCH 05/41] Add Phase 4 widgets: UnitsToggle, Wind, Markdown, Inspector Complete the full 9-widget catalog. UnitsToggle publishes a units output that display widgets consume for imperial/metric switching. WindChart shows speed and gust forecasts. MarkdownContent renders static rich text. DashInspector is a debug utility showing live widget instances, bindings, and output values. Updated initial dashboard state to include UnitsToggle wired into all unit-aware display widgets (CurrentConditions, ForecastChart, WindChart, SummaryGrid) alongside the existing CityChooser wiring. Co-Authored-By: Claude Opus 4.6 --- .../weatherv2/dash/WeatherV2DashModel.ts | 68 +++++- .../weatherv2/widgets/DashInspectorWidget.ts | 119 ++++++++++ .../widgets/MarkdownContentWidget.ts | 95 ++++++++ .../weatherv2/widgets/UnitsToggleWidget.ts | 78 +++++++ .../weatherv2/widgets/WindChartWidget.ts | 204 ++++++++++++++++++ 5 files changed, 559 insertions(+), 5 deletions(-) create mode 100644 client-app/src/examples/weatherv2/widgets/DashInspectorWidget.ts create mode 100644 client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts create mode 100644 client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts create mode 100644 client-app/src/examples/weatherv2/widgets/WindChartWidget.ts diff --git a/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts b/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts index 2bea564bc..6b410bd61 100644 --- a/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts +++ b/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts @@ -6,12 +6,16 @@ import {makeObservable} from '@xh/hoist/mobx'; import {WiringModel} from './WiringModel'; import {WeatherDataModel} from './WeatherDataModel'; -import {temperatureIcon, cloudRainIcon, calendarDaysIcon} from '../Icons'; +import {temperatureIcon, cloudRainIcon, calendarDaysIcon, windIcon} from '../Icons'; import {cityChooserWidget} from '../widgets/CityChooserWidget'; import {currentConditionsWidget} from '../widgets/CurrentConditionsWidget'; import {forecastChartWidget} from '../widgets/ForecastChartWidget'; import {precipChartWidget} from '../widgets/PrecipChartWidget'; import {summaryGridWidget} from '../widgets/SummaryGridWidget'; +import {unitsToggleWidget} from '../widgets/UnitsToggleWidget'; +import {windChartWidget} from '../widgets/WindChartWidget'; +import {markdownContentWidget} from '../widgets/MarkdownContentWidget'; +import {dashInspectorWidget} from '../widgets/DashInspectorWidget'; /** * Central model for the Weather V2 dashboard. @@ -81,6 +85,42 @@ export class WeatherV2DashModel extends HoistModel { unique: false, width: 6, height: 5 + }, + { + id: 'unitsToggle', + title: 'Units Toggle', + icon: Icon.gear(), + content: unitsToggleWidget, + unique: false, + width: 3, + height: 2 + }, + { + id: 'windChart', + title: 'Wind', + icon: windIcon(), + content: windChartWidget, + unique: false, + width: 6, + height: 5 + }, + { + id: 'markdownContent', + title: 'Markdown Content', + icon: Icon.info(), + content: markdownContentWidget, + unique: false, + width: 4, + height: 3 + }, + { + id: 'dashInspector', + title: 'Dash Inspector', + icon: Icon.code(), + content: dashInspectorWidget, + unique: true, + width: 6, + height: 5 } ], initialState: [ @@ -89,12 +129,18 @@ export class WeatherV2DashModel extends HoistModel { layout: {x: 0, y: 0, w: 3, h: 2}, state: {selectedCity: 'New York'} }, + { + viewSpecId: 'unitsToggle', + layout: {x: 0, y: 2, w: 3, h: 2}, + state: {units: 'imperial'} + }, { viewSpecId: 'currentConditions', layout: {x: 3, y: 0, w: 4, h: 5}, state: { bindings: { - city: {fromWidget: 'cityChooser', output: 'selectedCity'} + city: {fromWidget: 'cityChooser', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle', output: 'units'} } } }, @@ -103,7 +149,8 @@ export class WeatherV2DashModel extends HoistModel { layout: {x: 7, y: 0, w: 5, h: 5}, state: { bindings: { - city: {fromWidget: 'cityChooser', output: 'selectedCity'} + city: {fromWidget: 'cityChooser', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle', output: 'units'} }, series: ['temp', 'feelsLike'], chartType: 'line' @@ -119,11 +166,22 @@ export class WeatherV2DashModel extends HoistModel { } }, { - viewSpecId: 'summaryGrid', + viewSpecId: 'windChart', layout: {x: 6, y: 5, w: 6, h: 5}, state: { bindings: { - city: {fromWidget: 'cityChooser', output: 'selectedCity'} + city: {fromWidget: 'cityChooser', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle', output: 'units'} + } + } + }, + { + viewSpecId: 'summaryGrid', + layout: {x: 0, y: 10, w: 12, h: 5}, + state: { + bindings: { + city: {fromWidget: 'cityChooser', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle', output: 'units'} } } } diff --git a/client-app/src/examples/weatherv2/widgets/DashInspectorWidget.ts b/client-app/src/examples/weatherv2/widgets/DashInspectorWidget.ts new file mode 100644 index 000000000..54f58ae58 --- /dev/null +++ b/client-app/src/examples/weatherv2/widgets/DashInspectorWidget.ts @@ -0,0 +1,119 @@ +import {grid, GridModel} from '@xh/hoist/cmp/grid'; +import {creates, hoistCmp, managed} from '@xh/hoist/core'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {computed, makeObservable} from '@xh/hoist/mobx'; +import {WeatherWidgetModel} from '../dash/WeatherWidgetModel'; +import {widgetRegistry} from '../dash/WidgetRegistry'; +import {WidgetMeta} from '../dash/types'; + +//-------------------------------------------------- +// Model +//-------------------------------------------------- +export class DashInspectorModel extends WeatherWidgetModel { + static override meta: WidgetMeta = { + id: 'dashInspector', + title: 'Dash Inspector', + description: + 'Debug utility showing all active widgets, their bindings, and live output values.', + category: 'utility', + inputs: [], + outputs: [], + config: {}, + defaultSize: {w: 6, h: 5}, + minSize: {w: 4, h: 3} + }; + + @managed gridModel: GridModel; + + constructor() { + super(); + makeObservable(this); + } + + override onLinked() { + super.onLinked(); + this.gridModel = this.createGridModel(); + + this.addReaction({ + track: () => this.inspectorData, + run: data => this.gridModel.loadData(data) + }); + } + + @computed + get inspectorData(): Record[] { + const {AppModel} = require('../AppModel'); + const dashModel = AppModel.instance.weatherV2DashModel, + wiringModel = dashModel.wiringModel, + canvasModel = dashModel.dashCanvasModel, + allOutputs = wiringModel.allOutputs; + + const state = canvasModel.getPersistableState()?.state ?? []; + return state.map((item: any, idx: number) => { + const specId = item.viewSpecId, + meta = widgetRegistry.get(specId), + bindings = item.state?.bindings, + widgetOutputs = allOutputs.get(item.id); + + return { + id: idx, + instanceId: item.id ?? specId, + widgetType: meta?.title ?? specId, + category: meta?.category ?? '—', + bindings: bindings ? formatBindings(bindings) : '—', + outputs: widgetOutputs ? formatOutputs(widgetOutputs) : '—' + }; + }); + } + + private createGridModel(): GridModel { + return new GridModel({ + sortBy: 'instanceId', + emptyText: 'No widgets in dashboard.', + columns: [ + {field: 'instanceId', headerName: 'Instance', width: 140}, + {field: 'widgetType', headerName: 'Type', width: 120}, + {field: 'category', headerName: 'Category', width: 80}, + {field: 'bindings', headerName: 'Bindings', flex: 1}, + {field: 'outputs', headerName: 'Outputs', flex: 1} + ] + }); + } +} + +widgetRegistry.register(DashInspectorModel.meta); + +//-------------------------------------------------- +// Helpers +//-------------------------------------------------- +function formatBindings(bindings: Record): string { + return Object.entries(bindings) + .map(([input, spec]) => { + if ('const' in spec) return `${input}=${JSON.stringify(spec.const)}`; + if ('fromWidget' in spec) return `${input}←${spec.fromWidget}.${spec.output}`; + return `${input}=?`; + }) + .join(', '); +} + +function formatOutputs(outputs: Map): string { + return Array.from(outputs.entries()) + .map(([name, value]) => { + const display = + typeof value === 'string' ? value : value != null ? JSON.stringify(value) : 'null'; + return `${name}=${display}`; + }) + .join(', '); +} + +//-------------------------------------------------- +// Component +//-------------------------------------------------- +export const dashInspectorWidget = hoistCmp.factory({ + displayName: 'DashInspectorWidget', + model: creates(DashInspectorModel), + + render() { + return panel({item: grid()}); + } +}); diff --git a/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts b/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts new file mode 100644 index 000000000..19782f5e3 --- /dev/null +++ b/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts @@ -0,0 +1,95 @@ +import {hoistCmp, creates} from '@xh/hoist/core'; +import {div, frame} from '@xh/hoist/cmp/layout'; +import {bindable, makeObservable} from '@xh/hoist/mobx'; +import {WeatherWidgetModel} from '../dash/WeatherWidgetModel'; +import {widgetRegistry} from '../dash/WidgetRegistry'; +import {WidgetMeta} from '../dash/types'; + +//-------------------------------------------------- +// Model +//-------------------------------------------------- +export class MarkdownContentModel extends WeatherWidgetModel { + static override meta: WidgetMeta = { + id: 'markdownContent', + title: 'Markdown Content', + description: + 'Static rich-text display. Useful for dashboard titles, instructions, or annotations.', + category: 'utility', + inputs: [], + outputs: [], + config: { + content: { + type: 'string', + description: 'Markdown text to render.', + default: "# Welcome\nEdit this widget's content in the dashboard spec." + } + }, + defaultSize: {w: 4, h: 3}, + minSize: {w: 2, h: 1} + }; + + @bindable content: string = "# Welcome\nEdit this widget's content in the dashboard spec."; + + constructor() { + super(); + makeObservable(this); + } + + override onLinked() { + super.onLinked(); + this.markPersist('content'); + } +} + +widgetRegistry.register(MarkdownContentModel.meta); + +//-------------------------------------------------- +// Component +//-------------------------------------------------- +export const markdownContentWidget = hoistCmp.factory({ + displayName: 'MarkdownContentWidget', + model: creates(MarkdownContentModel), + + render({model}) { + // Simple markdown rendering — supports basic formatting via innerHTML. + // For V1, we render plain text with line breaks. A full markdown renderer + // (e.g., marked or react-markdown) can be added as a stretch goal. + const html = simpleMarkdownToHtml(model.content); + return frame({ + padding: 12, + style: {overflow: 'auto'}, + item: div({ + style: {lineHeight: 1.5}, + dangerouslySetInnerHTML: {__html: html} + }) + }); + } +}); + +/** + * Minimal markdown-to-HTML converter for basic formatting. + * Handles headings, bold, italic, links, and line breaks. + */ +function simpleMarkdownToHtml(md: string): string { + if (!md) return ''; + return md + .split('\n') + .map(line => { + // Headings + if (line.startsWith('### ')) return `

${esc(line.slice(4))}

`; + if (line.startsWith('## ')) return `

${esc(line.slice(3))}

`; + if (line.startsWith('# ')) return `

${esc(line.slice(2))}

`; + // Empty line = paragraph break + if (!line.trim()) return '
'; + // Bold and italic + let html = esc(line); + html = html.replace(/\*\*(.+?)\*\*/g, '$1'); + html = html.replace(/\*(.+?)\*/g, '$1'); + return `

${html}

`; + }) + .join(''); +} + +function esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} diff --git a/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts b/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts new file mode 100644 index 000000000..dcc1ebf1c --- /dev/null +++ b/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts @@ -0,0 +1,78 @@ +import {hoistCmp, creates} from '@xh/hoist/core'; +import {box} from '@xh/hoist/cmp/layout'; +import {button} from '@xh/hoist/desktop/cmp/button'; +import {buttonGroupInput} from '@xh/hoist/desktop/cmp/input'; +import {bindable, makeObservable} from '@xh/hoist/mobx'; +import {WeatherWidgetModel} from '../dash/WeatherWidgetModel'; +import {widgetRegistry} from '../dash/WidgetRegistry'; +import {WidgetMeta} from '../dash/types'; + +//-------------------------------------------------- +// Model +//-------------------------------------------------- +export class UnitsToggleModel extends WeatherWidgetModel { + static override meta: WidgetMeta = { + id: 'unitsToggle', + title: 'Units Toggle', + description: + 'Toggle between imperial and metric units. Display widgets that accept a units input will adapt.', + category: 'input', + inputs: [], + outputs: [ + {name: 'units', type: 'string', description: 'Unit system: "imperial" or "metric".'} + ], + config: { + units: { + type: 'enum', + description: 'Initial unit system.', + enum: ['imperial', 'metric'], + default: 'imperial' + } + }, + defaultSize: {w: 3, h: 2}, + minSize: {w: 2, h: 1} + }; + + @bindable units: string = 'imperial'; + + constructor() { + super(); + makeObservable(this); + } + + override onLinked() { + super.onLinked(); + this.markPersist('units'); + + this.addReaction({ + track: () => this.units, + run: units => this.publishOutput('units', units), + fireImmediately: true + }); + } +} + +widgetRegistry.register(UnitsToggleModel.meta); + +//-------------------------------------------------- +// Component +//-------------------------------------------------- +export const unitsToggleWidget = hoistCmp.factory({ + displayName: 'UnitsToggleWidget', + model: creates(UnitsToggleModel), + + render() { + return box({ + padding: 8, + alignItems: 'center', + justifyContent: 'center', + item: buttonGroupInput({ + bind: 'units', + items: [ + button({text: '°F / mph', value: 'imperial'}), + button({text: '°C / m/s', value: 'metric'}) + ] + }) + }); + } +}); diff --git a/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts new file mode 100644 index 000000000..322d94fdb --- /dev/null +++ b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts @@ -0,0 +1,204 @@ +import {chart, ChartModel} from '@xh/hoist/cmp/chart'; +import {creates, hoistCmp, LoadSpec, managed} from '@xh/hoist/core'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {fmtDate} from '@xh/hoist/format'; +import {computed, makeObservable} from '@xh/hoist/mobx'; +import {WeatherWidgetModel} from '../dash/WeatherWidgetModel'; +import {widgetRegistry} from '../dash/WidgetRegistry'; +import {convertWind, windUnit} from '../dash/unitUtils'; +import {WidgetMeta} from '../dash/types'; +import {WeatherData} from '../Types'; + +//-------------------------------------------------- +// Model +//-------------------------------------------------- +export class WindChartModel extends WeatherWidgetModel { + static override meta: WidgetMeta = { + id: 'windChart', + title: 'Wind', + description: 'Wind speed and gusts over the forecast period.', + category: 'display', + inputs: [ + { + name: 'city', + type: 'string', + required: true, + default: 'New York', + description: 'City to show wind data for.' + }, + { + name: 'units', + type: 'string', + required: false, + default: 'imperial', + description: 'Unit system (mph vs m/s).' + } + ], + outputs: [], + config: { + showGusts: { + type: 'boolean', + description: 'Show gust data alongside sustained.', + default: true + }, + chartType: { + type: 'enum', + description: 'Chart style.', + enum: ['line', 'area'], + default: 'line' + } + }, + defaultSize: {w: 6, h: 5}, + minSize: {w: 4, h: 3} + }; + + @managed chartModel: ChartModel; + + constructor() { + super(); + makeObservable(this); + } + + @computed get city(): string { + return this.resolveInput('city') ?? 'New York'; + } + + @computed get units(): string { + return this.resolveInput('units') ?? 'imperial'; + } + + get showGusts(): boolean { + return this.viewModel.viewState?.showGusts ?? true; + } + + get chartType(): string { + return this.viewModel.viewState?.chartType ?? 'line'; + } + + override onLinked() { + super.onLinked(); + this.chartModel = this.createChartModel(); + + this.addReaction({ + track: () => this.city, + run: () => this.loadAsync(), + fireImmediately: true + }); + + this.addReaction({ + track: () => [this.weatherData, this.units, this.showGusts], + run: () => this.updateChart() + }); + } + + override async doLoadAsync(loadSpec: LoadSpec) { + const {city} = this; + if (!city) return; + try { + const {AppModel} = require('../AppModel'); + await AppModel.instance.weatherV2DashModel.weatherDataModel.ensureDataAsync( + city, + loadSpec + ); + } catch (e) { + // Handled by WeatherDataModel + } + } + + @computed get weatherData(): WeatherData | null { + const {AppModel} = require('../AppModel'); + return AppModel.instance.weatherV2DashModel.weatherDataModel.getData(this.city); + } + + private createChartModel(): ChartModel { + return new ChartModel({ + highchartsConfig: { + chart: {zoomType: 'x'}, + title: {text: null}, + xAxis: { + type: 'datetime', + labels: { + formatter: function () { + return fmtDate(this.value, {fmt: 'ddd ha'}); + } + } + }, + yAxis: { + title: {text: null}, + labels: {format: '{value}'} + }, + tooltip: { + shared: true, + xDateFormat: '%A %b %e, %l:%M %p' + }, + legend: {enabled: true}, + credits: {enabled: false} + } + }); + } + + private updateChart() { + const data = this.weatherData; + if (!data?.forecast?.length) return; + + const {forecast} = data, + {units, showGusts, chartType} = this, + wUnit = windUnit(units), + speedData = [], + gustData = []; + + const type = chartType === 'line' ? 'spline' : chartType; + + for (const entry of forecast) { + const speed = Math.round(convertWind(entry.windSpeed, units) * 10) / 10; + speedData.push([entry.dt, speed]); + if (showGusts) { + const gust = entry.windGust + ? Math.round(convertWind(entry.windGust, units) * 10) / 10 + : speed; + gustData.push([entry.dt, gust]); + } + } + + const series: any[] = [ + { + name: 'Wind Speed', + type, + data: speedData, + color: '#7cb5ec', + lineWidth: 2, + marker: {enabled: false}, + tooltip: {valueSuffix: ` ${wUnit}`} + } + ]; + + if (showGusts) { + series.push({ + name: 'Gusts', + type, + data: gustData, + color: '#f45b5b', + lineWidth: 2, + dashStyle: 'ShortDash', + marker: {enabled: false}, + tooltip: {valueSuffix: ` ${wUnit}`} + }); + } + + this.chartModel.setSeries(series); + } +} + +widgetRegistry.register(WindChartModel.meta); + +//-------------------------------------------------- +// Component +//-------------------------------------------------- +export const windChartWidget = hoistCmp.factory({ + displayName: 'WindChartWidget', + model: creates(WindChartModel), + + render() { + return panel({item: chart()}); + } +}); From 7e18e8b33909caa90ebef55ff1b1d246bcfbc27c Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sat, 28 Feb 2026 00:06:10 -0800 Subject: [PATCH 06/41] Add Phase 5: validation pipeline and JSON spec editor harness Implement three-stage spec validation (structural, semantic, referential) with instance ID computation, binding graph cycle detection, and detailed error/warning messages with JSON paths. Add spec migration framework for future format evolution. Create JSON harness panel with Hoist jsonInput editor, validation display, Apply/Validate/Sync/Copy controls, and a Load Example dropdown with 4 curated specs (Minimal, Full Dashboard, City Comparison, Annotated). Harness is toggled via a JSON button in the app bar and appears as a side panel alongside the dashboard canvas. Co-Authored-By: Claude Opus 4.6 --- .../src/examples/weatherv2/AppComponent.ts | 21 +- client-app/src/examples/weatherv2/AppModel.ts | 7 + .../src/examples/weatherv2/WeatherV2.scss | 46 +++ .../examples/weatherv2/dash/exampleSpecs.ts | 238 ++++++++++++ .../src/examples/weatherv2/dash/validation.ts | 358 ++++++++++++++++++ .../weatherv2/harness/JsonHarnessModel.ts | 133 +++++++ .../weatherv2/harness/JsonHarnessPanel.ts | 143 +++++++ 7 files changed, 943 insertions(+), 3 deletions(-) create mode 100644 client-app/src/examples/weatherv2/dash/exampleSpecs.ts create mode 100644 client-app/src/examples/weatherv2/dash/validation.ts create mode 100644 client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts create mode 100644 client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts diff --git a/client-app/src/examples/weatherv2/AppComponent.ts b/client-app/src/examples/weatherv2/AppComponent.ts index b58a92365..4e3701ada 100644 --- a/client-app/src/examples/weatherv2/AppComponent.ts +++ b/client-app/src/examples/weatherv2/AppComponent.ts @@ -1,10 +1,13 @@ import {hoistCmp, uses} from '@xh/hoist/core'; +import {box, hframe} from '@xh/hoist/cmp/layout'; import {appBar, appBarSeparator} from '@xh/hoist/desktop/cmp/appbar'; +import {button} from '@xh/hoist/desktop/cmp/button'; import {panel} from '@xh/hoist/desktop/cmp/panel'; import {dashCanvas} from '@xh/hoist/desktop/cmp/dash'; import {viewManager} from '@xh/hoist/desktop/cmp/viewmanager'; import {Icon} from '@xh/hoist/icon'; import {AppModel} from './AppModel'; +import {jsonHarnessPanel} from './harness/JsonHarnessPanel'; import '../../core/Toolbox.scss'; import './WeatherV2.scss'; @@ -13,16 +16,28 @@ export const AppComponent = hoistCmp({ model: uses(AppModel), render({model}) { - const {weatherV2DashModel} = model; + const {weatherV2DashModel, showJsonHarness} = model; return panel({ tbar: appBar({ icon: Icon.sun({size: '2x', prefix: 'fal'}), title: 'Weather V2', leftItems: [viewManager()], - rightItems: [appBarSeparator()], + rightItems: [ + button({ + icon: Icon.code(), + text: 'JSON', + active: showJsonHarness, + outlined: true, + onClick: () => (model.showJsonHarness = !showJsonHarness) + }), + appBarSeparator() + ], appMenuButtonProps: {hideLogoutItem: false} }), - item: dashCanvas({model: weatherV2DashModel.dashCanvasModel}), + item: hframe( + showJsonHarness ? jsonHarnessPanel({width: 500, minWidth: 350}) : null, + box({flex: 1, item: dashCanvas({model: weatherV2DashModel.dashCanvasModel})}) + ), className: 'weather-v2-app' }); } diff --git a/client-app/src/examples/weatherv2/AppModel.ts b/client-app/src/examples/weatherv2/AppModel.ts index cf3eb2f3c..277be84f4 100644 --- a/client-app/src/examples/weatherv2/AppModel.ts +++ b/client-app/src/examples/weatherv2/AppModel.ts @@ -1,5 +1,6 @@ import {managed, XH} from '@xh/hoist/core'; import {ViewManagerModel} from '@xh/hoist/cmp/viewmanager'; +import {bindable, makeObservable} from '@xh/hoist/mobx'; import { autoRefreshAppOption, themeAppOption, @@ -12,6 +13,12 @@ export class AppModel extends BaseAppModel { static instance: AppModel; @managed weatherV2DashModel: WeatherV2DashModel; @managed weatherViewManager: ViewManagerModel; + @bindable showJsonHarness: boolean = false; + + constructor() { + super(); + makeObservable(this); + } override async initAsync() { await super.initAsync(); diff --git a/client-app/src/examples/weatherv2/WeatherV2.scss b/client-app/src/examples/weatherv2/WeatherV2.scss index 290a8caf9..7bda3f94b 100644 --- a/client-app/src/examples/weatherv2/WeatherV2.scss +++ b/client-app/src/examples/weatherv2/WeatherV2.scss @@ -1,5 +1,11 @@ .weather-v2-app { height: 100%; + + // Ensure dashCanvas fills remaining space in hframe + > .xh-hframe { + flex: 1; + min-height: 0; + } } .weather-v2-current-conditions { @@ -34,3 +40,43 @@ font-size: 13px; } } + +// Validation display in JSON harness +.weather-v2-validation { + padding: 6px 10px; + font-size: var(--xh-font-size-small-px); + border-top: var(--xh-border-solid); + + &--success { + color: var(--xh-green); + } + + &--warning { + color: var(--xh-orange); + } + + &--error { + color: var(--xh-red); + } +} + +.weather-v2-validation-details { + max-height: 150px; + overflow-y: auto; + padding: 4px 10px; + font-size: var(--xh-font-size-small-px); + font-family: var(--xh-font-family-mono); + border-top: var(--xh-border-solid); +} + +.weather-v2-validation-msg { + padding: 2px 0; + + &--error { + color: var(--xh-red); + } + + &--warning { + color: var(--xh-orange); + } +} diff --git a/client-app/src/examples/weatherv2/dash/exampleSpecs.ts b/client-app/src/examples/weatherv2/dash/exampleSpecs.ts new file mode 100644 index 000000000..30a9e6a58 --- /dev/null +++ b/client-app/src/examples/weatherv2/dash/exampleSpecs.ts @@ -0,0 +1,238 @@ +import {DashSpec} from './types'; + +export interface ExampleSpec { + name: string; + description: string; + spec: DashSpec; +} + +/** Minimal: Single city with conditions + forecast chart. */ +const minimalSpec: DashSpec = { + version: 1, + state: [ + { + viewSpecId: 'cityChooser', + layout: {x: 0, y: 0, w: 3, h: 2}, + state: {selectedCity: 'New York'} + }, + { + viewSpecId: 'currentConditions', + layout: {x: 3, y: 0, w: 4, h: 5}, + state: { + bindings: {city: {fromWidget: 'cityChooser', output: 'selectedCity'}} + } + }, + { + viewSpecId: 'forecastChart', + layout: {x: 7, y: 0, w: 5, h: 5}, + state: { + bindings: {city: {fromWidget: 'cityChooser', output: 'selectedCity'}}, + series: ['temp'], + chartType: 'line' + } + } + ] +}; + +/** Full Dashboard: All widget types wired together. */ +const fullSpec: DashSpec = { + version: 1, + state: [ + { + viewSpecId: 'cityChooser', + layout: {x: 0, y: 0, w: 3, h: 2}, + state: {selectedCity: 'New York'} + }, + { + viewSpecId: 'unitsToggle', + layout: {x: 0, y: 2, w: 3, h: 2}, + state: {units: 'imperial'} + }, + { + viewSpecId: 'currentConditions', + layout: {x: 3, y: 0, w: 4, h: 5}, + state: { + bindings: { + city: {fromWidget: 'cityChooser', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle', output: 'units'} + } + } + }, + { + viewSpecId: 'forecastChart', + layout: {x: 7, y: 0, w: 5, h: 5}, + state: { + bindings: { + city: {fromWidget: 'cityChooser', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle', output: 'units'} + }, + series: ['temp', 'feelsLike'], + chartType: 'line' + } + }, + { + viewSpecId: 'precipChart', + layout: {x: 0, y: 5, w: 6, h: 5}, + state: { + bindings: {city: {fromWidget: 'cityChooser', output: 'selectedCity'}} + } + }, + { + viewSpecId: 'windChart', + layout: {x: 6, y: 5, w: 6, h: 5}, + state: { + bindings: { + city: {fromWidget: 'cityChooser', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle', output: 'units'} + }, + showGusts: true + } + }, + { + viewSpecId: 'summaryGrid', + layout: {x: 0, y: 10, w: 12, h: 5}, + state: { + bindings: { + city: {fromWidget: 'cityChooser', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle', output: 'units'} + } + } + } + ] +}; + +/** City Comparison: Two cities side by side. */ +const comparisonSpec: DashSpec = { + version: 1, + state: [ + { + viewSpecId: 'cityChooser', + layout: {x: 0, y: 0, w: 3, h: 2}, + title: 'City A', + state: {selectedCity: 'New York'} + }, + { + viewSpecId: 'cityChooser', + layout: {x: 6, y: 0, w: 3, h: 2}, + title: 'City B', + state: {selectedCity: 'London'} + }, + { + viewSpecId: 'unitsToggle', + layout: {x: 3, y: 0, w: 3, h: 2}, + state: {units: 'imperial'} + }, + { + viewSpecId: 'forecastChart', + layout: {x: 0, y: 2, w: 6, h: 5}, + title: 'City A Forecast', + state: { + bindings: { + city: {fromWidget: 'cityChooser', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle', output: 'units'} + }, + series: ['temp'], + chartType: 'line' + } + }, + { + viewSpecId: 'forecastChart', + layout: {x: 6, y: 2, w: 6, h: 5}, + title: 'City B Forecast', + state: { + bindings: { + city: {fromWidget: 'cityChooser_2', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle', output: 'units'} + }, + series: ['temp'], + chartType: 'line' + } + }, + { + viewSpecId: 'currentConditions', + layout: {x: 0, y: 7, w: 6, h: 5}, + title: 'City A Conditions', + state: { + bindings: { + city: {fromWidget: 'cityChooser', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle', output: 'units'} + } + } + }, + { + viewSpecId: 'currentConditions', + layout: {x: 6, y: 7, w: 6, h: 5}, + title: 'City B Conditions', + state: { + bindings: { + city: {fromWidget: 'cityChooser_2', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle', output: 'units'} + } + } + } + ] +}; + +/** Annotated: Markdown header + display widgets + inspector. */ +const annotatedSpec: DashSpec = { + version: 1, + state: [ + { + viewSpecId: 'markdownContent', + layout: {x: 0, y: 0, w: 12, h: 2}, + title: 'Dashboard Guide', + state: { + content: + '# Weather Dashboard V2\nThis dashboard demonstrates **inter-widget wiring**. The City Chooser and Units Toggle publish outputs that drive all display widgets. Open the **Dash Inspector** to see live binding values.' + } + }, + { + viewSpecId: 'cityChooser', + layout: {x: 0, y: 2, w: 3, h: 2}, + state: {selectedCity: 'Tokyo'} + }, + { + viewSpecId: 'unitsToggle', + layout: {x: 3, y: 2, w: 3, h: 2}, + state: {units: 'metric'} + }, + { + viewSpecId: 'forecastChart', + layout: {x: 6, y: 2, w: 6, h: 5}, + state: { + bindings: { + city: {fromWidget: 'cityChooser', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle', output: 'units'} + }, + series: ['temp', 'humidity'], + chartType: 'area' + } + }, + { + viewSpecId: 'currentConditions', + layout: {x: 0, y: 4, w: 6, h: 5}, + state: { + bindings: { + city: {fromWidget: 'cityChooser', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle', output: 'units'} + } + } + }, + { + viewSpecId: 'dashInspector', + layout: {x: 0, y: 9, w: 12, h: 4}, + title: 'Wiring Inspector' + } + ] +}; + +export const EXAMPLE_SPECS: ExampleSpec[] = [ + {name: 'Minimal', description: 'City chooser + conditions + forecast', spec: minimalSpec}, + {name: 'Full Dashboard', description: 'All widget types wired together', spec: fullSpec}, + {name: 'City Comparison', description: 'Two cities side by side', spec: comparisonSpec}, + { + name: 'Annotated', + description: 'Markdown guide + inspector + display widgets', + spec: annotatedSpec + } +]; diff --git a/client-app/src/examples/weatherv2/dash/validation.ts b/client-app/src/examples/weatherv2/dash/validation.ts new file mode 100644 index 000000000..e22c8a461 --- /dev/null +++ b/client-app/src/examples/weatherv2/dash/validation.ts @@ -0,0 +1,358 @@ +import {widgetRegistry} from './WidgetRegistry'; +import {ValidationResult, ValidationMessage, DashSpec, DashWidgetState, BindingSpec} from './types'; + +const CURRENT_VERSION = 1; + +/** + * Migrations map: version → transform function. + * For V1 (initial release) there are no migrations. The framework is in place. + */ +const migrations: Record DashSpec> = {}; + +/** + * Migrate a spec to the current version. + */ +export function migrateSpec(spec: DashSpec): DashSpec { + let current = {...spec}; + while ((current.version ?? 1) < CURRENT_VERSION) { + const next = (current.version ?? 1) + 1; + const migrate = migrations[next]; + if (!migrate) break; + current = migrate(current); + } + return current; +} + +/** + * Full three-stage validation pipeline. + * Returns a ValidationResult with errors and warnings. + */ +export function validateSpec(spec: DashSpec): ValidationResult { + const errors: ValidationMessage[] = [], + warnings: ValidationMessage[] = []; + + if (!spec || typeof spec !== 'object') { + errors.push(msg('error', '', 'INVALID_SPEC', 'Spec must be a non-null object.')); + return {valid: false, errors, warnings}; + } + + if (!Array.isArray(spec.state)) { + errors.push(msg('error', 'state', 'MISSING_STATE', 'Spec must have a "state" array.')); + return {valid: false, errors, warnings}; + } + + // Stage 1: Structural validation + validateStructural(spec, errors, warnings); + + // Stage 2: Semantic validation + validateSemantic(spec, errors, warnings); + + // Stage 3: Referential validation (wiring graph) + validateReferential(spec, errors, warnings); + + return {valid: errors.length === 0, errors, warnings}; +} + +//-------------------------------------------------- +// Stage 1: Structural +//-------------------------------------------------- +function validateStructural( + spec: DashSpec, + errors: ValidationMessage[], + warnings: ValidationMessage[] +) { + const registeredIds = new Set(widgetRegistry.getIds()); + + spec.state.forEach((widget, idx) => { + const path = `state[${idx}]`; + + if (!widget.viewSpecId) { + errors.push( + msg('error', path, 'MISSING_VIEW_SPEC_ID', 'Widget must have a viewSpecId.') + ); + } else if (!registeredIds.has(widget.viewSpecId)) { + errors.push( + msg( + 'error', + `${path}.viewSpecId`, + 'UNKNOWN_WIDGET_TYPE', + `Unknown widget type "${widget.viewSpecId}". Available: ${[...registeredIds].join(', ')}.` + ) + ); + } + + if (!widget.layout) { + errors.push(msg('error', path, 'MISSING_LAYOUT', 'Widget must have a layout.')); + } else { + validateLayout(widget.layout, `${path}.layout`, errors, warnings); + } + }); +} + +function validateLayout( + layout: DashWidgetState['layout'], + path: string, + errors: ValidationMessage[], + warnings: ValidationMessage[] +) { + const {x, y, w, h} = layout; + + if (typeof x !== 'number' || x < 0) { + errors.push(msg('error', `${path}.x`, 'INVALID_X', `x must be >= 0, got ${x}.`)); + } + if (typeof y !== 'number' || y < 0) { + errors.push(msg('error', `${path}.y`, 'INVALID_Y', `y must be >= 0, got ${y}.`)); + } + if (typeof w !== 'number' || w < 1 || w > 12) { + errors.push(msg('error', `${path}.w`, 'INVALID_W', `w must be 1-12, got ${w}.`)); + } + if (typeof h !== 'number' || h < 1) { + errors.push(msg('error', `${path}.h`, 'INVALID_H', `h must be >= 1, got ${h}.`)); + } + if (typeof x === 'number' && typeof w === 'number' && x + w > 12) { + errors.push( + msg('error', path, 'LAYOUT_OVERFLOW', `Widget extends past column 12 (x=${x}, w=${w}).`) + ); + } +} + +//-------------------------------------------------- +// Stage 2: Semantic +//-------------------------------------------------- +function validateSemantic( + spec: DashSpec, + errors: ValidationMessage[], + warnings: ValidationMessage[] +) { + spec.state.forEach((widget, idx) => { + const path = `state[${idx}]`; + const meta = widgetRegistry.get(widget.viewSpecId); + if (!meta) return; // already flagged in structural + + const widgetState = widget.state ?? {}; + const configKeys = Object.keys(meta.config); + + // Check config property types and enum values + for (const [key, def] of Object.entries(meta.config)) { + const value = widgetState[key]; + if (value === undefined) continue; + + if (def.type === 'enum' && def.enum && !def.enum.includes(value)) { + errors.push( + msg( + 'error', + `${path}.state.${key}`, + 'INVALID_ENUM', + `"${value}" is not a valid value for ${key}. Must be one of: ${def.enum.join(', ')}.` + ) + ); + } + + if (def.type === 'boolean' && typeof value !== 'boolean') { + errors.push( + msg( + 'error', + `${path}.state.${key}`, + 'TYPE_MISMATCH', + `${key} must be a boolean, got ${typeof value}.` + ) + ); + } + + if (def.type === 'number' && typeof value !== 'number') { + errors.push( + msg( + 'error', + `${path}.state.${key}`, + 'TYPE_MISMATCH', + `${key} must be a number, got ${typeof value}.` + ) + ); + } + } + + // Warn about unknown state keys (excluding 'bindings' and known config) + const knownKeys = new Set([...configKeys, 'bindings']); + for (const key of Object.keys(widgetState)) { + if (!knownKeys.has(key)) { + warnings.push( + msg( + 'warning', + `${path}.state.${key}`, + 'UNKNOWN_STATE_KEY', + `Unknown state key "${key}" on ${widget.viewSpecId}. It will be preserved but may have no effect.` + ) + ); + } + } + + // Check binding inputs are declared + const bindings = widgetState.bindings; + if (bindings) { + const declaredInputNames = new Set(meta.inputs.map(i => i.name)); + for (const inputName of Object.keys(bindings)) { + if (!declaredInputNames.has(inputName)) { + warnings.push( + msg( + 'warning', + `${path}.state.bindings.${inputName}`, + 'UNKNOWN_INPUT', + `"${inputName}" is not a declared input of ${widget.viewSpecId}.` + ) + ); + } + } + } + + // Warn about required inputs without bindings + for (const input of meta.inputs) { + if (input.required && !bindings?.[input.name]) { + warnings.push( + msg( + 'warning', + `${path}.state.bindings`, + 'UNBOUND_REQUIRED_INPUT', + `Required input "${input.name}" on ${widget.viewSpecId} has no binding. Default value will be used.` + ) + ); + } + } + }); +} + +//-------------------------------------------------- +// Stage 3: Referential (wiring graph) +//-------------------------------------------------- +function validateReferential( + spec: DashSpec, + errors: ValidationMessage[], + _warnings: ValidationMessage[] +) { + // Compute expected widget instance IDs (matching DashCanvasModel's assignment) + const instanceIds = computeInstanceIds(spec.state); + const idSet = new Set(instanceIds); + const typeById = new Map(); // instanceId → viewSpecId + instanceIds.forEach((id, idx) => typeById.set(id, spec.state[idx].viewSpecId)); + + spec.state.forEach((widget, idx) => { + const path = `state[${idx}]`; + const bindings = widget.state?.bindings; + if (!bindings) return; + + for (const [inputName, rawBinding] of Object.entries(bindings)) { + const binding = rawBinding as BindingSpec; + const bindPath = `${path}.state.bindings.${inputName}`; + + if (binding && 'fromWidget' in binding) { + const fromWidget = binding.fromWidget; + const output = binding.output; + + // Check fromWidget exists + if (!idSet.has(fromWidget)) { + errors.push( + msg( + 'error', + bindPath, + 'DANGLING_WIDGET_REF', + `Binding references widget "${fromWidget}" which does not exist. Available: ${[...idSet].join(', ')}.` + ) + ); + continue; + } + + // Check output exists on source widget type + const sourceType = typeById.get(fromWidget); + if (sourceType) { + const sourceMeta = widgetRegistry.get(sourceType); + if (sourceMeta) { + const hasOutput = sourceMeta.outputs.some(o => o.name === output); + if (!hasOutput) { + errors.push( + msg( + 'error', + bindPath, + 'DANGLING_OUTPUT_REF', + `Widget type "${sourceType}" has no output named "${output}".` + ) + ); + } + } + } + } + } + }); + + // Cycle detection via topological sort + const graph = new Map>(); + instanceIds.forEach(id => graph.set(id, new Set())); + + spec.state.forEach((widget, idx) => { + const bindings = widget.state?.bindings; + if (!bindings) return; + const thisId = instanceIds[idx]; + + for (const rawBinding of Object.values(bindings) as BindingSpec[]) { + if (rawBinding && 'fromWidget' in rawBinding && idSet.has(rawBinding.fromWidget)) { + // Edge: fromWidget → thisWidget (data flows from source to consumer) + graph.get(rawBinding.fromWidget)?.add(thisId); + } + } + }); + + if (hasCycle(graph)) { + errors.push( + msg( + 'error', + 'state', + 'BINDING_CYCLE', + 'Circular dependency detected in widget bindings.' + ) + ); + } +} + +/** + * Compute widget instance IDs matching DashCanvasModel's assignment: + * first instance of type X → "X", second → "X_2", etc. + */ +function computeInstanceIds(state: DashWidgetState[]): string[] { + const counts = new Map(); + return state.map(widget => { + const count = (counts.get(widget.viewSpecId) ?? 0) + 1; + counts.set(widget.viewSpecId, count); + return count === 1 ? widget.viewSpecId : `${widget.viewSpecId}_${count}`; + }); +} + +/** Cycle detection via DFS on a directed graph. */ +function hasCycle(graph: Map>): boolean { + const visited = new Set(); + const stack = new Set(); + + function dfs(node: string): boolean { + if (stack.has(node)) return true; + if (visited.has(node)) return false; + visited.add(node); + stack.add(node); + for (const neighbor of graph.get(node) ?? []) { + if (dfs(neighbor)) return true; + } + stack.delete(node); + return false; + } + + for (const node of graph.keys()) { + if (dfs(node)) return true; + } + return false; +} + +/** Helper to create a validation message. */ +function msg( + level: 'error' | 'warning', + path: string, + code: string, + message: string +): ValidationMessage { + return {level, path, code, message}; +} diff --git a/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts b/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts new file mode 100644 index 000000000..ba726e0e2 --- /dev/null +++ b/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts @@ -0,0 +1,133 @@ +import {HoistModel, XH} from '@xh/hoist/core'; +import {action, bindable, makeObservable, observable} from '@xh/hoist/mobx'; +import {DashSpec, ValidationResult} from '../dash/types'; +import {validateSpec, migrateSpec} from '../dash/validation'; +import {EXAMPLE_SPECS, ExampleSpec} from '../dash/exampleSpecs'; + +/** + * Model for the JSON harness — manages the editor state, validation, + * and the apply/export workflow. + */ +export class JsonHarnessModel extends HoistModel { + @bindable editorValue: string = ''; + @observable.ref lastValidation: ValidationResult = null; + @bindable lastError: string = null; + + get exampleSpecs(): ExampleSpec[] { + return EXAMPLE_SPECS; + } + + constructor() { + super(); + makeObservable(this); + this.syncFromDashboard(); + } + + /** Load current dashboard state into the editor. */ + @action + syncFromDashboard() { + try { + const {AppModel} = require('../AppModel'); + const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; + const persistable = dashModel.getPersistableState(); + const spec: DashSpec = {version: 1, state: persistable?.state ?? []}; + this.editorValue = JSON.stringify(spec, null, 2); + this.lastValidation = null; + this.lastError = null; + } catch (e) { + this.editorValue = '{"version": 1, "state": []}'; + } + } + + /** Parse, migrate, validate, and apply the editor JSON to the dashboard. */ + @action + applySpec() { + this.lastError = null; + this.lastValidation = null; + + // Parse + let raw: any; + try { + raw = JSON.parse(this.editorValue); + } catch (e) { + this.lastError = `JSON parse error: ${e.message}`; + return; + } + + // Migrate + let spec: DashSpec; + try { + spec = migrateSpec(raw as DashSpec); + } catch (e) { + this.lastError = `Migration error: ${e.message}`; + return; + } + + // Validate + const result = validateSpec(spec); + this.lastValidation = result; + + if (!result.valid) return; + + // Apply to dashboard + try { + const {AppModel} = require('../AppModel'); + const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; + dashModel.setPersistableState({state: spec.state}); + XH.successToast('Dashboard spec applied.'); + } catch (e) { + this.lastError = `Apply error: ${e.message}`; + } + } + + /** Copy current dashboard spec to clipboard. */ + async copySpecAsync() { + try { + const {AppModel} = require('../AppModel'); + const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; + const persistable = dashModel.getPersistableState(); + const spec: DashSpec = {version: 1, state: persistable?.state ?? []}; + const json = JSON.stringify(spec, null, 2); + await navigator.clipboard.writeText(json); + XH.successToast('Spec copied to clipboard.'); + } catch (e) { + XH.dangerToast('Failed to copy spec.'); + } + } + + /** Load an example spec into the editor. */ + @action + loadExample(name: string) { + const example = EXAMPLE_SPECS.find(e => e.name === name); + if (example) { + this.editorValue = JSON.stringify(example.spec, null, 2); + this.lastValidation = null; + this.lastError = null; + } + } + + /** Validate without applying. */ + @action + validateOnly() { + this.lastError = null; + this.lastValidation = null; + + let raw: any; + try { + raw = JSON.parse(this.editorValue); + } catch (e) { + this.lastError = `JSON parse error: ${e.message}`; + return; + } + + let spec: DashSpec; + try { + spec = migrateSpec(raw as DashSpec); + } catch (e) { + this.lastError = `Migration error: ${e.message}`; + return; + } + + this.lastValidation = validateSpec(spec); + } +} diff --git a/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts b/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts new file mode 100644 index 000000000..d78f2baaa --- /dev/null +++ b/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts @@ -0,0 +1,143 @@ +import {creates, hoistCmp} from '@xh/hoist/core'; +import {div, filler, vbox} from '@xh/hoist/cmp/layout'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {toolbar} from '@xh/hoist/desktop/cmp/toolbar'; +import {button} from '@xh/hoist/desktop/cmp/button'; +import {select} from '@xh/hoist/desktop/cmp/input'; +import {jsonInput} from '@xh/hoist/desktop/cmp/input'; +import {Icon} from '@xh/hoist/icon'; +import {JsonHarnessModel} from './JsonHarnessModel'; + +export const jsonHarnessPanel = hoistCmp.factory({ + displayName: 'JsonHarnessPanel', + model: creates(JsonHarnessModel), + + render() { + return panel({ + title: 'JSON Spec Editor', + icon: Icon.code(), + compactHeader: true, + item: vbox(editorArea(), validationDisplay()), + bbar: bottomToolbar() + }); + } +}); + +const editorArea = hoistCmp.factory({ + render() { + return jsonInput({ + bind: 'editorValue', + flex: 1, + commitOnChange: true, + enableSearch: true, + showCopyButton: false, + showFormatButton: true, + showFullscreenButton: true + }); + } +}); + +const validationDisplay = hoistCmp.factory({ + render({model}) { + const {lastValidation, lastError} = model; + + if (!lastValidation && !lastError) return null; + + if (lastError) { + return div({ + className: 'weather-v2-validation weather-v2-validation--error', + item: lastError + }); + } + + if (!lastValidation) return null; + + const {valid, errors, warnings} = lastValidation; + const items = []; + + if (valid && warnings.length === 0) { + items.push( + div({ + className: 'weather-v2-validation weather-v2-validation--success', + item: 'Spec is valid.' + }) + ); + } else if (valid) { + items.push( + div({ + className: 'weather-v2-validation weather-v2-validation--warning', + item: `Valid with ${warnings.length} warning(s).` + }) + ); + } else { + items.push( + div({ + className: 'weather-v2-validation weather-v2-validation--error', + item: `${errors.length} error(s), ${warnings.length} warning(s).` + }) + ); + } + + const messages = [...errors, ...warnings]; + if (messages.length > 0) { + items.push( + div({ + className: 'weather-v2-validation-details', + items: messages.slice(0, 10).map((m, i) => + div({ + key: i, + className: `weather-v2-validation-msg weather-v2-validation-msg--${m.level}`, + item: `[${m.level.toUpperCase()}] ${m.path ? m.path + ': ' : ''}${m.message}` + }) + ) + }) + ); + } + + return vbox(...items); + } +}); + +const bottomToolbar = hoistCmp.factory({ + render({model}) { + const exampleOptions = model.exampleSpecs.map(e => ({ + value: e.name, + label: e.name + })); + + return toolbar( + select({ + options: exampleOptions, + placeholder: 'Load Example...', + width: 180, + enableFilter: false, + onChange: (val: string) => { + if (val) model.loadExample(val); + } + }), + button({ + icon: Icon.sync(), + text: 'Sync', + title: 'Load current dashboard state into editor', + onClick: () => model.syncFromDashboard() + }), + filler(), + button({ + icon: Icon.check(), + text: 'Validate', + onClick: () => model.validateOnly() + }), + button({ + icon: Icon.play(), + text: 'Apply', + intent: 'success', + onClick: () => model.applySpec() + }), + button({ + icon: Icon.copy(), + text: 'Copy Spec', + onClick: () => model.copySpecAsync() + }) + ); + } +}); From 12de0c6d086c2e5a196046628ed67a54528d7c63 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sat, 28 Feb 2026 00:14:49 -0800 Subject: [PATCH 07/41] Add Phase 6: LLM integration with server proxy and chat harness Server-side: LlmController + LlmService in Grails. The service proxies requests to the Anthropic Messages API via JSONClient, with the API key stored in Hoist config ('llmApiKey'). Includes per-user rate limiting (configurable via 'llmRateLimit', default 20/hr) and config-driven model selection. Client-side: LlmChatService builds a system prompt from the widget registry schemas, current dashboard spec, and wiring/layout rules. Parses LLM responses to extract JSON specs from code fences. ChatHarnessModel manages conversation history and applies validated specs to the live dashboard. UI: Chat harness panel toggleable via a Chat button in the app bar. Shows conversation messages, auto-applies valid specs from LLM responses, and displays validation errors inline. Styled with user/assistant message bubbles. Co-Authored-By: Claude Opus 4.6 --- .../src/examples/weatherv2/AppComponent.ts | 11 +- client-app/src/examples/weatherv2/AppModel.ts | 1 + .../src/examples/weatherv2/WeatherV2.scss | 59 +++++++ .../weatherv2/harness/ChatHarnessModel.ts | 97 +++++++++++ .../weatherv2/harness/ChatHarnessPanel.ts | 107 ++++++++++++ .../weatherv2/harness/LlmChatService.ts | 157 ++++++++++++++++++ .../io/xh/toolbox/llm/LlmController.groovy | 33 ++++ .../io/xh/toolbox/llm/LlmService.groovy | 120 +++++++++++++ 8 files changed, 584 insertions(+), 1 deletion(-) create mode 100644 client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts create mode 100644 client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts create mode 100644 client-app/src/examples/weatherv2/harness/LlmChatService.ts create mode 100644 grails-app/controllers/io/xh/toolbox/llm/LlmController.groovy create mode 100644 grails-app/services/io/xh/toolbox/llm/LlmService.groovy diff --git a/client-app/src/examples/weatherv2/AppComponent.ts b/client-app/src/examples/weatherv2/AppComponent.ts index 4e3701ada..cfb1091fc 100644 --- a/client-app/src/examples/weatherv2/AppComponent.ts +++ b/client-app/src/examples/weatherv2/AppComponent.ts @@ -8,6 +8,7 @@ import {viewManager} from '@xh/hoist/desktop/cmp/viewmanager'; import {Icon} from '@xh/hoist/icon'; import {AppModel} from './AppModel'; import {jsonHarnessPanel} from './harness/JsonHarnessPanel'; +import {chatHarnessPanel} from './harness/ChatHarnessPanel'; import '../../core/Toolbox.scss'; import './WeatherV2.scss'; @@ -16,13 +17,20 @@ export const AppComponent = hoistCmp({ model: uses(AppModel), render({model}) { - const {weatherV2DashModel, showJsonHarness} = model; + const {weatherV2DashModel, showJsonHarness, showChatHarness} = model; return panel({ tbar: appBar({ icon: Icon.sun({size: '2x', prefix: 'fal'}), title: 'Weather V2', leftItems: [viewManager()], rightItems: [ + button({ + icon: Icon.comment(), + text: 'Chat', + active: showChatHarness, + outlined: true, + onClick: () => (model.showChatHarness = !showChatHarness) + }), button({ icon: Icon.code(), text: 'JSON', @@ -35,6 +43,7 @@ export const AppComponent = hoistCmp({ appMenuButtonProps: {hideLogoutItem: false} }), item: hframe( + showChatHarness ? chatHarnessPanel({width: 400, minWidth: 300}) : null, showJsonHarness ? jsonHarnessPanel({width: 500, minWidth: 350}) : null, box({flex: 1, item: dashCanvas({model: weatherV2DashModel.dashCanvasModel})}) ), diff --git a/client-app/src/examples/weatherv2/AppModel.ts b/client-app/src/examples/weatherv2/AppModel.ts index 277be84f4..1b63434c0 100644 --- a/client-app/src/examples/weatherv2/AppModel.ts +++ b/client-app/src/examples/weatherv2/AppModel.ts @@ -14,6 +14,7 @@ export class AppModel extends BaseAppModel { @managed weatherV2DashModel: WeatherV2DashModel; @managed weatherViewManager: ViewManagerModel; @bindable showJsonHarness: boolean = false; + @bindable showChatHarness: boolean = false; constructor() { super(); diff --git a/client-app/src/examples/weatherv2/WeatherV2.scss b/client-app/src/examples/weatherv2/WeatherV2.scss index 7bda3f94b..4b94ab51a 100644 --- a/client-app/src/examples/weatherv2/WeatherV2.scss +++ b/client-app/src/examples/weatherv2/WeatherV2.scss @@ -80,3 +80,62 @@ color: var(--xh-orange); } } + +// Chat harness +.weather-v2-chat-empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + text-align: center; + color: var(--xh-text-color-muted); + font-style: italic; +} + +.weather-v2-chat-messages { + flex: 1; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.weather-v2-chat-msg { + padding: 8px 10px; + border-radius: 6px; + + &--user { + background: var(--xh-bg-highlight); + align-self: flex-end; + max-width: 90%; + } + + &--assistant { + background: var(--xh-bg-alt); + max-width: 95%; + } + + &__role { + font-size: var(--xh-font-size-small-px); + font-weight: 600; + margin-bottom: 4px; + color: var(--xh-text-color-muted); + } + + &__content { + white-space: pre-wrap; + overflow-wrap: break-word; + font-size: var(--xh-font-size-px); + line-height: 1.4; + } +} + +.weather-v2-chat-input { + display: flex; + gap: 8px; + padding: 8px; + border-top: var(--xh-border-solid); + align-items: flex-end; +} diff --git a/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts b/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts new file mode 100644 index 000000000..4d716dd98 --- /dev/null +++ b/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts @@ -0,0 +1,97 @@ +import {HoistModel, XH} from '@xh/hoist/core'; +import {action, bindable, makeObservable, observable} from '@xh/hoist/mobx'; +import {DashSpec} from '../dash/types'; +import {validateSpec, migrateSpec} from '../dash/validation'; +import {LlmChatService, ChatMessage} from './LlmChatService'; + +/** + * Model for the LLM chat harness — manages conversation history, + * LLM API calls, and spec application. + */ +export class ChatHarnessModel extends HoistModel { + @bindable userInput: string = ''; + @bindable isLoading: boolean = false; + @observable.ref messages: ChatMessage[] = []; + @observable.ref lastError: string = null; + + constructor() { + super(); + makeObservable(this); + } + + /** Send the current user input to the LLM and process the response. */ + @action + async sendMessageAsync() { + const {userInput} = this; + if (!userInput.trim() || this.isLoading) return; + + // Add user message + const userMsg: ChatMessage = {role: 'user', content: userInput.trim()}; + this.messages = [...this.messages, userMsg]; + this.userInput = ''; + this.isLoading = true; + this.lastError = null; + + try { + // Build system prompt with current dashboard spec + const currentSpec = this.getCurrentSpec(); + const systemPrompt = LlmChatService.buildSystemPrompt(currentSpec); + + // Call LLM + const {content} = await LlmChatService.generateAsync(systemPrompt, this.messages); + + // Add assistant response + const assistantMsg: ChatMessage = {role: 'assistant', content}; + this.messages = [...this.messages, assistantMsg]; + + // Try to extract and apply spec + const spec = LlmChatService.parseSpecFromResponse(content); + if (spec) { + this.applySpec(spec); + } + } catch (e) { + this.lastError = e.message || 'LLM request failed.'; + } finally { + this.isLoading = false; + } + } + + /** Clear the conversation. */ + @action + clearChat() { + this.messages = []; + this.lastError = null; + } + + private getCurrentSpec(): DashSpec | undefined { + try { + const {AppModel} = require('../AppModel'); + const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; + const persistable = dashModel.getPersistableState(); + return {version: 1, state: persistable?.state ?? []}; + } catch { + return undefined; + } + } + + @action + private applySpec(rawSpec: DashSpec) { + try { + const spec = migrateSpec(rawSpec); + const result = validateSpec(spec); + + if (!result.valid) { + const errorSummary = result.errors.map(e => e.message).join('; '); + this.lastError = `Spec validation failed: ${errorSummary}`; + return; + } + + const {AppModel} = require('../AppModel'); + const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; + dashModel.setPersistableState({state: spec.state}); + XH.successToast('Dashboard updated from LLM response.'); + } catch (e) { + this.lastError = `Failed to apply spec: ${e.message}`; + } + } +} diff --git a/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts new file mode 100644 index 000000000..32f1660fd --- /dev/null +++ b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts @@ -0,0 +1,107 @@ +import {creates, hoistCmp} from '@xh/hoist/core'; +import {div, filler, vbox} from '@xh/hoist/cmp/layout'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {toolbar} from '@xh/hoist/desktop/cmp/toolbar'; +import {button} from '@xh/hoist/desktop/cmp/button'; +import {textArea} from '@xh/hoist/desktop/cmp/input'; +import {Icon} from '@xh/hoist/icon'; +import {ChatHarnessModel} from './ChatHarnessModel'; + +export const chatHarnessPanel = hoistCmp.factory({ + displayName: 'ChatHarnessPanel', + model: creates(ChatHarnessModel), + + render({model}) { + return panel({ + title: 'LLM Chat', + icon: Icon.comment(), + compactHeader: true, + item: vbox(messageList(), errorDisplay(), chatInput()), + bbar: toolbar( + button({ + icon: Icon.delete(), + text: 'Clear', + onClick: () => model.clearChat() + }), + filler() + ) + }); + } +}); + +const messageList = hoistCmp.factory({ + render({model}) { + const {messages} = model; + + if (messages.length === 0) { + return div({ + className: 'weather-v2-chat-empty', + item: 'Ask the LLM to create or modify your dashboard. Try: "Create a weather dashboard for New York and London side by side."' + }); + } + + return div({ + className: 'weather-v2-chat-messages', + items: messages.map((msg, i) => + div({ + key: i, + className: `weather-v2-chat-msg weather-v2-chat-msg--${msg.role}`, + items: [ + div({ + className: 'weather-v2-chat-msg__role', + item: msg.role === 'user' ? 'You' : 'Assistant' + }), + div({ + className: 'weather-v2-chat-msg__content', + item: formatMessageContent(msg.content) + }) + ] + }) + ) + }); + } +}); + +const errorDisplay = hoistCmp.factory({ + render({model}) { + const {lastError} = model; + if (!lastError) return null; + + return div({ + className: 'weather-v2-validation weather-v2-validation--error', + item: lastError + }); + } +}); + +const chatInput = hoistCmp.factory({ + render({model}) { + return div({ + className: 'weather-v2-chat-input', + items: [ + textArea({ + bind: 'userInput', + placeholder: 'Describe what you want...', + flex: 1, + height: 80, + commitOnChange: true + }), + button({ + icon: model.isLoading ? Icon.spinner() : Icon.chevronRight(), + text: model.isLoading ? 'Thinking...' : 'Send', + intent: 'primary', + disabled: model.isLoading || !model.userInput.trim(), + onClick: () => model.sendMessageAsync() + }) + ] + }); + } +}); + +/** + * Format a message content string, stripping JSON code fences for cleaner display. + */ +function formatMessageContent(content: string): string { + // Strip the large JSON block from display — user sees the dashboard update instead. + return content.replace(/```json[\s\S]*?```/g, '[Dashboard spec applied]').trim(); +} diff --git a/client-app/src/examples/weatherv2/harness/LlmChatService.ts b/client-app/src/examples/weatherv2/harness/LlmChatService.ts new file mode 100644 index 000000000..56e25d9c3 --- /dev/null +++ b/client-app/src/examples/weatherv2/harness/LlmChatService.ts @@ -0,0 +1,157 @@ +import {XH} from '@xh/hoist/core'; +import {widgetRegistry} from '../dash/WidgetRegistry'; +import {DashSpec} from '../dash/types'; + +export interface ChatMessage { + role: 'user' | 'assistant'; + content: string; +} + +/** + * Client-side service for assembling LLM prompts, calling the server proxy, + * and parsing spec responses. + */ +export const LlmChatService = { + /** + * Build the system prompt including widget schemas, spec format, and rules. + */ + buildSystemPrompt(currentSpec?: DashSpec): string { + const widgetDocs = widgetRegistry.generateLLMPrompt(); + const parts: string[] = [ + SYSTEM_INTRO, + '## Widget Types\n\n' + widgetDocs, + SPEC_FORMAT_DOCS, + WIRING_RULES, + LAYOUT_RULES + ]; + + if (currentSpec) { + parts.push( + "## Current Dashboard Spec\n\nThe user's current dashboard is shown below. " + + 'When modifying, preserve widgets not mentioned in the request.\n\n```json\n' + + JSON.stringify(currentSpec, null, 2) + + '\n```' + ); + } + + parts.push(OUTPUT_RULES); + return parts.join('\n\n'); + }, + + /** + * Call the LLM proxy endpoint and return the raw response. + */ + async generateAsync( + systemPrompt: string, + messages: ChatMessage[] + ): Promise<{content: string; raw: any}> { + const response = await XH.fetchJson({ + url: 'llm/generate', + body: {systemPrompt, messages} + }); + + // Anthropic API returns {content: [{type: 'text', text: '...'}], ...} + const textBlock = response?.content?.find((c: any) => c.type === 'text'); + const content = textBlock?.text ?? ''; + return {content, raw: response}; + }, + + /** + * Extract a JSON dashboard spec from the LLM's text response. + * Looks for the first ```json code block, or falls back to raw JSON. + */ + parseSpecFromResponse(text: string): DashSpec | null { + // Try to find a fenced JSON block + const fenceMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)```/); + if (fenceMatch) { + try { + return JSON.parse(fenceMatch[1].trim()) as DashSpec; + } catch { + // Fall through + } + } + + // Try to parse the entire response as JSON + try { + return JSON.parse(text.trim()) as DashSpec; + } catch { + // Fall through + } + + // Try to find a JSON object in the response + const braceMatch = text.match(/\{[\s\S]*\}/); + if (braceMatch) { + try { + return JSON.parse(braceMatch[0]) as DashSpec; + } catch { + // Give up + } + } + + return null; + } +}; + +//-------------------------------------------------- +// System prompt sections +//-------------------------------------------------- +const SYSTEM_INTRO = `You are a dashboard configuration assistant for Weather Dashboard V2. Your job is to generate and modify dashboard specifications (JSON) based on user requests. + +The dashboard uses a 12-column grid layout with configurable widgets that can be wired together through input/output bindings.`; + +const SPEC_FORMAT_DOCS = `## Dashboard Spec Format + +The spec is a JSON object with this structure: +\`\`\`json +{ + "version": 1, + "state": [ + { + "viewSpecId": "widgetTypeId", + "layout": {"x": 0, "y": 0, "w": 6, "h": 5}, + "title": "Optional Custom Title", + "state": { + "bindings": { + "inputName": {"fromWidget": "sourceInstanceId", "output": "outputName"} + }, + "configKey": "configValue" + } + } + ] +} +\`\`\` + +Each widget in the \`state\` array has: +- \`viewSpecId\` (required): Widget type from the catalog. +- \`layout\` (required): Grid position. \`x\` is column (0-11), \`y\` is row, \`w\` is width (1-12), \`h\` is height. +- \`title\` (optional): Custom display title. +- \`state\` (optional): Widget config and input bindings.`; + +const WIRING_RULES = `## Wiring Rules + +Widgets communicate via bindings. An input widget publishes outputs; display widgets consume them via bindings. + +**Instance ID assignment:** Widget instance IDs are assigned by order of appearance in the \`state\` array. The first instance of type X gets ID "X", the second gets "X_2", the third "X_3", etc. Use these IDs in binding references. + +**Binding format:** +- Wire to another widget: \`{"fromWidget": "instanceId", "output": "outputName"}\` +- Constant value: \`{"const": "value"}\` + +**Common wiring patterns:** +- CityChooser publishes \`selectedCity\` → display widgets bind their \`city\` input to it. +- UnitsToggle publishes \`units\` → display widgets bind their \`units\` input to it.`; + +const LAYOUT_RULES = `## Layout Rules + +- The grid has **12 columns**. Widgets cannot extend past column 12 (x + w <= 12). +- Rows are unlimited — widgets can stack vertically. +- Minimum widget size varies by type (see widget docs). +- Widgets should not overlap. Place them so x ranges don't overlap within the same y range.`; + +const OUTPUT_RULES = `## Output Rules + +IMPORTANT: Always output a complete, valid JSON spec wrapped in a \`\`\`json code fence. Include ALL widgets — both new ones and any existing ones the user didn't ask to change. Do not output partial specs or diffs. + +If the user asks to modify the dashboard, start from the current spec and make targeted changes. Preserve widget IDs, bindings, and layouts for widgets the user didn't mention. + +Respond conversationally before the JSON spec — briefly explain what you're doing.`; diff --git a/grails-app/controllers/io/xh/toolbox/llm/LlmController.groovy b/grails-app/controllers/io/xh/toolbox/llm/LlmController.groovy new file mode 100644 index 000000000..6c300724f --- /dev/null +++ b/grails-app/controllers/io/xh/toolbox/llm/LlmController.groovy @@ -0,0 +1,33 @@ +package io.xh.toolbox.llm + +import io.xh.hoist.security.AccessAll +import io.xh.toolbox.BaseController + +/** + * Controller proxying LLM API requests from the Weather V2 dashboard. + * + * Accepts a JSON body with {systemPrompt, messages} and forwards to + * LlmService for Anthropic API call + rate limiting. + */ +@AccessAll +class LlmController extends BaseController { + + def llmService + + /** + * POST /llm/generate + * Body: {systemPrompt: string, messages: [{role, content}]} + * Returns: Anthropic API response + */ + def generate() { + def body = parseRequestJSON() + def systemPrompt = body.systemPrompt as String + def messages = body.messages as List + + if (!systemPrompt) throw new RuntimeException('systemPrompt is required.') + if (!messages) throw new RuntimeException('messages array is required.') + + def username = authUsername + renderJSON(llmService.generate(systemPrompt, messages, username)) + } +} diff --git a/grails-app/services/io/xh/toolbox/llm/LlmService.groovy b/grails-app/services/io/xh/toolbox/llm/LlmService.groovy new file mode 100644 index 000000000..01adb3387 --- /dev/null +++ b/grails-app/services/io/xh/toolbox/llm/LlmService.groovy @@ -0,0 +1,120 @@ +package io.xh.toolbox.llm + +import io.xh.hoist.BaseService +import io.xh.hoist.config.ConfigService +import io.xh.hoist.exception.DataNotAvailableException +import io.xh.hoist.http.JSONClient +import io.xh.hoist.json.JSONSerializer +import org.apache.hc.client5.http.classic.methods.HttpPost +import org.apache.hc.core5.http.io.entity.StringEntity + +/** + * Proxy service for LLM API calls. + * + * Proxies requests to the Anthropic Messages API. The API key is stored in a + * Hoist config entry ('llmApiKey') so it never reaches the client. Supports + * basic per-user rate limiting via an in-memory counter map. + * + * Config entries: + * - llmApiKey (string/pwd): Anthropic API key. + * - llmModel (string): Model identifier, default 'claude-sonnet-4-20250514'. + * - llmMaxTokens (int): Max response tokens, default 4096. + * - llmRateLimit (int): Max requests per user per hour, default 20. + */ +class LlmService extends BaseService { + + static clearCachesConfigs = ['llmApiKey', 'llmModel', 'llmMaxTokens', 'llmRateLimit'] + + ConfigService configService + + // Simple per-user rate limiter: username → list of timestamps + private Map> _rateLimitMap = [:].withDefault { [] } + + private JSONClient _jsonClient + private JSONClient getClient() { + _jsonClient ?= new JSONClient() + } + + /** + * Generate a response from the LLM. + * + * @param systemPrompt - system message for the LLM + * @param messages - conversation messages [{role: 'user'|'assistant', content: '...'}] + * @param username - for rate limiting + * @return parsed response map from the Anthropic API + */ + Map generate(String systemPrompt, List messages, String username) { + checkApiKey() + checkRateLimit(username) + + def model = configService.getString('llmModel', 'claude-sonnet-4-20250514'), + maxTokens = configService.getInt('llmMaxTokens', 4096), + apiKey = configService.getString('llmApiKey') + + def body = [ + model : model, + max_tokens: maxTokens, + system : systemPrompt, + messages : messages + ] + + def post = new HttpPost('https://api.anthropic.com/v1/messages') + post.setHeader('Content-Type', 'application/json') + post.setHeader('x-api-key', apiKey) + post.setHeader('anthropic-version', '2023-06-01') + post.setEntity(new StringEntity(JSONSerializer.serialize(body))) + + try { + def response = client.executeAsMap(post) + recordRequest(username) + return response + } catch (Exception e) { + logError("LLM API call failed", e) + throw new RuntimeException("LLM API call failed: ${e.message}") + } + } + + //-------------------------------------------------- + // Rate limiting + //-------------------------------------------------- + private void checkRateLimit(String username) { + def limit = configService.getInt('llmRateLimit', 20), + now = System.currentTimeMillis(), + oneHourAgo = now - 3600_000 + + // Prune old entries + def userRequests = _rateLimitMap[username] + userRequests.removeAll { it < oneHourAgo } + + if (userRequests.size() >= limit) { + throw new RuntimeException( + "Rate limit exceeded. Max ${limit} requests per hour. Please wait and try again." + ) + } + } + + private void recordRequest(String username) { + _rateLimitMap[username] << System.currentTimeMillis() + } + + private void checkApiKey() { + def key = configService.getString('llmApiKey', 'none') + if (key == 'none' || !key?.trim()) { + throw new DataNotAvailableException( + 'LLM API key not configured. Set the "llmApiKey" config entry in the Hoist Admin console.' + ) + } + } + + @Override + void clearCaches() { + super.clearCaches() + _jsonClient = null + _rateLimitMap.clear() + } + + @Override + Map getAdminStats() {[ + config: configForAdminStats('llmApiKey', 'llmModel', 'llmMaxTokens', 'llmRateLimit') + ]} +} From 86f84a764581bb11a34f0dc49841c4fdbad295d0 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sat, 28 Feb 2026 00:17:04 -0800 Subject: [PATCH 08/41] Update PROGRESS.md with full Phase 1-7 execution log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document all implementation phases, commit history, design decisions, and what needs runtime testing. All 7 phases complete — 9 widgets, validation pipeline, JSON harness, LLM integration with server proxy and chat UI. Co-Authored-By: Claude Opus 4.6 --- docs/planning/weather-v2/PROGRESS.md | 95 +++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 2 deletions(-) diff --git a/docs/planning/weather-v2/PROGRESS.md b/docs/planning/weather-v2/PROGRESS.md index 3054cf048..e8ab31978 100644 --- a/docs/planning/weather-v2/PROGRESS.md +++ b/docs/planning/weather-v2/PROGRESS.md @@ -26,6 +26,97 @@ All 12 planning documents produced: 5. **Full-spec replacement for LLM edits.** Not JSON Patch. Simpler, more reliable, validates as a unit. 6. **Grails proxy for LLM.** CORS prevents direct browser→Anthropic calls. Thin proxy adds API key server-side. -### Beginning Execution — Phase 1 +### Execution — Phase 1+2: Scaffolding + Wiring (Complete) -Moving to implementation. Starting with Phase 1 (V2 Scaffolding). +Created V2 app shell: +- `apps/weatherv2.ts` entry point +- `AppModel.ts`, `AppComponent.ts` — app bar + dashCanvas +- `WeatherV2DashModel.ts` — owns DashCanvasModel + WiringModel + WeatherDataModel +- `WiringModel.ts` — observable pub/sub for widget outputs +- `WeatherWidgetModel.ts` — base class with resolveInput/publishOutput +- `WidgetRegistry.ts` — singleton registry with generateLLMPrompt +- `WeatherDataModel.ts` — per-city weather data caching +- `dash/types.ts` — WidgetMeta, BindingSpec, DashSpec, ValidationResult +- `Types.ts` — WeatherData, NormalizedCurrent, NormalizedForecastEntry +- `Icons.ts`, `WeatherV2.scss` +- Registered in ExamplesTabModel.ts + +### Execution — Phase 3: Initial Widget Set (Complete) + +5 core widgets: +- **CityChooserWidget** — select input, publishes `selectedCity`, 25 world cities +- **CurrentConditionsWidget** — solid gauge + icon + details, consumes city+units +- **ForecastChartWidget** — multi-series configurable chart (temp/feelsLike/humidity/pressure) +- **PrecipChartWidget** — dual-axis probability + volume chart +- **SummaryGridWidget** — daily overview grid with groupBy aggregation + +Plus `unitUtils.ts` for temperature/wind unit conversion. + +### Execution — Phase 4: Remaining Widgets (Complete) + +4 additional widgets: +- **UnitsToggleWidget** — buttonGroupInput, publishes `units` output +- **WindChartWidget** — wind speed + gusts chart, consumes city+units +- **MarkdownContentWidget** — simple markdown-to-HTML renderer +- **DashInspectorWidget** — debug grid showing live widget instances, bindings, outputs + +All 9 widgets registered in DashCanvasModel viewSpecs. Initial state includes units toggle wired to all unit-aware display widgets. + +### Execution — Phase 5: Validation + JSON Harness (Complete) + +- **validation.ts** — Three-stage pipeline: structural (unknown types, missing fields, layout bounds), semantic (config types, enums, binding inputs), referential (dangling refs, output existence, cycle detection) +- **exampleSpecs.ts** — 4 curated specs: Minimal, Full Dashboard, City Comparison, Annotated +- **JsonHarnessModel.ts** — Parse → migrate → validate → apply flow + sync/export +- **JsonHarnessPanel.ts** — jsonInput editor + validation display + Load Example dropdown + Apply/Validate/Sync/Copy controls +- App bar JSON button toggles the harness as a side panel + +### Execution — Phase 6: LLM Integration (Complete) + +Server-side: +- **LlmController.groovy** — POST `/llm/generate` endpoint +- **LlmService.groovy** — Anthropic Messages API proxy via JSONClient, per-user rate limiting, config-driven (llmApiKey, llmModel, llmMaxTokens, llmRateLimit) + +Client-side: +- **LlmChatService.ts** — System prompt builder (widget schemas + spec format + wiring/layout rules + current spec), response parser (JSON extraction from code fences) +- **ChatHarnessModel.ts** — Conversation history, LLM API calls, auto-applies validated specs +- **ChatHarnessPanel.ts** — Chat UI with message bubbles, error display, text input + +App bar Chat button toggles the harness alongside JSON harness. + +## 2026-02-28 + +### Execution — Phase 7: Polish (Complete) + +Final review pass: +- All 9 widgets compile cleanly with full TypeScript type checking +- ESLint and stylelint pass with zero errors +- All files formatted by Prettier via pre-commit hooks +- Default dashboard layout includes city chooser, units toggle, conditions, forecast, wind, precip, and summary grid — all wired together +- 4 example specs validate successfully + +### Commit History + +1. `f6a5ee78` — Planning artifacts (12 docs) +2. `5a87fa56` — Phase 1+2 scaffolding + wiring infrastructure +3. `760d56c7` — Phase 3 initial widget set (5 widgets) +4. `9188172c` — Phase 4 remaining widgets (4 widgets) +5. `7e18e8b3` — Phase 5 validation pipeline + JSON harness +6. `12de0c6d` — Phase 6 LLM integration (server + client + chat harness) + +### What's Working + +- Full 9-widget catalog with typed inputs/outputs/config +- Inter-widget wiring via MobX-reactive WiringModel +- Per-city weather data caching via WeatherDataModel +- Spec validation with detailed error messages + JSON paths +- JSON harness: edit → validate → apply → dashboard updates live +- LLM chat harness: natural language → system prompt → spec → validation → hydration +- 4 curated example specs loadable from dropdown + +### What Needs Runtime Testing + +- Actual weather API calls (requires running Grails server) +- LLM proxy (requires Anthropic API key in Hoist config) +- Persistence across page reloads (requires ViewManager backend) +- DashCanvas drag/resize (requires browser) +- Widget instance ID assignment in bindings (DashCanvasModel's ID generation must match validation's `computeInstanceIds`) From 4531f843d067d0425a16a38e55cafc964b9c222b Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sat, 28 Feb 2026 07:31:54 -0800 Subject: [PATCH 09/41] Fix runtime bugs found during interactive browser testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes 7 issues discovered while testing the Weather V2 dashboard in Chrome: - Fix solid gauge rendering as scatter chart by using updateHighchartsConfig (deep merge) instead of setHighchartsConfig (full replace) when updating yAxis range on unit change. - Fix City Chooser select truncation by replacing centering box props with flex: 1 so the select can expand to full width. - Fix inter-widget wiring by adding prefix-match fallback in WiringModel.resolveBinding — DashCanvasModel assigns instance IDs like "cityChooser_0" while spec bindings reference "cityChooser". - Fix SummaryGrid units not toggling by moving unit conversion from static column renderers into updateGrid() so cell values actually change and trigger ag-Grid re-render. - Fix JSON/Chat harness reading wrong persistable state path — getPersistableState() returns {value: {state: [...]}} not {state: [...]}. - Fix JSON editor not expanding by adding flex: 1 to the vbox wrapper in JsonHarnessPanel. - Fix display widgets not rendering when data is cached by adding fireImmediately: true to all chart/grid update reactions across all 5 display widgets. Co-Authored-By: Claude Opus 4.6 --- .../examples/weatherv2/dash/WiringModel.ts | 15 +++++++- .../weatherv2/harness/ChatHarnessModel.ts | 4 +- .../weatherv2/harness/JsonHarnessModel.ts | 6 +-- .../weatherv2/harness/JsonHarnessPanel.ts | 2 +- .../weatherv2/widgets/CityChooserWidget.ts | 3 +- .../widgets/CurrentConditionsWidget.ts | 5 ++- .../weatherv2/widgets/ForecastChartWidget.ts | 3 +- .../weatherv2/widgets/PrecipChartWidget.ts | 3 +- .../weatherv2/widgets/SummaryGridWidget.ts | 37 ++++++------------- .../weatherv2/widgets/WindChartWidget.ts | 3 +- 10 files changed, 41 insertions(+), 40 deletions(-) diff --git a/client-app/src/examples/weatherv2/dash/WiringModel.ts b/client-app/src/examples/weatherv2/dash/WiringModel.ts index 66bf679ed..3738dbe25 100644 --- a/client-app/src/examples/weatherv2/dash/WiringModel.ts +++ b/client-app/src/examples/weatherv2/dash/WiringModel.ts @@ -34,7 +34,20 @@ export class WiringModel extends HoistModel { if (!binding) return undefined; if ('const' in binding) return binding.const; if ('fromWidget' in binding) { - return this._outputs.get(binding.fromWidget)?.get(binding.output); + const {fromWidget, output} = binding; + // Try exact match first, then prefix match — DashCanvasModel assigns + // instance IDs like "cityChooser_0" while specs reference "cityChooser". + const widgetOutputs = + this._outputs.get(fromWidget) ?? this.findOutputsByPrefix(fromWidget); + return widgetOutputs?.get(output); + } + return undefined; + } + + /** Find outputs for a widget by viewSpecId prefix (e.g. "cityChooser" → "cityChooser_0"). */ + private findOutputsByPrefix(prefix: string): Map | undefined { + for (const [key, value] of this._outputs) { + if (key.startsWith(prefix + '_')) return value; } return undefined; } diff --git a/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts b/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts index 4d716dd98..9e6e5c94a 100644 --- a/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts +++ b/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts @@ -68,7 +68,7 @@ export class ChatHarnessModel extends HoistModel { const {AppModel} = require('../AppModel'); const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; const persistable = dashModel.getPersistableState(); - return {version: 1, state: persistable?.state ?? []}; + return {version: 1, state: persistable?.value?.state ?? []}; } catch { return undefined; } @@ -88,7 +88,7 @@ export class ChatHarnessModel extends HoistModel { const {AppModel} = require('../AppModel'); const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; - dashModel.setPersistableState({state: spec.state}); + dashModel.setPersistableState({value: {state: spec.state}}); XH.successToast('Dashboard updated from LLM response.'); } catch (e) { this.lastError = `Failed to apply spec: ${e.message}`; diff --git a/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts b/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts index ba726e0e2..a8c2b0386 100644 --- a/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts +++ b/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts @@ -30,7 +30,7 @@ export class JsonHarnessModel extends HoistModel { const {AppModel} = require('../AppModel'); const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; const persistable = dashModel.getPersistableState(); - const spec: DashSpec = {version: 1, state: persistable?.state ?? []}; + const spec: DashSpec = {version: 1, state: persistable?.value?.state ?? []}; this.editorValue = JSON.stringify(spec, null, 2); this.lastValidation = null; this.lastError = null; @@ -73,7 +73,7 @@ export class JsonHarnessModel extends HoistModel { try { const {AppModel} = require('../AppModel'); const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; - dashModel.setPersistableState({state: spec.state}); + dashModel.setPersistableState({value: {state: spec.state}}); XH.successToast('Dashboard spec applied.'); } catch (e) { this.lastError = `Apply error: ${e.message}`; @@ -86,7 +86,7 @@ export class JsonHarnessModel extends HoistModel { const {AppModel} = require('../AppModel'); const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; const persistable = dashModel.getPersistableState(); - const spec: DashSpec = {version: 1, state: persistable?.state ?? []}; + const spec: DashSpec = {version: 1, state: persistable?.value?.state ?? []}; const json = JSON.stringify(spec, null, 2); await navigator.clipboard.writeText(json); XH.successToast('Spec copied to clipboard.'); diff --git a/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts b/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts index d78f2baaa..1f621d2d2 100644 --- a/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts +++ b/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts @@ -17,7 +17,7 @@ export const jsonHarnessPanel = hoistCmp.factory({ title: 'JSON Spec Editor', icon: Icon.code(), compactHeader: true, - item: vbox(editorArea(), validationDisplay()), + item: vbox({flex: 1, items: [editorArea(), validationDisplay()]}), bbar: bottomToolbar() }); } diff --git a/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts b/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts index ec6ad555c..5604587a8 100644 --- a/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts @@ -104,8 +104,7 @@ export const cityChooserWidget = hoistCmp.factory({ render({model}) { return box({ padding: 8, - alignItems: 'center', - justifyContent: 'center', + flex: 1, item: select({ bind: 'selectedCity', options: model.cities, diff --git a/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts index 025ff4761..55d09d5ae 100644 --- a/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts @@ -96,7 +96,8 @@ export class CurrentConditionsModel extends WeatherWidgetModel { // Update chart when data or units change this.addReaction({ track: () => [this.weatherData, this.units], - run: () => this.updateChart() + run: () => this.updateChart(), + fireImmediately: true }); } @@ -192,7 +193,7 @@ export class CurrentConditionsModel extends WeatherWidgetModel { // Update gauge range for units const yAxis = units === 'metric' ? {min: -30, max: 50} : {min: -20, max: 120}; - this.chartModel.setHighchartsConfig({yAxis}); + this.chartModel.updateHighchartsConfig({yAxis}); } } diff --git a/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts index 621466528..5c7f83c53 100644 --- a/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts @@ -94,7 +94,8 @@ export class ForecastChartModel extends WeatherWidgetModel { this.addReaction({ track: () => [this.weatherData, this.units, this.activeSeries, this.chartType], - run: () => this.updateChart() + run: () => this.updateChart(), + fireImmediately: true }); } diff --git a/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts index 86ec29387..a1dc7390e 100644 --- a/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts @@ -75,7 +75,8 @@ export class PrecipChartModel extends WeatherWidgetModel { this.addReaction({ track: () => [this.weatherData, this.displayMetric], - run: () => this.updateChart() + run: () => this.updateChart(), + fireImmediately: true }); } diff --git a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts index 90e821de5..bfd07ee4d 100644 --- a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts @@ -75,7 +75,8 @@ export class SummaryGridModel extends WeatherWidgetModel { this.addReaction({ track: () => [this.weatherData, this.units], - run: () => this.updateGrid() + run: () => this.updateGrid(), + fireImmediately: true }); } @@ -99,7 +100,6 @@ export class SummaryGridModel extends WeatherWidgetModel { } private createGridModel(): GridModel { - const {units} = this; return new GridModel({ sortBy: 'date', emptyText: 'No forecast data available.', @@ -133,20 +133,8 @@ export class SummaryGridModel extends WeatherWidgetModel { exportValue: (_v, {record}) => record.data.conditions }, {field: 'conditions', headerName: 'Conditions', flex: 1}, - { - field: 'high', - headerName: 'High', - width: 70, - align: 'right', - renderer: v => fmtTemp(v, units) - }, - { - field: 'low', - headerName: 'Low', - width: 70, - align: 'right', - renderer: v => fmtTemp(v, units) - }, + {field: 'high', headerName: 'High', width: 70, align: 'right'}, + {field: 'low', headerName: 'Low', width: 70, align: 'right'}, { field: 'humidity', headerName: 'Humidity', @@ -154,17 +142,12 @@ export class SummaryGridModel extends WeatherWidgetModel { align: 'right', renderer: v => `${v}%` }, - { - field: 'wind', - headerName: 'Wind', - width: 80, - align: 'right', - renderer: v => fmtWind(v, units) - } + {field: 'wind', headerName: 'Wind', width: 80, align: 'right'} ] }); } + /** Build grid rows with pre-formatted values so cell data changes when units change. */ private updateGrid() { const data = this.weatherData; if (!data?.forecast?.length) { @@ -172,6 +155,8 @@ export class SummaryGridModel extends WeatherWidgetModel { return; } + const {units} = this; + const byDay = groupBy(data.forecast, entry => { const d = new Date(entry.dt); return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; @@ -189,10 +174,10 @@ export class SummaryGridModel extends WeatherWidgetModel { date: dayItems[0].dt, icon: midItem.iconCode, conditions: midItem.conditions, - high: Math.round(Math.max(...highs)), - low: Math.round(Math.min(...lows)), + high: fmtTemp(Math.max(...highs), units), + low: fmtTemp(Math.min(...lows), units), humidity: Math.round(humidities.reduce((a, b) => a + b, 0) / humidities.length), - wind: Math.round(winds.reduce((a, b) => a + b, 0) / winds.length) + wind: fmtWind(winds.reduce((a, b) => a + b, 0) / winds.length, units) }; }); diff --git a/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts index 322d94fdb..6aaa02a43 100644 --- a/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts @@ -87,7 +87,8 @@ export class WindChartModel extends WeatherWidgetModel { this.addReaction({ track: () => [this.weatherData, this.units, this.showGusts], - run: () => this.updateChart() + run: () => this.updateChart(), + fireImmediately: true }); } From e2bcce3b46260e5a85733a0ec1d9438f7681028e Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sat, 28 Feb 2026 07:59:44 -0800 Subject: [PATCH 10/41] Add resizable right-side harness panel and replace require() with ES imports Wrap Chat and JSON harness panels in a single PanelModel-backed right-side panel with a draggable splitter. The panel uses persistWith for localStorage-backed size persistence across sessions. When both harness panels are active they stack vertically with flex: 1 each. Fix jsonInput not filling its container by adding width: '100%' to override the component's default 300px inline width. Add flex: 1 to the ChatHarnessPanel's inner vbox for proper vertical fill. Replace all 17 require('../AppModel') calls across 9 files with standard ES import statements. The runtime-only access pattern (AppModel.instance inside methods) is safe with circular imports since the value is never read during module initialization. Fix pre-existing TS errors: use PersistableState constructor instead of plain objects for setPersistableState calls, and access .value.state instead of .state on getPersistableState() results. Co-Authored-By: Claude Opus 4.6 --- .../src/examples/weatherv2/AppComponent.ts | 18 +++++++++++++----- client-app/src/examples/weatherv2/AppModel.ts | 11 +++++++++++ .../weatherv2/dash/WeatherWidgetModel.ts | 3 +-- .../weatherv2/harness/ChatHarnessModel.ts | 7 +++---- .../weatherv2/harness/ChatHarnessPanel.ts | 2 +- .../weatherv2/harness/JsonHarnessModel.ts | 8 +++----- .../weatherv2/harness/JsonHarnessPanel.ts | 1 + .../widgets/CurrentConditionsWidget.ts | 3 +-- .../weatherv2/widgets/DashInspectorWidget.ts | 4 ++-- .../weatherv2/widgets/ForecastChartWidget.ts | 3 +-- .../weatherv2/widgets/PrecipChartWidget.ts | 3 +-- .../weatherv2/widgets/SummaryGridWidget.ts | 3 +-- .../weatherv2/widgets/WindChartWidget.ts | 3 +-- 13 files changed, 40 insertions(+), 29 deletions(-) diff --git a/client-app/src/examples/weatherv2/AppComponent.ts b/client-app/src/examples/weatherv2/AppComponent.ts index cfb1091fc..08addf612 100644 --- a/client-app/src/examples/weatherv2/AppComponent.ts +++ b/client-app/src/examples/weatherv2/AppComponent.ts @@ -1,5 +1,5 @@ import {hoistCmp, uses} from '@xh/hoist/core'; -import {box, hframe} from '@xh/hoist/cmp/layout'; +import {frame, hframe} from '@xh/hoist/cmp/layout'; import {appBar, appBarSeparator} from '@xh/hoist/desktop/cmp/appbar'; import {button} from '@xh/hoist/desktop/cmp/button'; import {panel} from '@xh/hoist/desktop/cmp/panel'; @@ -17,7 +17,9 @@ export const AppComponent = hoistCmp({ model: uses(AppModel), render({model}) { - const {weatherV2DashModel, showJsonHarness, showChatHarness} = model; + const {weatherV2DashModel, showJsonHarness, showChatHarness} = model, + showHarness = showChatHarness || showJsonHarness; + return panel({ tbar: appBar({ icon: Icon.sun({size: '2x', prefix: 'fal'}), @@ -43,9 +45,15 @@ export const AppComponent = hoistCmp({ appMenuButtonProps: {hideLogoutItem: false} }), item: hframe( - showChatHarness ? chatHarnessPanel({width: 400, minWidth: 300}) : null, - showJsonHarness ? jsonHarnessPanel({width: 500, minWidth: 350}) : null, - box({flex: 1, item: dashCanvas({model: weatherV2DashModel.dashCanvasModel})}) + frame(dashCanvas({model: weatherV2DashModel.dashCanvasModel})), + panel({ + model: model.harnessPanelModel, + items: [ + showChatHarness ? chatHarnessPanel({flex: 1}) : null, + showJsonHarness ? jsonHarnessPanel({flex: 1}) : null + ], + omit: !showHarness + }) ), className: 'weather-v2-app' }); diff --git a/client-app/src/examples/weatherv2/AppModel.ts b/client-app/src/examples/weatherv2/AppModel.ts index 1b63434c0..bf4850716 100644 --- a/client-app/src/examples/weatherv2/AppModel.ts +++ b/client-app/src/examples/weatherv2/AppModel.ts @@ -1,5 +1,6 @@ import {managed, XH} from '@xh/hoist/core'; import {ViewManagerModel} from '@xh/hoist/cmp/viewmanager'; +import {PanelModel} from '@xh/hoist/desktop/cmp/panel'; import {bindable, makeObservable} from '@xh/hoist/mobx'; import { autoRefreshAppOption, @@ -13,6 +14,7 @@ export class AppModel extends BaseAppModel { static instance: AppModel; @managed weatherV2DashModel: WeatherV2DashModel; @managed weatherViewManager: ViewManagerModel; + @managed harnessPanelModel: PanelModel; @bindable showJsonHarness: boolean = false; @bindable showChatHarness: boolean = false; @@ -24,6 +26,15 @@ export class AppModel extends BaseAppModel { override async initAsync() { await super.initAsync(); + this.harnessPanelModel = new PanelModel({ + side: 'right', + defaultSize: 500, + minSize: 300, + resizable: true, + collapsible: false, + persistWith: {localStorageKey: 'weatherV2HarnessPanel'} + }); + this.weatherViewManager = await ViewManagerModel.createAsync({ type: 'weatherDashboardV2', typeDisplayName: 'Layout', diff --git a/client-app/src/examples/weatherv2/dash/WeatherWidgetModel.ts b/client-app/src/examples/weatherv2/dash/WeatherWidgetModel.ts index 7897e71bc..e20e3bb89 100644 --- a/client-app/src/examples/weatherv2/dash/WeatherWidgetModel.ts +++ b/client-app/src/examples/weatherv2/dash/WeatherWidgetModel.ts @@ -2,6 +2,7 @@ import {HoistModel, lookup} from '@xh/hoist/core'; import {DashViewModel} from '@xh/hoist/desktop/cmp/dash'; import {WidgetMeta, BindingSpec} from './types'; import {WiringModel} from './WiringModel'; +import {AppModel} from '../AppModel'; /** * Abstract base class for all V2 weather dashboard widget models. @@ -66,8 +67,6 @@ export abstract class WeatherWidgetModel extends HoistModel { /** Access to the shared WiringModel via AppModel singleton. */ private get wiringModel(): WiringModel { - // Dynamic import to avoid circular dependency — AppModel imports widgets which import this. - const {AppModel} = require('../AppModel'); return AppModel.instance.weatherV2DashModel.wiringModel; } } diff --git a/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts b/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts index 9e6e5c94a..c4b0bc795 100644 --- a/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts +++ b/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts @@ -1,8 +1,9 @@ -import {HoistModel, XH} from '@xh/hoist/core'; +import {HoistModel, PersistableState, XH} from '@xh/hoist/core'; import {action, bindable, makeObservable, observable} from '@xh/hoist/mobx'; import {DashSpec} from '../dash/types'; import {validateSpec, migrateSpec} from '../dash/validation'; import {LlmChatService, ChatMessage} from './LlmChatService'; +import {AppModel} from '../AppModel'; /** * Model for the LLM chat harness — manages conversation history, @@ -65,7 +66,6 @@ export class ChatHarnessModel extends HoistModel { private getCurrentSpec(): DashSpec | undefined { try { - const {AppModel} = require('../AppModel'); const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; const persistable = dashModel.getPersistableState(); return {version: 1, state: persistable?.value?.state ?? []}; @@ -86,9 +86,8 @@ export class ChatHarnessModel extends HoistModel { return; } - const {AppModel} = require('../AppModel'); const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; - dashModel.setPersistableState({value: {state: spec.state}}); + dashModel.setPersistableState(new PersistableState({state: spec.state})); XH.successToast('Dashboard updated from LLM response.'); } catch (e) { this.lastError = `Failed to apply spec: ${e.message}`; diff --git a/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts index 32f1660fd..e41f92701 100644 --- a/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts +++ b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts @@ -16,7 +16,7 @@ export const chatHarnessPanel = hoistCmp.factory({ title: 'LLM Chat', icon: Icon.comment(), compactHeader: true, - item: vbox(messageList(), errorDisplay(), chatInput()), + item: vbox({flex: 1, items: [messageList(), errorDisplay(), chatInput()]}), bbar: toolbar( button({ icon: Icon.delete(), diff --git a/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts b/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts index a8c2b0386..7d9df700e 100644 --- a/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts +++ b/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts @@ -1,8 +1,9 @@ -import {HoistModel, XH} from '@xh/hoist/core'; +import {HoistModel, PersistableState, XH} from '@xh/hoist/core'; import {action, bindable, makeObservable, observable} from '@xh/hoist/mobx'; import {DashSpec, ValidationResult} from '../dash/types'; import {validateSpec, migrateSpec} from '../dash/validation'; import {EXAMPLE_SPECS, ExampleSpec} from '../dash/exampleSpecs'; +import {AppModel} from '../AppModel'; /** * Model for the JSON harness — manages the editor state, validation, @@ -27,7 +28,6 @@ export class JsonHarnessModel extends HoistModel { @action syncFromDashboard() { try { - const {AppModel} = require('../AppModel'); const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; const persistable = dashModel.getPersistableState(); const spec: DashSpec = {version: 1, state: persistable?.value?.state ?? []}; @@ -71,9 +71,8 @@ export class JsonHarnessModel extends HoistModel { // Apply to dashboard try { - const {AppModel} = require('../AppModel'); const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; - dashModel.setPersistableState({value: {state: spec.state}}); + dashModel.setPersistableState(new PersistableState({state: spec.state})); XH.successToast('Dashboard spec applied.'); } catch (e) { this.lastError = `Apply error: ${e.message}`; @@ -83,7 +82,6 @@ export class JsonHarnessModel extends HoistModel { /** Copy current dashboard spec to clipboard. */ async copySpecAsync() { try { - const {AppModel} = require('../AppModel'); const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; const persistable = dashModel.getPersistableState(); const spec: DashSpec = {version: 1, state: persistable?.value?.state ?? []}; diff --git a/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts b/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts index 1f621d2d2..596368373 100644 --- a/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts +++ b/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts @@ -28,6 +28,7 @@ const editorArea = hoistCmp.factory({ return jsonInput({ bind: 'editorValue', flex: 1, + width: '100%', commitOnChange: true, enableSearch: true, showCopyButton: false, diff --git a/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts index 55d09d5ae..292725f4f 100644 --- a/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts @@ -7,6 +7,7 @@ import {widgetRegistry} from '../dash/WidgetRegistry'; import {fmtTemp, fmtWind} from '../dash/unitUtils'; import {WidgetMeta} from '../dash/types'; import {WeatherData} from '../Types'; +import {AppModel} from '../AppModel'; import '../WeatherV2.scss'; //-------------------------------------------------- @@ -105,7 +106,6 @@ export class CurrentConditionsModel extends WeatherWidgetModel { const {city} = this; if (!city) return; try { - const {AppModel} = require('../AppModel'); await AppModel.instance.weatherV2DashModel.weatherDataModel.ensureDataAsync( city, loadSpec @@ -117,7 +117,6 @@ export class CurrentConditionsModel extends WeatherWidgetModel { @computed get weatherData(): WeatherData | null { - const {AppModel} = require('../AppModel'); return AppModel.instance.weatherV2DashModel.weatherDataModel.getData(this.city); } diff --git a/client-app/src/examples/weatherv2/widgets/DashInspectorWidget.ts b/client-app/src/examples/weatherv2/widgets/DashInspectorWidget.ts index 54f58ae58..c6ec1dae2 100644 --- a/client-app/src/examples/weatherv2/widgets/DashInspectorWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/DashInspectorWidget.ts @@ -5,6 +5,7 @@ import {computed, makeObservable} from '@xh/hoist/mobx'; import {WeatherWidgetModel} from '../dash/WeatherWidgetModel'; import {widgetRegistry} from '../dash/WidgetRegistry'; import {WidgetMeta} from '../dash/types'; +import {AppModel} from '../AppModel'; //-------------------------------------------------- // Model @@ -42,13 +43,12 @@ export class DashInspectorModel extends WeatherWidgetModel { @computed get inspectorData(): Record[] { - const {AppModel} = require('../AppModel'); const dashModel = AppModel.instance.weatherV2DashModel, wiringModel = dashModel.wiringModel, canvasModel = dashModel.dashCanvasModel, allOutputs = wiringModel.allOutputs; - const state = canvasModel.getPersistableState()?.state ?? []; + const state = canvasModel.getPersistableState()?.value?.state ?? []; return state.map((item: any, idx: number) => { const specId = item.viewSpecId, meta = widgetRegistry.get(specId), diff --git a/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts index 5c7f83c53..b469fff33 100644 --- a/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts @@ -8,6 +8,7 @@ import {widgetRegistry} from '../dash/WidgetRegistry'; import {convertTemp, tempUnit} from '../dash/unitUtils'; import {WidgetMeta} from '../dash/types'; import {WeatherData} from '../Types'; +import {AppModel} from '../AppModel'; //-------------------------------------------------- // Model @@ -103,7 +104,6 @@ export class ForecastChartModel extends WeatherWidgetModel { const {city} = this; if (!city) return; try { - const {AppModel} = require('../AppModel'); await AppModel.instance.weatherV2DashModel.weatherDataModel.ensureDataAsync( city, loadSpec @@ -114,7 +114,6 @@ export class ForecastChartModel extends WeatherWidgetModel { } @computed get weatherData(): WeatherData | null { - const {AppModel} = require('../AppModel'); return AppModel.instance.weatherV2DashModel.weatherDataModel.getData(this.city); } diff --git a/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts index a1dc7390e..5f5a03b42 100644 --- a/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts @@ -9,6 +9,7 @@ import {WeatherWidgetModel} from '../dash/WeatherWidgetModel'; import {widgetRegistry} from '../dash/WidgetRegistry'; import {WidgetMeta} from '../dash/types'; import {WeatherData} from '../Types'; +import {AppModel} from '../AppModel'; //-------------------------------------------------- // Model @@ -84,7 +85,6 @@ export class PrecipChartModel extends WeatherWidgetModel { const {city} = this; if (!city) return; try { - const {AppModel} = require('../AppModel'); await AppModel.instance.weatherV2DashModel.weatherDataModel.ensureDataAsync( city, loadSpec @@ -95,7 +95,6 @@ export class PrecipChartModel extends WeatherWidgetModel { } @computed get weatherData(): WeatherData | null { - const {AppModel} = require('../AppModel'); return AppModel.instance.weatherV2DashModel.weatherDataModel.getData(this.city); } diff --git a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts index bfd07ee4d..a725f7747 100644 --- a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts @@ -9,6 +9,7 @@ import {widgetRegistry} from '../dash/WidgetRegistry'; import {fmtTemp, fmtWind} from '../dash/unitUtils'; import {WidgetMeta} from '../dash/types'; import {WeatherData} from '../Types'; +import {AppModel} from '../AppModel'; //-------------------------------------------------- // Model @@ -84,7 +85,6 @@ export class SummaryGridModel extends WeatherWidgetModel { const {city} = this; if (!city) return; try { - const {AppModel} = require('../AppModel'); await AppModel.instance.weatherV2DashModel.weatherDataModel.ensureDataAsync( city, loadSpec @@ -95,7 +95,6 @@ export class SummaryGridModel extends WeatherWidgetModel { } @computed get weatherData(): WeatherData | null { - const {AppModel} = require('../AppModel'); return AppModel.instance.weatherV2DashModel.weatherDataModel.getData(this.city); } diff --git a/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts index 6aaa02a43..1f10db5f3 100644 --- a/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts @@ -8,6 +8,7 @@ import {widgetRegistry} from '../dash/WidgetRegistry'; import {convertWind, windUnit} from '../dash/unitUtils'; import {WidgetMeta} from '../dash/types'; import {WeatherData} from '../Types'; +import {AppModel} from '../AppModel'; //-------------------------------------------------- // Model @@ -96,7 +97,6 @@ export class WindChartModel extends WeatherWidgetModel { const {city} = this; if (!city) return; try { - const {AppModel} = require('../AppModel'); await AppModel.instance.weatherV2DashModel.weatherDataModel.ensureDataAsync( city, loadSpec @@ -107,7 +107,6 @@ export class WindChartModel extends WeatherWidgetModel { } @computed get weatherData(): WeatherData | null { - const {AppModel} = require('../AppModel'); return AppModel.instance.weatherV2DashModel.weatherDataModel.getData(this.city); } From 267b58ae8d40e0b31fcbc47ea7f02e8af1a41362 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sat, 28 Feb 2026 08:11:03 -0800 Subject: [PATCH 11/41] Use Hoist markdown component in MarkdownContentWidget Replace the hand-rolled simpleMarkdownToHtml() + dangerouslySetInnerHTML approach with Hoist's built-in markdown() component, gaining full GFM support (tables, code blocks, blockquotes, lists, etc.). Add themed .weather-v2-markdown styles adapted from Toolbox's MarkdownPanel.scss. Fix example spec and default content to use proper markdown paragraph breaks (\n\n). Co-Authored-By: Claude Opus 4.6 --- .../src/examples/weatherv2/WeatherV2.scss | 134 ++++++++++++++++++ .../examples/weatherv2/dash/exampleSpecs.ts | 2 +- .../widgets/MarkdownContentWidget.ts | 49 +------ docs/planning/weather-v2/WIDGET-CATALOG.md | 14 ++ 4 files changed, 156 insertions(+), 43 deletions(-) diff --git a/client-app/src/examples/weatherv2/WeatherV2.scss b/client-app/src/examples/weatherv2/WeatherV2.scss index 4b94ab51a..853ebe561 100644 --- a/client-app/src/examples/weatherv2/WeatherV2.scss +++ b/client-app/src/examples/weatherv2/WeatherV2.scss @@ -81,6 +81,140 @@ } } +// Markdown widget — styles for Hoist `markdown` component within dashboard cards. +// Adapted from Toolbox's MarkdownPanel.scss, scaled for compact widget context. +.weather-v2-markdown { + padding: 12px; + overflow: auto; + flex: 1; + h1 { + font-size: 1.6em; + border-bottom: 2px solid var(--xh-orange); + padding-bottom: 8px; + margin-top: 0; + margin-bottom: 14px; + } + h2 { + font-size: 1.25em; + border-bottom: 1px solid var(--xh-border-color); + padding-bottom: 4px; + margin-top: 20px; + margin-bottom: 10px; + } + h3 { + font-size: 1.1em; + margin-top: 16px; + margin-bottom: 8px; + } + h4 { + font-size: 1em; + margin-top: 14px; + margin-bottom: 6px; + } + + pre { + background-color: var(--xh-bg-alt); + border: 1px solid var(--xh-border-color); + border-radius: 6px; + padding: 10px 14px; + overflow-x: auto; + font-size: 12px; + line-height: 1.5; + margin: 10px 0; + } + code { + font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace; + font-size: 0.88em; + } + :not(pre) > code { + background-color: var(--xh-bg-alt); + border: 1px solid var(--xh-border-color); + border-radius: 3px; + padding: 1px 5px; + } + + table { + border-collapse: collapse; + width: 100%; + margin: 10px 0; + font-size: 0.9em; + } + th, + td { + border: 1px solid var(--xh-border-color); + padding: 6px 10px; + text-align: left; + } + th { + background-color: var(--xh-bg-alt); + font-weight: 600; + } + tr:hover td { + background-color: var(--xh-bg-highlight); + } + + a { + color: var(--xh-blue); + text-decoration: none; + border-bottom: 1px solid transparent; + transition: border-color 0.15s ease; + } + a:hover { + border-bottom-color: var(--xh-blue); + } + + ul, + ol { + padding-left: 20px; + margin: 8px 0; + } + li { + margin: 4px 0; + line-height: 1.5; + } + li > ul, + li > ol { + margin: 2px 0; + } + + blockquote { + border-left: 4px solid var(--xh-orange); + margin: 10px 0; + padding: 6px 12px; + background-color: var(--xh-bg-alt); + border-radius: 0 6px 6px 0; + } + blockquote p:first-child { + margin-top: 0; + } + blockquote p:last-child { + margin-bottom: 0; + } + + hr { + border: none; + border-top: 1px solid var(--xh-border-color); + margin: 20px 0; + } + + p { + line-height: 1.6; + margin: 10px 0; + } + p:first-child { + margin-top: 0; + } + + img { + max-width: 100%; + border-radius: 4px; + } + + strong { + font-weight: 600; + } +} + // Chat harness .weather-v2-chat-empty { flex: 1; diff --git a/client-app/src/examples/weatherv2/dash/exampleSpecs.ts b/client-app/src/examples/weatherv2/dash/exampleSpecs.ts index 30a9e6a58..ca9593676 100644 --- a/client-app/src/examples/weatherv2/dash/exampleSpecs.ts +++ b/client-app/src/examples/weatherv2/dash/exampleSpecs.ts @@ -183,7 +183,7 @@ const annotatedSpec: DashSpec = { title: 'Dashboard Guide', state: { content: - '# Weather Dashboard V2\nThis dashboard demonstrates **inter-widget wiring**. The City Chooser and Units Toggle publish outputs that drive all display widgets. Open the **Dash Inspector** to see live binding values.' + '# Weather Dashboard V2\n\nThis dashboard demonstrates **inter-widget wiring**. The City Chooser and Units Toggle publish outputs that drive all display widgets. Open the **Dash Inspector** to see live binding values.' } }, { diff --git a/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts b/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts index 19782f5e3..1de16b850 100644 --- a/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts @@ -1,5 +1,6 @@ import {hoistCmp, creates} from '@xh/hoist/core'; -import {div, frame} from '@xh/hoist/cmp/layout'; +import {div} from '@xh/hoist/cmp/layout'; +import {markdown} from '@xh/hoist/cmp/markdown'; import {bindable, makeObservable} from '@xh/hoist/mobx'; import {WeatherWidgetModel} from '../dash/WeatherWidgetModel'; import {widgetRegistry} from '../dash/WidgetRegistry'; @@ -21,14 +22,14 @@ export class MarkdownContentModel extends WeatherWidgetModel { content: { type: 'string', description: 'Markdown text to render.', - default: "# Welcome\nEdit this widget's content in the dashboard spec." + default: "# Welcome\n\nEdit this widget's content in the dashboard spec." } }, defaultSize: {w: 4, h: 3}, minSize: {w: 2, h: 1} }; - @bindable content: string = "# Welcome\nEdit this widget's content in the dashboard spec."; + @bindable content: string = "# Welcome\n\nEdit this widget's content in the dashboard spec."; constructor() { super(); @@ -51,45 +52,9 @@ export const markdownContentWidget = hoistCmp.factory({ model: creates(MarkdownContentModel), render({model}) { - // Simple markdown rendering — supports basic formatting via innerHTML. - // For V1, we render plain text with line breaks. A full markdown renderer - // (e.g., marked or react-markdown) can be added as a stretch goal. - const html = simpleMarkdownToHtml(model.content); - return frame({ - padding: 12, - style: {overflow: 'auto'}, - item: div({ - style: {lineHeight: 1.5}, - dangerouslySetInnerHTML: {__html: html} - }) + return div({ + className: 'weather-v2-markdown', + item: markdown({content: model.content, lineBreaks: false}) }); } }); - -/** - * Minimal markdown-to-HTML converter for basic formatting. - * Handles headings, bold, italic, links, and line breaks. - */ -function simpleMarkdownToHtml(md: string): string { - if (!md) return ''; - return md - .split('\n') - .map(line => { - // Headings - if (line.startsWith('### ')) return `

${esc(line.slice(4))}

`; - if (line.startsWith('## ')) return `

${esc(line.slice(3))}

`; - if (line.startsWith('# ')) return `

${esc(line.slice(2))}

`; - // Empty line = paragraph break - if (!line.trim()) return '
'; - // Bold and italic - let html = esc(line); - html = html.replace(/\*\*(.+?)\*\*/g, '$1'); - html = html.replace(/\*(.+?)\*/g, '$1'); - return `

${html}

`; - }) - .join(''); -} - -function esc(s: string): string { - return s.replace(/&/g, '&').replace(//g, '>'); -} diff --git a/docs/planning/weather-v2/WIDGET-CATALOG.md b/docs/planning/weather-v2/WIDGET-CATALOG.md index 6f308509e..ae6d4d950 100644 --- a/docs/planning/weather-v2/WIDGET-CATALOG.md +++ b/docs/planning/weather-v2/WIDGET-CATALOG.md @@ -24,6 +24,7 @@ - `selectedCity` (string) — The currently selected city name. **Config:** + | Property | Type | Default | Description | |----------|------|---------|-------------| | `selectedCity` | string | `"New York"` | Initially selected city | @@ -60,6 +61,7 @@ - `units` (string) — `"imperial"` or `"metric"`. **Config:** + | Property | Type | Default | Description | |----------|------|---------|-------------| | `units` | enum | `"imperial"` | Initial unit system. Values: `imperial`, `metric` | @@ -87,12 +89,14 @@ **Purpose:** Current weather snapshot — temperature gauge, conditions icon, description, and key details (feels-like, humidity, wind). **Inputs:** + | Input | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | `city` | string | yes | `"New York"` | City to display | | `units` | string | no | `"imperial"` | Unit system | **Config:** + | Property | Type | Default | Description | |----------|------|---------|-------------| | `showFeelsLike` | boolean | `true` | Show feels-like temperature | @@ -126,12 +130,14 @@ **Purpose:** Multi-series line/area/column chart showing forecast data over the 5-day window. The primary charting widget — highly configurable series selection and chart types. **Inputs:** + | Input | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | `city` | string | yes | `"New York"` | City to show forecast for | | `units` | string | no | `"imperial"` | Unit system | **Config:** + | Property | Type | Default | Description | |----------|------|---------|-------------| | `series` | string[] | `["temp", "feelsLike"]` | Series to show. Options: `temp`, `feelsLike`, `humidity`, `pressure` | @@ -170,11 +176,13 @@ **Purpose:** Precipitation probability and/or volume over the forecast period. Dual-axis column chart. **Inputs:** + | Input | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | `city` | string | yes | `"New York"` | City to show precipitation for | **Config:** + | Property | Type | Default | Description | |----------|------|---------|-------------| | `metric` | enum | `"both"` | `"probability"`, `"volume"`, `"both"` | @@ -204,12 +212,14 @@ **Purpose:** Wind speed and gusts over the forecast period. **Inputs:** + | Input | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | `city` | string | yes | `"New York"` | City to show wind data for | | `units` | string | no | `"imperial"` | Unit system (mph vs m/s) | **Config:** + | Property | Type | Default | Description | |----------|------|---------|-------------| | `showGusts` | boolean | `true` | Show gust data alongside sustained | @@ -240,12 +250,14 @@ **Purpose:** Tabular daily overview — one row per day with high/low, conditions, humidity, wind. **Inputs:** + | Input | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | `city` | string | yes | `"New York"` | City to summarize | | `units` | string | no | `"imperial"` | Unit system | **Config:** + | Property | Type | Default | Description | |----------|------|---------|-------------| | `visibleColumns` | string[] | `["date","icon","conditions","high","low","humidity","wind"]` | Columns to display | @@ -274,6 +286,7 @@ **Purpose:** Static rich-text display using Hoist's Markdown renderer. Useful for dashboard titles, instructions, annotations, or any static content. No data inputs — purely content-driven. **Config:** + | Property | Type | Default | Description | |----------|------|---------|-------------| | `content` | string | `"# Welcome\nEdit this widget's content."` | Markdown text to render | @@ -303,6 +316,7 @@ **Purpose:** Debug/demo utility that visualizes the wiring graph, shows resolved input/output values, and displays validation status. Makes the IO story visible and legible during demos. **Config:** + | Property | Type | Default | Description | |----------|------|---------|-------------| | `showBindings` | boolean | `true` | Show binding details per widget | From f37346f06e9adb07ac9c1de1ceb6419d3a08744c Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sat, 28 Feb 2026 08:19:23 -0800 Subject: [PATCH 12/41] Convert WeatherDataModel to a HoistService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WeatherDataModel was a centralized singleton data cache — a natural fit for Hoist's service pattern. Relocated from dash/ to svc/WeatherDataService, registered via XH.installServicesAsync in AppModel, and typed on XHApi via Bootstrap.ts module augmentation. Widgets now access data through XH.weatherDataService instead of traversing the model hierarchy. Co-Authored-By: Claude Opus 4.6 --- client-app/src/Bootstrap.ts | 2 ++ client-app/src/examples/weatherv2/AppModel.ts | 2 ++ .../examples/weatherv2/dash/WeatherV2DashModel.ts | 7 ++----- .../WeatherDataService.ts} | 6 ++++-- .../weatherv2/widgets/CurrentConditionsWidget.ts | 10 +++------- .../weatherv2/widgets/ForecastChartWidget.ts | 12 ++++-------- .../examples/weatherv2/widgets/PrecipChartWidget.ts | 12 ++++-------- .../examples/weatherv2/widgets/SummaryGridWidget.ts | 12 ++++-------- .../examples/weatherv2/widgets/WindChartWidget.ts | 12 ++++-------- 9 files changed, 29 insertions(+), 46 deletions(-) rename client-app/src/examples/weatherv2/{dash/WeatherDataModel.ts => svc/WeatherDataService.ts} (96%) diff --git a/client-app/src/Bootstrap.ts b/client-app/src/Bootstrap.ts index 5598a60f0..2bb684b44 100755 --- a/client-app/src/Bootstrap.ts +++ b/client-app/src/Bootstrap.ts @@ -16,6 +16,7 @@ import {ContactService} from './examples/contact/svc/ContactService'; import {GitHubService} from './core/svc/GitHubService'; import {PortfolioService} from './core/svc/PortfolioService'; import {TaskService} from './examples/todo/TaskService'; +import {WeatherDataService} from './examples/weatherv2/svc/WeatherDataService'; declare module '@xh/hoist/core' { // Merge interface with XHApi class to include injected services. @@ -24,6 +25,7 @@ declare module '@xh/hoist/core' { gitHubService: GitHubService; portfolioService: PortfolioService; taskService: TaskService; + weatherDataService: WeatherDataService; } export interface HoistUser { diff --git a/client-app/src/examples/weatherv2/AppModel.ts b/client-app/src/examples/weatherv2/AppModel.ts index bf4850716..f5dfa9015 100644 --- a/client-app/src/examples/weatherv2/AppModel.ts +++ b/client-app/src/examples/weatherv2/AppModel.ts @@ -9,6 +9,7 @@ import { } from '@xh/hoist/desktop/cmp/appOption'; import {BaseAppModel} from '../../BaseAppModel'; import {WeatherV2DashModel} from './dash/WeatherV2DashModel'; +import {WeatherDataService} from './svc/WeatherDataService'; export class AppModel extends BaseAppModel { static instance: AppModel; @@ -25,6 +26,7 @@ export class AppModel extends BaseAppModel { override async initAsync() { await super.initAsync(); + await XH.installServicesAsync(WeatherDataService); this.harnessPanelModel = new PanelModel({ side: 'right', diff --git a/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts b/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts index 6b410bd61..d86fb85b7 100644 --- a/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts +++ b/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts @@ -4,7 +4,6 @@ import {DashCanvasModel} from '@xh/hoist/desktop/cmp/dash'; import {Icon} from '@xh/hoist/icon'; import {makeObservable} from '@xh/hoist/mobx'; import {WiringModel} from './WiringModel'; -import {WeatherDataModel} from './WeatherDataModel'; import {temperatureIcon, cloudRainIcon, calendarDaysIcon, windIcon} from '../Icons'; import {cityChooserWidget} from '../widgets/CityChooserWidget'; @@ -20,12 +19,11 @@ import {dashInspectorWidget} from '../widgets/DashInspectorWidget'; /** * Central model for the Weather V2 dashboard. * - * Owns the DashCanvasModel (layout + widgets), the WiringModel (inter-widget - * communication), and the WeatherDataModel (shared data cache). + * Owns the DashCanvasModel (layout + widgets) and the WiringModel (inter-widget + * communication). Weather data is provided by WeatherDataService. */ export class WeatherV2DashModel extends HoistModel { @managed wiringModel: WiringModel; - @managed weatherDataModel: WeatherDataModel; @managed dashCanvasModel: DashCanvasModel; viewManagerModel: ViewManagerModel; @@ -36,7 +34,6 @@ export class WeatherV2DashModel extends HoistModel { this.viewManagerModel = viewManagerModel; this.wiringModel = new WiringModel(); - this.weatherDataModel = new WeatherDataModel(); this.dashCanvasModel = new DashCanvasModel({ persistWith: {viewManagerModel}, diff --git a/client-app/src/examples/weatherv2/dash/WeatherDataModel.ts b/client-app/src/examples/weatherv2/svc/WeatherDataService.ts similarity index 96% rename from client-app/src/examples/weatherv2/dash/WeatherDataModel.ts rename to client-app/src/examples/weatherv2/svc/WeatherDataService.ts index 409747db1..590c28539 100644 --- a/client-app/src/examples/weatherv2/dash/WeatherDataModel.ts +++ b/client-app/src/examples/weatherv2/svc/WeatherDataService.ts @@ -1,4 +1,4 @@ -import {HoistModel, LoadSpec, XH} from '@xh/hoist/core'; +import {HoistService, LoadSpec, XH} from '@xh/hoist/core'; import {action, makeObservable, observable} from '@xh/hoist/mobx'; import { WeatherData, @@ -18,7 +18,9 @@ const STALE_MS = 5 * 60 * 1000; * `ensureDataAsync(city)` to trigger a fetch (if not cached or stale), * then read the observable cache reactively. */ -export class WeatherDataModel extends HoistModel { +export class WeatherDataService extends HoistService { + static instance: WeatherDataService; + /** Observable cache: city → WeatherData. Replace the map reference to trigger reactions. */ @observable.ref private _cache = new Map(); diff --git a/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts index 292725f4f..5750ace65 100644 --- a/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts @@ -1,13 +1,12 @@ import {chart, ChartModel} from '@xh/hoist/cmp/chart'; import {div, hbox, img, vbox} from '@xh/hoist/cmp/layout'; -import {creates, hoistCmp, LoadSpec, managed} from '@xh/hoist/core'; +import {creates, hoistCmp, LoadSpec, managed, XH} from '@xh/hoist/core'; import {computed, makeObservable} from '@xh/hoist/mobx'; import {WeatherWidgetModel} from '../dash/WeatherWidgetModel'; import {widgetRegistry} from '../dash/WidgetRegistry'; import {fmtTemp, fmtWind} from '../dash/unitUtils'; import {WidgetMeta} from '../dash/types'; import {WeatherData} from '../Types'; -import {AppModel} from '../AppModel'; import '../WeatherV2.scss'; //-------------------------------------------------- @@ -106,10 +105,7 @@ export class CurrentConditionsModel extends WeatherWidgetModel { const {city} = this; if (!city) return; try { - await AppModel.instance.weatherV2DashModel.weatherDataModel.ensureDataAsync( - city, - loadSpec - ); + await XH.weatherDataService.ensureDataAsync(city, loadSpec); } catch (e) { // Data model handles caching — errors will show via lastLoadException } @@ -117,7 +113,7 @@ export class CurrentConditionsModel extends WeatherWidgetModel { @computed get weatherData(): WeatherData | null { - return AppModel.instance.weatherV2DashModel.weatherDataModel.getData(this.city); + return XH.weatherDataService.getData(this.city); } private createChartModel(): ChartModel { diff --git a/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts index b469fff33..dc9cdd532 100644 --- a/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts @@ -1,5 +1,5 @@ import {chart, ChartModel} from '@xh/hoist/cmp/chart'; -import {creates, hoistCmp, LoadSpec, managed} from '@xh/hoist/core'; +import {creates, hoistCmp, LoadSpec, managed, XH} from '@xh/hoist/core'; import {panel} from '@xh/hoist/desktop/cmp/panel'; import {fmtDate} from '@xh/hoist/format'; import {computed, makeObservable} from '@xh/hoist/mobx'; @@ -8,7 +8,6 @@ import {widgetRegistry} from '../dash/WidgetRegistry'; import {convertTemp, tempUnit} from '../dash/unitUtils'; import {WidgetMeta} from '../dash/types'; import {WeatherData} from '../Types'; -import {AppModel} from '../AppModel'; //-------------------------------------------------- // Model @@ -104,17 +103,14 @@ export class ForecastChartModel extends WeatherWidgetModel { const {city} = this; if (!city) return; try { - await AppModel.instance.weatherV2DashModel.weatherDataModel.ensureDataAsync( - city, - loadSpec - ); + await XH.weatherDataService.ensureDataAsync(city, loadSpec); } catch (e) { - // Handled by WeatherDataModel + // Handled by WeatherDataService } } @computed get weatherData(): WeatherData | null { - return AppModel.instance.weatherV2DashModel.weatherDataModel.getData(this.city); + return XH.weatherDataService.getData(this.city); } private createChartModel(): ChartModel { diff --git a/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts index 5f5a03b42..c75763313 100644 --- a/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts @@ -1,6 +1,6 @@ import {chart, ChartModel} from '@xh/hoist/cmp/chart'; import {placeholder} from '@xh/hoist/cmp/layout'; -import {creates, hoistCmp, LoadSpec, managed} from '@xh/hoist/core'; +import {creates, hoistCmp, LoadSpec, managed, XH} from '@xh/hoist/core'; import {panel} from '@xh/hoist/desktop/cmp/panel'; import {fmtDate} from '@xh/hoist/format'; import {Icon} from '@xh/hoist/icon'; @@ -9,7 +9,6 @@ import {WeatherWidgetModel} from '../dash/WeatherWidgetModel'; import {widgetRegistry} from '../dash/WidgetRegistry'; import {WidgetMeta} from '../dash/types'; import {WeatherData} from '../Types'; -import {AppModel} from '../AppModel'; //-------------------------------------------------- // Model @@ -85,17 +84,14 @@ export class PrecipChartModel extends WeatherWidgetModel { const {city} = this; if (!city) return; try { - await AppModel.instance.weatherV2DashModel.weatherDataModel.ensureDataAsync( - city, - loadSpec - ); + await XH.weatherDataService.ensureDataAsync(city, loadSpec); } catch (e) { - // Handled by WeatherDataModel + // Handled by WeatherDataService } } @computed get weatherData(): WeatherData | null { - return AppModel.instance.weatherV2DashModel.weatherDataModel.getData(this.city); + return XH.weatherDataService.getData(this.city); } private createChartModel(): ChartModel { diff --git a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts index a725f7747..d7b400c46 100644 --- a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts @@ -1,6 +1,6 @@ import {grid, GridModel} from '@xh/hoist/cmp/grid'; import {img} from '@xh/hoist/cmp/layout'; -import {creates, hoistCmp, LoadSpec, managed} from '@xh/hoist/core'; +import {creates, hoistCmp, LoadSpec, managed, XH} from '@xh/hoist/core'; import {panel} from '@xh/hoist/desktop/cmp/panel'; import {computed, makeObservable} from '@xh/hoist/mobx'; import {groupBy} from 'lodash'; @@ -9,7 +9,6 @@ import {widgetRegistry} from '../dash/WidgetRegistry'; import {fmtTemp, fmtWind} from '../dash/unitUtils'; import {WidgetMeta} from '../dash/types'; import {WeatherData} from '../Types'; -import {AppModel} from '../AppModel'; //-------------------------------------------------- // Model @@ -85,17 +84,14 @@ export class SummaryGridModel extends WeatherWidgetModel { const {city} = this; if (!city) return; try { - await AppModel.instance.weatherV2DashModel.weatherDataModel.ensureDataAsync( - city, - loadSpec - ); + await XH.weatherDataService.ensureDataAsync(city, loadSpec); } catch (e) { - // Handled by WeatherDataModel + // Handled by WeatherDataService } } @computed get weatherData(): WeatherData | null { - return AppModel.instance.weatherV2DashModel.weatherDataModel.getData(this.city); + return XH.weatherDataService.getData(this.city); } private createGridModel(): GridModel { diff --git a/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts index 1f10db5f3..7fe6d599a 100644 --- a/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts @@ -1,5 +1,5 @@ import {chart, ChartModel} from '@xh/hoist/cmp/chart'; -import {creates, hoistCmp, LoadSpec, managed} from '@xh/hoist/core'; +import {creates, hoistCmp, LoadSpec, managed, XH} from '@xh/hoist/core'; import {panel} from '@xh/hoist/desktop/cmp/panel'; import {fmtDate} from '@xh/hoist/format'; import {computed, makeObservable} from '@xh/hoist/mobx'; @@ -8,7 +8,6 @@ import {widgetRegistry} from '../dash/WidgetRegistry'; import {convertWind, windUnit} from '../dash/unitUtils'; import {WidgetMeta} from '../dash/types'; import {WeatherData} from '../Types'; -import {AppModel} from '../AppModel'; //-------------------------------------------------- // Model @@ -97,17 +96,14 @@ export class WindChartModel extends WeatherWidgetModel { const {city} = this; if (!city) return; try { - await AppModel.instance.weatherV2DashModel.weatherDataModel.ensureDataAsync( - city, - loadSpec - ); + await XH.weatherDataService.ensureDataAsync(city, loadSpec); } catch (e) { - // Handled by WeatherDataModel + // Handled by WeatherDataService } } @computed get weatherData(): WeatherData | null { - return AppModel.instance.weatherV2DashModel.weatherDataModel.getData(this.city); + return XH.weatherDataService.getData(this.city); } private createChartModel(): ChartModel { From 334ef62f38d34f1afbe61b22609319dcb3829251 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sat, 28 Feb 2026 08:23:18 -0800 Subject: [PATCH 13/41] Move WeatherWidgetModel to widgets/ and rename to BaseWeatherWidgetModel The base widget model belongs with its subclasses in the widgets package. Renamed to BaseWeatherWidgetModel to better communicate its role as an abstract base class. Co-Authored-By: Claude Opus 4.6 --- .../BaseWeatherWidgetModel.ts} | 6 +++--- .../src/examples/weatherv2/widgets/CityChooserWidget.ts | 4 ++-- .../examples/weatherv2/widgets/CurrentConditionsWidget.ts | 4 ++-- .../src/examples/weatherv2/widgets/DashInspectorWidget.ts | 4 ++-- .../src/examples/weatherv2/widgets/ForecastChartWidget.ts | 4 ++-- .../src/examples/weatherv2/widgets/MarkdownContentWidget.ts | 4 ++-- .../src/examples/weatherv2/widgets/PrecipChartWidget.ts | 4 ++-- .../src/examples/weatherv2/widgets/SummaryGridWidget.ts | 4 ++-- .../src/examples/weatherv2/widgets/UnitsToggleWidget.ts | 4 ++-- .../src/examples/weatherv2/widgets/WindChartWidget.ts | 4 ++-- 10 files changed, 21 insertions(+), 21 deletions(-) rename client-app/src/examples/weatherv2/{dash/WeatherWidgetModel.ts => widgets/BaseWeatherWidgetModel.ts} (93%) diff --git a/client-app/src/examples/weatherv2/dash/WeatherWidgetModel.ts b/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts similarity index 93% rename from client-app/src/examples/weatherv2/dash/WeatherWidgetModel.ts rename to client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts index e20e3bb89..ac1cb5df1 100644 --- a/client-app/src/examples/weatherv2/dash/WeatherWidgetModel.ts +++ b/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts @@ -1,7 +1,7 @@ import {HoistModel, lookup} from '@xh/hoist/core'; import {DashViewModel} from '@xh/hoist/desktop/cmp/dash'; -import {WidgetMeta, BindingSpec} from './types'; -import {WiringModel} from './WiringModel'; +import {WidgetMeta, BindingSpec} from '../dash/types'; +import {WiringModel} from '../dash/WiringModel'; import {AppModel} from '../AppModel'; /** @@ -15,7 +15,7 @@ import {AppModel} from '../AppModel'; * Subclasses must define a static `meta: WidgetMeta` property * and register it with the WidgetRegistry. */ -export abstract class WeatherWidgetModel extends HoistModel { +export abstract class BaseWeatherWidgetModel extends HoistModel { /** Static metadata — override in every subclass. */ static meta: WidgetMeta; diff --git a/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts b/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts index 5604587a8..8c26e44e8 100644 --- a/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts @@ -2,7 +2,7 @@ import {hoistCmp, creates} from '@xh/hoist/core'; import {select} from '@xh/hoist/desktop/cmp/input'; import {box} from '@xh/hoist/cmp/layout'; import {bindable, makeObservable} from '@xh/hoist/mobx'; -import {WeatherWidgetModel} from '../dash/WeatherWidgetModel'; +import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; import {widgetRegistry} from '../dash/WidgetRegistry'; import {WidgetMeta} from '../dash/types'; @@ -37,7 +37,7 @@ export const CITIES = [ //-------------------------------------------------- // Model //-------------------------------------------------- -export class CityChooserModel extends WeatherWidgetModel { +export class CityChooserModel extends BaseWeatherWidgetModel { static override meta: WidgetMeta = { id: 'cityChooser', title: 'City Chooser', diff --git a/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts index 5750ace65..faaa6e30b 100644 --- a/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts @@ -2,7 +2,7 @@ import {chart, ChartModel} from '@xh/hoist/cmp/chart'; import {div, hbox, img, vbox} from '@xh/hoist/cmp/layout'; import {creates, hoistCmp, LoadSpec, managed, XH} from '@xh/hoist/core'; import {computed, makeObservable} from '@xh/hoist/mobx'; -import {WeatherWidgetModel} from '../dash/WeatherWidgetModel'; +import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; import {widgetRegistry} from '../dash/WidgetRegistry'; import {fmtTemp, fmtWind} from '../dash/unitUtils'; import {WidgetMeta} from '../dash/types'; @@ -12,7 +12,7 @@ import '../WeatherV2.scss'; //-------------------------------------------------- // Model //-------------------------------------------------- -export class CurrentConditionsModel extends WeatherWidgetModel { +export class CurrentConditionsModel extends BaseWeatherWidgetModel { static override meta: WidgetMeta = { id: 'currentConditions', title: 'Current Conditions', diff --git a/client-app/src/examples/weatherv2/widgets/DashInspectorWidget.ts b/client-app/src/examples/weatherv2/widgets/DashInspectorWidget.ts index c6ec1dae2..72d0d1e8d 100644 --- a/client-app/src/examples/weatherv2/widgets/DashInspectorWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/DashInspectorWidget.ts @@ -2,7 +2,7 @@ import {grid, GridModel} from '@xh/hoist/cmp/grid'; import {creates, hoistCmp, managed} from '@xh/hoist/core'; import {panel} from '@xh/hoist/desktop/cmp/panel'; import {computed, makeObservable} from '@xh/hoist/mobx'; -import {WeatherWidgetModel} from '../dash/WeatherWidgetModel'; +import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; import {widgetRegistry} from '../dash/WidgetRegistry'; import {WidgetMeta} from '../dash/types'; import {AppModel} from '../AppModel'; @@ -10,7 +10,7 @@ import {AppModel} from '../AppModel'; //-------------------------------------------------- // Model //-------------------------------------------------- -export class DashInspectorModel extends WeatherWidgetModel { +export class DashInspectorModel extends BaseWeatherWidgetModel { static override meta: WidgetMeta = { id: 'dashInspector', title: 'Dash Inspector', diff --git a/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts index dc9cdd532..6ff3204e0 100644 --- a/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts @@ -3,7 +3,7 @@ import {creates, hoistCmp, LoadSpec, managed, XH} from '@xh/hoist/core'; import {panel} from '@xh/hoist/desktop/cmp/panel'; import {fmtDate} from '@xh/hoist/format'; import {computed, makeObservable} from '@xh/hoist/mobx'; -import {WeatherWidgetModel} from '../dash/WeatherWidgetModel'; +import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; import {widgetRegistry} from '../dash/WidgetRegistry'; import {convertTemp, tempUnit} from '../dash/unitUtils'; import {WidgetMeta} from '../dash/types'; @@ -12,7 +12,7 @@ import {WeatherData} from '../Types'; //-------------------------------------------------- // Model //-------------------------------------------------- -export class ForecastChartModel extends WeatherWidgetModel { +export class ForecastChartModel extends BaseWeatherWidgetModel { static override meta: WidgetMeta = { id: 'forecastChart', title: 'Forecast Chart', diff --git a/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts b/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts index 1de16b850..3c91c9153 100644 --- a/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts @@ -2,14 +2,14 @@ import {hoistCmp, creates} from '@xh/hoist/core'; import {div} from '@xh/hoist/cmp/layout'; import {markdown} from '@xh/hoist/cmp/markdown'; import {bindable, makeObservable} from '@xh/hoist/mobx'; -import {WeatherWidgetModel} from '../dash/WeatherWidgetModel'; +import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; import {widgetRegistry} from '../dash/WidgetRegistry'; import {WidgetMeta} from '../dash/types'; //-------------------------------------------------- // Model //-------------------------------------------------- -export class MarkdownContentModel extends WeatherWidgetModel { +export class MarkdownContentModel extends BaseWeatherWidgetModel { static override meta: WidgetMeta = { id: 'markdownContent', title: 'Markdown Content', diff --git a/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts index c75763313..3484af9b3 100644 --- a/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts @@ -5,7 +5,7 @@ import {panel} from '@xh/hoist/desktop/cmp/panel'; import {fmtDate} from '@xh/hoist/format'; import {Icon} from '@xh/hoist/icon'; import {bindable, computed, makeObservable} from '@xh/hoist/mobx'; -import {WeatherWidgetModel} from '../dash/WeatherWidgetModel'; +import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; import {widgetRegistry} from '../dash/WidgetRegistry'; import {WidgetMeta} from '../dash/types'; import {WeatherData} from '../Types'; @@ -13,7 +13,7 @@ import {WeatherData} from '../Types'; //-------------------------------------------------- // Model //-------------------------------------------------- -export class PrecipChartModel extends WeatherWidgetModel { +export class PrecipChartModel extends BaseWeatherWidgetModel { static override meta: WidgetMeta = { id: 'precipChart', title: 'Precipitation', diff --git a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts index d7b400c46..7be8bd8f9 100644 --- a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts @@ -4,7 +4,7 @@ import {creates, hoistCmp, LoadSpec, managed, XH} from '@xh/hoist/core'; import {panel} from '@xh/hoist/desktop/cmp/panel'; import {computed, makeObservable} from '@xh/hoist/mobx'; import {groupBy} from 'lodash'; -import {WeatherWidgetModel} from '../dash/WeatherWidgetModel'; +import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; import {widgetRegistry} from '../dash/WidgetRegistry'; import {fmtTemp, fmtWind} from '../dash/unitUtils'; import {WidgetMeta} from '../dash/types'; @@ -13,7 +13,7 @@ import {WeatherData} from '../Types'; //-------------------------------------------------- // Model //-------------------------------------------------- -export class SummaryGridModel extends WeatherWidgetModel { +export class SummaryGridModel extends BaseWeatherWidgetModel { static override meta: WidgetMeta = { id: 'summaryGrid', title: '5-Day Summary', diff --git a/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts b/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts index dcc1ebf1c..92acc8def 100644 --- a/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts @@ -3,14 +3,14 @@ import {box} from '@xh/hoist/cmp/layout'; import {button} from '@xh/hoist/desktop/cmp/button'; import {buttonGroupInput} from '@xh/hoist/desktop/cmp/input'; import {bindable, makeObservable} from '@xh/hoist/mobx'; -import {WeatherWidgetModel} from '../dash/WeatherWidgetModel'; +import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; import {widgetRegistry} from '../dash/WidgetRegistry'; import {WidgetMeta} from '../dash/types'; //-------------------------------------------------- // Model //-------------------------------------------------- -export class UnitsToggleModel extends WeatherWidgetModel { +export class UnitsToggleModel extends BaseWeatherWidgetModel { static override meta: WidgetMeta = { id: 'unitsToggle', title: 'Units Toggle', diff --git a/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts index 7fe6d599a..ae36c10ad 100644 --- a/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts @@ -3,7 +3,7 @@ import {creates, hoistCmp, LoadSpec, managed, XH} from '@xh/hoist/core'; import {panel} from '@xh/hoist/desktop/cmp/panel'; import {fmtDate} from '@xh/hoist/format'; import {computed, makeObservable} from '@xh/hoist/mobx'; -import {WeatherWidgetModel} from '../dash/WeatherWidgetModel'; +import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; import {widgetRegistry} from '../dash/WidgetRegistry'; import {convertWind, windUnit} from '../dash/unitUtils'; import {WidgetMeta} from '../dash/types'; @@ -12,7 +12,7 @@ import {WeatherData} from '../Types'; //-------------------------------------------------- // Model //-------------------------------------------------- -export class WindChartModel extends WeatherWidgetModel { +export class WindChartModel extends BaseWeatherWidgetModel { static override meta: WidgetMeta = { id: 'windChart', title: 'Wind', From 480f2ac294bf74885d8e2f70c8747f61067749bb Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sat, 28 Feb 2026 08:43:30 -0800 Subject: [PATCH 14/41] Fix JSON panel showing stale dashboard state on initial open Read dashboard spec from live viewModels/layout instead of getPersistableState(), which can return stale initialState after persistence restore due to a framework bug in DashCanvasModel (xh/hoist-react#4276). Co-Authored-By: Claude Opus 4.6 --- .../weatherv2/harness/JsonHarnessModel.ts | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts b/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts index 7d9df700e..b60776d42 100644 --- a/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts +++ b/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts @@ -1,6 +1,7 @@ import {HoistModel, PersistableState, XH} from '@xh/hoist/core'; +import {DashCanvasModel} from '@xh/hoist/desktop/cmp/dash'; import {action, bindable, makeObservable, observable} from '@xh/hoist/mobx'; -import {DashSpec, ValidationResult} from '../dash/types'; +import {DashSpec, DashWidgetState, ValidationResult} from '../dash/types'; import {validateSpec, migrateSpec} from '../dash/validation'; import {EXAMPLE_SPECS, ExampleSpec} from '../dash/exampleSpecs'; import {AppModel} from '../AppModel'; @@ -29,8 +30,7 @@ export class JsonHarnessModel extends HoistModel { syncFromDashboard() { try { const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; - const persistable = dashModel.getPersistableState(); - const spec: DashSpec = {version: 1, state: persistable?.value?.state ?? []}; + const spec: DashSpec = {version: 1, state: this.buildSpecState(dashModel)}; this.editorValue = JSON.stringify(spec, null, 2); this.lastValidation = null; this.lastError = null; @@ -83,8 +83,7 @@ export class JsonHarnessModel extends HoistModel { async copySpecAsync() { try { const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; - const persistable = dashModel.getPersistableState(); - const spec: DashSpec = {version: 1, state: persistable?.value?.state ?? []}; + const spec: DashSpec = {version: 1, state: this.buildSpecState(dashModel)}; const json = JSON.stringify(spec, null, 2); await navigator.clipboard.writeText(json); XH.successToast('Spec copied to clipboard.'); @@ -128,4 +127,30 @@ export class JsonHarnessModel extends HoistModel { this.lastValidation = validateSpec(spec); } + + //------------------------ + // Implementation + //------------------------ + /** + * Build the current DashSpec state from the DashCanvasModel's live viewModels and layout. + * + * Workaround for https://github.com/xh/hoist-react/issues/4276 — + * getPersistableState() can return stale initialState after persistence restore. + * Once that issue is resolved, this method and its callers could be simplified to + * use getPersistableState() directly. + */ + private buildSpecState(dashModel: DashCanvasModel): DashWidgetState[] { + return dashModel.layout + .map(({i: id, x, y, w, h}) => { + const vm = dashModel.viewModels.find(v => v.id === id); + if (!vm) return null; + return { + layout: {x, y, w, h}, + viewSpecId: vm.viewSpec.id, + title: vm.title, + state: vm.viewState + }; + }) + .filter(Boolean); + } } From 6dd674b5c0a87325d1f5d6e7ba1a27392035aef2 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sat, 28 Feb 2026 08:57:12 -0800 Subject: [PATCH 15/41] Improve JSON harness validation UX and remove redundant copy button Show a success toast instead of a panel when the spec is valid. Only show the validation panel for errors/warnings, with a dismiss button in the compact header and a max height of 200px. Remove the dedicated Copy Spec button in favor of the jsonInput's built-in copy button, which copies whatever the user sees in the editor. Also update appbar button intents and validation SCSS to use intent variables. Co-Authored-By: Claude Opus 4.6 --- .../src/examples/weatherv2/AppComponent.ts | 4 +- .../src/examples/weatherv2/WeatherV2.scss | 8 +- .../weatherv2/harness/JsonHarnessModel.ts | 27 +++-- .../weatherv2/harness/JsonHarnessPanel.ts | 99 +++++++++---------- 4 files changed, 64 insertions(+), 74 deletions(-) diff --git a/client-app/src/examples/weatherv2/AppComponent.ts b/client-app/src/examples/weatherv2/AppComponent.ts index 08addf612..f15b1fcd3 100644 --- a/client-app/src/examples/weatherv2/AppComponent.ts +++ b/client-app/src/examples/weatherv2/AppComponent.ts @@ -31,13 +31,15 @@ export const AppComponent = hoistCmp({ text: 'Chat', active: showChatHarness, outlined: true, + intent: 'primary', onClick: () => (model.showChatHarness = !showChatHarness) }), button({ - icon: Icon.code(), + icon: Icon.json(), text: 'JSON', active: showJsonHarness, outlined: true, + intent: 'primary', onClick: () => (model.showJsonHarness = !showJsonHarness) }), appBarSeparator() diff --git a/client-app/src/examples/weatherv2/WeatherV2.scss b/client-app/src/examples/weatherv2/WeatherV2.scss index 853ebe561..828ed7d1b 100644 --- a/client-app/src/examples/weatherv2/WeatherV2.scss +++ b/client-app/src/examples/weatherv2/WeatherV2.scss @@ -43,20 +43,18 @@ // Validation display in JSON harness .weather-v2-validation { - padding: 6px 10px; font-size: var(--xh-font-size-small-px); - border-top: var(--xh-border-solid); &--success { - color: var(--xh-green); + color: var(--xh-intent-success); } &--warning { - color: var(--xh-orange); + color: var(--xh-intent-warning); } &--error { - color: var(--xh-red); + color: var(--xh-intent-danger); } } diff --git a/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts b/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts index b60776d42..f9b1ee792 100644 --- a/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts +++ b/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts @@ -79,19 +79,6 @@ export class JsonHarnessModel extends HoistModel { } } - /** Copy current dashboard spec to clipboard. */ - async copySpecAsync() { - try { - const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; - const spec: DashSpec = {version: 1, state: this.buildSpecState(dashModel)}; - const json = JSON.stringify(spec, null, 2); - await navigator.clipboard.writeText(json); - XH.successToast('Spec copied to clipboard.'); - } catch (e) { - XH.dangerToast('Failed to copy spec.'); - } - } - /** Load an example spec into the editor. */ @action loadExample(name: string) { @@ -125,7 +112,19 @@ export class JsonHarnessModel extends HoistModel { return; } - this.lastValidation = validateSpec(spec); + const result = validateSpec(spec); + if (result.valid && result.warnings.length === 0) { + XH.successToast('Spec is valid.'); + } else { + this.lastValidation = result; + } + } + + /** Clear validation/error display. */ + @action + dismissValidation() { + this.lastValidation = null; + this.lastError = null; } //------------------------ diff --git a/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts b/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts index 596368373..364f2d75f 100644 --- a/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts +++ b/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts @@ -31,7 +31,7 @@ const editorArea = hoistCmp.factory({ width: '100%', commitOnChange: true, enableSearch: true, - showCopyButton: false, + showCopyButton: true, showFormatButton: true, showFullscreenButton: true }); @@ -44,58 +44,54 @@ const validationDisplay = hoistCmp.factory({ if (!lastValidation && !lastError) return null; + let icon, title, className; if (lastError) { - return div({ - className: 'weather-v2-validation weather-v2-validation--error', - item: lastError - }); - } - - if (!lastValidation) return null; - - const {valid, errors, warnings} = lastValidation; - const items = []; - - if (valid && warnings.length === 0) { - items.push( - div({ - className: 'weather-v2-validation weather-v2-validation--success', - item: 'Spec is valid.' - }) - ); - } else if (valid) { - items.push( - div({ - className: 'weather-v2-validation weather-v2-validation--warning', - item: `Valid with ${warnings.length} warning(s).` - }) - ); + icon = Icon.warning(); + title = 'Error'; + className = 'weather-v2-validation--error'; + } else if (!lastValidation.valid) { + icon = Icon.warning(); + title = `${lastValidation.errors.length} error(s), ${lastValidation.warnings.length} warning(s)`; + className = 'weather-v2-validation--error'; } else { - items.push( - div({ - className: 'weather-v2-validation weather-v2-validation--error', - item: `${errors.length} error(s), ${warnings.length} warning(s).` - }) - ); + icon = Icon.warning(); + title = `Valid with ${lastValidation.warnings.length} warning(s)`; + className = 'weather-v2-validation--warning'; } - const messages = [...errors, ...warnings]; - if (messages.length > 0) { - items.push( - div({ - className: 'weather-v2-validation-details', - items: messages.slice(0, 10).map((m, i) => - div({ - key: i, - className: `weather-v2-validation-msg weather-v2-validation-msg--${m.level}`, - item: `[${m.level.toUpperCase()}] ${m.path ? m.path + ': ' : ''}${m.message}` - }) - ) - }) - ); - } + const messages = lastError + ? [lastError] + : [...(lastValidation?.errors ?? []), ...(lastValidation?.warnings ?? [])]; - return vbox(...items); + return panel({ + icon, + title, + className: `weather-v2-validation ${className}`, + compactHeader: true, + maxHeight: 200, + headerItems: [ + button({ + icon: Icon.close(), + minimal: true, + small: true, + onClick: () => model.dismissValidation() + }) + ], + item: div({ + className: 'weather-v2-validation-details', + style: {overflow: 'auto'}, + items: messages.slice(0, 10).map((m, i) => + div({ + key: i, + className: `weather-v2-validation-msg weather-v2-validation-msg--${typeof m === 'string' ? 'error' : m.level}`, + item: + typeof m === 'string' + ? m + : `[${m.level.toUpperCase()}] ${m.path ? m.path + ': ' : ''}${m.message}` + }) + ) + }) + }); } }); @@ -119,7 +115,7 @@ const bottomToolbar = hoistCmp.factory({ button({ icon: Icon.sync(), text: 'Sync', - title: 'Load current dashboard state into editor', + tooltip: 'Load current dashboard state into editor', onClick: () => model.syncFromDashboard() }), filler(), @@ -133,11 +129,6 @@ const bottomToolbar = hoistCmp.factory({ text: 'Apply', intent: 'success', onClick: () => model.applySpec() - }), - button({ - icon: Icon.copy(), - text: 'Copy Spec', - onClick: () => model.copySpecAsync() }) ); } From 9866549bf33abe740563eb2badab5128a9dda2c1 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sat, 28 Feb 2026 09:03:14 -0800 Subject: [PATCH 16/41] Scope validation success toast to JSON harness panel Use a containerRef to render the "Spec is valid" toast inside the JSON panel rather than as a global toast, keeping feedback contextual. Co-Authored-By: Claude Opus 4.6 --- .../src/examples/weatherv2/harness/JsonHarnessModel.ts | 8 +++++++- .../src/examples/weatherv2/harness/JsonHarnessPanel.ts | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts b/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts index f9b1ee792..429e2d749 100644 --- a/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts +++ b/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts @@ -1,3 +1,4 @@ +import {createRef} from 'react'; import {HoistModel, PersistableState, XH} from '@xh/hoist/core'; import {DashCanvasModel} from '@xh/hoist/desktop/cmp/dash'; import {action, bindable, makeObservable, observable} from '@xh/hoist/mobx'; @@ -14,6 +15,7 @@ export class JsonHarnessModel extends HoistModel { @bindable editorValue: string = ''; @observable.ref lastValidation: ValidationResult = null; @bindable lastError: string = null; + containerRef = createRef(); get exampleSpecs(): ExampleSpec[] { return EXAMPLE_SPECS; @@ -114,7 +116,11 @@ export class JsonHarnessModel extends HoistModel { const result = validateSpec(spec); if (result.valid && result.warnings.length === 0) { - XH.successToast('Spec is valid.'); + XH.successToast({ + message: 'Spec is valid.', + containerRef: this.containerRef.current, + position: 'top' + }); } else { this.lastValidation = result; } diff --git a/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts b/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts index 364f2d75f..00dee9d4a 100644 --- a/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts +++ b/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts @@ -12,8 +12,9 @@ export const jsonHarnessPanel = hoistCmp.factory({ displayName: 'JsonHarnessPanel', model: creates(JsonHarnessModel), - render() { + render({model}) { return panel({ + ref: model.containerRef, title: 'JSON Spec Editor', icon: Icon.code(), compactHeader: true, From 3b98ee54e7f487a0daa8c1ba799d88fe17dc1bef Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sat, 28 Feb 2026 09:33:29 -0800 Subject: [PATCH 17/41] Improve JSON harness UX, persist panel state, and add testIds Clarify the direction of the Sync and Apply buttons in the JSON harness panel. "Sync from Dash" overwrites the editor with current dashboard state, "Apply to Dash" pushes editor content to the dashboard. Both buttons are now disabled when the editor and dashboard are in sync, providing a clear visual indicator of divergence. Persist the open/closed state of the JSON and Chat harness panels using the @persist decorator with a shared persistWith key on AppModel. Also consolidate the harness PanelModel to share the same persistence key via a path. Add testId props to all key interactive and display elements across the WeatherV2 app to support Chrome-based interactive testing and future e2e test automation. Co-Authored-By: Claude Opus 4.6 --- .../src/examples/weatherv2/AppComponent.ts | 2 ++ client-app/src/examples/weatherv2/AppModel.ts | 10 ++++++---- .../weatherv2/harness/ChatHarnessPanel.ts | 4 ++++ .../weatherv2/harness/JsonHarnessModel.ts | 17 ++++++++++++++++- .../weatherv2/harness/JsonHarnessPanel.ts | 16 +++++++++++++--- .../weatherv2/widgets/CityChooserWidget.ts | 2 ++ .../widgets/CurrentConditionsWidget.ts | 1 + .../weatherv2/widgets/DashInspectorWidget.ts | 2 +- .../weatherv2/widgets/ForecastChartWidget.ts | 2 +- .../weatherv2/widgets/MarkdownContentWidget.ts | 5 +++-- .../weatherv2/widgets/PrecipChartWidget.ts | 1 + .../weatherv2/widgets/SummaryGridWidget.ts | 2 +- .../weatherv2/widgets/UnitsToggleWidget.ts | 2 ++ .../weatherv2/widgets/WindChartWidget.ts | 2 +- 14 files changed, 54 insertions(+), 14 deletions(-) diff --git a/client-app/src/examples/weatherv2/AppComponent.ts b/client-app/src/examples/weatherv2/AppComponent.ts index f15b1fcd3..7d7d4e5a9 100644 --- a/client-app/src/examples/weatherv2/AppComponent.ts +++ b/client-app/src/examples/weatherv2/AppComponent.ts @@ -27,6 +27,7 @@ export const AppComponent = hoistCmp({ leftItems: [viewManager()], rightItems: [ button({ + testId: 'chat-btn', icon: Icon.comment(), text: 'Chat', active: showChatHarness, @@ -35,6 +36,7 @@ export const AppComponent = hoistCmp({ onClick: () => (model.showChatHarness = !showChatHarness) }), button({ + testId: 'json-btn', icon: Icon.json(), text: 'JSON', active: showJsonHarness, diff --git a/client-app/src/examples/weatherv2/AppModel.ts b/client-app/src/examples/weatherv2/AppModel.ts index f5dfa9015..01a7cb8e2 100644 --- a/client-app/src/examples/weatherv2/AppModel.ts +++ b/client-app/src/examples/weatherv2/AppModel.ts @@ -1,4 +1,4 @@ -import {managed, XH} from '@xh/hoist/core'; +import {managed, persist, XH} from '@xh/hoist/core'; import {ViewManagerModel} from '@xh/hoist/cmp/viewmanager'; import {PanelModel} from '@xh/hoist/desktop/cmp/panel'; import {bindable, makeObservable} from '@xh/hoist/mobx'; @@ -13,11 +13,13 @@ import {WeatherDataService} from './svc/WeatherDataService'; export class AppModel extends BaseAppModel { static instance: AppModel; + override persistWith = {localStorageKey: 'weatherV2App'}; + @managed weatherV2DashModel: WeatherV2DashModel; @managed weatherViewManager: ViewManagerModel; @managed harnessPanelModel: PanelModel; - @bindable showJsonHarness: boolean = false; - @bindable showChatHarness: boolean = false; + @persist @bindable showJsonHarness: boolean = false; + @persist @bindable showChatHarness: boolean = false; constructor() { super(); @@ -34,7 +36,7 @@ export class AppModel extends BaseAppModel { minSize: 300, resizable: true, collapsible: false, - persistWith: {localStorageKey: 'weatherV2HarnessPanel'} + persistWith: {...this.persistWith, path: 'harnessPanel'} }); this.weatherViewManager = await ViewManagerModel.createAsync({ diff --git a/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts index e41f92701..0cfab8dcc 100644 --- a/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts +++ b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts @@ -13,12 +13,14 @@ export const chatHarnessPanel = hoistCmp.factory({ render({model}) { return panel({ + testId: 'chat-panel', title: 'LLM Chat', icon: Icon.comment(), compactHeader: true, item: vbox({flex: 1, items: [messageList(), errorDisplay(), chatInput()]}), bbar: toolbar( button({ + testId: 'chat-clear-btn', icon: Icon.delete(), text: 'Clear', onClick: () => model.clearChat() @@ -80,6 +82,7 @@ const chatInput = hoistCmp.factory({ className: 'weather-v2-chat-input', items: [ textArea({ + testId: 'chat-input', bind: 'userInput', placeholder: 'Describe what you want...', flex: 1, @@ -87,6 +90,7 @@ const chatInput = hoistCmp.factory({ commitOnChange: true }), button({ + testId: 'chat-send-btn', icon: model.isLoading ? Icon.spinner() : Icon.chevronRight(), text: model.isLoading ? 'Thinking...' : 'Send', intent: 'primary', diff --git a/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts b/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts index 429e2d749..771eb8650 100644 --- a/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts +++ b/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts @@ -1,7 +1,7 @@ import {createRef} from 'react'; import {HoistModel, PersistableState, XH} from '@xh/hoist/core'; import {DashCanvasModel} from '@xh/hoist/desktop/cmp/dash'; -import {action, bindable, makeObservable, observable} from '@xh/hoist/mobx'; +import {action, bindable, computed, makeObservable, observable} from '@xh/hoist/mobx'; import {DashSpec, DashWidgetState, ValidationResult} from '../dash/types'; import {validateSpec, migrateSpec} from '../dash/validation'; import {EXAMPLE_SPECS, ExampleSpec} from '../dash/exampleSpecs'; @@ -21,6 +21,21 @@ export class JsonHarnessModel extends HoistModel { return EXAMPLE_SPECS; } + /** True when editor content differs from the live dashboard state. */ + @computed + get isDiverged(): boolean { + try { + const editorNormalized = JSON.stringify(JSON.parse(this.editorValue)); + const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; + const dashSpec: DashSpec = {version: 1, state: this.buildSpecState(dashModel)}; + const dashNormalized = JSON.stringify(dashSpec); + return editorNormalized !== dashNormalized; + } catch { + // Unparseable editor content is always considered diverged. + return true; + } + } + constructor() { super(); makeObservable(this); diff --git a/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts b/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts index 00dee9d4a..f6af05cd9 100644 --- a/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts +++ b/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts @@ -14,6 +14,7 @@ export const jsonHarnessPanel = hoistCmp.factory({ render({model}) { return panel({ + testId: 'json-panel', ref: model.containerRef, title: 'JSON Spec Editor', icon: Icon.code(), @@ -27,6 +28,7 @@ export const jsonHarnessPanel = hoistCmp.factory({ const editorArea = hoistCmp.factory({ render() { return jsonInput({ + testId: 'json-editor', bind: 'editorValue', flex: 1, width: '100%', @@ -105,6 +107,7 @@ const bottomToolbar = hoistCmp.factory({ return toolbar( select({ + testId: 'json-load-example', options: exampleOptions, placeholder: 'Load Example...', width: 180, @@ -114,21 +117,28 @@ const bottomToolbar = hoistCmp.factory({ } }), button({ + testId: 'json-sync-btn', icon: Icon.sync(), - text: 'Sync', - tooltip: 'Load current dashboard state into editor', + text: 'Sync from Dash', + tooltip: 'Overwrite editor with current dashboard state', + disabled: !model.isDiverged, onClick: () => model.syncFromDashboard() }), filler(), button({ + testId: 'json-validate-btn', icon: Icon.check(), text: 'Validate', onClick: () => model.validateOnly() }), + '-', button({ + testId: 'json-apply-btn', icon: Icon.play(), - text: 'Apply', + text: 'Apply to Dash', + tooltip: 'Apply editor spec to the dashboard', intent: 'success', + disabled: !model.isDiverged, onClick: () => model.applySpec() }) ); diff --git a/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts b/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts index 8c26e44e8..528f343b3 100644 --- a/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts @@ -103,9 +103,11 @@ export const cityChooserWidget = hoistCmp.factory({ render({model}) { return box({ + testId: 'city-chooser', padding: 8, flex: 1, item: select({ + testId: 'city-select', bind: 'selectedCity', options: model.cities, enableFilter: model.enableSearch, diff --git a/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts index faaa6e30b..2d8542da5 100644 --- a/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts @@ -223,6 +223,7 @@ export const currentConditionsWidget = hoistCmp.factory({ current.description.charAt(0).toUpperCase() + current.description.slice(1); return vbox({ + testId: 'current-conditions', className: 'weather-v2-current-conditions', alignItems: 'center', flex: 1, diff --git a/client-app/src/examples/weatherv2/widgets/DashInspectorWidget.ts b/client-app/src/examples/weatherv2/widgets/DashInspectorWidget.ts index 72d0d1e8d..21c5860a7 100644 --- a/client-app/src/examples/weatherv2/widgets/DashInspectorWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/DashInspectorWidget.ts @@ -114,6 +114,6 @@ export const dashInspectorWidget = hoistCmp.factory({ model: creates(DashInspectorModel), render() { - return panel({item: grid()}); + return panel({testId: 'dash-inspector', item: grid()}); } }); diff --git a/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts index 6ff3204e0..50dc64cab 100644 --- a/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts @@ -207,6 +207,6 @@ export const forecastChartWidget = hoistCmp.factory({ model: creates(ForecastChartModel), render() { - return panel({item: chart()}); + return panel({testId: 'forecast-chart', item: chart()}); } }); diff --git a/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts b/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts index 3c91c9153..ea94cc17f 100644 --- a/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts @@ -1,5 +1,5 @@ import {hoistCmp, creates} from '@xh/hoist/core'; -import {div} from '@xh/hoist/cmp/layout'; +import {box} from '@xh/hoist/cmp/layout'; import {markdown} from '@xh/hoist/cmp/markdown'; import {bindable, makeObservable} from '@xh/hoist/mobx'; import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; @@ -52,7 +52,8 @@ export const markdownContentWidget = hoistCmp.factory({ model: creates(MarkdownContentModel), render({model}) { - return div({ + return box({ + testId: 'markdown-content', className: 'weather-v2-markdown', item: markdown({content: model.content, lineBreaks: false}) }); diff --git a/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts index 3484af9b3..6be8ed933 100644 --- a/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts @@ -192,6 +192,7 @@ export const precipChartWidget = hoistCmp.factory({ render({model}) { return panel({ + testId: 'precip-chart', item: model.hasData ? chart() : placeholder({ diff --git a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts index 7be8bd8f9..8bfef1d04 100644 --- a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts @@ -190,6 +190,6 @@ export const summaryGridWidget = hoistCmp.factory({ model: creates(SummaryGridModel), render() { - return panel({item: grid()}); + return panel({testId: 'summary-grid', item: grid()}); } }); diff --git a/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts b/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts index 92acc8def..c065f4bc7 100644 --- a/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts @@ -63,10 +63,12 @@ export const unitsToggleWidget = hoistCmp.factory({ render() { return box({ + testId: 'units-toggle', padding: 8, alignItems: 'center', justifyContent: 'center', item: buttonGroupInput({ + testId: 'units-input', bind: 'units', items: [ button({text: '°F / mph', value: 'imperial'}), diff --git a/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts index ae36c10ad..b02506b7c 100644 --- a/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts @@ -195,6 +195,6 @@ export const windChartWidget = hoistCmp.factory({ model: creates(WindChartModel), render() { - return panel({item: chart()}); + return panel({testId: 'wind-chart', item: chart()}); } }); From 7b7c919c986d23c1bad91fc0e8bb245de1f12bea Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sat, 28 Feb 2026 09:38:25 -0800 Subject: [PATCH 18/41] Move Weather V2 planning docs into example package and add README Relocated all 13 planning docs from docs/planning/weather-v2/ into client-app/src/examples/weatherv2/planning/ to keep design artifacts closer to the source code they describe. Added a README.md at the weatherv2 package root with a project summary, directory guide, widget catalog, planning doc index, and agent-oriented notes for AI coding assistants starting work on this project. Co-Authored-By: Claude Opus 4.6 --- client-app/src/examples/weatherv2/README.md | 125 ++++++++++++++++++ .../weatherv2/planning}/DEMO-SCRIPTS.md | 0 .../weatherv2/planning}/DEPLOYMENT-MEMO.md | 0 .../examples/weatherv2/planning}/DSL-SPEC.md | 0 .../weatherv2/planning}/HOIST-CONVENTIONS.md | 0 .../src/examples/weatherv2/planning}/PLAN.md | 0 .../examples/weatherv2/planning}/PROGRESS.md | 0 .../examples/weatherv2/planning}/PROMPT.md | 0 .../src/examples/weatherv2/planning}/RISKS.md | 0 .../examples/weatherv2/planning}/ROADMAP.md | 0 .../src/examples/weatherv2/planning}/TASKS.md | 0 .../weatherv2/planning}/WIDGET-CATALOG.md | 0 .../weatherv2/planning}/WIDGET-SCHEMA.md | 0 .../weatherv2/planning}/WIRING-DESIGN.md | 0 14 files changed, 125 insertions(+) create mode 100644 client-app/src/examples/weatherv2/README.md rename {docs/planning/weather-v2 => client-app/src/examples/weatherv2/planning}/DEMO-SCRIPTS.md (100%) rename {docs/planning/weather-v2 => client-app/src/examples/weatherv2/planning}/DEPLOYMENT-MEMO.md (100%) rename {docs/planning/weather-v2 => client-app/src/examples/weatherv2/planning}/DSL-SPEC.md (100%) rename {docs/planning/weather-v2 => client-app/src/examples/weatherv2/planning}/HOIST-CONVENTIONS.md (100%) rename {docs/planning/weather-v2 => client-app/src/examples/weatherv2/planning}/PLAN.md (100%) rename {docs/planning/weather-v2 => client-app/src/examples/weatherv2/planning}/PROGRESS.md (100%) rename {docs/planning/weather-v2 => client-app/src/examples/weatherv2/planning}/PROMPT.md (100%) rename {docs/planning/weather-v2 => client-app/src/examples/weatherv2/planning}/RISKS.md (100%) rename {docs/planning/weather-v2 => client-app/src/examples/weatherv2/planning}/ROADMAP.md (100%) rename {docs/planning/weather-v2 => client-app/src/examples/weatherv2/planning}/TASKS.md (100%) rename {docs/planning/weather-v2 => client-app/src/examples/weatherv2/planning}/WIDGET-CATALOG.md (100%) rename {docs/planning/weather-v2 => client-app/src/examples/weatherv2/planning}/WIDGET-SCHEMA.md (100%) rename {docs/planning/weather-v2 => client-app/src/examples/weatherv2/planning}/WIRING-DESIGN.md (100%) diff --git a/client-app/src/examples/weatherv2/README.md b/client-app/src/examples/weatherv2/README.md new file mode 100644 index 000000000..6ddf26f3e --- /dev/null +++ b/client-app/src/examples/weatherv2/README.md @@ -0,0 +1,125 @@ +# Weather Dashboard V2 + +An LLM-driven dashboard example built on Hoist's `DashCanvasModel`. Demonstrates that Hoist's +native persisted state can function as a declarative DSL — one that an LLM can generate from +natural language, validate against a schema, and hydrate into a live, interactive dashboard. + +This app lives alongside the original Weather V1 example (`../weather/`) for A/B comparison. V1 is +unchanged; both share the same server-side weather endpoints. + +## Key Concepts + +- **Dashboard-as-DSL** — The "spec" format is not a new language. It is the JSON produced by + `DashCanvasModel.getPersistableState()`, extended with a `bindings` convention in each widget's + `viewState` for inter-widget wiring. +- **Inter-widget wiring** — Widgets declare typed inputs and outputs. A `WiringModel` coordinates + reactive data flow via MobX observables. A city chooser publishes `selectedCity`; display widgets + bind to it and update automatically. +- **LLM generation pipeline** — User describes a dashboard in natural language → system prompt with + widget schemas + spec format → LLM produces JSON spec → validation pipeline checks it → valid + specs hydrate into a live dashboard. + +## Directory Structure + +``` +weatherv2/ +├── AppModel.ts / AppComponent.ts — App shell (app bar + DashCanvas + harness panels) +├── Icons.ts, Types.ts — Shared icons and normalized weather data types +├── WeatherV2.scss — V2-specific styles +├── dash/ +│ ├── WeatherV2DashModel.ts — Central model: owns DashCanvasModel + WiringModel +│ ├── WiringModel.ts — Observable pub/sub for widget outputs +│ ├── WidgetRegistry.ts — Widget type registry + LLM prompt/schema generation +│ ├── types.ts — WidgetMeta, BindingSpec, DashSpec, ValidationResult +│ ├── validation.ts — 3-stage validation pipeline (structural/semantic/referential) +│ ├── unitUtils.ts — Temperature/wind unit conversion helpers +│ └── exampleSpecs.ts — Curated example dashboard specs +├── harness/ +│ ├── JsonHarnessModel.ts/Panel.ts — JSON editor: view/edit/validate/apply specs +│ ├── ChatHarnessModel.ts/Panel.ts — LLM chat: natural language → dashboard +│ ├── LlmChatService.ts — System prompt builder + LLM API client +│ └── WeatherDataService.ts — Per-city weather data caching +├── widgets/ +│ ├── BaseWeatherWidgetModel.ts — Base class: resolveInput/publishOutput/persistence +│ ├── CityChooserWidget.ts — City select input, publishes selectedCity +│ ├── UnitsToggleWidget.ts — Imperial/metric toggle, publishes units +│ ├── CurrentConditionsWidget.ts — Solid gauge + conditions details +│ ├── ForecastChartWidget.ts — Multi-series configurable chart +│ ├── PrecipChartWidget.ts — Dual-axis precipitation chart +│ ├── WindChartWidget.ts — Wind speed + gusts chart +│ ├── SummaryGridWidget.ts — Daily overview grid +│ ├── MarkdownContentWidget.ts — Static markdown renderer +│ └── DashInspectorWidget.ts — Debug view of live wiring graph +└── planning/ — Design docs (see below) +``` + +### Server-side + +- `grails-app/controllers/io/xh/toolbox/llm/LlmController.groovy` — POST `/llm/generate` endpoint +- `grails-app/services/io/xh/toolbox/llm/LlmService.groovy` — Anthropic Messages API proxy, + per-user rate limiting, config-driven (API key, model, max tokens) +- Weather endpoints are shared with V1 via `WeatherController`/`WeatherService` (OpenWeatherMap) + +## Widget Catalog (9 widgets) + +| Widget | Type | Key Inputs | Key Outputs | Purpose | +|--------|------|------------|-------------|---------| +| City Chooser | Input | — | `selectedCity` | Select a city from a dropdown | +| Units Toggle | Input | — | `units` | Switch imperial/metric | +| Current Conditions | Display | `city`, `units` | — | Temperature gauge + details | +| Forecast Chart | Display | `city`, `units` | — | Multi-series temp/humidity chart | +| Precip Chart | Display | `city` | — | Precipitation probability + volume | +| Wind Chart | Display | `city`, `units` | — | Wind speed + gusts | +| Summary Grid | Display | `city`, `units` | — | 5-day daily overview | +| Markdown Content | Utility | — | — | Static markdown display | +| Dash Inspector | Utility | — | — | Debug view of wiring state | + +## Planning Docs + +Detailed design documents are in [`./planning/`](./planning/): + +| Document | Contents | +|----------|----------| +| `PLAN.md` | Master 7-phase implementation plan with full task breakdown | +| `ROADMAP.md` | Phase-by-phase overview with scope and decision points | +| `PROGRESS.md` | Running log of what was built and when | +| `TASKS.md` | Structured checklist (~55 tasks across 7 phases) | +| `DSL-SPEC.md` | Dashboard spec schema, validation pipeline, hydration flow | +| `WIRING-DESIGN.md` | Inter-widget IO model: bindings, MobX propagation, examples | +| `WIDGET-CATALOG.md` | Full widget catalog with inputs/outputs/config | +| `WIDGET-SCHEMA.md` | WidgetMeta interface design and per-widget schema examples | +| `HOIST-CONVENTIONS.md` | House style checklist for V2 code | +| `DEPLOYMENT-MEMO.md` | LLM provider integration approach (Grails proxy) | +| `RISKS.md` | 10 identified risks with mitigations | +| `DEMO-SCRIPTS.md` | 5 customer demo scenarios with exact prompts | +| `PROMPT.md` | Original project brief and requirements | + +## Agent Notes + +Things that matter when working on this code: + +1. **Read Hoist docs first.** Use the `hoist-react` MCP tools (`hoist-search-docs`, + `hoist-search-symbols`) before writing code. The AGENTS.md at the repo root has the full primer. + Key docs: `desktop/cmp/dash`, `persistence`, `cmp/viewmanager`, `core`, `conventions`. + +2. **The spec IS the persisted state.** There is no translation layer. `DashCanvasModel.getPersistableState()` + returns the same JSON the JSON harness displays and the LLM generates. `setPersistableState()` + accepts it. Don't introduce an intermediate format. + +3. **Widget instance IDs are positional.** DashCanvasModel assigns IDs based on order in the `state` + array: first `cityChooser` → ID `"cityChooser"`, second → `"cityChooser_2"`. Bindings use these + IDs. The validation pipeline's `computeInstanceIds` must match DashCanvasModel's behavior. + +4. **WiringModel is MobX-reactive.** Widgets publish outputs to an observable map; downstream + widgets resolve inputs by reading from it. Changes propagate via standard MobX reactions. Don't + add event systems or manual subscriptions. + +5. **Don't modify V1.** The original weather app at `../weather/` stays unchanged for comparison. + Server-side weather endpoints (`WeatherController`/`WeatherService`) are shared — extend + additively, don't break V1's existing contract. + +6. **LLM proxy requires config.** The Grails `LlmService` reads `llmApiKey`, `llmModel`, + `llmMaxTokens`, and `llmRateLimit` from Hoist Config. Without `llmApiKey` configured on the + server, the chat harness will show a helpful error but the JSON harness still works. + +7. **Branch: `weatherv2`.** All V2 work happens on this branch. Commit frequently as checkpoints. diff --git a/docs/planning/weather-v2/DEMO-SCRIPTS.md b/client-app/src/examples/weatherv2/planning/DEMO-SCRIPTS.md similarity index 100% rename from docs/planning/weather-v2/DEMO-SCRIPTS.md rename to client-app/src/examples/weatherv2/planning/DEMO-SCRIPTS.md diff --git a/docs/planning/weather-v2/DEPLOYMENT-MEMO.md b/client-app/src/examples/weatherv2/planning/DEPLOYMENT-MEMO.md similarity index 100% rename from docs/planning/weather-v2/DEPLOYMENT-MEMO.md rename to client-app/src/examples/weatherv2/planning/DEPLOYMENT-MEMO.md diff --git a/docs/planning/weather-v2/DSL-SPEC.md b/client-app/src/examples/weatherv2/planning/DSL-SPEC.md similarity index 100% rename from docs/planning/weather-v2/DSL-SPEC.md rename to client-app/src/examples/weatherv2/planning/DSL-SPEC.md diff --git a/docs/planning/weather-v2/HOIST-CONVENTIONS.md b/client-app/src/examples/weatherv2/planning/HOIST-CONVENTIONS.md similarity index 100% rename from docs/planning/weather-v2/HOIST-CONVENTIONS.md rename to client-app/src/examples/weatherv2/planning/HOIST-CONVENTIONS.md diff --git a/docs/planning/weather-v2/PLAN.md b/client-app/src/examples/weatherv2/planning/PLAN.md similarity index 100% rename from docs/planning/weather-v2/PLAN.md rename to client-app/src/examples/weatherv2/planning/PLAN.md diff --git a/docs/planning/weather-v2/PROGRESS.md b/client-app/src/examples/weatherv2/planning/PROGRESS.md similarity index 100% rename from docs/planning/weather-v2/PROGRESS.md rename to client-app/src/examples/weatherv2/planning/PROGRESS.md diff --git a/docs/planning/weather-v2/PROMPT.md b/client-app/src/examples/weatherv2/planning/PROMPT.md similarity index 100% rename from docs/planning/weather-v2/PROMPT.md rename to client-app/src/examples/weatherv2/planning/PROMPT.md diff --git a/docs/planning/weather-v2/RISKS.md b/client-app/src/examples/weatherv2/planning/RISKS.md similarity index 100% rename from docs/planning/weather-v2/RISKS.md rename to client-app/src/examples/weatherv2/planning/RISKS.md diff --git a/docs/planning/weather-v2/ROADMAP.md b/client-app/src/examples/weatherv2/planning/ROADMAP.md similarity index 100% rename from docs/planning/weather-v2/ROADMAP.md rename to client-app/src/examples/weatherv2/planning/ROADMAP.md diff --git a/docs/planning/weather-v2/TASKS.md b/client-app/src/examples/weatherv2/planning/TASKS.md similarity index 100% rename from docs/planning/weather-v2/TASKS.md rename to client-app/src/examples/weatherv2/planning/TASKS.md diff --git a/docs/planning/weather-v2/WIDGET-CATALOG.md b/client-app/src/examples/weatherv2/planning/WIDGET-CATALOG.md similarity index 100% rename from docs/planning/weather-v2/WIDGET-CATALOG.md rename to client-app/src/examples/weatherv2/planning/WIDGET-CATALOG.md diff --git a/docs/planning/weather-v2/WIDGET-SCHEMA.md b/client-app/src/examples/weatherv2/planning/WIDGET-SCHEMA.md similarity index 100% rename from docs/planning/weather-v2/WIDGET-SCHEMA.md rename to client-app/src/examples/weatherv2/planning/WIDGET-SCHEMA.md diff --git a/docs/planning/weather-v2/WIRING-DESIGN.md b/client-app/src/examples/weatherv2/planning/WIRING-DESIGN.md similarity index 100% rename from docs/planning/weather-v2/WIRING-DESIGN.md rename to client-app/src/examples/weatherv2/planning/WIRING-DESIGN.md From 51858c55f82695d086d4d9184ae0dcb7ce4afd65 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sat, 28 Feb 2026 10:27:43 -0800 Subject: [PATCH 19/41] Promote LlmChatService to a proper HoistService, UI polish Convert LlmChatService from a plain object in harness/ to a standard HoistService class in svc/. Register it via XH.installServicesAsync in AppModel and add XHApi type augmentation in Bootstrap.ts so it's accessible as XH.llmChatService. Update ChatHarnessModel to use the service singleton and replace the manual isLoading flag with a @managed TaskObserver.trackLast() + .linkTo() pattern. Wrap post-await observable mutations in runInAction for MobX strict-mode compliance. Fix the fetch call to use XH.postJson instead of XH.fetchJson for the POST body. Add sparkles icon (faSparkles) to Icons.ts and use it for the Chat toggle button and panel header in place of Icon.comment(). Fix server-side LlmService.groovy to use configService.getPwd() instead of getString() for the llmApiKey config entry, which is stored as pwd type. Co-Authored-By: Claude Opus 4.6 --- client-app/src/Bootstrap.ts | 2 + .../src/examples/weatherv2/AppComponent.ts | 3 +- client-app/src/examples/weatherv2/AppModel.ts | 3 +- client-app/src/examples/weatherv2/Icons.ts | 4 +- client-app/src/examples/weatherv2/README.md | 7 ++- .../weatherv2/harness/ChatHarnessModel.ts | 61 +++++++++++-------- .../weatherv2/harness/ChatHarnessPanel.ts | 10 +-- .../{harness => svc}/LlmChatService.ts | 24 ++++---- .../io/xh/toolbox/llm/LlmService.groovy | 4 +- 9 files changed, 66 insertions(+), 52 deletions(-) rename client-app/src/examples/weatherv2/{harness => svc}/LlmChatService.ts (92%) diff --git a/client-app/src/Bootstrap.ts b/client-app/src/Bootstrap.ts index 2bb684b44..eaefea1ee 100755 --- a/client-app/src/Bootstrap.ts +++ b/client-app/src/Bootstrap.ts @@ -16,6 +16,7 @@ import {ContactService} from './examples/contact/svc/ContactService'; import {GitHubService} from './core/svc/GitHubService'; import {PortfolioService} from './core/svc/PortfolioService'; import {TaskService} from './examples/todo/TaskService'; +import {LlmChatService} from './examples/weatherv2/svc/LlmChatService'; import {WeatherDataService} from './examples/weatherv2/svc/WeatherDataService'; declare module '@xh/hoist/core' { @@ -23,6 +24,7 @@ declare module '@xh/hoist/core' { export interface XHApi { contactService: ContactService; gitHubService: GitHubService; + llmChatService: LlmChatService; portfolioService: PortfolioService; taskService: TaskService; weatherDataService: WeatherDataService; diff --git a/client-app/src/examples/weatherv2/AppComponent.ts b/client-app/src/examples/weatherv2/AppComponent.ts index 7d7d4e5a9..6815bfb28 100644 --- a/client-app/src/examples/weatherv2/AppComponent.ts +++ b/client-app/src/examples/weatherv2/AppComponent.ts @@ -6,6 +6,7 @@ import {panel} from '@xh/hoist/desktop/cmp/panel'; import {dashCanvas} from '@xh/hoist/desktop/cmp/dash'; import {viewManager} from '@xh/hoist/desktop/cmp/viewmanager'; import {Icon} from '@xh/hoist/icon'; +import {sparklesIcon} from './Icons'; import {AppModel} from './AppModel'; import {jsonHarnessPanel} from './harness/JsonHarnessPanel'; import {chatHarnessPanel} from './harness/ChatHarnessPanel'; @@ -28,7 +29,7 @@ export const AppComponent = hoistCmp({ rightItems: [ button({ testId: 'chat-btn', - icon: Icon.comment(), + icon: sparklesIcon(), text: 'Chat', active: showChatHarness, outlined: true, diff --git a/client-app/src/examples/weatherv2/AppModel.ts b/client-app/src/examples/weatherv2/AppModel.ts index 01a7cb8e2..57ddbfa1b 100644 --- a/client-app/src/examples/weatherv2/AppModel.ts +++ b/client-app/src/examples/weatherv2/AppModel.ts @@ -9,6 +9,7 @@ import { } from '@xh/hoist/desktop/cmp/appOption'; import {BaseAppModel} from '../../BaseAppModel'; import {WeatherV2DashModel} from './dash/WeatherV2DashModel'; +import {LlmChatService} from './svc/LlmChatService'; import {WeatherDataService} from './svc/WeatherDataService'; export class AppModel extends BaseAppModel { @@ -28,7 +29,7 @@ export class AppModel extends BaseAppModel { override async initAsync() { await super.initAsync(); - await XH.installServicesAsync(WeatherDataService); + await XH.installServicesAsync(LlmChatService, WeatherDataService); this.harnessPanelModel = new PanelModel({ side: 'right', diff --git a/client-app/src/examples/weatherv2/Icons.ts b/client-app/src/examples/weatherv2/Icons.ts index 5670303ec..8e9156e61 100644 --- a/client-app/src/examples/weatherv2/Icons.ts +++ b/client-app/src/examples/weatherv2/Icons.ts @@ -3,15 +3,17 @@ import { faCloudRain, faDropletPercent, faCalendarDays, + faSparkles, faTemperatureHalf, faWind } from '@fortawesome/pro-regular-svg-icons'; import {Icon} from '@xh/hoist/icon'; -library.add(faCloudRain, faDropletPercent, faCalendarDays, faTemperatureHalf, faWind); +library.add(faCloudRain, faDropletPercent, faCalendarDays, faSparkles, faTemperatureHalf, faWind); export const temperatureIcon = (opts = {}) => Icon.icon({iconName: 'temperature-half', ...opts}); export const cloudRainIcon = (opts = {}) => Icon.icon({iconName: 'cloud-rain', ...opts}); export const windIcon = (opts = {}) => Icon.icon({iconName: 'wind', ...opts}); export const dropletPercentIcon = (opts = {}) => Icon.icon({iconName: 'droplet-percent', ...opts}); export const calendarDaysIcon = (opts = {}) => Icon.icon({iconName: 'calendar-days', ...opts}); +export const sparklesIcon = (opts = {}) => Icon.icon({iconName: 'sparkles', ...opts}); diff --git a/client-app/src/examples/weatherv2/README.md b/client-app/src/examples/weatherv2/README.md index 6ddf26f3e..c465b5b71 100644 --- a/client-app/src/examples/weatherv2/README.md +++ b/client-app/src/examples/weatherv2/README.md @@ -34,11 +34,12 @@ weatherv2/ │ ├── validation.ts — 3-stage validation pipeline (structural/semantic/referential) │ ├── unitUtils.ts — Temperature/wind unit conversion helpers │ └── exampleSpecs.ts — Curated example dashboard specs +├── svc/ +│ ├── LlmChatService.ts — System prompt builder + LLM API client (HoistService) +│ └── WeatherDataService.ts — Per-city weather data caching (HoistService) ├── harness/ │ ├── JsonHarnessModel.ts/Panel.ts — JSON editor: view/edit/validate/apply specs -│ ├── ChatHarnessModel.ts/Panel.ts — LLM chat: natural language → dashboard -│ ├── LlmChatService.ts — System prompt builder + LLM API client -│ └── WeatherDataService.ts — Per-city weather data caching +│ └── ChatHarnessModel.ts/Panel.ts — LLM chat: natural language → dashboard ├── widgets/ │ ├── BaseWeatherWidgetModel.ts — Base class: resolveInput/publishOutput/persistence │ ├── CityChooserWidget.ts — City select input, publishes selectedCity diff --git a/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts b/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts index c4b0bc795..b486f8b89 100644 --- a/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts +++ b/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts @@ -1,8 +1,8 @@ -import {HoistModel, PersistableState, XH} from '@xh/hoist/core'; -import {action, bindable, makeObservable, observable} from '@xh/hoist/mobx'; +import {HoistModel, managed, PersistableState, TaskObserver, XH} from '@xh/hoist/core'; +import {action, bindable, makeObservable, observable, runInAction} from '@xh/hoist/mobx'; import {DashSpec} from '../dash/types'; import {validateSpec, migrateSpec} from '../dash/validation'; -import {LlmChatService, ChatMessage} from './LlmChatService'; +import {ChatMessage} from '../svc/LlmChatService'; import {AppModel} from '../AppModel'; /** @@ -11,10 +11,12 @@ import {AppModel} from '../AppModel'; */ export class ChatHarnessModel extends HoistModel { @bindable userInput: string = ''; - @bindable isLoading: boolean = false; @observable.ref messages: ChatMessage[] = []; @observable.ref lastError: string = null; + @managed + generateTask = TaskObserver.trackLast(); + constructor() { super(); makeObservable(this); @@ -24,46 +26,51 @@ export class ChatHarnessModel extends HoistModel { @action async sendMessageAsync() { const {userInput} = this; - if (!userInput.trim() || this.isLoading) return; + if (!userInput.trim() || this.generateTask.isPending) return; // Add user message const userMsg: ChatMessage = {role: 'user', content: userInput.trim()}; this.messages = [...this.messages, userMsg]; this.userInput = ''; - this.isLoading = true; this.lastError = null; + this.doGenerateAsync().linkTo(this.generateTask); + } + + /** Clear the conversation. */ + @action + clearChat() { + this.messages = []; + this.lastError = null; + } + + //------------------ + // Implementation + //------------------ + private async doGenerateAsync() { try { + const svc = XH.llmChatService; + // Build system prompt with current dashboard spec const currentSpec = this.getCurrentSpec(); - const systemPrompt = LlmChatService.buildSystemPrompt(currentSpec); + const systemPrompt = svc.buildSystemPrompt(currentSpec); // Call LLM - const {content} = await LlmChatService.generateAsync(systemPrompt, this.messages); - - // Add assistant response - const assistantMsg: ChatMessage = {role: 'assistant', content}; - this.messages = [...this.messages, assistantMsg]; + const {content} = await svc.generateAsync(systemPrompt, this.messages); - // Try to extract and apply spec - const spec = LlmChatService.parseSpecFromResponse(content); - if (spec) { - this.applySpec(spec); - } + // Add assistant response and apply any spec — in action since we're post-await + runInAction(() => { + this.messages = [...this.messages, {role: 'assistant', content}]; + const spec = svc.parseSpecFromResponse(content); + if (spec) this.applySpec(spec); + }); } catch (e) { - this.lastError = e.message || 'LLM request failed.'; - } finally { - this.isLoading = false; + runInAction(() => { + this.lastError = e.message || 'LLM request failed.'; + }); } } - /** Clear the conversation. */ - @action - clearChat() { - this.messages = []; - this.lastError = null; - } - private getCurrentSpec(): DashSpec | undefined { try { const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; diff --git a/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts index 0cfab8dcc..868f9b907 100644 --- a/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts +++ b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts @@ -5,6 +5,7 @@ import {toolbar} from '@xh/hoist/desktop/cmp/toolbar'; import {button} from '@xh/hoist/desktop/cmp/button'; import {textArea} from '@xh/hoist/desktop/cmp/input'; import {Icon} from '@xh/hoist/icon'; +import {sparklesIcon} from '../Icons'; import {ChatHarnessModel} from './ChatHarnessModel'; export const chatHarnessPanel = hoistCmp.factory({ @@ -15,7 +16,7 @@ export const chatHarnessPanel = hoistCmp.factory({ return panel({ testId: 'chat-panel', title: 'LLM Chat', - icon: Icon.comment(), + icon: sparklesIcon(), compactHeader: true, item: vbox({flex: 1, items: [messageList(), errorDisplay(), chatInput()]}), bbar: toolbar( @@ -78,6 +79,7 @@ const errorDisplay = hoistCmp.factory({ const chatInput = hoistCmp.factory({ render({model}) { + const isPending = model.generateTask.isPending; return div({ className: 'weather-v2-chat-input', items: [ @@ -91,10 +93,10 @@ const chatInput = hoistCmp.factory({ }), button({ testId: 'chat-send-btn', - icon: model.isLoading ? Icon.spinner() : Icon.chevronRight(), - text: model.isLoading ? 'Thinking...' : 'Send', + icon: isPending ? Icon.spinner() : Icon.chevronRight(), + text: isPending ? 'Thinking...' : 'Send', intent: 'primary', - disabled: model.isLoading || !model.userInput.trim(), + disabled: isPending || !model.userInput.trim(), onClick: () => model.sendMessageAsync() }) ] diff --git a/client-app/src/examples/weatherv2/harness/LlmChatService.ts b/client-app/src/examples/weatherv2/svc/LlmChatService.ts similarity index 92% rename from client-app/src/examples/weatherv2/harness/LlmChatService.ts rename to client-app/src/examples/weatherv2/svc/LlmChatService.ts index 56e25d9c3..80eb987ec 100644 --- a/client-app/src/examples/weatherv2/harness/LlmChatService.ts +++ b/client-app/src/examples/weatherv2/svc/LlmChatService.ts @@ -1,4 +1,4 @@ -import {XH} from '@xh/hoist/core'; +import {HoistService, XH} from '@xh/hoist/core'; import {widgetRegistry} from '../dash/WidgetRegistry'; import {DashSpec} from '../dash/types'; @@ -8,13 +8,13 @@ export interface ChatMessage { } /** - * Client-side service for assembling LLM prompts, calling the server proxy, + * Service for assembling LLM prompts, calling the server proxy, * and parsing spec responses. */ -export const LlmChatService = { - /** - * Build the system prompt including widget schemas, spec format, and rules. - */ +export class LlmChatService extends HoistService { + static instance: LlmChatService; + + /** Build the system prompt including widget schemas, spec format, and rules. */ buildSystemPrompt(currentSpec?: DashSpec): string { const widgetDocs = widgetRegistry.generateLLMPrompt(); const parts: string[] = [ @@ -36,16 +36,14 @@ export const LlmChatService = { parts.push(OUTPUT_RULES); return parts.join('\n\n'); - }, + } - /** - * Call the LLM proxy endpoint and return the raw response. - */ + /** Call the LLM proxy endpoint and return the raw response. */ async generateAsync( systemPrompt: string, messages: ChatMessage[] ): Promise<{content: string; raw: any}> { - const response = await XH.fetchJson({ + const response = await XH.postJson({ url: 'llm/generate', body: {systemPrompt, messages} }); @@ -54,7 +52,7 @@ export const LlmChatService = { const textBlock = response?.content?.find((c: any) => c.type === 'text'); const content = textBlock?.text ?? ''; return {content, raw: response}; - }, + } /** * Extract a JSON dashboard spec from the LLM's text response. @@ -90,7 +88,7 @@ export const LlmChatService = { return null; } -}; +} //-------------------------------------------------- // System prompt sections diff --git a/grails-app/services/io/xh/toolbox/llm/LlmService.groovy b/grails-app/services/io/xh/toolbox/llm/LlmService.groovy index 01adb3387..ec16c67a9 100644 --- a/grails-app/services/io/xh/toolbox/llm/LlmService.groovy +++ b/grails-app/services/io/xh/toolbox/llm/LlmService.groovy @@ -49,7 +49,7 @@ class LlmService extends BaseService { def model = configService.getString('llmModel', 'claude-sonnet-4-20250514'), maxTokens = configService.getInt('llmMaxTokens', 4096), - apiKey = configService.getString('llmApiKey') + apiKey = configService.getPwd('llmApiKey') def body = [ model : model, @@ -98,7 +98,7 @@ class LlmService extends BaseService { } private void checkApiKey() { - def key = configService.getString('llmApiKey', 'none') + def key = configService.getPwd('llmApiKey', 'none') if (key == 'none' || !key?.trim()) { throw new DataNotAvailableException( 'LLM API key not configured. Set the "llmApiKey" config entry in the Hoist Admin console.' From 6cfa528c2654112fa091f68489fb3903b911ea39 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sat, 28 Feb 2026 10:55:53 -0800 Subject: [PATCH 20/41] Chat UX, markdown fix, auto-generated widget titles, and LLM testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enter-to-submit in chat panel (Shift+Enter for newlines). Fix markdown widget rendering by replacing flex box wrapper with plain div. Add reactive auto-generated titles to display widgets showing bound city context (e.g. "Forecast — Tokyo"), with getAutoTitle() virtual method in BaseWeatherWidgetModel. Markdown widget gets explicit title config via state.title. Disable user renaming on all viewSpecs. Update system prompt to guide LLM on title behavior. Update example specs to match new title conventions. Update PROGRESS.md with comprehensive session notes including 12 API + 3 UI LLM test results. Co-Authored-By: Claude Opus 4.6 --- .../weatherv2/dash/WeatherV2DashModel.ts | 9 +++ .../examples/weatherv2/dash/exampleSpecs.ts | 6 +- .../weatherv2/harness/ChatHarnessPanel.ts | 8 +- .../examples/weatherv2/planning/PROGRESS.md | 81 +++++++++++++++++-- .../examples/weatherv2/svc/LlmChatService.ts | 6 +- .../widgets/BaseWeatherWidgetModel.ts | 21 +++++ .../widgets/CurrentConditionsWidget.ts | 4 + .../weatherv2/widgets/ForecastChartWidget.ts | 4 + .../widgets/MarkdownContentWidget.ts | 17 +++- .../weatherv2/widgets/PrecipChartWidget.ts | 4 + .../weatherv2/widgets/SummaryGridWidget.ts | 4 + .../weatherv2/widgets/WindChartWidget.ts | 4 + 12 files changed, 150 insertions(+), 18 deletions(-) diff --git a/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts b/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts index d86fb85b7..9ebbeb8b5 100644 --- a/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts +++ b/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts @@ -44,6 +44,7 @@ export class WeatherV2DashModel extends HoistModel { icon: Icon.globe(), content: cityChooserWidget, unique: false, + allowRename: false, width: 3, height: 2 }, @@ -53,6 +54,7 @@ export class WeatherV2DashModel extends HoistModel { icon: Icon.sun(), content: currentConditionsWidget, unique: false, + allowRename: false, width: 4, height: 5 }, @@ -62,6 +64,7 @@ export class WeatherV2DashModel extends HoistModel { icon: temperatureIcon(), content: forecastChartWidget, unique: false, + allowRename: false, width: 8, height: 5 }, @@ -71,6 +74,7 @@ export class WeatherV2DashModel extends HoistModel { icon: cloudRainIcon(), content: precipChartWidget, unique: false, + allowRename: false, width: 6, height: 5 }, @@ -80,6 +84,7 @@ export class WeatherV2DashModel extends HoistModel { icon: calendarDaysIcon(), content: summaryGridWidget, unique: false, + allowRename: false, width: 6, height: 5 }, @@ -89,6 +94,7 @@ export class WeatherV2DashModel extends HoistModel { icon: Icon.gear(), content: unitsToggleWidget, unique: false, + allowRename: false, width: 3, height: 2 }, @@ -98,6 +104,7 @@ export class WeatherV2DashModel extends HoistModel { icon: windIcon(), content: windChartWidget, unique: false, + allowRename: false, width: 6, height: 5 }, @@ -107,6 +114,7 @@ export class WeatherV2DashModel extends HoistModel { icon: Icon.info(), content: markdownContentWidget, unique: false, + allowRename: false, width: 4, height: 3 }, @@ -116,6 +124,7 @@ export class WeatherV2DashModel extends HoistModel { icon: Icon.code(), content: dashInspectorWidget, unique: true, + allowRename: false, width: 6, height: 5 } diff --git a/client-app/src/examples/weatherv2/dash/exampleSpecs.ts b/client-app/src/examples/weatherv2/dash/exampleSpecs.ts index ca9593676..11c3c91f6 100644 --- a/client-app/src/examples/weatherv2/dash/exampleSpecs.ts +++ b/client-app/src/examples/weatherv2/dash/exampleSpecs.ts @@ -125,7 +125,6 @@ const comparisonSpec: DashSpec = { { viewSpecId: 'forecastChart', layout: {x: 0, y: 2, w: 6, h: 5}, - title: 'City A Forecast', state: { bindings: { city: {fromWidget: 'cityChooser', output: 'selectedCity'}, @@ -138,7 +137,6 @@ const comparisonSpec: DashSpec = { { viewSpecId: 'forecastChart', layout: {x: 6, y: 2, w: 6, h: 5}, - title: 'City B Forecast', state: { bindings: { city: {fromWidget: 'cityChooser_2', output: 'selectedCity'}, @@ -151,7 +149,6 @@ const comparisonSpec: DashSpec = { { viewSpecId: 'currentConditions', layout: {x: 0, y: 7, w: 6, h: 5}, - title: 'City A Conditions', state: { bindings: { city: {fromWidget: 'cityChooser', output: 'selectedCity'}, @@ -162,7 +159,6 @@ const comparisonSpec: DashSpec = { { viewSpecId: 'currentConditions', layout: {x: 6, y: 7, w: 6, h: 5}, - title: 'City B Conditions', state: { bindings: { city: {fromWidget: 'cityChooser_2', output: 'selectedCity'}, @@ -180,8 +176,8 @@ const annotatedSpec: DashSpec = { { viewSpecId: 'markdownContent', layout: {x: 0, y: 0, w: 12, h: 2}, - title: 'Dashboard Guide', state: { + title: 'Dashboard Guide', content: '# Weather Dashboard V2\n\nThis dashboard demonstrates **inter-widget wiring**. The City Chooser and Units Toggle publish outputs that drive all display widgets. Open the **Dash Inspector** to see live binding values.' } diff --git a/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts index 868f9b907..3838d8f36 100644 --- a/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts +++ b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts @@ -89,7 +89,13 @@ const chatInput = hoistCmp.factory({ placeholder: 'Describe what you want...', flex: 1, height: 80, - commitOnChange: true + commitOnChange: true, + onKeyDown: (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + model.sendMessageAsync(); + } + } }), button({ testId: 'chat-send-btn', diff --git a/client-app/src/examples/weatherv2/planning/PROGRESS.md b/client-app/src/examples/weatherv2/planning/PROGRESS.md index e8ab31978..b058470c8 100644 --- a/client-app/src/examples/weatherv2/planning/PROGRESS.md +++ b/client-app/src/examples/weatherv2/planning/PROGRESS.md @@ -113,10 +113,79 @@ Final review pass: - LLM chat harness: natural language → system prompt → spec → validation → hydration - 4 curated example specs loadable from dropdown -### What Needs Runtime Testing +### LLM Chat Service Promoted to HoistService -- Actual weather API calls (requires running Grails server) -- LLM proxy (requires Anthropic API key in Hoist config) -- Persistence across page reloads (requires ViewManager backend) -- DashCanvas drag/resize (requires browser) -- Widget instance ID assignment in bindings (DashCanvasModel's ID generation must match validation's `computeInstanceIds`) +LlmChatService refactored from a plain class to a proper HoistService, installed via `XH.installServicesAsync()` in AppModel. Accessible as `XH.llmChatService` throughout the app. + +### UI Polish + +- JSON harness: improved UX with persisted panel state and added testIds +- Chat/JSON panels use `PanelModel` with right-side resizable layout + +### Runtime Testing Results + +All runtime features confirmed working with live Grails server: +- Weather API calls via OpenWeatherMap — data caching works correctly +- LLM proxy via Anthropic API — full round-trip conversation works +- DashCanvas drag/resize works +- Widget instance IDs in bindings match DashCanvasModel's ID generation +- ViewManager persistence across page reloads works + +### Chat UX: Enter-to-Submit + +Added `onKeyDown` handler to ChatHarnessPanel's textArea. Enter now submits the message; Shift+Enter inserts a newline. Matches standard chat UX conventions. + +### Markdown Widget Rendering Fix + +The markdown widget was rendering header content inline with body text because Hoist's `box` component applies `display: flex` via inline styles, causing react-markdown's block-level elements (h1, p) to lay out horizontally. Fixed by replacing `box` with a plain `div` from `@xh/hoist/cmp/layout` — the CSS `.weather-v2-markdown` class provides the necessary `flex: 1`, `padding`, and `overflow: auto` for the widget to fill its card correctly. + +### Thorough LLM Chat Testing (12 API + 3 UI tests) + +Systematic testing of the LLM chat functionality across multiple scenarios: + +**Results (all passing):** +- Fresh dashboard builds from natural language (correct widget selection, bindings, layout) +- Iterative refinement (add/remove/modify widgets while preserving existing ones) +- Multi-instance wiring (dual city choosers with correct `cityChooser`/`cityChooser_2` instance IDs) +- Capability self-description (accurate listing of all 9 widgets with categories and wiring explanation) +- Off-topic/impossible requests handled gracefully (poems, stock prices, gibberish all redirected) +- Prompt injection rejected cleanly +- Const bindings used correctly for hard-wired city inputs +- Full end-to-end UI pipeline: LLM → JSON parse → validation → hydration → live dashboard + +**System prompt quality:** The current prompt produces consistently valid specs with correct bindings, sensible layouts, and appropriate widget selection. No critical issues found. + +### Auto-Generated Widget Titles + +Implemented reactive auto-titles that show widget context (particularly the active city) in widget headers: + +**Architecture:** +- `BaseWeatherWidgetModel.getAutoTitle()` — virtual method returning `null` by default +- `BaseWeatherWidgetModel.onLinked()` — reaction tracks `getAutoTitle()` and pushes to `viewModel.title` +- Display widgets override: `"Current Conditions — Tokyo"`, `"Forecast — Chicago"`, etc. +- Titles update reactively when the city chooser selection changes + +**Widget-specific behavior:** +- **Display widgets** (CurrentConditions, ForecastChart, PrecipChart, WindChart, SummaryGrid): Auto-generate `""` titles from their bound city input +- **MarkdownContent**: Exposes a `title` config in its state (`state.title`) — LLM/user sets it explicitly since content is free-form +- **Input widgets** (CityChooser, UnitsToggle) and **DashInspector**: Use default viewSpec titles + +**User renaming disabled:** Set `allowRename: false` on all 9 viewSpecs in `WeatherV2DashModel` to prevent conflicts between user-set titles and auto-generated ones. + +**System prompt updated:** Added guidance telling the LLM not to set top-level `title` for auto-titled display widgets (it would be overwritten) and to use `state.title` for markdown widgets. + +**Example specs updated:** Removed top-level `title` from display widgets in comparison spec; moved markdown title into `state.title` in annotated spec. + +### What's Working (Updated) + +- Full 9-widget catalog with typed inputs/outputs/config +- Inter-widget wiring via MobX-reactive WiringModel +- Per-city weather data caching via WeatherDataService +- Spec validation with detailed error messages + JSON paths +- JSON harness: edit → validate → apply → dashboard updates live +- LLM chat harness: natural language → system prompt → spec → validation → hydration +- Enter-to-submit in chat with Shift+Enter for newlines +- Auto-generated widget titles with reactive city context +- Markdown rendering with proper block layout +- 4 curated example specs loadable from dropdown +- Widget renaming disabled to prevent title conflicts diff --git a/client-app/src/examples/weatherv2/svc/LlmChatService.ts b/client-app/src/examples/weatherv2/svc/LlmChatService.ts index 80eb987ec..7b69d4e75 100644 --- a/client-app/src/examples/weatherv2/svc/LlmChatService.ts +++ b/client-app/src/examples/weatherv2/svc/LlmChatService.ts @@ -107,7 +107,6 @@ The spec is a JSON object with this structure: { "viewSpecId": "widgetTypeId", "layout": {"x": 0, "y": 0, "w": 6, "h": 5}, - "title": "Optional Custom Title", "state": { "bindings": { "inputName": {"fromWidget": "sourceInstanceId", "output": "outputName"} @@ -122,8 +121,9 @@ The spec is a JSON object with this structure: Each widget in the \`state\` array has: - \`viewSpecId\` (required): Widget type from the catalog. - \`layout\` (required): Grid position. \`x\` is column (0-11), \`y\` is row, \`w\` is width (1-12), \`h\` is height. -- \`title\` (optional): Custom display title. -- \`state\` (optional): Widget config and input bindings.`; +- \`state\` (optional): Widget config and input bindings. + +**Widget Titles:** Display widgets (currentConditions, forecastChart, precipChart, windChart, summaryGrid) auto-generate their titles from their bound city (e.g. "Forecast — Tokyo"). Do NOT set a top-level \`title\` for these widgets — it will be overwritten. For the \`markdownContent\` widget, set the title via \`state.title\` (e.g. \`"state": {"title": "Dashboard Header", "content": "..."}\`). Input widgets (cityChooser, unitsToggle) and dashInspector use their default titles.`; const WIRING_RULES = `## Wiring Rules diff --git a/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts b/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts index ac1cb5df1..4a8afdd6a 100644 --- a/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts +++ b/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts @@ -25,6 +25,27 @@ export abstract class BaseWeatherWidgetModel extends HoistModel { override onLinked() { super.onLinked(); this.persistWith = {dashViewModel: this.viewModel}; + + // Auto-title: subclasses override getAutoTitle() to provide reactive titles + this.addReaction({ + track: () => this.getAutoTitle(), + run: title => { + if (title != null) this.viewModel.title = title; + }, + fireImmediately: true + }); + } + + //-------------------------------------------------- + // Auto-Title + //-------------------------------------------------- + + /** + * Override in subclasses to provide a reactive auto-generated title. + * Returning null keeps the default viewSpec title unchanged. + */ + protected getAutoTitle(): string | null { + return null; } //-------------------------------------------------- diff --git a/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts index 2d8542da5..4f9495d9c 100644 --- a/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts @@ -82,6 +82,10 @@ export class CurrentConditionsModel extends BaseWeatherWidgetModel { return this.viewModel.viewState?.showWind ?? true; } + protected override getAutoTitle(): string { + return `Current Conditions — ${this.city}`; + } + override onLinked() { super.onLinked(); diff --git a/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts index 50dc64cab..10041e41c 100644 --- a/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts @@ -82,6 +82,10 @@ export class ForecastChartModel extends BaseWeatherWidgetModel { return this.viewModel.viewState?.showLegend ?? true; } + protected override getAutoTitle(): string { + return `Forecast — ${this.city}`; + } + override onLinked() { super.onLinked(); this.chartModel = this.createChartModel(); diff --git a/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts b/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts index ea94cc17f..b6f07cb4a 100644 --- a/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts @@ -1,5 +1,5 @@ import {hoistCmp, creates} from '@xh/hoist/core'; -import {box} from '@xh/hoist/cmp/layout'; +import {div} from '@xh/hoist/cmp/layout'; import {markdown} from '@xh/hoist/cmp/markdown'; import {bindable, makeObservable} from '@xh/hoist/mobx'; import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; @@ -23,6 +23,12 @@ export class MarkdownContentModel extends BaseWeatherWidgetModel { type: 'string', description: 'Markdown text to render.', default: "# Welcome\n\nEdit this widget's content in the dashboard spec." + }, + title: { + type: 'string', + description: + 'Display title for the widget header. Set this to label the widget since its title cannot be auto-generated.', + default: 'Markdown Content' } }, defaultSize: {w: 4, h: 3}, @@ -30,6 +36,7 @@ export class MarkdownContentModel extends BaseWeatherWidgetModel { }; @bindable content: string = "# Welcome\n\nEdit this widget's content in the dashboard spec."; + @bindable widgetTitle: string = 'Markdown Content'; constructor() { super(); @@ -39,6 +46,11 @@ export class MarkdownContentModel extends BaseWeatherWidgetModel { override onLinked() { super.onLinked(); this.markPersist('content'); + this.markPersist('widgetTitle', {path: 'title'}); + } + + protected override getAutoTitle(): string { + return this.widgetTitle; } } @@ -52,8 +64,7 @@ export const markdownContentWidget = hoistCmp.factory({ model: creates(MarkdownContentModel), render({model}) { - return box({ - testId: 'markdown-content', + return div({ className: 'weather-v2-markdown', item: markdown({content: model.content, lineBreaks: false}) }); diff --git a/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts index 6be8ed933..1b396a2a4 100644 --- a/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts @@ -63,6 +63,10 @@ export class PrecipChartModel extends BaseWeatherWidgetModel { return this.viewModel.viewState?.metric ?? 'both'; } + protected override getAutoTitle(): string { + return `Precipitation — ${this.city}`; + } + override onLinked() { super.onLinked(); this.chartModel = this.createChartModel(); diff --git a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts index 8bfef1d04..6cd96331f 100644 --- a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts @@ -63,6 +63,10 @@ export class SummaryGridModel extends BaseWeatherWidgetModel { return this.resolveInput('units') ?? 'imperial'; } + protected override getAutoTitle(): string { + return `5-Day Summary — ${this.city}`; + } + override onLinked() { super.onLinked(); this.gridModel = this.createGridModel(); diff --git a/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts index b02506b7c..aca15ce2b 100644 --- a/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts @@ -75,6 +75,10 @@ export class WindChartModel extends BaseWeatherWidgetModel { return this.viewModel.viewState?.chartType ?? 'line'; } + protected override getAutoTitle(): string { + return `Wind — ${this.city}`; + } + override onLinked() { super.onLinked(); this.chartModel = this.createChartModel(); From 8109e5bb96f5b90d9c98a57977a834c2901ec15d Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sat, 28 Feb 2026 15:38:24 -0800 Subject: [PATCH 21/41] Fix widget ID convention mismatch, move auto-titles to DashModel level Align spec-level widget instance IDs with DashCanvasModel's 0-indexed convention (cityChooser_0, cityChooser_1) instead of our ad-hoc scheme (cityChooser, cityChooser_2). This eliminates the translation layer in WiringModel and fixes multi-instance binding resolution. Move auto-title computation from widget content models (BaseWeatherWidgetModel.getAutoTitle) into WeatherV2DashModel. Content models are only created when React renders a widget, so lazily-rendered widgets never got titles. The DashModel reaction operates on DashViewModels which always exist regardless of render state. Also: disable ViewManager auto-save, add chat placeholder, add LLM activity tracking. Co-Authored-By: Claude Opus 4.6 --- client-app/src/examples/weatherv2/AppModel.ts | 3 +- .../weatherv2/dash/WeatherV2DashModel.ts | 66 ++++++++++++++++--- .../examples/weatherv2/dash/WiringModel.ts | 14 +--- .../examples/weatherv2/dash/exampleSpecs.ts | 46 ++++++------- .../src/examples/weatherv2/dash/validation.ts | 10 +-- .../weatherv2/harness/ChatHarnessPanel.ts | 7 +- .../examples/weatherv2/svc/LlmChatService.ts | 15 +++-- .../widgets/BaseWeatherWidgetModel.ts | 21 ------ .../widgets/CurrentConditionsWidget.ts | 4 -- .../weatherv2/widgets/ForecastChartWidget.ts | 4 -- .../widgets/MarkdownContentWidget.ts | 4 -- .../weatherv2/widgets/PrecipChartWidget.ts | 4 -- .../weatherv2/widgets/SummaryGridWidget.ts | 4 -- .../weatherv2/widgets/WindChartWidget.ts | 4 -- 14 files changed, 100 insertions(+), 106 deletions(-) diff --git a/client-app/src/examples/weatherv2/AppModel.ts b/client-app/src/examples/weatherv2/AppModel.ts index 57ddbfa1b..302185a1e 100644 --- a/client-app/src/examples/weatherv2/AppModel.ts +++ b/client-app/src/examples/weatherv2/AppModel.ts @@ -20,7 +20,7 @@ export class AppModel extends BaseAppModel { @managed weatherViewManager: ViewManagerModel; @managed harnessPanelModel: PanelModel; @persist @bindable showJsonHarness: boolean = false; - @persist @bindable showChatHarness: boolean = false; + @persist @bindable showChatHarness: boolean = true; constructor() { super(); @@ -44,6 +44,7 @@ export class AppModel extends BaseAppModel { type: 'weatherDashboardV2', typeDisplayName: 'Layout', enableDefault: true, + enableAutoSave: false, manageGlobal: XH.getUser().isHoistAdmin }); diff --git a/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts b/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts index 9ebbeb8b5..449906a52 100644 --- a/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts +++ b/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts @@ -1,6 +1,6 @@ import {HoistModel, managed} from '@xh/hoist/core'; import {ViewManagerModel} from '@xh/hoist/cmp/viewmanager'; -import {DashCanvasModel} from '@xh/hoist/desktop/cmp/dash'; +import {DashCanvasModel, DashViewModel} from '@xh/hoist/desktop/cmp/dash'; import {Icon} from '@xh/hoist/icon'; import {makeObservable} from '@xh/hoist/mobx'; import {WiringModel} from './WiringModel'; @@ -145,8 +145,8 @@ export class WeatherV2DashModel extends HoistModel { layout: {x: 3, y: 0, w: 4, h: 5}, state: { bindings: { - city: {fromWidget: 'cityChooser', output: 'selectedCity'}, - units: {fromWidget: 'unitsToggle', output: 'units'} + city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} } } }, @@ -155,8 +155,8 @@ export class WeatherV2DashModel extends HoistModel { layout: {x: 7, y: 0, w: 5, h: 5}, state: { bindings: { - city: {fromWidget: 'cityChooser', output: 'selectedCity'}, - units: {fromWidget: 'unitsToggle', output: 'units'} + city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} }, series: ['temp', 'feelsLike'], chartType: 'line' @@ -167,7 +167,7 @@ export class WeatherV2DashModel extends HoistModel { layout: {x: 0, y: 5, w: 6, h: 5}, state: { bindings: { - city: {fromWidget: 'cityChooser', output: 'selectedCity'} + city: {fromWidget: 'cityChooser_0', output: 'selectedCity'} } } }, @@ -176,8 +176,8 @@ export class WeatherV2DashModel extends HoistModel { layout: {x: 6, y: 5, w: 6, h: 5}, state: { bindings: { - city: {fromWidget: 'cityChooser', output: 'selectedCity'}, - units: {fromWidget: 'unitsToggle', output: 'units'} + city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} } } }, @@ -186,12 +186,58 @@ export class WeatherV2DashModel extends HoistModel { layout: {x: 0, y: 10, w: 12, h: 5}, state: { bindings: { - city: {fromWidget: 'cityChooser', output: 'selectedCity'}, - units: {fromWidget: 'unitsToggle', output: 'units'} + city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} } } } ] }); + + // Auto-title: reactively set titles on display widgets from their bound city. + // Runs at this level (not in content models) because DashCanvas may lazily render + // widget content — DashViewModels always exist regardless of render state. + this.addReaction({ + track: () => this.dashCanvasModel.viewModels.map(vm => this.computeAutoTitle(vm)), + run: titles => { + this.dashCanvasModel.viewModels.forEach((vm, i) => { + if (titles[i] != null) vm.title = titles[i]; + }); + }, + fireImmediately: true + }); + } + + //-------------------------------------------------- + // Auto-Title + //-------------------------------------------------- + + /** Compute an auto-generated title for a widget, or null to leave as-is. */ + private computeAutoTitle(vm: DashViewModel): string | null { + const specId = vm.viewSpec.id; + + // Markdown widget: title comes from its persisted state + if (specId === 'markdownContent') { + return vm.viewState?.title ?? 'Markdown Content'; + } + + // Display widgets: title = prefix + bound city + const titlePrefix = DISPLAY_WIDGET_TITLES[specId]; + if (!titlePrefix) return null; + + const cityBinding = vm.viewState?.bindings?.city; + if (!cityBinding) return titlePrefix; + + const city = this.wiringModel.resolveBinding(cityBinding); + return city ? `${titlePrefix} — ${city}` : titlePrefix; } } + +/** Display widgets that get auto-generated titles with city context. */ +const DISPLAY_WIDGET_TITLES: Record = { + currentConditions: 'Current Conditions', + forecastChart: 'Forecast', + precipChart: 'Precipitation', + windChart: 'Wind', + summaryGrid: '5-Day Summary' +}; diff --git a/client-app/src/examples/weatherv2/dash/WiringModel.ts b/client-app/src/examples/weatherv2/dash/WiringModel.ts index 3738dbe25..c05a4e094 100644 --- a/client-app/src/examples/weatherv2/dash/WiringModel.ts +++ b/client-app/src/examples/weatherv2/dash/WiringModel.ts @@ -35,19 +35,7 @@ export class WiringModel extends HoistModel { if ('const' in binding) return binding.const; if ('fromWidget' in binding) { const {fromWidget, output} = binding; - // Try exact match first, then prefix match — DashCanvasModel assigns - // instance IDs like "cityChooser_0" while specs reference "cityChooser". - const widgetOutputs = - this._outputs.get(fromWidget) ?? this.findOutputsByPrefix(fromWidget); - return widgetOutputs?.get(output); - } - return undefined; - } - - /** Find outputs for a widget by viewSpecId prefix (e.g. "cityChooser" → "cityChooser_0"). */ - private findOutputsByPrefix(prefix: string): Map | undefined { - for (const [key, value] of this._outputs) { - if (key.startsWith(prefix + '_')) return value; + return this._outputs.get(fromWidget)?.get(output); } return undefined; } diff --git a/client-app/src/examples/weatherv2/dash/exampleSpecs.ts b/client-app/src/examples/weatherv2/dash/exampleSpecs.ts index 11c3c91f6..cb0d9d5a3 100644 --- a/client-app/src/examples/weatherv2/dash/exampleSpecs.ts +++ b/client-app/src/examples/weatherv2/dash/exampleSpecs.ts @@ -19,14 +19,14 @@ const minimalSpec: DashSpec = { viewSpecId: 'currentConditions', layout: {x: 3, y: 0, w: 4, h: 5}, state: { - bindings: {city: {fromWidget: 'cityChooser', output: 'selectedCity'}} + bindings: {city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}} } }, { viewSpecId: 'forecastChart', layout: {x: 7, y: 0, w: 5, h: 5}, state: { - bindings: {city: {fromWidget: 'cityChooser', output: 'selectedCity'}}, + bindings: {city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}}, series: ['temp'], chartType: 'line' } @@ -53,8 +53,8 @@ const fullSpec: DashSpec = { layout: {x: 3, y: 0, w: 4, h: 5}, state: { bindings: { - city: {fromWidget: 'cityChooser', output: 'selectedCity'}, - units: {fromWidget: 'unitsToggle', output: 'units'} + city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} } } }, @@ -63,8 +63,8 @@ const fullSpec: DashSpec = { layout: {x: 7, y: 0, w: 5, h: 5}, state: { bindings: { - city: {fromWidget: 'cityChooser', output: 'selectedCity'}, - units: {fromWidget: 'unitsToggle', output: 'units'} + city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} }, series: ['temp', 'feelsLike'], chartType: 'line' @@ -74,7 +74,7 @@ const fullSpec: DashSpec = { viewSpecId: 'precipChart', layout: {x: 0, y: 5, w: 6, h: 5}, state: { - bindings: {city: {fromWidget: 'cityChooser', output: 'selectedCity'}} + bindings: {city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}} } }, { @@ -82,8 +82,8 @@ const fullSpec: DashSpec = { layout: {x: 6, y: 5, w: 6, h: 5}, state: { bindings: { - city: {fromWidget: 'cityChooser', output: 'selectedCity'}, - units: {fromWidget: 'unitsToggle', output: 'units'} + city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} }, showGusts: true } @@ -93,8 +93,8 @@ const fullSpec: DashSpec = { layout: {x: 0, y: 10, w: 12, h: 5}, state: { bindings: { - city: {fromWidget: 'cityChooser', output: 'selectedCity'}, - units: {fromWidget: 'unitsToggle', output: 'units'} + city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} } } } @@ -127,8 +127,8 @@ const comparisonSpec: DashSpec = { layout: {x: 0, y: 2, w: 6, h: 5}, state: { bindings: { - city: {fromWidget: 'cityChooser', output: 'selectedCity'}, - units: {fromWidget: 'unitsToggle', output: 'units'} + city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} }, series: ['temp'], chartType: 'line' @@ -139,8 +139,8 @@ const comparisonSpec: DashSpec = { layout: {x: 6, y: 2, w: 6, h: 5}, state: { bindings: { - city: {fromWidget: 'cityChooser_2', output: 'selectedCity'}, - units: {fromWidget: 'unitsToggle', output: 'units'} + city: {fromWidget: 'cityChooser_1', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} }, series: ['temp'], chartType: 'line' @@ -151,8 +151,8 @@ const comparisonSpec: DashSpec = { layout: {x: 0, y: 7, w: 6, h: 5}, state: { bindings: { - city: {fromWidget: 'cityChooser', output: 'selectedCity'}, - units: {fromWidget: 'unitsToggle', output: 'units'} + city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} } } }, @@ -161,8 +161,8 @@ const comparisonSpec: DashSpec = { layout: {x: 6, y: 7, w: 6, h: 5}, state: { bindings: { - city: {fromWidget: 'cityChooser_2', output: 'selectedCity'}, - units: {fromWidget: 'unitsToggle', output: 'units'} + city: {fromWidget: 'cityChooser_1', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} } } } @@ -197,8 +197,8 @@ const annotatedSpec: DashSpec = { layout: {x: 6, y: 2, w: 6, h: 5}, state: { bindings: { - city: {fromWidget: 'cityChooser', output: 'selectedCity'}, - units: {fromWidget: 'unitsToggle', output: 'units'} + city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} }, series: ['temp', 'humidity'], chartType: 'area' @@ -209,8 +209,8 @@ const annotatedSpec: DashSpec = { layout: {x: 0, y: 4, w: 6, h: 5}, state: { bindings: { - city: {fromWidget: 'cityChooser', output: 'selectedCity'}, - units: {fromWidget: 'unitsToggle', output: 'units'} + city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} } } }, diff --git a/client-app/src/examples/weatherv2/dash/validation.ts b/client-app/src/examples/weatherv2/dash/validation.ts index e22c8a461..e60b2cb14 100644 --- a/client-app/src/examples/weatherv2/dash/validation.ts +++ b/client-app/src/examples/weatherv2/dash/validation.ts @@ -312,15 +312,15 @@ function validateReferential( } /** - * Compute widget instance IDs matching DashCanvasModel's assignment: - * first instance of type X → "X", second → "X_2", etc. + * Compute widget instance IDs matching DashCanvasModel's 0-indexed assignment: + * first instance of type X → "X_0", second → "X_1", etc. */ function computeInstanceIds(state: DashWidgetState[]): string[] { const counts = new Map(); return state.map(widget => { - const count = (counts.get(widget.viewSpecId) ?? 0) + 1; - counts.set(widget.viewSpecId, count); - return count === 1 ? widget.viewSpecId : `${widget.viewSpecId}_${count}`; + const idx = counts.get(widget.viewSpecId) ?? 0; + counts.set(widget.viewSpecId, idx + 1); + return `${widget.viewSpecId}_${idx}`; }); } diff --git a/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts index 3838d8f36..afbb59153 100644 --- a/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts +++ b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts @@ -1,5 +1,5 @@ import {creates, hoistCmp} from '@xh/hoist/core'; -import {div, filler, vbox} from '@xh/hoist/cmp/layout'; +import {div, filler, placeholder, vbox} from '@xh/hoist/cmp/layout'; import {panel} from '@xh/hoist/desktop/cmp/panel'; import {toolbar} from '@xh/hoist/desktop/cmp/toolbar'; import {button} from '@xh/hoist/desktop/cmp/button'; @@ -37,10 +37,7 @@ const messageList = hoistCmp.factory({ const {messages} = model; if (messages.length === 0) { - return div({ - className: 'weather-v2-chat-empty', - item: 'Ask the LLM to create or modify your dashboard. Try: "Create a weather dashboard for New York and London side by side."' - }); + return placeholder(sparklesIcon(), 'Ask the LLM to create or modify your dashboard.'); } return div({ diff --git a/client-app/src/examples/weatherv2/svc/LlmChatService.ts b/client-app/src/examples/weatherv2/svc/LlmChatService.ts index 7b69d4e75..a50b9fce3 100644 --- a/client-app/src/examples/weatherv2/svc/LlmChatService.ts +++ b/client-app/src/examples/weatherv2/svc/LlmChatService.ts @@ -45,7 +45,14 @@ export class LlmChatService extends HoistService { ): Promise<{content: string; raw: any}> { const response = await XH.postJson({ url: 'llm/generate', - body: {systemPrompt, messages} + body: {systemPrompt, messages}, + track: { + category: 'WeatherV2', + message: 'LLM generate', + severity: 'DEBUG', + data: {messageCount: messages.length}, + logData: true + } }); // Anthropic API returns {content: [{type: 'text', text: '...'}], ...} @@ -129,15 +136,15 @@ const WIRING_RULES = `## Wiring Rules Widgets communicate via bindings. An input widget publishes outputs; display widgets consume them via bindings. -**Instance ID assignment:** Widget instance IDs are assigned by order of appearance in the \`state\` array. The first instance of type X gets ID "X", the second gets "X_2", the third "X_3", etc. Use these IDs in binding references. +**Instance ID assignment:** Widget instance IDs are assigned by order of appearance in the \`state\` array using 0-indexed suffixes: first instance of type X gets ID "X_0", second gets "X_1", third "X_2", etc. Use these IDs in binding references. **Binding format:** - Wire to another widget: \`{"fromWidget": "instanceId", "output": "outputName"}\` - Constant value: \`{"const": "value"}\` **Common wiring patterns:** -- CityChooser publishes \`selectedCity\` → display widgets bind their \`city\` input to it. -- UnitsToggle publishes \`units\` → display widgets bind their \`units\` input to it.`; +- CityChooser publishes \`selectedCity\` → display widgets bind their \`city\` input to it, e.g. \`{"fromWidget": "cityChooser_0", "output": "selectedCity"}\`. +- UnitsToggle publishes \`units\` → display widgets bind their \`units\` input to it, e.g. \`{"fromWidget": "unitsToggle_0", "output": "units"}\`.`; const LAYOUT_RULES = `## Layout Rules diff --git a/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts b/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts index 4a8afdd6a..ac1cb5df1 100644 --- a/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts +++ b/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts @@ -25,27 +25,6 @@ export abstract class BaseWeatherWidgetModel extends HoistModel { override onLinked() { super.onLinked(); this.persistWith = {dashViewModel: this.viewModel}; - - // Auto-title: subclasses override getAutoTitle() to provide reactive titles - this.addReaction({ - track: () => this.getAutoTitle(), - run: title => { - if (title != null) this.viewModel.title = title; - }, - fireImmediately: true - }); - } - - //-------------------------------------------------- - // Auto-Title - //-------------------------------------------------- - - /** - * Override in subclasses to provide a reactive auto-generated title. - * Returning null keeps the default viewSpec title unchanged. - */ - protected getAutoTitle(): string | null { - return null; } //-------------------------------------------------- diff --git a/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts index 4f9495d9c..2d8542da5 100644 --- a/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts @@ -82,10 +82,6 @@ export class CurrentConditionsModel extends BaseWeatherWidgetModel { return this.viewModel.viewState?.showWind ?? true; } - protected override getAutoTitle(): string { - return `Current Conditions — ${this.city}`; - } - override onLinked() { super.onLinked(); diff --git a/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts index 10041e41c..50dc64cab 100644 --- a/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts @@ -82,10 +82,6 @@ export class ForecastChartModel extends BaseWeatherWidgetModel { return this.viewModel.viewState?.showLegend ?? true; } - protected override getAutoTitle(): string { - return `Forecast — ${this.city}`; - } - override onLinked() { super.onLinked(); this.chartModel = this.createChartModel(); diff --git a/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts b/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts index b6f07cb4a..b6cd0a05c 100644 --- a/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts @@ -48,10 +48,6 @@ export class MarkdownContentModel extends BaseWeatherWidgetModel { this.markPersist('content'); this.markPersist('widgetTitle', {path: 'title'}); } - - protected override getAutoTitle(): string { - return this.widgetTitle; - } } widgetRegistry.register(MarkdownContentModel.meta); diff --git a/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts index 1b396a2a4..6be8ed933 100644 --- a/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts @@ -63,10 +63,6 @@ export class PrecipChartModel extends BaseWeatherWidgetModel { return this.viewModel.viewState?.metric ?? 'both'; } - protected override getAutoTitle(): string { - return `Precipitation — ${this.city}`; - } - override onLinked() { super.onLinked(); this.chartModel = this.createChartModel(); diff --git a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts index 6cd96331f..8bfef1d04 100644 --- a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts @@ -63,10 +63,6 @@ export class SummaryGridModel extends BaseWeatherWidgetModel { return this.resolveInput('units') ?? 'imperial'; } - protected override getAutoTitle(): string { - return `5-Day Summary — ${this.city}`; - } - override onLinked() { super.onLinked(); this.gridModel = this.createGridModel(); diff --git a/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts index aca15ce2b..b02506b7c 100644 --- a/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts @@ -75,10 +75,6 @@ export class WindChartModel extends BaseWeatherWidgetModel { return this.viewModel.viewState?.chartType ?? 'line'; } - protected override getAutoTitle(): string { - return `Wind — ${this.city}`; - } - override onLinked() { super.onLinked(); this.chartModel = this.createChartModel(); From 87e1e572638451a04317399a82635b93ebd4ffb8 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sat, 28 Feb 2026 16:20:48 -0800 Subject: [PATCH 22/41] Dashboard UX overhaul, LLM chat agent, and robust widget title resolution Rename chat panel to "Dashboard Agent" with thinking bubble, typewriter effect for responses, and clickable suggestion chips in the empty state. Add a widget chooser panel using DashCanvasWidgetChooser. Move chat Clear button to panel header with reset icon. Reduce row height to 30px for finer layout control and update all widget sizes, viewSpecs, and example specs accordingly. Style units toggle with primary outlined buttons that flex to fill. Switch summary grid to managed autosize mode. Fix widget auto-titles failing for LLM-generated specs: resolveInput now falls back to direct state values before meta defaults, and computeAutoTitle checks viewState.city when no binding exists. Strengthen system prompt to require const bindings for fixed-city widgets. Expand curated cities list and enable free-form city entry. Co-Authored-By: Claude Opus 4.6 --- .../src/examples/weatherv2/AppComponent.ts | 31 +++- client-app/src/examples/weatherv2/AppModel.ts | 1 + .../src/examples/weatherv2/WeatherV2.scss | 110 ++++++++++++- .../weatherv2/dash/WeatherV2DashModel.ts | 55 ++++--- .../examples/weatherv2/dash/exampleSpecs.ts | 49 +++--- .../weatherv2/harness/ChatHarnessModel.ts | 113 +++++++++++-- .../weatherv2/harness/ChatHarnessPanel.ts | 149 ++++++++++++++---- .../examples/weatherv2/svc/LlmChatService.ts | 36 ++++- .../widgets/BaseWeatherWidgetModel.ts | 11 +- .../weatherv2/widgets/CityChooserWidget.ts | 33 +++- .../widgets/CurrentConditionsWidget.ts | 4 +- .../weatherv2/widgets/DashInspectorWidget.ts | 4 +- .../weatherv2/widgets/ForecastChartWidget.ts | 4 +- .../widgets/MarkdownContentWidget.ts | 4 +- .../weatherv2/widgets/PrecipChartWidget.ts | 4 +- .../weatherv2/widgets/SummaryGridWidget.ts | 9 +- .../weatherv2/widgets/UnitsToggleWidget.ts | 13 +- .../weatherv2/widgets/WindChartWidget.ts | 4 +- 18 files changed, 502 insertions(+), 132 deletions(-) diff --git a/client-app/src/examples/weatherv2/AppComponent.ts b/client-app/src/examples/weatherv2/AppComponent.ts index 6815bfb28..bbd08a570 100644 --- a/client-app/src/examples/weatherv2/AppComponent.ts +++ b/client-app/src/examples/weatherv2/AppComponent.ts @@ -3,7 +3,7 @@ import {frame, hframe} from '@xh/hoist/cmp/layout'; import {appBar, appBarSeparator} from '@xh/hoist/desktop/cmp/appbar'; import {button} from '@xh/hoist/desktop/cmp/button'; import {panel} from '@xh/hoist/desktop/cmp/panel'; -import {dashCanvas} from '@xh/hoist/desktop/cmp/dash'; +import {dashCanvas, dashCanvasWidgetChooser} from '@xh/hoist/desktop/cmp/dash'; import {viewManager} from '@xh/hoist/desktop/cmp/viewmanager'; import {Icon} from '@xh/hoist/icon'; import {sparklesIcon} from './Icons'; @@ -18,8 +18,8 @@ export const AppComponent = hoistCmp({ model: uses(AppModel), render({model}) { - const {weatherV2DashModel, showJsonHarness, showChatHarness} = model, - showHarness = showChatHarness || showJsonHarness; + const {weatherV2DashModel, showJsonHarness, showChatHarness, showWidgetChooser} = model, + showHarness = showChatHarness || showJsonHarness || showWidgetChooser; return panel({ tbar: appBar({ @@ -30,7 +30,7 @@ export const AppComponent = hoistCmp({ button({ testId: 'chat-btn', icon: sparklesIcon(), - text: 'Chat', + text: 'Dashboard Agent', active: showChatHarness, outlined: true, intent: 'primary', @@ -45,6 +45,15 @@ export const AppComponent = hoistCmp({ intent: 'primary', onClick: () => (model.showJsonHarness = !showJsonHarness) }), + button({ + testId: 'widget-chooser-btn', + icon: Icon.boxFull(), + text: 'Widgets', + active: showWidgetChooser, + outlined: true, + intent: 'primary', + onClick: () => (model.showWidgetChooser = !showWidgetChooser) + }), appBarSeparator() ], appMenuButtonProps: {hideLogoutItem: false} @@ -55,7 +64,19 @@ export const AppComponent = hoistCmp({ model: model.harnessPanelModel, items: [ showChatHarness ? chatHarnessPanel({flex: 1}) : null, - showJsonHarness ? jsonHarnessPanel({flex: 1}) : null + showJsonHarness ? jsonHarnessPanel({flex: 1}) : null, + showWidgetChooser + ? panel({ + testId: 'widget-chooser-panel', + title: 'Widget Chooser', + icon: Icon.boxFull(), + compactHeader: true, + flex: 1, + item: dashCanvasWidgetChooser({ + dashCanvasModel: weatherV2DashModel.dashCanvasModel + }) + }) + : null ], omit: !showHarness }) diff --git a/client-app/src/examples/weatherv2/AppModel.ts b/client-app/src/examples/weatherv2/AppModel.ts index 302185a1e..1f385a0d5 100644 --- a/client-app/src/examples/weatherv2/AppModel.ts +++ b/client-app/src/examples/weatherv2/AppModel.ts @@ -21,6 +21,7 @@ export class AppModel extends BaseAppModel { @managed harnessPanelModel: PanelModel; @persist @bindable showJsonHarness: boolean = false; @persist @bindable showChatHarness: boolean = true; + @persist @bindable showWidgetChooser: boolean = false; constructor() { super(); diff --git a/client-app/src/examples/weatherv2/WeatherV2.scss b/client-app/src/examples/weatherv2/WeatherV2.scss index 828ed7d1b..502affd23 100644 --- a/client-app/src/examples/weatherv2/WeatherV2.scss +++ b/client-app/src/examples/weatherv2/WeatherV2.scss @@ -213,18 +213,66 @@ } } -// Chat harness +// Chat harness — empty state .weather-v2-chat-empty { flex: 1; display: flex; + flex-direction: column; align-items: center; justify-content: center; padding: 20px; - text-align: center; - color: var(--xh-text-color-muted); - font-style: italic; + gap: 20px; + + &__intro { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + color: var(--xh-text-color-muted); + } + + &__text { + font-size: var(--xh-font-size-px); + text-align: center; + max-width: 320px; + line-height: 1.5; + } + + &__suggestions { + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; + max-width: 360px; + } + + &__suggestions-label { + font-size: var(--xh-font-size-small-px); + color: var(--xh-text-color-muted); + font-weight: 600; + margin-bottom: 2px; + } +} + +.weather-v2-chat-suggestion { + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--xh-border-color); + background: var(--xh-bg-alt); + font-size: var(--xh-font-size-small-px); + line-height: 1.4; + cursor: pointer; + transition: + background 0.15s ease, + border-color 0.15s ease; + + &:hover { + background: var(--xh-bg-highlight); + border-color: var(--xh-blue-muted); + } } +// Chat harness — message list .weather-v2-chat-messages { flex: 1; overflow-y: auto; @@ -261,9 +309,63 @@ overflow-wrap: break-word; font-size: var(--xh-font-size-px); line-height: 1.4; + + &--typing::after { + content: '\25AE'; + animation: blink-cursor 0.6s step-end infinite; + color: var(--xh-text-color-muted); + margin-left: 1px; + } + } +} + +// Thinking dots animation +.weather-v2-thinking-dots { + display: flex; + gap: 4px; + padding: 4px 0; +} + +.weather-v2-thinking-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--xh-text-color-muted); + animation: thinking-bounce 1.2s ease-in-out infinite; + + &:nth-child(2) { + animation-delay: 0.2s; + } + + &:nth-child(3) { + animation-delay: 0.4s; + } +} + +@keyframes thinking-bounce { + 0%, + 60%, + 100% { + transform: translateY(0); + opacity: 0.4; + } + 30% { + transform: translateY(-4px); + opacity: 1; + } +} + +@keyframes blink-cursor { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; } } +// Chat harness — input .weather-v2-chat-input { display: flex; gap: 8px; diff --git a/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts b/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts index 449906a52..ca28435d3 100644 --- a/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts +++ b/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts @@ -37,6 +37,7 @@ export class WeatherV2DashModel extends HoistModel { this.dashCanvasModel = new DashCanvasModel({ persistWith: {viewManagerModel}, + rowHeight: 30, viewSpecs: [ { id: 'cityChooser', @@ -46,7 +47,7 @@ export class WeatherV2DashModel extends HoistModel { unique: false, allowRename: false, width: 3, - height: 2 + height: 3 }, { id: 'currentConditions', @@ -56,7 +57,7 @@ export class WeatherV2DashModel extends HoistModel { unique: false, allowRename: false, width: 4, - height: 5 + height: 8 }, { id: 'forecastChart', @@ -66,7 +67,7 @@ export class WeatherV2DashModel extends HoistModel { unique: false, allowRename: false, width: 8, - height: 5 + height: 8 }, { id: 'precipChart', @@ -76,7 +77,7 @@ export class WeatherV2DashModel extends HoistModel { unique: false, allowRename: false, width: 6, - height: 5 + height: 8 }, { id: 'summaryGrid', @@ -86,7 +87,7 @@ export class WeatherV2DashModel extends HoistModel { unique: false, allowRename: false, width: 6, - height: 5 + height: 8 }, { id: 'unitsToggle', @@ -96,7 +97,7 @@ export class WeatherV2DashModel extends HoistModel { unique: false, allowRename: false, width: 3, - height: 2 + height: 3 }, { id: 'windChart', @@ -106,7 +107,7 @@ export class WeatherV2DashModel extends HoistModel { unique: false, allowRename: false, width: 6, - height: 5 + height: 8 }, { id: 'markdownContent', @@ -116,7 +117,7 @@ export class WeatherV2DashModel extends HoistModel { unique: false, allowRename: false, width: 4, - height: 3 + height: 5 }, { id: 'dashInspector', @@ -126,23 +127,23 @@ export class WeatherV2DashModel extends HoistModel { unique: true, allowRename: false, width: 6, - height: 5 + height: 8 } ], initialState: [ { viewSpecId: 'cityChooser', - layout: {x: 0, y: 0, w: 3, h: 2}, + layout: {x: 0, y: 0, w: 3, h: 3}, state: {selectedCity: 'New York'} }, { viewSpecId: 'unitsToggle', - layout: {x: 0, y: 2, w: 3, h: 2}, + layout: {x: 0, y: 3, w: 3, h: 3}, state: {units: 'imperial'} }, { viewSpecId: 'currentConditions', - layout: {x: 3, y: 0, w: 4, h: 5}, + layout: {x: 3, y: 0, w: 4, h: 8}, state: { bindings: { city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, @@ -152,7 +153,7 @@ export class WeatherV2DashModel extends HoistModel { }, { viewSpecId: 'forecastChart', - layout: {x: 7, y: 0, w: 5, h: 5}, + layout: {x: 7, y: 0, w: 5, h: 8}, state: { bindings: { city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, @@ -164,7 +165,7 @@ export class WeatherV2DashModel extends HoistModel { }, { viewSpecId: 'precipChart', - layout: {x: 0, y: 5, w: 6, h: 5}, + layout: {x: 0, y: 8, w: 6, h: 8}, state: { bindings: { city: {fromWidget: 'cityChooser_0', output: 'selectedCity'} @@ -173,7 +174,7 @@ export class WeatherV2DashModel extends HoistModel { }, { viewSpecId: 'windChart', - layout: {x: 6, y: 5, w: 6, h: 5}, + layout: {x: 6, y: 8, w: 6, h: 8}, state: { bindings: { city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, @@ -183,7 +184,7 @@ export class WeatherV2DashModel extends HoistModel { }, { viewSpecId: 'summaryGrid', - layout: {x: 0, y: 10, w: 12, h: 5}, + layout: {x: 0, y: 16, w: 12, h: 8}, state: { bindings: { city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, @@ -197,6 +198,8 @@ export class WeatherV2DashModel extends HoistModel { // Auto-title: reactively set titles on display widgets from their bound city. // Runs at this level (not in content models) because DashCanvas may lazily render // widget content — DashViewModels always exist regardless of render state. + // Delay avoids setting titles synchronously during the render cycle that fires + // when a new spec is applied (widget onLinked → publishOutput → this reaction). this.addReaction({ track: () => this.dashCanvasModel.viewModels.map(vm => this.computeAutoTitle(vm)), run: titles => { @@ -204,7 +207,8 @@ export class WeatherV2DashModel extends HoistModel { if (titles[i] != null) vm.title = titles[i]; }); }, - fireImmediately: true + fireImmediately: true, + delay: 1 }); } @@ -221,18 +225,29 @@ export class WeatherV2DashModel extends HoistModel { return vm.viewState?.title ?? 'Markdown Content'; } - // Display widgets: title = prefix + bound city + // Input widgets: static titles (always enforced so LLM-generated specs can't blank them) + const staticTitle = STATIC_WIDGET_TITLES[specId]; + if (staticTitle) return staticTitle; + + // Display widgets: title = prefix + city (from binding or direct state) const titlePrefix = DISPLAY_WIDGET_TITLES[specId]; if (!titlePrefix) return null; const cityBinding = vm.viewState?.bindings?.city; - if (!cityBinding) return titlePrefix; + const city = cityBinding + ? this.wiringModel.resolveBinding(cityBinding) + : vm.viewState?.city; - const city = this.wiringModel.resolveBinding(cityBinding); return city ? `${titlePrefix} — ${city}` : titlePrefix; } } +/** Input/utility widgets that always get a fixed title. */ +const STATIC_WIDGET_TITLES: Record = { + cityChooser: 'City', + unitsToggle: 'Units' +}; + /** Display widgets that get auto-generated titles with city context. */ const DISPLAY_WIDGET_TITLES: Record = { currentConditions: 'Current Conditions', diff --git a/client-app/src/examples/weatherv2/dash/exampleSpecs.ts b/client-app/src/examples/weatherv2/dash/exampleSpecs.ts index cb0d9d5a3..d3f7c4ff1 100644 --- a/client-app/src/examples/weatherv2/dash/exampleSpecs.ts +++ b/client-app/src/examples/weatherv2/dash/exampleSpecs.ts @@ -12,19 +12,19 @@ const minimalSpec: DashSpec = { state: [ { viewSpecId: 'cityChooser', - layout: {x: 0, y: 0, w: 3, h: 2}, + layout: {x: 0, y: 0, w: 3, h: 3}, state: {selectedCity: 'New York'} }, { viewSpecId: 'currentConditions', - layout: {x: 3, y: 0, w: 4, h: 5}, + layout: {x: 3, y: 0, w: 4, h: 8}, state: { bindings: {city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}} } }, { viewSpecId: 'forecastChart', - layout: {x: 7, y: 0, w: 5, h: 5}, + layout: {x: 7, y: 0, w: 5, h: 8}, state: { bindings: {city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}}, series: ['temp'], @@ -40,17 +40,17 @@ const fullSpec: DashSpec = { state: [ { viewSpecId: 'cityChooser', - layout: {x: 0, y: 0, w: 3, h: 2}, + layout: {x: 0, y: 0, w: 3, h: 3}, state: {selectedCity: 'New York'} }, { viewSpecId: 'unitsToggle', - layout: {x: 0, y: 2, w: 3, h: 2}, + layout: {x: 0, y: 3, w: 3, h: 3}, state: {units: 'imperial'} }, { viewSpecId: 'currentConditions', - layout: {x: 3, y: 0, w: 4, h: 5}, + layout: {x: 3, y: 0, w: 4, h: 8}, state: { bindings: { city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, @@ -60,7 +60,7 @@ const fullSpec: DashSpec = { }, { viewSpecId: 'forecastChart', - layout: {x: 7, y: 0, w: 5, h: 5}, + layout: {x: 7, y: 0, w: 5, h: 8}, state: { bindings: { city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, @@ -72,14 +72,14 @@ const fullSpec: DashSpec = { }, { viewSpecId: 'precipChart', - layout: {x: 0, y: 5, w: 6, h: 5}, + layout: {x: 0, y: 8, w: 6, h: 8}, state: { bindings: {city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}} } }, { viewSpecId: 'windChart', - layout: {x: 6, y: 5, w: 6, h: 5}, + layout: {x: 6, y: 8, w: 6, h: 8}, state: { bindings: { city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, @@ -90,7 +90,7 @@ const fullSpec: DashSpec = { }, { viewSpecId: 'summaryGrid', - layout: {x: 0, y: 10, w: 12, h: 5}, + layout: {x: 0, y: 16, w: 12, h: 8}, state: { bindings: { city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, @@ -107,24 +107,22 @@ const comparisonSpec: DashSpec = { state: [ { viewSpecId: 'cityChooser', - layout: {x: 0, y: 0, w: 3, h: 2}, - title: 'City A', + layout: {x: 0, y: 0, w: 3, h: 3}, state: {selectedCity: 'New York'} }, { viewSpecId: 'cityChooser', - layout: {x: 6, y: 0, w: 3, h: 2}, - title: 'City B', + layout: {x: 6, y: 0, w: 3, h: 3}, state: {selectedCity: 'London'} }, { viewSpecId: 'unitsToggle', - layout: {x: 3, y: 0, w: 3, h: 2}, + layout: {x: 3, y: 0, w: 3, h: 3}, state: {units: 'imperial'} }, { viewSpecId: 'forecastChart', - layout: {x: 0, y: 2, w: 6, h: 5}, + layout: {x: 0, y: 3, w: 6, h: 8}, state: { bindings: { city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, @@ -136,7 +134,7 @@ const comparisonSpec: DashSpec = { }, { viewSpecId: 'forecastChart', - layout: {x: 6, y: 2, w: 6, h: 5}, + layout: {x: 6, y: 3, w: 6, h: 8}, state: { bindings: { city: {fromWidget: 'cityChooser_1', output: 'selectedCity'}, @@ -148,7 +146,7 @@ const comparisonSpec: DashSpec = { }, { viewSpecId: 'currentConditions', - layout: {x: 0, y: 7, w: 6, h: 5}, + layout: {x: 0, y: 11, w: 6, h: 8}, state: { bindings: { city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, @@ -158,7 +156,7 @@ const comparisonSpec: DashSpec = { }, { viewSpecId: 'currentConditions', - layout: {x: 6, y: 7, w: 6, h: 5}, + layout: {x: 6, y: 11, w: 6, h: 8}, state: { bindings: { city: {fromWidget: 'cityChooser_1', output: 'selectedCity'}, @@ -175,7 +173,7 @@ const annotatedSpec: DashSpec = { state: [ { viewSpecId: 'markdownContent', - layout: {x: 0, y: 0, w: 12, h: 2}, + layout: {x: 0, y: 0, w: 12, h: 3}, state: { title: 'Dashboard Guide', content: @@ -184,17 +182,17 @@ const annotatedSpec: DashSpec = { }, { viewSpecId: 'cityChooser', - layout: {x: 0, y: 2, w: 3, h: 2}, + layout: {x: 0, y: 3, w: 3, h: 3}, state: {selectedCity: 'Tokyo'} }, { viewSpecId: 'unitsToggle', - layout: {x: 3, y: 2, w: 3, h: 2}, + layout: {x: 3, y: 3, w: 3, h: 3}, state: {units: 'metric'} }, { viewSpecId: 'forecastChart', - layout: {x: 6, y: 2, w: 6, h: 5}, + layout: {x: 6, y: 3, w: 6, h: 8}, state: { bindings: { city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, @@ -206,7 +204,7 @@ const annotatedSpec: DashSpec = { }, { viewSpecId: 'currentConditions', - layout: {x: 0, y: 4, w: 6, h: 5}, + layout: {x: 0, y: 6, w: 6, h: 8}, state: { bindings: { city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, @@ -216,8 +214,7 @@ const annotatedSpec: DashSpec = { }, { viewSpecId: 'dashInspector', - layout: {x: 0, y: 9, w: 12, h: 4}, - title: 'Wiring Inspector' + layout: {x: 0, y: 14, w: 12, h: 7} } ] }; diff --git a/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts b/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts index b486f8b89..ace1eb42b 100644 --- a/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts +++ b/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts @@ -1,35 +1,77 @@ import {HoistModel, managed, PersistableState, TaskObserver, XH} from '@xh/hoist/core'; -import {action, bindable, makeObservable, observable, runInAction} from '@xh/hoist/mobx'; +import {action, bindable, computed, makeObservable, observable, runInAction} from '@xh/hoist/mobx'; import {DashSpec} from '../dash/types'; import {validateSpec, migrateSpec} from '../dash/validation'; import {ChatMessage} from '../svc/LlmChatService'; import {AppModel} from '../AppModel'; +/** A message for display, including a "thinking" placeholder state. */ +export interface DisplayMessage { + role: 'user' | 'assistant'; + content: string; + thinking?: boolean; +} + /** * Model for the LLM chat harness — manages conversation history, - * LLM API calls, and spec application. + * LLM API calls, spec application, and typewriter display effect. */ export class ChatHarnessModel extends HoistModel { @bindable userInput: string = ''; @observable.ref messages: ChatMessage[] = []; @observable.ref lastError: string = null; + // Typewriter effect state + @observable typingMessageIdx: number = -1; + @observable typingChars: number = 0; + @managed generateTask = TaskObserver.trackLast(); + private _typingTimer: ReturnType = null; + constructor() { super(); makeObservable(this); } - /** Send the current user input to the LLM and process the response. */ + /** Messages to render, including a thinking placeholder while awaiting LLM response. */ + @computed + get displayMessages(): DisplayMessage[] { + const msgs: DisplayMessage[] = this.messages.map(m => ({...m})); + if ( + this.generateTask.isPending && + (msgs.length === 0 || msgs[msgs.length - 1].role === 'user') + ) { + msgs.push({role: 'assistant', content: '', thinking: true}); + } + return msgs; + } + + /** + * Get displayed content for a message, accounting for typewriter effect. + * Call with the formatted (JSON-stripped) content, not the raw LLM text. + */ + getDisplayContent(index: number, formattedContent: string): string { + if (index === this.typingMessageIdx && this.typingChars < formattedContent.length) { + return formattedContent.slice(0, this.typingChars); + } + return formattedContent; + } + + /** Whether the typewriter effect is currently animating. */ + @computed + get isTyping(): boolean { + return this.typingMessageIdx >= 0; + } + + /** Send the current user input (or provided text) to the LLM. */ @action - async sendMessageAsync() { - const {userInput} = this; - if (!userInput.trim() || this.generateTask.isPending) return; + async sendMessageAsync(content?: string) { + const input = content ?? this.userInput; + if (!input.trim() || this.generateTask.isPending) return; - // Add user message - const userMsg: ChatMessage = {role: 'user', content: userInput.trim()}; + const userMsg: ChatMessage = {role: 'user', content: input.trim()}; this.messages = [...this.messages, userMsg]; this.userInput = ''; this.lastError = null; @@ -40,10 +82,16 @@ export class ChatHarnessModel extends HoistModel { /** Clear the conversation. */ @action clearChat() { + this.stopTyping(); this.messages = []; this.lastError = null; } + override destroy() { + this.stopTyping(); + super.destroy(); + } + //------------------ // Implementation //------------------ @@ -58,11 +106,12 @@ export class ChatHarnessModel extends HoistModel { // Call LLM const {content} = await svc.generateAsync(systemPrompt, this.messages); - // Add assistant response and apply any spec — in action since we're post-await + // Add assistant response, apply any spec, and start typewriter runInAction(() => { this.messages = [...this.messages, {role: 'assistant', content}]; const spec = svc.parseSpecFromResponse(content); if (spec) this.applySpec(spec); + this.startTyping(this.messages.length - 1, content); }); } catch (e) { runInAction(() => { @@ -100,4 +149,50 @@ export class ChatHarnessModel extends HoistModel { this.lastError = `Failed to apply spec: ${e.message}`; } } + + //------------------ + // Typewriter + //------------------ + @action + private startTyping(index: number, rawContent: string) { + this.stopTyping(); + const formatted = formatMessageContent(rawContent); + // Skip animation for very short messages + if (formatted.length < 30) return; + + this.typingMessageIdx = index; + this.typingChars = 0; + + // Target ~1.2s total animation; tick every 12ms + const charsPerTick = Math.max(3, Math.ceil(formatted.length / 100)); + this._typingTimer = setInterval(() => { + runInAction(() => { + this.typingChars += charsPerTick; + if (this.typingChars >= formatted.length) { + this.stopTyping(); + } + }); + }, 12); + } + + private stopTyping() { + if (this._typingTimer) { + clearInterval(this._typingTimer); + this._typingTimer = null; + } + if (this.typingMessageIdx >= 0) { + runInAction(() => { + this.typingMessageIdx = -1; + this.typingChars = 0; + }); + } + } +} + +/** + * Format a message content string, stripping JSON code fences for cleaner display. + * Exported for use by both model (typewriter length) and panel (render). + */ +export function formatMessageContent(content: string): string { + return content.replace(/```json[\s\S]*?```/g, '[Dashboard spec applied]').trim(); } diff --git a/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts index afbb59153..cf8588dac 100644 --- a/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts +++ b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts @@ -1,12 +1,11 @@ import {creates, hoistCmp} from '@xh/hoist/core'; -import {div, filler, placeholder, vbox} from '@xh/hoist/cmp/layout'; +import {div, vbox} from '@xh/hoist/cmp/layout'; import {panel} from '@xh/hoist/desktop/cmp/panel'; -import {toolbar} from '@xh/hoist/desktop/cmp/toolbar'; import {button} from '@xh/hoist/desktop/cmp/button'; import {textArea} from '@xh/hoist/desktop/cmp/input'; import {Icon} from '@xh/hoist/icon'; import {sparklesIcon} from '../Icons'; -import {ChatHarnessModel} from './ChatHarnessModel'; +import {ChatHarnessModel, DisplayMessage, formatMessageContent} from './ChatHarnessModel'; export const chatHarnessPanel = hoistCmp.factory({ displayName: 'ChatHarnessPanel', @@ -15,53 +14,137 @@ export const chatHarnessPanel = hoistCmp.factory({ render({model}) { return panel({ testId: 'chat-panel', - title: 'LLM Chat', + title: 'Dashboard Agent', icon: sparklesIcon(), compactHeader: true, - item: vbox({flex: 1, items: [messageList(), errorDisplay(), chatInput()]}), - bbar: toolbar( + headerItems: [ button({ testId: 'chat-clear-btn', - icon: Icon.delete(), - text: 'Clear', + icon: Icon.reset(), + tooltip: 'Clear conversation', onClick: () => model.clearChat() - }), - filler() - ) + }) + ], + item: vbox({flex: 1, items: [messageList(), errorDisplay(), chatInput()]}) }); } }); +//-------------------------------------------------- +// Message List +//-------------------------------------------------- const messageList = hoistCmp.factory({ render({model}) { - const {messages} = model; + const {displayMessages} = model; - if (messages.length === 0) { - return placeholder(sparklesIcon(), 'Ask the LLM to create or modify your dashboard.'); + if (displayMessages.length === 0) { + return emptyState(); } return div({ className: 'weather-v2-chat-messages', - items: messages.map((msg, i) => + items: displayMessages.map((msg, i) => renderBubble(model, msg, i)), + ref: (el: HTMLElement) => { + if (el) el.scrollTop = el.scrollHeight; + } + }); + } +}); + +/** Render a single chat bubble — thinking placeholder, user, or assistant. */ +function renderBubble(model: ChatHarnessModel, msg: DisplayMessage, index: number) { + if (msg.thinking) { + return div({ + key: `thinking-${index}`, + className: + 'weather-v2-chat-msg weather-v2-chat-msg--assistant weather-v2-chat-msg--thinking', + items: [ + div({className: 'weather-v2-chat-msg__role', item: 'Assistant'}), + div({ + className: 'weather-v2-chat-msg__content', + item: div({ + className: 'weather-v2-thinking-dots', + items: [ + div({className: 'weather-v2-thinking-dot'}), + div({className: 'weather-v2-thinking-dot'}), + div({className: 'weather-v2-thinking-dot'}) + ] + }) + }) + ] + }); + } + + const formatted = formatMessageContent(msg.content); + const displayed = model.getDisplayContent(index, formatted); + const isTyping = index === model.typingMessageIdx; + + return div({ + key: index, + className: `weather-v2-chat-msg weather-v2-chat-msg--${msg.role}`, + items: [ + div({ + className: 'weather-v2-chat-msg__role', + item: msg.role === 'user' ? 'You' : 'Assistant' + }), + div({ + className: `weather-v2-chat-msg__content${isTyping ? ' weather-v2-chat-msg__content--typing' : ''}`, + item: displayed + }) + ] + }); +} + +//-------------------------------------------------- +// Empty State with Suggestions +//-------------------------------------------------- +const SUGGESTIONS = [ + 'Compare weather in New York and Tokyo side by side', + 'Show current conditions for 16 major cities in a 4x4 grid', + 'Build a compact 3-city overview dashboard', + 'Simplify to just current conditions and the 5-day forecast', + 'Add a markdown welcome banner at the top of the dashboard' +]; + +const emptyState = hoistCmp.factory({ + render({model}) { + return div({ + className: 'weather-v2-chat-empty', + items: [ div({ - key: i, - className: `weather-v2-chat-msg weather-v2-chat-msg--${msg.role}`, + className: 'weather-v2-chat-empty__intro', items: [ + sparklesIcon({size: '2x'}), div({ - className: 'weather-v2-chat-msg__role', - item: msg.role === 'user' ? 'You' : 'Assistant' - }), - div({ - className: 'weather-v2-chat-msg__content', - item: formatMessageContent(msg.content) + className: 'weather-v2-chat-empty__text', + item: 'Tell the agent what to build and it will create or update your dashboard.' }) ] + }), + div({ + className: 'weather-v2-chat-empty__suggestions', + items: [ + div({ + className: 'weather-v2-chat-empty__suggestions-label', + item: 'Try asking:' + }), + ...SUGGESTIONS.map(suggestion => + div({ + className: 'weather-v2-chat-suggestion', + item: suggestion, + onClick: () => model.sendMessageAsync(suggestion) + }) + ) + ] }) - ) + ] }); } }); +//-------------------------------------------------- +// Error Display +//-------------------------------------------------- const errorDisplay = hoistCmp.factory({ render({model}) { const {lastError} = model; @@ -74,6 +157,9 @@ const errorDisplay = hoistCmp.factory({ } }); +//-------------------------------------------------- +// Chat Input +//-------------------------------------------------- const chatInput = hoistCmp.factory({ render({model}) { const isPending = model.generateTask.isPending; @@ -83,10 +169,11 @@ const chatInput = hoistCmp.factory({ textArea({ testId: 'chat-input', bind: 'userInput', - placeholder: 'Describe what you want...', + placeholder: 'Tell the agent what to build...', flex: 1, height: 80, commitOnChange: true, + disabled: isPending, onKeyDown: (e: KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); @@ -96,8 +183,8 @@ const chatInput = hoistCmp.factory({ }), button({ testId: 'chat-send-btn', - icon: isPending ? Icon.spinner() : Icon.chevronRight(), - text: isPending ? 'Thinking...' : 'Send', + icon: Icon.chevronRight(), + text: 'Send', intent: 'primary', disabled: isPending || !model.userInput.trim(), onClick: () => model.sendMessageAsync() @@ -106,11 +193,3 @@ const chatInput = hoistCmp.factory({ }); } }); - -/** - * Format a message content string, stripping JSON code fences for cleaner display. - */ -function formatMessageContent(content: string): string { - // Strip the large JSON block from display — user sees the dashboard update instead. - return content.replace(/```json[\s\S]*?```/g, '[Dashboard spec applied]').trim(); -} diff --git a/client-app/src/examples/weatherv2/svc/LlmChatService.ts b/client-app/src/examples/weatherv2/svc/LlmChatService.ts index a50b9fce3..d5861bb3a 100644 --- a/client-app/src/examples/weatherv2/svc/LlmChatService.ts +++ b/client-app/src/examples/weatherv2/svc/LlmChatService.ts @@ -1,6 +1,7 @@ import {HoistService, XH} from '@xh/hoist/core'; import {widgetRegistry} from '../dash/WidgetRegistry'; import {DashSpec} from '../dash/types'; +import {CITIES} from '../widgets/CityChooserWidget'; export interface ChatMessage { role: 'user' | 'assistant'; @@ -17,12 +18,14 @@ export class LlmChatService extends HoistService { /** Build the system prompt including widget schemas, spec format, and rules. */ buildSystemPrompt(currentSpec?: DashSpec): string { const widgetDocs = widgetRegistry.generateLLMPrompt(); + const cityList = CITIES.join(', '); const parts: string[] = [ SYSTEM_INTRO, '## Widget Types\n\n' + widgetDocs, SPEC_FORMAT_DOCS, WIRING_RULES, - LAYOUT_RULES + LAYOUT_RULES, + CITY_RULES.replace('{{CITIES}}', cityList) ]; if (currentSpec) { @@ -130,7 +133,7 @@ Each widget in the \`state\` array has: - \`layout\` (required): Grid position. \`x\` is column (0-11), \`y\` is row, \`w\` is width (1-12), \`h\` is height. - \`state\` (optional): Widget config and input bindings. -**Widget Titles:** Display widgets (currentConditions, forecastChart, precipChart, windChart, summaryGrid) auto-generate their titles from their bound city (e.g. "Forecast — Tokyo"). Do NOT set a top-level \`title\` for these widgets — it will be overwritten. For the \`markdownContent\` widget, set the title via \`state.title\` (e.g. \`"state": {"title": "Dashboard Header", "content": "..."}\`). Input widgets (cityChooser, unitsToggle) and dashInspector use their default titles.`; +**Widget Titles:** Display widgets (currentConditions, forecastChart, precipChart, windChart, summaryGrid) auto-generate their titles from their bound city (e.g. "Forecast — Tokyo"). Do NOT set a top-level \`title\` for these widgets — it will be overwritten. Input widgets (cityChooser, unitsToggle) also receive automatic static titles ("City", "Units") — do NOT set titles for these either. For the \`markdownContent\` widget, set the title via \`state.title\` (e.g. \`"state": {"title": "Dashboard Header", "content": "..."}\`). The dashInspector uses its default title.`; const WIRING_RULES = `## Wiring Rules @@ -142,16 +145,39 @@ Widgets communicate via bindings. An input widget publishes outputs; display wid - Wire to another widget: \`{"fromWidget": "instanceId", "output": "outputName"}\` - Constant value: \`{"const": "value"}\` +**IMPORTANT: Every display widget input MUST be set via a binding** — either a \`fromWidget\` binding or a \`const\` binding. Do NOT set input values as direct state keys. For example, to set a widget's city to "Tokyo", use \`"bindings": {"city": {"const": "Tokyo"}}\`, NOT \`"city": "Tokyo"\` at the state level. + **Common wiring patterns:** - CityChooser publishes \`selectedCity\` → display widgets bind their \`city\` input to it, e.g. \`{"fromWidget": "cityChooser_0", "output": "selectedCity"}\`. -- UnitsToggle publishes \`units\` → display widgets bind their \`units\` input to it, e.g. \`{"fromWidget": "unitsToggle_0", "output": "units"}\`.`; +- UnitsToggle publishes \`units\` → display widgets bind their \`units\` input to it, e.g. \`{"fromWidget": "unitsToggle_0", "output": "units"}\`. +- Fixed city (no chooser widget): use a const binding, e.g. \`"bindings": {"city": {"const": "Tokyo"}}\`. +- Fixed units (no toggle widget): use a const binding, e.g. \`"bindings": {"units": {"const": "metric"}}\`.`; const LAYOUT_RULES = `## Layout Rules -- The grid has **12 columns**. Widgets cannot extend past column 12 (x + w <= 12). +- The grid has **12 columns** and each row is **30px** tall. Widgets cannot extend past column 12 (x + w <= 12). - Rows are unlimited — widgets can stack vertically. - Minimum widget size varies by type (see widget docs). -- Widgets should not overlap. Place them so x ranges don't overlap within the same y range.`; +- Widgets should not overlap. Place them so x ranges don't overlap within the same y range. + +**Sizing guidelines (row height = 30px):** +- Input widgets (cityChooser, unitsToggle): h=3 (90px) — just enough for the frame + control. Keep these compact. +- Display widgets (charts, grids, conditions): h=8 (240px) is a good default. Use h=6 for compact or h=10+ for emphasis. +- Markdown banners: h=3 to h=5 depending on content length. + +**Layout best practices:** +- Strive for balanced, symmetrical arrangements. Avoid leaving large empty gaps between widgets. +- Align widgets on common row boundaries where possible for a clean grid appearance. +- Place input controls (city chooser, units toggle) near the top for easy access. +- When comparing multiple cities, arrange them in evenly-spaced columns (e.g. 3 cities at w=4 each, or 2 cities at w=6 each).`; + +const CITY_RULES = `## Available Cities + +The city chooser dropdown includes these curated cities: {{CITIES}}. + +However, the weather API accepts **any valid city name worldwide** — the dropdown also allows users to type in custom cities. When the user asks about a city not in the list, you can use it directly in the \`selectedCity\` config. Use the standard English name for the city (e.g. "Munich" not "München", "Rome" not "Roma"). + +If a user asks "what cities are available", mention both the curated list and the ability to enter any city name. If asked for a city you're unsure about, use it anyway — the weather API will handle it.`; const OUTPUT_RULES = `## Output Rules diff --git a/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts b/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts index ac1cb5df1..5996aabc7 100644 --- a/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts +++ b/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts @@ -38,15 +38,20 @@ export abstract class BaseWeatherWidgetModel extends HoistModel { * if no binding is defined. */ protected resolveInput(inputName: string): T | undefined { - const bindings = this.viewModel.viewState?.bindings; - const binding: BindingSpec | undefined = bindings?.[inputName]; + const viewState = this.viewModel.viewState; + // 1. Resolve from binding (fromWidget or const) + const binding: BindingSpec | undefined = viewState?.bindings?.[inputName]; if (binding) { const resolved = this.wiringModel.resolveBinding(binding); if (resolved !== undefined) return resolved as T; } - // Fall back to declared default + // 2. Fall back to direct state value (e.g. LLM sets {city: "Tokyo"} without a binding) + const directValue = viewState?.[inputName]; + if (directValue !== undefined) return directValue as T; + + // 3. Fall back to declared default from widget meta const meta = (this.constructor as any).meta as WidgetMeta; const inputDef = meta?.inputs?.find(i => i.name === inputName); return inputDef?.default as T; diff --git a/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts b/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts index 528f343b3..bc7e78dc5 100644 --- a/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts @@ -6,32 +6,52 @@ import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; import {widgetRegistry} from '../dash/WidgetRegistry'; import {WidgetMeta} from '../dash/types'; +/** + * Curated list of major cities known to work well with the OpenWeatherMap API. + * Shown as structured suggestions in the dropdown, but users and the LLM can + * also enter any city name — the weather API accepts any valid city worldwide. + */ export const CITIES = [ 'Atlanta', 'Austin', + 'Bangkok', + 'Berlin', 'Boston', + 'Buenos Aires', + 'Cairo', 'Chicago', 'Dallas', 'Denver', + 'Dubai', 'Houston', + 'Hong Kong', + 'Istanbul', 'Las Vegas', 'London', 'Los Angeles', + 'Madrid', + 'Mexico City', 'Miami', 'Minneapolis', + 'Mumbai', 'Nashville', 'New York', 'Paris', 'Philadelphia', 'Phoenix', 'Portland', + 'Rome', 'San Antonio', 'San Diego', 'San Francisco', 'Seattle', + 'Seoul', + 'Shanghai', + 'Singapore', 'Sydney', 'Tokyo', - 'Toronto' + 'Toronto', + 'Vancouver' ]; //-------------------------------------------------- @@ -42,7 +62,7 @@ export class CityChooserModel extends BaseWeatherWidgetModel { id: 'cityChooser', title: 'City Chooser', description: - 'Dropdown selector that emits the selected city name. Other widgets bind to this to display data for the chosen city.', + 'Dropdown selector that emits the selected city name. Includes a curated list of major cities but also accepts any city name supported by the weather API.', category: 'input', inputs: [], outputs: [ @@ -51,7 +71,8 @@ export class CityChooserModel extends BaseWeatherWidgetModel { config: { selectedCity: { type: 'string', - description: 'Initially selected city.', + description: + 'Initially selected city. Can be any city name the weather API supports.', default: 'New York' }, enableSearch: { @@ -60,8 +81,8 @@ export class CityChooserModel extends BaseWeatherWidgetModel { default: true } }, - defaultSize: {w: 3, h: 2}, - minSize: {w: 2, h: 1} + defaultSize: {w: 3, h: 3}, + minSize: {w: 2, h: 3} }; @bindable selectedCity: string = 'New York'; @@ -111,6 +132,8 @@ export const cityChooserWidget = hoistCmp.factory({ bind: 'selectedCity', options: model.cities, enableFilter: model.enableSearch, + enableCreate: true, + createMessageFn: q => `Use "${q}"`, width: '100%' }) }); diff --git a/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts index 2d8542da5..917aa7edd 100644 --- a/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts @@ -49,8 +49,8 @@ export class CurrentConditionsModel extends BaseWeatherWidgetModel { }, showWind: {type: 'boolean', description: 'Show wind speed.', default: true} }, - defaultSize: {w: 4, h: 5}, - minSize: {w: 3, h: 3} + defaultSize: {w: 4, h: 8}, + minSize: {w: 3, h: 5} }; @managed chartModel: ChartModel; diff --git a/client-app/src/examples/weatherv2/widgets/DashInspectorWidget.ts b/client-app/src/examples/weatherv2/widgets/DashInspectorWidget.ts index 21c5860a7..6d602342a 100644 --- a/client-app/src/examples/weatherv2/widgets/DashInspectorWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/DashInspectorWidget.ts @@ -20,8 +20,8 @@ export class DashInspectorModel extends BaseWeatherWidgetModel { inputs: [], outputs: [], config: {}, - defaultSize: {w: 6, h: 5}, - minSize: {w: 4, h: 3} + defaultSize: {w: 6, h: 8}, + minSize: {w: 4, h: 5} }; @managed gridModel: GridModel; diff --git a/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts index 50dc64cab..1e209fc14 100644 --- a/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts @@ -51,8 +51,8 @@ export class ForecastChartModel extends BaseWeatherWidgetModel { }, showLegend: {type: 'boolean', description: 'Show chart legend.', default: true} }, - defaultSize: {w: 8, h: 5}, - minSize: {w: 4, h: 3} + defaultSize: {w: 8, h: 8}, + minSize: {w: 4, h: 5} }; @managed chartModel: ChartModel; diff --git a/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts b/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts index b6cd0a05c..0bd65a0c6 100644 --- a/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts @@ -31,8 +31,8 @@ export class MarkdownContentModel extends BaseWeatherWidgetModel { default: 'Markdown Content' } }, - defaultSize: {w: 4, h: 3}, - minSize: {w: 2, h: 1} + defaultSize: {w: 4, h: 5}, + minSize: {w: 2, h: 2} }; @bindable content: string = "# Welcome\n\nEdit this widget's content in the dashboard spec."; diff --git a/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts index 6be8ed933..7ff70d2fb 100644 --- a/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts @@ -43,8 +43,8 @@ export class PrecipChartModel extends BaseWeatherWidgetModel { default: false } }, - defaultSize: {w: 6, h: 5}, - minSize: {w: 4, h: 3} + defaultSize: {w: 6, h: 8}, + minSize: {w: 4, h: 5} }; @managed chartModel: ChartModel; diff --git a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts index 8bfef1d04..01f053e1c 100644 --- a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts @@ -1,4 +1,4 @@ -import {grid, GridModel} from '@xh/hoist/cmp/grid'; +import {grid, GridAutosizeMode, GridModel} from '@xh/hoist/cmp/grid'; import {img} from '@xh/hoist/cmp/layout'; import {creates, hoistCmp, LoadSpec, managed, XH} from '@xh/hoist/core'; import {panel} from '@xh/hoist/desktop/cmp/panel'; @@ -44,8 +44,8 @@ export class SummaryGridModel extends BaseWeatherWidgetModel { default: ['date', 'icon', 'conditions', 'high', 'low', 'humidity', 'wind'] } }, - defaultSize: {w: 6, h: 5}, - minSize: {w: 4, h: 3} + defaultSize: {w: 6, h: 8}, + minSize: {w: 4, h: 5} }; @managed gridModel: GridModel; @@ -98,6 +98,7 @@ export class SummaryGridModel extends BaseWeatherWidgetModel { return new GridModel({ sortBy: 'date', emptyText: 'No forecast data available.', + autosizeOptions: {mode: GridAutosizeMode.MANAGED}, columns: [ { field: 'date', @@ -127,7 +128,7 @@ export class SummaryGridModel extends BaseWeatherWidgetModel { }, exportValue: (_v, {record}) => record.data.conditions }, - {field: 'conditions', headerName: 'Conditions', flex: 1}, + {field: 'conditions', headerName: 'Conditions', width: 150}, {field: 'high', headerName: 'High', width: 70, align: 'right'}, {field: 'low', headerName: 'Low', width: 70, align: 'right'}, { diff --git a/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts b/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts index c065f4bc7..044919f47 100644 --- a/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts @@ -29,8 +29,8 @@ export class UnitsToggleModel extends BaseWeatherWidgetModel { default: 'imperial' } }, - defaultSize: {w: 3, h: 2}, - minSize: {w: 2, h: 1} + defaultSize: {w: 3, h: 3}, + minSize: {w: 2, h: 3} }; @bindable units: string = 'imperial'; @@ -67,12 +67,17 @@ export const unitsToggleWidget = hoistCmp.factory({ padding: 8, alignItems: 'center', justifyContent: 'center', + flex: 1, item: buttonGroupInput({ testId: 'units-input', bind: 'units', + intent: 'primary', + outlined: true, + width: '100%', + maxWidth: 300, items: [ - button({text: '°F / mph', value: 'imperial'}), - button({text: '°C / m/s', value: 'metric'}) + button({text: '°F / mph', value: 'imperial', flex: 1}), + button({text: '°C / m/s', value: 'metric', flex: 1}) ] }) }); diff --git a/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts index b02506b7c..7517c28ae 100644 --- a/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts @@ -48,8 +48,8 @@ export class WindChartModel extends BaseWeatherWidgetModel { default: 'line' } }, - defaultSize: {w: 6, h: 5}, - minSize: {w: 4, h: 3} + defaultSize: {w: 6, h: 8}, + minSize: {w: 4, h: 5} }; @managed chartModel: ChartModel; From cf62158fe82d3ade2f04b0ca61cee5b1e71f62c8 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sat, 28 Feb 2026 16:37:30 -0800 Subject: [PATCH 23/41] Fix chat panel overflow, add ideal size metadata for input widgets, UX polish Constrain the chat harness body with min-height:0 and overflow:hidden so the message list scrolls internally instead of growing the panel and pushing sibling panels (widget chooser) down. Add idealSize field to WidgetMeta and set it on cityChooser and unitsToggle (h=3). Update WidgetRegistry LLM prompt generation to emit ideal size annotations, and strengthen the system prompt layout rules to always use h=3 for input widgets unless the user requests otherwise. Also includes prior uncommitted polish: move viewManager to appBar rightItems, compact markdown widget spacing, add groupName categories to widget chooser specs. Co-Authored-By: Claude Opus 4.6 --- .../src/examples/weatherv2/AppComponent.ts | 3 +- .../src/examples/weatherv2/WeatherV2.scss | 35 ++++++++++++------- .../weatherv2/dash/WeatherV2DashModel.ts | 29 +++++++++------ .../examples/weatherv2/dash/WidgetRegistry.ts | 8 +++++ .../src/examples/weatherv2/dash/types.ts | 1 + .../weatherv2/harness/ChatHarnessPanel.ts | 6 +++- .../examples/weatherv2/svc/LlmChatService.ts | 2 +- .../weatherv2/widgets/CityChooserWidget.ts | 1 + .../weatherv2/widgets/UnitsToggleWidget.ts | 1 + 9 files changed, 61 insertions(+), 25 deletions(-) diff --git a/client-app/src/examples/weatherv2/AppComponent.ts b/client-app/src/examples/weatherv2/AppComponent.ts index bbd08a570..77a06d771 100644 --- a/client-app/src/examples/weatherv2/AppComponent.ts +++ b/client-app/src/examples/weatherv2/AppComponent.ts @@ -25,8 +25,9 @@ export const AppComponent = hoistCmp({ tbar: appBar({ icon: Icon.sun({size: '2x', prefix: 'fal'}), title: 'Weather V2', - leftItems: [viewManager()], rightItems: [ + viewManager(), + appBarSeparator(), button({ testId: 'chat-btn', icon: sparklesIcon(), diff --git a/client-app/src/examples/weatherv2/WeatherV2.scss b/client-app/src/examples/weatherv2/WeatherV2.scss index 502affd23..d6393e39e 100644 --- a/client-app/src/examples/weatherv2/WeatherV2.scss +++ b/client-app/src/examples/weatherv2/WeatherV2.scss @@ -82,31 +82,35 @@ // Markdown widget — styles for Hoist `markdown` component within dashboard cards. // Adapted from Toolbox's MarkdownPanel.scss, scaled for compact widget context. .weather-v2-markdown { - padding: 12px; + padding: 2px 10px 6px; overflow: auto; flex: 1; h1 { - font-size: 1.6em; + font-size: 1.25em; border-bottom: 2px solid var(--xh-orange); - padding-bottom: 8px; + padding-bottom: 3px; margin-top: 0; - margin-bottom: 14px; + margin-bottom: 6px; } h2 { - font-size: 1.25em; + font-size: 1.15em; border-bottom: 1px solid var(--xh-border-color); - padding-bottom: 4px; - margin-top: 20px; - margin-bottom: 10px; + padding-bottom: 3px; + margin-top: 12px; + margin-bottom: 6px; } h3 { - font-size: 1.1em; - margin-top: 16px; - margin-bottom: 8px; + font-size: 1.05em; + margin-top: 10px; + margin-bottom: 5px; } h4 { font-size: 1em; - margin-top: 14px; + margin-top: 8px; + margin-bottom: 4px; + } + p { + margin-top: 0; margin-bottom: 6px; } @@ -213,6 +217,13 @@ } } +// Chat harness — body wrapper: constrain height so messages scroll internally +// rather than growing the panel and pushing sibling panels down. +.weather-v2-chat-body { + min-height: 0; + overflow: hidden; +} + // Chat harness — empty state .weather-v2-chat-empty { flex: 1; diff --git a/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts b/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts index ca28435d3..52cae9ad7 100644 --- a/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts +++ b/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts @@ -43,16 +43,29 @@ export class WeatherV2DashModel extends HoistModel { id: 'cityChooser', title: 'City Chooser', icon: Icon.globe(), + groupName: 'Input', content: cityChooserWidget, unique: false, allowRename: false, width: 3, height: 3 }, + { + id: 'unitsToggle', + title: 'Units Toggle', + icon: Icon.gear(), + groupName: 'Input', + content: unitsToggleWidget, + unique: false, + allowRename: false, + width: 3, + height: 3 + }, { id: 'currentConditions', title: 'Current Conditions', icon: Icon.sun(), + groupName: 'Display', content: currentConditionsWidget, unique: false, allowRename: false, @@ -63,6 +76,7 @@ export class WeatherV2DashModel extends HoistModel { id: 'forecastChart', title: 'Forecast Chart', icon: temperatureIcon(), + groupName: 'Display', content: forecastChartWidget, unique: false, allowRename: false, @@ -73,6 +87,7 @@ export class WeatherV2DashModel extends HoistModel { id: 'precipChart', title: 'Precipitation', icon: cloudRainIcon(), + groupName: 'Display', content: precipChartWidget, unique: false, allowRename: false, @@ -83,26 +98,18 @@ export class WeatherV2DashModel extends HoistModel { id: 'summaryGrid', title: '5-Day Summary', icon: calendarDaysIcon(), + groupName: 'Display', content: summaryGridWidget, unique: false, allowRename: false, width: 6, height: 8 }, - { - id: 'unitsToggle', - title: 'Units Toggle', - icon: Icon.gear(), - content: unitsToggleWidget, - unique: false, - allowRename: false, - width: 3, - height: 3 - }, { id: 'windChart', title: 'Wind', icon: windIcon(), + groupName: 'Display', content: windChartWidget, unique: false, allowRename: false, @@ -113,6 +120,7 @@ export class WeatherV2DashModel extends HoistModel { id: 'markdownContent', title: 'Markdown Content', icon: Icon.info(), + groupName: 'Utility', content: markdownContentWidget, unique: false, allowRename: false, @@ -123,6 +131,7 @@ export class WeatherV2DashModel extends HoistModel { id: 'dashInspector', title: 'Dash Inspector', icon: Icon.code(), + groupName: 'Utility', content: dashInspectorWidget, unique: true, allowRename: false, diff --git a/client-app/src/examples/weatherv2/dash/WidgetRegistry.ts b/client-app/src/examples/weatherv2/dash/WidgetRegistry.ts index 3fd9d9f33..6a7a6db7c 100644 --- a/client-app/src/examples/weatherv2/dash/WidgetRegistry.ts +++ b/client-app/src/examples/weatherv2/dash/WidgetRegistry.ts @@ -84,6 +84,14 @@ class WidgetRegistryImpl { } lines.push(`Default size: ${meta.defaultSize.w}×${meta.defaultSize.h}`); + if (meta.idealSize) { + const parts: string[] = []; + if (meta.idealSize.w) parts.push(`w=${meta.idealSize.w}`); + if (meta.idealSize.h) parts.push(`h=${meta.idealSize.h}`); + lines.push( + `Ideal size: ${parts.join(', ')} — prefer this size to avoid wasting space.` + ); + } return lines.join('\n'); }) .join('\n\n'); diff --git a/client-app/src/examples/weatherv2/dash/types.ts b/client-app/src/examples/weatherv2/dash/types.ts index 9fdfaaef4..bfd7d0e9c 100644 --- a/client-app/src/examples/weatherv2/dash/types.ts +++ b/client-app/src/examples/weatherv2/dash/types.ts @@ -16,6 +16,7 @@ export interface WidgetMeta { outputs: OutputDef[]; config: Record; defaultSize: {w: number; h: number}; + idealSize?: {w?: number; h?: number}; minSize?: {w?: number; h?: number}; maxSize?: {w?: number; h?: number}; } diff --git a/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts index cf8588dac..272e7c712 100644 --- a/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts +++ b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts @@ -25,7 +25,11 @@ export const chatHarnessPanel = hoistCmp.factory({ onClick: () => model.clearChat() }) ], - item: vbox({flex: 1, items: [messageList(), errorDisplay(), chatInput()]}) + item: vbox({ + className: 'weather-v2-chat-body', + flex: 1, + items: [messageList(), errorDisplay(), chatInput()] + }) }); } }); diff --git a/client-app/src/examples/weatherv2/svc/LlmChatService.ts b/client-app/src/examples/weatherv2/svc/LlmChatService.ts index d5861bb3a..d00745621 100644 --- a/client-app/src/examples/weatherv2/svc/LlmChatService.ts +++ b/client-app/src/examples/weatherv2/svc/LlmChatService.ts @@ -161,7 +161,7 @@ const LAYOUT_RULES = `## Layout Rules - Widgets should not overlap. Place them so x ranges don't overlap within the same y range. **Sizing guidelines (row height = 30px):** -- Input widgets (cityChooser, unitsToggle): h=3 (90px) — just enough for the frame + control. Keep these compact. +- Input widgets (cityChooser, unitsToggle): h=3 (90px) — just enough for the frame + control. **Always use h=3 for these** unless the user specifically requests a larger size. Making them taller wastes vertical space. Check each widget's "Ideal size" annotation and prefer it. - Display widgets (charts, grids, conditions): h=8 (240px) is a good default. Use h=6 for compact or h=10+ for emphasis. - Markdown banners: h=3 to h=5 depending on content length. diff --git a/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts b/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts index bc7e78dc5..f83e9c558 100644 --- a/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts @@ -82,6 +82,7 @@ export class CityChooserModel extends BaseWeatherWidgetModel { } }, defaultSize: {w: 3, h: 3}, + idealSize: {h: 3}, minSize: {w: 2, h: 3} }; diff --git a/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts b/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts index 044919f47..696d178d2 100644 --- a/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts @@ -30,6 +30,7 @@ export class UnitsToggleModel extends BaseWeatherWidgetModel { } }, defaultSize: {w: 3, h: 3}, + idealSize: {h: 3}, minSize: {w: 2, h: 3} }; From 8215fb6ce1a125c81ed03c6c7f4d97659311c3d0 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sat, 28 Feb 2026 16:50:31 -0800 Subject: [PATCH 24/41] Increase LLM timeout to 120s, add retry/edit on error in chat harness Bump the XH.postJson timeout from the 30s default to 120s for the LLM generate call to handle longer responses without timing out. Add retry and edit actions to the chat error display. On error, a styled bar shows the error message with Retry (resubmits the last user message as-is) and Edit (pops the message back into the input for modification) buttons. Co-Authored-By: Claude Opus 4.6 --- .../src/examples/weatherv2/WeatherV2.scss | 17 ++++++++++++ .../weatherv2/harness/ChatHarnessModel.ts | 20 ++++++++++++++ .../weatherv2/harness/ChatHarnessPanel.ts | 26 ++++++++++++++++--- .../examples/weatherv2/svc/LlmChatService.ts | 1 + 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/client-app/src/examples/weatherv2/WeatherV2.scss b/client-app/src/examples/weatherv2/WeatherV2.scss index d6393e39e..1cb016454 100644 --- a/client-app/src/examples/weatherv2/WeatherV2.scss +++ b/client-app/src/examples/weatherv2/WeatherV2.scss @@ -376,6 +376,23 @@ } } +// Chat harness — error display with retry/edit actions +.weather-v2-chat-error { + padding: 8px 10px; + border-top: var(--xh-border-solid); + background: var(--xh-red-trans1); + + &__message { + color: var(--xh-intent-danger); + font-size: var(--xh-font-size-small-px); + margin-bottom: 6px; + } + + &__actions { + gap: 6px; + } +} + // Chat harness — input .weather-v2-chat-input { display: flex; diff --git a/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts b/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts index ace1eb42b..4b86d54d0 100644 --- a/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts +++ b/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts @@ -79,6 +79,26 @@ export class ChatHarnessModel extends HoistModel { this.doGenerateAsync().linkTo(this.generateTask); } + /** Retry the last user message after an error. */ + @action + retryLastAsync() { + if (!this.lastError || this.generateTask.isPending) return; + this.lastError = null; + this.doGenerateAsync().linkTo(this.generateTask); + } + + /** Pop the last user message back into the input for editing after an error. */ + @action + editLast() { + if (!this.lastError || this.generateTask.isPending) return; + const msgs = this.messages; + if (msgs.length && msgs[msgs.length - 1].role === 'user') { + this.userInput = msgs[msgs.length - 1].content; + this.messages = msgs.slice(0, -1); + } + this.lastError = null; + } + /** Clear the conversation. */ @action clearChat() { diff --git a/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts index 272e7c712..857178e09 100644 --- a/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts +++ b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts @@ -1,5 +1,5 @@ import {creates, hoistCmp} from '@xh/hoist/core'; -import {div, vbox} from '@xh/hoist/cmp/layout'; +import {div, hbox, vbox} from '@xh/hoist/cmp/layout'; import {panel} from '@xh/hoist/desktop/cmp/panel'; import {button} from '@xh/hoist/desktop/cmp/button'; import {textArea} from '@xh/hoist/desktop/cmp/input'; @@ -155,8 +155,28 @@ const errorDisplay = hoistCmp.factory({ if (!lastError) return null; return div({ - className: 'weather-v2-validation weather-v2-validation--error', - item: lastError + className: 'weather-v2-chat-error', + items: [ + div({className: 'weather-v2-chat-error__message', item: lastError}), + hbox({ + className: 'weather-v2-chat-error__actions', + items: [ + button({ + icon: Icon.refresh(), + text: 'Retry', + intent: 'danger', + outlined: true, + onClick: () => model.retryLastAsync() + }), + button({ + icon: Icon.edit(), + text: 'Edit', + outlined: true, + onClick: () => model.editLast() + }) + ] + }) + ] }); } }); diff --git a/client-app/src/examples/weatherv2/svc/LlmChatService.ts b/client-app/src/examples/weatherv2/svc/LlmChatService.ts index d00745621..44d504d1b 100644 --- a/client-app/src/examples/weatherv2/svc/LlmChatService.ts +++ b/client-app/src/examples/weatherv2/svc/LlmChatService.ts @@ -49,6 +49,7 @@ export class LlmChatService extends HoistService { const response = await XH.postJson({ url: 'llm/generate', body: {systemPrompt, messages}, + timeout: {interval: 120_000, message: 'LLM request timed out.'}, track: { category: 'WeatherV2', message: 'LLM generate', From f3f8f5c4dea86f321537fe3cf117cfcb0d87b58f Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sat, 28 Feb 2026 17:03:32 -0800 Subject: [PATCH 25/41] Render chat assistant messages as markdown, upgrade default LLM to Sonnet 4.6 Use Hoist's markdown() component for completed assistant messages in the chat harness. Keeps plain text during typewriter animation to avoid broken partial-markdown rendering. Added compact chat-specific markdown styles (.weather-v2-chat-markdown) adapted from the widget markdown styles but tuned for the narrow side panel context. Update the default LLM model from claude-sonnet-4-20250514 (legacy Sonnet 4) to claude-sonnet-4-6 (latest Sonnet 4.6) for improved speed, accuracy, and more recent training data. Co-Authored-By: Claude Opus 4.6 --- .../src/examples/weatherv2/WeatherV2.scss | 109 ++++++++++++++++++ .../weatherv2/harness/ChatHarnessPanel.ts | 15 ++- .../io/xh/toolbox/llm/LlmService.groovy | 4 +- 3 files changed, 125 insertions(+), 3 deletions(-) diff --git a/client-app/src/examples/weatherv2/WeatherV2.scss b/client-app/src/examples/weatherv2/WeatherV2.scss index 1cb016454..6eb5a1bb9 100644 --- a/client-app/src/examples/weatherv2/WeatherV2.scss +++ b/client-app/src/examples/weatherv2/WeatherV2.scss @@ -330,6 +330,115 @@ } } +// Chat harness — markdown rendering within assistant message bubbles. +// Compact variant of .weather-v2-markdown, sized for the narrow side panel. +.weather-v2-chat-markdown { + white-space: normal; + font-size: var(--xh-font-size-small-px); + line-height: 1.5; + + > :first-child { + margin-top: 0; + } + > :last-child { + margin-bottom: 0; + } + + p { + margin: 4px 0; + line-height: 1.5; + } + + h1, + h2, + h3, + h4 { + margin-top: 8px; + margin-bottom: 4px; + font-weight: 600; + } + h1 { + font-size: 1.15em; + } + h2 { + font-size: 1.1em; + } + h3, + h4 { + font-size: 1em; + } + + strong { + font-weight: 600; + } + + ul, + ol { + padding-left: 18px; + margin: 4px 0; + } + li { + margin: 2px 0; + } + + pre { + background: var(--xh-bg-alt); + border: 1px solid var(--xh-border-color); + border-radius: 4px; + padding: 6px 8px; + overflow-x: auto; + font-size: 11px; + line-height: 1.4; + margin: 6px 0; + } + code { + font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace; + font-size: 0.9em; + } + :not(pre) > code { + background: var(--xh-bg-alt); + border: 1px solid var(--xh-border-color); + border-radius: 3px; + padding: 1px 4px; + } + + blockquote { + border-left: 3px solid var(--xh-orange); + margin: 6px 0; + padding: 4px 8px; + background: var(--xh-bg-alt); + border-radius: 0 4px 4px 0; + } + + table { + border-collapse: collapse; + width: 100%; + margin: 6px 0; + font-size: 0.9em; + } + th, + td { + border: 1px solid var(--xh-border-color); + padding: 4px 6px; + text-align: left; + } + th { + background: var(--xh-bg-alt); + font-weight: 600; + } + + a { + color: var(--xh-blue); + text-decoration: none; + } + + hr { + border: none; + border-top: 1px solid var(--xh-border-color); + margin: 8px 0; + } +} + // Thinking dots animation .weather-v2-thinking-dots { display: flex; diff --git a/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts index 857178e09..d1b5f42ca 100644 --- a/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts +++ b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts @@ -1,5 +1,6 @@ import {creates, hoistCmp} from '@xh/hoist/core'; import {div, hbox, vbox} from '@xh/hoist/cmp/layout'; +import {markdown} from '@xh/hoist/cmp/markdown'; import {panel} from '@xh/hoist/desktop/cmp/panel'; import {button} from '@xh/hoist/desktop/cmp/button'; import {textArea} from '@xh/hoist/desktop/cmp/input'; @@ -82,6 +83,18 @@ function renderBubble(model: ChatHarnessModel, msg: DisplayMessage, index: numbe const formatted = formatMessageContent(msg.content); const displayed = model.getDisplayContent(index, formatted); const isTyping = index === model.typingMessageIdx; + const isAssistant = msg.role === 'assistant'; + + // Render completed assistant messages as markdown; use plain text during + // typewriter animation (partial markdown would produce broken rendering) + // and for user messages. + const contentItem = + isAssistant && !isTyping + ? div({ + className: 'weather-v2-chat-markdown', + item: markdown({content: displayed, lineBreaks: false}) + }) + : displayed; return div({ key: index, @@ -93,7 +106,7 @@ function renderBubble(model: ChatHarnessModel, msg: DisplayMessage, index: numbe }), div({ className: `weather-v2-chat-msg__content${isTyping ? ' weather-v2-chat-msg__content--typing' : ''}`, - item: displayed + item: contentItem }) ] }); diff --git a/grails-app/services/io/xh/toolbox/llm/LlmService.groovy b/grails-app/services/io/xh/toolbox/llm/LlmService.groovy index ec16c67a9..e4d8348f7 100644 --- a/grails-app/services/io/xh/toolbox/llm/LlmService.groovy +++ b/grails-app/services/io/xh/toolbox/llm/LlmService.groovy @@ -17,7 +17,7 @@ import org.apache.hc.core5.http.io.entity.StringEntity * * Config entries: * - llmApiKey (string/pwd): Anthropic API key. - * - llmModel (string): Model identifier, default 'claude-sonnet-4-20250514'. + * - llmModel (string): Model identifier, default 'claude-sonnet-4-6'. * - llmMaxTokens (int): Max response tokens, default 4096. * - llmRateLimit (int): Max requests per user per hour, default 20. */ @@ -47,7 +47,7 @@ class LlmService extends BaseService { checkApiKey() checkRateLimit(username) - def model = configService.getString('llmModel', 'claude-sonnet-4-20250514'), + def model = configService.getString('llmModel', 'claude-sonnet-4-6'), maxTokens = configService.getInt('llmMaxTokens', 4096), apiKey = configService.getPwd('llmApiKey') From 264e05870b915a0a58c1012e7d50879759ecefb5 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sat, 28 Feb 2026 17:15:58 -0800 Subject: [PATCH 26/41] Fix chat markdown font size to match user messages, add table hover style Change chat markdown font-size from --xh-font-size-small-px to --xh-font-size-px so assistant messages render at the same size as user messages. Add tr:hover highlight on table rows matching the Toolbox MarkdownPanel convention. Co-Authored-By: Claude Opus 4.6 --- client-app/src/examples/weatherv2/WeatherV2.scss | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/client-app/src/examples/weatherv2/WeatherV2.scss b/client-app/src/examples/weatherv2/WeatherV2.scss index 6eb5a1bb9..0a4df236c 100644 --- a/client-app/src/examples/weatherv2/WeatherV2.scss +++ b/client-app/src/examples/weatherv2/WeatherV2.scss @@ -331,10 +331,10 @@ } // Chat harness — markdown rendering within assistant message bubbles. -// Compact variant of .weather-v2-markdown, sized for the narrow side panel. +// Adapted from Toolbox's MarkdownPanel.scss, scaled for the narrow side panel. .weather-v2-chat-markdown { white-space: normal; - font-size: var(--xh-font-size-small-px); + font-size: var(--xh-font-size-px); line-height: 1.5; > :first-child { @@ -426,6 +426,9 @@ background: var(--xh-bg-alt); font-weight: 600; } + tr:hover td { + background: var(--xh-bg-highlight); + } a { color: var(--xh-blue); From 542bfd39f03213977c630bcfba79ba5d67fb8a3c Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sat, 28 Feb 2026 17:41:07 -0800 Subject: [PATCH 27/41] Add business-audience guidance to LLM system prompt Instruct the dashboard agent to tailor its conversational responses for business users, not developers. Technical terms like bindings, wiring, instance IDs, and JSON structure are suppressed unless the user explicitly asks a dev question. Also guide the agent to respond conversationally without producing a spec when the user asks a general question. Co-Authored-By: Claude Opus 4.6 --- .../src/examples/weatherv2/svc/LlmChatService.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/client-app/src/examples/weatherv2/svc/LlmChatService.ts b/client-app/src/examples/weatherv2/svc/LlmChatService.ts index 44d504d1b..797a0fbea 100644 --- a/client-app/src/examples/weatherv2/svc/LlmChatService.ts +++ b/client-app/src/examples/weatherv2/svc/LlmChatService.ts @@ -104,9 +104,16 @@ export class LlmChatService extends HoistService { //-------------------------------------------------- // System prompt sections //-------------------------------------------------- -const SYSTEM_INTRO = `You are a dashboard configuration assistant for Weather Dashboard V2. Your job is to generate and modify dashboard specifications (JSON) based on user requests. +const SYSTEM_INTRO = `You are a friendly dashboard assistant for Weather Dashboard V2. Your job is to build and modify weather dashboards based on what the user asks for. -The dashboard uses a 12-column grid layout with configurable widgets that can be wired together through input/output bindings.`; +Your audience is **business users, not software developers**. In your conversational responses: +- Use plain, approachable language. Talk about what the dashboard will *show* and *do*, not how it works internally. +- **Never mention** technical terms like "bindings", "wiring", "instance IDs", "specs", "JSON", "viewSpecId", "state arrays", or grid coordinates unless the user explicitly asks a technical or developer-oriented question. +- Instead of "I'll wire the forecast chart's city input to the city chooser via a binding", say something like "I'll connect the forecast chart to your city selector so it updates when you pick a different city." +- Keep responses concise and helpful — a sentence or two explaining what you're setting up is plenty. +- If the user asks a technical or developer question (e.g. about the JSON format, how bindings work, the spec structure), then and only then should you provide technical details. + +The dashboard uses a 12-column grid layout with configurable widgets that can be connected together so they share data (e.g. a city selector driving all the charts).`; const SPEC_FORMAT_DOCS = `## Dashboard Spec Format @@ -186,4 +193,6 @@ IMPORTANT: Always output a complete, valid JSON spec wrapped in a \`\`\`json cod If the user asks to modify the dashboard, start from the current spec and make targeted changes. Preserve widget IDs, bindings, and layouts for widgets the user didn't mention. -Respond conversationally before the JSON spec — briefly explain what you're doing.`; +Before the JSON spec, include a brief, friendly explanation of what you're setting up or changing — written for a business audience, not a technical one. Focus on what the user will *see* in the dashboard, not how it's implemented. + +If the user asks a general question, or something conversational that does not require a dashboard change, respond naturally without producing a JSON spec. Not every message needs a dashboard update.`; From d4cc5463a556a4ebec9392f33793996fbbd8e670 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sat, 28 Feb 2026 23:10:09 -0800 Subject: [PATCH 28/41] Add LLM tool use (function calling) for app operations in WeatherV2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give the LLM callable tools for app operations beyond dashboard spec generation. Tools execute client-side since they manipulate UI state; the server passes tool definitions through to the Anthropic API. 6 tools: save_dashboard_as_view, switch_to_view, reset_dashboard, toggle_theme, open_widget_chooser, show_json_spec. Multi-turn loop in ChatHarnessModel handles tool_use → tool_result → final text flow (max 5 iterations). Server: LlmService/LlmController accept optional tools parameter, forward to Anthropic API. Default max tokens increased to 8192. Client: New LlmToolService (HoistService) with tool definitions + execution. LlmChatService returns full content block arrays and adds tool guidance to system prompt. ChatHarnessModel splits API messages from display messages, runs tool execution loop. ChatHarnessPanel renders tool calls as collapsible details/summary elements. Tool-use example added to empty state suggestions. Co-Authored-By: Claude Opus 4.6 --- client-app/src/Bootstrap.ts | 2 + client-app/src/examples/weatherv2/AppModel.ts | 3 +- client-app/src/examples/weatherv2/README.md | 4 + .../src/examples/weatherv2/WeatherV2.scss | 52 +++++ .../weatherv2/harness/ChatHarnessModel.ts | 172 ++++++++++++++--- .../weatherv2/harness/ChatHarnessPanel.ts | 80 +++++++- .../examples/weatherv2/planning/PROGRESS.md | 28 +++ .../examples/weatherv2/svc/LlmChatService.ts | 58 +++++- .../examples/weatherv2/svc/LlmToolService.ts | 180 ++++++++++++++++++ .../io/xh/toolbox/llm/LlmController.groovy | 5 +- .../io/xh/toolbox/llm/LlmService.groovy | 13 +- 11 files changed, 547 insertions(+), 50 deletions(-) create mode 100644 client-app/src/examples/weatherv2/svc/LlmToolService.ts diff --git a/client-app/src/Bootstrap.ts b/client-app/src/Bootstrap.ts index eaefea1ee..9fbce372e 100755 --- a/client-app/src/Bootstrap.ts +++ b/client-app/src/Bootstrap.ts @@ -17,6 +17,7 @@ import {GitHubService} from './core/svc/GitHubService'; import {PortfolioService} from './core/svc/PortfolioService'; import {TaskService} from './examples/todo/TaskService'; import {LlmChatService} from './examples/weatherv2/svc/LlmChatService'; +import {LlmToolService} from './examples/weatherv2/svc/LlmToolService'; import {WeatherDataService} from './examples/weatherv2/svc/WeatherDataService'; declare module '@xh/hoist/core' { @@ -25,6 +26,7 @@ declare module '@xh/hoist/core' { contactService: ContactService; gitHubService: GitHubService; llmChatService: LlmChatService; + llmToolService: LlmToolService; portfolioService: PortfolioService; taskService: TaskService; weatherDataService: WeatherDataService; diff --git a/client-app/src/examples/weatherv2/AppModel.ts b/client-app/src/examples/weatherv2/AppModel.ts index 1f385a0d5..a7c8536b6 100644 --- a/client-app/src/examples/weatherv2/AppModel.ts +++ b/client-app/src/examples/weatherv2/AppModel.ts @@ -10,6 +10,7 @@ import { import {BaseAppModel} from '../../BaseAppModel'; import {WeatherV2DashModel} from './dash/WeatherV2DashModel'; import {LlmChatService} from './svc/LlmChatService'; +import {LlmToolService} from './svc/LlmToolService'; import {WeatherDataService} from './svc/WeatherDataService'; export class AppModel extends BaseAppModel { @@ -30,7 +31,7 @@ export class AppModel extends BaseAppModel { override async initAsync() { await super.initAsync(); - await XH.installServicesAsync(LlmChatService, WeatherDataService); + await XH.installServicesAsync(LlmChatService, LlmToolService, WeatherDataService); this.harnessPanelModel = new PanelModel({ side: 'right', diff --git a/client-app/src/examples/weatherv2/README.md b/client-app/src/examples/weatherv2/README.md index c465b5b71..281c84a00 100644 --- a/client-app/src/examples/weatherv2/README.md +++ b/client-app/src/examples/weatherv2/README.md @@ -18,6 +18,9 @@ unchanged; both share the same server-side weather endpoints. - **LLM generation pipeline** — User describes a dashboard in natural language → system prompt with widget schemas + spec format → LLM produces JSON spec → validation pipeline checks it → valid specs hydrate into a live dashboard. +- **LLM tool use** — The LLM has access to callable tools (via Anthropic's function calling API) + for app operations: saving/loading views, toggling theme, opening panels. Tools execute + client-side; the server passes tool definitions through to the Anthropic API. ## Directory Structure @@ -36,6 +39,7 @@ weatherv2/ │ └── exampleSpecs.ts — Curated example dashboard specs ├── svc/ │ ├── LlmChatService.ts — System prompt builder + LLM API client (HoistService) +│ ├── LlmToolService.ts — LLM tool definitions + execution (HoistService) │ └── WeatherDataService.ts — Per-city weather data caching (HoistService) ├── harness/ │ ├── JsonHarnessModel.ts/Panel.ts — JSON editor: view/edit/validate/apply specs diff --git a/client-app/src/examples/weatherv2/WeatherV2.scss b/client-app/src/examples/weatherv2/WeatherV2.scss index 0a4df236c..e93b4e2fb 100644 --- a/client-app/src/examples/weatherv2/WeatherV2.scss +++ b/client-app/src/examples/weatherv2/WeatherV2.scss @@ -505,6 +505,58 @@ } } +// Chat harness — tool call display +.weather-v2-tool-calls { + display: flex; + flex-direction: column; + gap: 4px; + margin: 4px 0; +} + +.weather-v2-tool-call { + border-radius: 4px; + font-size: var(--xh-font-size-small-px); + background: var(--xh-bg-highlight); + border: 1px solid var(--xh-border-color); + + &__summary { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + cursor: pointer; + color: var(--xh-text-color-muted); + font-size: var(--xh-font-size-small-px); + list-style: none; + user-select: none; + + &::-webkit-details-marker { + display: none; + } + + &::before { + content: '\25B6'; + font-size: 8px; + transition: transform 0.15s ease; + } + } + + &[open] > &__summary::before { + transform: rotate(90deg); + } + + &__detail { + padding: 4px 8px 6px 22px; + font-size: var(--xh-font-size-small-px); + color: var(--xh-text-color-muted); + border-top: 1px solid var(--xh-border-color); + } + + &__error { + color: var(--xh-intent-danger); + } +} + // Chat harness — input .weather-v2-chat-input { display: flex; diff --git a/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts b/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts index 4b86d54d0..2451988d3 100644 --- a/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts +++ b/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts @@ -2,23 +2,36 @@ import {HoistModel, managed, PersistableState, TaskObserver, XH} from '@xh/hoist import {action, bindable, computed, makeObservable, observable, runInAction} from '@xh/hoist/mobx'; import {DashSpec} from '../dash/types'; import {validateSpec, migrateSpec} from '../dash/validation'; -import {ChatMessage} from '../svc/LlmChatService'; +import {ChatMessage, ContentBlock} from '../svc/LlmChatService'; import {AppModel} from '../AppModel'; +/** Tool call info for display in the chat UI. */ +export interface ToolCallDisplay { + name: string; + input: Record; + result: string; + isError: boolean; +} + /** A message for display, including a "thinking" placeholder state. */ export interface DisplayMessage { role: 'user' | 'assistant'; content: string; thinking?: boolean; + toolCalls?: ToolCallDisplay[]; } +/** Max tool-use loop iterations to prevent runaway. */ +const MAX_TOOL_ITERATIONS = 5; + /** * Model for the LLM chat harness — manages conversation history, - * LLM API calls, spec application, and typewriter display effect. + * LLM API calls, tool execution, spec application, and typewriter display effect. */ export class ChatHarnessModel extends HoistModel { @bindable userInput: string = ''; @observable.ref messages: ChatMessage[] = []; + @observable.ref displayMessages: DisplayMessage[] = []; @observable.ref lastError: string = null; // Typewriter effect state @@ -29,25 +42,16 @@ export class ChatHarnessModel extends HoistModel { generateTask = TaskObserver.trackLast(); private _typingTimer: ReturnType = null; + // Synchronous guard against concurrent sends — complements the MobX-based + // generateTask.isPending check which can miss rapid-fire calls from the + // same tick due to batching. + private _isGenerating = false; constructor() { super(); makeObservable(this); } - /** Messages to render, including a thinking placeholder while awaiting LLM response. */ - @computed - get displayMessages(): DisplayMessage[] { - const msgs: DisplayMessage[] = this.messages.map(m => ({...m})); - if ( - this.generateTask.isPending && - (msgs.length === 0 || msgs[msgs.length - 1].role === 'user') - ) { - msgs.push({role: 'assistant', content: '', thinking: true}); - } - return msgs; - } - /** * Get displayed content for a message, accounting for typewriter effect. * Call with the formatted (JSON-stripped) content, not the raw LLM text. @@ -69,22 +73,29 @@ export class ChatHarnessModel extends HoistModel { @action async sendMessageAsync(content?: string) { const input = content ?? this.userInput; - if (!input.trim() || this.generateTask.isPending) return; + if (!input.trim() || this._isGenerating) return; + this._isGenerating = true; const userMsg: ChatMessage = {role: 'user', content: input.trim()}; this.messages = [...this.messages, userMsg]; + this.rebuildDisplayMessages(); this.userInput = ''; this.lastError = null; - this.doGenerateAsync().linkTo(this.generateTask); + this.doGenerateAsync() + .finally(() => (this._isGenerating = false)) + .linkTo(this.generateTask); } /** Retry the last user message after an error. */ @action retryLastAsync() { - if (!this.lastError || this.generateTask.isPending) return; + if (!this.lastError || this._isGenerating) return; + this._isGenerating = true; this.lastError = null; - this.doGenerateAsync().linkTo(this.generateTask); + this.doGenerateAsync() + .finally(() => (this._isGenerating = false)) + .linkTo(this.generateTask); } /** Pop the last user message back into the input for editing after an error. */ @@ -93,8 +104,10 @@ export class ChatHarnessModel extends HoistModel { if (!this.lastError || this.generateTask.isPending) return; const msgs = this.messages; if (msgs.length && msgs[msgs.length - 1].role === 'user') { - this.userInput = msgs[msgs.length - 1].content; + const lastContent = msgs[msgs.length - 1].content; + this.userInput = typeof lastContent === 'string' ? lastContent : ''; this.messages = msgs.slice(0, -1); + this.rebuildDisplayMessages(); } this.lastError = null; } @@ -104,6 +117,7 @@ export class ChatHarnessModel extends HoistModel { clearChat() { this.stopTyping(); this.messages = []; + this.displayMessages = []; this.lastError = null; } @@ -117,21 +131,80 @@ export class ChatHarnessModel extends HoistModel { //------------------ private async doGenerateAsync() { try { - const svc = XH.llmChatService; + const chatSvc = XH.llmChatService, + toolSvc = XH.llmToolService; - // Build system prompt with current dashboard spec const currentSpec = this.getCurrentSpec(); - const systemPrompt = svc.buildSystemPrompt(currentSpec); + const systemPrompt = chatSvc.buildSystemPrompt(currentSpec); + const tools = toolSvc.getToolDefinitions(); + + // Accumulate tool calls across iterations for display + const allToolCalls: ToolCallDisplay[] = []; + let allTextParts: string[] = []; + + // Tool use loop: call LLM, execute any tools, send results back + for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) { + const {content} = await chatSvc.generateAsync(systemPrompt, this.messages, tools); + + // Append assistant message with full content blocks to API history + runInAction(() => { + this.messages = [...this.messages, {role: 'assistant', content}]; + }); + + // Collect text parts from this response + const textParts = content.filter(b => b.type === 'text' && b.text).map(b => b.text); + allTextParts.push(...textParts); + + // Check for tool use blocks — break if none + const toolUseBlocks = content.filter(b => b.type === 'tool_use'); + if (toolUseBlocks.length === 0) break; + + // Execute each tool and collect results + const toolResults: ContentBlock[] = []; + for (const block of toolUseBlocks) { + const {content: result, isError} = await toolSvc.executeToolAsync( + block.name, + block.input + ); + allToolCalls.push({ + name: block.name, + input: block.input, + result, + isError + }); + toolResults.push({ + type: 'tool_result', + tool_use_id: block.id, + content: result, + is_error: isError + }); + } + + // Send tool results back as a user message + runInAction(() => { + this.messages = [...this.messages, {role: 'user', content: toolResults}]; + }); + } - // Call LLM - const {content} = await svc.generateAsync(systemPrompt, this.messages); + // Build final display text from all text parts + const finalText = allTextParts.join('\n\n'); - // Add assistant response, apply any spec, and start typewriter runInAction(() => { - this.messages = [...this.messages, {role: 'assistant', content}]; - const spec = svc.parseSpecFromResponse(content); + // Parse and apply any spec from the combined text + const spec = chatSvc.parseSpecFromResponse(finalText); if (spec) this.applySpec(spec); - this.startTyping(this.messages.length - 1, content); + + // Add display message with tool calls + text + this.displayMessages = [ + ...this.displayMessages, + { + role: 'assistant', + content: finalText, + toolCalls: allToolCalls.length > 0 ? allToolCalls : undefined + } + ]; + + this.startTyping(this.displayMessages.length - 1, finalText); }); } catch (e) { runInAction(() => { @@ -140,6 +213,47 @@ export class ChatHarnessModel extends HoistModel { } } + /** + * Rebuild displayMessages from the API messages array. + * Called when user messages are added/removed (send, edit). + * Only processes user messages — assistant display messages are added + * by doGenerateAsync after the full tool loop completes. + */ + @action + private rebuildDisplayMessages() { + const display: DisplayMessage[] = []; + for (const msg of this.messages) { + if (msg.role === 'user' && typeof msg.content === 'string') { + display.push({role: 'user', content: msg.content}); + } + // Assistant display messages and tool-result user messages are not + // rebuilt here — they're managed by doGenerateAsync. + } + + // Preserve existing assistant display messages by interleaving: + // Walk through existing displayMessages to keep assistant entries intact. + const existing = this.displayMessages; + const merged: DisplayMessage[] = []; + let userIdx = 0; + const userMsgs = display; + + for (const dm of existing) { + if (dm.role === 'user') { + if (userIdx < userMsgs.length) { + merged.push(userMsgs[userIdx++]); + } + } else { + merged.push(dm); + } + } + // Append any remaining new user messages + while (userIdx < userMsgs.length) { + merged.push(userMsgs[userIdx++]); + } + + this.displayMessages = merged; + } + private getCurrentSpec(): DashSpec | undefined { try { const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; diff --git a/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts index d1b5f42ca..79e7ea1cd 100644 --- a/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts +++ b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts @@ -1,3 +1,4 @@ +import {createElement} from 'react'; import {creates, hoistCmp} from '@xh/hoist/core'; import {div, hbox, vbox} from '@xh/hoist/cmp/layout'; import {markdown} from '@xh/hoist/cmp/markdown'; @@ -6,7 +7,12 @@ import {button} from '@xh/hoist/desktop/cmp/button'; import {textArea} from '@xh/hoist/desktop/cmp/input'; import {Icon} from '@xh/hoist/icon'; import {sparklesIcon} from '../Icons'; -import {ChatHarnessModel, DisplayMessage, formatMessageContent} from './ChatHarnessModel'; +import { + ChatHarnessModel, + DisplayMessage, + ToolCallDisplay, + formatMessageContent +} from './ChatHarnessModel'; export const chatHarnessPanel = hoistCmp.factory({ displayName: 'ChatHarnessPanel', @@ -40,15 +46,24 @@ export const chatHarnessPanel = hoistCmp.factory({ //-------------------------------------------------- const messageList = hoistCmp.factory({ render({model}) { - const {displayMessages} = model; + const {displayMessages, generateTask} = model; - if (displayMessages.length === 0) { + // Append a thinking placeholder when waiting for an LLM response + const msgs = [...displayMessages]; + if ( + generateTask.isPending && + (msgs.length === 0 || msgs[msgs.length - 1].role === 'user') + ) { + msgs.push({role: 'assistant', content: '', thinking: true}); + } + + if (msgs.length === 0) { return emptyState(); } return div({ className: 'weather-v2-chat-messages', - items: displayMessages.map((msg, i) => renderBubble(model, msg, i)), + items: msgs.map((msg, i) => renderBubble(model, msg, i)), ref: (el: HTMLElement) => { if (el) el.scrollTop = el.scrollHeight; } @@ -104,6 +119,8 @@ function renderBubble(model: ChatHarnessModel, msg: DisplayMessage, index: numbe className: 'weather-v2-chat-msg__role', item: msg.role === 'user' ? 'You' : 'Assistant' }), + // Tool calls rendered between role label and text content + ...(msg.toolCalls?.length ? [renderToolCalls(msg.toolCalls)] : []), div({ className: `weather-v2-chat-msg__content${isTyping ? ' weather-v2-chat-msg__content--typing' : ''}`, item: contentItem @@ -112,6 +129,57 @@ function renderBubble(model: ChatHarnessModel, msg: DisplayMessage, index: numbe }); } +/** Render tool call summary cards (collapsed by default using native details/summary). */ +function renderToolCalls(toolCalls: ToolCallDisplay[]) { + return div({ + className: 'weather-v2-tool-calls', + items: toolCalls.map((tc, i) => + createElement( + 'details', + {key: `tool-${i}`, className: 'weather-v2-tool-call'}, + createElement( + 'summary', + {className: 'weather-v2-tool-call__summary'}, + Icon.bolt(), + friendlyToolSummary(tc) + ), + div({ + className: 'weather-v2-tool-call__detail', + items: [ + tc.input && Object.keys(tc.input).length > 0 + ? div({item: `Input: ${JSON.stringify(tc.input)}`}) + : null, + div({ + className: tc.isError ? 'weather-v2-tool-call__error' : null, + item: `Result: ${tc.result}` + }) + ] + }) + ) + ) + }); +} + +/** Map tool name + input to a human-readable summary. */ +function friendlyToolSummary(tc: ToolCallDisplay): string { + switch (tc.name) { + case 'save_dashboard_as_view': + return `Saved view: ${tc.input?.name ?? ''}`; + case 'switch_to_view': + return `Switched to: ${tc.input?.name ?? ''}`; + case 'reset_dashboard': + return 'Reset to defaults'; + case 'toggle_theme': + return 'Toggled theme'; + case 'open_widget_chooser': + return 'Opened widget chooser'; + case 'show_json_spec': + return 'Opened JSON editor'; + default: + return tc.name; + } +} + //-------------------------------------------------- // Empty State with Suggestions //-------------------------------------------------- @@ -119,8 +187,8 @@ const SUGGESTIONS = [ 'Compare weather in New York and Tokyo side by side', 'Show current conditions for 16 major cities in a 4x4 grid', 'Build a compact 3-city overview dashboard', - 'Simplify to just current conditions and the 5-day forecast', - 'Add a markdown welcome banner at the top of the dashboard' + 'Set up a Tokyo weather dashboard and save it as a view called "Tokyo Overview"', + 'Simplify to just current conditions and the 5-day forecast' ]; const emptyState = hoistCmp.factory({ diff --git a/client-app/src/examples/weatherv2/planning/PROGRESS.md b/client-app/src/examples/weatherv2/planning/PROGRESS.md index b058470c8..ba58cd756 100644 --- a/client-app/src/examples/weatherv2/planning/PROGRESS.md +++ b/client-app/src/examples/weatherv2/planning/PROGRESS.md @@ -189,3 +189,31 @@ Implemented reactive auto-titles that show widget context (particularly the acti - Markdown rendering with proper block layout - 4 curated example specs loadable from dropdown - Widget renaming disabled to prevent title conflicts + +### LLM Tool Use (Function Calling) + +Added Anthropic native tool use API to give the LLM callable tools for app operations beyond dashboard spec generation. + +**Architecture:** +- Tools execute **client-side** since they manipulate UI state (ViewManager, theme, panels) +- Server is a pass-through — forwards the `tools` array to the Anthropic API +- Multi-turn loop: LLM responds with `tool_use` blocks → client executes → sends `tool_result` → LLM responds with final text (max 5 iterations) + +**6 tools implemented:** +| Tool | Action | +|------|--------| +| `save_dashboard_as_view` | Save current dashboard as a named view | +| `switch_to_view` | Switch to an existing saved view by name | +| `reset_dashboard` | Reset dashboard to saved state | +| `toggle_theme` | Toggle light/dark theme | +| `open_widget_chooser` | Open widget chooser panel | +| `show_json_spec` | Open JSON spec editor | + +**Files changed:** +- **Server:** `LlmService.groovy` + `LlmController.groovy` — accept optional `tools` param, increased default max tokens to 8192 +- **Client (new):** `svc/LlmToolService.ts` — HoistService with tool definitions + execution +- **Client (modified):** `svc/LlmChatService.ts` — returns full content block array, tool guidance in system prompt +- **Client (modified):** `harness/ChatHarnessModel.ts` — tool use loop, split API messages from display messages, ToolCallDisplay type +- **Client (modified):** `harness/ChatHarnessPanel.ts` — tool calls rendered as collapsible `
` elements, tool-use suggestion in empty state +- **Client (modified):** `WeatherV2.scss` — `.weather-v2-tool-call` styles +- **Client (modified):** `AppModel.ts` + `Bootstrap.ts` — LlmToolService registration + type augmentation diff --git a/client-app/src/examples/weatherv2/svc/LlmChatService.ts b/client-app/src/examples/weatherv2/svc/LlmChatService.ts index 797a0fbea..d4756955a 100644 --- a/client-app/src/examples/weatherv2/svc/LlmChatService.ts +++ b/client-app/src/examples/weatherv2/svc/LlmChatService.ts @@ -2,10 +2,31 @@ import {HoistService, XH} from '@xh/hoist/core'; import {widgetRegistry} from '../dash/WidgetRegistry'; import {DashSpec} from '../dash/types'; import {CITIES} from '../widgets/CityChooserWidget'; +import {ToolDefinition} from './LlmToolService'; + +/** A content block in an Anthropic API message. */ +export interface ContentBlock { + type: 'text' | 'tool_use' | 'tool_result'; + // text block + text?: string; + // tool_use block + id?: string; + name?: string; + input?: Record; + // tool_result block + tool_use_id?: string; + content?: string; + is_error?: boolean; +} +/** + * A message in the conversation history. + * User messages use a simple string content; assistant messages may contain + * rich content blocks (text + tool_use) when tool calling is active. + */ export interface ChatMessage { role: 'user' | 'assistant'; - content: string; + content: string | ContentBlock[]; } /** @@ -37,18 +58,27 @@ export class LlmChatService extends HoistService { ); } + parts.push(TOOL_USE_GUIDANCE); parts.push(OUTPUT_RULES); return parts.join('\n\n'); } - /** Call the LLM proxy endpoint and return the raw response. */ + /** + * Call the LLM proxy endpoint and return the full response. + * Returns the raw content block array (which may contain text + tool_use blocks), + * the stop reason, and the full API response. + */ async generateAsync( systemPrompt: string, - messages: ChatMessage[] - ): Promise<{content: string; raw: any}> { + messages: ChatMessage[], + tools?: ToolDefinition[] + ): Promise<{content: ContentBlock[]; stopReason: string; raw: any}> { + const body: any = {systemPrompt, messages}; + if (tools?.length) body.tools = tools; + const response = await XH.postJson({ url: 'llm/generate', - body: {systemPrompt, messages}, + body, timeout: {interval: 120_000, message: 'LLM request timed out.'}, track: { category: 'WeatherV2', @@ -59,10 +89,9 @@ export class LlmChatService extends HoistService { } }); - // Anthropic API returns {content: [{type: 'text', text: '...'}], ...} - const textBlock = response?.content?.find((c: any) => c.type === 'text'); - const content = textBlock?.text ?? ''; - return {content, raw: response}; + const content: ContentBlock[] = response?.content ?? []; + const stopReason: string = response?.stop_reason ?? 'end_turn'; + return {content, stopReason, raw: response}; } /** @@ -187,6 +216,17 @@ However, the weather API accepts **any valid city name worldwide** — the dropd If a user asks "what cities are available", mention both the curated list and the ability to enter any city name. If asked for a city you're unsure about, use it anyway — the weather API will handle it.`; +const TOOL_USE_GUIDANCE = `## Tools + +You have tools available for performing app operations. When the user asks for an action like saving a view, switching themes, or opening a panel, you MUST invoke the tool via the tool_use mechanism — do NOT describe, simulate, or role-play tool calls in your text. Only the tool_use API actually executes actions; writing tool calls in text does nothing. + +**When to use tools vs. JSON specs:** +- Use **tools** for app operations (saving views, changing theme, opening panels). The tool definitions describe each one. +- Use **JSON specs** for building or modifying dashboard layouts (adding/removing/repositioning widgets). +- You can combine both in one turn — e.g. generate a dashboard spec AND call save_dashboard_as_view. + +After a tool executes, briefly confirm the result in your text response.`; + const OUTPUT_RULES = `## Output Rules IMPORTANT: Always output a complete, valid JSON spec wrapped in a \`\`\`json code fence. Include ALL widgets — both new ones and any existing ones the user didn't ask to change. Do not output partial specs or diffs. diff --git a/client-app/src/examples/weatherv2/svc/LlmToolService.ts b/client-app/src/examples/weatherv2/svc/LlmToolService.ts new file mode 100644 index 000000000..74d9363fd --- /dev/null +++ b/client-app/src/examples/weatherv2/svc/LlmToolService.ts @@ -0,0 +1,180 @@ +import {HoistService, XH} from '@xh/hoist/core'; +import {AppModel} from '../AppModel'; + +export interface ToolDefinition { + name: string; + description: string; + input_schema: { + type: 'object'; + properties: Record; + required?: string[]; + }; +} + +/** + * Service that defines and executes LLM tools (function calling). + * + * Tools are client-side operations that manipulate UI state — saving/loading views, + * toggling theme, opening panels. The LLM chooses when to call them based on the + * user's natural language requests. + */ +export class LlmToolService extends HoistService { + static instance: LlmToolService; + + /** Return Anthropic-format tool definitions for the API request. */ + getToolDefinitions(): ToolDefinition[] { + return TOOL_DEFINITIONS; + } + + /** Execute a tool call and return a result string. */ + async executeToolAsync( + name: string, + input: Record + ): Promise<{content: string; isError: boolean}> { + try { + const result = await this.doExecuteAsync(name, input); + return {content: result, isError: false}; + } catch (e) { + return {content: e.message || `Tool "${name}" failed.`, isError: true}; + } + } + + //------------------ + // Implementation + //------------------ + private async doExecuteAsync(name: string, input: Record): Promise { + const appModel = AppModel.instance, + viewManager = appModel.weatherViewManager; + + switch (name) { + case 'save_dashboard_as_view': { + const viewName = input.name as string; + if (!viewName?.trim()) throw new Error('View name is required.'); + await viewManager.saveAsAsync({ + name: viewName.trim(), + group: '', + description: '', + isShared: false, + isGlobal: false, + value: viewManager.getValue() + }); + return `View "${viewName.trim()}" saved successfully.`; + } + + case 'switch_to_view': { + const viewName = input.name as string; + if (!viewName?.trim()) throw new Error('View name is required.'); + const target = viewManager.views.find( + v => v.name.toLowerCase() === viewName.trim().toLowerCase() + ); + if (!target) { + const available = viewManager.views.map(v => v.name).join(', '); + throw new Error( + `No view found matching "${viewName}". Available views: ${available || 'none'}` + ); + } + await viewManager.selectViewAsync(target, {alertUnsavedChanges: false}); + return `Switched to view "${target.name}".`; + } + + case 'reset_dashboard': { + await viewManager.resetAsync(); + return "Dashboard reset to the current view's saved state."; + } + + case 'toggle_theme': { + XH.toggleTheme(); + const newTheme = XH.darkTheme ? 'dark' : 'light'; + return `Theme toggled to ${newTheme} mode.`; + } + + case 'open_widget_chooser': { + appModel.showWidgetChooser = true; + return 'Widget chooser panel opened.'; + } + + case 'show_json_spec': { + appModel.showJsonHarness = true; + return 'JSON spec editor opened.'; + } + + default: + throw new Error(`Unknown tool: "${name}"`); + } + } +} + +//-------------------------------------------------- +// Tool definitions in Anthropic API format +//-------------------------------------------------- +const TOOL_DEFINITIONS: ToolDefinition[] = [ + { + name: 'save_dashboard_as_view', + description: + 'Save the current dashboard layout as a new named view. The current dashboard state will be captured automatically.', + input_schema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'The name for the saved view (e.g. "Tokyo Overview")' + } + }, + required: ['name'] + } + }, + { + name: 'switch_to_view', + description: + "Switch to an existing saved view by name. The dashboard will update to show that view's layout.", + input_schema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'The name of the saved view to switch to' + } + }, + required: ['name'] + } + }, + { + name: 'reset_dashboard', + description: + "Reset the dashboard to the current view's last saved state, discarding any unsaved changes.", + input_schema: { + type: 'object', + properties: {}, + required: [] + } + }, + { + name: 'toggle_theme', + description: 'Toggle between light and dark theme for the entire application.', + input_schema: { + type: 'object', + properties: {}, + required: [] + } + }, + { + name: 'open_widget_chooser', + description: + 'Open the widget chooser panel so the user can manually browse and add widgets to the dashboard.', + input_schema: { + type: 'object', + properties: {}, + required: [] + } + }, + { + name: 'show_json_spec', + description: + 'Open the JSON spec editor panel so the user can view or manually edit the raw dashboard specification.', + input_schema: { + type: 'object', + properties: {}, + required: [] + } + } +]; diff --git a/grails-app/controllers/io/xh/toolbox/llm/LlmController.groovy b/grails-app/controllers/io/xh/toolbox/llm/LlmController.groovy index 6c300724f..425e521bf 100644 --- a/grails-app/controllers/io/xh/toolbox/llm/LlmController.groovy +++ b/grails-app/controllers/io/xh/toolbox/llm/LlmController.groovy @@ -16,18 +16,19 @@ class LlmController extends BaseController { /** * POST /llm/generate - * Body: {systemPrompt: string, messages: [{role, content}]} + * Body: {systemPrompt: string, messages: [{role, content}], tools?: [...]} * Returns: Anthropic API response */ def generate() { def body = parseRequestJSON() def systemPrompt = body.systemPrompt as String def messages = body.messages as List + def tools = body.tools as List if (!systemPrompt) throw new RuntimeException('systemPrompt is required.') if (!messages) throw new RuntimeException('messages array is required.') def username = authUsername - renderJSON(llmService.generate(systemPrompt, messages, username)) + renderJSON(llmService.generate(systemPrompt, messages, username, tools)) } } diff --git a/grails-app/services/io/xh/toolbox/llm/LlmService.groovy b/grails-app/services/io/xh/toolbox/llm/LlmService.groovy index e4d8348f7..7f05cbf52 100644 --- a/grails-app/services/io/xh/toolbox/llm/LlmService.groovy +++ b/grails-app/services/io/xh/toolbox/llm/LlmService.groovy @@ -41,14 +41,15 @@ class LlmService extends BaseService { * @param systemPrompt - system message for the LLM * @param messages - conversation messages [{role: 'user'|'assistant', content: '...'}] * @param username - for rate limiting + * @param tools - optional list of tool definitions for function calling * @return parsed response map from the Anthropic API */ - Map generate(String systemPrompt, List messages, String username) { + Map generate(String systemPrompt, List messages, String username, List tools = null) { checkApiKey() checkRateLimit(username) def model = configService.getString('llmModel', 'claude-sonnet-4-6'), - maxTokens = configService.getInt('llmMaxTokens', 4096), + maxTokens = configService.getInt('llmMaxTokens', 8192), apiKey = configService.getPwd('llmApiKey') def body = [ @@ -58,11 +59,17 @@ class LlmService extends BaseService { messages : messages ] + if (tools) { + body.tools = tools + } + + def serialized = JSONSerializer.serialize(body) + def post = new HttpPost('https://api.anthropic.com/v1/messages') post.setHeader('Content-Type', 'application/json') post.setHeader('x-api-key', apiKey) post.setHeader('anthropic-version', '2023-06-01') - post.setEntity(new StringEntity(JSONSerializer.serialize(body))) + post.setEntity(new StringEntity(serialized)) try { def response = client.executeAsMap(post) From 9fea272ab0172addbda53b964c5fc7f01932ae27 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sun, 1 Mar 2026 00:20:01 -0800 Subject: [PATCH 29/41] Fix save_dashboard_as_view tool creating views with empty string group Pass null instead of '' for the group parameter in saveAsAsync so views are created without a group rather than with an empty string group name. Co-Authored-By: Claude Opus 4.6 --- client-app/src/examples/weatherv2/svc/LlmToolService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client-app/src/examples/weatherv2/svc/LlmToolService.ts b/client-app/src/examples/weatherv2/svc/LlmToolService.ts index 74d9363fd..02a032a41 100644 --- a/client-app/src/examples/weatherv2/svc/LlmToolService.ts +++ b/client-app/src/examples/weatherv2/svc/LlmToolService.ts @@ -52,7 +52,7 @@ export class LlmToolService extends HoistService { if (!viewName?.trim()) throw new Error('View name is required.'); await viewManager.saveAsAsync({ name: viewName.trim(), - group: '', + group: null, description: '', isShared: false, isGlobal: false, From da9ac9425c84f024790fb1b3ab5a7bf8d9d1ef9e Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sun, 1 Mar 2026 11:24:20 -0800 Subject: [PATCH 30/41] Add widget configuration UI with settings modal, color-coded linkage indicators, and semantic input/output types Introduce a user-driven settings path for configurable dashboard widgets. Clicking the gear icon in a widget header opens a modal dialog showing the widget on the left and a settings form on the right. The form renders controls for declared inputs (with provider widget source selection) and config properties (switches, button groups, selects based on type). Key implementation details: - BaseWeatherWidgetModel creates a PanelModel with modal support for widgets that declare inputs or config. Reactively builds DashCanvasViewModel.headerItems with color dots and gear button. - settingsAwarePanel wraps widget content in a stable React tree (panel > hframe > frame > content) so charts/grids are never unmounted when toggling modal state. - colorCoding module assigns palette colors to input widgets, shown in their headers and in consumer widget headers to indicate linkage. - WeatherV2DashModel indexes duplicate input widget titles (e.g. "City #1", "City #2"). - Input/output types changed from generic 'string' to semantic types ('city', 'units') so the provider widget picker only offers compatible sources. Co-Authored-By: Claude Opus 4.6 --- .../src/examples/weatherv2/AppComponent.ts | 25 +- .../src/examples/weatherv2/WeatherV2.scss | 111 +++++ .../weatherv2/dash/WeatherV2DashModel.ts | 11 +- .../examples/weatherv2/dash/colorCoding.ts | 91 +++++ .../widgets/BaseWeatherWidgetModel.ts | 112 ++++- .../weatherv2/widgets/CityChooserWidget.ts | 6 +- .../widgets/CurrentConditionsWidget.ts | 9 +- .../weatherv2/widgets/ForecastChartWidget.ts | 10 +- .../widgets/MarkdownContentWidget.ts | 4 +- .../weatherv2/widgets/PrecipChartWidget.ts | 18 +- .../weatherv2/widgets/SummaryGridWidget.ts | 10 +- .../weatherv2/widgets/UnitsToggleWidget.ts | 8 +- .../weatherv2/widgets/WidgetSettingsForm.ts | 385 ++++++++++++++++++ .../weatherv2/widgets/WindChartWidget.ts | 10 +- .../weatherv2/widgets/settingsAwarePanel.ts | 34 ++ 15 files changed, 798 insertions(+), 46 deletions(-) create mode 100644 client-app/src/examples/weatherv2/dash/colorCoding.ts create mode 100644 client-app/src/examples/weatherv2/widgets/WidgetSettingsForm.ts create mode 100644 client-app/src/examples/weatherv2/widgets/settingsAwarePanel.ts diff --git a/client-app/src/examples/weatherv2/AppComponent.ts b/client-app/src/examples/weatherv2/AppComponent.ts index 77a06d771..0d5f216fc 100644 --- a/client-app/src/examples/weatherv2/AppComponent.ts +++ b/client-app/src/examples/weatherv2/AppComponent.ts @@ -1,7 +1,7 @@ import {hoistCmp, uses} from '@xh/hoist/core'; import {frame, hframe} from '@xh/hoist/cmp/layout'; import {appBar, appBarSeparator} from '@xh/hoist/desktop/cmp/appbar'; -import {button} from '@xh/hoist/desktop/cmp/button'; +import {button, dashCanvasAddViewButton} from '@xh/hoist/desktop/cmp/button'; import {panel} from '@xh/hoist/desktop/cmp/panel'; import {dashCanvas, dashCanvasWidgetChooser} from '@xh/hoist/desktop/cmp/dash'; import {viewManager} from '@xh/hoist/desktop/cmp/viewmanager'; @@ -18,7 +18,14 @@ export const AppComponent = hoistCmp({ model: uses(AppModel), render({model}) { - const {weatherV2DashModel, showJsonHarness, showChatHarness, showWidgetChooser} = model, + const { + weatherViewManager, + weatherV2DashModel, + showJsonHarness, + showChatHarness, + showWidgetChooser + } = model, + {dashCanvasModel} = weatherV2DashModel, showHarness = showChatHarness || showJsonHarness || showWidgetChooser; return panel({ @@ -26,7 +33,13 @@ export const AppComponent = hoistCmp({ icon: Icon.sun({size: '2x', prefix: 'fal'}), title: 'Weather V2', rightItems: [ - viewManager(), + viewManager({model: weatherViewManager}), + appBarSeparator(), + dashCanvasAddViewButton({ + dashCanvasModel, + rightIcon: Icon.chevronDown(), + outlined: true + }), appBarSeparator(), button({ testId: 'chat-btn', @@ -60,7 +73,7 @@ export const AppComponent = hoistCmp({ appMenuButtonProps: {hideLogoutItem: false} }), item: hframe( - frame(dashCanvas({model: weatherV2DashModel.dashCanvasModel})), + frame(dashCanvas({model: dashCanvasModel})), panel({ model: model.harnessPanelModel, items: [ @@ -73,9 +86,7 @@ export const AppComponent = hoistCmp({ icon: Icon.boxFull(), compactHeader: true, flex: 1, - item: dashCanvasWidgetChooser({ - dashCanvasModel: weatherV2DashModel.dashCanvasModel - }) + item: dashCanvasWidgetChooser({dashCanvasModel}) }) : null ], diff --git a/client-app/src/examples/weatherv2/WeatherV2.scss b/client-app/src/examples/weatherv2/WeatherV2.scss index e93b4e2fb..b8a330a62 100644 --- a/client-app/src/examples/weatherv2/WeatherV2.scss +++ b/client-app/src/examples/weatherv2/WeatherV2.scss @@ -565,3 +565,114 @@ border-top: var(--xh-border-solid); align-items: flex-end; } + +//------------------------------------------------------ +// Widget Settings Form — shown in modal alongside widget +//------------------------------------------------------ +.weather-v2-settings-form { + border-left: var(--xh-border-solid); + background: var(--xh-bg); + min-width: 280px; + max-width: 380px; + + &__header { + border-bottom: var(--xh-border-solid); + padding: 0 8px; + min-height: 32px; + gap: 6px; + font-weight: 600; + font-size: var(--xh-font-size-px); + } + + &__title { + font-weight: 600; + } + + &__body { + flex: 1; + overflow-y: auto; + padding: 12px; + } + + &__section { + &:not(:first-child) { + margin-top: 16px; + padding-top: 12px; + border-top: var(--xh-border-solid); + } + } + + &__section-title { + font-weight: 600; + font-size: var(--xh-font-size-small-px); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--xh-text-color-muted); + margin-bottom: 10px; + } + + &__field { + margin-bottom: 12px; + } + + &__field-label { + font-size: var(--xh-font-size-small-px); + font-weight: 500; + margin-bottom: 4px; + display: flex; + align-items: center; + } + + &__field-desc { + font-size: 11px; + color: var(--xh-text-color-muted); + margin-top: 3px; + line-height: 1.3; + } + + &__manual-input { + margin-top: 6px; + } + + &__linked-note { + margin-top: 4px; + font-size: var(--xh-font-size-small-px); + color: var(--xh-blue); + display: flex; + align-items: center; + gap: 4px; + } + + &__checkbox-group { + gap: 4px; + } + + &__checkbox-item { + gap: 6px; + padding: 2px 0; + } + + &__checkbox-label { + font-size: var(--xh-font-size-small-px); + } +} + +//------------------------------------------------------ +// Color-coded dot for input widget linkage indicators +//------------------------------------------------------ +.weather-v2-color-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 4px; + flex-shrink: 0; +} + +// Swatch row shown in consumer widget headers +.weather-v2-color-swatches { + display: flex; + gap: 3px; + align-items: center; + margin-left: 6px; +} diff --git a/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts b/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts index 52cae9ad7..cd4994c0b 100644 --- a/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts +++ b/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts @@ -234,9 +234,16 @@ export class WeatherV2DashModel extends HoistModel { return vm.viewState?.title ?? 'Markdown Content'; } - // Input widgets: static titles (always enforced so LLM-generated specs can't blank them) + // Input widgets: indexed titles when multiple of the same type exist const staticTitle = STATIC_WIDGET_TITLES[specId]; - if (staticTitle) return staticTitle; + if (staticTitle) { + const siblings = this.dashCanvasModel.viewModels.filter(v => v.viewSpec.id === specId); + if (siblings.length > 1) { + const idx = siblings.findIndex(v => v.id === vm.id) + 1; + return `${staticTitle} #${idx}`; + } + return staticTitle; + } // Display widgets: title = prefix + city (from binding or direct state) const titlePrefix = DISPLAY_WIDGET_TITLES[specId]; diff --git a/client-app/src/examples/weatherv2/dash/colorCoding.ts b/client-app/src/examples/weatherv2/dash/colorCoding.ts new file mode 100644 index 000000000..2f232584d --- /dev/null +++ b/client-app/src/examples/weatherv2/dash/colorCoding.ts @@ -0,0 +1,91 @@ +import {widgetRegistry} from './WidgetRegistry'; +import {AppModel} from '../AppModel'; + +/** + * Color palette for input widgets. Colors are assigned in order of widget + * appearance in the dashboard. Each input widget instance gets a stable color + * used in its header, the provider widget picker, and consumer widget headers. + */ +const INPUT_WIDGET_COLORS = [ + '#4A90D9', // Blue + '#7B61FF', // Purple + '#E67E22', // Orange + '#27AE60', // Green + '#E74C3C', // Red + '#16A085', // Teal + '#F39C12', // Amber + '#8E44AD' // Violet +]; + +/** + * Get the assigned color for an input widget instance. + * Returns undefined for non-input widgets. + */ +export function getInputWidgetColor(widgetId: string): string | undefined { + const dashModel = AppModel.instance?.weatherV2DashModel; + if (!dashModel) return undefined; + + const canvasModel = dashModel.dashCanvasModel, + inputVMs = canvasModel.viewModels.filter(vm => { + const meta = widgetRegistry.get(vm.viewSpec.id); + return meta?.category === 'input'; + }); + + const idx = inputVMs.findIndex(vm => vm.id === widgetId); + if (idx < 0) return undefined; + return INPUT_WIDGET_COLORS[idx % INPUT_WIDGET_COLORS.length]; +} + +/** + * Get all input widget colors as a map of widgetId → color. + * Used by WeatherV2DashModel for reactive title decoration. + */ +export function getAllInputWidgetColors(): Map { + const dashModel = AppModel.instance?.weatherV2DashModel; + if (!dashModel) return new Map(); + + const canvasModel = dashModel.dashCanvasModel, + result = new Map(); + + let colorIdx = 0; + for (const vm of canvasModel.viewModels) { + const meta = widgetRegistry.get(vm.viewSpec.id); + if (meta?.category === 'input') { + result.set(vm.id, INPUT_WIDGET_COLORS[colorIdx % INPUT_WIDGET_COLORS.length]); + colorIdx++; + } + } + return result; +} + +/** + * Get colors of provider widgets bound to a consuming widget. + * Returns an array of colors for all active input bindings. + */ +export function getConsumerWidgetColors(widgetId: string): string[] { + const dashModel = AppModel.instance?.weatherV2DashModel; + if (!dashModel) return []; + + const canvasModel = dashModel.dashCanvasModel, + vm = canvasModel.viewModels.find(v => v.id === widgetId); + if (!vm) return []; + + const bindings = vm.viewState?.bindings; + if (!bindings) return []; + + const colors: string[] = []; + const seenProviders = new Set(); + for (const binding of Object.values(bindings)) { + if (binding && typeof binding === 'object' && 'fromWidget' in binding) { + const providerId = (binding as any).fromWidget; + if (!seenProviders.has(providerId)) { + seenProviders.add(providerId); + const color = getInputWidgetColor(providerId); + if (color) colors.push(color); + } + } + } + return colors; +} + +export {INPUT_WIDGET_COLORS}; diff --git a/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts b/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts index 5996aabc7..bd23098ef 100644 --- a/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts +++ b/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts @@ -1,7 +1,12 @@ -import {HoistModel, lookup} from '@xh/hoist/core'; -import {DashViewModel} from '@xh/hoist/desktop/cmp/dash'; +import {createElement, ReactNode} from 'react'; +import {HoistModel, lookup, managed} from '@xh/hoist/core'; +import {DashCanvasViewModel, DashViewModel} from '@xh/hoist/desktop/cmp/dash'; +import {PanelModel} from '@xh/hoist/desktop/cmp/panel'; +import {button} from '@xh/hoist/desktop/cmp/button'; +import {Icon} from '@xh/hoist/icon'; import {WidgetMeta, BindingSpec} from '../dash/types'; import {WiringModel} from '../dash/WiringModel'; +import {getInputWidgetColor, getConsumerWidgetColors} from '../dash/colorCoding'; import {AppModel} from '../AppModel'; /** @@ -11,6 +16,8 @@ import {AppModel} from '../AppModel'; * - Access to the parent DashViewModel (for persistence). * - Input resolution via the WiringModel. * - Output publication via the WiringModel. + * - Modal-based settings support for configurable widgets. + * - Color-coded header indicators for widget linkages. * * Subclasses must define a static `meta: WidgetMeta` property * and register it with the WidgetRegistry. @@ -22,9 +29,99 @@ export abstract class BaseWeatherWidgetModel extends HoistModel { @lookup(() => DashViewModel) viewModel: DashViewModel; + /** + * PanelModel with modal support for the settings dialog. + * Created only for widgets that declare inputs or config properties. + */ + @managed panelModel: PanelModel; + + /** True if this widget type has user-configurable settings. */ + get hasSettings(): boolean { + const meta = (this.constructor as any).meta as WidgetMeta; + if (!meta) return false; + return meta.inputs.length > 0 || Object.keys(meta.config).length > 0; + } + + constructor() { + super(); + const meta = (this.constructor as any).meta as WidgetMeta; + const hasSettings = meta && (meta.inputs.length > 0 || Object.keys(meta.config).length > 0); + if (hasSettings) { + this.panelModel = new PanelModel({ + modalSupport: { + width: '80vw', + height: '70vh', + canOutsideClickClose: true + }, + collapsible: false, + resizable: false + }); + } + } + override onLinked() { super.onLinked(); this.persistWith = {dashViewModel: this.viewModel}; + + // Reactively build header items: color indicators + gear button. + // Tracks canvasModel.viewModels so colors update when widgets are added/removed. + this.addReaction({ + track: () => { + const meta = (this.constructor as any).meta as WidgetMeta; + if (!meta) return null; + const vmId = this.viewModel.id; + if (meta.category === 'input') return getInputWidgetColor(vmId); + if (meta.inputs.length > 0) return getConsumerWidgetColors(vmId); + return null; + }, + run: () => { + const canvasVM = this.viewModel as DashCanvasViewModel; + canvasVM.headerItems = this.buildHeaderItems(); + }, + fireImmediately: true, + delay: 1 + }); + } + + //-------------------------------------------------- + // Header Items + //-------------------------------------------------- + + /** Build the header items array with color indicators and settings gear button. */ + private buildHeaderItems(): ReactNode[] { + const meta = (this.constructor as any).meta as WidgetMeta, + items: ReactNode[] = []; + + if (!meta) return items; + + const vmId = this.viewModel.id; + + // Color dot for input widgets + if (meta.category === 'input') { + const color = getInputWidgetColor(vmId); + if (color) items.push(colorDot(color)); + } + + // Color swatches for consumer widgets showing linked providers + if (meta.inputs.length > 0) { + const colors = getConsumerWidgetColors(vmId); + for (const color of colors) { + items.push(colorDot(color)); + } + } + + // Gear button for configurable widgets + if (this.panelModel) { + items.push( + button({ + icon: Icon.gear(), + minimal: true, + onClick: () => this.panelModel.toggleIsModal() + }) + ); + } + + return items; } //-------------------------------------------------- @@ -71,7 +168,16 @@ export abstract class BaseWeatherWidgetModel extends HoistModel { //-------------------------------------------------- /** Access to the shared WiringModel via AppModel singleton. */ - private get wiringModel(): WiringModel { + protected get wiringModel(): WiringModel { return AppModel.instance.weatherV2DashModel.wiringModel; } } + +/** Create a small colored dot element for widget header color coding. */ +function colorDot(color: string): ReactNode { + return createElement('span', { + className: 'weather-v2-color-dot', + style: {backgroundColor: color}, + key: `color-${color}` + }); +} diff --git a/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts b/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts index f83e9c558..214afdeb9 100644 --- a/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts @@ -3,6 +3,7 @@ import {select} from '@xh/hoist/desktop/cmp/input'; import {box} from '@xh/hoist/cmp/layout'; import {bindable, makeObservable} from '@xh/hoist/mobx'; import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; +import {settingsAwarePanel} from './settingsAwarePanel'; import {widgetRegistry} from '../dash/WidgetRegistry'; import {WidgetMeta} from '../dash/types'; @@ -66,7 +67,7 @@ export class CityChooserModel extends BaseWeatherWidgetModel { category: 'input', inputs: [], outputs: [ - {name: 'selectedCity', type: 'string', description: 'The currently selected city name.'} + {name: 'selectedCity', type: 'city', description: 'The currently selected city name.'} ], config: { selectedCity: { @@ -124,7 +125,7 @@ export const cityChooserWidget = hoistCmp.factory({ model: creates(CityChooserModel), render({model}) { - return box({ + const content = box({ testId: 'city-chooser', padding: 8, flex: 1, @@ -138,5 +139,6 @@ export const cityChooserWidget = hoistCmp.factory({ width: '100%' }) }); + return settingsAwarePanel(model, content); } }); diff --git a/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts index 917aa7edd..7e7cdd126 100644 --- a/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts @@ -3,6 +3,7 @@ import {div, hbox, img, vbox} from '@xh/hoist/cmp/layout'; import {creates, hoistCmp, LoadSpec, managed, XH} from '@xh/hoist/core'; import {computed, makeObservable} from '@xh/hoist/mobx'; import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; +import {settingsAwarePanel} from './settingsAwarePanel'; import {widgetRegistry} from '../dash/WidgetRegistry'; import {fmtTemp, fmtWind} from '../dash/unitUtils'; import {WidgetMeta} from '../dash/types'; @@ -22,14 +23,14 @@ export class CurrentConditionsModel extends BaseWeatherWidgetModel { inputs: [ { name: 'city', - type: 'string', + type: 'city', required: true, default: 'New York', description: 'City to display.' }, { name: 'units', - type: 'string', + type: 'units', required: false, default: 'imperial', description: 'Unit system: "imperial" or "metric".' @@ -222,7 +223,7 @@ export const currentConditionsWidget = hoistCmp.factory({ const description = current.description.charAt(0).toUpperCase() + current.description.slice(1); - return vbox({ + const content = vbox({ testId: 'current-conditions', className: 'weather-v2-current-conditions', alignItems: 'center', @@ -253,5 +254,7 @@ export const currentConditionsWidget = hoistCmp.factory({ }) ] }); + + return settingsAwarePanel(model, content); } }); diff --git a/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts index 1e209fc14..71e2b615f 100644 --- a/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts @@ -1,9 +1,9 @@ import {chart, ChartModel} from '@xh/hoist/cmp/chart'; import {creates, hoistCmp, LoadSpec, managed, XH} from '@xh/hoist/core'; -import {panel} from '@xh/hoist/desktop/cmp/panel'; import {fmtDate} from '@xh/hoist/format'; import {computed, makeObservable} from '@xh/hoist/mobx'; import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; +import {settingsAwarePanel} from './settingsAwarePanel'; import {widgetRegistry} from '../dash/WidgetRegistry'; import {convertTemp, tempUnit} from '../dash/unitUtils'; import {WidgetMeta} from '../dash/types'; @@ -22,14 +22,14 @@ export class ForecastChartModel extends BaseWeatherWidgetModel { inputs: [ { name: 'city', - type: 'string', + type: 'city', required: true, default: 'New York', description: 'City to show forecast for.' }, { name: 'units', - type: 'string', + type: 'units', required: false, default: 'imperial', description: 'Unit system: "imperial" or "metric".' @@ -206,7 +206,7 @@ export const forecastChartWidget = hoistCmp.factory({ displayName: 'ForecastChartWidget', model: creates(ForecastChartModel), - render() { - return panel({testId: 'forecast-chart', item: chart()}); + render({model}) { + return settingsAwarePanel(model, chart()); } }); diff --git a/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts b/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts index 0bd65a0c6..7242a2cbe 100644 --- a/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts @@ -3,6 +3,7 @@ import {div} from '@xh/hoist/cmp/layout'; import {markdown} from '@xh/hoist/cmp/markdown'; import {bindable, makeObservable} from '@xh/hoist/mobx'; import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; +import {settingsAwarePanel} from './settingsAwarePanel'; import {widgetRegistry} from '../dash/WidgetRegistry'; import {WidgetMeta} from '../dash/types'; @@ -60,9 +61,10 @@ export const markdownContentWidget = hoistCmp.factory({ model: creates(MarkdownContentModel), render({model}) { - return div({ + const content = div({ className: 'weather-v2-markdown', item: markdown({content: model.content, lineBreaks: false}) }); + return settingsAwarePanel(model, content); } }); diff --git a/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts index 7ff70d2fb..c61f5afd3 100644 --- a/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts @@ -1,11 +1,11 @@ import {chart, ChartModel} from '@xh/hoist/cmp/chart'; import {placeholder} from '@xh/hoist/cmp/layout'; import {creates, hoistCmp, LoadSpec, managed, XH} from '@xh/hoist/core'; -import {panel} from '@xh/hoist/desktop/cmp/panel'; import {fmtDate} from '@xh/hoist/format'; import {Icon} from '@xh/hoist/icon'; import {bindable, computed, makeObservable} from '@xh/hoist/mobx'; import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; +import {settingsAwarePanel} from './settingsAwarePanel'; import {widgetRegistry} from '../dash/WidgetRegistry'; import {WidgetMeta} from '../dash/types'; import {WeatherData} from '../Types'; @@ -23,7 +23,7 @@ export class PrecipChartModel extends BaseWeatherWidgetModel { inputs: [ { name: 'city', - type: 'string', + type: 'city', required: true, default: 'New York', description: 'City to show precipitation for.' @@ -191,13 +191,11 @@ export const precipChartWidget = hoistCmp.factory({ model: creates(PrecipChartModel), render({model}) { - return panel({ - testId: 'precip-chart', - item: model.hasData - ? chart() - : placeholder({ - items: [Icon.sun(), 'No precipitation expected in the forecast period'] - }) - }); + const content = model.hasData + ? chart() + : placeholder({ + items: [Icon.sun(), 'No precipitation expected in the forecast period'] + }); + return settingsAwarePanel(model, content); } }); diff --git a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts index 01f053e1c..d13140bb4 100644 --- a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts @@ -1,10 +1,10 @@ import {grid, GridAutosizeMode, GridModel} from '@xh/hoist/cmp/grid'; import {img} from '@xh/hoist/cmp/layout'; import {creates, hoistCmp, LoadSpec, managed, XH} from '@xh/hoist/core'; -import {panel} from '@xh/hoist/desktop/cmp/panel'; import {computed, makeObservable} from '@xh/hoist/mobx'; import {groupBy} from 'lodash'; import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; +import {settingsAwarePanel} from './settingsAwarePanel'; import {widgetRegistry} from '../dash/WidgetRegistry'; import {fmtTemp, fmtWind} from '../dash/unitUtils'; import {WidgetMeta} from '../dash/types'; @@ -23,14 +23,14 @@ export class SummaryGridModel extends BaseWeatherWidgetModel { inputs: [ { name: 'city', - type: 'string', + type: 'city', required: true, default: 'New York', description: 'City to summarize.' }, { name: 'units', - type: 'string', + type: 'units', required: false, default: 'imperial', description: 'Unit system.' @@ -190,7 +190,7 @@ export const summaryGridWidget = hoistCmp.factory({ displayName: 'SummaryGridWidget', model: creates(SummaryGridModel), - render() { - return panel({testId: 'summary-grid', item: grid()}); + render({model}) { + return settingsAwarePanel(model, grid()); } }); diff --git a/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts b/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts index 696d178d2..9ae5530e3 100644 --- a/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts @@ -4,6 +4,7 @@ import {button} from '@xh/hoist/desktop/cmp/button'; import {buttonGroupInput} from '@xh/hoist/desktop/cmp/input'; import {bindable, makeObservable} from '@xh/hoist/mobx'; import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; +import {settingsAwarePanel} from './settingsAwarePanel'; import {widgetRegistry} from '../dash/WidgetRegistry'; import {WidgetMeta} from '../dash/types'; @@ -19,7 +20,7 @@ export class UnitsToggleModel extends BaseWeatherWidgetModel { category: 'input', inputs: [], outputs: [ - {name: 'units', type: 'string', description: 'Unit system: "imperial" or "metric".'} + {name: 'units', type: 'units', description: 'Unit system: "imperial" or "metric".'} ], config: { units: { @@ -62,8 +63,8 @@ export const unitsToggleWidget = hoistCmp.factory({ displayName: 'UnitsToggleWidget', model: creates(UnitsToggleModel), - render() { - return box({ + render({model}) { + const content = box({ testId: 'units-toggle', padding: 8, alignItems: 'center', @@ -82,5 +83,6 @@ export const unitsToggleWidget = hoistCmp.factory({ ] }) }); + return settingsAwarePanel(model, content); } }); diff --git a/client-app/src/examples/weatherv2/widgets/WidgetSettingsForm.ts b/client-app/src/examples/weatherv2/widgets/WidgetSettingsForm.ts new file mode 100644 index 000000000..515374b13 --- /dev/null +++ b/client-app/src/examples/weatherv2/widgets/WidgetSettingsForm.ts @@ -0,0 +1,385 @@ +import {hoistCmp} from '@xh/hoist/core'; +import {div, filler, hbox, span, vbox} from '@xh/hoist/cmp/layout'; +import { + select, + switchInput, + textInput, + numberInput, + buttonGroupInput +} from '@xh/hoist/desktop/cmp/input'; +import {button} from '@xh/hoist/desktop/cmp/button'; +import {toolbar} from '@xh/hoist/desktop/cmp/toolbar'; +import {Icon} from '@xh/hoist/icon'; +import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; +import {widgetRegistry} from '../dash/WidgetRegistry'; +import {InputDef, ConfigPropertyDef, WidgetMeta} from '../dash/types'; +import {AppModel} from '../AppModel'; +import {getInputWidgetColor} from '../dash/colorCoding'; + +//-------------------------------------------------- +// Constants +//-------------------------------------------------- +const SOURCE_DEFAULT = '__default__'; +const SOURCE_MANUAL = '__manual__'; + +//-------------------------------------------------- +// Component +//-------------------------------------------------- +/** + * Settings form for a configurable widget. + * Renders controls for the widget's declared inputs (with provider widget linkage) + * and config properties. Shown in the modal dialog alongside the widget preview. + */ +export const widgetSettingsForm = hoistCmp.factory({ + displayName: 'WidgetSettingsForm', + + render(props: any) { + const widgetModel: BaseWeatherWidgetModel = props.widgetModel; + const meta = (widgetModel.constructor as any).meta as WidgetMeta; + if (!meta) return null; + + const {inputs, config} = meta, + hasInputs = inputs.length > 0, + hasConfig = Object.keys(config).length > 0; + + return vbox({ + ...props, + className: 'weather-v2-settings-form', + items: [ + toolbar({ + className: 'weather-v2-settings-form__header', + items: [ + Icon.gear(), + span({className: 'weather-v2-settings-form__title', item: 'Settings'}), + filler(), + button({ + icon: Icon.close(), + minimal: true, + onClick: () => widgetModel.panelModel.toggleIsModal() + }) + ] + }), + div({ + className: 'weather-v2-settings-form__body', + items: [ + hasInputs ? renderInputsSection(widgetModel, inputs) : null, + hasConfig ? renderConfigSection(widgetModel, config) : null + ] + }) + ] + }); + } +}); + +//-------------------------------------------------- +// Inputs Section — configure where each input comes from +//-------------------------------------------------- +function renderInputsSection(widgetModel: BaseWeatherWidgetModel, inputs: InputDef[]) { + return vbox({ + className: 'weather-v2-settings-form__section', + items: [ + div({ + className: 'weather-v2-settings-form__section-title', + item: 'Inputs' + }), + ...inputs.map(inputDef => renderInputField(widgetModel, inputDef)) + ] + }); +} + +function renderInputField(widgetModel: BaseWeatherWidgetModel, inputDef: InputDef) { + const viewState = widgetModel.viewModel.viewState ?? {}, + bindings = viewState.bindings ?? {}, + binding = bindings[inputDef.name], + providers = findProviders(inputDef, widgetModel.viewModel.id), + currentSource = determineSource(binding, viewState[inputDef.name]), + isLinked = currentSource !== SOURCE_DEFAULT && currentSource !== SOURCE_MANUAL, + manualValue = viewState[inputDef.name] ?? inputDef.default ?? ''; + + const sourceOptions = [ + { + label: `Default${inputDef.default != null ? ` (${inputDef.default})` : ''}`, + value: SOURCE_DEFAULT + }, + {label: 'Set manually...', value: SOURCE_MANUAL}, + ...providers.map(p => ({ + label: formatProviderLabel(p), + value: `${p.widgetId}:${p.outputName}` + })) + ]; + + return vbox({ + className: 'weather-v2-settings-form__field', + items: [ + div({ + className: 'weather-v2-settings-form__field-label', + item: inputDef.name + }), + select({ + value: currentSource, + options: sourceOptions, + width: '100%', + onChange: (val: string) => handleSourceChange(widgetModel, inputDef, val) + }), + currentSource === SOURCE_MANUAL + ? textInput({ + className: 'weather-v2-settings-form__manual-input', + value: manualValue?.toString() ?? '', + width: '100%', + placeholder: `Enter ${inputDef.name}...`, + onCommit: (val: string) => { + const vs = {...(widgetModel.viewModel.viewState ?? {})}; + vs[inputDef.name] = val; + widgetModel.viewModel.setViewState(vs); + } + }) + : null, + isLinked + ? div({ + className: 'weather-v2-settings-form__linked-note', + items: [Icon.link(), span(' Linked to provider widget')] + }) + : null + ] + }); +} + +//-------------------------------------------------- +// Config Section — widget-specific configuration +//-------------------------------------------------- +function renderConfigSection( + widgetModel: BaseWeatherWidgetModel, + config: Record +) { + return vbox({ + className: 'weather-v2-settings-form__section', + items: [ + div({ + className: 'weather-v2-settings-form__section-title', + item: 'Configuration' + }), + ...Object.entries(config).map(([key, def]) => renderConfigField(widgetModel, key, def)) + ] + }); +} + +function renderConfigField( + widgetModel: BaseWeatherWidgetModel, + configKey: string, + configDef: ConfigPropertyDef +) { + const viewState = widgetModel.viewModel.viewState ?? {}, + currentValue = viewState[configKey] ?? configDef.default; + + const onChange = (val: any) => { + widgetModel.viewModel.setViewStateKey(configKey, val); + }; + + let control; + switch (configDef.type) { + case 'boolean': + control = switchInput({ + value: !!currentValue, + onChange, + inline: true, + label: null + }); + break; + + case 'enum': + if (configDef.enum && configDef.enum.length <= 3) { + control = buttonGroupInput({ + value: currentValue, + onChange, + outlined: true, + width: '100%', + items: configDef.enum.map(opt => button({text: opt, value: opt, flex: 1})) + }); + } else { + control = select({ + value: currentValue, + options: configDef.enum ?? [], + onChange, + width: '100%' + }); + } + break; + + case 'number': + control = numberInput({ + value: currentValue, + onCommit: onChange, + width: '100%', + min: configDef.min, + max: configDef.max + }); + break; + + case 'string[]': + control = renderStringArrayControl(currentValue, configDef, onChange); + break; + + case 'string': + default: + control = textInput({ + value: currentValue?.toString() ?? '', + onCommit: onChange, + width: '100%' + }); + break; + } + + return vbox({ + className: 'weather-v2-settings-form__field', + items: [ + hbox({ + className: 'weather-v2-settings-form__field-label', + items: [span(configKey), filler(), configDef.type === 'boolean' ? control : null] + }), + configDef.type !== 'boolean' ? control : null, + configDef.description + ? div({ + className: 'weather-v2-settings-form__field-desc', + item: configDef.description + }) + : null + ] + }); +} + +/** Control for string[] config — renders switches for known options or a text input fallback. */ +function renderStringArrayControl( + value: string[], + configDef: ConfigPropertyDef, + onChange: (val: string[]) => void +) { + const knownOptions = extractOptionsFromDesc(configDef.description); + if (knownOptions.length > 0) { + const currentSet = new Set(value ?? []); + return vbox({ + className: 'weather-v2-settings-form__checkbox-group', + items: knownOptions.map(opt => + hbox({ + className: 'weather-v2-settings-form__checkbox-item', + alignItems: 'center', + items: [ + switchInput({ + value: currentSet.has(opt), + onChange: (checked: boolean) => { + const next = new Set(currentSet); + if (checked) next.add(opt); + else next.delete(opt); + onChange(Array.from(next)); + }, + inline: true, + label: null + }), + span({ + className: 'weather-v2-settings-form__checkbox-label', + item: opt + }) + ] + }) + ) + }); + } + + return textInput({ + value: (value ?? []).join(', '), + onCommit: (val: string) => { + onChange( + val + .split(',') + .map(s => s.trim()) + .filter(Boolean) + ); + }, + width: '100%', + placeholder: 'Comma-separated values...' + }); +} + +//-------------------------------------------------- +// Helpers +//-------------------------------------------------- + +interface ProviderInfo { + widgetId: string; + outputName: string; + widgetTitle: string; + specId: string; +} + +/** Find all widget instances that produce outputs compatible with the given input. */ +function findProviders(inputDef: InputDef, currentWidgetId: string): ProviderInfo[] { + const dashModel = AppModel.instance.weatherV2DashModel, + canvasModel = dashModel.dashCanvasModel, + providers: ProviderInfo[] = []; + + for (const vm of canvasModel.viewModels) { + if (vm.id === currentWidgetId) continue; + const meta = widgetRegistry.get(vm.viewSpec.id); + if (!meta) continue; + for (const output of meta.outputs) { + if (output.type === inputDef.type) { + providers.push({ + widgetId: vm.id, + outputName: output.name, + widgetTitle: vm.title ?? meta.title, + specId: vm.viewSpec.id + }); + } + } + } + return providers; +} + +/** Determine the current source setting for an input from its binding/value. */ +function determineSource(binding: any, directValue: any): string { + if (binding) { + if ('fromWidget' in binding) return `${binding.fromWidget}:${binding.output}`; + if ('const' in binding) return SOURCE_MANUAL; + } + if (directValue !== undefined) return SOURCE_MANUAL; + return SOURCE_DEFAULT; +} + +/** Handle changes to the source dropdown for an input. */ +function handleSourceChange( + widgetModel: BaseWeatherWidgetModel, + inputDef: InputDef, + newSource: string +) { + const viewState = {...(widgetModel.viewModel.viewState ?? {})}, + bindings = {...(viewState.bindings ?? {})}; + + if (newSource === SOURCE_DEFAULT) { + delete bindings[inputDef.name]; + delete viewState[inputDef.name]; + } else if (newSource === SOURCE_MANUAL) { + delete bindings[inputDef.name]; + viewState[inputDef.name] = inputDef.default ?? ''; + } else { + // Provider: "widgetId:outputName" + const [fromWidget, output] = newSource.split(':'); + bindings[inputDef.name] = {fromWidget, output}; + delete viewState[inputDef.name]; + } + + viewState.bindings = Object.keys(bindings).length > 0 ? bindings : undefined; + widgetModel.viewModel.setViewState(viewState); +} + +/** Format a provider widget label for the source dropdown. */ +function formatProviderLabel(provider: ProviderInfo): string { + const color = getInputWidgetColor(provider.widgetId); + const colorPrefix = color ? '\u25CF ' : ''; + return `${colorPrefix}${provider.widgetTitle}`; +} + +/** Try to extract known options from a config description (e.g., quoted strings). */ +function extractOptionsFromDesc(description: string): string[] { + const matches = description.match(/"([^"]+)"/g); + if (!matches || matches.length < 2) return []; + return matches.map(m => m.replace(/"/g, '')); +} diff --git a/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts index 7517c28ae..0ab26bcd4 100644 --- a/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts @@ -1,9 +1,9 @@ import {chart, ChartModel} from '@xh/hoist/cmp/chart'; import {creates, hoistCmp, LoadSpec, managed, XH} from '@xh/hoist/core'; -import {panel} from '@xh/hoist/desktop/cmp/panel'; import {fmtDate} from '@xh/hoist/format'; import {computed, makeObservable} from '@xh/hoist/mobx'; import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; +import {settingsAwarePanel} from './settingsAwarePanel'; import {widgetRegistry} from '../dash/WidgetRegistry'; import {convertWind, windUnit} from '../dash/unitUtils'; import {WidgetMeta} from '../dash/types'; @@ -21,14 +21,14 @@ export class WindChartModel extends BaseWeatherWidgetModel { inputs: [ { name: 'city', - type: 'string', + type: 'city', required: true, default: 'New York', description: 'City to show wind data for.' }, { name: 'units', - type: 'string', + type: 'units', required: false, default: 'imperial', description: 'Unit system (mph vs m/s).' @@ -194,7 +194,7 @@ export const windChartWidget = hoistCmp.factory({ displayName: 'WindChartWidget', model: creates(WindChartModel), - render() { - return panel({testId: 'wind-chart', item: chart()}); + render({model}) { + return settingsAwarePanel(model, chart()); } }); diff --git a/client-app/src/examples/weatherv2/widgets/settingsAwarePanel.ts b/client-app/src/examples/weatherv2/widgets/settingsAwarePanel.ts new file mode 100644 index 000000000..046bb38e3 --- /dev/null +++ b/client-app/src/examples/weatherv2/widgets/settingsAwarePanel.ts @@ -0,0 +1,34 @@ +import {ReactNode} from 'react'; +import {frame, hframe} from '@xh/hoist/cmp/layout'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; +import {widgetSettingsForm} from './WidgetSettingsForm'; + +/** + * Wraps widget content in a panel with modal support for settings. + * + * Uses a stable React tree structure (panel > hframe > frame > content) regardless + * of modal state so that content components (charts, grids) are never unmounted + * and remounted when toggling the settings modal. + * + * When modal is inactive, the settings form is simply omitted from the hframe, + * leaving content as the sole child filling 100% of the available space. + * + * Widgets without settings (no PanelModel) return the content unchanged. + */ +export function settingsAwarePanel(model: BaseWeatherWidgetModel, content: ReactNode): ReactNode { + const {panelModel} = model; + if (!panelModel) return content; + + return panel({ + model: panelModel, + flex: 1, + item: hframe({ + flex: 1, + items: [ + frame({flex: 1, item: content}), + panelModel.isModal ? widgetSettingsForm({widgetModel: model}) : null + ] + }) + }); +} From fe68479f8b94df12ee027730089a8b2d2d8e5219 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sun, 1 Mar 2026 15:55:43 -0800 Subject: [PATCH 31/41] Add input binding lifecycle management with stale binding culling and simplified settings form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automatically cull stale bindings when a provider widget is removed from the dashboard, and seed declared input defaults into viewState when widgets are added. Simplify the resolveInput fallback chain to binding → manual value → undefined (removing the meta-default step, since defaults are now seeded at addition time). Replace the three-state settings form model: inputs are either Linked (bound to a provider), Manual (direct value), or Unbound (needs configuration) — the old "Default" option is removed. Input widgets (CityChooser, UnitsToggle) no longer expose their primary output value in the settings form — only ancillary config like enableSearch appears there. Co-Authored-By: Claude Opus 4.6 --- .../src/examples/weatherv2/WeatherV2.scss | 18 ++ .../weatherv2/dash/WeatherV2DashModel.ts | 85 ++++++ .../examples/weatherv2/dash/WidgetRegistry.ts | 3 +- .../src/examples/weatherv2/dash/types.ts | 2 +- .../src/examples/weatherv2/dash/validation.ts | 18 +- .../examples/weatherv2/planning/PROGRESS.md | 31 ++ .../weatherv2/planning/WIRING-DESIGN.md | 29 +- .../examples/weatherv2/svc/LlmChatService.ts | 2 +- .../widgets/BaseWeatherWidgetModel.ts | 96 +++++- .../weatherv2/widgets/CityChooserWidget.ts | 6 - .../widgets/CurrentConditionsWidget.ts | 14 +- .../weatherv2/widgets/ForecastChartWidget.ts | 3 +- .../widgets/MarkdownContentWidget.ts | 1 - .../weatherv2/widgets/PrecipChartWidget.ts | 1 - .../weatherv2/widgets/SummaryGridWidget.ts | 1 - .../weatherv2/widgets/UnitsToggleWidget.ts | 9 +- .../weatherv2/widgets/WidgetSettingsForm.ts | 280 +++++++----------- .../weatherv2/widgets/WindChartWidget.ts | 13 +- .../weatherv2/widgets/settingsAwarePanel.ts | 2 +- 19 files changed, 376 insertions(+), 238 deletions(-) diff --git a/client-app/src/examples/weatherv2/WeatherV2.scss b/client-app/src/examples/weatherv2/WeatherV2.scss index b8a330a62..1874135c9 100644 --- a/client-app/src/examples/weatherv2/WeatherV2.scss +++ b/client-app/src/examples/weatherv2/WeatherV2.scss @@ -643,6 +643,24 @@ gap: 4px; } + &__unbound-note { + margin-top: 4px; + font-size: var(--xh-font-size-small-px); + color: var(--xh-orange); + display: flex; + align-items: center; + gap: 4px; + } + + &__field-label--warning { + color: var(--xh-orange); + } + + &__warning-icon { + margin-right: 4px; + color: var(--xh-orange); + } + &__checkbox-group { gap: 4px; } diff --git a/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts b/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts index cd4994c0b..138b6f015 100644 --- a/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts +++ b/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts @@ -4,6 +4,7 @@ import {DashCanvasModel, DashViewModel} from '@xh/hoist/desktop/cmp/dash'; import {Icon} from '@xh/hoist/icon'; import {makeObservable} from '@xh/hoist/mobx'; import {WiringModel} from './WiringModel'; +import {widgetRegistry} from './WidgetRegistry'; import {temperatureIcon, cloudRainIcon, calendarDaysIcon, windIcon} from '../Icons'; import {cityChooserWidget} from '../widgets/CityChooserWidget'; @@ -204,6 +205,33 @@ export class WeatherV2DashModel extends HoistModel { ] }); + // Widget lifecycle: track viewModel additions/removals to cull stale bindings + // and initialize input defaults. Registered BEFORE auto-title so culled viewState + // is settled before title computation runs. + let previousIds = new Set(); + this.addReaction({ + track: () => new Set(this.dashCanvasModel.viewModels.map(vm => vm.id)), + run: currentIds => { + // On removal: cull wiring outputs and stale bindings + for (const id of previousIds) { + if (!currentIds.has(id)) { + this.wiringModel.removeWidget(id); + this.cullBindingsTo(id); + } + } + + // On addition: seed input defaults into viewState + for (const id of currentIds) { + if (!previousIds.has(id)) { + this.initInputDefaults(id); + } + } + + previousIds = currentIds; + }, + fireImmediately: true + }); + // Auto-title: reactively set titles on display widgets from their bound city. // Runs at this level (not in content models) because DashCanvas may lazily render // widget content — DashViewModels always exist regardless of render state. @@ -221,6 +249,63 @@ export class WeatherV2DashModel extends HoistModel { }); } + //-------------------------------------------------- + // Widget Lifecycle + //-------------------------------------------------- + + /** Remove bindings that reference a removed widget from all remaining widgets. */ + private cullBindingsTo(removedId: string) { + for (const vm of this.dashCanvasModel.viewModels) { + const bindings = vm.viewState?.bindings; + if (!bindings) continue; + + let changed = false; + const updated = {...bindings}; + for (const [inputName, binding] of Object.entries(updated)) { + if ( + binding && + typeof binding === 'object' && + 'fromWidget' in binding && + (binding as any).fromWidget === removedId + ) { + delete updated[inputName]; + changed = true; + } + } + + if (changed) { + const vs = {...vm.viewState}; + vs.bindings = Object.keys(updated).length > 0 ? updated : undefined; + vm.setViewState(vs); + } + } + } + + /** Seed declared input defaults into viewState for a newly added widget. */ + private initInputDefaults(widgetId: string) { + const vm = this.dashCanvasModel.viewModels.find(v => v.id === widgetId); + if (!vm) return; + + const meta = widgetRegistry.get(vm.viewSpec.id); + if (!meta) return; + + let changed = false; + const vs = {...(vm.viewState ?? {})}; + + for (const input of meta.inputs) { + if (input.default === undefined) continue; + // Only seed if neither a binding nor a manual value already exists + const hasBinding = vs.bindings?.[input.name] != null; + const hasManualValue = vs[input.name] !== undefined; + if (!hasBinding && !hasManualValue) { + vs[input.name] = input.default; + changed = true; + } + } + + if (changed) vm.setViewState(vs); + } + //-------------------------------------------------- // Auto-Title //-------------------------------------------------- diff --git a/client-app/src/examples/weatherv2/dash/WidgetRegistry.ts b/client-app/src/examples/weatherv2/dash/WidgetRegistry.ts index 6a7a6db7c..2119efa93 100644 --- a/client-app/src/examples/weatherv2/dash/WidgetRegistry.ts +++ b/client-app/src/examples/weatherv2/dash/WidgetRegistry.ts @@ -79,7 +79,8 @@ class WidgetRegistryImpl { def.default !== undefined ? `, default: ${JSON.stringify(def.default)}` : ''; - lines.push(` - ${key} (${typeStr}${defStr}) — ${def.description}`); + const descStr = def.description ? ` — ${def.description}` : ''; + lines.push(` - ${key} (${typeStr}${defStr})${descStr}`); } } diff --git a/client-app/src/examples/weatherv2/dash/types.ts b/client-app/src/examples/weatherv2/dash/types.ts index bfd7d0e9c..0d3b1a896 100644 --- a/client-app/src/examples/weatherv2/dash/types.ts +++ b/client-app/src/examples/weatherv2/dash/types.ts @@ -40,7 +40,7 @@ export interface OutputDef { /** Config property definition within a widget schema. */ export interface ConfigPropertyDef { type: 'string' | 'number' | 'boolean' | 'enum' | 'string[]'; - description: string; + description?: string; default?: any; enum?: string[]; min?: number; diff --git a/client-app/src/examples/weatherv2/dash/validation.ts b/client-app/src/examples/weatherv2/dash/validation.ts index e60b2cb14..4f2c26c96 100644 --- a/client-app/src/examples/weatherv2/dash/validation.ts +++ b/client-app/src/examples/weatherv2/dash/validation.ts @@ -171,8 +171,10 @@ function validateSemantic( } } - // Warn about unknown state keys (excluding 'bindings' and known config) - const knownKeys = new Set([...configKeys, 'bindings']); + // Warn about unknown state keys (excluding 'bindings', known config, input names, and output names) + const inputNames = meta.inputs.map(i => i.name); + const outputNames = meta.outputs.map(o => o.name); + const knownKeys = new Set([...configKeys, ...inputNames, ...outputNames, 'bindings']); for (const key of Object.keys(widgetState)) { if (!knownKeys.has(key)) { warnings.push( @@ -204,15 +206,19 @@ function validateSemantic( } } - // Warn about required inputs without bindings + // Warn about required inputs without bindings or manual values for (const input of meta.inputs) { - if (input.required && !bindings?.[input.name]) { + if ( + input.required && + !bindings?.[input.name] && + widgetState[input.name] === undefined + ) { warnings.push( msg( 'warning', - `${path}.state.bindings`, + `${path}.state`, 'UNBOUND_REQUIRED_INPUT', - `Required input "${input.name}" on ${widget.viewSpecId} has no binding. Default value will be used.` + `Required input "${input.name}" on ${widget.viewSpecId} has no binding or manual value. Widget is unconfigured.` ) ); } diff --git a/client-app/src/examples/weatherv2/planning/PROGRESS.md b/client-app/src/examples/weatherv2/planning/PROGRESS.md index ba58cd756..db462f22f 100644 --- a/client-app/src/examples/weatherv2/planning/PROGRESS.md +++ b/client-app/src/examples/weatherv2/planning/PROGRESS.md @@ -217,3 +217,34 @@ Added Anthropic native tool use API to give the LLM callable tools for app opera - **Client (modified):** `harness/ChatHarnessPanel.ts` — tool calls rendered as collapsible `
` elements, tool-use suggestion in empty state - **Client (modified):** `WeatherV2.scss` — `.weather-v2-tool-call` styles - **Client (modified):** `AppModel.ts` + `Bootstrap.ts` — LlmToolService registration + type augmentation + +## 2026-03-01 + +### Input Binding Lifecycle — Culling, Simplified Sources, Unbound State + +Three related changes to how widget input bindings are managed: + +**1. Binding culling on widget removal (`WeatherV2DashModel.ts`)** +- Added MobX reaction tracking `dashCanvasModel.viewModels` IDs +- On widget removal: calls `wiringModel.removeWidget()` AND scans all remaining widgets to delete bindings referencing the removed widget +- On widget addition: seeds declared input defaults into `viewState` (only if neither binding nor manual value already exists) +- Registered before the auto-title reaction so culled viewState settles first + +**2. Simplified `resolveInput` fallback chain (`BaseWeatherWidgetModel.ts`)** +- Removed the meta-default fallback (step 3) +- Chain is now: binding → manual viewState value → `undefined` (unbound) +- Input defaults are seeded into viewState at addition time, so the meta fallback is redundant + +**3. Simplified settings form source model (`WidgetSettingsForm.ts`)** +- Removed `SOURCE_DEFAULT` constant and "Default" dropdown option +- Added `SOURCE_UNBOUND` sentinel for unconfigured inputs +- Three states: Linked (bound to provider), Manual (direct value), Unbound (needs configuration) +- Warning icon and note shown for unbound required inputs +- "Not configured" option only appears when input is currently unbound + +**4. Validation updates (`validation.ts`)** +- `UNBOUND_REQUIRED_INPUT` now checks for missing binding AND missing manual value +- Input names added to `knownKeys` set so manual values don't trigger `UNKNOWN_STATE_KEY` warnings + +**5. Unbound state styling (`WeatherV2.scss`)** +- Warning-colored note and icon styles for unbound inputs in settings form diff --git a/client-app/src/examples/weatherv2/planning/WIRING-DESIGN.md b/client-app/src/examples/weatherv2/planning/WIRING-DESIGN.md index 3678cfc4f..ea70a777d 100644 --- a/client-app/src/examples/weatherv2/planning/WIRING-DESIGN.md +++ b/client-app/src/examples/weatherv2/planning/WIRING-DESIGN.md @@ -46,7 +46,9 @@ Three binding types: |------|-----------|---------| | Widget output | `{"fromWidget": "city1", "output": "selectedCity"}` | Read another widget's output | | Constant | `{"const": "New York"}` | Static value | -| Unbound | *(key absent)* | Use input's default value | +| Unbound | *(no binding, no manual value in viewState)* | Widget is unconfigured — needs reconfiguration | + +Note: Input defaults are seeded into `viewState` at widget-addition time. When a binding is culled (e.g. the provider widget is removed) and no manual value exists, the input is unbound and `resolveInput()` returns `undefined`. ### Widget Instance IDs @@ -124,17 +126,20 @@ abstract class WeatherWidgetModel extends HoistModel { this.persistWith = {dashViewModel: this.viewModel}; } - /** Resolve an input binding to its current value. */ + /** Resolve an input binding to its current value. + * Chain: binding → manual viewState value → undefined (unbound). + * Input defaults are seeded into viewState at widget-addition time, + * so there is no meta-default fallback here. */ protected resolveInput(inputName: string): T | undefined { - const bindings = this.viewModel.viewState?.bindings ?? {}; - const binding = bindings[inputName]; - if (!binding) { - // Return default from meta - const inputDef = (this.constructor as any).meta?.inputs - ?.find(i => i.name === inputName); - return inputDef?.default; + const viewState = this.viewModel.viewState; + const binding = viewState?.bindings?.[inputName]; + if (binding) { + const resolved = this.wiringModel.resolveBinding(binding); + if (resolved !== undefined) return resolved; } - return this.wiringModel.resolveBinding(binding); + const directValue = viewState?.[inputName]; + if (directValue !== undefined) return directValue; + return undefined; } /** Publish an output value. */ @@ -223,8 +228,8 @@ Chart re-renders with London data ### Runtime Validation -1. **Missing output value.** If a widget hasn't published its output yet (e.g., still loading), downstream inputs resolve to `undefined` → widget uses its default. -2. **Widget removal.** When a widget is removed from the canvas, `WiringModel.removeWidget()` clears its outputs. Downstream widgets fall back to defaults. Bindings referencing the removed widget become dangling — this is acceptable at runtime (widget shows default state) but should warn in the JSON harness. +1. **Missing output value.** If a widget hasn't published its output yet (e.g., still loading), downstream inputs resolve to `undefined` → widget uses its `??` fallback. +2. **Widget removal.** When a widget is removed from the canvas, `WeatherV2DashModel` automatically: (a) calls `WiringModel.removeWidget()` to clear its outputs, and (b) scans all remaining widgets' `viewState.bindings` to cull any entries referencing the removed widget. Culled inputs become unbound (`resolveInput` returns `undefined`), and the settings form shows them as "Not configured" with a warning indicator. ## Cycle Prevention diff --git a/client-app/src/examples/weatherv2/svc/LlmChatService.ts b/client-app/src/examples/weatherv2/svc/LlmChatService.ts index d4756955a..e2fc9f698 100644 --- a/client-app/src/examples/weatherv2/svc/LlmChatService.ts +++ b/client-app/src/examples/weatherv2/svc/LlmChatService.ts @@ -212,7 +212,7 @@ const CITY_RULES = `## Available Cities The city chooser dropdown includes these curated cities: {{CITIES}}. -However, the weather API accepts **any valid city name worldwide** — the dropdown also allows users to type in custom cities. When the user asks about a city not in the list, you can use it directly in the \`selectedCity\` config. Use the standard English name for the city (e.g. "Munich" not "München", "Rome" not "Roma"). +However, the weather API accepts **any valid city name worldwide** — the dropdown also allows users to type in custom cities. When the user asks about a city not in the list, you can set it directly via \`state.selectedCity\` on the cityChooser widget. Use the standard English name for the city (e.g. "Munich" not "München", "Rome" not "Roma"). If a user asks "what cities are available", mention both the curated list and the ability to enter any city name. If asked for a city you're unsure about, use it anyway — the weather API will handle it.`; diff --git a/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts b/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts index bd23098ef..856e0833e 100644 --- a/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts +++ b/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts @@ -2,9 +2,12 @@ import {createElement, ReactNode} from 'react'; import {HoistModel, lookup, managed} from '@xh/hoist/core'; import {DashCanvasViewModel, DashViewModel} from '@xh/hoist/desktop/cmp/dash'; import {PanelModel} from '@xh/hoist/desktop/cmp/panel'; +import {FormModel} from '@xh/hoist/cmp/form'; +import {required, numberIs} from '@xh/hoist/data'; import {button} from '@xh/hoist/desktop/cmp/button'; import {Icon} from '@xh/hoist/icon'; -import {WidgetMeta, BindingSpec} from '../dash/types'; +import {isEqual} from 'lodash'; +import {WidgetMeta, BindingSpec, ConfigPropertyDef} from '../dash/types'; import {WiringModel} from '../dash/WiringModel'; import {getInputWidgetColor, getConsumerWidgetColors} from '../dash/colorCoding'; import {AppModel} from '../AppModel'; @@ -35,6 +38,12 @@ export abstract class BaseWeatherWidgetModel extends HoistModel { */ @managed panelModel: PanelModel; + /** + * FormModel for config property editing in the settings dialog. + * Created only for widgets that declare config properties. + */ + @managed configFormModel: FormModel; + /** True if this widget type has user-configurable settings. */ get hasSettings(): boolean { const meta = (this.constructor as any).meta as WidgetMeta; @@ -57,12 +66,68 @@ export abstract class BaseWeatherWidgetModel extends HoistModel { resizable: false }); } + + const configEntries = meta ? Object.entries(meta.config) : []; + if (configEntries.length > 0) { + this.configFormModel = new FormModel({ + fields: configEntries.map(([key, def]) => configPropertyToField(key, def)) + }); + } } override onLinked() { super.onLinked(); this.persistWith = {dashViewModel: this.viewModel}; + // Init configFormModel from current viewState and set up bidirectional sync. + if (this.configFormModel) { + const meta = (this.constructor as any).meta as WidgetMeta, + configKeys = Object.keys(meta.config); + + // Init form values from persisted viewState. + const initialValues: Record = {}; + for (const key of configKeys) { + initialValues[key] = this.viewModel.viewState?.[key] ?? meta.config[key].default; + } + this.configFormModel.init(initialValues); + + // Sync form → viewState: push form changes to persisted state. + this.addReaction({ + track: () => { + const vals: Record = {}; + for (const key of configKeys) vals[key] = this.configFormModel.values[key]; + return vals; + }, + run: vals => { + for (const key of configKeys) { + this.viewModel.setViewStateKey(key, vals[key]); + } + } + }); + + // Sync viewState → form: reflect external changes (e.g. LLM updates). + this.addReaction({ + track: () => { + const vs = this.viewModel.viewState ?? {}, + vals: Record = {}; + for (const key of configKeys) vals[key] = vs[key]; + return vals; + }, + run: vals => { + const formVals = this.configFormModel.values, + updates: Record = {}; + let hasUpdates = false; + for (const key of configKeys) { + if (!isEqual(vals[key], formVals[key])) { + updates[key] = vals[key]; + hasUpdates = true; + } + } + if (hasUpdates) this.configFormModel.setValues(updates); + } + }); + } + // Reactively build header items: color indicators + gear button. // Tracks canvasModel.viewModels so colors update when widgets are added/removed. this.addReaction({ @@ -130,9 +195,14 @@ export abstract class BaseWeatherWidgetModel extends HoistModel { /** * Resolve a named input to its current value. - * Reads the binding from persisted viewState and resolves it - * through the WiringModel. Returns the input's default value - * if no binding is defined. + * + * Fallback chain: + * 1. Binding (fromWidget or const) → resolved value + * 2. Direct viewState value (manual / LLM-set) + * 3. undefined (unbound — widget needs reconfiguration) + * + * Input defaults are seeded into viewState at widget-addition time by + * WeatherV2DashModel, so there is no meta-default fallback here. */ protected resolveInput(inputName: string): T | undefined { const viewState = this.viewModel.viewState; @@ -144,14 +214,12 @@ export abstract class BaseWeatherWidgetModel extends HoistModel { if (resolved !== undefined) return resolved as T; } - // 2. Fall back to direct state value (e.g. LLM sets {city: "Tokyo"} without a binding) + // 2. Fall back to direct state value (manual or seeded default) const directValue = viewState?.[inputName]; if (directValue !== undefined) return directValue as T; - // 3. Fall back to declared default from widget meta - const meta = (this.constructor as any).meta as WidgetMeta; - const inputDef = meta?.inputs?.find(i => i.name === inputName); - return inputDef?.default as T; + // 3. Unbound — no binding and no manual value + return undefined; } //-------------------------------------------------- @@ -181,3 +249,13 @@ function colorDot(color: string): ReactNode { key: `color-${color}` }); } + +/** Map a ConfigPropertyDef to a FormModel field spec. */ +function configPropertyToField(key: string, def: ConfigPropertyDef) { + const rules = []; + if (def.required) rules.push(required); + if (def.type === 'number' && (def.min != null || def.max != null)) { + rules.push(numberIs({min: def.min, max: def.max})); + } + return {name: key, initialValue: def.default ?? null, rules}; +} diff --git a/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts b/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts index 214afdeb9..6c6fe4223 100644 --- a/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts @@ -70,12 +70,6 @@ export class CityChooserModel extends BaseWeatherWidgetModel { {name: 'selectedCity', type: 'city', description: 'The currently selected city name.'} ], config: { - selectedCity: { - type: 'string', - description: - 'Initially selected city. Can be any city name the weather API supports.', - default: 'New York' - }, enableSearch: { type: 'boolean', description: 'Enable type-ahead filtering in the dropdown.', diff --git a/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts index 7e7cdd126..8903dcc5f 100644 --- a/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts @@ -38,17 +38,9 @@ export class CurrentConditionsModel extends BaseWeatherWidgetModel { ], outputs: [], config: { - showFeelsLike: { - type: 'boolean', - description: 'Show feels-like temperature.', - default: true - }, - showHumidity: { - type: 'boolean', - description: 'Show humidity percentage.', - default: true - }, - showWind: {type: 'boolean', description: 'Show wind speed.', default: true} + showFeelsLike: {type: 'boolean', default: true}, + showHumidity: {type: 'boolean', default: true}, + showWind: {type: 'boolean', default: true} }, defaultSize: {w: 4, h: 8}, minSize: {w: 3, h: 5} diff --git a/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts index 71e2b615f..b74539589 100644 --- a/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts @@ -45,11 +45,10 @@ export class ForecastChartModel extends BaseWeatherWidgetModel { }, chartType: { type: 'enum', - description: 'Chart rendering style.', enum: ['line', 'area', 'column'], default: 'line' }, - showLegend: {type: 'boolean', description: 'Show chart legend.', default: true} + showLegend: {type: 'boolean', default: true} }, defaultSize: {w: 8, h: 8}, minSize: {w: 4, h: 5} diff --git a/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts b/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts index 7242a2cbe..6269428c4 100644 --- a/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts @@ -22,7 +22,6 @@ export class MarkdownContentModel extends BaseWeatherWidgetModel { config: { content: { type: 'string', - description: 'Markdown text to render.', default: "# Welcome\n\nEdit this widget's content in the dashboard spec." }, title: { diff --git a/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts index c61f5afd3..80184b580 100644 --- a/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts @@ -33,7 +33,6 @@ export class PrecipChartModel extends BaseWeatherWidgetModel { config: { metric: { type: 'enum', - description: 'What to display.', enum: ['probability', 'volume', 'both'], default: 'both' }, diff --git a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts index d13140bb4..f17d5cc35 100644 --- a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts @@ -40,7 +40,6 @@ export class SummaryGridModel extends BaseWeatherWidgetModel { config: { visibleColumns: { type: 'string[]', - description: 'Columns to display.', default: ['date', 'icon', 'conditions', 'high', 'low', 'humidity', 'wind'] } }, diff --git a/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts b/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts index 9ae5530e3..f7656ebae 100644 --- a/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts @@ -22,14 +22,7 @@ export class UnitsToggleModel extends BaseWeatherWidgetModel { outputs: [ {name: 'units', type: 'units', description: 'Unit system: "imperial" or "metric".'} ], - config: { - units: { - type: 'enum', - description: 'Initial unit system.', - enum: ['imperial', 'metric'], - default: 'imperial' - } - }, + config: {}, defaultSize: {w: 3, h: 3}, idealSize: {h: 3}, minSize: {w: 2, h: 3} diff --git a/client-app/src/examples/weatherv2/widgets/WidgetSettingsForm.ts b/client-app/src/examples/weatherv2/widgets/WidgetSettingsForm.ts index 515374b13..d54213548 100644 --- a/client-app/src/examples/weatherv2/widgets/WidgetSettingsForm.ts +++ b/client-app/src/examples/weatherv2/widgets/WidgetSettingsForm.ts @@ -1,5 +1,8 @@ -import {hoistCmp} from '@xh/hoist/core'; -import {div, filler, hbox, span, vbox} from '@xh/hoist/cmp/layout'; +import {hoistCmp, uses} from '@xh/hoist/core'; +import {div, filler, span, vbox} from '@xh/hoist/cmp/layout'; +import {form} from '@xh/hoist/cmp/form'; +import {genDisplayName} from '@xh/hoist/data'; +import {formField} from '@xh/hoist/desktop/cmp/form'; import { select, switchInput, @@ -9,6 +12,7 @@ import { } from '@xh/hoist/desktop/cmp/input'; import {button} from '@xh/hoist/desktop/cmp/button'; import {toolbar} from '@xh/hoist/desktop/cmp/toolbar'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; import {Icon} from '@xh/hoist/icon'; import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; import {widgetRegistry} from '../dash/WidgetRegistry'; @@ -19,8 +23,8 @@ import {getInputWidgetColor} from '../dash/colorCoding'; //-------------------------------------------------- // Constants //-------------------------------------------------- -const SOURCE_DEFAULT = '__default__'; const SOURCE_MANUAL = '__manual__'; +const SOURCE_UNBOUND = '__unbound__'; //-------------------------------------------------- // Component @@ -32,41 +36,40 @@ const SOURCE_MANUAL = '__manual__'; */ export const widgetSettingsForm = hoistCmp.factory({ displayName: 'WidgetSettingsForm', + className: 'weather-v2-settings-form', + model: uses(BaseWeatherWidgetModel, {publishMode: 'none'}), - render(props: any) { - const widgetModel: BaseWeatherWidgetModel = props.widgetModel; - const meta = (widgetModel.constructor as any).meta as WidgetMeta; + render({model, className}) { + const meta = (model.constructor as any).meta as WidgetMeta; if (!meta) return null; const {inputs, config} = meta, hasInputs = inputs.length > 0, hasConfig = Object.keys(config).length > 0; - return vbox({ - ...props, - className: 'weather-v2-settings-form', - items: [ - toolbar({ - className: 'weather-v2-settings-form__header', - items: [ - Icon.gear(), - span({className: 'weather-v2-settings-form__title', item: 'Settings'}), - filler(), - button({ - icon: Icon.close(), - minimal: true, - onClick: () => widgetModel.panelModel.toggleIsModal() - }) - ] - }), - div({ - className: 'weather-v2-settings-form__body', - items: [ - hasInputs ? renderInputsSection(widgetModel, inputs) : null, - hasConfig ? renderConfigSection(widgetModel, config) : null - ] - }) - ] + return panel({ + className, + scrollable: true, + tbar: toolbar({ + className: 'weather-v2-settings-form__header', + items: [ + Icon.gear(), + span({className: 'weather-v2-settings-form__title', item: 'Settings'}), + filler(), + button({ + icon: Icon.close(), + minimal: true, + onClick: () => model.panelModel.toggleIsModal() + }) + ] + }), + item: div({ + className: 'weather-v2-settings-form__body', + items: [ + hasInputs ? renderInputsSection(model, inputs) : null, + hasConfig ? renderConfigSection(model, config) : null + ] + }) }); } }); @@ -93,14 +96,11 @@ function renderInputField(widgetModel: BaseWeatherWidgetModel, inputDef: InputDe binding = bindings[inputDef.name], providers = findProviders(inputDef, widgetModel.viewModel.id), currentSource = determineSource(binding, viewState[inputDef.name]), - isLinked = currentSource !== SOURCE_DEFAULT && currentSource !== SOURCE_MANUAL, - manualValue = viewState[inputDef.name] ?? inputDef.default ?? ''; + isLinked = currentSource !== SOURCE_MANUAL && currentSource !== SOURCE_UNBOUND, + isUnbound = currentSource === SOURCE_UNBOUND, + manualValue = viewState[inputDef.name] ?? ''; const sourceOptions = [ - { - label: `Default${inputDef.default != null ? ` (${inputDef.default})` : ''}`, - value: SOURCE_DEFAULT - }, {label: 'Set manually...', value: SOURCE_MANUAL}, ...providers.map(p => ({ label: formatProviderLabel(p), @@ -108,12 +108,23 @@ function renderInputField(widgetModel: BaseWeatherWidgetModel, inputDef: InputDe })) ]; + // Show "Not configured" only when currently unbound — once the user picks + // a real source it disappears from the dropdown. + if (isUnbound) { + sourceOptions.unshift({label: 'Not configured', value: SOURCE_UNBOUND}); + } + return vbox({ className: 'weather-v2-settings-form__field', items: [ div({ - className: 'weather-v2-settings-form__field-label', - item: inputDef.name + className: `weather-v2-settings-form__field-label${isUnbound && inputDef.required ? ' weather-v2-settings-form__field-label--warning' : ''}`, + items: [ + isUnbound && inputDef.required + ? Icon.warning({className: 'weather-v2-settings-form__warning-icon'}) + : null, + genDisplayName(inputDef.name) + ] }), select({ value: currentSource, @@ -139,18 +150,34 @@ function renderInputField(widgetModel: BaseWeatherWidgetModel, inputDef: InputDe className: 'weather-v2-settings-form__linked-note', items: [Icon.link(), span(' Linked to provider widget')] }) + : null, + isUnbound + ? div({ + className: 'weather-v2-settings-form__unbound-note', + items: [ + Icon.warning(), + span( + inputDef.required + ? ' Required input — select a source' + : ' No source configured' + ) + ] + }) : null ] }); } //-------------------------------------------------- -// Config Section — widget-specific configuration +// Config Section — widget-specific configuration via FormModel //-------------------------------------------------- function renderConfigSection( widgetModel: BaseWeatherWidgetModel, config: Record ) { + const {configFormModel} = widgetModel; + if (!configFormModel) return null; + return vbox({ className: 'weather-v2-settings-form__section', items: [ @@ -158,145 +185,66 @@ function renderConfigSection( className: 'weather-v2-settings-form__section-title', item: 'Configuration' }), - ...Object.entries(config).map(([key, def]) => renderConfigField(widgetModel, key, def)) + form({ + model: configFormModel, + fieldDefaults: {commitOnChange: true}, + items: Object.entries(config).map(([key, def]) => renderConfigField(key, def)) + }) ] }); } -function renderConfigField( - widgetModel: BaseWeatherWidgetModel, - configKey: string, - configDef: ConfigPropertyDef -) { - const viewState = widgetModel.viewModel.viewState ?? {}, - currentValue = viewState[configKey] ?? configDef.default; - - const onChange = (val: any) => { - widgetModel.viewModel.setViewStateKey(configKey, val); - }; +function renderConfigField(configKey: string, configDef: ConfigPropertyDef) { + const info = configDef.description || undefined; - let control; switch (configDef.type) { case 'boolean': - control = switchInput({ - value: !!currentValue, - onChange, - inline: true, - label: null - }); - break; + return formField({field: configKey, info, item: switchInput({label: null})}); case 'enum': if (configDef.enum && configDef.enum.length <= 3) { - control = buttonGroupInput({ - value: currentValue, - onChange, - outlined: true, - width: '100%', - items: configDef.enum.map(opt => button({text: opt, value: opt, flex: 1})) - }); - } else { - control = select({ - value: currentValue, - options: configDef.enum ?? [], - onChange, - width: '100%' + return formField({ + field: configKey, + info, + item: buttonGroupInput({ + outlined: true, + items: configDef.enum.map(opt => button({text: opt, value: opt, flex: 1})) + }) }); } - break; + return formField({ + field: configKey, + info, + item: select({options: configDef.enum ?? []}) + }); case 'number': - control = numberInput({ - value: currentValue, - onCommit: onChange, - width: '100%', - min: configDef.min, - max: configDef.max + return formField({ + field: configKey, + info, + item: numberInput({min: configDef.min, max: configDef.max}) }); - break; - case 'string[]': - control = renderStringArrayControl(currentValue, configDef, onChange); - break; + case 'string[]': { + const knownOptions = extractOptionsFromDesc(configDef.description); + if (knownOptions.length > 0) { + return formField({ + field: configKey, + info, + item: select({enableMulti: true, enableFilter: false, options: knownOptions}) + }); + } + return formField({ + field: configKey, + info, + item: textInput({placeholder: 'Comma-separated values...'}) + }); + } case 'string': default: - control = textInput({ - value: currentValue?.toString() ?? '', - onCommit: onChange, - width: '100%' - }); - break; + return formField({field: configKey, info, item: textInput()}); } - - return vbox({ - className: 'weather-v2-settings-form__field', - items: [ - hbox({ - className: 'weather-v2-settings-form__field-label', - items: [span(configKey), filler(), configDef.type === 'boolean' ? control : null] - }), - configDef.type !== 'boolean' ? control : null, - configDef.description - ? div({ - className: 'weather-v2-settings-form__field-desc', - item: configDef.description - }) - : null - ] - }); -} - -/** Control for string[] config — renders switches for known options or a text input fallback. */ -function renderStringArrayControl( - value: string[], - configDef: ConfigPropertyDef, - onChange: (val: string[]) => void -) { - const knownOptions = extractOptionsFromDesc(configDef.description); - if (knownOptions.length > 0) { - const currentSet = new Set(value ?? []); - return vbox({ - className: 'weather-v2-settings-form__checkbox-group', - items: knownOptions.map(opt => - hbox({ - className: 'weather-v2-settings-form__checkbox-item', - alignItems: 'center', - items: [ - switchInput({ - value: currentSet.has(opt), - onChange: (checked: boolean) => { - const next = new Set(currentSet); - if (checked) next.add(opt); - else next.delete(opt); - onChange(Array.from(next)); - }, - inline: true, - label: null - }), - span({ - className: 'weather-v2-settings-form__checkbox-label', - item: opt - }) - ] - }) - ) - }); - } - - return textInput({ - value: (value ?? []).join(', '), - onCommit: (val: string) => { - onChange( - val - .split(',') - .map(s => s.trim()) - .filter(Boolean) - ); - }, - width: '100%', - placeholder: 'Comma-separated values...' - }); } //-------------------------------------------------- @@ -341,7 +289,7 @@ function determineSource(binding: any, directValue: any): string { if ('const' in binding) return SOURCE_MANUAL; } if (directValue !== undefined) return SOURCE_MANUAL; - return SOURCE_DEFAULT; + return SOURCE_UNBOUND; } /** Handle changes to the source dropdown for an input. */ @@ -350,13 +298,13 @@ function handleSourceChange( inputDef: InputDef, newSource: string ) { + // SOURCE_UNBOUND is a read-only display state — no-op + if (newSource === SOURCE_UNBOUND) return; + const viewState = {...(widgetModel.viewModel.viewState ?? {})}, bindings = {...(viewState.bindings ?? {})}; - if (newSource === SOURCE_DEFAULT) { - delete bindings[inputDef.name]; - delete viewState[inputDef.name]; - } else if (newSource === SOURCE_MANUAL) { + if (newSource === SOURCE_MANUAL) { delete bindings[inputDef.name]; viewState[inputDef.name] = inputDef.default ?? ''; } else { @@ -378,8 +326,8 @@ function formatProviderLabel(provider: ProviderInfo): string { } /** Try to extract known options from a config description (e.g., quoted strings). */ -function extractOptionsFromDesc(description: string): string[] { - const matches = description.match(/"([^"]+)"/g); +function extractOptionsFromDesc(description?: string): string[] { + const matches = description?.match(/"([^"]+)"/g); if (!matches || matches.length < 2) return []; return matches.map(m => m.replace(/"/g, '')); } diff --git a/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts index 0ab26bcd4..0d828de1e 100644 --- a/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts @@ -36,17 +36,8 @@ export class WindChartModel extends BaseWeatherWidgetModel { ], outputs: [], config: { - showGusts: { - type: 'boolean', - description: 'Show gust data alongside sustained.', - default: true - }, - chartType: { - type: 'enum', - description: 'Chart style.', - enum: ['line', 'area'], - default: 'line' - } + showGusts: {type: 'boolean', default: true}, + chartType: {type: 'enum', enum: ['line', 'area'], default: 'line'} }, defaultSize: {w: 6, h: 8}, minSize: {w: 4, h: 5} diff --git a/client-app/src/examples/weatherv2/widgets/settingsAwarePanel.ts b/client-app/src/examples/weatherv2/widgets/settingsAwarePanel.ts index 046bb38e3..ee938f1a8 100644 --- a/client-app/src/examples/weatherv2/widgets/settingsAwarePanel.ts +++ b/client-app/src/examples/weatherv2/widgets/settingsAwarePanel.ts @@ -27,7 +27,7 @@ export function settingsAwarePanel(model: BaseWeatherWidgetModel, content: React flex: 1, items: [ frame({flex: 1, item: content}), - panelModel.isModal ? widgetSettingsForm({widgetModel: model}) : null + panelModel.isModal ? widgetSettingsForm() : null ] }) }); From 78a22b98115e850c316c1c10aed9531c57edd2e6 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sun, 1 Mar 2026 16:28:15 -0800 Subject: [PATCH 32/41] Improve widget settings: markdown codeInput, units enum, and showLegend fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use codeInput with markdown mode for the MarkdownContentWidget's content config, replacing the single-line textInput. Add a 'markdown' type to ConfigPropertyDef to support this. Reorder the config so title appears before content in the settings form. Add enum constraint to the units InputDef on all display widgets (ForecastChart, Wind, Precip, CurrentConditions, SummaryGrid) so the manual input renders as a buttonGroupInput with "imperial"/"metric" options instead of a raw textInput. Fix the showLegend toggle on ForecastChartWidget by adding a MobX reaction that calls chartModel.updateHighchartsConfig() when the value changes — previously it was only applied at chart creation time. Add the same showLegend config and reaction to PrecipChartWidget. Co-Authored-By: Claude Opus 4.6 --- .../src/examples/weatherv2/WeatherV2.scss | 6 +++ .../src/examples/weatherv2/dash/types.ts | 3 +- .../widgets/CurrentConditionsWidget.ts | 1 + .../weatherv2/widgets/ForecastChartWidget.ts | 7 +++ .../widgets/MarkdownContentWidget.ts | 8 ++-- .../weatherv2/widgets/PrecipChartWidget.ts | 13 ++++- .../weatherv2/widgets/SummaryGridWidget.ts | 1 + .../weatherv2/widgets/WidgetSettingsForm.ts | 48 ++++++++++++++----- .../weatherv2/widgets/WindChartWidget.ts | 1 + 9 files changed, 71 insertions(+), 17 deletions(-) diff --git a/client-app/src/examples/weatherv2/WeatherV2.scss b/client-app/src/examples/weatherv2/WeatherV2.scss index 1874135c9..4ddfba2b9 100644 --- a/client-app/src/examples/weatherv2/WeatherV2.scss +++ b/client-app/src/examples/weatherv2/WeatherV2.scss @@ -661,6 +661,12 @@ color: var(--xh-orange); } + // CodeInput needs explicit height on the formField — its internal elements + // use height: 100% which resolves only when a parent has explicit height. + .xh-form-field--code-input { + height: 230px; + } + &__checkbox-group { gap: 4px; } diff --git a/client-app/src/examples/weatherv2/dash/types.ts b/client-app/src/examples/weatherv2/dash/types.ts index 0d3b1a896..1ee9e6d7d 100644 --- a/client-app/src/examples/weatherv2/dash/types.ts +++ b/client-app/src/examples/weatherv2/dash/types.ts @@ -27,6 +27,7 @@ export interface InputDef { type: string; required?: boolean; default?: any; + enum?: string[]; description: string; } @@ -39,7 +40,7 @@ export interface OutputDef { /** Config property definition within a widget schema. */ export interface ConfigPropertyDef { - type: 'string' | 'number' | 'boolean' | 'enum' | 'string[]'; + type: 'string' | 'number' | 'boolean' | 'enum' | 'string[]' | 'markdown'; description?: string; default?: any; enum?: string[]; diff --git a/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts index 8903dcc5f..4b07c5ea5 100644 --- a/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts @@ -33,6 +33,7 @@ export class CurrentConditionsModel extends BaseWeatherWidgetModel { type: 'units', required: false, default: 'imperial', + enum: ['imperial', 'metric'], description: 'Unit system: "imperial" or "metric".' } ], diff --git a/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts index b74539589..8f896b102 100644 --- a/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts @@ -32,6 +32,7 @@ export class ForecastChartModel extends BaseWeatherWidgetModel { type: 'units', required: false, default: 'imperial', + enum: ['imperial', 'metric'], description: 'Unit system: "imperial" or "metric".' } ], @@ -96,6 +97,12 @@ export class ForecastChartModel extends BaseWeatherWidgetModel { run: () => this.updateChart(), fireImmediately: true }); + + this.addReaction({ + track: () => this.showLegend, + run: showLegend => + this.chartModel.updateHighchartsConfig({legend: {enabled: showLegend}}) + }); } override async doLoadAsync(loadSpec: LoadSpec) { diff --git a/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts b/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts index 6269428c4..7b1798cb6 100644 --- a/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts @@ -20,15 +20,15 @@ export class MarkdownContentModel extends BaseWeatherWidgetModel { inputs: [], outputs: [], config: { - content: { - type: 'string', - default: "# Welcome\n\nEdit this widget's content in the dashboard spec." - }, title: { type: 'string', description: 'Display title for the widget header. Set this to label the widget since its title cannot be auto-generated.', default: 'Markdown Content' + }, + content: { + type: 'markdown', + default: "# Welcome\n\nEdit this widget's content in the dashboard spec." } }, defaultSize: {w: 4, h: 5}, diff --git a/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts index 80184b580..e2c10eb20 100644 --- a/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts @@ -40,7 +40,8 @@ export class PrecipChartModel extends BaseWeatherWidgetModel { type: 'boolean', description: 'Highlight high-probability periods.', default: false - } + }, + showLegend: {type: 'boolean', default: true} }, defaultSize: {w: 6, h: 8}, minSize: {w: 4, h: 5} @@ -62,6 +63,10 @@ export class PrecipChartModel extends BaseWeatherWidgetModel { return this.viewModel.viewState?.metric ?? 'both'; } + get showLegend(): boolean { + return this.viewModel.viewState?.showLegend ?? true; + } + override onLinked() { super.onLinked(); this.chartModel = this.createChartModel(); @@ -77,6 +82,12 @@ export class PrecipChartModel extends BaseWeatherWidgetModel { run: () => this.updateChart(), fireImmediately: true }); + + this.addReaction({ + track: () => this.showLegend, + run: showLegend => + this.chartModel.updateHighchartsConfig({legend: {enabled: showLegend}}) + }); } override async doLoadAsync(loadSpec: LoadSpec) { diff --git a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts index f17d5cc35..253306e39 100644 --- a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts @@ -33,6 +33,7 @@ export class SummaryGridModel extends BaseWeatherWidgetModel { type: 'units', required: false, default: 'imperial', + enum: ['imperial', 'metric'], description: 'Unit system.' } ], diff --git a/client-app/src/examples/weatherv2/widgets/WidgetSettingsForm.ts b/client-app/src/examples/weatherv2/widgets/WidgetSettingsForm.ts index d54213548..23d32da72 100644 --- a/client-app/src/examples/weatherv2/widgets/WidgetSettingsForm.ts +++ b/client-app/src/examples/weatherv2/widgets/WidgetSettingsForm.ts @@ -4,6 +4,7 @@ import {form} from '@xh/hoist/cmp/form'; import {genDisplayName} from '@xh/hoist/data'; import {formField} from '@xh/hoist/desktop/cmp/form'; import { + codeInput, select, switchInput, textInput, @@ -133,17 +134,7 @@ function renderInputField(widgetModel: BaseWeatherWidgetModel, inputDef: InputDe onChange: (val: string) => handleSourceChange(widgetModel, inputDef, val) }), currentSource === SOURCE_MANUAL - ? textInput({ - className: 'weather-v2-settings-form__manual-input', - value: manualValue?.toString() ?? '', - width: '100%', - placeholder: `Enter ${inputDef.name}...`, - onCommit: (val: string) => { - const vs = {...(widgetModel.viewModel.viewState ?? {})}; - vs[inputDef.name] = val; - widgetModel.viewModel.setViewState(vs); - } - }) + ? renderManualInput(widgetModel, inputDef, manualValue) : null, isLinked ? div({ @@ -241,6 +232,13 @@ function renderConfigField(configKey: string, configDef: ConfigPropertyDef) { }); } + case 'markdown': + return formField({ + field: configKey, + info, + item: codeInput({mode: 'markdown', showFullscreenButton: true}) + }); + case 'string': default: return formField({field: configKey, info, item: textInput()}); @@ -318,6 +316,34 @@ function handleSourceChange( widgetModel.viewModel.setViewState(viewState); } +/** Render the appropriate input control for a manual value based on the input's metadata. */ +function renderManualInput(widgetModel: BaseWeatherWidgetModel, inputDef: InputDef, value: any) { + const onChange = (val: any) => { + const vs = {...(widgetModel.viewModel.viewState ?? {})}; + vs[inputDef.name] = val; + widgetModel.viewModel.setViewState(vs); + }; + + if (inputDef.enum?.length) { + return buttonGroupInput({ + className: 'weather-v2-settings-form__manual-input', + value: value ?? inputDef.default ?? inputDef.enum[0], + outlined: true, + width: '100%', + onChange, + items: inputDef.enum.map(opt => button({text: opt, value: opt, flex: 1})) + }); + } + + return textInput({ + className: 'weather-v2-settings-form__manual-input', + value: value?.toString() ?? '', + width: '100%', + placeholder: `Enter ${inputDef.name}...`, + onCommit: onChange + }); +} + /** Format a provider widget label for the source dropdown. */ function formatProviderLabel(provider: ProviderInfo): string { const color = getInputWidgetColor(provider.widgetId); diff --git a/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts index 0d828de1e..e47b050fe 100644 --- a/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts @@ -31,6 +31,7 @@ export class WindChartModel extends BaseWeatherWidgetModel { type: 'units', required: false, default: 'imperial', + enum: ['imperial', 'metric'], description: 'Unit system (mph vs m/s).' } ], From 85633103cf9c4af0701b64aa4e2fd26f0908098b Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sun, 1 Mar 2026 20:36:18 -0800 Subject: [PATCH 33/41] Add manual editing toggle with hidePanelHeader widget config Add a persisted manualEditingEnabled toggle to AppModel and wire it through the dashboard. When disabled, the DashCanvasModel locks layout and content (preventing drag/resize and disabling Add/Remove/Replace menu items), gear buttons are hidden from widget headers, and the Add Widget and Widgets toolbar buttons are disabled. The Dashboard Agent and JSON editor remain fully functional. Add hidePanelHeader boolean config to all 9 widgets. When manual editing is disabled, widgets with this setting hide their panel header for a clean chrome-free display. When editing is re-enabled, headers always show so the gear button remains accessible. Toolbar toggle buttons now only receive primary intent when active and enabled, avoiding visual noise from inactive highlighted buttons. Co-Authored-By: Claude Opus 4.6 --- .../src/examples/weatherv2/AppComponent.ts | 25 ++++++-- client-app/src/examples/weatherv2/AppModel.ts | 1 + .../weatherv2/dash/WeatherV2DashModel.ts | 13 +++- .../widgets/BaseWeatherWidgetModel.ts | 64 +++++++++++-------- .../weatherv2/widgets/CityChooserWidget.ts | 6 ++ .../widgets/CurrentConditionsWidget.ts | 7 +- .../weatherv2/widgets/DashInspectorWidget.ts | 8 ++- .../weatherv2/widgets/ForecastChartWidget.ts | 7 +- .../widgets/MarkdownContentWidget.ts | 5 ++ .../weatherv2/widgets/PrecipChartWidget.ts | 7 +- .../weatherv2/widgets/SummaryGridWidget.ts | 5 ++ .../weatherv2/widgets/UnitsToggleWidget.ts | 8 ++- .../weatherv2/widgets/WindChartWidget.ts | 7 +- 13 files changed, 123 insertions(+), 40 deletions(-) diff --git a/client-app/src/examples/weatherv2/AppComponent.ts b/client-app/src/examples/weatherv2/AppComponent.ts index 0d5f216fc..fe210e609 100644 --- a/client-app/src/examples/weatherv2/AppComponent.ts +++ b/client-app/src/examples/weatherv2/AppComponent.ts @@ -21,12 +21,14 @@ export const AppComponent = hoistCmp({ const { weatherViewManager, weatherV2DashModel, + manualEditingEnabled, showJsonHarness, showChatHarness, showWidgetChooser } = model, {dashCanvasModel} = weatherV2DashModel, - showHarness = showChatHarness || showJsonHarness || showWidgetChooser; + activeWidgetChooser = showWidgetChooser && manualEditingEnabled, + showHarness = showChatHarness || showJsonHarness || activeWidgetChooser; return panel({ tbar: appBar({ @@ -35,10 +37,20 @@ export const AppComponent = hoistCmp({ rightItems: [ viewManager({model: weatherViewManager}), appBarSeparator(), + button({ + testId: 'manual-editing-btn', + icon: Icon.edit(), + text: 'Manual Editing', + active: manualEditingEnabled, + outlined: true, + intent: manualEditingEnabled ? 'primary' : undefined, + onClick: () => (model.manualEditingEnabled = !manualEditingEnabled) + }), dashCanvasAddViewButton({ dashCanvasModel, rightIcon: Icon.chevronDown(), - outlined: true + outlined: true, + disabled: !manualEditingEnabled }), appBarSeparator(), button({ @@ -47,7 +59,7 @@ export const AppComponent = hoistCmp({ text: 'Dashboard Agent', active: showChatHarness, outlined: true, - intent: 'primary', + intent: showChatHarness ? 'primary' : undefined, onClick: () => (model.showChatHarness = !showChatHarness) }), button({ @@ -56,7 +68,7 @@ export const AppComponent = hoistCmp({ text: 'JSON', active: showJsonHarness, outlined: true, - intent: 'primary', + intent: showJsonHarness ? 'primary' : undefined, onClick: () => (model.showJsonHarness = !showJsonHarness) }), button({ @@ -65,7 +77,8 @@ export const AppComponent = hoistCmp({ text: 'Widgets', active: showWidgetChooser, outlined: true, - intent: 'primary', + intent: activeWidgetChooser ? 'primary' : undefined, + disabled: !manualEditingEnabled, onClick: () => (model.showWidgetChooser = !showWidgetChooser) }), appBarSeparator() @@ -79,7 +92,7 @@ export const AppComponent = hoistCmp({ items: [ showChatHarness ? chatHarnessPanel({flex: 1}) : null, showJsonHarness ? jsonHarnessPanel({flex: 1}) : null, - showWidgetChooser + activeWidgetChooser ? panel({ testId: 'widget-chooser-panel', title: 'Widget Chooser', diff --git a/client-app/src/examples/weatherv2/AppModel.ts b/client-app/src/examples/weatherv2/AppModel.ts index a7c8536b6..cba83c0b8 100644 --- a/client-app/src/examples/weatherv2/AppModel.ts +++ b/client-app/src/examples/weatherv2/AppModel.ts @@ -20,6 +20,7 @@ export class AppModel extends BaseAppModel { @managed weatherV2DashModel: WeatherV2DashModel; @managed weatherViewManager: ViewManagerModel; @managed harnessPanelModel: PanelModel; + @persist @bindable manualEditingEnabled: boolean = true; @persist @bindable showJsonHarness: boolean = false; @persist @bindable showChatHarness: boolean = true; @persist @bindable showWidgetChooser: boolean = false; diff --git a/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts b/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts index 138b6f015..93e1c627b 100644 --- a/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts +++ b/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts @@ -1,4 +1,4 @@ -import {HoistModel, managed} from '@xh/hoist/core'; +import {HoistModel, managed, XH} from '@xh/hoist/core'; import {ViewManagerModel} from '@xh/hoist/cmp/viewmanager'; import {DashCanvasModel, DashViewModel} from '@xh/hoist/desktop/cmp/dash'; import {Icon} from '@xh/hoist/icon'; @@ -247,6 +247,17 @@ export class WeatherV2DashModel extends HoistModel { fireImmediately: true, delay: 1 }); + + // Lock/unlock canvas editing based on manual editing toggle. + this.addReaction({ + track: () => (XH.appModel as any).manualEditingEnabled, + run: enabled => { + const locked = !enabled; + this.dashCanvasModel.layoutLocked = locked; + this.dashCanvasModel.contentLocked = locked; + }, + fireImmediately: true + }); } //-------------------------------------------------- diff --git a/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts b/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts index 856e0833e..49758a64f 100644 --- a/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts +++ b/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts @@ -32,16 +32,10 @@ export abstract class BaseWeatherWidgetModel extends HoistModel { @lookup(() => DashViewModel) viewModel: DashViewModel; - /** - * PanelModel with modal support for the settings dialog. - * Created only for widgets that declare inputs or config properties. - */ + /** PanelModel with modal support for the settings dialog. */ @managed panelModel: PanelModel; - /** - * FormModel for config property editing in the settings dialog. - * Created only for widgets that declare config properties. - */ + /** FormModel for config property editing in the settings dialog. */ @managed configFormModel: FormModel; /** True if this widget type has user-configurable settings. */ @@ -54,25 +48,23 @@ export abstract class BaseWeatherWidgetModel extends HoistModel { constructor() { super(); const meta = (this.constructor as any).meta as WidgetMeta; - const hasSettings = meta && (meta.inputs.length > 0 || Object.keys(meta.config).length > 0); - if (hasSettings) { - this.panelModel = new PanelModel({ - modalSupport: { - width: '80vw', - height: '70vh', - canOutsideClickClose: true - }, - collapsible: false, - resizable: false - }); - } + // Always create PanelModel — every widget has at least the hidePanelHeader config. + this.panelModel = new PanelModel({ + modalSupport: { + width: '80vw', + height: '70vh', + canOutsideClickClose: true + }, + collapsible: false, + resizable: false + }); + + // Always create configFormModel — every widget has at least hidePanelHeader. const configEntries = meta ? Object.entries(meta.config) : []; - if (configEntries.length > 0) { - this.configFormModel = new FormModel({ - fields: configEntries.map(([key, def]) => configPropertyToField(key, def)) - }); - } + this.configFormModel = new FormModel({ + fields: configEntries.map(([key, def]) => configPropertyToField(key, def)) + }); } override onLinked() { @@ -130,11 +122,14 @@ export abstract class BaseWeatherWidgetModel extends HoistModel { // Reactively build header items: color indicators + gear button. // Tracks canvasModel.viewModels so colors update when widgets are added/removed. + // Also tracks manualEditingEnabled so gear button visibility updates. this.addReaction({ track: () => { const meta = (this.constructor as any).meta as WidgetMeta; if (!meta) return null; const vmId = this.viewModel.id; + // Track editing toggle to rebuild header items (gear visibility). + void AppModel.instance.manualEditingEnabled; if (meta.category === 'input') return getInputWidgetColor(vmId); if (meta.inputs.length > 0) return getConsumerWidgetColors(vmId); return null; @@ -146,6 +141,21 @@ export abstract class BaseWeatherWidgetModel extends HoistModel { fireImmediately: true, delay: 1 }); + + // Sync hidePanelHeader based on viewState config + editing toggle. + this.addReaction({ + track: () => ({ + editing: AppModel.instance.manualEditingEnabled, + hide: this.viewModel.viewState?.hidePanelHeader ?? false + }), + run: ({editing, hide}) => { + const canvasVM = this.viewModel as DashCanvasViewModel; + // While editing, always show header so gear is accessible. + // When locked, respect the widget's hidePanelHeader setting. + canvasVM.hidePanelHeader = editing ? false : hide; + }, + fireImmediately: true + }); } //-------------------------------------------------- @@ -175,8 +185,8 @@ export abstract class BaseWeatherWidgetModel extends HoistModel { } } - // Gear button for configurable widgets - if (this.panelModel) { + // Gear button — only visible when manual editing is enabled + if (this.panelModel && AppModel.instance.manualEditingEnabled) { items.push( button({ icon: Icon.gear(), diff --git a/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts b/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts index 6c6fe4223..243ce3561 100644 --- a/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts @@ -74,6 +74,11 @@ export class CityChooserModel extends BaseWeatherWidgetModel { type: 'boolean', description: 'Enable type-ahead filtering in the dropdown.', default: true + }, + hidePanelHeader: { + type: 'boolean', + default: false, + description: 'Hide widget header bar when manual editing is disabled' } }, defaultSize: {w: 3, h: 3}, @@ -123,6 +128,7 @@ export const cityChooserWidget = hoistCmp.factory({ testId: 'city-chooser', padding: 8, flex: 1, + alignItems: 'center', item: select({ testId: 'city-select', bind: 'selectedCity', diff --git a/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts index 4b07c5ea5..66783ae48 100644 --- a/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts @@ -41,7 +41,12 @@ export class CurrentConditionsModel extends BaseWeatherWidgetModel { config: { showFeelsLike: {type: 'boolean', default: true}, showHumidity: {type: 'boolean', default: true}, - showWind: {type: 'boolean', default: true} + showWind: {type: 'boolean', default: true}, + hidePanelHeader: { + type: 'boolean', + default: false, + description: 'Hide widget header bar when manual editing is disabled' + } }, defaultSize: {w: 4, h: 8}, minSize: {w: 3, h: 5} diff --git a/client-app/src/examples/weatherv2/widgets/DashInspectorWidget.ts b/client-app/src/examples/weatherv2/widgets/DashInspectorWidget.ts index 6d602342a..8ed80eb93 100644 --- a/client-app/src/examples/weatherv2/widgets/DashInspectorWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/DashInspectorWidget.ts @@ -19,7 +19,13 @@ export class DashInspectorModel extends BaseWeatherWidgetModel { category: 'utility', inputs: [], outputs: [], - config: {}, + config: { + hidePanelHeader: { + type: 'boolean', + default: false, + description: 'Hide widget header bar when manual editing is disabled' + } + }, defaultSize: {w: 6, h: 8}, minSize: {w: 4, h: 5} }; diff --git a/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts index 8f896b102..bc9f94360 100644 --- a/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts @@ -49,7 +49,12 @@ export class ForecastChartModel extends BaseWeatherWidgetModel { enum: ['line', 'area', 'column'], default: 'line' }, - showLegend: {type: 'boolean', default: true} + showLegend: {type: 'boolean', default: true}, + hidePanelHeader: { + type: 'boolean', + default: false, + description: 'Hide widget header bar when manual editing is disabled' + } }, defaultSize: {w: 8, h: 8}, minSize: {w: 4, h: 5} diff --git a/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts b/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts index 7b1798cb6..4a1cee69b 100644 --- a/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts @@ -29,6 +29,11 @@ export class MarkdownContentModel extends BaseWeatherWidgetModel { content: { type: 'markdown', default: "# Welcome\n\nEdit this widget's content in the dashboard spec." + }, + hidePanelHeader: { + type: 'boolean', + default: false, + description: 'Hide widget header bar when manual editing is disabled' } }, defaultSize: {w: 4, h: 5}, diff --git a/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts index e2c10eb20..e57a57d18 100644 --- a/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts @@ -41,7 +41,12 @@ export class PrecipChartModel extends BaseWeatherWidgetModel { description: 'Highlight high-probability periods.', default: false }, - showLegend: {type: 'boolean', default: true} + showLegend: {type: 'boolean', default: true}, + hidePanelHeader: { + type: 'boolean', + default: false, + description: 'Hide widget header bar when manual editing is disabled' + } }, defaultSize: {w: 6, h: 8}, minSize: {w: 4, h: 5} diff --git a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts index 253306e39..cfacf2b81 100644 --- a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts @@ -42,6 +42,11 @@ export class SummaryGridModel extends BaseWeatherWidgetModel { visibleColumns: { type: 'string[]', default: ['date', 'icon', 'conditions', 'high', 'low', 'humidity', 'wind'] + }, + hidePanelHeader: { + type: 'boolean', + default: false, + description: 'Hide widget header bar when manual editing is disabled' } }, defaultSize: {w: 6, h: 8}, diff --git a/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts b/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts index f7656ebae..15e8ba941 100644 --- a/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts @@ -22,7 +22,13 @@ export class UnitsToggleModel extends BaseWeatherWidgetModel { outputs: [ {name: 'units', type: 'units', description: 'Unit system: "imperial" or "metric".'} ], - config: {}, + config: { + hidePanelHeader: { + type: 'boolean', + default: false, + description: 'Hide widget header bar when manual editing is disabled' + } + }, defaultSize: {w: 3, h: 3}, idealSize: {h: 3}, minSize: {w: 2, h: 3} diff --git a/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts index e47b050fe..9428c1725 100644 --- a/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts @@ -38,7 +38,12 @@ export class WindChartModel extends BaseWeatherWidgetModel { outputs: [], config: { showGusts: {type: 'boolean', default: true}, - chartType: {type: 'enum', enum: ['line', 'area'], default: 'line'} + chartType: {type: 'enum', enum: ['line', 'area'], default: 'line'}, + hidePanelHeader: { + type: 'boolean', + default: false, + description: 'Hide widget header bar when manual editing is disabled' + } }, defaultSize: {w: 6, h: 8}, minSize: {w: 4, h: 5} From 0b0c0e3ad3558964f0f82e75058f4a48ad485ee3 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sun, 1 Mar 2026 21:12:54 -0800 Subject: [PATCH 34/41] Add harness panel improvements: equal sizing, response timing, meta prompts, and editing tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix equal flex sizing for the three right-hand harness panels (Dashboard Agent, JSON Spec Editor, Widget Chooser) by adding flex: 1 and minHeight: 0 directly on each panel's root element. The minHeight: 0 override prevents content from inflating a panel beyond its flex-allocated share — the key insight was that hoistCmp.factory components don't forward layout props from call sites to their inner panel(), so the fix had to go inside the component definitions. Add toggle_manual_editing as an LLM-callable tool, useful for previewing exact layout sizing and especially helpful when hiding widget title bars. Add two meta-inquiry sample prompts ("What can you help me do?" and "How do I change which city my dashboard shows?") in a separate "Or ask about the agent" section below the existing suggestions. Both were tested against the LLM and yield consistently helpful, well-organized responses. Removed the redundant "Build a compact 3-city overview" prompt. Display LLM response elapsed time next to the "Assistant" byline in chat bubbles, formatted as a friendly human-readable duration (e.g. "9.5s"). Co-Authored-By: Claude Opus 4.6 --- .../src/examples/weatherv2/AppComponent.ts | 5 +- .../src/examples/weatherv2/WeatherV2.scss | 13 +++++ .../weatherv2/harness/ChatHarnessModel.ts | 9 +++- .../weatherv2/harness/ChatHarnessPanel.ts | 47 ++++++++++++++++++- .../weatherv2/harness/JsonHarnessPanel.ts | 2 + .../examples/weatherv2/svc/LlmToolService.ts | 16 +++++++ 6 files changed, 86 insertions(+), 6 deletions(-) diff --git a/client-app/src/examples/weatherv2/AppComponent.ts b/client-app/src/examples/weatherv2/AppComponent.ts index fe210e609..b91588df1 100644 --- a/client-app/src/examples/weatherv2/AppComponent.ts +++ b/client-app/src/examples/weatherv2/AppComponent.ts @@ -90,8 +90,8 @@ export const AppComponent = hoistCmp({ panel({ model: model.harnessPanelModel, items: [ - showChatHarness ? chatHarnessPanel({flex: 1}) : null, - showJsonHarness ? jsonHarnessPanel({flex: 1}) : null, + showChatHarness ? chatHarnessPanel() : null, + showJsonHarness ? jsonHarnessPanel() : null, activeWidgetChooser ? panel({ testId: 'widget-chooser-panel', @@ -99,6 +99,7 @@ export const AppComponent = hoistCmp({ icon: Icon.boxFull(), compactHeader: true, flex: 1, + minHeight: 0, item: dashCanvasWidgetChooser({dashCanvasModel}) }) : null diff --git a/client-app/src/examples/weatherv2/WeatherV2.scss b/client-app/src/examples/weatherv2/WeatherV2.scss index 4ddfba2b9..d51fda3ad 100644 --- a/client-app/src/examples/weatherv2/WeatherV2.scss +++ b/client-app/src/examples/weatherv2/WeatherV2.scss @@ -281,6 +281,10 @@ background: var(--xh-bg-highlight); border-color: var(--xh-blue-muted); } + + &--meta { + font-style: italic; + } } // Chat harness — message list @@ -313,6 +317,15 @@ font-weight: 600; margin-bottom: 4px; color: var(--xh-text-color-muted); + display: flex; + align-items: baseline; + gap: 6px; + } + + &__elapsed { + font-weight: 400; + font-size: 11px; + opacity: 0.7; } &__content { diff --git a/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts b/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts index 2451988d3..ae652a752 100644 --- a/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts +++ b/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts @@ -19,6 +19,8 @@ export interface DisplayMessage { content: string; thinking?: boolean; toolCalls?: ToolCallDisplay[]; + /** Elapsed time in ms for the full LLM generate cycle (including tool loops). */ + elapsedMs?: number; } /** Max tool-use loop iterations to prevent runaway. */ @@ -130,6 +132,7 @@ export class ChatHarnessModel extends HoistModel { // Implementation //------------------ private async doGenerateAsync() { + const startTime = Date.now(); try { const chatSvc = XH.llmChatService, toolSvc = XH.llmToolService; @@ -194,13 +197,15 @@ export class ChatHarnessModel extends HoistModel { const spec = chatSvc.parseSpecFromResponse(finalText); if (spec) this.applySpec(spec); - // Add display message with tool calls + text + // Add display message with tool calls + text + elapsed time + const elapsedMs = Date.now() - startTime; this.displayMessages = [ ...this.displayMessages, { role: 'assistant', content: finalText, - toolCalls: allToolCalls.length > 0 ? allToolCalls : undefined + toolCalls: allToolCalls.length > 0 ? allToolCalls : undefined, + elapsedMs } ]; diff --git a/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts index 79e7ea1cd..544a58ef2 100644 --- a/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts +++ b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts @@ -24,6 +24,8 @@ export const chatHarnessPanel = hoistCmp.factory({ title: 'Dashboard Agent', icon: sparklesIcon(), compactHeader: true, + flex: 1, + minHeight: 0, headerItems: [ button({ testId: 'chat-clear-btn', @@ -117,7 +119,15 @@ function renderBubble(model: ChatHarnessModel, msg: DisplayMessage, index: numbe items: [ div({ className: 'weather-v2-chat-msg__role', - item: msg.role === 'user' ? 'You' : 'Assistant' + items: [ + msg.role === 'user' ? 'You' : 'Assistant', + msg.elapsedMs + ? div({ + className: 'weather-v2-chat-msg__elapsed', + item: formatElapsed(msg.elapsedMs) + }) + : null + ] }), // Tool calls rendered between role label and text content ...(msg.toolCalls?.length ? [renderToolCalls(msg.toolCalls)] : []), @@ -160,6 +170,16 @@ function renderToolCalls(toolCalls: ToolCallDisplay[]) { }); } +/** Format elapsed milliseconds into a friendly human-readable string. */ +function formatElapsed(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const secs = ms / 1000; + if (secs < 60) return `${secs.toFixed(1)}s`; + const mins = Math.floor(secs / 60); + const remainSecs = Math.round(secs % 60); + return `${mins}m ${remainSecs}s`; +} + /** Map tool name + input to a human-readable summary. */ function friendlyToolSummary(tc: ToolCallDisplay): string { switch (tc.name) { @@ -175,6 +195,8 @@ function friendlyToolSummary(tc: ToolCallDisplay): string { return 'Opened widget chooser'; case 'show_json_spec': return 'Opened JSON editor'; + case 'toggle_manual_editing': + return 'Toggled manual editing'; default: return tc.name; } @@ -186,11 +208,15 @@ function friendlyToolSummary(tc: ToolCallDisplay): string { const SUGGESTIONS = [ 'Compare weather in New York and Tokyo side by side', 'Show current conditions for 16 major cities in a 4x4 grid', - 'Build a compact 3-city overview dashboard', 'Set up a Tokyo weather dashboard and save it as a view called "Tokyo Overview"', 'Simplify to just current conditions and the 5-day forecast' ]; +const META_SUGGESTIONS = [ + 'What can you help me do?', + 'How do I change which city my dashboard shows?' +]; + const emptyState = hoistCmp.factory({ render({model}) { return div({ @@ -221,6 +247,23 @@ const emptyState = hoistCmp.factory({ }) ) ] + }), + div({ + className: 'weather-v2-chat-empty__suggestions', + items: [ + div({ + className: 'weather-v2-chat-empty__suggestions-label', + item: 'Or ask about the agent:' + }), + ...META_SUGGESTIONS.map(suggestion => + div({ + className: + 'weather-v2-chat-suggestion weather-v2-chat-suggestion--meta', + item: suggestion, + onClick: () => model.sendMessageAsync(suggestion) + }) + ) + ] }) ] }); diff --git a/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts b/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts index f6af05cd9..1862cbd07 100644 --- a/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts +++ b/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts @@ -19,6 +19,8 @@ export const jsonHarnessPanel = hoistCmp.factory({ title: 'JSON Spec Editor', icon: Icon.code(), compactHeader: true, + flex: 1, + minHeight: 0, item: vbox({flex: 1, items: [editorArea(), validationDisplay()]}), bbar: bottomToolbar() }); diff --git a/client-app/src/examples/weatherv2/svc/LlmToolService.ts b/client-app/src/examples/weatherv2/svc/LlmToolService.ts index 02a032a41..db98afe82 100644 --- a/client-app/src/examples/weatherv2/svc/LlmToolService.ts +++ b/client-app/src/examples/weatherv2/svc/LlmToolService.ts @@ -98,6 +98,12 @@ export class LlmToolService extends HoistService { return 'JSON spec editor opened.'; } + case 'toggle_manual_editing': { + const newVal = !appModel.manualEditingEnabled; + appModel.manualEditingEnabled = newVal; + return `Manual editing ${newVal ? 'enabled' : 'disabled'}.`; + } + default: throw new Error(`Unknown tool: "${name}"`); } @@ -176,5 +182,15 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [ properties: {}, required: [] } + }, + { + name: 'toggle_manual_editing', + description: + "Toggle manual editing mode on or off. When enabled, the user can drag, resize, and add widgets directly. Useful for previewing exact layout sizing, and especially helpful whenever a user asks to hide a widget's title bar — enabling manual editing lets them see and adjust the resulting layout.", + input_schema: { + type: 'object', + properties: {}, + required: [] + } } ]; From 4f64dd4400d8a55ec7bf71cb64e8f474e9db7572 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sun, 1 Mar 2026 21:25:20 -0800 Subject: [PATCH 35/41] Add colored border to input widgets matching their linkage color Input widgets (city choosers, units toggles) now display a 2px colored border around the entire DashCanvas card, using the same color assigned to them in the linkage color coding scheme. This makes the visual association between input producers and display consumers more prominent, and critically keeps the linkage visible even when the widget's panel header is hidden via the hidePanelHeader config. The border is applied via a MobX reaction in BaseWeatherWidgetModel that tracks the DashCanvasViewModel's observable ref (which resolves after DOM mount) and the widget's assigned color. This ensures the border updates reactively when widgets are added/removed and the color assignments shift. Co-Authored-By: Claude Opus 4.6 --- .../widgets/BaseWeatherWidgetModel.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts b/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts index 49758a64f..705906b7c 100644 --- a/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts +++ b/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts @@ -142,6 +142,33 @@ export abstract class BaseWeatherWidgetModel extends HoistModel { delay: 1 }); + // Apply colored border to the outer DashCanvas card for input widgets. + // Tracked separately so it fires when the DOM ref becomes available + // (after mount) and when the color changes (widgets added/removed). + this.addReaction({ + track: () => { + const meta = (this.constructor as any).meta as WidgetMeta; + if (meta?.category !== 'input') return null; + const canvasVM = this.viewModel as DashCanvasViewModel; + const el = canvasVM.ref?.current; + if (!el) return null; + return getInputWidgetColor(this.viewModel.id) ?? null; + }, + run: color => { + const canvasVM = this.viewModel as DashCanvasViewModel, + el = canvasVM.ref?.current as HTMLElement | null; + if (!el) return; + if (color) { + el.style.border = `2px solid ${color}`; + el.style.borderRadius = 'var(--xh-border-radius-px)'; + } else { + el.style.border = ''; + el.style.borderRadius = ''; + } + }, + fireImmediately: true + }); + // Sync hidePanelHeader based on viewState config + editing toggle. this.addReaction({ track: () => ({ From 4389d27992325561738dd93d5d23f376ae52b19b Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sun, 1 Mar 2026 21:44:44 -0800 Subject: [PATCH 36/41] Refine widget visual styling: rounded cards, square indicators, left-stripe borders, and empty state gradient Change color-coded header indicators from dots to slightly rounded squares. Widen input widget border stripe to 8px to match indicator size and remove the per-widget border-radius in favor of a global rule. Add border-radius and overflow hidden to all DashCanvas widget cards via the .react-grid-item class. Add a subtle radial gradient to the chat empty state for depth. Co-Authored-By: Claude Opus 4.6 --- client-app/src/examples/weatherv2/WeatherV2.scss | 13 ++++++++++++- .../weatherv2/widgets/BaseWeatherWidgetModel.ts | 6 ++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/client-app/src/examples/weatherv2/WeatherV2.scss b/client-app/src/examples/weatherv2/WeatherV2.scss index d51fda3ad..0d7b412c8 100644 --- a/client-app/src/examples/weatherv2/WeatherV2.scss +++ b/client-app/src/examples/weatherv2/WeatherV2.scss @@ -6,6 +6,12 @@ flex: 1; min-height: 0; } + + // Round corners on all DashCanvas widget cards + .react-grid-item { + border-radius: var(--xh-border-radius-px); + overflow: hidden; + } } .weather-v2-current-conditions { @@ -233,6 +239,11 @@ justify-content: center; padding: 20px; gap: 20px; + background: radial-gradient( + ellipse at center, + color-mix(in srgb, var(--xh-bg-alt) 40%, transparent) 0%, + transparent 70% + ); &__intro { display: flex; @@ -701,7 +712,7 @@ display: inline-block; width: 8px; height: 8px; - border-radius: 50%; + border-radius: 2px; margin-right: 4px; flex-shrink: 0; } diff --git a/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts b/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts index 705906b7c..23c94cb7f 100644 --- a/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts +++ b/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts @@ -159,11 +159,9 @@ export abstract class BaseWeatherWidgetModel extends HoistModel { el = canvasVM.ref?.current as HTMLElement | null; if (!el) return; if (color) { - el.style.border = `2px solid ${color}`; - el.style.borderRadius = 'var(--xh-border-radius-px)'; + el.style.borderLeft = `8px solid ${color}`; } else { - el.style.border = ''; - el.style.borderRadius = ''; + el.style.borderLeft = ''; } }, fireImmediately: true From addc81392d2c90660362258424b9f56370fc0a09 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Sun, 1 Mar 2026 21:51:48 -0800 Subject: [PATCH 37/41] Show grid background when manual editing is enabled and allow external drops on canvas Sync DashCanvasModel.showGridBackground with the manualEditingEnabled toggle so the grid lines appear as a visual cue during editing. Also enable allowsDrop on the DashCanvasModel. Co-Authored-By: Claude Opus 4.6 --- client-app/src/examples/weatherv2/AppModel.ts | 8 ++++++++ .../src/examples/weatherv2/dash/WeatherV2DashModel.ts | 1 + 2 files changed, 9 insertions(+) diff --git a/client-app/src/examples/weatherv2/AppModel.ts b/client-app/src/examples/weatherv2/AppModel.ts index cba83c0b8..65a0ec721 100644 --- a/client-app/src/examples/weatherv2/AppModel.ts +++ b/client-app/src/examples/weatherv2/AppModel.ts @@ -52,6 +52,14 @@ export class AppModel extends BaseAppModel { }); this.weatherV2DashModel = new WeatherV2DashModel(this.weatherViewManager); + + this.addReaction({ + track: () => this.manualEditingEnabled, + run: editing => { + this.weatherV2DashModel.dashCanvasModel.showGridBackground = editing; + }, + fireImmediately: true + }); } override getAppOptions() { diff --git a/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts b/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts index 93e1c627b..298ecd9eb 100644 --- a/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts +++ b/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts @@ -39,6 +39,7 @@ export class WeatherV2DashModel extends HoistModel { this.dashCanvasModel = new DashCanvasModel({ persistWith: {viewManagerModel}, rowHeight: 30, + allowsDrop: true, viewSpecs: [ { id: 'cityChooser', From b0c80fb3ab45d66a1dcbf0a64cbad221ebd594ce Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Mon, 2 Mar 2026 07:59:46 -0800 Subject: [PATCH 38/41] Add inline ColumnChooser to SummaryGridWidget settings panel Replace the generic visibleColumns multi-select config with Hoist's native ColChooserModel and LeftRightChooser UI embedded directly in the settings form. Column visibility changes apply immediately via commitOnChange and persist with the widget's viewState through DashViewProvider. The settings form now detects grid widgets with a colChooserModel and renders the native chooser inline, widen settings panel max-width to accommodate. Co-Authored-By: Claude Opus 4.6 --- .../src/examples/weatherv2/WeatherV2.scss | 10 ++++++- .../weatherv2/widgets/SummaryGridWidget.ts | 23 +++++++++++++--- .../weatherv2/widgets/WidgetSettingsForm.ts | 26 +++++++++++++++++++ 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/client-app/src/examples/weatherv2/WeatherV2.scss b/client-app/src/examples/weatherv2/WeatherV2.scss index 0d7b412c8..fcb65c5e8 100644 --- a/client-app/src/examples/weatherv2/WeatherV2.scss +++ b/client-app/src/examples/weatherv2/WeatherV2.scss @@ -597,7 +597,7 @@ border-left: var(--xh-border-solid); background: var(--xh-bg); min-width: 280px; - max-width: 380px; + max-width: 500px; &__header { border-bottom: var(--xh-border-solid); @@ -691,6 +691,14 @@ height: 230px; } + // Inline ColumnChooser — constrain the LeftRightChooser to fit within + // the settings panel without overflowing. + .xh-col-chooser { + height: 300px; + border: var(--xh-border-solid); + border-radius: var(--xh-border-radius-px); + } + &__checkbox-group { gap: 4px; } diff --git a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts index cfacf2b81..ace4da501 100644 --- a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts @@ -1,6 +1,7 @@ import {grid, GridAutosizeMode, GridModel} from '@xh/hoist/cmp/grid'; import {img} from '@xh/hoist/cmp/layout'; import {creates, hoistCmp, LoadSpec, managed, XH} from '@xh/hoist/core'; +import {ColChooserModel} from '@xh/hoist/desktop/cmp/grid/impl/colchooser/ColChooserModel'; import {computed, makeObservable} from '@xh/hoist/mobx'; import {groupBy} from 'lodash'; import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; @@ -39,10 +40,6 @@ export class SummaryGridModel extends BaseWeatherWidgetModel { ], outputs: [], config: { - visibleColumns: { - type: 'string[]', - default: ['date', 'icon', 'conditions', 'high', 'low', 'humidity', 'wind'] - }, hidePanelHeader: { type: 'boolean', default: false, @@ -83,6 +80,17 @@ export class SummaryGridModel extends BaseWeatherWidgetModel { run: () => this.updateGrid(), fireImmediately: true }); + + // Sync chooser data when settings modal opens so the LeftRightChooser + // reflects the current column visibility state. + this.addReaction({ + track: () => this.panelModel.isModal, + run: isModal => { + if (isModal) { + (this.gridModel.colChooserModel as ColChooserModel).syncChooserData(); + } + } + }); } override async doLoadAsync(loadSpec: LoadSpec) { @@ -104,6 +112,12 @@ export class SummaryGridModel extends BaseWeatherWidgetModel { sortBy: 'date', emptyText: 'No forecast data available.', autosizeOptions: {mode: GridAutosizeMode.MANAGED}, + colChooserModel: {commitOnChange: true}, + persistWith: { + dashViewModel: this.viewModel, + persistSort: false, + persistGrouping: false + }, columns: [ { field: 'date', @@ -121,6 +135,7 @@ export class SummaryGridModel extends BaseWeatherWidgetModel { { field: 'icon', headerName: '', + chooserName: 'Icon', width: 50, rendererIsComplex: true, renderer: (v, {record}) => { diff --git a/client-app/src/examples/weatherv2/widgets/WidgetSettingsForm.ts b/client-app/src/examples/weatherv2/widgets/WidgetSettingsForm.ts index 23d32da72..f1fb59274 100644 --- a/client-app/src/examples/weatherv2/widgets/WidgetSettingsForm.ts +++ b/client-app/src/examples/weatherv2/widgets/WidgetSettingsForm.ts @@ -1,8 +1,11 @@ import {hoistCmp, uses} from '@xh/hoist/core'; +import {GridModel} from '@xh/hoist/cmp/grid'; import {div, filler, span, vbox} from '@xh/hoist/cmp/layout'; import {form} from '@xh/hoist/cmp/form'; import {genDisplayName} from '@xh/hoist/data'; import {formField} from '@xh/hoist/desktop/cmp/form'; +import {colChooser} from '@xh/hoist/desktop/cmp/grid/impl/colchooser/ColChooser'; +import {ColChooserModel} from '@xh/hoist/desktop/cmp/grid/impl/colchooser/ColChooserModel'; import { codeInput, select, @@ -68,6 +71,7 @@ export const widgetSettingsForm = hoistCmp.factory({ className: 'weather-v2-settings-form__body', items: [ hasInputs ? renderInputsSection(model, inputs) : null, + renderColumnChooserSection(model), hasConfig ? renderConfigSection(model, config) : null ] }) @@ -159,6 +163,28 @@ function renderInputField(widgetModel: BaseWeatherWidgetModel, inputDef: InputDe }); } +//-------------------------------------------------- +// Column Chooser Section — native LeftRightChooser for grid widgets +//-------------------------------------------------- +function renderColumnChooserSection(widgetModel: BaseWeatherWidgetModel) { + const gridModel = (widgetModel as any).gridModel as GridModel | undefined; + if (!gridModel) return null; + + const colChooserModel = gridModel.colChooserModel as ColChooserModel; + if (!colChooserModel) return null; + + return vbox({ + className: 'weather-v2-settings-form__section', + items: [ + div({ + className: 'weather-v2-settings-form__section-title', + item: 'Columns' + }), + colChooser({model: colChooserModel}) + ] + }); +} + //-------------------------------------------------- // Config Section — widget-specific configuration via FormModel //-------------------------------------------------- From c14132efdab05919bfcdf6ba7726844155d368f2 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Tue, 3 Mar 2026 09:59:45 -0800 Subject: [PATCH 39/41] Move dashboard creation/modification into LLM tool-calling stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard changes are now handled via structured tool calls (set_dashboard, add_widget, remove_widget, update_widget, move_widget, list_widgets) instead of parsing JSON from the LLM's text response. This eliminates fragile regex extraction, enables granular widget updates without re-emitting the entire spec, and unifies all LLM-driven actions under a single tool-calling pipeline. Key changes: - LlmToolService: Added 6 dashboard manipulation tools with validation, error handling, and auto-placement. Each tool operates on the DashCanvasModel state array and validates the result before applying. - LlmChatService: Updated system prompt to direct the LLM to use tools for all dashboard changes. Added strong guidance favoring set_dashboard for structural changes (>3 tool calls) and update_widget for isolated tweaks. Current spec section now annotates widgets with instance IDs. - ChatHarnessModel: Removed text-based parseSpecFromResponse/applySpec flow. Dashboard changes now flow through the existing tool loop. Added consolidated single toast after tool loop completes (instead of per-tool toast spam). - ChatHarnessPanel: Updated tool call UI — Icon.tools() icon, standard text color (not muted), friendly summaries for new dashboard tools, and large JSON payloads render in a readonly jsonInput with search/copy/fullscreen. - validation.ts: Exported computeInstanceIds for use by tool service. - CityChooserWidget, UnitsToggleWidget, BaseWeatherWidgetModel: Added delay:1 to fireImmediately reactions that publish outputs or modify DashCanvasViewModel state during onLinked, fixing React "can't modify state while rendering" warnings. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/examples/weatherv2/WeatherV2.scss | 2 - .../src/examples/weatherv2/dash/validation.ts | 2 +- .../weatherv2/harness/ChatHarnessModel.ts | 47 +- .../weatherv2/harness/ChatHarnessPanel.ts | 80 +++- .../examples/weatherv2/svc/LlmChatService.ts | 58 ++- .../examples/weatherv2/svc/LlmToolService.ts | 426 +++++++++++++++++- .../widgets/BaseWeatherWidgetModel.ts | 4 +- .../weatherv2/widgets/CityChooserWidget.ts | 9 +- .../weatherv2/widgets/UnitsToggleWidget.ts | 5 +- 9 files changed, 564 insertions(+), 69 deletions(-) diff --git a/client-app/src/examples/weatherv2/WeatherV2.scss b/client-app/src/examples/weatherv2/WeatherV2.scss index fcb65c5e8..bad1ed93a 100644 --- a/client-app/src/examples/weatherv2/WeatherV2.scss +++ b/client-app/src/examples/weatherv2/WeatherV2.scss @@ -549,7 +549,6 @@ gap: 6px; padding: 4px 8px; cursor: pointer; - color: var(--xh-text-color-muted); font-size: var(--xh-font-size-small-px); list-style: none; user-select: none; @@ -572,7 +571,6 @@ &__detail { padding: 4px 8px 6px 22px; font-size: var(--xh-font-size-small-px); - color: var(--xh-text-color-muted); border-top: 1px solid var(--xh-border-color); } diff --git a/client-app/src/examples/weatherv2/dash/validation.ts b/client-app/src/examples/weatherv2/dash/validation.ts index 4f2c26c96..174e3c500 100644 --- a/client-app/src/examples/weatherv2/dash/validation.ts +++ b/client-app/src/examples/weatherv2/dash/validation.ts @@ -321,7 +321,7 @@ function validateReferential( * Compute widget instance IDs matching DashCanvasModel's 0-indexed assignment: * first instance of type X → "X_0", second → "X_1", etc. */ -function computeInstanceIds(state: DashWidgetState[]): string[] { +export function computeInstanceIds(state: DashWidgetState[]): string[] { const counts = new Map(); return state.map(widget => { const idx = counts.get(widget.viewSpecId) ?? 0; diff --git a/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts b/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts index ae652a752..0c7c65d07 100644 --- a/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts +++ b/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts @@ -1,7 +1,6 @@ -import {HoistModel, managed, PersistableState, TaskObserver, XH} from '@xh/hoist/core'; +import {HoistModel, managed, TaskObserver, XH} from '@xh/hoist/core'; import {action, bindable, computed, makeObservable, observable, runInAction} from '@xh/hoist/mobx'; import {DashSpec} from '../dash/types'; -import {validateSpec, migrateSpec} from '../dash/validation'; import {ChatMessage, ContentBlock} from '../svc/LlmChatService'; import {AppModel} from '../AppModel'; @@ -28,7 +27,10 @@ const MAX_TOOL_ITERATIONS = 5; /** * Model for the LLM chat harness — manages conversation history, - * LLM API calls, tool execution, spec application, and typewriter display effect. + * LLM API calls, tool execution loop, and typewriter display effect. + * + * Dashboard changes are handled by dashboard tools in LlmToolService, which + * execute within the tool-use loop alongside app operation tools. */ export class ChatHarnessModel extends HoistModel { @bindable userInput: string = ''; @@ -192,11 +194,22 @@ export class ChatHarnessModel extends HoistModel { // Build final display text from all text parts const finalText = allTextParts.join('\n\n'); - runInAction(() => { - // Parse and apply any spec from the combined text - const spec = chatSvc.parseSpecFromResponse(finalText); - if (spec) this.applySpec(spec); + // Single consolidated toast if any dashboard-mutating tools were called + const DASH_TOOLS = new Set([ + 'set_dashboard', + 'add_widget', + 'remove_widget', + 'update_widget', + 'move_widget' + ]); + const dashToolCount = allToolCalls.filter( + tc => DASH_TOOLS.has(tc.name) && !tc.isError + ).length; + if (dashToolCount > 0) { + XH.successToast('Dashboard updated.'); + } + runInAction(() => { // Add display message with tool calls + text + elapsed time const elapsedMs = Date.now() - startTime; this.displayMessages = [ @@ -269,26 +282,6 @@ export class ChatHarnessModel extends HoistModel { } } - @action - private applySpec(rawSpec: DashSpec) { - try { - const spec = migrateSpec(rawSpec); - const result = validateSpec(spec); - - if (!result.valid) { - const errorSummary = result.errors.map(e => e.message).join('; '); - this.lastError = `Spec validation failed: ${errorSummary}`; - return; - } - - const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; - dashModel.setPersistableState(new PersistableState({state: spec.state})); - XH.successToast('Dashboard updated from LLM response.'); - } catch (e) { - this.lastError = `Failed to apply spec: ${e.message}`; - } - } - //------------------ // Typewriter //------------------ diff --git a/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts index 544a58ef2..626cf3ae4 100644 --- a/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts +++ b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts @@ -4,7 +4,7 @@ import {div, hbox, vbox} from '@xh/hoist/cmp/layout'; import {markdown} from '@xh/hoist/cmp/markdown'; import {panel} from '@xh/hoist/desktop/cmp/panel'; import {button} from '@xh/hoist/desktop/cmp/button'; -import {textArea} from '@xh/hoist/desktop/cmp/input'; +import {jsonInput, textArea} from '@xh/hoist/desktop/cmp/input'; import {Icon} from '@xh/hoist/icon'; import {sparklesIcon} from '../Icons'; import { @@ -150,19 +150,14 @@ function renderToolCalls(toolCalls: ToolCallDisplay[]) { createElement( 'summary', {className: 'weather-v2-tool-call__summary'}, - Icon.bolt(), + Icon.tools(), friendlyToolSummary(tc) ), div({ className: 'weather-v2-tool-call__detail', items: [ - tc.input && Object.keys(tc.input).length > 0 - ? div({item: `Input: ${JSON.stringify(tc.input)}`}) - : null, - div({ - className: tc.isError ? 'weather-v2-tool-call__error' : null, - item: `Result: ${tc.result}` - }) + renderToolPayload('Input', tc.input), + renderToolPayload('Result', tc.result, tc.isError) ] }) ) @@ -170,6 +165,45 @@ function renderToolCalls(toolCalls: ToolCallDisplay[]) { }); } +/** + * Render a tool call payload (input or result). Small values render inline; + * large JSON payloads render in a readonly jsonInput with search and copy. + */ +function renderToolPayload(label: string, value: any, isError?: boolean): any { + if (value == null) return null; + + const isObject = typeof value === 'object'; + const str = isObject ? JSON.stringify(value, null, 2) : String(value); + + // Empty objects don't need display + if (isObject && Object.keys(value).length === 0) return null; + + // Only large JSON objects get the jsonInput treatment + if (isObject && str.length >= 120) { + return div({ + className: isError ? 'weather-v2-tool-call__error' : null, + items: [ + div({item: `${label}:`}), + jsonInput({ + value: str, + readonly: true, + showCopyButton: true, + showFullscreenButton: true, + editorProps: {lineNumbers: false}, + width: '100%', + minHeight: 240 + }) + ] + }); + } + + // Everything else renders inline + return div({ + className: isError ? 'weather-v2-tool-call__error' : null, + item: `${label}: ${isObject ? JSON.stringify(value) : str}` + }); +} + /** Format elapsed milliseconds into a friendly human-readable string. */ function formatElapsed(ms: number): string { if (ms < 1000) return `${ms}ms`; @@ -183,20 +217,34 @@ function formatElapsed(ms: number): string { /** Map tool name + input to a human-readable summary. */ function friendlyToolSummary(tc: ToolCallDisplay): string { switch (tc.name) { + // Dashboard tools + case 'set_dashboard': + return 'set_dashboard'; + case 'add_widget': + return `add_widget: ${tc.input?.viewSpecId ?? ''}`; + case 'remove_widget': + return `remove_widget: ${tc.input?.instanceId ?? ''}`; + case 'update_widget': + return `update_widget: ${tc.input?.instanceId ?? ''}`; + case 'move_widget': + return `move_widget: ${tc.input?.instanceId ?? ''}`; + case 'list_widgets': + return 'list_widgets'; + // App operation tools case 'save_dashboard_as_view': - return `Saved view: ${tc.input?.name ?? ''}`; + return `save_view: ${tc.input?.name ?? ''}`; case 'switch_to_view': - return `Switched to: ${tc.input?.name ?? ''}`; + return `switch_to_view: ${tc.input?.name ?? ''}`; case 'reset_dashboard': - return 'Reset to defaults'; + return 'reset_dashboard'; case 'toggle_theme': - return 'Toggled theme'; + return 'toggle_theme'; case 'open_widget_chooser': - return 'Opened widget chooser'; + return 'open_widget_chooser'; case 'show_json_spec': - return 'Opened JSON editor'; + return 'show_json_spec'; case 'toggle_manual_editing': - return 'Toggled manual editing'; + return 'toggle_manual_editing'; default: return tc.name; } diff --git a/client-app/src/examples/weatherv2/svc/LlmChatService.ts b/client-app/src/examples/weatherv2/svc/LlmChatService.ts index e2fc9f698..5fbd71b61 100644 --- a/client-app/src/examples/weatherv2/svc/LlmChatService.ts +++ b/client-app/src/examples/weatherv2/svc/LlmChatService.ts @@ -1,6 +1,7 @@ import {HoistService, XH} from '@xh/hoist/core'; import {widgetRegistry} from '../dash/WidgetRegistry'; import {DashSpec} from '../dash/types'; +import {computeInstanceIds} from '../dash/validation'; import {CITIES} from '../widgets/CityChooserWidget'; import {ToolDefinition} from './LlmToolService'; @@ -49,11 +50,18 @@ export class LlmChatService extends HoistService { CITY_RULES.replace('{{CITIES}}', cityList) ]; - if (currentSpec) { + if (currentSpec?.state?.length) { + const ids = computeInstanceIds(currentSpec.state); + const annotated = currentSpec.state.map((widget, idx) => ({ + instanceId: ids[idx], + ...widget + })); parts.push( - "## Current Dashboard Spec\n\nThe user's current dashboard is shown below. " + - 'When modifying, preserve widgets not mentioned in the request.\n\n```json\n' + - JSON.stringify(currentSpec, null, 2) + + '## Current Dashboard State\n\n' + + `The dashboard currently has ${annotated.length} widget(s). ` + + 'Each widget is shown with its `instanceId` — use these IDs with ' + + 'update_widget, remove_widget, and move_widget tools.\n\n```json\n' + + JSON.stringify(annotated, null, 2) + '\n```' ); } @@ -218,21 +226,41 @@ If a user asks "what cities are available", mention both the curated list and th const TOOL_USE_GUIDANCE = `## Tools -You have tools available for performing app operations. When the user asks for an action like saving a view, switching themes, or opening a panel, you MUST invoke the tool via the tool_use mechanism — do NOT describe, simulate, or role-play tool calls in your text. Only the tool_use API actually executes actions; writing tool calls in text does nothing. +You have tools for **all** dashboard and app operations. You MUST use tools to make changes — do NOT output raw JSON specs in your text response. Only tool_use API calls actually execute actions. -**When to use tools vs. JSON specs:** -- Use **tools** for app operations (saving views, changing theme, opening panels). The tool definitions describe each one. -- Use **JSON specs** for building or modifying dashboard layouts (adding/removing/repositioning widgets). -- You can combine both in one turn — e.g. generate a dashboard spec AND call save_dashboard_as_view. +**Dashboard tools** (use these for all layout and widget changes): +- \`set_dashboard\` — Replace the entire dashboard with a complete spec. **This is the primary tool for dashboard changes.** +- \`update_widget\` — Update a single widget's config or bindings (state changes merge). Best for targeted tweaks. +- \`add_widget\` — Add one widget. Returns the new instance ID. +- \`remove_widget\` — Remove a widget by instance ID. +- \`move_widget\` — Reposition or resize a single widget. +- \`list_widgets\` — Query the current dashboard inventory. -After a tool executes, briefly confirm the result in your text response.`; +**CRITICAL — Choosing the right tool:** +Use \`set_dashboard\` for ANY request that involves: +- Building a new dashboard from scratch +- Adding or removing multiple widgets +- Rearranging the layout (repositioning widgets) +- Any combination of adding widgets + changing bindings + adjusting layout +- Structural changes that affect more than 2-3 widgets -const OUTPUT_RULES = `## Output Rules +Use \`update_widget\` ONLY for small, isolated changes to 1-3 existing widgets where the layout stays the same: +- Changing a chart type on one widget +- Switching a binding source +- Toggling a config flag + +**Why this matters:** Each granular tool call (add_widget, move_widget, update_widget) applies immediately and rebuilds the dashboard. Chaining many of them creates a poor experience — multiple redraws, potential layout instability, and harder-to-debug results. A single \`set_dashboard\` call produces the final state atomically and reliably. + +**Rule of thumb:** If your plan involves more than 3 tool calls, use \`set_dashboard\` instead with the complete desired layout. -IMPORTANT: Always output a complete, valid JSON spec wrapped in a \`\`\`json code fence. Include ALL widgets — both new ones and any existing ones the user didn't ask to change. Do not output partial specs or diffs. +**App operation tools:** save_dashboard_as_view, switch_to_view, reset_dashboard, toggle_theme, open_widget_chooser, show_json_spec, toggle_manual_editing. + +After tool calls, briefly confirm the result in your text response.`; + +const OUTPUT_RULES = `## Output Rules -If the user asks to modify the dashboard, start from the current spec and make targeted changes. Preserve widget IDs, bindings, and layouts for widgets the user didn't mention. +**Do NOT output raw JSON specs or code fences in your text response.** All dashboard changes must be made through tool calls (set_dashboard, add_widget, remove_widget, update_widget, move_widget). -Before the JSON spec, include a brief, friendly explanation of what you're setting up or changing — written for a business audience, not a technical one. Focus on what the user will *see* in the dashboard, not how it's implemented. +Your text response should be a brief, friendly explanation of what you're setting up or changing — written for a business audience, not a technical one. Focus on what the user will *see* in the dashboard, not how it's implemented. -If the user asks a general question, or something conversational that does not require a dashboard change, respond naturally without producing a JSON spec. Not every message needs a dashboard update.`; +If the user asks a general question or something conversational that does not require a dashboard change, respond naturally without any tool calls. Not every message needs a dashboard update.`; diff --git a/client-app/src/examples/weatherv2/svc/LlmToolService.ts b/client-app/src/examples/weatherv2/svc/LlmToolService.ts index db98afe82..65cb2c67a 100644 --- a/client-app/src/examples/weatherv2/svc/LlmToolService.ts +++ b/client-app/src/examples/weatherv2/svc/LlmToolService.ts @@ -1,5 +1,8 @@ -import {HoistService, XH} from '@xh/hoist/core'; +import {HoistService, PersistableState, XH} from '@xh/hoist/core'; import {AppModel} from '../AppModel'; +import {DashSpec, DashWidgetState} from '../dash/types'; +import {validateSpec, migrateSpec, computeInstanceIds} from '../dash/validation'; +import {widgetRegistry} from '../dash/WidgetRegistry'; export interface ToolDefinition { name: string; @@ -14,9 +17,12 @@ export interface ToolDefinition { /** * Service that defines and executes LLM tools (function calling). * - * Tools are client-side operations that manipulate UI state — saving/loading views, - * toggling theme, opening panels. The LLM chooses when to call them based on the - * user's natural language requests. + * Tools are client-side operations that manipulate app and dashboard state. + * The LLM chooses when to call them based on the user's natural language requests. + * + * Dashboard tools (set_dashboard, add_widget, remove_widget, update_widget, + * move_widget, list_widgets) provide structured, validated dashboard manipulation. + * App tools (save/load views, toggle theme, etc.) handle non-dashboard operations. */ export class LlmToolService extends HoistService { static instance: LlmToolService; @@ -47,6 +53,30 @@ export class LlmToolService extends HoistService { viewManager = appModel.weatherViewManager; switch (name) { + //---------------------------------------------- + // Dashboard manipulation tools + //---------------------------------------------- + case 'set_dashboard': + return this.doSetDashboard(input); + + case 'add_widget': + return this.doAddWidget(input); + + case 'remove_widget': + return this.doRemoveWidget(input); + + case 'update_widget': + return this.doUpdateWidget(input); + + case 'move_widget': + return this.doMoveWidget(input); + + case 'list_widgets': + return this.doListWidgets(); + + //---------------------------------------------- + // App operation tools + //---------------------------------------------- case 'save_dashboard_as_view': { const viewName = input.name as string; if (!viewName?.trim()) throw new Error('View name is required.'); @@ -108,12 +138,400 @@ export class LlmToolService extends HoistService { throw new Error(`Unknown tool: "${name}"`); } } + + //---------------------------------------------- + // Dashboard tool implementations + //---------------------------------------------- + + /** Replace the entire dashboard with a new spec. */ + private doSetDashboard(input: Record): string { + const rawSpec = input.spec as DashSpec; + if (!rawSpec || !Array.isArray(rawSpec.state)) { + throw new Error('spec must be an object with a "state" array.'); + } + + const spec = migrateSpec({version: rawSpec.version ?? 1, state: rawSpec.state}); + const result = validateSpec(spec); + if (!result.valid) { + const errorSummary = result.errors.map(e => e.message).join('; '); + throw new Error(`Validation failed: ${errorSummary}`); + } + + this.applyState(spec.state); + + const ids = computeInstanceIds(spec.state); + const widgetSummary = ids.join(', '); + return `Dashboard updated with ${spec.state.length} widgets: ${widgetSummary}.`; + } + + /** Add a single widget to the dashboard. */ + private doAddWidget(input: Record): string { + const {viewSpecId, layout, state: widgetState} = input; + + if (!viewSpecId || !widgetRegistry.has(viewSpecId)) { + const available = widgetRegistry.getIds().join(', '); + throw new Error(`Invalid viewSpecId "${viewSpecId}". Available types: ${available}.`); + } + + const currentState = this.getCurrentStateArray(); + + // Build the new widget entry + const newWidget: DashWidgetState = { + viewSpecId, + layout: layout ?? this.getAutoLayout(viewSpecId) + }; + if (widgetState) newWidget.state = widgetState as Record; + + const newState = [...currentState, newWidget]; + + // Validate the resulting full spec + const spec: DashSpec = {version: 1, state: newState}; + const result = validateSpec(spec); + if (!result.valid) { + const errorSummary = result.errors.map(e => e.message).join('; '); + throw new Error(`Cannot add widget — validation failed: ${errorSummary}`); + } + + this.applyState(newState); + + const ids = computeInstanceIds(newState); + const newId = ids[ids.length - 1]; + return `Added ${viewSpecId} widget as "${newId}".`; + } + + /** Remove a widget by instance ID. */ + private doRemoveWidget(input: Record): string { + const {instanceId} = input; + if (!instanceId) throw new Error('instanceId is required.'); + + const currentState = this.getCurrentStateArray(); + const ids = computeInstanceIds(currentState); + const idx = ids.indexOf(instanceId); + + if (idx === -1) { + throw new Error( + `No widget found with instanceId "${instanceId}". ` + + `Current widgets: ${ids.join(', ')}.` + ); + } + + const newState = [...currentState.slice(0, idx), ...currentState.slice(idx + 1)]; + + // Validate (removal can break bindings referencing the removed widget) + const spec: DashSpec = {version: 1, state: newState}; + const result = validateSpec(spec); + if (!result.valid) { + const errorSummary = result.errors.map(e => e.message).join('; '); + throw new Error( + `Cannot remove "${instanceId}" — validation failed: ${errorSummary}. ` + + 'Other widgets may have bindings referencing this widget. ' + + 'Update or remove those bindings first.' + ); + } + + this.applyState(newState); + return `Removed widget "${instanceId}". Dashboard now has ${newState.length} widgets.`; + } + + /** Update a widget's state (config, bindings) by instance ID. */ + private doUpdateWidget(input: Record): string { + const {instanceId, state: stateChanges, layout: layoutChanges} = input; + if (!instanceId) throw new Error('instanceId is required.'); + if (!stateChanges && !layoutChanges) { + throw new Error('At least one of "state" or "layout" must be provided.'); + } + + const currentState = this.getCurrentStateArray(); + const ids = computeInstanceIds(currentState); + const idx = ids.indexOf(instanceId); + + if (idx === -1) { + throw new Error( + `No widget found with instanceId "${instanceId}". ` + + `Current widgets: ${ids.join(', ')}.` + ); + } + + const widget = {...currentState[idx]}; + + // Merge state changes (shallow merge, with special handling for bindings) + if (stateChanges) { + const existingState = {...(widget.state ?? {})}; + for (const [key, value] of Object.entries(stateChanges)) { + if (key === 'bindings') { + // Merge bindings: new bindings override existing ones by input name + existingState.bindings = { + ...(existingState.bindings ?? {}), + ...(value as Record) + }; + } else { + existingState[key] = value; + } + } + widget.state = existingState; + } + + // Merge layout changes + if (layoutChanges) { + widget.layout = {...widget.layout, ...layoutChanges}; + } + + const newState = [...currentState]; + newState[idx] = widget; + + const spec: DashSpec = {version: 1, state: newState}; + const result = validateSpec(spec); + if (!result.valid) { + const errorSummary = result.errors.map(e => e.message).join('; '); + throw new Error(`Cannot update "${instanceId}" — validation failed: ${errorSummary}`); + } + + this.applyState(newState); + return `Updated widget "${instanceId}".`; + } + + /** Move/resize a widget by instance ID. */ + private doMoveWidget(input: Record): string { + const {instanceId, layout} = input; + if (!instanceId) throw new Error('instanceId is required.'); + if (!layout) throw new Error('layout is required.'); + + // Delegate to update_widget with layout-only changes + return this.doUpdateWidget({instanceId, layout}); + } + + /** List current widgets with instance IDs, types, layouts, and state. */ + private doListWidgets(): string { + const currentState = this.getCurrentStateArray(); + const ids = computeInstanceIds(currentState); + + const widgets = currentState.map((widget, idx) => ({ + instanceId: ids[idx], + viewSpecId: widget.viewSpecId, + layout: widget.layout, + state: widget.state ?? {} + })); + + return JSON.stringify(widgets, null, 2); + } + + //---------------------------------------------- + // Helpers + //---------------------------------------------- + + /** Get the current dashboard state array from DashCanvasModel. */ + private getCurrentStateArray(): DashWidgetState[] { + const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; + const persistable = dashModel.getPersistableState(); + return persistable?.value?.state ?? []; + } + + /** Validate and apply a state array to the dashboard. */ + private applyState(state: DashWidgetState[]) { + const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; + dashModel.setPersistableState(new PersistableState({state})); + } + + /** Compute a reasonable auto-placement layout for a new widget. */ + private getAutoLayout(viewSpecId: string): {x: number; y: number; w: number; h: number} { + const meta = widgetRegistry.get(viewSpecId); + const w = meta?.defaultSize?.w ?? 6; + const h = meta?.defaultSize?.h ?? 6; + + // Place at the bottom of the current dashboard + const currentState = this.getCurrentStateArray(); + let maxY = 0; + for (const widget of currentState) { + const bottom = (widget.layout?.y ?? 0) + (widget.layout?.h ?? 0); + if (bottom > maxY) maxY = bottom; + } + + return {x: 0, y: maxY, w, h}; + } } //-------------------------------------------------- // Tool definitions in Anthropic API format //-------------------------------------------------- const TOOL_DEFINITIONS: ToolDefinition[] = [ + //---------------------------------------------- + // Dashboard manipulation tools + //---------------------------------------------- + { + name: 'set_dashboard', + description: + 'Replace the entire dashboard with a complete new spec. Use this when creating a ' + + 'dashboard from scratch or making large-scale restructuring changes that affect most ' + + 'widgets. The spec is validated before being applied. For smaller, targeted changes, ' + + 'prefer add_widget, remove_widget, or update_widget.', + input_schema: { + type: 'object', + properties: { + spec: { + type: 'object', + description: + 'Complete dashboard spec with "version" (number, typically 1) and ' + + '"state" (array of widget objects). Each widget has: viewSpecId (string), ' + + 'layout ({x, y, w, h}), and optional state ({bindings, ...config}).', + properties: { + version: {type: 'number'}, + state: { + type: 'array', + items: { + type: 'object', + properties: { + viewSpecId: {type: 'string'}, + layout: { + type: 'object', + properties: { + x: {type: 'number'}, + y: {type: 'number'}, + w: {type: 'number'}, + h: {type: 'number'} + }, + required: ['x', 'y', 'w', 'h'] + }, + state: {type: 'object'} + }, + required: ['viewSpecId', 'layout'] + } + } + }, + required: ['state'] + } + }, + required: ['spec'] + } + }, + { + name: 'add_widget', + description: + 'Add a single widget to the dashboard. The widget is appended and assigned the ' + + 'next available instance ID for its type. Returns the new instance ID. If layout ' + + 'is omitted, the widget is auto-placed at the bottom of the dashboard.', + input_schema: { + type: 'object', + properties: { + viewSpecId: { + type: 'string', + description: 'Widget type to add (e.g. "forecastChart", "cityChooser").' + }, + layout: { + type: 'object', + description: 'Grid position and size. Omit to auto-place at the bottom.', + properties: { + x: {type: 'number', description: 'Column (0-11)'}, + y: {type: 'number', description: 'Row'}, + w: {type: 'number', description: 'Width in columns (1-12)'}, + h: {type: 'number', description: 'Height in rows'} + }, + required: ['x', 'y', 'w', 'h'] + }, + state: { + type: 'object', + description: + 'Optional initial state with config keys and bindings. Example: ' + + '{"bindings": {"city": {"fromWidget": "cityChooser_0", "output": "selectedCity"}}, ' + + '"series": ["temp", "humidity"]}' + } + }, + required: ['viewSpecId'] + } + }, + { + name: 'remove_widget', + description: + 'Remove a widget from the dashboard by its instance ID. Will fail if other widgets ' + + 'have bindings referencing this widget — update those bindings first.', + input_schema: { + type: 'object', + properties: { + instanceId: { + type: 'string', + description: 'Instance ID of the widget to remove (e.g. "forecastChart_0").' + } + }, + required: ['instanceId'] + } + }, + { + name: 'update_widget', + description: + "Update a widget's configuration, bindings, and/or layout. State changes are " + + 'merged with existing state — only the keys you provide are updated. Bindings are ' + + 'also merged by input name. Use this for targeted changes like switching a chart type, ' + + 'changing a binding, or adjusting position.', + input_schema: { + type: 'object', + properties: { + instanceId: { + type: 'string', + description: 'Instance ID of the widget to update (e.g. "forecastChart_0").' + }, + state: { + type: 'object', + description: + 'State changes to merge. Include "bindings" to update wiring, or config ' + + 'keys to change settings. Example: {"chartType": "bar"} or ' + + '{"bindings": {"city": {"const": "Tokyo"}}}.' + }, + layout: { + type: 'object', + description: + 'Layout changes to merge. Only include the dimensions you want to change.', + properties: { + x: {type: 'number'}, + y: {type: 'number'}, + w: {type: 'number'}, + h: {type: 'number'} + } + } + }, + required: ['instanceId'] + } + }, + { + name: 'move_widget', + description: + 'Move or resize a widget by updating its layout position. Shorthand for ' + + 'update_widget with only layout changes.', + input_schema: { + type: 'object', + properties: { + instanceId: { + type: 'string', + description: 'Instance ID of the widget to move (e.g. "forecastChart_0").' + }, + layout: { + type: 'object', + description: 'New layout. Include only the dimensions to change.', + properties: { + x: {type: 'number', description: 'Column (0-11)'}, + y: {type: 'number', description: 'Row'}, + w: {type: 'number', description: 'Width in columns (1-12)'}, + h: {type: 'number', description: 'Height in rows'} + } + } + }, + required: ['instanceId', 'layout'] + } + }, + { + name: 'list_widgets', + description: + "Get the current dashboard widget inventory. Returns each widget's instance ID, " + + 'type, layout, and state (including bindings). Use this to inspect the current ' + + 'dashboard before making targeted changes.', + input_schema: { + type: 'object', + properties: {}, + required: [] + } + }, + + //---------------------------------------------- + // App operation tools + //---------------------------------------------- { name: 'save_dashboard_as_view', description: diff --git a/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts b/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts index 23c94cb7f..78db73acb 100644 --- a/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts +++ b/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts @@ -168,6 +168,7 @@ export abstract class BaseWeatherWidgetModel extends HoistModel { }); // Sync hidePanelHeader based on viewState config + editing toggle. + // delay: 1 avoids modifying canvasVM observable state during render. this.addReaction({ track: () => ({ editing: AppModel.instance.manualEditingEnabled, @@ -179,7 +180,8 @@ export abstract class BaseWeatherWidgetModel extends HoistModel { // When locked, respect the widget's hidePanelHeader setting. canvasVM.hidePanelHeader = editing ? false : hide; }, - fireImmediately: true + fireImmediately: true, + delay: 1 }); } diff --git a/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts b/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts index 243ce3561..a7237e682 100644 --- a/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts @@ -97,11 +97,16 @@ export class CityChooserModel extends BaseWeatherWidgetModel { super.onLinked(); this.markPersist('selectedCity'); - // Publish output whenever city changes + // Publish output whenever city changes. + // delay: 1 avoids modifying observable state synchronously during the + // React render cycle that triggers onLinked (fireImmediately + @action + // on publishOutput would otherwise cause "can't update state while + // rendering" warnings from React). this.addReaction({ track: () => this.selectedCity, run: city => this.publishOutput('selectedCity', city), - fireImmediately: true + fireImmediately: true, + delay: 1 }); } diff --git a/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts b/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts index 15e8ba941..80a1fbe77 100644 --- a/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts +++ b/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts @@ -45,10 +45,13 @@ export class UnitsToggleModel extends BaseWeatherWidgetModel { super.onLinked(); this.markPersist('units'); + // delay: 1 avoids modifying observable state synchronously during the + // React render cycle that triggers onLinked. this.addReaction({ track: () => this.units, run: units => this.publishOutput('units', units), - fireImmediately: true + fireImmediately: true, + delay: 1 }); } } From de71f77207c8db0738bccfe13856078a09bfbc70 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Thu, 23 Apr 2026 14:10:55 -0700 Subject: [PATCH 40/41] Adopt hoist-react v85 initAsync / installServicesAsync API in weatherv2 Align with the v85 signatures used by the rest of the app: accept an InitContext on initAsync, forward it to super, and pass the service list as an array alongside ctx to installServicesAsync. Wrap the ViewManagerModel.createAsync call in a named child span under ctx.span so view-load timing nests under xh.client.appInit in OTEL. Co-Authored-By: Claude Opus 4.7 (1M context) --- client-app/src/examples/weatherv2/AppModel.ts | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/client-app/src/examples/weatherv2/AppModel.ts b/client-app/src/examples/weatherv2/AppModel.ts index 65a0ec721..04ed0674c 100644 --- a/client-app/src/examples/weatherv2/AppModel.ts +++ b/client-app/src/examples/weatherv2/AppModel.ts @@ -1,4 +1,4 @@ -import {managed, persist, XH} from '@xh/hoist/core'; +import {InitContext, managed, persist, XH} from '@xh/hoist/core'; import {ViewManagerModel} from '@xh/hoist/cmp/viewmanager'; import {PanelModel} from '@xh/hoist/desktop/cmp/panel'; import {bindable, makeObservable} from '@xh/hoist/mobx'; @@ -30,9 +30,9 @@ export class AppModel extends BaseAppModel { makeObservable(this); } - override async initAsync() { - await super.initAsync(); - await XH.installServicesAsync(LlmChatService, LlmToolService, WeatherDataService); + override async initAsync(ctx: InitContext) { + await super.initAsync(ctx); + await XH.installServicesAsync([LlmChatService, LlmToolService, WeatherDataService], ctx); this.harnessPanelModel = new PanelModel({ side: 'right', @@ -43,13 +43,18 @@ export class AppModel extends BaseAppModel { persistWith: {...this.persistWith, path: 'harnessPanel'} }); - this.weatherViewManager = await ViewManagerModel.createAsync({ - type: 'weatherDashboardV2', - typeDisplayName: 'Layout', - enableDefault: true, - enableAutoSave: false, - manageGlobal: XH.getUser().isHoistAdmin - }); + await this.withSpanAsync( + {name: 'toolbox.weatherv2.loadViews', parent: ctx.span}, + async () => { + this.weatherViewManager = await ViewManagerModel.createAsync({ + type: 'weatherDashboardV2', + typeDisplayName: 'Layout', + enableDefault: true, + enableAutoSave: false, + manageGlobal: XH.getUser().isHoistAdmin + }); + } + ); this.weatherV2DashModel = new WeatherV2DashModel(this.weatherViewManager); From 27b1d9926b98f54d6b0542bbe1ffe941bd54c994 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Tue, 19 May 2026 17:53:25 -0700 Subject: [PATCH 41/41] Adopt renamed Runner API in weatherv2 AppModel `HoistBase.withSpanAsync(config, fn)` was replaced by the builder form `newSpan(config).run(fn)`. Other call sites migrated in #843; this one was on a divergent branch at the time. Co-Authored-By: Claude Opus 4.7 (1M context) --- client-app/src/examples/weatherv2/AppModel.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client-app/src/examples/weatherv2/AppModel.ts b/client-app/src/examples/weatherv2/AppModel.ts index 04ed0674c..ac896b006 100644 --- a/client-app/src/examples/weatherv2/AppModel.ts +++ b/client-app/src/examples/weatherv2/AppModel.ts @@ -43,8 +43,7 @@ export class AppModel extends BaseAppModel { persistWith: {...this.persistWith, path: 'harnessPanel'} }); - await this.withSpanAsync( - {name: 'toolbox.weatherv2.loadViews', parent: ctx.span}, + await this.newSpan({name: 'toolbox.weatherv2.loadViews', parent: ctx.span}).run( async () => { this.weatherViewManager = await ViewManagerModel.createAsync({ type: 'weatherDashboardV2',