@digitornai/sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ /** Result of a {@link useCompile} run. */
2
+ export interface CompileResult {
3
+ /** True when the build command exited 0. */
4
+ ok: boolean;
5
+ /** Tail of the build log (stdout+stderr), bounded by the daemon. */
6
+ log: string;
7
+ /** True when the build hit the daemon's time budget. */
8
+ timedOut: boolean;
9
+ }
10
+ /**
11
+ * Rebuild the current session's document without an agent turn.
12
+ *
13
+ * The app owns the button (via {@link useTopBarActions}); this hook owns the
14
+ * action. It POSTs to the session's build endpoint, which runs the command the
15
+ * APP DECLARED in its manifest (`runtime.preview_compile`) inside the workdir,
16
+ * with the app's provisioned requirements on PATH (e.g. `tectonic`). The
17
+ * command is author-defined and never travels from here, so the iframe cannot
18
+ * ask the host to run something arbitrary. The existing preview watcher shows
19
+ * the fresh output.
20
+ *
21
+ * ```tsx
22
+ * const { compile, compiling } = useCompile();
23
+ * useTopBarActions([
24
+ * { id: "recompile", label: "Recompile", icon: "hammer", onClick: () => void compile() },
25
+ * ]);
26
+ * ```
27
+ */
28
+ export declare function useCompile(): {
29
+ compile: () => Promise<CompileResult>;
30
+ compiling: boolean;
31
+ };
@@ -0,0 +1,48 @@
1
+ import * as React from "react";
2
+ import { useDigitorn } from "./provider.js";
3
+ import { useWorkspaceEndpoint } from "./workspace.js";
4
+ /**
5
+ * Rebuild the current session's document without an agent turn.
6
+ *
7
+ * The app owns the button (via {@link useTopBarActions}); this hook owns the
8
+ * action. It POSTs to the session's build endpoint, which runs the command the
9
+ * APP DECLARED in its manifest (`runtime.preview_compile`) inside the workdir,
10
+ * with the app's provisioned requirements on PATH (e.g. `tectonic`). The
11
+ * command is author-defined and never travels from here, so the iframe cannot
12
+ * ask the host to run something arbitrary. The existing preview watcher shows
13
+ * the fresh output.
14
+ *
15
+ * ```tsx
16
+ * const { compile, compiling } = useCompile();
17
+ * useTopBarActions([
18
+ * { id: "recompile", label: "Recompile", icon: "hammer", onClick: () => void compile() },
19
+ * ]);
20
+ * ```
21
+ */
22
+ export function useCompile() {
23
+ const { root, headers, preview } = useWorkspaceEndpoint();
24
+ const { session } = useDigitorn();
25
+ const [compiling, setCompiling] = React.useState(false);
26
+ const compile = React.useCallback(async () => {
27
+ setCompiling(true);
28
+ try {
29
+ const url = preview
30
+ ? `${root}/preview/compile?t=${encodeURIComponent(session.previewToken)}`
31
+ : `${root}/preview/compile`;
32
+ const res = await fetch(url, { method: "POST", headers });
33
+ const body = (await res.json().catch(() => ({})));
34
+ return {
35
+ ok: body?.ok === true,
36
+ log: typeof body?.log === "string" ? body.log : "",
37
+ timedOut: body?.timed_out === true,
38
+ };
39
+ }
40
+ catch (e) {
41
+ return { ok: false, log: String(e?.message ?? e), timedOut: false };
42
+ }
43
+ finally {
44
+ setCompiling(false);
45
+ }
46
+ }, [root, headers, preview, session.previewToken]);
47
+ return { compile, compiling };
48
+ }
@@ -0,0 +1,11 @@
1
+ import { type Socket } from "socket.io-client";
2
+ import type { SessionInfo, Envelope } from "./types.js";
3
+ import type { Action } from "./reducer.js";
4
+ type Dispatch = (action: Action) => void;
5
+ export declare function createConnection(session: SessionInfo, dispatch: Dispatch, seqRef: {
6
+ current: number;
7
+ }, onEnvelope?: (env: Envelope) => void): Socket;
8
+ export declare function emitSendMessage(socket: Socket | null, sessionId: string, text: string, correlationId: string): void;
9
+ export declare function emitAbort(socket: Socket | null, sessionId: string): void;
10
+ export declare function emitResolveApproval(socket: Socket | null, sessionId: string, id: string, approved: boolean, reason?: string): void;
11
+ export {};
@@ -0,0 +1,89 @@
1
+ import { io } from "socket.io-client";
2
+ export function createConnection(session, dispatch, seqRef, onEnvelope) {
3
+ let url = `${session.baseUrl}/events`;
4
+ if (session.token)
5
+ url += `?token=${encodeURIComponent(session.token)}`;
6
+ // The embedded preview can't present a usable JWT, so it authenticates its
7
+ // socket with the per-session `?t=` preview token - the daemon accepts it and
8
+ // locks the socket to this session's room (read-only). A JWT, when present,
9
+ // is still tried first. This is what lets the preview get live push instead
10
+ // of polling.
11
+ const auth = {};
12
+ if (session.token)
13
+ auth.token = session.token;
14
+ if (session.previewToken) {
15
+ auth.preview_token = session.previewToken;
16
+ auth.app_id = session.appId;
17
+ auth.session_id = session.sessionId;
18
+ }
19
+ const socket = io(url, {
20
+ transports: ["websocket"],
21
+ auth,
22
+ forceNew: true,
23
+ reconnectionDelay: 500,
24
+ reconnectionDelayMax: 5000,
25
+ });
26
+ socket.on("connect", () => {
27
+ dispatch({ type: "connected" });
28
+ socket.emit("join_session", {
29
+ app_id: session.appId,
30
+ session_id: session.sessionId,
31
+ since: seqRef.current,
32
+ });
33
+ });
34
+ socket.on("disconnect", () => dispatch({ type: "disconnected" }));
35
+ socket.on("connect_error", () => dispatch({ type: "disconnected" }));
36
+ socket.on("event", (env) => {
37
+ if (!env || typeof env.type !== "string")
38
+ return;
39
+ if (typeof env.seq === "number" && env.seq > seqRef.current) {
40
+ seqRef.current = env.seq;
41
+ }
42
+ dispatch({ type: "event", env });
43
+ onEnvelope?.(env);
44
+ });
45
+ // The daemon also emits a handful of signals as their OWN named Socket.IO
46
+ // event (not wrapped in the generic "event" envelope) - a build/dev-server
47
+ // becoming reachable is the one apps most want to react to. Folded into
48
+ // the same `state.events` stream so `useEvents(e => e.type === "...")`
49
+ // sees them too, instead of every app re-implementing its own listener.
50
+ // `seq: 0` keeps these out of the replay/resume bookkeeping above - they
51
+ // are live-only notifications, never part of session history.
52
+ socket.on("web_preview:attached", (payload) => {
53
+ // The payload's own `type` field means "static" | "devserver" (the kind
54
+ // of attachment) - spread it BEFORE the envelope's `type`, so the
55
+ // Envelope-level field stays "web_preview:attached" and the payload's
56
+ // own `type` survives as e.g. `env.type_` would - actually keep it
57
+ // under a distinct key so nothing is lost.
58
+ const { type: attachKind, ...rest } = payload;
59
+ const env = {
60
+ seq: 0,
61
+ ts: Date.now(),
62
+ session_id: session.sessionId,
63
+ ...rest,
64
+ attach_kind: attachKind,
65
+ type: "web_preview:attached",
66
+ };
67
+ dispatch({ type: "event", env });
68
+ onEnvelope?.(env);
69
+ });
70
+ return socket;
71
+ }
72
+ export function emitSendMessage(socket, sessionId, text, correlationId) {
73
+ socket?.emit("send_message", {
74
+ session_id: sessionId,
75
+ text,
76
+ correlation_id: correlationId,
77
+ });
78
+ }
79
+ export function emitAbort(socket, sessionId) {
80
+ socket?.emit("abort_turn", { session_id: sessionId });
81
+ }
82
+ export function emitResolveApproval(socket, sessionId, id, approved, reason) {
83
+ socket?.emit("resolve_approval", {
84
+ session_id: sessionId,
85
+ id,
86
+ approved,
87
+ reason,
88
+ });
89
+ }
@@ -0,0 +1,24 @@
1
+ import type { ChatMessage, Envelope } from "./types.js";
2
+ export declare function useConnection(): {
3
+ connected: boolean;
4
+ error: string | null;
5
+ };
6
+ export declare function useChat(): {
7
+ messages: ChatMessage[];
8
+ send: (text: string) => void;
9
+ abort: () => void;
10
+ busy: boolean;
11
+ };
12
+ export declare function useStream(): {
13
+ content: string;
14
+ reasoning: string;
15
+ toolCalls: import("./types.js").ToolCall[];
16
+ streaming: boolean;
17
+ };
18
+ export declare function useAgentStatus(): import("./types.js").AgentStatus;
19
+ export declare function useApprovals(): {
20
+ pending: import("./types.js").Approval[];
21
+ approve: (id: string, reason?: string) => void;
22
+ reject: (id: string, reason?: string) => void;
23
+ };
24
+ export declare function useEvents(filter?: (e: Envelope) => boolean): Envelope[];
package/dist/hooks.js ADDED
@@ -0,0 +1,41 @@
1
+ import { useDigitorn } from "./provider.js";
2
+ export function useConnection() {
3
+ const { state } = useDigitorn();
4
+ return { connected: state.connected, error: state.error };
5
+ }
6
+ export function useChat() {
7
+ const { state, send, abort } = useDigitorn();
8
+ return {
9
+ messages: state.messages,
10
+ send,
11
+ abort,
12
+ busy: state.turnActive,
13
+ };
14
+ }
15
+ export function useStream() {
16
+ const { state } = useDigitorn();
17
+ const last = state.messages[state.messages.length - 1];
18
+ const tail = last && last.role === "assistant" ? last : null;
19
+ return {
20
+ content: tail?.content ?? "",
21
+ reasoning: tail?.reasoning ?? "",
22
+ toolCalls: tail?.toolCalls ?? [],
23
+ streaming: !!tail?.streaming,
24
+ };
25
+ }
26
+ export function useAgentStatus() {
27
+ const { state } = useDigitorn();
28
+ return state.status;
29
+ }
30
+ export function useApprovals() {
31
+ const { state, resolveApproval } = useDigitorn();
32
+ return {
33
+ pending: state.approvals,
34
+ approve: (id, reason) => resolveApproval(id, true, reason),
35
+ reject: (id, reason) => resolveApproval(id, false, reason),
36
+ };
37
+ }
38
+ export function useEvents(filter) {
39
+ const { state } = useDigitorn();
40
+ return filter ? state.events.filter(filter) : state.events;
41
+ }
@@ -0,0 +1,13 @@
1
+ export { Digitorn, useDigitorn, useTheme, useThumbnailMode, type DigitornProps, type EventListener, type ThemeInfo, type ThemeMode, } from "./provider.js";
2
+ export { useConnection, useChat, useStream, useAgentStatus, useApprovals, useEvents, } from "./hooks.js";
3
+ export { useWorkspace, useWatch, useFile, useFileJson, useWorkspaceEndpoint, } from "./workspace.js";
4
+ export type { WorkspaceApi, WatchMatch, FileChange, DirEntry, } from "./workspace.js";
5
+ export { useAgentSnapshot, type AgentSnapshotCapture, type UseAgentSnapshotOptions, } from "./agentSnapshot.js";
6
+ export { useSharedDoc, type SharedDocOptions } from "./shared.js";
7
+ export { useTopBarActions, switchView, type TopBarAction } from "./topbar.js";
8
+ export { useCompile, type CompileResult } from "./compile.js";
9
+ export { usePreviewAttach, type PreviewAttach } from "./previewAttach.js";
10
+ export type { State, ChatMessage, ToolCall, Approval, AgentStatus, Envelope, SessionInfo, } from "./types.js";
11
+ export { useAgentAction, useAgentActions, agentTarget, type AgentActionArgs, type AgentActionHandler, type AgentActionMap, } from "./agentAction.js";
12
+ export * from "./agentContext.js";
13
+ export { reportProposals, onReviewCommand, type ReviewCommand, type ProposalsState, } from "./reviewMode.js";
package/dist/index.js ADDED
@@ -0,0 +1,23 @@
1
+ export { Digitorn, useDigitorn, useTheme, useThumbnailMode, } from "./provider.js";
2
+ export { useConnection, useChat, useStream, useAgentStatus, useApprovals, useEvents, } from "./hooks.js";
3
+ // ── Workspace: the universal, unopinionated primitives ────────────────
4
+ // The agent's edits live in the daemon workdir; these hooks let ANY app read /
5
+ // watch / write whatever state it wants (one JSON, a folder, many files).
6
+ export { useWorkspace, useWatch, useFile, useFileJson, useWorkspaceEndpoint, } from "./workspace.js";
7
+ export { useAgentSnapshot, } from "./agentSnapshot.js";
8
+ // Convenience sugar over the above for the common "one JSON file" case.
9
+ export { useSharedDoc } from "./shared.js";
10
+ // Top bar: let a custom workspace view add its own buttons/menus to the
11
+ // host chrome while it's the active tab, and ask the host to switch views.
12
+ export { useTopBarActions, switchView } from "./topbar.js";
13
+ // One-click rebuild of the current document without an agent turn - the app
14
+ // owns the button (useTopBarActions), this owns the action.
15
+ export { useCompile } from "./compile.js";
16
+ // The agent's build/dev-server preview, checked once on mount and kept live -
17
+ // the primitive a Lovable-style view switches itself on.
18
+ export { usePreviewAttach } from "./previewAttach.js";
19
+ // Expose semantic actions to a driving agent (the preview shim's app_action →
20
+ // digitorn:act event). Standalone — no provider required.
21
+ export { useAgentAction, useAgentActions, agentTarget, } from "./agentAction.js";
22
+ export * from "./agentContext.js";
23
+ export { reportProposals, onReviewCommand, } from "./reviewMode.js";
@@ -0,0 +1,37 @@
1
+ export interface PreviewAttach {
2
+ /** Fully authenticated, session-scoped URL - drop it straight into an
3
+ * `<iframe src>`. */
4
+ url: string;
5
+ /** "static" (a built index.html), "devserver" (a running dev server), or
6
+ * "web" (the app's own bundled `web/dist/` UI). */
7
+ kind: "static" | "devserver" | "web";
8
+ }
9
+ /**
10
+ * The agent's build/dev-server preview, kept live - the one primitive a
11
+ * Lovable-style custom view needs to switch itself from "nothing yet" to
12
+ * "here's what the agent built", and keep switching every time a new build
13
+ * lands. `undefined` until something is attached.
14
+ *
15
+ * Checks once on mount (so reopening a session that already has a build
16
+ * shows it immediately, not just future ones) and then follows every live
17
+ * `web_preview:attached` push - no polling either way.
18
+ *
19
+ * ```tsx
20
+ * function LiveBuildPreview() {
21
+ * const attach = usePreviewAttach();
22
+ * if (!attach) return <Welcome />; // your own empty state
23
+ * return <iframe src={attach.url} title="build" />;
24
+ * }
25
+ * ```
26
+ *
27
+ * A view isn't limited to swapping what it renders - the same trigger can
28
+ * drive a full navigation instead:
29
+ *
30
+ * ```tsx
31
+ * const attach = usePreviewAttach();
32
+ * React.useEffect(() => {
33
+ * if (attach) window.location.href = "/built.html";
34
+ * }, [attach]);
35
+ * ```
36
+ */
37
+ export declare function usePreviewAttach(): PreviewAttach | undefined;
@@ -0,0 +1,62 @@
1
+ import * as React from "react";
2
+ import { useDigitorn } from "./provider.js";
3
+ import { useWorkspaceEndpoint } from "./workspace.js";
4
+ /**
5
+ * The agent's build/dev-server preview, kept live - the one primitive a
6
+ * Lovable-style custom view needs to switch itself from "nothing yet" to
7
+ * "here's what the agent built", and keep switching every time a new build
8
+ * lands. `undefined` until something is attached.
9
+ *
10
+ * Checks once on mount (so reopening a session that already has a build
11
+ * shows it immediately, not just future ones) and then follows every live
12
+ * `web_preview:attached` push - no polling either way.
13
+ *
14
+ * ```tsx
15
+ * function LiveBuildPreview() {
16
+ * const attach = usePreviewAttach();
17
+ * if (!attach) return <Welcome />; // your own empty state
18
+ * return <iframe src={attach.url} title="build" />;
19
+ * }
20
+ * ```
21
+ *
22
+ * A view isn't limited to swapping what it renders - the same trigger can
23
+ * drive a full navigation instead:
24
+ *
25
+ * ```tsx
26
+ * const attach = usePreviewAttach();
27
+ * React.useEffect(() => {
28
+ * if (attach) window.location.href = "/built.html";
29
+ * }, [attach]);
30
+ * ```
31
+ */
32
+ export function usePreviewAttach() {
33
+ const { session, onEvent } = useDigitorn();
34
+ const { root, headers } = useWorkspaceEndpoint();
35
+ const [attach, setAttach] = React.useState(undefined);
36
+ React.useEffect(() => {
37
+ if (!session.sessionId)
38
+ return;
39
+ let cancelled = false;
40
+ const url = session.previewToken
41
+ ? `${root}/preview/web-preview?t=${encodeURIComponent(session.previewToken)}`
42
+ : `${session.baseUrl}/api/apps/${encodeURIComponent(session.appId)}/web-preview?session_id=${encodeURIComponent(session.sessionId)}`;
43
+ fetch(url, { headers })
44
+ .then((r) => r.json())
45
+ .then((d) => {
46
+ if (cancelled || !d.attached || !d.url)
47
+ return;
48
+ setAttach({ url: d.url, kind: d.type ?? "static" });
49
+ })
50
+ .catch(() => { });
51
+ return () => {
52
+ cancelled = true;
53
+ };
54
+ }, [session.sessionId, session.previewToken, root, headers]);
55
+ React.useEffect(() => onEvent((env) => {
56
+ const e = env;
57
+ if (e.type !== "web_preview:attached" || !e.url)
58
+ return;
59
+ setAttach({ url: e.url, kind: e.attach_kind ?? "static" });
60
+ }), [onEvent]);
61
+ return attach;
62
+ }
@@ -0,0 +1,48 @@
1
+ import * as React from "react";
2
+ import { type State, type SessionInfo, type Envelope } from "./types.js";
3
+ /** Subscribe to raw event envelopes as they arrive. Returns an unsubscribe. */
4
+ export type EventListener = (env: Envelope) => void;
5
+ /** Light/dark the host is currently showing (resolved from its "auto"). */
6
+ export type ThemeMode = "light" | "dark";
7
+ /** The host theme, mirrored into the preview so an app can match it. */
8
+ export type ThemeInfo = {
9
+ /** Resolved light or dark - the value an app should adapt to. */
10
+ mode: ThemeMode;
11
+ /** Host accent color, when provided (else null). */
12
+ accent: string | null;
13
+ };
14
+ type DigitornContextValue = {
15
+ state: State;
16
+ session: SessionInfo;
17
+ theme: ThemeInfo;
18
+ /** True when the app is being rendered for a GALLERY THUMBNAIL capture
19
+ * (the host loads the preview URL with `thumbnail=1`). Apps should honor it
20
+ * by rendering their content clean and fit-to-view: no chrome/toolbars, no
21
+ * interaction affordances, zoomed to the content. A generic primitive - every
22
+ * embedded app reads the same flag, nothing app-specific lives in the host. */
23
+ thumbnail: boolean;
24
+ send: (text: string) => void;
25
+ abort: () => void;
26
+ resolveApproval: (id: string, approved: boolean, reason?: string) => void;
27
+ onEvent: (listener: EventListener) => () => void;
28
+ };
29
+ export type DigitornProps = Partial<SessionInfo> & {
30
+ children: React.ReactNode;
31
+ };
32
+ export declare function Digitorn({ children, ...rest }: DigitornProps): React.JSX.Element;
33
+ export declare function useDigitorn(): DigitornContextValue;
34
+ /**
35
+ * The host theme, mirrored into the preview and kept live. `mode` is "light" or
36
+ * "dark"; the SDK also sets `data-theme` on the document root, so most apps need
37
+ * only CSS (`:root[data-theme="dark"] { … }`) and never call this. Reach for it
38
+ * when an app has its OWN theming API to drive (e.g. Excalidraw's appState.theme).
39
+ */
40
+ export declare function useTheme(): ThemeInfo;
41
+ /**
42
+ * True when the app is being rendered for a gallery-thumbnail capture (preview
43
+ * URL carries `thumbnail=1`). Render clean and fit-to-view - hide chrome/tools,
44
+ * disable interaction, zoom to the content - so the captured JPEG is just the
45
+ * document. A shared primitive: every embedded app reads the same flag.
46
+ */
47
+ export declare function useThumbnailMode(): boolean;
48
+ export {};
@@ -0,0 +1,182 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { createConnection, emitSendMessage, emitAbort, emitResolveApproval, } from "./connect.js";
4
+ import { reduce } from "./reducer.js";
5
+ import { initialState, } from "./types.js";
6
+ const DigitornContext = React.createContext(null);
7
+ // ── Theme ────────────────────────────────────────────────────────────────
8
+ // The preview iframe adopts the HOST's theme automatically, matching the shared
9
+ // postMessage contract (host side: src/lib/preview-postmessage.ts). The host
10
+ // seeds `?theme=` in the iframe URL and posts `digi:theme-change` on every
11
+ // change; the SDK resolves it to light/dark, applies `data-theme` (+ colorScheme
12
+ // + `--digitorn-accent`) on the document, and exposes it via `useTheme()`. Any
13
+ // app can then style off `:root[data-theme="dark"]` with no per-app wiring - a
14
+ // preview should never look out of place against the surrounding app.
15
+ function resolveMode(raw) {
16
+ const t = (raw ?? "").toLowerCase();
17
+ if (t === "dark" || t === "light")
18
+ return t;
19
+ // "auto"/"system"/absent → follow the OS preference.
20
+ return typeof window !== "undefined" &&
21
+ window.matchMedia?.("(prefers-color-scheme: dark)").matches
22
+ ? "dark"
23
+ : "light";
24
+ }
25
+ function initialTheme() {
26
+ if (typeof window === "undefined")
27
+ return { mode: "light", accent: null };
28
+ const q = new URLSearchParams(window.location.search);
29
+ return { mode: resolveMode(q.get("theme") ?? q.get("mode")), accent: q.get("accent") };
30
+ }
31
+ function applyTheme(t) {
32
+ if (typeof document === "undefined")
33
+ return;
34
+ const root = document.documentElement;
35
+ root.dataset.theme = t.mode;
36
+ root.style.colorScheme = t.mode;
37
+ if (t.accent)
38
+ root.style.setProperty("--digitorn-accent", t.accent);
39
+ }
40
+ function resolveSession(props) {
41
+ const q = typeof window !== "undefined"
42
+ ? new URLSearchParams(window.location.search)
43
+ : new URLSearchParams();
44
+ const origin = typeof window !== "undefined" ? window.location.origin : "";
45
+ // The preview iframe is served by the daemon at
46
+ // /api/apps/{app_id}/sessions/{session_id}/preview/serve/...
47
+ // so the app + session are recoverable from the path even when the host
48
+ // only put session_id + token in the query. This lets the SDK self-bootstrap
49
+ // with zero host wiring beyond the token the preview already carries.
50
+ const path = typeof window !== "undefined" ? window.location.pathname : "";
51
+ const m = path.match(/\/api\/apps\/([^/]+)\/sessions\/([^/]+)/);
52
+ const pathApp = m?.[1] ? decodeURIComponent(m[1]) : "";
53
+ const pathSid = m?.[2] ? decodeURIComponent(m[2]) : "";
54
+ return {
55
+ baseUrl: props.baseUrl ?? q.get("daemon") ?? origin,
56
+ appId: props.appId ?? q.get("app") ?? q.get("app_id") ?? pathApp,
57
+ sessionId: props.sessionId ?? q.get("session") ?? q.get("session_id") ?? pathSid,
58
+ token: props.token ?? q.get("token") ?? undefined,
59
+ previewToken: props.previewToken ?? q.get("t") ?? undefined,
60
+ };
61
+ }
62
+ /** Read the gallery-thumbnail capture flag from the preview URL (`thumbnail=1`). */
63
+ function resolveThumbnail() {
64
+ if (typeof window === "undefined")
65
+ return false;
66
+ const v = new URLSearchParams(window.location.search).get("thumbnail");
67
+ return v === "1" || v === "true";
68
+ }
69
+ function newCorrelationId() {
70
+ try {
71
+ return crypto.randomUUID();
72
+ }
73
+ catch {
74
+ return `c-${Date.now()}-${Math.random().toString(36).slice(2)}`;
75
+ }
76
+ }
77
+ export function Digitorn({ children, ...rest }) {
78
+ const session = React.useMemo(() => resolveSession(rest), []);
79
+ const thumbnail = React.useMemo(() => resolveThumbnail(), []);
80
+ const [state, dispatch] = React.useReducer(reduce, initialState);
81
+ const socketRef = React.useRef(null);
82
+ const seqRef = React.useRef(0);
83
+ const listenersRef = React.useRef(new Set());
84
+ // Theme: seed from the URL / OS, then follow the host live via the shared
85
+ // `digi:theme-change` postMessage (contract: src/lib/preview-postmessage.ts).
86
+ const [theme, setTheme] = React.useState(initialTheme);
87
+ React.useEffect(() => {
88
+ applyTheme(theme);
89
+ }, [theme]);
90
+ React.useEffect(() => {
91
+ if (typeof window === "undefined")
92
+ return;
93
+ // Tell the host we're ready to receive pushes; it re-sends the current
94
+ // theme (and clears its loading state) on `digi:ready`.
95
+ try {
96
+ window.parent?.postMessage({ type: "digi:ready" }, "*");
97
+ }
98
+ catch {
99
+ /* cross-origin parent may reject - the URL seed already covered us */
100
+ }
101
+ const onMessage = (e) => {
102
+ const d = e.data;
103
+ if (!d || d.type !== "digi:theme-change" || !d.theme)
104
+ return;
105
+ setTheme({ mode: resolveMode(d.theme.mode), accent: d.theme.accent ?? null });
106
+ };
107
+ window.addEventListener("message", onMessage);
108
+ return () => window.removeEventListener("message", onMessage);
109
+ }, []);
110
+ React.useEffect(() => {
111
+ if (!session.sessionId)
112
+ return;
113
+ // Always connect: the host uses its JWT, the embedded preview uses its
114
+ // per-session `?t=` preview token (createConnection puts whichever it has in
115
+ // the socket auth). Either way the daemon joins us to the session room and
116
+ // PUSHES `workspace_changes` - the poll in useWatch only wakes if this
117
+ // socket never connects.
118
+ const socket = createConnection(session, dispatch, seqRef, (env) => {
119
+ for (const l of listenersRef.current) {
120
+ try {
121
+ l(env);
122
+ }
123
+ catch {
124
+ /* a listener throwing must not break the fan-out */
125
+ }
126
+ }
127
+ });
128
+ socketRef.current = socket;
129
+ return () => {
130
+ socket.removeAllListeners();
131
+ socket.disconnect();
132
+ socketRef.current = null;
133
+ };
134
+ }, [session]);
135
+ const onEvent = React.useCallback((listener) => {
136
+ listenersRef.current.add(listener);
137
+ return () => {
138
+ listenersRef.current.delete(listener);
139
+ };
140
+ }, []);
141
+ const send = React.useCallback((text) => {
142
+ const t = text.trim();
143
+ if (!t)
144
+ return;
145
+ const cid = newCorrelationId();
146
+ dispatch({ type: "local_user", content: t, correlationId: cid });
147
+ emitSendMessage(socketRef.current, session.sessionId, t, cid);
148
+ }, [session.sessionId]);
149
+ const abort = React.useCallback(() => {
150
+ emitAbort(socketRef.current, session.sessionId);
151
+ }, [session.sessionId]);
152
+ const resolveApproval = React.useCallback((id, approved, reason) => {
153
+ emitResolveApproval(socketRef.current, session.sessionId, id, approved, reason);
154
+ }, [session.sessionId]);
155
+ const value = React.useMemo(() => ({ state, session, theme, thumbnail, send, abort, resolveApproval, onEvent }), [state, session, theme, thumbnail, send, abort, resolveApproval, onEvent]);
156
+ return (_jsx(DigitornContext.Provider, { value: value, children: children }));
157
+ }
158
+ export function useDigitorn() {
159
+ const ctx = React.useContext(DigitornContext);
160
+ if (!ctx) {
161
+ throw new Error("useDigitorn must be used inside <Digitorn>");
162
+ }
163
+ return ctx;
164
+ }
165
+ /**
166
+ * The host theme, mirrored into the preview and kept live. `mode` is "light" or
167
+ * "dark"; the SDK also sets `data-theme` on the document root, so most apps need
168
+ * only CSS (`:root[data-theme="dark"] { … }`) and never call this. Reach for it
169
+ * when an app has its OWN theming API to drive (e.g. Excalidraw's appState.theme).
170
+ */
171
+ export function useTheme() {
172
+ return useDigitorn().theme;
173
+ }
174
+ /**
175
+ * True when the app is being rendered for a gallery-thumbnail capture (preview
176
+ * URL carries `thumbnail=1`). Render clean and fit-to-view - hide chrome/tools,
177
+ * disable interaction, zoom to the content - so the captured JPEG is just the
178
+ * document. A shared primitive: every embedded app reads the same flag.
179
+ */
180
+ export function useThumbnailMode() {
181
+ return useDigitorn().thumbnail;
182
+ }
@@ -0,0 +1,14 @@
1
+ import type { State, Envelope } from "./types.js";
2
+ export type Action = {
3
+ type: "connected";
4
+ } | {
5
+ type: "disconnected";
6
+ } | {
7
+ type: "local_user";
8
+ content: string;
9
+ correlationId: string;
10
+ } | {
11
+ type: "event";
12
+ env: Envelope;
13
+ };
14
+ export declare function reduce(state: State, action: Action): State;