diff --git a/src/components/Logging/Filters.tsx b/src/components/Logging/Filters.tsx index 446a9c6..b98cc52 100644 --- a/src/components/Logging/Filters.tsx +++ b/src/components/Logging/Filters.tsx @@ -4,8 +4,15 @@ import type {LogFilterPredicate} from "./useLogs.ts"; import styles from "./Filters.module.css"; +/** + * A generic setter type compatible with React's state setters. + */ type Setter = (value: T | ((prev: T) => T)) => void; +/** + * Mapping of log level names to their corresponding numeric severity. + * Used for comparison in log filtering predicates. + */ const optionMapping = new Map([ ["ALL", 0], ["DEBUG", 10], @@ -16,6 +23,17 @@ const optionMapping = new Map([ ["NONE", 999_999_999_999], // It is technically possible to have a higher level, but this is fine ]); +/** + * Renders a single log-level selector (dropdown) for a specific filter target. + * + * Used by both the global filter and agent-specific filters. + * + * @param name - The display name or identifier for the filter target. + * @param level - The currently selected log level. + * @param setLevel - Function to update the selected log level. + * @param onDelete - Optional callback for deleting this filter element. + * @returns A JSX element that renders a labeled dropdown for selecting log levels. + */ function LevelPredicateElement({ name, level, @@ -54,8 +72,19 @@ function LevelPredicateElement({ } +/** Key used for the global log-level predicate in the filter map. */ const GLOBAL_LOG_LEVEL_PREDICATE_KEY = "global_log_level"; +/** + * Renders and manages the **global log-level filter**. + * + * This component defines a baseline log level that all logs must meet or exceed + * to be displayed, unless overridden by per-agent filters. + * + * @param filterPredicates - Map of current log filter predicates. + * @param setFilterPredicates - Setter function to update the filter predicates map. + * @returns A JSX element rendering the global log-level selector. + */ function GlobalLevelFilter({ filterPredicates, setFilterPredicates, @@ -78,6 +107,7 @@ function GlobalLevelFilter({ }); } + // Initialize default global level on mount. useEffect(() => { if (filterPredicates.has(GLOBAL_LOG_LEVEL_PREDICATE_KEY)) return; setSelected("INFO"); @@ -91,8 +121,21 @@ function GlobalLevelFilter({ />; } +/** Prefix for agent-specific log-level predicate keys in the filter map. */ const AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX = "agent_log_level_"; +/** + * Renders and manages **per-agent log-level filters**. + * + * Allows the user to set specific log levels for individual agents, overriding + * the global filter for those agents. Includes functionality to add, edit, + * or remove agent-level filters. + * + * @param filterPredicates - Map of current log filter predicates. + * @param setFilterPredicates - Setter function to update the filter predicates map. + * @param agentNames - Set of agent names available for filtering. + * @returns A JSX element rendering agent-level filters and a dropdown to add new ones. + */ function AgentLevelFilters({ filterPredicates, setFilterPredicates, @@ -105,7 +148,7 @@ function AgentLevelFilters({ const rootRef = useRef(null); const [open, setOpen] = useState(false); - // Click outside to close + // Close dropdown or panels when clicking outside or pressing Escape. useEffect(() => { if (!open) return; const onDocClick = (e: MouseEvent) => { @@ -124,13 +167,16 @@ function AgentLevelFilters({ }; }, [open]); + // Identify which predicates correspond to agents. const agentPredicates = [...filterPredicates.keys()].filter((key) => key.startsWith(AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX)); - /** - * Create or change the predicate for an agent. If the level is not given, the global level is used. - * @param agentName The name of the agent. - * @param level The level to filter by. If not given, the global level is used. + /** + * Creates or updates the log filter predicate for a specific agent. + * Falls back to the global log level if no level is specified. + * + * @param agentName - The name of the agent to filter. + * @param level - Optional log level to apply; defaults to the global level. */ const setAgentPredicate = (agentName: string, level?: string ) => { level = level ?? filterPredicates.get(GLOBAL_LOG_LEVEL_PREDICATE_KEY)?.value ?? "ALL"; @@ -147,6 +193,11 @@ function AgentLevelFilters({ }); } + /** + * Deletes the log filter predicate for a specific agent. + * + * @param agentName - The name of the agent whose filter should be removed. + */ const deleteAgentPredicate = (agentName: string) => { setFilterPredicates((curr) => { const fullName = AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX + agentName; @@ -184,6 +235,17 @@ function AgentLevelFilters({ ; } +/** + * Main Filters component that aggregates global and per-agent log filters. + * + * Combines the global log-level filter and agent-specific filters into a unified UI. + * Updates a shared `Map` to determine which logs are shown. + * + * @param filterPredicates - The map of all active log filter predicates. + * @param setFilterPredicates - Setter to update the map of predicates. + * @param agentNames - Set of available agent names to display filters for. + * @returns A React component that renders all log filter controls. + */ export default function Filters({ filterPredicates, setFilterPredicates, diff --git a/src/components/Logging/Logging.tsx b/src/components/Logging/Logging.tsx index ede0bcc..8c2101f 100644 --- a/src/components/Logging/Logging.tsx +++ b/src/components/Logging/Logging.tsx @@ -8,13 +8,26 @@ import {type Cell, useCell} from "../../utils/cellStore.ts"; import styles from "./Logging.module.css"; + +/** + * Zustand store definition for managing user preferences related to logging. + * + * Includes flags for toggling relative timestamps and automatic scroll behavior. + */ type LoggingSettings = { + /** Whether to display log timestamps as relative (e.g., "2m 15s ago") instead of absolute. */ showRelativeTime: boolean; + /** Updates the `showRelativeTime` setting. */ setShowRelativeTime: (showRelativeTime: boolean) => void; + /** Whether the log view should automatically scroll to the newest entry. */ scrollToBottom: boolean; + /** Updates the `scrollToBottom` setting. */ setScrollToBottom: (scrollToBottom: boolean) => void; }; +/** + * Global Zustand store for logging UI preferences. + */ const useLoggingSettings = create((set) => ({ showRelativeTime: false, setShowRelativeTime: (showRelativeTime: boolean) => set({ showRelativeTime }), @@ -22,6 +35,16 @@ const useLoggingSettings = create((set) => ({ setScrollToBottom: (scrollToBottom: boolean) => set({ scrollToBottom }), })); +/** + * Renders a single log message entry with colored level indicators and timestamp formatting. + * + * This component automatically re-renders when the underlying log record (`recordCell`) + * changes. It also triggers the `onUpdate` callback whenever the record updates (e.g., for auto-scrolling). + * + * @param recordCell - A reactive `Cell` containing a single `LogRecord`. + * @param onUpdate - Optional callback triggered when the log entry updates. + * @returns A JSX element displaying a formatted log message. + */ function LogMessage({ recordCell, onUpdate, @@ -33,7 +56,8 @@ function LogMessage({ const record = useCell(recordCell); /** - * Normalizes the log level number to a multiple of 10, for which there are CSS styles. + * Normalizes the log level number to a multiple of 10, + * for which there are CSS styles. (e.g., INFO = 20, ERROR = 40). */ const normalizedLevelNo = (() => { // By default, the highest level is 50 (CRITICAL). Custom levels can be higher, but we don't have more critical color. @@ -42,8 +66,10 @@ function LogMessage({ return Math.round(record.levelno / 10) * 10; })(); + /** Simplifies the logger name by showing only the last path segment. */ const normalizedName = record.name.split(".").pop() || record.name; + // Notify parent component (e.g. for scroll updates) when this record changes. useEffect(() => { if (onUpdate) onUpdate(); }, [record, onUpdate]); @@ -65,11 +91,23 @@ function LogMessage({ ; } +/** + * Displays a scrollable list of log messages. + * + * Handles: + * - Auto-scrolling when new messages arrive. + * - Allowing users to scroll manually and disable auto-scroll. + * - A floating "Scroll to bottom" button when not at the bottom. + * + * @param recordCells - Array of reactive log records to display. + * @returns A scrollable log list component. + */ function LogMessages({ recordCells }: { recordCells: Cell[] }) { const scrollableRef = useRef(null); const lastElementRef = useRef(null) const { scrollToBottom, setScrollToBottom } = useLoggingSettings(); + // Disable auto-scroll if the user manually scrolls. useEffect(() => { if (!scrollableRef.current) return; const currentScrollableRef = scrollableRef.current; @@ -85,6 +123,12 @@ function LogMessages({ recordCells }: { recordCells: Cell[] }) { } }, [scrollableRef, setScrollToBottom]); + /** + * Scrolls the last log message into view if auto-scroll is enabled, + * or if forced (e.g., user clicks "Scroll to bottom"). + * + * @param force - If true, forces scrolling even if `scrollToBottom` is false. + */ function scrollLastElementIntoView(force = false) { if ((!scrollToBottom && !force) || !lastElementRef.current) return; lastElementRef.current.scrollIntoView({ behavior: "smooth" }); @@ -111,6 +155,19 @@ function LogMessages({ recordCells }: { recordCells: Cell[] }) { ; } +/** + * Top-level logging panel component. + * + * Combines: + * - The `Filters` component for adjusting log visibility. + * - The `LogMessages` component for displaying filtered logs. + * - Zustand-managed UI settings (auto-scroll, timestamp display). + * + * This component uses the `useLogs` hook to fetch and filter logs based on + * active predicates, and re-renders automatically as new logs arrive. + * + * @returns The complete logging UI as a React element. + */ export default function Logging() { const [filterPredicates, setFilterPredicates] = useState(new Map()); const { filteredLogs, distinctNames } = useLogs(filterPredicates) diff --git a/src/components/Logging/useLogs.ts b/src/components/Logging/useLogs.ts index 76eed92..d51fdcb 100644 --- a/src/components/Logging/useLogs.ts +++ b/src/components/Logging/useLogs.ts @@ -3,6 +3,19 @@ import {useCallback, useEffect, useRef, useState} from "react"; import {applyPriorityPredicates, type PriorityFilterPredicate} from "../../utils/priorityFiltering.ts"; import {cell, type Cell} from "../../utils/cellStore.ts"; +/** + * Represents a single log record emitted by the backend logging system. + * + * @property name - The name of the logger or source (e.g., `"agent.core"`). + * @property message - The message content of the log record. + * @property levelname - The human-readable severity level (e.g., `"INFO"`, `"ERROR"`). + * @property levelno - The numeric severity value corresponding to `levelname`. + * @property created - The UNIX timestamp (in seconds) when this record was created. + * @property relativeCreated - The time (in milliseconds) since the logging system started. + * @property reference - (Optional) A reference identifier linking related log messages. + * @property firstCreated - Timestamp of the first log in this reference group. + * @property firstRelativeCreated - Relative timestamp of the first log in this reference group. + */ export type LogRecord = { name: string; message: string; @@ -15,29 +28,68 @@ export type LogRecord = { firstRelativeCreated: number; }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type LogFilterPredicate = PriorityFilterPredicate & { value: any }; +/** + * A log filter predicate with priority support, used to determine whether + * a log record should be displayed. + * + * This extends a general `PriorityFilterPredicate` and includes an optional + * `value` field for UI metadata (e.g., selected log level or agent). + * + * @template T - The type of record being filtered (here, `LogRecord`). + */ +export type LogFilterPredicate = PriorityFilterPredicate & { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any }; + /** + * React hook that manages the lifecycle of log records, including: + * - Receiving live log messages via Server-Sent Events (SSE), + * - Applying priority-based filtering rules, + * - Managing distinct logger names and reference-linked messages. + * + * Returns both the filtered logs (as reactive `Cell` objects) + * and a set of distinct logger names for use in UI components (e.g., Filters). + * + * @param filterPredicates - A `Map` of log filter predicates, keyed by ID or type. + * @returns An object containing: + * - `filteredLogs`: The currently visible (filtered) log messages. + * - `distinctNames`: A set of all distinct logger names encountered. + * + * @example + * ```ts + * const { filteredLogs, distinctNames } = useLogs(activeFilters); + * ``` + */ export function useLogs(filterPredicates: Map) { + /** Distinct logger names encountered across all logs. */ const [distinctNames, setDistinctNames] = useState>(new Set()); + /** Filtered logs that pass all active predicates, stored as reactive cells. */ const [filtered, setFiltered] = useState[]>([]); + /** Persistent reference to the active EventSource connection. */ const sseRef = useRef(null); + /** Keeps a stable reference to the current filter map (avoids re-renders). */ const filtersRef = useRef(filterPredicates); + /** Stores all received logs (the unfiltered full history). */ const logsRef = useRef([]); /** Map to store the first message for each reference, instance can be updated to change contents. */ const firstByRefRef = useRef>>(new Map()); /** - * Apply the filter predicates to a log record. + * Apply all active filter predicates to a log record. * @param log The log record to apply the filters to. - * @returns `true` if the record passes. + * @returns `true` if the record passes all filters; otherwise `false`. */ const applyFilters = useCallback((log: LogRecord) => applyPriorityPredicates(log, [...filtersRef.current.values()]), []); - /** Recomputes the entire filtered list. Use when filter predicates change. */ + /** + * Fully recomputes the filtered log list based on the current + * filter predicates and historical logs. + * + * Should be invoked whenever the filter map changes. + */ const recomputeFiltered = useCallback(() => { const newFiltered: Cell[] = []; firstByRefRef.current = new Map(); @@ -49,6 +101,7 @@ export function useLogs(filterPredicates: Map) { firstRelativeCreated: message.relativeCreated, }); + // Handle reference grouping: update the first message in the group. if (message.reference) { const first = firstByRefRef.current.get(message.reference); if (first) { @@ -59,14 +112,14 @@ export function useLogs(filterPredicates: Map) { firstRelativeCreated: prev.firstRelativeCreated ?? prev.relativeCreated, })); - // Don't add it to the list again - continue; + continue; // Don't add it to the list again (it's a duplicate). } else { // Add the first message with this reference to the registry firstByRefRef.current.set(message.reference, messageCell); } } + // Include only if it passes current filters. if (applyFilters(message)) { newFiltered.push(messageCell); } @@ -75,20 +128,23 @@ export function useLogs(filterPredicates: Map) { setFiltered(newFiltered); }, [applyFilters, setFiltered]); - // Reapply filters to all logs, only when filters change + // Re-filter all logs whenever filter predicates change. useEffect(() => { filtersRef.current = filterPredicates; recomputeFiltered(); }, [filterPredicates, recomputeFiltered]); /** - * Handle a new log message. Updates the filtered list and to the full history. - * @param message The new log message. + * Handles a newly received log record. + * Updates the full log history, distinct names set, and filtered log list. + * + * @param message - The new log record to process. */ const handleNewMessage = useCallback((message: LogRecord) => { - // Add to the full history for re-filtering on filter changes + // Store in complete history for future refiltering. logsRef.current.push(message); + // Track distinct logger names. setDistinctNames((prev) => { if (prev.has(message.name)) return prev; const newSet = new Set(prev); @@ -96,12 +152,14 @@ export function useLogs(filterPredicates: Map) { return newSet; }); + // Wrap in a reactive cell for UI binding. const messageCell = cell({ ...message, firstCreated: message.created, firstRelativeCreated: message.relativeCreated, }); + // Handle reference-linked updates. if (message.reference) { const first = firstByRefRef.current.get(message.reference); if (first) { @@ -112,20 +170,28 @@ export function useLogs(filterPredicates: Map) { firstRelativeCreated: prev.firstRelativeCreated ?? prev.relativeCreated, })); - // Don't add it to the list again - return; + return; // Do not duplicate reference group entries. } else { - // Add the first message with this reference to the registry firstByRefRef.current.set(message.reference, messageCell); } } + // Only append if message passes filters. if (applyFilters(message)) { setFiltered((curr) => [...curr, messageCell]); } }, [applyFilters, setFiltered]); + /** + * Initializes the SSE (Server-Sent Events) stream for real-time logs. + * + * Subscribes to messages from the backend logging endpoint and + * dispatches each message to `handleNewMessage`. + * + * Cleans up the EventSource connection when the component unmounts. + */ useEffect(() => { + // Only create one SSE connection for the lifetime of the hook. if (sseRef.current) return; const es = new EventSource("http://localhost:8000/logs/stream"); diff --git a/src/components/ScrollIntoView.tsx b/src/components/ScrollIntoView.tsx index bcbc7d4..df5148f 100644 --- a/src/components/ScrollIntoView.tsx +++ b/src/components/ScrollIntoView.tsx @@ -1,9 +1,17 @@ import {useEffect, useRef} from "react"; /** - * An element that always scrolls into view when it is rendered. When added to a list, the entire list will scroll to show this element. + * A React component that automatically scrolls itself into view whenever rendered. + * + * This component is especially useful in scrollable containers to keep the most + * recent content visible (e.g., chat applications, live logs, or notifications). + * + * It uses the browser's `Element.scrollIntoView()` API with smooth scrolling behavior. + * + * @returns A `
` element that scrolls into view when mounted or updated. */ export default function ScrollIntoView() { + /** Ref to the DOM element that will be scrolled into view. */ const elementRef = useRef(null); useEffect(() => { diff --git a/src/components/TextField.tsx b/src/components/TextField.tsx index 58de55d..f9527c8 100644 --- a/src/components/TextField.tsx +++ b/src/components/TextField.tsx @@ -2,15 +2,22 @@ import {useState} from "react"; import styles from "./TextField.module.css"; /** - * A text input element in our own style that calls `setValue` at every keystroke. + * A styled text input that updates its value **in real time** at every keystroke. * - * @param {Object} props - The component props. - * @param {string} props.value - The value of the text input. - * @param {(value: string) => void} props.setValue - A function that sets the value of the text input. - * @param {string} [props.placeholder] - The placeholder text for the text input. - * @param {string} [props.className] - Additional CSS classes for the text input. - * @param {string} [props.id] - The ID of the text input. - * @param {string} [props.ariaLabel] - The ARIA label for the text input. + * Automatically toggles between read-only and editable modes to integrate with + * drag-based UIs (like React Flow). Calls `onCommit` when editing is completed. + * + * @param props - Component properties. + * @param props.value - The current text input value. + * @param props.setValue - Callback invoked on every keystroke to update the value. + * @param props.onCommit - Callback invoked when editing is finalized (on blur or Enter). + * @param props.placeholder - Optional placeholder text displayed when the input is empty. + * @param props.className - Optional additional CSS class names. + * @param props.id - Optional unique HTML `id` for the input element. + * @param props.ariaLabel - Optional ARIA label for accessibility. + * @param props.invalid - If true, applies error styling to indicate invalid input. + * + * @returns A styled `` element that updates its value in real time. */ export function RealtimeTextField({ value = "", @@ -31,14 +38,19 @@ export function RealtimeTextField({ ariaLabel?: string, invalid?: boolean, }) { + /** Tracks whether the input is currently read-only (for drag compatibility). */ const [readOnly, setReadOnly] = useState(true); + /** Finalizes editing and calls `onCommit` when the user exits the field. */ const updateData = () => { setReadOnly(true); onCommit(); }; - const updateOnEnter = (event: React.KeyboardEvent) => { if (event.key === "Enter") (event.target as HTMLInputElement).blur(); }; + /** Handles the Enter key — commits the input by triggering a blur event. */ + const updateOnEnter = (event: React.KeyboardEvent) => { + if (event.key === "Enter") + (event.target as HTMLInputElement).blur(); }; return void} props.setValue - A function that sets the value of the text input. - * @param {string} [props.placeholder] - The placeholder text for the text input. - * @param {string} [props.className] - Additional CSS classes for the text input. - * @param {string} [props.id] - The ID of the text input. - * @param {string} [props.ariaLabel] - The ARIA label for the text input. + * Internally wraps `RealtimeTextField` and buffers input changes locally, + * calling `setValue` only once editing is complete. + * + * @param props - Component properties. + * @param props.value - The current text input value. + * @param props.setValue - Callback invoked when the user commits the change. + * @param props.placeholder - Optional placeholder text displayed when the input is empty. + * @param props.className - Optional additional CSS class names. + * @param props.id - Optional unique HTML `id` for the input element. + * @param props.ariaLabel - Optional ARIA label for accessibility. + * @param props.invalid - If true, applies error styling to indicate invalid input. + * + * @returns A styled `` element that updates its parent state only on commit. */ export function TextField({ value = "", diff --git a/src/components/components.tsx b/src/components/components.tsx index 24dd429..7ee7f0d 100644 --- a/src/components/components.tsx +++ b/src/components/components.tsx @@ -1,6 +1,14 @@ import { useState } from 'react' +/** + * A minimal counter component that demonstrates basic React state handling. + * + * Maintains an internal count value and provides buttons to increment and reset it. + * + * @returns A JSX element rendering the counter UI. + */ function Counter() { + /** The current counter value. */ const [count, setCount] = useState(0) return ( diff --git a/src/pages/ConnectedRobots/ConnectedRobots.tsx b/src/pages/ConnectedRobots/ConnectedRobots.tsx index b7ec65f..176f8d5 100644 --- a/src/pages/ConnectedRobots/ConnectedRobots.tsx +++ b/src/pages/ConnectedRobots/ConnectedRobots.tsx @@ -1,20 +1,35 @@ import { useEffect, useState } from 'react' +/** + * Displays the current connection status of a robot in real time. + * + * Opens an SSE connection to the backend (`/robot/ping_stream`) that emits + * simple boolean JSON messages (`true` or `false`). Updates automatically when + * the robot connects or disconnects. + * + * @returns A React element showing the current robot connection status. + */ export default function ConnectedRobots() { + /** + * The current connection state: + * - `true`: Robot is connected. + * - `false`: Robot is not connected. + * - `null`: Connection status is unknown (initial check in progress). + */ const [connected, setConnected] = useState(null); useEffect(() => { - // We're excepting a stream of data like that looks like this: `data = False` or `data = True` + // Open a Server-Sent Events (SSE) connection to receive live ping updates. + // We're expecting a stream of data like that looks like this: `data = False` or `data = True` const eventSource = new EventSource("http://localhost:8000/robot/ping_stream"); eventSource.onmessage = (event) => { - // Receive message and parse + // Expecting messages in JSON format: `true` or `false` console.log("received message:", event.data); try { const data = JSON.parse(event.data); - // Set connected to value. try { setConnected(data) } @@ -26,6 +41,8 @@ export default function ConnectedRobots() { console.log("Ping message not in correct format:", event.data); } }; + + // Clean up the SSE connection when the component unmounts. return () => eventSource.close(); }, []); diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index c6afa92..c998e25 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -2,6 +2,14 @@ import { Link } from 'react-router' import pepperLogo from '../../assets/pepper_transp2_small.svg' import styles from './Home.module.css' +/** + * The home page component providing navigation and project branding. + * + * Renders the Pepper logo and a set of navigational links + * implemented via React Router. + * + * @returns A JSX element representing the app’s home page. + */ function Home() { return (
diff --git a/src/pages/Robot/Robot.tsx b/src/pages/Robot/Robot.tsx index 0038dd9..803b2f5 100644 --- a/src/pages/Robot/Robot.tsx +++ b/src/pages/Robot/Robot.tsx @@ -1,13 +1,34 @@ import { useState, useEffect, useRef } from 'react' +/** + * Displays a live robot interaction panel with user input, conversation history, + * and real-time updates from the robot backend via Server-Sent Events (SSE). + * + * @returns A React element rendering the interactive robot UI. + */ export default function Robot() { + /** The text message currently entered by the user. */ const [message, setMessage] = useState(''); + /** Whether the robot’s microphone or listening mode is currently active. */ const [listening, setListening] = useState(false); - const [conversation, setConversation] = useState<{"role": "user" | "assistant", "content": string}[]>([]) + /** The ongoing conversation history as a sequence of user/assistant messages. */ + const [conversation, setConversation] = useState< + {"role": "user" | "assistant", "content": string}[]>([]) + /** Reference to the scrollable conversation container for auto-scrolling. */ const conversationRef = useRef(null); + /** + * Index used to force refresh the SSE connection or clear conversation. + * Incrementing this value triggers a reset of the live data stream. + */ const [conversationIndex, setConversationIndex] = useState(0); + /** + * Sends a message to the robot backend. + * + * Makes a POST request to `/message` with the user’s text. + * The backend may respond with confirmation or error information. + */ const sendMessage = async () => { try { const response = await fetch("http://localhost:8000/message", { @@ -24,6 +45,17 @@ export default function Robot() { } }; + /** + * Establishes a persistent Server-Sent Events (SSE) connection + * to receive real-time updates from the robot backend. + * + * Handles three event types: + * - `voice_active`: whether the robot is currently listening. + * - `speech`: recognized user speech input. + * - `llm_response`: the robot’s language model-generated reply. + * + * The connection resets whenever `conversationIndex` changes. + */ useEffect(() => { const eventSource = new EventSource("http://localhost:8000/sse"); @@ -43,6 +75,10 @@ export default function Robot() { }; }, [conversationIndex]); + /** + * Automatically scrolls the conversation view to the bottom + * whenever a new message is added. + */ useEffect(() => { if (!conversationRef || !conversationRef.current) return; conversationRef.current.scrollTop = conversationRef.current.scrollHeight; diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index ca8ef73..e64acc1 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -12,7 +12,10 @@ import TriggerNode, { TriggerConnects, TriggerReduce } from "./nodes/TriggerNode import { TriggerNodeDefaults } from "./nodes/TriggerNode.default"; /** - * The types of the nodes we have registered. + * Registered node types in the visual programming system. + * + * Key: the node type string used in the flow graph. + * Value: the corresponding React component for rendering the node. */ export const NodeTypes = { start: StartNode, @@ -24,8 +27,9 @@ export const NodeTypes = { }; /** - * The default functions of the nodes we have registered. + * Default data and settings for each node type. * These are defined in the .default.ts files. + * These defaults are used when a new node is created to initialize its properties. */ export const NodeDefaults = { start: StartNodeDefaults, @@ -38,7 +42,10 @@ export const NodeDefaults = { /** - * The reduce functions of the nodes we have registered. + * Reduce functions for each node type. + * + * A reduce function extracts the relevant data from a node and its children. + * Used during graph evaluation or export. */ export const NodeReduces = { start: StartReduce, @@ -51,7 +58,9 @@ export const NodeReduces = { /** - * The connection functionality of the nodes we have registered. + * Connection functions for each node type. + * + * These functions define how nodes of a particular type can connect to other nodes. */ export const NodeConnects = { start: StartConnects, @@ -63,8 +72,9 @@ export const NodeConnects = { } /** - * Functions that define whether a node should be deleted, currently constant only for start and end. - * Any node types that aren't mentioned are 'true', and can be deleted by default. + * Defines whether a node type can be deleted. + * + * Returns a function per node type. Nodes not explicitly listed are deletable by default. */ export const NodeDeletes = { start: () => false, @@ -72,8 +82,10 @@ export const NodeDeletes = { } /** - * Defines which types are variables in the phase node- - * any node that is NOT mentioned here, is automatically seen as a variable of a phase. + * Defines which node types are considered variables in a phase node. + * + * Any node type not listed here is automatically treated as part of a phase. + * This allows the system to dynamically group nodes under a phase node. */ export const NodesInPhase = { start: () => false, diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index 63164c2..e79715f 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -13,13 +13,14 @@ import { NodeDefaults, NodeConnects, NodeDeletes } from './NodeRegistry'; /** - * Create a node given the correct data - * @param type the type of the node to create - * @param id the id of the node to create - * @param position the position of the node to create - * @param data the data in the node to create - * @param deletable if this node should be able to be deleted IN ANY WAY POSSIBLE - * @constructor + * A Function to create a new node with the correct default data and properties. + * + * @param id - The unique ID of the node. + * @param type - The type of node to create (must exist in NodeDefaults). + * @param position - The XY position of the node in the flow canvas. + * @param data - The data object to initialize the node with. + * @param deletable - Optional flag to indicate if the node can be deleted (can be deleted by default). + * @returns A fully initialized Node object ready to be added to the flow. */ function createNode(id: string, type: string, position: XYPosition, data: Record, deletable? : boolean) { const defaultData = NodeDefaults[type as keyof typeof NodeDefaults] @@ -33,7 +34,7 @@ function createNode(id: string, type: string, position: XYPosition, data: Record return {...defaultData, ...newData} } -//* Initial nodes, created by using createNode. */ +//* Initial nodes to populate the flow at startup. const initialNodes : Node[] = [ createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false), createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false), @@ -41,7 +42,7 @@ const initialNodes : Node[] = [ createNode('norms-1', 'norm', {x:-200, y:100}, {label: "Initial Norms", normList: ["Be a robot", "get good"]}), ]; -// * Initial edges * / +//* Initial edges to connect the startup nodes. const initialEdges: Edge[] = [ { id: 'start-phase-1', source: 'start', target: 'phase-1' }, { id: 'phase-1-end', source: 'phase-1', target: 'end' }, @@ -52,16 +53,33 @@ const initialEdges: Edge[] = [ * How we have defined the functions for our FlowState. * We have the normal functionality of a default FlowState with some exceptions to account for extra functionality. * The biggest changes are in onConnect and onDelete, which we have given extra functionality based on the nodes defined functions. + * + * * Provides: + * - Node and edge state management + * - Node creation, deletion, and updates + * - Custom connection handling via NodeConnects + * - Edge reconnection handling */ const useFlowStore = create((set, get) => ({ nodes: initialNodes, edges: initialEdges, edgeReconnectSuccessful: true, + /** + * Handles changes to nodes triggered by ReactFlow. + */ onNodesChange: (changes) => set({nodes: applyNodeChanges(changes, get().nodes)}), + + /** + * Handles changes to edges triggered by ReactFlow. + */ onEdgesChange: (changes) => set({ edges: applyEdgeChanges(changes, get().edges) }), + /** + * Handles creating a new connection between nodes. + * Updates edges and calls the node-specific connection functions. + */ onConnect: (connection) => { const edges = addEdge(connection, get().edges); const nodes = get().nodes; @@ -86,6 +104,9 @@ const useFlowStore = create((set, get) => ({ set({ nodes, edges }); }, + /** + * Handles reconnecting an edge between nodes. + */ onReconnect: (oldEdge, newConnection) => { get().edgeReconnectSuccessful = true; set({ edges: reconnectEdge(oldEdge, newConnection, get().edges) }); @@ -98,7 +119,11 @@ const useFlowStore = create((set, get) => ({ } set({ edgeReconnectSuccessful: true }); }, - + + /** + * Deletes a node by ID, respecting NodeDeletes rules. + * Also removes all edges connected to that node. + */ deleteNode: (nodeId) => { // Let's find our node to check if they have a special deletion function const ourNode = get().nodes.find((n)=>n.id==nodeId); @@ -112,10 +137,19 @@ const useFlowStore = create((set, get) => ({ })} }, - + /** + * Replaces the entire nodes array in the store. + */ setNodes: (nodes) => set({ nodes }), + + /** + * Replaces the entire edges array in the store. + */ setEdges: (edges) => set({ edges }), + /** + * Updates the data of a node by merging new data with existing data. + */ updateNodeData: (nodeId, data) => { set({ nodes: get().nodes.map((node) => { @@ -127,6 +161,9 @@ const useFlowStore = create((set, get) => ({ }); }, + /** + * Adds a new node to the flow store. + */ addNode: (node: Node) => { set({ nodes: [...get().nodes, node] }); }, diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx index 6b98d6b..e466bed 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx @@ -2,23 +2,76 @@ import type { Edge, OnNodesChange, OnEdgesChange, OnConnect, OnReconnect, Node } from '@xyflow/react'; import type { NodeTypes } from './NodeRegistry'; -export type AppNode = typeof NodeTypes +/** + * Type representing all registered node types. + * This corresponds to the keys of NodeTypes in NodeRegistry. + */ +export type AppNode = typeof NodeTypes; +/** + * The FlowState type defines the shape of the Zustand store used for managing the visual programming flow. + * + * Includes: + * - Nodes and edges currently in the flow + * - Callbacks for node and edge changes + * - Node deletion and updates + * - Edge reconnection handling + */ export type FlowState = { nodes: Node[]; edges: Edge[]; edgeReconnectSuccessful: boolean; + /** Handler for changes to nodes triggered by ReactFlow */ onNodesChange: OnNodesChange; + + /** Handler for changes to edges triggered by ReactFlow */ onEdgesChange: OnEdgesChange; + + /** Handler for creating a new connection between nodes */ onConnect: OnConnect; + + /** Handler for reconnecting an existing edge */ onReconnect: OnReconnect; + + /** Called when an edge reconnect process starts */ onReconnectStart: () => void; + + /** + * Called when an edge reconnect process ends. + * @param _ - event or unused parameter + * @param edge - the edge that finished reconnecting + */ onReconnectEnd: (_: unknown, edge: { id: string }) => void; + /** + * Deletes a node and any connected edges. + * @param nodeId - the ID of the node to delete + */ deleteNode: (nodeId: string) => void; + + /** + * Replaces the current nodes array in the store. + * @param nodes - new array of nodes + */ setNodes: (nodes: Node[]) => void; + + /** + * Replaces the current edges array in the store. + * @param edges - new array of edges + */ setEdges: (edges: Edge[]) => void; + + /** + * Updates the data of a node by merging new data with existing node data. + * @param nodeId - the ID of the node to update + * @param data - object containing new data fields to merge + */ updateNodeData: (nodeId: string, data: object) => void; + + /** + * Adds a new node to the flow. + * @param node - the Node object to add + */ addNode: (node: Node) => void; -}; \ No newline at end of file +}; diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 97b563b..92f211c 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -6,7 +6,12 @@ import styles from '../../VisProg.module.css'; import { NodeDefaults, type NodeTypes } from '../NodeRegistry' /** - * DraggableNodeProps dictates the type properties of a DraggableNode + * Props for a draggable node within the drag-and-drop toolbar. + * + * @property className - Optional custom CSS classes for styling. + * @property children - The visual content or label rendered inside the draggable node. + * @property nodeType - The type of node represented (key from `NodeTypes`). + * @property onDrop - Function called when the node is dropped on the flow pane. */ interface DraggableNodeProps { className?: string; @@ -16,15 +21,20 @@ interface DraggableNodeProps { } /** - * Definition of a node inside the drag and drop toolbar. - * These nodes require an onDrop function that dictates - * how the node is created in the graph. + * A draggable node element used in the drag-and-drop toolbar. + * + * Integrates with the NeoDrag library to handle drag events. + * On drop, it calls the provided `onDrop` function with the node type and drop position. + * + * @param props - The draggable node configuration. + * @returns A React element representing a draggable node. */ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeProps) { const draggableRef = useRef(null); const [position, setPosition] = useState({ x: 0, y: 0 }); - // @ts-expect-error from the neodrag package — safe to ignore + // The NeoDrag hook enables smooth drag functionality for this element. + // @ts-expect-error: NeoDrag typing incompatibility — safe to ignore. useDraggable(draggableRef, { position, onDrag: ({ offsetX, offsetY }) => { @@ -44,16 +54,23 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP } /** - * addNode — adds a new node to the flow using the unified class-based system. - * Keeps numbering logic for phase/norm nodes. + * Adds a new node to the flow graph. + * + * Handles: + * - Automatic node ID generation based on existing nodes of the same type. + * - Loading of default data from the `NodeDefaults` registry. + * - Integration with the flow store to update global node state. + * + * @param nodeType - The type of node to create (from `NodeTypes`). + * @param position - The XY position in the flow canvas where the node will appear. */ function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { const { nodes, setNodes } = useFlowStore.getState(); - // Find out if there's any default data about our ndoe + // Load any predefined data for this node type. const defaultData = NodeDefaults[nodeType] ?? {} - // Currently, we find out what the Id is by checking the last node and adding one + // Currently, we find out what the Id is by checking the last node and adding one. const sameTypeNodes = nodes.filter((node) => node.type === nodeType); const nextNumber = sameTypeNodes.length > 0 @@ -77,16 +94,28 @@ function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { } /** - * DndToolbar defines how the drag and drop toolbar component works - * and includes the default onDrop behavior. + * The drag-and-drop toolbar component for the visual programming interface. + * + * Displays draggable node templates based on entries in `NodeDefaults`. + * Each droppable node can be dragged into the flow pane to instantiate it. + * + * Automatically filters nodes whose `droppable` flag is set to `true`. + * + * @returns A React element representing the drag-and-drop toolbar. */ export function DndToolbar() { const { screenToFlowPosition } = useReactFlow(); + /** + * Handles dropping a node onto the flow pane. + * Translates screen coordinates into flow coordinates using React Flow utilities. + */ const handleNodeDrop = useCallback( (nodeType: keyof typeof NodeTypes, screenPosition: XYPosition) => { const flow = document.querySelector('.react-flow'); const flowRect = flow?.getBoundingClientRect(); + + // Only add the node if it is inside the flow canvas area. const isInFlow = flowRect && screenPosition.x >= flowRect.left && @@ -103,7 +132,7 @@ export function DndToolbar() { ); - // Map over our default settings to see which of them have their droppable data set to true + // Map over the default nodes to get all nodes that can be dropped from the toolbar. const droppableNodes = Object.entries(NodeDefaults) .filter(([, data]) => data.droppable) .map(([type, data]) => ({ diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx index 090fa38..460a508 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx @@ -2,7 +2,12 @@ import { NodeToolbar } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import useFlowStore from "../VisProgStores.tsx"; -//Toolbar definitions +/** + * Props for the Toolbar component. + * + * @property nodeId - The ID of the node this toolbar is attached to. + * @property allowDelete - If `true`, the delete button is enabled; otherwise disabled. + */ type ToolbarProps = { nodeId: string; allowDelete: boolean; @@ -10,12 +15,12 @@ type ToolbarProps = { /** * Node Toolbar definition: - * handles: node deleting functionality - * can be added to any custom node component as a React component + * Handles: node deleting functionality + * Can be integrated to any custom node component as a React component * - * @param {string} nodeId - * @param {boolean} allowDelete - * @returns {React.JSX.Element} + * @param {string} nodeId - The ID of the node for which the toolbar is rendered. + * @param {boolean} allowDelete - Enables or disables the delete functionality. + * @returns {React.JSX.Element} A JSX element representing the toolbar. * @constructor */ export function Toolbar({nodeId, allowDelete}: ToolbarProps) { diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index 8cfa122..5be666b 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -93,6 +93,12 @@ export function GoalReduce(node: Node, nodes: Node[]) { } } +/** + * This function is called whenever a connection is made with this node type (Goal) + * @param thisNode the node of this node type which function is called + * @param otherNode the other node which was part of the connection + * @param isThisSource whether this instance of the node was the source in the connection, true = yes. + */ export function GoalConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { // Replace this for connection logic if (thisNode == undefined && otherNode == undefined && isThisSource == false) { diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index 5789cac..d2ca50d 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -76,6 +76,12 @@ export function NormReduce(node: Node, nodes: Node[]) { } } +/** + * This function is called whenever a connection is made with this node type (Norm) + * @param thisNode the node of this node type which function is called + * @param otherNode the other node which was part of the connection + * @param isThisSource whether this instance of the node was the source in the connection, true = yes. + */ export function NormConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { // Replace this for connection logic if (thisNode == undefined && otherNode == undefined && isThisSource == false) { diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index a6f114e..9c09c6e 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -14,10 +14,16 @@ import { RealtimeTextField, TextField } from '../../../../components/TextField'; import duplicateIndices from '../../../../utils/duplicateIndices'; /** - * The default data dot a Trigger node - * @param label: the label of this Trigger - * @param droppable: whether this node is droppable from the drop bar (initialized as true) - * @param children: ID's of children of this node + * The default data structure for a Trigger node + * + * Represents configuration for a node that activates when a specific condition is met, + * such as keywords being spoken or emotions detected. + * + * @property label: the display label of this Trigger node. + * @property droppable: Whether this node can be dropped from the toolbar (default: true). + * @property triggerType - The type of trigger ("keywords" or a custom string). + * @property triggers - The list of keyword triggers (if applicable). + * @property hasReduce - Whether this node supports reduction logic. */ export type TriggerNodeData = { label: string; @@ -31,14 +37,20 @@ export type TriggerNodeData = { export type TriggerNode = Node +/** + * Determines whether a Trigger node can connect to another node or edge. + * + * @param connection - The connection or edge being attempted to connect towards. + * @returns `true` if the connection is defined; otherwise, `false`. + */ export function TriggerNodeCanConnect(connection: Connection | Edge): boolean { return (connection != undefined); } /** * Defines how a Trigger node should be rendered - * @param props NodeProps, like id, label, children - * @returns React.JSX.Element + * @param props - Node properties provided by React Flow, including `id` and `data`. + * @returns The rendered TriggerNode React element (React.JSX.Element). */ export default function TriggerNode(props: NodeProps) { const data = props.data; @@ -66,9 +78,10 @@ export default function TriggerNode(props: NodeProps) { } /** - * Reduces each Trigger, including its children down into its relevant data. - * @param node: The Node Properties of this node. - * @param nodes: all the nodes in the graph. + * Reduces each Trigger, including its children down into its core data. + * @param node - The Trigger node to reduce. + * @param nodes - The list of all nodes in the current flow graph. + * @returns A simplified object containing the node label and its list of triggers. */ export function TriggerReduce(node: Node, nodes: Node[]) { // Replace this for nodes functionality @@ -83,10 +96,11 @@ export function TriggerReduce(node: Node, nodes: Node[]) { } /** - * This function is called whenever a connection is made with this node type (trigger) - * @param thisNode the node of this node type which function is called - * @param otherNode the other node which was part of the connection - * @param isThisSource whether this instance of the node was the source in the connection, true = yes. + * Handles logic that occurs when a connection is made involving a Trigger node. + * + * @param thisNode - The current Trigger node being connected. + * @param otherNode - The other node involved in the connection. + * @param isThisSource - Whether this node was the source of the connection. */ export function TriggerConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { // Replace this for connection logic @@ -96,23 +110,33 @@ export function TriggerConnects(thisNode: Node, otherNode: Node, isThisSource: b } // Definitions for the possible triggers, being keywords and emotions + +/** Represents a single keyword trigger entry. */ type Keyword = { id: string, keyword: string }; +/** Properties for an emotion-type trigger node. */ export type EmotionTriggerNodeProps = { type: "emotion"; value: string; } + +/** Props for a keyword-type trigger node. */ export type KeywordTriggerNodeProps = { type: "keywords"; value: Keyword[]; } +/** Union type for all possible Trigger node configurations. */ export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps; + /** - * The JSX element that is responsible for updating the field and showing the text - * @param param0 the function that updates the field - * @returns React.JSX.Element that handles adding keywords + * Renders an input element that allows users to add new keyword triggers. + * + * When the input is committed, the `addKeyword` callback is called with the new keyword. + * + * @param param0 - An object containing the `addKeyword` function. + * @returns A React element(React.JSX.Element) providing an input for adding keywords. */ function KeywordAdder({ addKeyword }: { addKeyword: (keyword: string) => void }) { const [input, setInput] = useState(""); @@ -136,6 +160,14 @@ function KeywordAdder({ addKeyword }: { addKeyword: (keyword: string) => void })
; } +/** + * Displays and manages a list of keyword triggers for a Trigger node. + * Handles adding, editing, and removing keywords, as well as detecting duplicate entries. + * + * @param keywords - The current list of keyword triggers. + * @param setKeywords - A callback to update the keyword list in the parent node. + * @returns A React element(React.JSX.Element) for editing keyword triggers. + */ function Keywords({ keywords, setKeywords, diff --git a/src/utils/cellStore.ts b/src/utils/cellStore.ts index eb64907..9c14695 100644 --- a/src/utils/cellStore.ts +++ b/src/utils/cellStore.ts @@ -2,12 +2,56 @@ import {useSyncExternalStore} from "react"; type Unsub = () => void; + +/** + * A simple reactive state container that holds a value of type `T` that provides methods to get, set, and subscribe. + */ export type Cell = { + /** + * Returns the current value stored in the cell. + */ get: () => T; + /** + * Updates the cell's value, pass either a direct value or an updater function. + * + * @example + * ```ts + * count.set(5); + * count.set(prev => prev + 1); + * ``` + */ set: (next: T | ((prev: T) => T)) => void; + + /** + * Subscribe to changes in the cell's value, meaning the provided callback is called whenever the value changes. + * Returns an unsubscribe function. + * + * @example + * ```ts + * const unsubscribe = count.subscribe(() => console.log(count.get())); + * // later: + * unsubscribe(); + * ``` + */ subscribe: (callback: () => void) => Unsub; }; +/** + * Creates a new reactive state container (`Cell`) with an initial value. + * + * This function allows you to store and mutate state outside of React, + * while still supporting subscriptions for reactivity. + * + * @param initial - The initial value for the cell. + * @returns A Cell object with `get`, `set`, and `subscribe` methods. + * + * @example + * ```ts + * const count = cell(0); + * count.set(10); + * console.log(count.get()); // 10 + * ``` + */ export function cell(initial: T): Cell { let value = initial; const listeners = new Set<() => void>(); @@ -24,6 +68,29 @@ export function cell(initial: T): Cell { }; } +/** + * React hook that subscribes a component to a Cell. + * + * Automatically re-renders the component whenever the Cell's value changes. + * Uses React’s built-in `useSyncExternalStore` for correct subscription behavior. + * + * @param c - The cell to subscribe to. + * @returns The current value of the cell. + * + * @example + * ```tsx + * const count = cell(0); + * + * function Counter() { + * const value = useCell(count); + * return ( + * + * ); + * } + * ``` + */ export function useCell(c: Cell) { return useSyncExternalStore(c.subscribe, c.get, c.get); }