Skip to content

feat: Phase 9 & 10 — Spec v2.0.4 Core & UI Protocol Compliance#38

Merged
hotlong merged 4 commits intomainfrom
copilot/complete-next-phase-development
Feb 11, 2026
Merged

feat: Phase 9 & 10 — Spec v2.0.4 Core & UI Protocol Compliance#38
hotlong merged 4 commits intomainfrom
copilot/complete-next-phase-development

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Feb 10, 2026

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 wrapping client.automation.trigger() and client.workflow.approve/reject() with loading/error state
  • hooks/usePackageManagement.ts — Full package lifecycle (list/install/uninstall/enable/disable) via client.packages.*, with auto-refetch after mutations
  • hooks/useAnalyticsQuery.ts — Extended with explain() method delegating to client.analytics.explain()
  • app/(app)/packages.tsx — Package management screen with toggle/uninstall UI

Phase 10: UI Protocol

  • components/renderers/ReportRenderer.tsx — Report view supporting tabular, summary, matrix types with column aggregation (count/sum/avg/min/max) and grouping
  • lib/page-renderer.ts + components/renderers/PageRenderer.tsx — SDUI page composition: validates PageSchema, resolves {{variable}} bindings, renders regions with single/two-column layouts
  • app/(app)/page/[id].tsx — Dynamic SDUI route fetching page schema from client.meta.getItem("page", id)
  • lib/theme-bridge.ts — Converts ThemeSchema (colors, typography, spacing, borderRadius) to flat token map; toTailwindExtend() for NativeWind integration
  • lib/widget-registry.ts + components/renderers/WidgetHost.tsx — Widget registration with manifest, lifecycle events (mount/unmount/refresh/configure), and default property resolution

Registry & Types

  • ViewRenderer.tsx registry extended with lazy-loaded report and page renderers
  • ViewType union extended; new components exported from renderers/index.ts
  • hooks/useObjectStack.ts barrel updated with useAutomation, usePackageManagement
// Automation
const { trigger, approve, reject } = useAutomation();
await trigger("onboard-user", { userId: "123" });

// Package management
const { packages, install, enable, disable, uninstall } = usePackageManagement();

// Analytics explain
const { explain } = useAnalyticsQuery({ metric: "tasks", groupBy: "status" });
const { sql, plan } = await explain();

// Theme tokens
const tokens = resolveThemeTokens(serverTheme);
const tailwindExtend = toTailwindExtend(tokens);

// Widget system
registerWidget({ type: "metric-card", label: "Metric", properties: [...] }, MetricCardComponent);

Tests

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.

Copilot AI and others added 3 commits February 10, 2026 21:44
…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>
Copilot AI changed the title [WIP] Complete development for next phase, phase 9, and phase 10 feat: Phase 9 & 10 — Spec v2.0.4 Core & UI Protocol Compliance Feb 10, 2026
Copilot AI requested a review from hotlong February 10, 2026 21:54
@hotlong hotlong marked this pull request as ready for review February 11, 2026 01:42
Copilot AI review requested due to automatic review settings February 11, 2026 01:42
@hotlong hotlong merged commit f7bb870 into main Feb 11, 2026
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +58 to +64
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;
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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) {

Copilot uses AI. Check for mistakes.
Comment thread lib/page-renderer.ts
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;
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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;
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +134 to +149
// 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);
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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);
}

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +45
const mountedRef = useRef(false);

useEffect(() => {
if (!getWidget(type)) return;
if (!mountedRef.current) {
mountedRef.current = true;
emitWidgetLifecycle({
type: "mount",
widgetType: type,
timestamp: Date.now(),
});
}
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants