From 10aec4281288172aab1fc38f0419055fcedb088b Mon Sep 17 00:00:00 2001
From: Irakli Gozalishvili
Date: Fri, 21 Mar 2025 13:03:12 -0700
Subject: [PATCH 01/15] feat: state machine component
---
jumble/src/components/View.tsx | 106 +++++++++++++++++++++++++++++++++
1 file changed, 106 insertions(+)
create mode 100644 jumble/src/components/View.tsx
diff --git a/jumble/src/components/View.tsx b/jumble/src/components/View.tsx
new file mode 100644
index 000000000..0374b6eab
--- /dev/null
+++ b/jumble/src/components/View.tsx
@@ -0,0 +1,106 @@
+import { JSX, useCallback, useEffect, useRef, useState } from "react";
+
+export interface Behavior {
+ init(): { state: Model; fx?: Effect[] };
+ update(
+ model: Model,
+ command: Command,
+ ): { state: Model; fx?: Effect[] };
+
+ view(model: Model, controller: Controller): JSX.Element;
+}
+
+export interface Effect {
+ (): Promise;
+}
+
+export interface Controller {
+ perform(effect: Effect): void;
+
+ dispatch(command: Command): () => void;
+}
+
+class Process implements Controller {
+ state: Model;
+ view: JSX.Element;
+
+ constructor(
+ public behavior: Behavior,
+ public advance: (self: [Process]) => void,
+ ) {
+ const { state, fx } = behavior.init();
+ this.state = state;
+ this.view = behavior.view(state, this);
+
+ this.enqueue(fx ?? []);
+ }
+
+ async perform(effect: Effect) {
+ const command = await effect();
+ const { state, fx } = this.behavior.update(this.state, command);
+ this.state = state;
+ this.view = this.behavior.view(this.state, this);
+ this.enqueue(fx ?? []);
+
+ this.advance([this]);
+ }
+
+ enqueue(effects: Effect[]) {
+ for (const effect of effects) {
+ this.perform(effect);
+ }
+ }
+
+ dispatch(command: Command) {
+ return () => this.perform(async () => command);
+ }
+
+ terminate() {
+ }
+}
+
+/**
+ * Define react component as state machine.
+ *
+ * @example
+ * ```js
+ * const Counter = View({
+ * init() {
+ * return { state: { count: 0 } };
+ * },
+ * update({ count }: { count: number }, command: "inc" | "dec") {
+ * switch (command) {
+ * case "inc":
+ * return { state: { count: count + 1 } };
+ * case "dec":
+ * return { state: { count: count + 1 } };
+ * default:
+ * return { state: { count } };
+ * }
+ * },
+ * view(state, controller) {
+ * return ;
+ * },
+ * });
+ * ```
+ *
+ * Then you can use it as react component as
+ *
+ * ```js
+ *
+ * ```
+ */
+export default (behavior: Behavior) => {
+ return function View() {
+ const [process, advance] = useState<[Process]>();
+
+ useEffect(() => {
+ const process = new Process(behavior, advance);
+ advance([process]);
+
+ return () => process.terminate();
+ }, []);
+
+ return process?.[0]?.view ?? null;
+ };
+};
From 15ae47e6ebcf50dcb69d8b54882fd2bb8fba30f8 Mon Sep 17 00:00:00 2001
From: Ben Follington <5009316+bfollington@users.noreply.github.com>
Date: Fri, 21 Mar 2025 16:33:27 -0700
Subject: [PATCH 02/15] Implement mock inspector component
---
jumble/src/components/Inspector.tsx | 457 ++++++++++++++++++++++
jumble/src/hooks/use-resizeable-drawer.ts | 126 ++++++
jumble/src/views/CharmDetailView.tsx | 221 ++++++-----
3 files changed, 696 insertions(+), 108 deletions(-)
create mode 100644 jumble/src/components/Inspector.tsx
create mode 100644 jumble/src/hooks/use-resizeable-drawer.ts
diff --git a/jumble/src/components/Inspector.tsx b/jumble/src/components/Inspector.tsx
new file mode 100644
index 000000000..a5d97f467
--- /dev/null
+++ b/jumble/src/components/Inspector.tsx
@@ -0,0 +1,457 @@
+import * as Inspector from "@commontools/runner/storage/inspector";
+import React, { useCallback, useState } from "react";
+import { useResizableDrawer } from "@/hooks/use-resizeable-drawer.ts";
+import JsonView from "@uiw/react-json-view";
+import { githubDarkTheme } from "@uiw/react-json-view/githubDark";
+
+// Mock data for testing the ModelInspector component
+const createMockModel = () => {
+ const now = Date.now();
+
+ return {
+ connection: {
+ ready: { ok: { attempt: 3 } },
+ time: now,
+ },
+ push: {
+ "job:tx1": {
+ ok: {
+ invocation: { cmd: "/memory/transact", args: { data: "example1" } },
+ authorization: { access: { "tx1": {} } },
+ },
+ },
+ "job:tx2": {
+ error: Object.assign(new Error("Transaction failed"), {
+ reason: "conflict",
+ time: now - 5000,
+ }),
+ },
+ },
+ pull: {
+ "job:query1": {
+ ok: {
+ invocation: { cmd: "/memory/query", args: { filter: "all" } },
+ authorization: { access: { "query1": {} } },
+ },
+ },
+ "job:query2": {
+ error: Object.assign(new Error("Query timed out"), {
+ reason: "timeout",
+ time: now - 2000,
+ }),
+ },
+ },
+ subscriptions: {
+ "job:sub1": {
+ source: { cmd: "/memory/query/subscribe", args: { topic: "updates" } },
+ opened: now - 10000,
+ updated: now - 1000,
+ value: { id: "update-123", status: "active", timestamp: now - 1000 },
+ },
+ "job:sub2": {
+ source: {
+ cmd: "/memory/query/subscribe",
+ args: { topic: "notifications" },
+ },
+ opened: now - 20000,
+ updated: now - 500,
+ value: [
+ { id: "notif-1", message: "New message received", read: false },
+ { id: "notif-2", message: "Task completed", read: true },
+ ],
+ },
+ },
+ } as Inspector.Model;
+};
+
+// Example usage with dummy data
+export const DummyModelInspector: React.FC = () => {
+ const mockModel = createMockModel();
+ return ;
+};
+const ModelInspector: React.FC<{ model: Inspector.Model }> = ({ model }) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const [activeTab, setActiveTab] = useState<"actions" | "subscriptions">(
+ "actions",
+ );
+ const [expandedRows, setExpandedRows] = useState>(new Set());
+ const [filterText, setFilterText] = useState("");
+
+ const formatTime = (time: number) => {
+ const date = new Date(time);
+ return `${date.toLocaleTimeString()}.${
+ date.getMilliseconds().toString().padStart(3, "0")
+ }`;
+ };
+
+ const toggleRowExpand = (id: string) => {
+ setExpandedRows((prev) => {
+ const newSet = new Set(prev);
+ prev.has(id) ? newSet.delete(id) : newSet.add(id);
+ return newSet;
+ });
+ };
+
+ const connectionStatus = () => {
+ if (model.connection.pending) {
+ return model.connection.pending.ok
+ ? (
+
+ Conn: A{model.connection.pending.ok.attempt}
+
+ )
+ : (
+
+ Err: {model.connection.pending.error?.message}
+
+ );
+ }
+ return (
+
+ Conn: A{model.connection.ready?.ok?.attempt}
+
+ );
+ };
+
+ // Helper function to extract changes fields from transaction
+ const extractTransactionDetails = (result: any) => {
+ try {
+ if (
+ result.ok?.invocation?.cmd === "/memory/transact" &&
+ result.ok?.invocation?.args?.changes
+ ) {
+ const changes = result.ok.invocation.args.changes;
+ // Extract of, the, cause
+ const ofs = Object.keys(changes);
+ if (ofs.length > 0) {
+ const of = ofs[0];
+ const thes = Object.keys(changes[of]);
+ if (thes.length > 0) {
+ const the = thes[0];
+ const causes = Object.keys(changes[of][the]);
+ if (causes.length > 0) {
+ const cause = causes[0];
+ return { of, the, cause };
+ }
+ }
+ }
+ }
+ return null;
+ } catch (e) {
+ return null;
+ }
+ };
+
+ const getActionItems = useCallback(() => {
+ const pushItems = Object.entries(model.push).map(([id, result]) => ({
+ id,
+ type: "push",
+ result,
+ time: result.error?.time || model.connection.time,
+ details: extractTransactionDetails(result),
+ hasError: !!result.error,
+ }));
+
+ const pullItems = Object.entries(model.pull).map(([id, result]) => ({
+ id,
+ type: "pull",
+ result,
+ time: result.error?.time || model.connection.time,
+ details: null, // pulls don't have of/the/cause
+ hasError: !!result.error,
+ }));
+
+ const items = [...pushItems, ...pullItems].sort((a, b) => b.time - a.time);
+
+ if (!filterText) return items;
+
+ try {
+ const regex = new RegExp(filterText, "i");
+ return items.filter((item) => {
+ return regex.test(item.id) ||
+ regex.test(item.type) ||
+ regex.test(JSON.stringify(item.result));
+ });
+ } catch (e) {
+ // If regex is invalid, fallback to simple string search
+ return items.filter((item) => {
+ const itemStr = JSON.stringify(item);
+ return itemStr.toLowerCase().includes(filterText.toLowerCase());
+ });
+ }
+ }, [model, filterText]);
+
+ const getFilteredSubscriptions = useCallback(() => {
+ if (!filterText) return Object.entries(model.subscriptions);
+
+ try {
+ const regex = new RegExp(filterText, "i");
+ return Object.entries(model.subscriptions).filter(([id, sub]) => {
+ return regex.test(id) ||
+ regex.test(sub.source.cmd) ||
+ regex.test(JSON.stringify(sub));
+ });
+ } catch (e) {
+ // Fallback to simple string search
+ return Object.entries(model.subscriptions).filter(([id, sub]) => {
+ const subStr = JSON.stringify({ id, ...sub });
+ return subStr.toLowerCase().includes(filterText.toLowerCase());
+ });
+ }
+ }, [model.subscriptions, filterText]);
+
+ const {
+ drawerHeight,
+ isResizing,
+ handleResizeStart,
+ handleTouchResizeStart,
+ } = useResizableDrawer({
+ initialHeight: 240,
+ resizeDirection: "down", // Resize from bottom up (for bottom drawer)
+ });
+
+ // Icons for action types
+ const getActionIcon = (type: string, hasError: boolean) => {
+ if (hasError) return "⁈";
+ return type === "push" ? "↑" : "↓";
+ };
+
+ return (
+
+
+
+ {isOpen && (
+
+ {/* Resize Handle */}
+
+
+ {/* Tab Navigation with Filter Box */}
+
+
+
+
+
+
+
setFilterText(e.target.value)}
+ className="w-32 px-2 py-0.5 text-xs bg-gray-800 border border-gray-700 rounded text-white"
+ />
+
{connectionStatus()}
+
+
+
+ {/* Content Area - This is the scrollable container */}
+
+ {activeTab === "actions" && (
+
+ {getActionItems().length > 0
+ ? (
+ getActionItems().map((
+ { id, type, result, time, details, hasError },
+ ) => (
+
+
toggleRowExpand(id)}
+ >
+
+
+ {getActionIcon(type, hasError)}
+
+
+ {id}
+
+ {details && (
+
+
+ {details.of}
+
+ .
+ {details.the}
+
+ .
+ {details.cause}
+
+
+ )}
+
+
+
+ {formatTime(time)}
+
+
+ {expandedRows.has(id) ? "▼" : "▶"}
+
+
+
+
+ {result.error
+ ? (
+
+ {result.error.message}
+ {(result.error as any).reason &&
+ (
+
+ ({(result.error as any).reason})
+
+ )}
+
+ )
+ : (
+
+ {result.ok?.invocation.cmd}
+
+ )}
+
+ {expandedRows.has(id) && (
+
+
+
+ )}
+
+ ))
+ )
+ : (
+
+ No actions {filterText && "matching filter"}
+
+ )}
+
+ )}
+
+ {activeTab === "subscriptions" && (
+
+ {getFilteredSubscriptions().length > 0
+ ? (
+
+
+
+ | ID |
+ Command |
+ Age |
+ |
+
+
+
+ {getFilteredSubscriptions().map((
+ [id, sub],
+ ) => (
+
+
+ |
+ {id}
+ |
+
+ {sub.source.cmd}
+ |
+
+ {Math.floor((Date.now() - sub.opened) / 1000)}s
+ {sub.updated && (
+
+ (+{Math.floor(
+ (Date.now() - sub.updated) / 1000,
+ )}s)
+
+ )}
+ |
+
+
+ |
+
+ {expandedRows.has(id) && (
+
+ |
+
+
+
+ |
+
+ )}
+
+ ))}
+
+
+ )
+ : (
+
+ No subscriptions {filterText && "matching filter"}
+
+ )}
+
+ )}
+
+
+ )}
+
+ );
+};
diff --git a/jumble/src/hooks/use-resizeable-drawer.ts b/jumble/src/hooks/use-resizeable-drawer.ts
new file mode 100644
index 000000000..aeef1522c
--- /dev/null
+++ b/jumble/src/hooks/use-resizeable-drawer.ts
@@ -0,0 +1,126 @@
+import { useCallback, useRef, useState } from "react";
+
+// A shared hook for resizable drawers that could be imported in both components
+export function useResizableDrawer({
+ initialHeight = 240,
+ minHeight = 150,
+ maxHeightFactor = 0.8,
+ resizeDirection = "up", // 'up' for Inspector (resize from bottom to top), 'down' for CharmDetailView (resize from top to bottom)
+} = {}) {
+ const [drawerHeight, setDrawerHeight] = useState(initialHeight);
+ const [isResizing, setIsResizing] = useState(false);
+ const resizeStartY = useRef(null);
+ const startHeight = useRef(null);
+
+ const handleResizeStart = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ resizeStartY.current = e.clientY;
+ startHeight.current = drawerHeight;
+ setIsResizing(true);
+
+ // Add a layer over the entire document to capture events
+ const overlay = document.createElement("div");
+ overlay.id = "resize-overlay";
+ overlay.style.position = "fixed";
+ overlay.style.top = "0";
+ overlay.style.left = "0";
+ overlay.style.width = "100%";
+ overlay.style.height = "100%";
+ overlay.style.zIndex = "9999";
+ overlay.style.cursor = "ns-resize";
+ document.body.appendChild(overlay);
+
+ const handleResizeMove = (e: MouseEvent) => {
+ if (resizeStartY.current !== null && startHeight.current !== null) {
+ // Calculate the difference based on resize direction
+ const diff = resizeDirection === "up"
+ ? e.clientY - resizeStartY.current // Inspector - moving up increases height
+ : resizeStartY.current - e.clientY; // CharmDetailView - moving down increases height
+
+ const newHeight = Math.max(
+ minHeight,
+ Math.min(
+ globalThis.innerHeight * maxHeightFactor,
+ startHeight.current + diff,
+ ),
+ );
+ setDrawerHeight(newHeight);
+ }
+ };
+
+ const handleResizeEnd = () => {
+ resizeStartY.current = null;
+ startHeight.current = null;
+ setIsResizing(false);
+
+ // Remove overlay
+ const overlay = document.getElementById("resize-overlay");
+ if (overlay) {
+ document.body.removeChild(overlay);
+ }
+
+ document.removeEventListener("mousemove", handleResizeMove);
+ document.removeEventListener("mouseup", handleResizeEnd);
+ };
+
+ document.addEventListener("mousemove", handleResizeMove);
+ document.addEventListener("mouseup", handleResizeEnd);
+ },
+ [drawerHeight, resizeDirection, minHeight, maxHeightFactor],
+ );
+
+ const handleTouchResizeStart = useCallback(
+ (e: React.TouchEvent) => {
+ e.preventDefault();
+ if (e.touches.length === 1) {
+ resizeStartY.current = e.touches[0].clientY;
+ startHeight.current = drawerHeight;
+ setIsResizing(true);
+ }
+
+ const handleTouchMove = (e: TouchEvent) => {
+ if (
+ resizeStartY.current !== null &&
+ startHeight.current !== null &&
+ e.touches.length === 1
+ ) {
+ // Calculate the difference based on resize direction
+ const diff = resizeDirection === "up"
+ ? e.touches[0].clientY - resizeStartY.current // Inspector
+ : resizeStartY.current - e.touches[0].clientY; // CharmDetailView
+
+ const newHeight = Math.max(
+ minHeight,
+ Math.min(
+ globalThis.innerHeight * maxHeightFactor,
+ startHeight.current + diff,
+ ),
+ );
+ setDrawerHeight(newHeight);
+ }
+ };
+
+ const handleTouchEnd = () => {
+ resizeStartY.current = null;
+ startHeight.current = null;
+ setIsResizing(false);
+ document.removeEventListener("touchmove", handleTouchMove);
+ document.removeEventListener("touchend", handleTouchEnd);
+ };
+
+ document.addEventListener("touchmove", handleTouchMove, {
+ passive: false,
+ });
+ document.addEventListener("touchend", handleTouchEnd);
+ },
+ [drawerHeight, resizeDirection, minHeight, maxHeightFactor],
+ );
+
+ return {
+ drawerHeight,
+ isResizing,
+ handleResizeStart,
+ handleTouchResizeStart,
+ };
+}
diff --git a/jumble/src/views/CharmDetailView.tsx b/jumble/src/views/CharmDetailView.tsx
index 7c4103b46..473f488cf 100644
--- a/jumble/src/views/CharmDetailView.tsx
+++ b/jumble/src/views/CharmDetailView.tsx
@@ -40,6 +40,7 @@ import { Composer, ComposerSubmitBar } from "@/components/Composer.tsx";
import { useCharmMentions } from "@/components/CommandCenter.tsx";
import { formatPromptWithMentions } from "@/utils/format.ts";
import { CharmLink } from "@/components/CharmLink.tsx";
+import { useResizableDrawer } from "@/hooks/use-resizeable-drawer.ts";
type Tab = "iterate" | "code" | "data";
type OperationType = "iterate" | "extend";
@@ -95,111 +96,111 @@ const useCharmOperationContext = () => {
// =================== Custom Hooks ===================
-// Hook for managing bottom sheet functionality
-function useBottomSheet(initialHeight = 585) {
- const [sheetHeight, setSheetHeight] = useState(initialHeight);
- const [isResizing, setIsResizing] = useState(false);
- const resizeStartY = useRef(null);
- const startHeight = useRef(null);
-
- const handleResizeStart = useCallback(
- (e: React.MouseEvent) => {
- e.preventDefault();
- resizeStartY.current = e.clientY;
- startHeight.current = sheetHeight;
- setIsResizing(true);
-
- // Add a layer over the entire document to capture events
- const overlay = document.createElement("div");
- overlay.id = "resize-overlay";
- overlay.style.position = "fixed";
- overlay.style.top = "0";
- overlay.style.left = "0";
- overlay.style.width = "100%";
- overlay.style.height = "100%";
- overlay.style.zIndex = "9999";
- overlay.style.cursor = "ns-resize";
- document.body.appendChild(overlay);
-
- const handleResizeMove = (e: MouseEvent) => {
- if (resizeStartY.current !== null && startHeight.current !== null) {
- const diff = resizeStartY.current - e.clientY;
- const newHeight = Math.max(
- 150,
- Math.min(globalThis.innerHeight * 0.8, startHeight.current + diff),
- );
- setSheetHeight(newHeight);
- }
- };
-
- const handleResizeEnd = () => {
- resizeStartY.current = null;
- startHeight.current = null;
- setIsResizing(false);
-
- // Remove overlay
- const overlay = document.getElementById("resize-overlay");
- if (overlay) {
- document.body.removeChild(overlay);
- }
-
- document.removeEventListener("mousemove", handleResizeMove);
- document.removeEventListener("mouseup", handleResizeEnd);
- };
-
- document.addEventListener("mousemove", handleResizeMove);
- document.addEventListener("mouseup", handleResizeEnd);
- },
- [sheetHeight],
- );
-
- const handleTouchResizeStart = useCallback(
- (e: React.TouchEvent) => {
- e.preventDefault();
- if (e.touches.length === 1) {
- resizeStartY.current = e.touches[0].clientY;
- startHeight.current = sheetHeight;
- setIsResizing(true);
- }
-
- const handleTouchMove = (e: TouchEvent) => {
- if (
- resizeStartY.current !== null &&
- startHeight.current !== null &&
- e.touches.length === 1
- ) {
- const diff = resizeStartY.current - e.touches[0].clientY;
- const newHeight = Math.max(
- 150,
- Math.min(globalThis.innerHeight * 0.8, startHeight.current + diff),
- );
- setSheetHeight(newHeight);
- }
- };
-
- const handleTouchEnd = () => {
- resizeStartY.current = null;
- startHeight.current = null;
- setIsResizing(false);
- document.removeEventListener("touchmove", handleTouchMove);
- document.removeEventListener("touchend", handleTouchEnd);
- };
-
- document.addEventListener("touchmove", handleTouchMove, {
- passive: false,
- });
- document.addEventListener("touchend", handleTouchEnd);
- },
- [sheetHeight],
- );
-
- return {
- sheetHeight,
- isResizing,
- handleResizeStart,
- handleTouchResizeStart,
- };
-}
+// // Hook for managing bottom sheet functionality
+// function useBottomSheet(initialHeight = 585) {
+// const [sheetHeight, setSheetHeight] = useState(initialHeight);
+// const [isResizing, setIsResizing] = useState(false);
+// const resizeStartY = useRef(null);
+// const startHeight = useRef(null);
+
+// const handleResizeStart = useCallback(
+// (e: React.MouseEvent) => {
+// e.preventDefault();
+// resizeStartY.current = e.clientY;
+// startHeight.current = sheetHeight;
+// setIsResizing(true);
+
+// // Add a layer over the entire document to capture events
+// const overlay = document.createElement("div");
+// overlay.id = "resize-overlay";
+// overlay.style.position = "fixed";
+// overlay.style.top = "0";
+// overlay.style.left = "0";
+// overlay.style.width = "100%";
+// overlay.style.height = "100%";
+// overlay.style.zIndex = "9999";
+// overlay.style.cursor = "ns-resize";
+// document.body.appendChild(overlay);
+
+// const handleResizeMove = (e: MouseEvent) => {
+// if (resizeStartY.current !== null && startHeight.current !== null) {
+// const diff = resizeStartY.current - e.clientY;
+// const newHeight = Math.max(
+// 150,
+// Math.min(globalThis.innerHeight * 0.8, startHeight.current + diff),
+// );
+// setSheetHeight(newHeight);
+// }
+// };
+
+// const handleResizeEnd = () => {
+// resizeStartY.current = null;
+// startHeight.current = null;
+// setIsResizing(false);
+
+// // Remove overlay
+// const overlay = document.getElementById("resize-overlay");
+// if (overlay) {
+// document.body.removeChild(overlay);
+// }
+
+// document.removeEventListener("mousemove", handleResizeMove);
+// document.removeEventListener("mouseup", handleResizeEnd);
+// };
+
+// document.addEventListener("mousemove", handleResizeMove);
+// document.addEventListener("mouseup", handleResizeEnd);
+// },
+// [sheetHeight],
+// );
+
+// const handleTouchResizeStart = useCallback(
+// (e: React.TouchEvent) => {
+// e.preventDefault();
+// if (e.touches.length === 1) {
+// resizeStartY.current = e.touches[0].clientY;
+// startHeight.current = sheetHeight;
+// setIsResizing(true);
+// }
+
+// const handleTouchMove = (e: TouchEvent) => {
+// if (
+// resizeStartY.current !== null &&
+// startHeight.current !== null &&
+// e.touches.length === 1
+// ) {
+// const diff = resizeStartY.current - e.touches[0].clientY;
+// const newHeight = Math.max(
+// 150,
+// Math.min(globalThis.innerHeight * 0.8, startHeight.current + diff),
+// );
+// setSheetHeight(newHeight);
+// }
+// };
+
+// const handleTouchEnd = () => {
+// resizeStartY.current = null;
+// startHeight.current = null;
+// setIsResizing(false);
+// document.removeEventListener("touchmove", handleTouchMove);
+// document.removeEventListener("touchend", handleTouchEnd);
+// };
+
+// document.addEventListener("touchmove", handleTouchMove, {
+// passive: false,
+// });
+// document.addEventListener("touchend", handleTouchEnd);
+// },
+// [sheetHeight],
+// );
+
+// return {
+// sheetHeight,
+// isResizing,
+// handleResizeStart,
+// handleTouchResizeStart,
+// };
+// }
// Hook for tab management
function useTabNavigation() {
@@ -1257,14 +1258,18 @@ const BottomSheet = ({
}: {
children: (activeTab: Tab, isResizing: boolean) => React.ReactNode;
}) => {
- const { sheetHeight, isResizing, handleResizeStart, handleTouchResizeStart } =
- useBottomSheet();
+ const {
+ drawerHeight,
+ isResizing,
+ handleResizeStart,
+ handleTouchResizeStart,
+ } = useResizableDrawer({ initialHeight: 585, resizeDirection: "down" });
const { activeTab, handleTabChange } = useTabNavigation();
return (
{/* Resize Handle */}
<5009316+bfollington@users.noreply.github.com>
Date: Fri, 21 Mar 2025 16:52:24 -0700
Subject: [PATCH 03/15] Export Model interface
---
runner/src/storage/inspector.ts | 30 +++++++++++++++++++++++-------
1 file changed, 23 insertions(+), 7 deletions(-)
diff --git a/runner/src/storage/inspector.ts b/runner/src/storage/inspector.ts
index 163c3f1fb..2614ebe80 100644
--- a/runner/src/storage/inspector.ts
+++ b/runner/src/storage/inspector.ts
@@ -11,7 +11,7 @@ import {
Transaction,
TransactionError,
UCAN,
- Variant
+ Variant,
} from "@commontools/memory/interface";
import { JSONValue } from "../../../builder/src/index.ts";
@@ -51,8 +51,7 @@ export interface ConnectionError extends Error {
reason: "timeout" | "error" | "close";
time: Time;
}
-
-class Model {
+export interface Model {
/**
* Status of the connection to the upstream. If pending it will contain
* result holding potentially an error which occurred causing a reconnection
@@ -80,11 +79,28 @@ class Model {
updated?: Time;
value: JSONValue | undefined;
}>;
+}
+
+class Model {
+ connection: Status
>;
+ push: PushState;
+ pull: PullState;
+ subscriptions: Record;
constructor(
- connection: typeof this.connection,
- push: typeof this.push,
- pull: typeof this.pull,
- subscriptions: typeof this.subscriptions,
+ connection: Status>,
+ push: PushState,
+ pull: PullState,
+ subscriptions: Record,
) {
this.connection = connection;
this.push = push;
From ca52c76a1d9d0bc6f29362179543c6ec4362ac3a Mon Sep 17 00:00:00 2001
From: Irakli Gozalishvili
Date: Fri, 21 Mar 2025 18:44:42 -0700
Subject: [PATCH 04/15] feat: implement tasks
---
deno.jsonc | 30 +--
jumble/src/components/View.tsx | 330 ++++++++++++++++++++++++++-------
jumble/src/views/Shell.tsx | 47 +++++
3 files changed, 314 insertions(+), 93 deletions(-)
diff --git a/deno.jsonc b/deno.jsonc
index c415c99ef..120572606 100644
--- a/deno.jsonc
+++ b/deno.jsonc
@@ -25,16 +25,8 @@
},
"compilerOptions": {
"jsx": "react-jsxdev",
- "lib": [
- "deno.ns",
- "dom",
- "dom.iterable",
- "dom.asynciterable",
- "esnext"
- ],
- "types": [
- "./jumble/src/global.d.ts"
- ]
+ "lib": ["deno.ns", "dom", "dom.iterable", "dom.asynciterable", "esnext"],
+ "types": ["./jumble/src/global.d.ts"]
},
"exclude": [
"jumble/.vite/deps/",
@@ -46,21 +38,11 @@
"**/coverage/"
],
"lint": {
- "exclude": [
- "deno-vite-plugin"
- ],
+ "exclude": ["deno-vite-plugin"],
"rules": {
- "tags": [
- "recommended"
- ],
- "include": [
- "ban-untagged-todo",
- "no-external-import"
- ],
- "exclude": [
- "no-unused-vars",
- "no-explicit-any"
- ]
+ "tags": ["recommended"],
+ "include": ["ban-untagged-todo", "no-external-import"],
+ "exclude": ["no-unused-vars", "no-explicit-any", "require-yield"]
}
},
"fmt": {
diff --git a/jumble/src/components/View.tsx b/jumble/src/components/View.tsx
index 0374b6eab..de4972853 100644
--- a/jumble/src/components/View.tsx
+++ b/jumble/src/components/View.tsx
@@ -1,13 +1,26 @@
+// deno-lint-ignore-file require-yield
import { JSX, useCallback, useEffect, useRef, useState } from "react";
+class Perform {
+ constructor(public execute: () => Promise) {
+ }
+}
+
+class Spawn {
+ constructor(
+ public work: () => Task,
+ ) {
+ }
+}
+
+export type FX =
+ | Send
+ | Spawn | Perform | Wait>;
+
export interface Behavior {
- init(): { state: Model; fx?: Effect[] };
- update(
- model: Model,
- command: Command,
- ): { state: Model; fx?: Effect[] };
+ init(): Task, Model>;
- view(model: Model, controller: Controller): JSX.Element;
+ update: (model: Model, command: Command) => Task, Model>;
}
export interface Effect {
@@ -15,92 +28,271 @@ export interface Effect {
}
export interface Controller {
- perform(effect: Effect): void;
-
dispatch(command: Command): () => void;
}
-class Process implements Controller {
- state: Model;
- view: JSX.Element;
+export interface Subscriber {
+ (self: Service): View;
+}
+
+function* test() {
+ yield Promise.resolve();
+}
+
+export interface Task> {
+ [Symbol.iterator](): Job;
+}
+
+export interface Job {
+ throw(error: InferError): Step;
+ return(ok: Ok): Step;
+ next(value: void): Step;
+ [Symbol.iterator](): Job;
+}
+
+export type InferError = Command extends Throw ? Error
+ : never;
+
+export type InferReturn = Command extends Return ? Ok
+ : never;
+export type Step<
+ Ok extends unknown,
+ Command extends unknown,
+> = IteratorResult;
+
+class Throw {
+ constructor(public error: Failure) {}
+}
+
+class Wait {
+ constructor(public promise: Promise) {
+ }
+}
+
+type T = Generator;
+
+export const wait = function* (
+ promise: Promise,
+): Task, T> {
+ const result = yield new Wait(promise);
+ return result as T;
+};
+
+export const sleep = function* (ms: number = 0): Task, void> {
+ yield* wait(new Promise((wake) => setTimeout(wake, ms)));
+};
+
+export const spawn = function* (
+ work: () => Task,
+): Task, Spawn> {
+ const spawn = new Spawn(work);
+ yield spawn;
+ return spawn;
+};
+
+export const fork = (task: Task) => spawn(() => task);
+
+export const send = function* <
+ Command extends string | number | boolean | null | object,
+>(
+ command: Command,
+): Task, void> {
+ yield new Send(command);
+};
+
+class Return {
+ constructor(public ok: Ok) {}
+}
+
+class Send {
+ constructor(public command: Command) {}
+}
+
+export interface Execution<
+ Ok extends unknown,
+ Command extends unknown,
+> {
+ next(): IterableIterator;
+ [Symbol.iterator](): Execution;
+}
+
+export const service = (behavior: Behavior) =>
+ new Service(behavior);
+
+export type InferSend = Effect extends Send ? Command
+ : never;
+
+class Service {
+ state!: Model;
+
+ subscribers: Set> | undefined;
+ inbox: Command[] = [];
+ queue: FX[] = [];
+
+ work: Job | Perform | Wait, void>[] = [];
+ idle: boolean = true;
constructor(
public behavior: Behavior,
- public advance: (self: [Process]) => void,
) {
- const { state, fx } = behavior.init();
- this.state = state;
- this.view = behavior.view(state, this);
+ }
- this.enqueue(fx ?? []);
+ execute(command: Command) {
+ this.inbox.push(command);
+ this.wake();
}
- async perform(effect: Effect) {
- const command = await effect();
- const { state, fx } = this.behavior.update(this.state, command);
- this.state = state;
- this.view = this.behavior.view(this.state, this);
- this.enqueue(fx ?? []);
+ wake() {
+ if (this.idle) {
+ this.idle = false;
+ while (this.inbox.length > 0 || this.queue.length > 0) {
+ for (const command of this.inbox.splice(0)) {
+ this.state = this.advance(this.behavior.update(this.state, command));
+ }
+
+ for (const effect of this.queue.splice(0)) {
+ if (effect instanceof Send) {
+ this.inbox.push(effect.command);
+ } else if (effect instanceof Spawn) {
+ this.spawn(effect);
+ }
+ }
+ }
- this.advance([this]);
+ this.idle = true;
+ this.notify();
+ }
+ }
+
+ async spawn(effect: Spawn | Perform>) {
+ const work = effect.work()[Symbol.iterator]();
+ let state = undefined;
+ while (true) {
+ const step = work.next(state as void);
+ if (step.done) {
+ return step.value;
+ } else {
+ const { value } = step;
+ if (value instanceof Send) {
+ this.execute(value.command);
+ } else if (value instanceof Wait) {
+ state = await value.promise;
+ } else {
+ state = await value.execute();
+ }
+ }
+ }
}
- enqueue(effects: Effect[]) {
- for (const effect of effects) {
- this.perform(effect);
+ advance(task: Task, Model>) {
+ const work = task[Symbol.iterator]();
+ while (true) {
+ const step = work.next();
+ if (step.done) {
+ this.state = step.value;
+ return this.state;
+ } else {
+ this.queue.push(step.value);
+ }
+ }
+ }
+
+ notify() {
+ for (const subscriber of this.subscribers ?? []) {
+ subscriber(this);
}
}
dispatch(command: Command) {
- return () => this.perform(async () => command);
+ return () => this.execute(command);
}
terminate() {
}
-}
-/**
- * Define react component as state machine.
- *
- * @example
- * ```js
- * const Counter = View({
- * init() {
- * return { state: { count: 0 } };
- * },
- * update({ count }: { count: number }, command: "inc" | "dec") {
- * switch (command) {
- * case "inc":
- * return { state: { count: count + 1 } };
- * case "dec":
- * return { state: { count: count + 1 } };
- * default:
- * return { state: { count } };
- * }
- * },
- * view(state, controller) {
- * return ;
- * },
- * });
- * ```
- *
- * Then you can use it as react component as
- *
- * ```js
- *
- * ```
- */
-export default (behavior: Behavior) => {
- return function View() {
- const [process, advance] = useState<[Process]>();
+ subscribe(subscriber: Subscriber) {
+ if (!this.subscribers) {
+ this.subscribers = new Set([subscriber]);
+ this.advance(this.behavior.init());
+ this.wake();
+ } else {
+ this.subscribers.add(subscriber);
+ }
+ }
+ render(view: (state: Model, controller: Controller) => View) {
+ const [process, advance] = useState<[Service]>();
+ const [ui, setUI] = useState(null);
+
+ useEffect(
+ () =>
+ this.subscribe((process) => {
+ console.log("advance");
+ advance([process]);
+ }),
+ [],
+ );
useEffect(() => {
- const process = new Process(behavior, advance);
- advance([process]);
+ console.log("render");
+ const [task] = process ?? [];
+ if (task) {
+ setUI(view(task.state, task));
+ }
+ }, [process]);
+
+ return ui;
+ }
- return () => process.terminate();
- }, []);
+ View(view: (state: Model, controller: Controller) => View) {
+ return () => this.render(view);
+ }
+}
- return process?.[0]?.view ?? null;
- };
-};
+// /**
+// * Define react component as state machine.
+// *
+// * @example
+// * ```js
+// * const Counter = View({
+// * init() {
+// * return { state: { count: 0 } };
+// * },
+// * update({ count }: { count: number }, command: "inc" | "dec") {
+// * switch (command) {
+// * case "inc":
+// * return { state: { count: count + 1 } };
+// * case "dec":
+// * return { state: { count: count + 1 } };
+// * default:
+// * return { state: { count } };
+// * }
+// * },
+// * view(state, controller) {
+// * return ;
+// * },
+// * });
+// * ```
+// *
+// * Then you can use it as react component as
+// *
+// * ```js
+// *
+// * ```
+// */
+// export default (behavior: Behavior) => {
+// return function View() {
+// const [process, advance] = useState<[Process]>();
+
+// useEffect(() => {
+// const process = new Process(behavior, advance);
+// advance([process]);
+
+// return () => process.terminate();
+// }, []);
+
+// return process?.[0]?.view ?? null;
+// };
+// };
+
+export { Service as Process };
+export default Service;
diff --git a/jumble/src/views/Shell.tsx b/jumble/src/views/Shell.tsx
index cf3edef62..cb36ca843 100644
--- a/jumble/src/views/Shell.tsx
+++ b/jumble/src/views/Shell.tsx
@@ -10,6 +10,51 @@ import { ActionBar } from "@/components/ActionBar.tsx";
import { CharmPublisher } from "@/components/Publish.tsx";
import { useGlobalActions } from "@/hooks/use-global-actions.tsx";
import { SyncStatusProvider } from "@/contexts/SyncStatusContext.tsx";
+import * as Process from "@/components/View.tsx";
+
+function* subscribe() {
+ const test = yield* Process.wait(Promise.resolve(1));
+
+ yield* Process.send("inc");
+}
+
+function* test() {
+ yield* Process.send("inc");
+
+ return { count: 0 };
+}
+
+const Counter = Process.service({
+ *init() {
+ yield* Process.spawn(function* () {
+ // while (true) {
+ yield* Process.sleep(1000);
+ yield* Process.send("inc");
+ // }
+ });
+
+ return { count: 0 };
+ },
+
+ *update({ count }, command: "inc" | "dec") {
+ switch (command) {
+ case "inc":
+ return { count: count + 1 };
+ case "dec":
+ return { count: count + 1 };
+ default:
+ return { count };
+ }
+ },
+});
+
+const CounterView = Counter.View((state, controller) => (
+
+));
+
+const CounterView2 = Counter.View((state, controller) => (
+ {state.count}
+));
export default function Shell() {
const { charmId } = useParams();
@@ -33,6 +78,8 @@ export default function Shell() {
+
+
From b7f4f8d94a4d77c3bcda75979781a1dceca45e7f Mon Sep 17 00:00:00 2001
From: Ben Follington <5009316+bfollington@users.noreply.github.com>
Date: Mon, 24 Mar 2025 14:06:48 -0700
Subject: [PATCH 05/15] Inspector is alive!
---
.../{Inspector.tsx => NetworkInspector.tsx} | 69 ++++++++++++++++++-
jumble/src/components/User.tsx | 27 ++------
jumble/src/views/Shell.tsx | 4 +-
3 files changed, 72 insertions(+), 28 deletions(-)
rename jumble/src/components/{Inspector.tsx => NetworkInspector.tsx} (89%)
diff --git a/jumble/src/components/Inspector.tsx b/jumble/src/components/NetworkInspector.tsx
similarity index 89%
rename from jumble/src/components/Inspector.tsx
rename to jumble/src/components/NetworkInspector.tsx
index a5d97f467..5addfe3d6 100644
--- a/jumble/src/components/Inspector.tsx
+++ b/jumble/src/components/NetworkInspector.tsx
@@ -1,8 +1,35 @@
import * as Inspector from "@commontools/runner/storage/inspector";
-import React, { useCallback, useState } from "react";
+import React, { useCallback, useEffect, useRef, useState } from "react";
import { useResizableDrawer } from "@/hooks/use-resizeable-drawer.ts";
import JsonView from "@uiw/react-json-view";
import { githubDarkTheme } from "@uiw/react-json-view/githubDark";
+import { getDoc } from "@commontools/runner";
+
+import { useCell } from "@/hooks/use-cell.ts";
+const model = getDoc(Inspector.create(), "inspector", "").asCell();
+
+// Custom hooks
+export function useStorageBroadcast(callback: (data: any) => void) {
+ useEffect(() => {
+ const messages = new BroadcastChannel("storage/remote");
+ messages.onmessage = ({ data }) => callback(data);
+ return () => messages.close();
+ }, [callback]);
+}
+
+export function useStatusMonitor() {
+ const status = useRef(Inspector.create());
+
+ const updateStatus = useCallback((command: Inspector.Command) => {
+ if (!status.current) {
+ throw new Error("Status is not initialized");
+ }
+ const state = Inspector.update(status.current, command);
+ status.current = state;
+ }, []);
+
+ return { status, updateStatus };
+}
// Mock data for testing the ModelInspector component
const createMockModel = () => {
@@ -66,8 +93,11 @@ const createMockModel = () => {
// Example usage with dummy data
export const DummyModelInspector: React.FC = () => {
- const mockModel = createMockModel();
- return
;
+ const { status, updateStatus } = useStatusMonitor();
+ useStorageBroadcast(updateStatus);
+ if (!status.current) return null;
+
+ return
;
};
const ModelInspector: React.FC<{ model: Inspector.Model }> = ({ model }) => {
const [isOpen, setIsOpen] = useState(false);
@@ -76,6 +106,39 @@ const ModelInspector: React.FC<{ model: Inspector.Model }> = ({ model }) => {
);
const [expandedRows, setExpandedRows] = useState
>(new Set());
const [filterText, setFilterText] = useState("");
+ const [, setRenderTrigger] = useState(0);
+ const rafRef = useRef(null);
+ const lastRenderTimeRef = useRef(0);
+
+ // Set up render loop with requestAnimationFrame when inspector is open
+ useEffect(() => {
+ if (!isOpen) {
+ if (rafRef.current) {
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = null;
+ }
+ return;
+ }
+
+ const renderLoop = () => {
+ const now = performance.now();
+ // Limit to ~10 FPS when not actively receiving updates
+ if (now - lastRenderTimeRef.current > 100) {
+ setRenderTrigger(prev => (prev + 1) % 1000); // Force re-render
+ lastRenderTimeRef.current = now;
+ }
+ rafRef.current = requestAnimationFrame(renderLoop);
+ };
+
+ rafRef.current = requestAnimationFrame(renderLoop);
+
+ return () => {
+ if (rafRef.current) {
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = null;
+ }
+ };
+ }, [isOpen]);
const formatTime = (time: number) => {
const date = new Date(time);
diff --git a/jumble/src/components/User.tsx b/jumble/src/components/User.tsx
index 62b67ed95..1a7bedbc0 100644
--- a/jumble/src/components/User.tsx
+++ b/jumble/src/components/User.tsx
@@ -9,6 +9,10 @@ import * as Inspector from "@commontools/runner/storage/inspector";
import { FaArrowDown, FaArrowUp, FaExclamationTriangle } from "react-icons/fa";
import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ useStatusMonitor,
+ useStorageBroadcast,
+} from "@/components/NetworkInspector.tsx";
// Constants
const COLORS = {
@@ -80,15 +84,6 @@ const AVATAR_SHAPES = [
`,
];
-// Custom hooks
-export function useStorageBroadcast(callback: (data: any) => void) {
- useEffect(() => {
- const messages = new BroadcastChannel("storage/remote");
- messages.onmessage = ({ data }) => callback(data);
- return () => messages.close();
- }, [callback]);
-}
-
function useAvatarGenerator(did: string | undefined) {
const [avatarColor, setAvatarColor] = useState("");
const [avatarShape, setAvatarShape] = useState("");
@@ -117,20 +112,6 @@ function useAvatarGenerator(did: string | undefined) {
return { avatarColor, avatarShape };
}
-function useStatusMonitor() {
- const status = useRef(Inspector.create());
-
- const updateStatus = useCallback((command: Inspector.Command) => {
- if (!status.current) {
- throw new Error("Status is not initialized");
- }
- const state = Inspector.update(status.current, command);
- status.current = state;
- }, []);
-
- return { status, updateStatus };
-}
-
// Helper functions
const ease = (current: number, target: number, factor: number = 0.1) => {
return current + (target - current) * factor;
diff --git a/jumble/src/views/Shell.tsx b/jumble/src/views/Shell.tsx
index cb36ca843..710651e65 100644
--- a/jumble/src/views/Shell.tsx
+++ b/jumble/src/views/Shell.tsx
@@ -11,6 +11,7 @@ import { CharmPublisher } from "@/components/Publish.tsx";
import { useGlobalActions } from "@/hooks/use-global-actions.tsx";
import { SyncStatusProvider } from "@/contexts/SyncStatusContext.tsx";
import * as Process from "@/components/View.tsx";
+import { DummyModelInspector } from "@/components/NetworkInspector.tsx";
function* subscribe() {
const test = yield* Process.wait(Promise.resolve(1));
@@ -78,8 +79,7 @@ export default function Shell() {
-
-
+
From 86aa09bbe645b7bcfef3597f4b8f5c871874da18 Mon Sep 17 00:00:00 2001
From: Ben Follington <5009316+bfollington@users.noreply.github.com>
Date: Mon, 24 Mar 2025 14:22:40 -0700
Subject: [PATCH 06/15] Extract animation and smoothing logic into hook
---
jumble/src/components/NetworkInspector.tsx | 56 +++++++++-
jumble/src/components/User.tsx | 90 +++++-----------
jumble/src/hooks/use-animation-smoothing.ts | 111 ++++++++++++++++++++
3 files changed, 190 insertions(+), 67 deletions(-)
create mode 100644 jumble/src/hooks/use-animation-smoothing.ts
diff --git a/jumble/src/components/NetworkInspector.tsx b/jumble/src/components/NetworkInspector.tsx
index 5addfe3d6..64e5d6bb5 100644
--- a/jumble/src/components/NetworkInspector.tsx
+++ b/jumble/src/components/NetworkInspector.tsx
@@ -4,6 +4,7 @@ import { useResizableDrawer } from "@/hooks/use-resizeable-drawer.ts";
import JsonView from "@uiw/react-json-view";
import { githubDarkTheme } from "@uiw/react-json-view/githubDark";
import { getDoc } from "@commontools/runner";
+import { useAnimationSmoothing } from "@/hooks/use-animation-smoothing.ts";
import { useCell } from "@/hooks/use-cell.ts";
const model = getDoc(Inspector.create(), "inspector", "").asCell();
@@ -107,8 +108,11 @@ const ModelInspector: React.FC<{ model: Inspector.Model }> = ({ model }) => {
const [expandedRows, setExpandedRows] = useState>(new Set());
const [filterText, setFilterText] = useState("");
const [, setRenderTrigger] = useState(0);
- const rafRef = useRef(null);
const lastRenderTimeRef = useRef(0);
+ const buttonTextRef = useRef(null);
+
+ // Use our animation smoothing hook
+ const { updateValue, getValue, rafRef } = useAnimationSmoothing();
// Set up render loop with requestAnimationFrame when inspector is open
useEffect(() => {
@@ -138,7 +142,53 @@ const ModelInspector: React.FC<{ model: Inspector.Model }> = ({ model }) => {
rafRef.current = null;
}
};
- }, [isOpen]);
+ }, [isOpen, rafRef]);
+
+ // Update button text with counts using requestAnimationFrame
+ useEffect(() => {
+ const updateCounts = () => {
+ if (!buttonTextRef.current) {
+ rafRef.current = requestAnimationFrame(updateCounts);
+ return;
+ }
+
+ // Count calculations
+ const actualPushCount = Object.values(model.push).filter(v => v.ok).length;
+ const actualPullCount = Object.values(model.pull).filter(v => v.ok).length;
+ const pushErrorCount = Object.values(model.push).filter(v => v.error).length;
+ const pullErrorCount = Object.values(model.pull).filter(v => v.error).length;
+ const actualErrorCount = pushErrorCount + pullErrorCount;
+
+ // Update values with easing
+ const pushResult = updateValue("push", actualPushCount);
+ const pullResult = updateValue("pull", actualPullCount);
+ const errorResult = updateValue("error", actualErrorCount);
+
+ // Create status text
+ const statusParts = [];
+ if (pushResult.value > 0) statusParts.push(`↑${pushResult.value}`);
+ if (pullResult.value > 0) statusParts.push(`↓${pullResult.value}`);
+ if (errorResult.value > 0) statusParts.push(`!${errorResult.value}`);
+
+ const statusText = statusParts.length > 0
+ ? `Inspector ${statusParts.join(" ")} ${isOpen ? "▼" : "▲"}`
+ : `Inspector ${isOpen ? "▼" : "▲"}`;
+
+ buttonTextRef.current.textContent = statusText;
+
+ // Continue animation
+ rafRef.current = requestAnimationFrame(updateCounts);
+ };
+
+ // Start updating counts
+ rafRef.current = requestAnimationFrame(updateCounts);
+
+ return () => {
+ if (rafRef.current) {
+ cancelAnimationFrame(rafRef.current);
+ }
+ };
+ }, [model, isOpen, updateValue, rafRef]);
const formatTime = (time: number) => {
const date = new Date(time);
@@ -286,7 +336,7 @@ const ModelInspector: React.FC<{ model: Inspector.Model }> = ({ model }) => {
onClick={() => setIsOpen(!isOpen)}
className="absolute top-0 left-4 transform -translate-y-full bg-gray-800 text-white px-2 py-0.5 rounded-t-md text-xs"
>
- Inspector {isOpen ? "▼" : "▲"}
+ Inspector {isOpen ? "▼" : "▲"}
{isOpen && (
diff --git a/jumble/src/components/User.tsx b/jumble/src/components/User.tsx
index 1a7bedbc0..71303ead0 100644
--- a/jumble/src/components/User.tsx
+++ b/jumble/src/components/User.tsx
@@ -13,6 +13,7 @@ import {
useStatusMonitor,
useStorageBroadcast,
} from "@/components/NetworkInspector.tsx";
+import { useAnimationSmoothing } from "@/hooks/use-animation-smoothing.ts";
// Constants
const COLORS = {
@@ -164,16 +165,12 @@ export function User() {
const pullErrorRef = useRef(null);
// Animation state refs
- const animationRef = useRef(null);
const rotationRef = useRef(0);
const opacityRef = useRef(0);
const bounceRef = useRef(0);
- const easedPushCountRef = useRef(0);
- const easedPullCountRef = useRef(0);
- const easedErrorCountRef = useRef(0);
- const lastPushTimestampRef = useRef(0);
- const lastPullTimestampRef = useRef(0);
- const lastErrorTimestampRef = useRef(0);
+
+ // Use our shared animation smoothing hook
+ const { updateValue, getValue, rafRef } = useAnimationSmoothing();
// Get DID from session
useEffect(() => {
@@ -190,13 +187,13 @@ export function User() {
useEffect(() => {
const animate = () => {
if (!svgRef.current || !avatarRef.current || !tooltipRef.current) {
- animationRef.current = requestAnimationFrame(animate);
+ rafRef.current = requestAnimationFrame(animate);
return;
}
const circle = svgRef.current.querySelector("circle");
if (!circle) {
- animationRef.current = requestAnimationFrame(animate);
+ rafRef.current = requestAnimationFrame(animate);
return;
}
@@ -215,56 +212,20 @@ export function User() {
Object.values(currentStatus.pull).filter((v) => v.error).length;
const actualErrorCount = pushErrorCount + pullErrorCount;
- // Update timestamps for animation duration
- if (actualPushCount > Math.round(easedPushCountRef.current)) {
- lastPushTimestampRef.current = now;
- }
- if (actualPullCount > Math.round(easedPullCountRef.current)) {
- lastPullTimestampRef.current = now;
- }
- if (actualErrorCount > Math.round(easedErrorCountRef.current)) {
- lastErrorTimestampRef.current = now;
- }
-
- // Activity states
- const pushActive = actualPushCount > 0 ||
- (now - lastPushTimestampRef.current < MIN_ANIMATION_DURATION);
- const pullActive = actualPullCount > 0 ||
- (now - lastPullTimestampRef.current < MIN_ANIMATION_DURATION);
- const errorActive = actualErrorCount > 0 ||
- (now - lastErrorTimestampRef.current < MIN_ANIMATION_DURATION);
-
- // Ease count values
- const easingFactor = 0.06;
- easedPushCountRef.current = ease(
- easedPushCountRef.current,
- pushActive ? Math.max(actualPushCount, 0.01) : 0,
- easingFactor,
- );
- easedPullCountRef.current = ease(
- easedPullCountRef.current,
- pullActive ? Math.max(actualPullCount, 0.01) : 0,
- easingFactor,
- );
- easedErrorCountRef.current = ease(
- easedErrorCountRef.current,
- errorActive ? Math.max(actualErrorCount, 0.01) : 0,
- easingFactor,
- );
-
+ // Use our shared hook to update values with easing
+ const pushResult = updateValue("push", actualPushCount);
+ const pullResult = updateValue("pull", actualPullCount);
+ const errorResult = updateValue("error", actualErrorCount);
+
+ // Activity states based on the hook's results
+ const pushActive = pushResult.isActive;
+ const pullActive = pullResult.isActive;
+ const errorActive = errorResult.isActive;
+
// Display counts - even if count is 0 but animation is active, show at least 1
- const displayPushCount =
- pushActive && Math.round(easedPushCountRef.current) === 0
- ? 1
- : Math.round(easedPushCountRef.current);
- const displayPullCount =
- pullActive && Math.round(easedPullCountRef.current) === 0
- ? 1
- : Math.round(easedPullCountRef.current);
- const displayErrorCount =
- errorActive && Math.round(easedErrorCountRef.current) === 0
- ? 1
- : Math.round(easedErrorCountRef.current);
+ const displayPushCount = pushActive && pushResult.value === 0 ? 1 : pushResult.value;
+ const displayPullCount = pullActive && pullResult.value === 0 ? 1 : pullResult.value;
+ const displayErrorCount = errorActive && errorResult.value === 0 ? 1 : errorResult.value;
// Default status message
let statusMessage = "Click to log out";
@@ -463,19 +424,20 @@ export function User() {
);
// Continue animation
- animationRef.current = requestAnimationFrame(animate);
+ rafRef.current = requestAnimationFrame(animate);
};
- // Start animation
- animationRef.current = requestAnimationFrame(animate);
+ // Start animation using our shared RAF reference
+ rafRef.current = requestAnimationFrame(animate);
// Cleanup
return () => {
- if (animationRef.current) {
- cancelAnimationFrame(animationRef.current);
+ if (rafRef.current) {
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = null;
}
};
- }, []);
+ }, [updateValue, rafRef]);
return (
diff --git a/jumble/src/hooks/use-animation-smoothing.ts b/jumble/src/hooks/use-animation-smoothing.ts
new file mode 100644
index 000000000..3e411d15c
--- /dev/null
+++ b/jumble/src/hooks/use-animation-smoothing.ts
@@ -0,0 +1,111 @@
+import { useRef, useEffect } from "react";
+
+// Minimum time animation stays active after last update
+const MIN_ANIMATION_DURATION = 800;
+
+/**
+ * Hook for smoothing animation values with easing
+ * and minimum duration for network operation indicators
+ */
+export function useAnimationSmoothing(
+ initialValues: Record
= {}
+) {
+ // Animation refs
+ const rafRef = useRef(null);
+ const easedValuesRef = useRef>({});
+ const lastUpdatesRef = useRef>({});
+
+ // Initialize refs with provided initial values
+ useEffect(() => {
+ Object.entries(initialValues).forEach(([key, value]) => {
+ easedValuesRef.current[key] = value;
+ lastUpdatesRef.current[key] = 0;
+ });
+ }, []);
+
+ // Helper function for easing values
+ const ease = (current: number, target: number, factor: number = 0.1) => {
+ return current + (target - current) * factor;
+ };
+
+ /**
+ * Update a value with easing effect
+ * @param key Unique identifier for the value
+ * @param actualValue The current actual value to ease toward
+ * @param isActive Whether the animation is active (reset timestamp)
+ * @param easingFactor Optional easing factor (0-1)
+ */
+ const updateValue = (
+ key: string,
+ actualValue: number,
+ isActive: boolean = true,
+ easingFactor: number = 0.06
+ ) => {
+ const now = Date.now();
+
+ // Initialize if doesn't exist
+ if (easedValuesRef.current[key] === undefined) {
+ easedValuesRef.current[key] = 0;
+ lastUpdatesRef.current[key] = 0;
+ }
+
+ // Update timestamp if value increased or is manually set as active
+ if (isActive || actualValue > Math.round(easedValuesRef.current[key])) {
+ lastUpdatesRef.current[key] = now;
+ }
+
+ // Determine if animation should be active (min duration logic)
+ const isAnimationActive = actualValue > 0 ||
+ (now - lastUpdatesRef.current[key] < MIN_ANIMATION_DURATION);
+
+ // Update eased value
+ easedValuesRef.current[key] = ease(
+ easedValuesRef.current[key],
+ isAnimationActive ? Math.max(actualValue, 0.01) : 0,
+ easingFactor
+ );
+
+ // Return displayable rounded value
+ return {
+ value: Math.round(easedValuesRef.current[key]),
+ isActive: isAnimationActive
+ };
+ };
+
+ /**
+ * Get the current eased value
+ * @param key Unique identifier for the value
+ */
+ const getValue = (key: string) => {
+ const now = Date.now();
+
+ if (easedValuesRef.current[key] === undefined) {
+ return { value: 0, isActive: false };
+ }
+
+ const isAnimationActive =
+ Math.round(easedValuesRef.current[key]) > 0 ||
+ (now - lastUpdatesRef.current[key] < MIN_ANIMATION_DURATION);
+
+ return {
+ value: Math.round(easedValuesRef.current[key]),
+ isActive: isAnimationActive
+ };
+ };
+
+ // Cleanup animation frame on unmount
+ useEffect(() => {
+ return () => {
+ if (rafRef.current) {
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = null;
+ }
+ };
+ }, []);
+
+ return {
+ updateValue,
+ getValue,
+ rafRef
+ };
+}
\ No newline at end of file
From b7d7d75401cec8045f6c557001309ad06ef33044 Mon Sep 17 00:00:00 2001
From: Ben Follington <5009316+bfollington@users.noreply.github.com>
Date: Mon, 24 Mar 2025 16:19:54 -0700
Subject: [PATCH 07/15] Toggleable via command and local storage
---
jumble/src/components/NetworkInspector.tsx | 13 +++++-
jumble/src/components/commands.ts | 26 +++++++++++
.../src/contexts/NetworkInspectorContext.tsx | 31 +++++++++++++
jumble/src/hooks/use-network-inspector.ts | 46 +++++++++++++++++++
jumble/src/views/Shell.tsx | 26 ++++++-----
5 files changed, 129 insertions(+), 13 deletions(-)
create mode 100644 jumble/src/contexts/NetworkInspectorContext.tsx
create mode 100644 jumble/src/hooks/use-network-inspector.ts
diff --git a/jumble/src/components/NetworkInspector.tsx b/jumble/src/components/NetworkInspector.tsx
index 64e5d6bb5..c06d80426 100644
--- a/jumble/src/components/NetworkInspector.tsx
+++ b/jumble/src/components/NetworkInspector.tsx
@@ -100,8 +100,17 @@ export const DummyModelInspector: React.FC = () => {
return ;
};
-const ModelInspector: React.FC<{ model: Inspector.Model }> = ({ model }) => {
- const [isOpen, setIsOpen] = useState(false);
+
+export const ToggleableNetworkInspector: React.FC<{ visible: boolean }> = ({ visible }) => {
+ const { status, updateStatus } = useStatusMonitor();
+ useStorageBroadcast(updateStatus);
+
+ if (!visible || !status.current) return null;
+
+ return ;
+};
+const ModelInspector: React.FC<{ model: Inspector.Model, initiallyOpen?: boolean }> = ({ model, initiallyOpen = false }) => {
+ const [isOpen, setIsOpen] = useState(initiallyOpen);
const [activeTab, setActiveTab] = useState<"actions" | "subscriptions">(
"actions",
);
diff --git a/jumble/src/components/commands.ts b/jumble/src/components/commands.ts
index 5937d4b8d..42babbfae 100644
--- a/jumble/src/components/commands.ts
+++ b/jumble/src/components/commands.ts
@@ -768,6 +768,32 @@ async function handleAddRemoteRecipe(
export function getCommands(deps: CommandContext): CommandItem[] {
return [
+ {
+ id: "enable-network-inspector",
+ type: "action",
+ title: "Enable Network Inspector",
+ group: "Tools",
+ predicate: localStorage.getItem("networkInspectorVisible") !== "true",
+ handler: () => {
+ localStorage.setItem("networkInspectorVisible", "true");
+ deps.setOpen(false);
+ // Force a refresh to make sure the UI updates
+ window.dispatchEvent(new Event("networkInspectorUpdate"));
+ },
+ },
+ {
+ id: "disable-network-inspector",
+ type: "action",
+ title: "Disable Network Inspector",
+ group: "Tools",
+ predicate: localStorage.getItem("networkInspectorVisible") === "true",
+ handler: () => {
+ localStorage.setItem("networkInspectorVisible", "false");
+ deps.setOpen(false);
+ // Force a refresh to make sure the UI updates
+ window.dispatchEvent(new Event("networkInspectorUpdate"));
+ },
+ },
{
id: "new-charm",
type: "input",
diff --git a/jumble/src/contexts/NetworkInspectorContext.tsx b/jumble/src/contexts/NetworkInspectorContext.tsx
new file mode 100644
index 000000000..6ced1b19d
--- /dev/null
+++ b/jumble/src/contexts/NetworkInspectorContext.tsx
@@ -0,0 +1,31 @@
+import React, { createContext, useContext, ReactNode } from "react";
+import { useNetworkInspector } from "@/hooks/use-network-inspector.ts";
+
+interface NetworkInspectorContextType {
+ visible: boolean;
+ toggleVisibility: (value?: boolean) => void;
+ show: () => void;
+ hide: () => void;
+}
+
+const NetworkInspectorContext = createContext(null);
+
+export function NetworkInspectorProvider({ children }: { children: ReactNode }) {
+ const inspector = useNetworkInspector();
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useNetworkInspectorContext() {
+ const context = useContext(NetworkInspectorContext);
+ if (!context) {
+ throw new Error(
+ "useNetworkInspectorContext must be used within a NetworkInspectorProvider"
+ );
+ }
+ return context;
+}
\ No newline at end of file
diff --git a/jumble/src/hooks/use-network-inspector.ts b/jumble/src/hooks/use-network-inspector.ts
new file mode 100644
index 000000000..8ff39f57d
--- /dev/null
+++ b/jumble/src/hooks/use-network-inspector.ts
@@ -0,0 +1,46 @@
+import { useEffect, useState } from "react";
+
+export function useNetworkInspector() {
+ const [visible, setVisible] = useState(false);
+
+ // On first render, check localStorage for saved preference
+ useEffect(() => {
+ const updateFromStorage = () => {
+ const savedPreference = localStorage.getItem("networkInspectorVisible");
+ if (savedPreference !== null) {
+ setVisible(savedPreference === "true");
+ }
+ };
+
+ // Initialize from localStorage
+ updateFromStorage();
+
+ // Listen for changes in localStorage (for cross-tab synchronization)
+ window.addEventListener("storage", updateFromStorage);
+
+ // Also listen for our custom event for same-tab updates
+ window.addEventListener("networkInspectorUpdate", updateFromStorage);
+
+ return () => {
+ window.removeEventListener("storage", updateFromStorage);
+ window.removeEventListener("networkInspectorUpdate", updateFromStorage);
+ };
+ }, []);
+
+ // Update localStorage when the visible state changes
+ const toggleVisibility = (value?: boolean) => {
+ const newValue = value !== undefined ? value : !visible;
+ setVisible(newValue);
+ localStorage.setItem("networkInspectorVisible", String(newValue));
+
+ // Dispatch an event to notify other components about the change
+ window.dispatchEvent(new Event("networkInspectorUpdate"));
+ };
+
+ return {
+ visible,
+ toggleVisibility,
+ show: () => toggleVisibility(true),
+ hide: () => toggleVisibility(false),
+ };
+}
\ No newline at end of file
diff --git a/jumble/src/views/Shell.tsx b/jumble/src/views/Shell.tsx
index 710651e65..961f1002e 100644
--- a/jumble/src/views/Shell.tsx
+++ b/jumble/src/views/Shell.tsx
@@ -11,7 +11,8 @@ import { CharmPublisher } from "@/components/Publish.tsx";
import { useGlobalActions } from "@/hooks/use-global-actions.tsx";
import { SyncStatusProvider } from "@/contexts/SyncStatusContext.tsx";
import * as Process from "@/components/View.tsx";
-import { DummyModelInspector } from "@/components/NetworkInspector.tsx";
+import { ToggleableNetworkInspector } from "@/components/NetworkInspector.tsx";
+import { NetworkInspectorProvider } from "@/contexts/NetworkInspectorContext.tsx";
function* subscribe() {
const test = yield* Process.wait(Promise.resolve(1));
@@ -57,6 +58,7 @@ const CounterView2 = Counter.View((state, controller) => (
{state.count}
));
+
export default function Shell() {
const { charmId } = useParams();
useGlobalActions();
@@ -69,18 +71,20 @@ export default function Shell() {
return (
-
+
);
From 4e91b31ac0cccfc5ea2c45429541f9aba18e7b7a Mon Sep 17 00:00:00 2001
From: Ben Follington <5009316+bfollington@users.noreply.github.com>
Date: Mon, 24 Mar 2025 16:37:04 -0700
Subject: [PATCH 08/15] Fix User.tsx
---
jumble/src/components/User.tsx | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/jumble/src/components/User.tsx b/jumble/src/components/User.tsx
index 71303ead0..20ba0f633 100644
--- a/jumble/src/components/User.tsx
+++ b/jumble/src/components/User.tsx
@@ -168,6 +168,8 @@ export function User() {
const rotationRef = useRef(0);
const opacityRef = useRef(0);
const bounceRef = useRef(0);
+ const easedPushCountRef = useRef(0);
+ const easedPullCountRef = useRef(0);
// Use our shared animation smoothing hook
const { updateValue, getValue, rafRef } = useAnimationSmoothing();
@@ -217,6 +219,10 @@ export function User() {
const pullResult = updateValue("pull", actualPullCount);
const errorResult = updateValue("error", actualErrorCount);
+ // Update our refs with the eased values
+ easedPushCountRef.current = pushResult.value;
+ easedPullCountRef.current = pullResult.value;
+
// Activity states based on the hook's results
const pushActive = pushResult.isActive;
const pullActive = pullResult.isActive;
From 428ede6e243af30b3910a7232f8d3a15f70623f4 Mon Sep 17 00:00:00 2001
From: Ben Follington <5009316+bfollington@users.noreply.github.com>
Date: Mon, 24 Mar 2025 16:43:10 -0700
Subject: [PATCH 09/15] Toggle inspector via localStorage preference
---
jumble/src/components/NetworkInspector.tsx | 79 +++++++++++++++++++---
jumble/src/components/commands.ts | 50 +++++++-------
2 files changed, 94 insertions(+), 35 deletions(-)
diff --git a/jumble/src/components/NetworkInspector.tsx b/jumble/src/components/NetworkInspector.tsx
index c06d80426..2510b8ed6 100644
--- a/jumble/src/components/NetworkInspector.tsx
+++ b/jumble/src/components/NetworkInspector.tsx
@@ -302,6 +302,55 @@ const ModelInspector: React.FC<{ model: Inspector.Model, initiallyOpen?: boolean
});
}
}, [model, filterText]);
+
+ // Define helper functions for filtering to avoid circular dependencies
+ const filterItems = useCallback((items: any[], filter: string) => {
+ try {
+ const regex = new RegExp(filter, "i");
+ return items.filter((item) => {
+ return regex.test(item.id) ||
+ regex.test(item.type) ||
+ regex.test(JSON.stringify(item.result));
+ });
+ } catch (e) {
+ // If regex is invalid, fallback to simple string search
+ return items.filter((item) => {
+ const itemStr = JSON.stringify(item);
+ return itemStr.toLowerCase().includes(filter.toLowerCase());
+ });
+ }
+ }, []);
+
+ // Functions to count items efficiently
+ const getActionCount = useCallback(() => {
+ if (!filterText) {
+ // If no filter, just count the keys
+ return Object.keys(model.push).length + Object.keys(model.pull).length;
+ } else {
+ // If filter is applied, we need a quick filtered count
+ const pushItems = Object.entries(model.push).map(([id, result]) => ({
+ id, type: "push", result
+ }));
+ const pullItems = Object.entries(model.pull).map(([id, result]) => ({
+ id, type: "pull", result
+ }));
+ const allItems = [...pushItems, ...pullItems];
+ return filterItems(allItems, filterText).length;
+ }
+ }, [model.push, model.pull, filterText, filterItems]);
+
+ const getSubscriptionCount = useCallback(() => {
+ if (!filterText) {
+ // If no filter, just count the keys
+ return Object.keys(model.subscriptions).length;
+ } else {
+ // If filter is applied, we need to check filtered items
+ const subEntries = Object.entries(model.subscriptions).map(([id, sub]) => ({
+ id, sub, type: "subscription"
+ }));
+ return filterItems(subEntries, filterText).length;
+ }
+ }, [model.subscriptions, filterText, filterItems]);
const getFilteredSubscriptions = useCallback(() => {
if (!filterText) return Object.entries(model.subscriptions);
@@ -374,7 +423,7 @@ const ModelInspector: React.FC<{ model: Inspector.Model, initiallyOpen?: boolean
}`}
onClick={() => setActiveTab("actions")}
>
- Actions
+ Actions ({getActionCount()})
diff --git a/jumble/src/components/commands.ts b/jumble/src/components/commands.ts
index 42babbfae..81d748936 100644
--- a/jumble/src/components/commands.ts
+++ b/jumble/src/components/commands.ts
@@ -768,32 +768,6 @@ async function handleAddRemoteRecipe(
export function getCommands(deps: CommandContext): CommandItem[] {
return [
- {
- id: "enable-network-inspector",
- type: "action",
- title: "Enable Network Inspector",
- group: "Tools",
- predicate: localStorage.getItem("networkInspectorVisible") !== "true",
- handler: () => {
- localStorage.setItem("networkInspectorVisible", "true");
- deps.setOpen(false);
- // Force a refresh to make sure the UI updates
- window.dispatchEvent(new Event("networkInspectorUpdate"));
- },
- },
- {
- id: "disable-network-inspector",
- type: "action",
- title: "Disable Network Inspector",
- group: "Tools",
- predicate: localStorage.getItem("networkInspectorVisible") === "true",
- handler: () => {
- localStorage.setItem("networkInspectorVisible", "false");
- deps.setOpen(false);
- // Force a refresh to make sure the UI updates
- window.dispatchEvent(new Event("networkInspectorUpdate"));
- },
- },
{
id: "new-charm",
type: "input",
@@ -1084,6 +1058,30 @@ export function getCommands(deps: CommandContext): CommandItem[] {
type: "menu",
title: "Advanced",
children: [
+ {
+ id: "enable-network-inspector",
+ type: "action",
+ title: "Enable Network Inspector",
+ predicate: localStorage.getItem("networkInspectorVisible") !== "true",
+ handler: () => {
+ localStorage.setItem("networkInspectorVisible", "true");
+ deps.setOpen(false);
+ // Refresh the page to ensure the setting takes effect
+ window.location.reload();
+ },
+ },
+ {
+ id: "disable-network-inspector",
+ type: "action",
+ title: "Disable Network Inspector",
+ predicate: localStorage.getItem("networkInspectorVisible") === "true",
+ handler: () => {
+ localStorage.setItem("networkInspectorVisible", "false");
+ deps.setOpen(false);
+ // Refresh the page to ensure the setting takes effect
+ window.location.reload();
+ },
+ },
{
id: "start-counter-job",
type: "action",
From 2fa1d84166561e5b27e73bade2369fec6d69e8b6 Mon Sep 17 00:00:00 2001
From: Ben Follington <5009316+bfollington@users.noreply.github.com>
Date: Mon, 24 Mar 2025 17:13:53 -0700
Subject: [PATCH 10/15] Fix JsonView type error
---
deno.lock | 1 +
jumble/src/components/NetworkInspector.tsx | 113 ++++++---------------
2 files changed, 32 insertions(+), 82 deletions(-)
diff --git a/deno.lock b/deno.lock
index f810730dd..1ad0e37b0 100644
--- a/deno.lock
+++ b/deno.lock
@@ -90,6 +90,7 @@
"npm:@types/three@0.173": "0.173.0",
"npm:@uiw/codemirror-theme-vscode@^4.23.7": "4.23.10_@codemirror+view@6.36.4",
"npm:@uiw/react-codemirror@^4.23.7": "4.23.10_@babel+runtime@7.26.10_@codemirror+state@6.5.2_@codemirror+theme-one-dark@6.1.2_@codemirror+view@6.36.4_codemirror@6.0.1_react@18.3.1_react-dom@18.3.1__react@18.3.1_@codemirror+commands@6.8.0",
+ "npm:@uiw/react-json-view@*": "2.0.0-alpha.30_@babel+runtime@7.26.10_react@18.3.1_react-dom@18.3.1__react@18.3.1",
"npm:@uiw/react-json-view@2.0.0-alpha.30": "2.0.0-alpha.30_@babel+runtime@7.26.10_react@18.3.1_react-dom@18.3.1__react@18.3.1",
"npm:@use-gesture/react@^10.3.1": "10.3.1_react@18.3.1",
"npm:@vercel/otel@^1.10.1": "1.10.4_@opentelemetry+api@1.9.0_@opentelemetry+api-logs@0.57.2_@opentelemetry+instrumentation@0.57.2__@opentelemetry+api@1.9.0_@opentelemetry+resources@1.30.1__@opentelemetry+api@1.9.0_@opentelemetry+sdk-logs@0.57.2__@opentelemetry+api@1.9.0_@opentelemetry+sdk-metrics@1.30.1__@opentelemetry+api@1.9.0_@opentelemetry+sdk-trace-base@1.30.1__@opentelemetry+api@1.9.0",
diff --git a/jumble/src/components/NetworkInspector.tsx b/jumble/src/components/NetworkInspector.tsx
index 2510b8ed6..b0d382459 100644
--- a/jumble/src/components/NetworkInspector.tsx
+++ b/jumble/src/components/NetworkInspector.tsx
@@ -1,8 +1,16 @@
import * as Inspector from "@commontools/runner/storage/inspector";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useResizableDrawer } from "@/hooks/use-resizeable-drawer.ts";
-import JsonView from "@uiw/react-json-view";
+import JsonViewImport from "@uiw/react-json-view";
import { githubDarkTheme } from "@uiw/react-json-view/githubDark";
+
+// Type assertion to help TypeScript understand this is a valid React component
+const JsonView: React.FC<{
+ value: any;
+ style: any;
+ collapsed?: number | boolean;
+}> = JsonViewImport as any;
+
import { getDoc } from "@commontools/runner";
import { useAnimationSmoothing } from "@/hooks/use-animation-smoothing.ts";
@@ -32,65 +40,6 @@ export function useStatusMonitor() {
return { status, updateStatus };
}
-// Mock data for testing the ModelInspector component
-const createMockModel = () => {
- const now = Date.now();
-
- return {
- connection: {
- ready: { ok: { attempt: 3 } },
- time: now,
- },
- push: {
- "job:tx1": {
- ok: {
- invocation: { cmd: "/memory/transact", args: { data: "example1" } },
- authorization: { access: { "tx1": {} } },
- },
- },
- "job:tx2": {
- error: Object.assign(new Error("Transaction failed"), {
- reason: "conflict",
- time: now - 5000,
- }),
- },
- },
- pull: {
- "job:query1": {
- ok: {
- invocation: { cmd: "/memory/query", args: { filter: "all" } },
- authorization: { access: { "query1": {} } },
- },
- },
- "job:query2": {
- error: Object.assign(new Error("Query timed out"), {
- reason: "timeout",
- time: now - 2000,
- }),
- },
- },
- subscriptions: {
- "job:sub1": {
- source: { cmd: "/memory/query/subscribe", args: { topic: "updates" } },
- opened: now - 10000,
- updated: now - 1000,
- value: { id: "update-123", status: "active", timestamp: now - 1000 },
- },
- "job:sub2": {
- source: {
- cmd: "/memory/query/subscribe",
- args: { topic: "notifications" },
- },
- opened: now - 20000,
- updated: now - 500,
- value: [
- { id: "notif-1", message: "New message received", read: false },
- { id: "notif-2", message: "Task completed", read: true },
- ],
- },
- },
- } as Inspector.Model;
-};
// Example usage with dummy data
export const DummyModelInspector: React.FC = () => {
@@ -104,9 +53,9 @@ export const DummyModelInspector: React.FC = () => {
export const ToggleableNetworkInspector: React.FC<{ visible: boolean }> = ({ visible }) => {
const { status, updateStatus } = useStatusMonitor();
useStorageBroadcast(updateStatus);
-
+
if (!visible || !status.current) return null;
-
+
return ;
};
const ModelInspector: React.FC<{ model: Inspector.Model, initiallyOpen?: boolean }> = ({ model, initiallyOpen = false }) => {
@@ -119,10 +68,10 @@ const ModelInspector: React.FC<{ model: Inspector.Model, initiallyOpen?: boolean
const [, setRenderTrigger] = useState(0);
const lastRenderTimeRef = useRef(0);
const buttonTextRef = useRef(null);
-
+
// Use our animation smoothing hook
const { updateValue, getValue, rafRef } = useAnimationSmoothing();
-
+
// Set up render loop with requestAnimationFrame when inspector is open
useEffect(() => {
if (!isOpen) {
@@ -142,9 +91,9 @@ const ModelInspector: React.FC<{ model: Inspector.Model, initiallyOpen?: boolean
}
rafRef.current = requestAnimationFrame(renderLoop);
};
-
+
rafRef.current = requestAnimationFrame(renderLoop);
-
+
return () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
@@ -152,7 +101,7 @@ const ModelInspector: React.FC<{ model: Inspector.Model, initiallyOpen?: boolean
}
};
}, [isOpen, rafRef]);
-
+
// Update button text with counts using requestAnimationFrame
useEffect(() => {
const updateCounts = () => {
@@ -160,38 +109,38 @@ const ModelInspector: React.FC<{ model: Inspector.Model, initiallyOpen?: boolean
rafRef.current = requestAnimationFrame(updateCounts);
return;
}
-
+
// Count calculations
const actualPushCount = Object.values(model.push).filter(v => v.ok).length;
const actualPullCount = Object.values(model.pull).filter(v => v.ok).length;
const pushErrorCount = Object.values(model.push).filter(v => v.error).length;
const pullErrorCount = Object.values(model.pull).filter(v => v.error).length;
const actualErrorCount = pushErrorCount + pullErrorCount;
-
+
// Update values with easing
const pushResult = updateValue("push", actualPushCount);
const pullResult = updateValue("pull", actualPullCount);
const errorResult = updateValue("error", actualErrorCount);
-
+
// Create status text
const statusParts = [];
if (pushResult.value > 0) statusParts.push(`↑${pushResult.value}`);
if (pullResult.value > 0) statusParts.push(`↓${pullResult.value}`);
if (errorResult.value > 0) statusParts.push(`!${errorResult.value}`);
-
- const statusText = statusParts.length > 0
+
+ const statusText = statusParts.length > 0
? `Inspector ${statusParts.join(" ")} ${isOpen ? "▼" : "▲"}`
: `Inspector ${isOpen ? "▼" : "▲"}`;
-
+
buttonTextRef.current.textContent = statusText;
-
+
// Continue animation
rafRef.current = requestAnimationFrame(updateCounts);
};
-
+
// Start updating counts
rafRef.current = requestAnimationFrame(updateCounts);
-
+
return () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
@@ -302,7 +251,7 @@ const ModelInspector: React.FC<{ model: Inspector.Model, initiallyOpen?: boolean
});
}
}, [model, filterText]);
-
+
// Define helper functions for filtering to avoid circular dependencies
const filterItems = useCallback((items: any[], filter: string) => {
try {
@@ -320,7 +269,7 @@ const ModelInspector: React.FC<{ model: Inspector.Model, initiallyOpen?: boolean
});
}
}, []);
-
+
// Functions to count items efficiently
const getActionCount = useCallback(() => {
if (!filterText) {
@@ -338,15 +287,15 @@ const ModelInspector: React.FC<{ model: Inspector.Model, initiallyOpen?: boolean
return filterItems(allItems, filterText).length;
}
}, [model.push, model.pull, filterText, filterItems]);
-
+
const getSubscriptionCount = useCallback(() => {
if (!filterText) {
// If no filter, just count the keys
return Object.keys(model.subscriptions).length;
} else {
// If filter is applied, we need to check filtered items
- const subEntries = Object.entries(model.subscriptions).map(([id, sub]) => ({
- id, sub, type: "subscription"
+ const subEntries = Object.entries(model.subscriptions).map(([id, sub]) => ({
+ id, sub, type: "subscription"
}));
return filterItems(subEntries, filterText).length;
}
@@ -449,7 +398,7 @@ const ModelInspector: React.FC<{ model: Inspector.Model, initiallyOpen?: boolean
}`}
/>
{filterText && (
-