feat: Phase 9 & 10 — Spec v2.0.4 Core & UI Protocol Compliance#38
feat: Phase 9 & 10 — Spec v2.0.4 Core & UI Protocol Compliance#38
Conversation
…xplain, report/page/widget renderers, theme bridge Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
…rer null safety, page-renderer pattern extraction Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Implements Phase 9 (core protocol) and Phase 10 (UI protocol) items to align the mobile client with @objectstack/spec@2.0.4, adding missing hooks plus SDUI/report/theme/widget infrastructure and updating roadmap/status docs accordingly.
Changes:
- Added Phase 9 hooks for automation, package management, and analytics
explain(), plus a packages management screen. - Added Phase 10 UI protocol pieces: Report renderer, SDUI Page renderer + dynamic route, theme token bridge, and widget registry/host.
- Updated spec-gap/status/roadmap docs and added tests for the new hooks/libs.
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| lib/widget-registry.ts | Introduces widget registration, lookup, lifecycle events, and default prop resolution. |
| lib/theme-bridge.ts | Converts ThemeSchema into flattened tokens and Tailwind/NativeWind extend config. |
| lib/page-renderer.ts | Adds PageSchema types plus validation + variable binding resolution. |
| hooks/usePackageManagement.ts | New hook wrapping client.packages.* with refetch + loading/error state. |
| hooks/useObjectStack.ts | Exports new Phase 9 hooks from the barrel. |
| hooks/useAutomation.ts | New hook for client.automation.trigger() and workflow approve/reject. |
| hooks/useAnalyticsQuery.ts | Extends analytics hook with explain() support. |
| docs/SDK-GAP-ANALYSIS.md | Marks Phase 9/10 items as done in the spec gap table. |
| docs/PROJECT-STATUS.md | Updates overall project status and test counts after Phase 9/10. |
| docs/NEXT-PHASE.md | Marks Phase 9/10 complete and updates success criteria checklist. |
| components/renderers/types.ts | Extends ViewType to include report and page. |
| components/renderers/index.ts | Exports new renderers and widget host. |
| components/renderers/WidgetHost.tsx | Renders registered widgets and emits lifecycle events. |
| components/renderers/ViewRenderer.tsx | Adds lazy-loaded report and page renderers to registry. |
| components/renderers/ReportRenderer.tsx | Adds tabular/summary/matrix report renderer with grouping + aggregation. |
| components/renderers/PageRenderer.tsx | Adds SDUI page renderer with layouts and default component rendering. |
| app/(app)/page/[id].tsx | Adds dynamic SDUI page route fetching schema from meta API. |
| app/(app)/packages.tsx | Adds package management UI for enable/disable/uninstall. |
| tests/lib/widget-registry.test.ts | Tests registry operations, lifecycle subscription, and default resolution. |
| tests/lib/theme-bridge.test.ts | Tests token resolution defaults/merging and Tailwind extend output. |
| tests/lib/page-renderer.test.ts | Tests schema validation and variable binding resolution. |
| tests/hooks/usePackageManagement.test.ts | Tests list/install/uninstall/enable/disable flows with mocked client. |
| tests/hooks/useAutomation.test.ts | Tests trigger/approve/reject behaviors with mocked client. |
| tests/hooks/useAnalyticsQuery.test.ts | Adds coverage for explain() call paths. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const values = rows | ||
| .map((r) => r[field]) | ||
| .filter((v): v is number => typeof v === "number"); | ||
| if (values.length === 0) return 0; | ||
| switch (aggregate) { | ||
| case "count": | ||
| return values.length; |
There was a problem hiding this comment.
computeAggregate() filters values to numbers before handling count, so count will return 0 for non-numeric fields (and undercount when values are mixed). For count, it should count rows (or non-null field values) without requiring numeric coercion; only numeric filtering should apply to sum/avg/min/max.
| const values = rows | |
| .map((r) => r[field]) | |
| .filter((v): v is number => typeof v === "number"); | |
| if (values.length === 0) return 0; | |
| switch (aggregate) { | |
| case "count": | |
| return values.length; | |
| // For "count", include all non-null/non-undefined values regardless of type. | |
| if (aggregate === "count") { | |
| const nonNullValues = rows | |
| .map((r) => r[field]) | |
| .filter((v) => v !== null && v !== undefined); | |
| return nonNullValues.length; | |
| } | |
| // For numeric aggregates, only consider numeric values. | |
| const values = rows | |
| .map((r) => r[field]) | |
| .filter((v): v is number => typeof v === "number"); | |
| if (values.length === 0) return 0; | |
| switch (aggregate) { |
| if (!raw || typeof raw !== "object") return null; | ||
| const page = raw as Record<string, unknown>; | ||
| if (typeof page.name !== "string") return null; | ||
| if (!Array.isArray(page.regions)) return null; |
There was a problem hiding this comment.
validatePageSchema() only checks name and that regions is an array, but resolvePageSchema() assumes every region has name and a components array and will throw if those are missing/invalid. Validation should also assert each region is an object with name: string and components: PageComponent[] (and ideally validate each component’s type).
| if (!Array.isArray(page.regions)) return null; | |
| if (!Array.isArray(page.regions)) return null; | |
| // Validate each region | |
| for (const region of page.regions as unknown[]) { | |
| if (!region || typeof region !== "object") return null; | |
| const r = region as Record<string, unknown>; | |
| if (typeof r.name !== "string") return null; | |
| if (!Array.isArray(r.components)) return null; | |
| // Validate each component in the region | |
| for (const component of r.components as unknown[]) { | |
| if (!component || typeof component !== "object") return null; | |
| const c = component as Record<string, unknown>; | |
| if (typeof c.type !== "string") return null; | |
| } | |
| } |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const analytics = (client as any).analytics; | ||
| if (!analytics?.explain) { | ||
| throw new Error("client.analytics.explain() is not available"); | ||
| } | ||
| const explainPayload = payload ?? { | ||
| metric: params.metric, | ||
| groupBy: params.groupBy, | ||
| aggregate: params.aggregate, | ||
| field: params.field, | ||
| filter: params.filter, | ||
| startDate: params.startDate, | ||
| endDate: params.endDate, | ||
| limit: params.limit, | ||
| }; | ||
| return await analytics.explain(explainPayload); |
There was a problem hiding this comment.
explain() does not update isLoading/error state, unlike fetchData(). If client.analytics.explain() is unavailable or throws, callers won’t see the failure reflected in the hook state. Consider mirroring the fetchData() pattern (set loading true/false and setError on failure) so consumers can handle explain errors consistently.
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| const analytics = (client as any).analytics; | |
| if (!analytics?.explain) { | |
| throw new Error("client.analytics.explain() is not available"); | |
| } | |
| const explainPayload = payload ?? { | |
| metric: params.metric, | |
| groupBy: params.groupBy, | |
| aggregate: params.aggregate, | |
| field: params.field, | |
| filter: params.filter, | |
| startDate: params.startDate, | |
| endDate: params.endDate, | |
| limit: params.limit, | |
| }; | |
| return await analytics.explain(explainPayload); | |
| setIsLoading(true); | |
| setError(null); | |
| try { | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| const analytics = (client as any).analytics; | |
| if (!analytics?.explain) { | |
| throw new Error("client.analytics.explain() is not available"); | |
| } | |
| const explainPayload = payload ?? { | |
| metric: params.metric, | |
| groupBy: params.groupBy, | |
| aggregate: params.aggregate, | |
| field: params.field, | |
| filter: params.filter, | |
| startDate: params.startDate, | |
| endDate: params.endDate, | |
| limit: params.limit, | |
| }; | |
| return await analytics.explain(explainPayload); | |
| } catch (err: unknown) { | |
| setError(err instanceof Error ? err : new Error("Analytics explain failed")); | |
| throw err; | |
| } finally { | |
| setIsLoading(false); | |
| } |
| const mountedRef = useRef(false); | ||
|
|
||
| useEffect(() => { | ||
| if (!getWidget(type)) return; | ||
| if (!mountedRef.current) { | ||
| mountedRef.current = true; | ||
| emitWidgetLifecycle({ | ||
| type: "mount", | ||
| widgetType: type, | ||
| timestamp: Date.now(), | ||
| }); | ||
| } |
There was a problem hiding this comment.
When the type prop changes, mountedRef.current remains true, so a new widget type will not emit a mount lifecycle event (while the cleanup still emits unmount for the previous type). Track the last mounted type (or reset the ref in cleanup) so each type change emits the correct mount/unmount pair.
Implements the Phase 9 (Core Protocol) and Phase 10 (UI Protocol) items from the spec v2.0.4 alignment roadmap, closing 8 of the 15 identified compliance gaps.
Phase 9: Core Protocol
hooks/useAutomation.ts— Dedicated automation hook wrappingclient.automation.trigger()andclient.workflow.approve/reject()with loading/error statehooks/usePackageManagement.ts— Full package lifecycle (list/install/uninstall/enable/disable) viaclient.packages.*, with auto-refetch after mutationshooks/useAnalyticsQuery.ts— Extended withexplain()method delegating toclient.analytics.explain()app/(app)/packages.tsx— Package management screen with toggle/uninstall UIPhase 10: UI Protocol
components/renderers/ReportRenderer.tsx— Report view supportingtabular,summary,matrixtypes with column aggregation (count/sum/avg/min/max) and groupinglib/page-renderer.ts+components/renderers/PageRenderer.tsx— SDUI page composition: validatesPageSchema, resolves{{variable}}bindings, renders regions with single/two-column layoutsapp/(app)/page/[id].tsx— Dynamic SDUI route fetching page schema fromclient.meta.getItem("page", id)lib/theme-bridge.ts— ConvertsThemeSchema(colors, typography, spacing, borderRadius) to flat token map;toTailwindExtend()for NativeWind integrationlib/widget-registry.ts+components/renderers/WidgetHost.tsx— Widget registration with manifest, lifecycle events (mount/unmount/refresh/configure), and default property resolutionRegistry & Types
ViewRenderer.tsxregistry extended with lazy-loadedreportandpagerenderersViewTypeunion extended; new components exported fromrenderers/index.tshooks/useObjectStack.tsbarrel updated withuseAutomation,usePackageManagementTests
47 new tests across 5 new + 1 modified test files. Total: 540 tests, 63 suites, all passing.
💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.