@tangle-network/ui 1.0.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.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +33 -0
- package/dist/active-sessions-store-CeOmXgv5.d.ts +85 -0
- package/dist/artifact-pane-DvJyPWV4.d.ts +24 -0
- package/dist/auth.d.ts +74 -0
- package/dist/auth.js +15 -0
- package/dist/button-CMQuQEW_.d.ts +17 -0
- package/dist/chat.d.ts +232 -0
- package/dist/chat.js +30 -0
- package/dist/chunk-2NFQRQOD.js +1009 -0
- package/dist/chunk-2VH6PUXD.js +186 -0
- package/dist/chunk-34A66VBG.js +214 -0
- package/dist/chunk-3OI2QKFD.js +0 -0
- package/dist/chunk-4CLN43XT.js +45 -0
- package/dist/chunk-54SQQMMM.js +156 -0
- package/dist/chunk-5Z5ZYMOJ.js +0 -0
- package/dist/chunk-66BNMOVT.js +167 -0
- package/dist/chunk-6BGQA4BQ.js +0 -0
- package/dist/chunk-7UO2ZMRQ.js +133 -0
- package/dist/chunk-BX6AQMUS.js +183 -0
- package/dist/chunk-CD53GZOM.js +59 -0
- package/dist/chunk-CSAIKY36.js +54 -0
- package/dist/chunk-EEE55AVS.js +1201 -0
- package/dist/chunk-GYPQXTJU.js +230 -0
- package/dist/chunk-HFL6R6IF.js +37 -0
- package/dist/chunk-HJKCSXCH.js +737 -0
- package/dist/chunk-LISXUB4D.js +1222 -0
- package/dist/chunk-LQS34IGP.js +0 -0
- package/dist/chunk-MKTSMWVD.js +109 -0
- package/dist/chunk-NKDZ7GZE.js +192 -0
- package/dist/chunk-OEX7NZE3.js +321 -0
- package/dist/chunk-Q56BYXQF.js +61 -0
- package/dist/chunk-Q7EIIWTC.js +0 -0
- package/dist/chunk-REJESC5U.js +117 -0
- package/dist/chunk-RQGKSCEZ.js +0 -0
- package/dist/chunk-RQHJBTEU.js +10 -0
- package/dist/chunk-TMFOPHHN.js +299 -0
- package/dist/chunk-XGKULLYE.js +40 -0
- package/dist/chunk-XIHMJ7ZQ.js +614 -0
- package/dist/chunk-YJ2G3XO5.js +1048 -0
- package/dist/chunk-YNN4O57I.js +754 -0
- package/dist/code-block-DjXf8eOG.d.ts +19 -0
- package/dist/document-editor-pane-A5LT5H4N.js +12 -0
- package/dist/document-editor-pane-DyDEX_Zm.d.ts +124 -0
- package/dist/editor.d.ts +120 -0
- package/dist/editor.js +34 -0
- package/dist/files.d.ts +175 -0
- package/dist/files.js +20 -0
- package/dist/hooks.d.ts +56 -0
- package/dist/hooks.js +41 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +446 -0
- package/dist/markdown.d.ts +15 -0
- package/dist/markdown.js +14 -0
- package/dist/message-BHWbxBtT.d.ts +15 -0
- package/dist/openui.d.ts +115 -0
- package/dist/openui.js +12 -0
- package/dist/parts-dj7AcUg0.d.ts +36 -0
- package/dist/primitives.d.ts +332 -0
- package/dist/primitives.js +191 -0
- package/dist/run-PfLmDAox.d.ts +41 -0
- package/dist/run.d.ts +69 -0
- package/dist/run.js +36 -0
- package/dist/sdk-hooks.d.ts +285 -0
- package/dist/sdk-hooks.js +31 -0
- package/dist/stores.d.ts +17 -0
- package/dist/stores.js +76 -0
- package/dist/tool-call-feed-Bs3MyQMT.d.ts +68 -0
- package/dist/tool-display-z4JcDmMQ.d.ts +32 -0
- package/dist/tool-previews.d.ts +48 -0
- package/dist/tool-previews.js +21 -0
- package/dist/types.d.ts +19 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +45 -0
- package/dist/utils.js +32 -0
- package/package.json +193 -0
- package/src/auth/auth.tsx +228 -0
- package/src/auth/index.ts +13 -0
- package/src/auth/login-layout.tsx +46 -0
- package/src/chat/agent-timeline.stories.tsx +429 -0
- package/src/chat/agent-timeline.tsx +360 -0
- package/src/chat/chat-container.tsx +486 -0
- package/src/chat/chat-input.stories.tsx +142 -0
- package/src/chat/chat-input.tsx +389 -0
- package/src/chat/chat-message.stories.tsx +237 -0
- package/src/chat/chat-message.tsx +129 -0
- package/src/chat/index.ts +18 -0
- package/src/chat/message-list.stories.tsx +336 -0
- package/src/chat/message-list.tsx +79 -0
- package/src/chat/thinking-indicator.stories.tsx +56 -0
- package/src/chat/thinking-indicator.tsx +30 -0
- package/src/chat/user-message.stories.tsx +92 -0
- package/src/chat/user-message.tsx +43 -0
- package/src/editor/document-editor-pane.tsx +351 -0
- package/src/editor/editor-provider.tsx +428 -0
- package/src/editor/editor-toolbar.tsx +130 -0
- package/src/editor/index.ts +31 -0
- package/src/editor/markdown-conversion.ts +21 -0
- package/src/editor/markdown-document-editor.tsx +137 -0
- package/src/editor/tiptap-editor.tsx +331 -0
- package/src/editor/use-editor.ts +221 -0
- package/src/files/file-artifact-pane.tsx +183 -0
- package/src/files/file-preview.tsx +342 -0
- package/src/files/file-tabs.tsx +71 -0
- package/src/files/file-tree.tsx +258 -0
- package/src/files/index.ts +17 -0
- package/src/files/rich-file-tree.stories.tsx +104 -0
- package/src/files/rich-file-tree.test.tsx +42 -0
- package/src/files/rich-file-tree.tsx +232 -0
- package/src/hooks/index.ts +10 -0
- package/src/hooks/use-auth.ts +153 -0
- package/src/hooks/use-auto-scroll.ts +59 -0
- package/src/hooks/use-dropdown-menu.ts +40 -0
- package/src/hooks/use-live-time.test.tsx +40 -0
- package/src/hooks/use-live-time.ts +27 -0
- package/src/hooks/use-realtime-session.ts +319 -0
- package/src/hooks/use-run-collapse-state.ts +25 -0
- package/src/hooks/use-run-groups.ts +111 -0
- package/src/hooks/use-sdk-session.ts +575 -0
- package/src/hooks/use-sse-stream.ts +475 -0
- package/src/hooks/use-tool-call-stream.ts +96 -0
- package/src/index.ts +14 -0
- package/src/lib/utils.ts +6 -0
- package/src/markdown/code-block.tsx +198 -0
- package/src/markdown/index.ts +2 -0
- package/src/markdown/markdown.stories.tsx +190 -0
- package/src/markdown/markdown.tsx +62 -0
- package/src/openui/index.ts +20 -0
- package/src/openui/openui-artifact-renderer.tsx +542 -0
- package/src/primitives/artifact-pane.tsx +91 -0
- package/src/primitives/avatar.stories.tsx +95 -0
- package/src/primitives/avatar.tsx +47 -0
- package/src/primitives/badge.stories.tsx +57 -0
- package/src/primitives/badge.tsx +97 -0
- package/src/primitives/button.stories.tsx +48 -0
- package/src/primitives/button.tsx +115 -0
- package/src/primitives/card.stories.tsx +53 -0
- package/src/primitives/card.tsx +98 -0
- package/src/primitives/code-block.stories.tsx +115 -0
- package/src/primitives/code-block.tsx +22 -0
- package/src/primitives/design-tokens.stories.tsx +162 -0
- package/src/primitives/dialog.stories.tsx +176 -0
- package/src/primitives/dialog.tsx +137 -0
- package/src/primitives/drop-zone.stories.tsx +123 -0
- package/src/primitives/drop-zone.tsx +131 -0
- package/src/primitives/dropdown-menu.stories.tsx +122 -0
- package/src/primitives/dropdown-menu.tsx +214 -0
- package/src/primitives/empty-state.stories.tsx +81 -0
- package/src/primitives/empty-state.tsx +40 -0
- package/src/primitives/index.ts +118 -0
- package/src/primitives/input.stories.tsx +113 -0
- package/src/primitives/input.tsx +136 -0
- package/src/primitives/label.stories.tsx +84 -0
- package/src/primitives/label.tsx +24 -0
- package/src/primitives/progress.stories.tsx +93 -0
- package/src/primitives/progress.tsx +50 -0
- package/src/primitives/segmented-control.test.tsx +328 -0
- package/src/primitives/segmented-control.tsx +154 -0
- package/src/primitives/select.stories.tsx +164 -0
- package/src/primitives/select.tsx +158 -0
- package/src/primitives/sidebar-drop-zone.stories.tsx +100 -0
- package/src/primitives/sidebar-drop-zone.tsx +149 -0
- package/src/primitives/skeleton.stories.tsx +79 -0
- package/src/primitives/skeleton.tsx +55 -0
- package/src/primitives/stat-card.stories.tsx +137 -0
- package/src/primitives/stat-card.tsx +97 -0
- package/src/primitives/switch.stories.tsx +85 -0
- package/src/primitives/switch.tsx +28 -0
- package/src/primitives/table.stories.tsx +170 -0
- package/src/primitives/table.tsx +116 -0
- package/src/primitives/tabs.stories.tsx +180 -0
- package/src/primitives/tabs.tsx +71 -0
- package/src/primitives/terminal-display.stories.tsx +191 -0
- package/src/primitives/terminal-display.tsx +189 -0
- package/src/primitives/theme-toggle.stories.tsx +32 -0
- package/src/primitives/theme-toggle.tsx +96 -0
- package/src/primitives/toast.stories.tsx +155 -0
- package/src/primitives/toast.tsx +190 -0
- package/src/primitives/upload-progress.stories.tsx +120 -0
- package/src/primitives/upload-progress.tsx +110 -0
- package/src/run/expanded-tool-detail.stories.tsx +182 -0
- package/src/run/expanded-tool-detail.tsx +186 -0
- package/src/run/index.ts +13 -0
- package/src/run/inline-thinking-item.stories.tsx +136 -0
- package/src/run/inline-thinking-item.tsx +120 -0
- package/src/run/inline-tool-item.stories.tsx +222 -0
- package/src/run/inline-tool-item.tsx +190 -0
- package/src/run/run-group.stories.tsx +322 -0
- package/src/run/run-group.tsx +569 -0
- package/src/run/run-item-primitives.tsx +17 -0
- package/src/run/tool-call-feed.stories.tsx +294 -0
- package/src/run/tool-call-feed.tsx +192 -0
- package/src/run/tool-call-step.stories.tsx +198 -0
- package/src/run/tool-call-step.tsx +240 -0
- package/src/sdk-hooks.ts +38 -0
- package/src/stores/active-sessions-store.ts +455 -0
- package/src/stores/chat-store.ts +43 -0
- package/src/stores/index.ts +2 -0
- package/src/tool-previews/command-preview.tsx +116 -0
- package/src/tool-previews/diff-preview.tsx +85 -0
- package/src/tool-previews/glob-results-preview.tsx +98 -0
- package/src/tool-previews/grep-results-preview.tsx +157 -0
- package/src/tool-previews/index.ts +22 -0
- package/src/tool-previews/preview-primitives.tsx +84 -0
- package/src/tool-previews/question-preview.tsx +101 -0
- package/src/tool-previews/web-search-preview.tsx +117 -0
- package/src/tool-previews/write-file-preview.tsx +80 -0
- package/src/types/branding.ts +11 -0
- package/src/types/index.ts +5 -0
- package/src/types/message.ts +13 -0
- package/src/types/parts.ts +51 -0
- package/src/types/run.ts +56 -0
- package/src/types/tool-display.ts +41 -0
- package/src/utils/copy-text.ts +30 -0
- package/src/utils/format.test.ts +43 -0
- package/src/utils/format.ts +56 -0
- package/src/utils/index.ts +10 -0
- package/src/utils/time-ago.ts +9 -0
- package/src/utils/tool-display.ts +238 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
export interface AuthUser {
|
|
6
|
+
customer_id: string;
|
|
7
|
+
email: string;
|
|
8
|
+
name?: string;
|
|
9
|
+
tier: string;
|
|
10
|
+
github?: {
|
|
11
|
+
login: string;
|
|
12
|
+
connected: boolean;
|
|
13
|
+
} | null;
|
|
14
|
+
session_expires_at?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UseAuthOptions {
|
|
18
|
+
apiBaseUrl: string;
|
|
19
|
+
revalidateOnFocus?: boolean;
|
|
20
|
+
shouldRetryOnError?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface UseAuthResult {
|
|
24
|
+
user: AuthUser | null;
|
|
25
|
+
isLoading: boolean;
|
|
26
|
+
isError: boolean;
|
|
27
|
+
error: Error | null;
|
|
28
|
+
mutate: () => Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Hook for managing authentication state.
|
|
33
|
+
* Fetches user session from the API and provides loading/error states.
|
|
34
|
+
*/
|
|
35
|
+
export function useAuth({
|
|
36
|
+
apiBaseUrl,
|
|
37
|
+
revalidateOnFocus = false,
|
|
38
|
+
shouldRetryOnError = false,
|
|
39
|
+
}: UseAuthOptions): UseAuthResult {
|
|
40
|
+
const [user, setUser] = React.useState<AuthUser | null>(null);
|
|
41
|
+
const [isLoading, setIsLoading] = React.useState(true);
|
|
42
|
+
const [error, setError] = React.useState<Error | null>(null);
|
|
43
|
+
const retryTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
44
|
+
const abortRef = React.useRef<AbortController | null>(null);
|
|
45
|
+
|
|
46
|
+
const fetchSession = React.useCallback(async () => {
|
|
47
|
+
abortRef.current?.abort();
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
abortRef.current = controller;
|
|
50
|
+
|
|
51
|
+
setIsLoading(true);
|
|
52
|
+
setError(null);
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch(`${apiBaseUrl}/auth/session`, {
|
|
56
|
+
credentials: "include",
|
|
57
|
+
signal: controller.signal,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
throw new Error("Not authenticated");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const data = await res.json();
|
|
65
|
+
if (data.success && data.data) {
|
|
66
|
+
setUser(data.data);
|
|
67
|
+
} else {
|
|
68
|
+
setUser(null);
|
|
69
|
+
}
|
|
70
|
+
} catch (err) {
|
|
71
|
+
if ((err as Error).name === "AbortError") return;
|
|
72
|
+
setError(err instanceof Error ? err : new Error("Unknown error"));
|
|
73
|
+
setUser(null);
|
|
74
|
+
|
|
75
|
+
if (shouldRetryOnError) {
|
|
76
|
+
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
|
77
|
+
retryTimerRef.current = setTimeout(fetchSession, 5000);
|
|
78
|
+
}
|
|
79
|
+
} finally {
|
|
80
|
+
if (!controller.signal.aborted) {
|
|
81
|
+
setIsLoading(false);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}, [apiBaseUrl, shouldRetryOnError]);
|
|
85
|
+
|
|
86
|
+
React.useEffect(() => {
|
|
87
|
+
fetchSession();
|
|
88
|
+
return () => {
|
|
89
|
+
abortRef.current?.abort();
|
|
90
|
+
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
|
91
|
+
};
|
|
92
|
+
}, [fetchSession]);
|
|
93
|
+
|
|
94
|
+
React.useEffect(() => {
|
|
95
|
+
if (!revalidateOnFocus) return;
|
|
96
|
+
|
|
97
|
+
const handleFocus = () => {
|
|
98
|
+
fetchSession();
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
window.addEventListener("focus", handleFocus);
|
|
102
|
+
return () => window.removeEventListener("focus", handleFocus);
|
|
103
|
+
}, [revalidateOnFocus, fetchSession]);
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
user,
|
|
107
|
+
isLoading,
|
|
108
|
+
isError: !!error,
|
|
109
|
+
error,
|
|
110
|
+
mutate: fetchSession,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Creates a fetcher function that includes auth credentials.
|
|
116
|
+
* Uses both cookie-based session and localStorage API key for backwards compatibility.
|
|
117
|
+
*/
|
|
118
|
+
export function createAuthFetcher(_apiBaseUrl: string) {
|
|
119
|
+
return async function authFetcher<T = unknown>(
|
|
120
|
+
url: string,
|
|
121
|
+
options?: RequestInit,
|
|
122
|
+
): Promise<T> {
|
|
123
|
+
const res = await fetch(url, {
|
|
124
|
+
...options,
|
|
125
|
+
credentials: "include",
|
|
126
|
+
headers: {
|
|
127
|
+
...options?.headers,
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (!res.ok) {
|
|
132
|
+
throw new Error(`Request failed with status ${res.status}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return res.json();
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Hook to get the API key from localStorage.
|
|
141
|
+
* For backwards compatibility with API key-based auth.
|
|
142
|
+
*/
|
|
143
|
+
export function useApiKey(): string | null {
|
|
144
|
+
const [apiKey, setApiKey] = React.useState<string | null>(null);
|
|
145
|
+
|
|
146
|
+
React.useEffect(() => {
|
|
147
|
+
if (typeof window !== "undefined") {
|
|
148
|
+
setApiKey(localStorage.getItem("apiKey"));
|
|
149
|
+
}
|
|
150
|
+
}, []);
|
|
151
|
+
|
|
152
|
+
return apiKey;
|
|
153
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
type RefObject,
|
|
7
|
+
} from "react";
|
|
8
|
+
|
|
9
|
+
const BOTTOM_THRESHOLD = 40;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Scroll-to-bottom behaviour: sticks to the bottom while streaming,
|
|
13
|
+
* pauses when user scrolls up, resumes when user scrolls back down.
|
|
14
|
+
*/
|
|
15
|
+
export function useAutoScroll(
|
|
16
|
+
containerRef: RefObject<HTMLElement | null>,
|
|
17
|
+
deps: unknown[] = [],
|
|
18
|
+
) {
|
|
19
|
+
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
20
|
+
const userScrolledUp = useRef(false);
|
|
21
|
+
|
|
22
|
+
const checkBottom = useCallback(() => {
|
|
23
|
+
const el = containerRef.current;
|
|
24
|
+
if (!el) return true;
|
|
25
|
+
return el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD;
|
|
26
|
+
}, [containerRef]);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const el = containerRef.current;
|
|
30
|
+
if (!el) return;
|
|
31
|
+
|
|
32
|
+
const onScroll = () => {
|
|
33
|
+
const atBottom = checkBottom();
|
|
34
|
+
setIsAtBottom(atBottom);
|
|
35
|
+
userScrolledUp.current = !atBottom;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
el.addEventListener("scroll", onScroll, { passive: true });
|
|
39
|
+
return () => el.removeEventListener("scroll", onScroll);
|
|
40
|
+
}, [containerRef, checkBottom]);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (userScrolledUp.current) return;
|
|
44
|
+
const el = containerRef.current;
|
|
45
|
+
if (!el) return;
|
|
46
|
+
el.scrollTop = el.scrollHeight;
|
|
47
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
48
|
+
}, deps);
|
|
49
|
+
|
|
50
|
+
const scrollToBottom = useCallback(() => {
|
|
51
|
+
const el = containerRef.current;
|
|
52
|
+
if (!el) return;
|
|
53
|
+
userScrolledUp.current = false;
|
|
54
|
+
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
|
55
|
+
setIsAtBottom(true);
|
|
56
|
+
}, [containerRef]);
|
|
57
|
+
|
|
58
|
+
return { isAtBottom, scrollToBottom };
|
|
59
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
interface UseDropdownMenuOptions {
|
|
4
|
+
closeOnEsc?: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function useDropdownMenu(options?: UseDropdownMenuOptions) {
|
|
8
|
+
const closeOnEsc = options?.closeOnEsc ?? true;
|
|
9
|
+
const [open, setOpen] = useState(false);
|
|
10
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
function handleClick(e: MouseEvent) {
|
|
14
|
+
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
15
|
+
setOpen(false);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (open) {
|
|
19
|
+
document.addEventListener('mousedown', handleClick);
|
|
20
|
+
}
|
|
21
|
+
return () => document.removeEventListener('mousedown', handleClick);
|
|
22
|
+
}, [open]);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!open || !closeOnEsc) return;
|
|
26
|
+
function handleKey(e: KeyboardEvent) {
|
|
27
|
+
if (e.key === 'Escape') setOpen(false);
|
|
28
|
+
}
|
|
29
|
+
document.addEventListener('keydown', handleKey);
|
|
30
|
+
return () => document.removeEventListener('keydown', handleKey);
|
|
31
|
+
}, [open, closeOnEsc]);
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
open,
|
|
35
|
+
setOpen,
|
|
36
|
+
ref,
|
|
37
|
+
toggle: () => setOpen((prev) => !prev),
|
|
38
|
+
close: () => setOpen(false),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
|
2
|
+
import { renderHook, act } from "@testing-library/react";
|
|
3
|
+
import { useLiveTime } from "./use-live-time";
|
|
4
|
+
|
|
5
|
+
describe("useLiveTime", () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.useFakeTimers();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.useRealTimers();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("advances Date.now() snapshot on the given cadence", () => {
|
|
15
|
+
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
|
|
16
|
+
const { result } = renderHook(() => useLiveTime(1000));
|
|
17
|
+
const initial = result.current;
|
|
18
|
+
|
|
19
|
+
act(() => {
|
|
20
|
+
vi.advanceTimersByTime(1000);
|
|
21
|
+
});
|
|
22
|
+
expect(result.current).toBeGreaterThan(initial);
|
|
23
|
+
|
|
24
|
+
act(() => {
|
|
25
|
+
vi.advanceTimersByTime(2000);
|
|
26
|
+
});
|
|
27
|
+
expect(result.current).toBeGreaterThanOrEqual(initial + 3000);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("falls back to a safe cadence when intervalMs is NaN", () => {
|
|
31
|
+
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
|
|
32
|
+
const setIntervalSpy = vi.spyOn(window, "setInterval");
|
|
33
|
+
renderHook(() => useLiveTime(Number.NaN));
|
|
34
|
+
// Must not pass NaN (browsers coerce to 0 and spin a render loop)
|
|
35
|
+
// nor drop below the 100ms floor.
|
|
36
|
+
const [, delay] = setIntervalSpy.mock.calls[0]!;
|
|
37
|
+
expect(Number.isFinite(delay)).toBe(true);
|
|
38
|
+
expect(delay).toBeGreaterThanOrEqual(100);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns `Date.now()` and re-renders every `intervalMs` so derived
|
|
7
|
+
* values like "uptime" tick forward without polling upstream data.
|
|
8
|
+
*/
|
|
9
|
+
export function useLiveTime(intervalMs: number = 1000): number {
|
|
10
|
+
const [now, setNow] = React.useState<number>(() => Date.now());
|
|
11
|
+
|
|
12
|
+
React.useEffect(() => {
|
|
13
|
+
// Guard against NaN/negative: `setInterval(cb, NaN)` coerces to 0
|
|
14
|
+
// in browsers, which would spin a tight render loop.
|
|
15
|
+
const requested =
|
|
16
|
+
Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : 1000;
|
|
17
|
+
const delay = Math.max(requested, 100);
|
|
18
|
+
const id = window.setInterval(() => {
|
|
19
|
+
setNow(Date.now());
|
|
20
|
+
}, delay);
|
|
21
|
+
return () => {
|
|
22
|
+
window.clearInterval(id);
|
|
23
|
+
};
|
|
24
|
+
}, [intervalMs]);
|
|
25
|
+
|
|
26
|
+
return now;
|
|
27
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { createElement, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { SdkSessionEvent } from "./use-sdk-session";
|
|
3
|
+
import {
|
|
4
|
+
bumpActiveSessionActivity,
|
|
5
|
+
registerActiveSession,
|
|
6
|
+
setActiveSessionAttention,
|
|
7
|
+
setActiveSessionConnection,
|
|
8
|
+
setActiveSessionError,
|
|
9
|
+
setActiveSessionRunning,
|
|
10
|
+
setForegroundActiveSession,
|
|
11
|
+
unregisterActiveSession,
|
|
12
|
+
updateActiveSessionMeta,
|
|
13
|
+
type ActiveSessionConnectionState,
|
|
14
|
+
type ActiveSessionTransportMode,
|
|
15
|
+
type RegisterActiveSessionOptions,
|
|
16
|
+
} from "../stores/active-sessions-store";
|
|
17
|
+
|
|
18
|
+
export interface RealtimeSessionOptions extends RegisterActiveSessionOptions {
|
|
19
|
+
connectUrl?: string | null;
|
|
20
|
+
enabled?: boolean;
|
|
21
|
+
foreground?: boolean;
|
|
22
|
+
keepRegistered?: boolean;
|
|
23
|
+
reconnect?: boolean;
|
|
24
|
+
reconnectIntervalMs?: number;
|
|
25
|
+
maxReconnectAttempts?: number;
|
|
26
|
+
transportMode?: ActiveSessionTransportMode;
|
|
27
|
+
onEvent?: (event: SdkSessionEvent) => void;
|
|
28
|
+
onOpen?: () => void;
|
|
29
|
+
onClose?: () => void;
|
|
30
|
+
onError?: (error: Error) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RealtimeSessionState {
|
|
34
|
+
connectionState: ActiveSessionConnectionState;
|
|
35
|
+
lastError: string | null;
|
|
36
|
+
reconnectAttempts: number;
|
|
37
|
+
isConnected: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RealtimeSessionTarget extends RealtimeSessionOptions {
|
|
41
|
+
key?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RealtimeSessionRegistryProps {
|
|
45
|
+
sessions: RealtimeSessionTarget[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseEvent(message: string): SdkSessionEvent | null {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(message) as SdkSessionEvent;
|
|
51
|
+
return typeof parsed?.type === "string" ? parsed : null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function eventTimestamp(event: SdkSessionEvent): number | null {
|
|
58
|
+
const rootTimestamp = (event as { timestamp?: unknown }).timestamp;
|
|
59
|
+
if (typeof rootTimestamp === "number" && Number.isFinite(rootTimestamp)) {
|
|
60
|
+
return rootTimestamp;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const raw = event.data?.timestamp ?? event.data?.ts ?? event.data?.time ?? event.data?.eventAt;
|
|
64
|
+
if (typeof raw === "number" && Number.isFinite(raw)) return raw;
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resolveErrorMessage(event: SdkSessionEvent): string | null {
|
|
69
|
+
const message = event.data?.message;
|
|
70
|
+
return typeof message === "string" && message.length > 0 ? message : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function updateStoreFromEvent(sessionId: string, event: SdkSessionEvent): void {
|
|
74
|
+
const lastEventAt = eventTimestamp(event) ?? Date.now();
|
|
75
|
+
bumpActiveSessionActivity(sessionId, { lastEventAt });
|
|
76
|
+
|
|
77
|
+
if (event.type === "session.run.started") {
|
|
78
|
+
setActiveSessionRunning(sessionId, true, { lastEventAt });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (event.type === "session.run.completed" || event.type === "done" || event.type === "result") {
|
|
83
|
+
setActiveSessionRunning(sessionId, false, { lastEventAt });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (event.type === "session.attention") {
|
|
88
|
+
setActiveSessionAttention(sessionId, true, { lastEventAt });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (event.type === "error" || event.type === "session.run.failed") {
|
|
93
|
+
setActiveSessionError(sessionId, resolveErrorMessage(event) ?? "Session error");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function useRealtimeSession({
|
|
99
|
+
sessionId,
|
|
100
|
+
projectId = null,
|
|
101
|
+
projectLabel,
|
|
102
|
+
title,
|
|
103
|
+
href,
|
|
104
|
+
metadata,
|
|
105
|
+
connectUrl,
|
|
106
|
+
enabled = true,
|
|
107
|
+
foreground = true,
|
|
108
|
+
keepRegistered = true,
|
|
109
|
+
reconnect = true,
|
|
110
|
+
reconnectIntervalMs = 1500,
|
|
111
|
+
maxReconnectAttempts = Infinity,
|
|
112
|
+
transportMode = "websocket",
|
|
113
|
+
onEvent,
|
|
114
|
+
onOpen,
|
|
115
|
+
onClose,
|
|
116
|
+
onError,
|
|
117
|
+
}: RealtimeSessionOptions): RealtimeSessionState {
|
|
118
|
+
const [connectionState, setConnectionState] = useState<ActiveSessionConnectionState>("disconnected");
|
|
119
|
+
const [lastError, setLastError] = useState<string | null>(null);
|
|
120
|
+
const [reconnectAttempts, setReconnectAttempts] = useState(0);
|
|
121
|
+
const socketRef = useRef<WebSocket | null>(null);
|
|
122
|
+
const timerRef = useRef<number | null>(null);
|
|
123
|
+
const reconnectAttemptsRef = useRef(0);
|
|
124
|
+
const shouldReconnectRef = useRef(true);
|
|
125
|
+
|
|
126
|
+
const registration = useMemo<RegisterActiveSessionOptions>(
|
|
127
|
+
() => ({
|
|
128
|
+
sessionId,
|
|
129
|
+
projectId,
|
|
130
|
+
projectLabel,
|
|
131
|
+
title,
|
|
132
|
+
href,
|
|
133
|
+
metadata,
|
|
134
|
+
}),
|
|
135
|
+
[href, metadata, projectId, projectLabel, sessionId, title],
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (!sessionId || !keepRegistered) return undefined;
|
|
140
|
+
registerActiveSession(registration);
|
|
141
|
+
return () => {
|
|
142
|
+
unregisterActiveSession(sessionId);
|
|
143
|
+
};
|
|
144
|
+
}, [keepRegistered, registration, sessionId]);
|
|
145
|
+
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (!sessionId || !keepRegistered) return;
|
|
148
|
+
updateActiveSessionMeta(sessionId, {
|
|
149
|
+
projectId,
|
|
150
|
+
projectLabel,
|
|
151
|
+
title,
|
|
152
|
+
href,
|
|
153
|
+
metadata,
|
|
154
|
+
});
|
|
155
|
+
}, [href, keepRegistered, metadata, projectId, projectLabel, sessionId, title]);
|
|
156
|
+
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (!sessionId || !foreground) return undefined;
|
|
159
|
+
setForegroundActiveSession(sessionId);
|
|
160
|
+
return () => {
|
|
161
|
+
setForegroundActiveSession(null);
|
|
162
|
+
};
|
|
163
|
+
}, [foreground, sessionId]);
|
|
164
|
+
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (!enabled || !sessionId || !connectUrl || typeof window === "undefined") {
|
|
167
|
+
setConnectionState("disconnected");
|
|
168
|
+
if (sessionId) {
|
|
169
|
+
setActiveSessionConnection(sessionId, {
|
|
170
|
+
connectionState: "disconnected",
|
|
171
|
+
reconnectState: "idle",
|
|
172
|
+
transportMode,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
shouldReconnectRef.current = true;
|
|
179
|
+
|
|
180
|
+
const clearReconnectTimer = () => {
|
|
181
|
+
if (timerRef.current != null) {
|
|
182
|
+
window.clearTimeout(timerRef.current);
|
|
183
|
+
timerRef.current = null;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const connect = () => {
|
|
188
|
+
clearReconnectTimer();
|
|
189
|
+
setConnectionState(reconnectAttemptsRef.current > 0 ? "reconnecting" : "connecting");
|
|
190
|
+
setActiveSessionConnection(sessionId, {
|
|
191
|
+
connectionState: reconnectAttemptsRef.current > 0 ? "reconnecting" : "connecting",
|
|
192
|
+
reconnectState: reconnectAttemptsRef.current > 0 ? "reconnecting" : "idle",
|
|
193
|
+
transportMode,
|
|
194
|
+
lastError: null,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const socket = new window.WebSocket(connectUrl);
|
|
198
|
+
socketRef.current = socket;
|
|
199
|
+
|
|
200
|
+
socket.onopen = () => {
|
|
201
|
+
reconnectAttemptsRef.current = 0;
|
|
202
|
+
setReconnectAttempts(0);
|
|
203
|
+
setLastError(null);
|
|
204
|
+
setConnectionState("connected");
|
|
205
|
+
registerActiveSession(registration);
|
|
206
|
+
setActiveSessionConnection(sessionId, {
|
|
207
|
+
connectionState: "connected",
|
|
208
|
+
reconnectState: "idle",
|
|
209
|
+
transportMode,
|
|
210
|
+
lastError: null,
|
|
211
|
+
lastEventAt: Date.now(),
|
|
212
|
+
});
|
|
213
|
+
onOpen?.();
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
socket.onmessage = (message) => {
|
|
217
|
+
const event = parseEvent(message.data);
|
|
218
|
+
if (!event) return;
|
|
219
|
+
registerActiveSession(registration);
|
|
220
|
+
updateStoreFromEvent(sessionId, event);
|
|
221
|
+
onEvent?.(event);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
socket.onerror = () => {
|
|
225
|
+
const nextError = "Realtime session connection error";
|
|
226
|
+
setLastError(nextError);
|
|
227
|
+
setConnectionState("error");
|
|
228
|
+
setActiveSessionConnection(sessionId, {
|
|
229
|
+
connectionState: "error",
|
|
230
|
+
reconnectState: reconnect ? "reconnecting" : "failed",
|
|
231
|
+
transportMode,
|
|
232
|
+
lastError: nextError,
|
|
233
|
+
});
|
|
234
|
+
onError?.(new Error(nextError));
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
socket.onclose = () => {
|
|
238
|
+
socketRef.current = null;
|
|
239
|
+
onClose?.();
|
|
240
|
+
|
|
241
|
+
if (!shouldReconnectRef.current || !reconnect) {
|
|
242
|
+
setConnectionState("disconnected");
|
|
243
|
+
setActiveSessionConnection(sessionId, {
|
|
244
|
+
connectionState: "disconnected",
|
|
245
|
+
reconnectState: "idle",
|
|
246
|
+
transportMode,
|
|
247
|
+
});
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
reconnectAttemptsRef.current += 1;
|
|
252
|
+
setReconnectAttempts(reconnectAttemptsRef.current);
|
|
253
|
+
|
|
254
|
+
if (reconnectAttemptsRef.current > maxReconnectAttempts) {
|
|
255
|
+
setConnectionState("error");
|
|
256
|
+
setActiveSessionConnection(sessionId, {
|
|
257
|
+
connectionState: "error",
|
|
258
|
+
reconnectState: "failed",
|
|
259
|
+
transportMode,
|
|
260
|
+
lastError: "Unable to reconnect to realtime session",
|
|
261
|
+
});
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
setConnectionState("reconnecting");
|
|
266
|
+
timerRef.current = window.setTimeout(connect, reconnectIntervalMs);
|
|
267
|
+
};
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
connect();
|
|
271
|
+
|
|
272
|
+
return () => {
|
|
273
|
+
shouldReconnectRef.current = false;
|
|
274
|
+
clearReconnectTimer();
|
|
275
|
+
const socket = socketRef.current;
|
|
276
|
+
socketRef.current = null;
|
|
277
|
+
if (socket && socket.readyState < WebSocket.CLOSING) {
|
|
278
|
+
socket.close();
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
}, [
|
|
282
|
+
connectUrl,
|
|
283
|
+
enabled,
|
|
284
|
+
maxReconnectAttempts,
|
|
285
|
+
onClose,
|
|
286
|
+
onError,
|
|
287
|
+
onEvent,
|
|
288
|
+
onOpen,
|
|
289
|
+
reconnect,
|
|
290
|
+
reconnectIntervalMs,
|
|
291
|
+
registration,
|
|
292
|
+
sessionId,
|
|
293
|
+
transportMode,
|
|
294
|
+
]);
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
connectionState,
|
|
298
|
+
lastError,
|
|
299
|
+
reconnectAttempts,
|
|
300
|
+
isConnected: connectionState === "connected",
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function RealtimeSessionRegistryItem(props: RealtimeSessionTarget) {
|
|
305
|
+
useRealtimeSession({
|
|
306
|
+
...props,
|
|
307
|
+
foreground: props.foreground ?? false,
|
|
308
|
+
});
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function RealtimeSessionRegistry({ sessions }: RealtimeSessionRegistryProps) {
|
|
313
|
+
return sessions.map((session) =>
|
|
314
|
+
createElement(RealtimeSessionRegistryItem, {
|
|
315
|
+
key: session.key ?? session.sessionId,
|
|
316
|
+
...session,
|
|
317
|
+
}),
|
|
318
|
+
);
|
|
319
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import type { Run } from "../types/run";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Manages per-run collapse state with auto-collapse after run completion.
|
|
6
|
+
*
|
|
7
|
+
* - Runs stay expanded by default
|
|
8
|
+
* - Collapse is manual only
|
|
9
|
+
*/
|
|
10
|
+
export function useRunCollapseState(runs: Run[]) {
|
|
11
|
+
const [collapsedMap, setCollapsedMap] = useState<Record<string, boolean>>({});
|
|
12
|
+
|
|
13
|
+
const isCollapsed = useCallback(
|
|
14
|
+
(runId: string): boolean => {
|
|
15
|
+
return collapsedMap[runId] ?? false;
|
|
16
|
+
},
|
|
17
|
+
[collapsedMap],
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const toggleCollapse = useCallback((runId: string) => {
|
|
21
|
+
setCollapsedMap((prev) => ({ ...prev, [runId]: !prev[runId] }));
|
|
22
|
+
}, []);
|
|
23
|
+
|
|
24
|
+
return { isCollapsed, toggleCollapse };
|
|
25
|
+
}
|