@agent-relay/dashboard 2.0.80 → 2.0.82

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.
Files changed (244) hide show
  1. package/out/404.html +1 -1
  2. package/out/_next/static/chunks/{118-4c8241b0218335de.js → 118-ae2b650136a5a5fc.js} +1 -1
  3. package/out/_next/static/chunks/407-0c82986cf79c8ecb.js +1 -0
  4. package/out/_next/static/chunks/app/app/[[...slug]]/{page-1e81c047cff17212.js → page-f7eca1b66fb4249b.js} +1 -1
  5. package/out/_next/static/chunks/app/{page-6892fe2dd07fb48b.js → page-0ee604f7070d14c0.js} +1 -1
  6. package/out/_next/static/css/8968d98ed4c4d33f.css +1 -0
  7. package/out/about.html +2 -2
  8. package/out/about.txt +1 -1
  9. package/out/app/onboarding.html +1 -1
  10. package/out/app/onboarding.txt +1 -1
  11. package/out/app.html +1 -1
  12. package/out/app.txt +2 -2
  13. package/out/blog/go-to-bed-wake-up-to-a-finished-product.html +2 -2
  14. package/out/blog/go-to-bed-wake-up-to-a-finished-product.txt +1 -1
  15. package/out/blog/let-them-cook-multi-agent-orchestration.html +2 -2
  16. package/out/blog/let-them-cook-multi-agent-orchestration.txt +2 -2
  17. package/out/blog.html +2 -2
  18. package/out/blog.txt +1 -1
  19. package/out/careers.html +2 -2
  20. package/out/careers.txt +1 -1
  21. package/out/changelog.html +2 -2
  22. package/out/changelog.txt +1 -1
  23. package/out/cloud/link.html +1 -1
  24. package/out/cloud/link.txt +2 -2
  25. package/out/complete-profile.html +2 -2
  26. package/out/complete-profile.txt +1 -1
  27. package/out/connect-repos.html +1 -1
  28. package/out/connect-repos.txt +1 -1
  29. package/out/contact.html +2 -2
  30. package/out/contact.txt +1 -1
  31. package/out/docs.html +2 -2
  32. package/out/docs.txt +1 -1
  33. package/out/history.html +1 -1
  34. package/out/history.txt +2 -2
  35. package/out/index.html +1 -1
  36. package/out/index.txt +2 -2
  37. package/out/login.html +2 -2
  38. package/out/login.txt +1 -1
  39. package/out/metrics.html +1 -1
  40. package/out/metrics.txt +2 -2
  41. package/out/pricing.html +2 -2
  42. package/out/pricing.txt +1 -1
  43. package/out/privacy.html +2 -2
  44. package/out/privacy.txt +1 -1
  45. package/out/providers/setup/claude.html +1 -1
  46. package/out/providers/setup/claude.txt +1 -1
  47. package/out/providers/setup/codex.html +1 -1
  48. package/out/providers/setup/codex.txt +1 -1
  49. package/out/providers/setup/cursor.html +1 -1
  50. package/out/providers/setup/cursor.txt +1 -1
  51. package/out/providers.html +1 -1
  52. package/out/providers.txt +1 -1
  53. package/out/security.html +2 -2
  54. package/out/security.txt +1 -1
  55. package/out/signup.html +2 -2
  56. package/out/signup.txt +1 -1
  57. package/out/terms.html +2 -2
  58. package/out/terms.txt +1 -1
  59. package/package.json +7 -1
  60. package/src/app/about/page.tsx +7 -0
  61. package/src/app/app/[[...slug]]/DashboardPageClient.tsx +853 -0
  62. package/src/app/app/[[...slug]]/page.tsx +23 -0
  63. package/src/app/app/onboarding/page.tsx +394 -0
  64. package/src/app/apple-icon.png +0 -0
  65. package/src/app/blog/go-to-bed-wake-up-to-a-finished-product/page.tsx +88 -0
  66. package/src/app/blog/let-them-cook-multi-agent-orchestration/page.tsx +93 -0
  67. package/src/app/blog/page.tsx +15 -0
  68. package/src/app/careers/page.tsx +7 -0
  69. package/src/app/changelog/page.tsx +7 -0
  70. package/src/app/cloud/link/page.tsx +464 -0
  71. package/src/app/complete-profile/page.tsx +204 -0
  72. package/src/app/connect-repos/page.tsx +410 -0
  73. package/src/app/contact/page.tsx +7 -0
  74. package/src/app/docs/page.tsx +7 -0
  75. package/src/app/favicon.png +0 -0
  76. package/src/app/globals.css +200 -0
  77. package/src/app/history/page.tsx +658 -0
  78. package/src/app/layout.tsx +25 -0
  79. package/src/app/login/page.tsx +424 -0
  80. package/src/app/metrics/page.tsx +781 -0
  81. package/src/app/page.tsx +59 -0
  82. package/src/app/pricing/page.tsx +7 -0
  83. package/src/app/privacy/page.tsx +7 -0
  84. package/src/app/providers/page.tsx +193 -0
  85. package/src/app/providers/setup/[provider]/ProviderSetupClient.tsx +197 -0
  86. package/src/app/providers/setup/[provider]/constants.ts +35 -0
  87. package/src/app/providers/setup/[provider]/page.tsx +42 -0
  88. package/src/app/security/page.tsx +7 -0
  89. package/src/app/signup/page.tsx +533 -0
  90. package/src/app/terms/page.tsx +7 -0
  91. package/src/components/ActivityFeed.tsx +216 -0
  92. package/src/components/AddWorkspaceModal.tsx +170 -0
  93. package/src/components/AgentCard.test.tsx +134 -0
  94. package/src/components/AgentCard.tsx +585 -0
  95. package/src/components/AgentList.test.tsx +147 -0
  96. package/src/components/AgentList.tsx +419 -0
  97. package/src/components/AgentLogPreview.tsx +173 -0
  98. package/src/components/AgentProfilePanel.tsx +569 -0
  99. package/src/components/App.tsx +3424 -0
  100. package/src/components/BillingPanel.tsx +922 -0
  101. package/src/components/BillingResult.tsx +447 -0
  102. package/src/components/BroadcastComposer.tsx +690 -0
  103. package/src/components/ChannelAdminPanel.tsx +773 -0
  104. package/src/components/ChannelBrowser.tsx +385 -0
  105. package/src/components/ChannelChat.tsx +261 -0
  106. package/src/components/ChannelSidebar.tsx +399 -0
  107. package/src/components/CloudSessionProvider.tsx +130 -0
  108. package/src/components/CommandPalette.tsx +815 -0
  109. package/src/components/ConfirmationDialog.tsx +133 -0
  110. package/src/components/ConversationHistory.tsx +518 -0
  111. package/src/components/CoordinatorPanel.tsx +956 -0
  112. package/src/components/DecisionQueue.tsx +717 -0
  113. package/src/components/DirectMessageView.tsx +164 -0
  114. package/src/components/FileAutocomplete.tsx +368 -0
  115. package/src/components/FleetOverview.tsx +278 -0
  116. package/src/components/LogViewer.tsx +310 -0
  117. package/src/components/LogViewerPanel.tsx +482 -0
  118. package/src/components/Logo.tsx +284 -0
  119. package/src/components/MentionAutocomplete.tsx +384 -0
  120. package/src/components/MessageComposer.tsx +473 -0
  121. package/src/components/MessageList.tsx +725 -0
  122. package/src/components/MessageSenderName.tsx +91 -0
  123. package/src/components/MessageStatusIndicator.tsx +142 -0
  124. package/src/components/NewConversationModal.tsx +400 -0
  125. package/src/components/NotificationToast.tsx +488 -0
  126. package/src/components/OnlineUsersIndicator.tsx +164 -0
  127. package/src/components/Pagination.tsx +124 -0
  128. package/src/components/PricingPlans.tsx +386 -0
  129. package/src/components/ProjectList.tsx +711 -0
  130. package/src/components/ProviderAuthFlow.tsx +343 -0
  131. package/src/components/ProviderConnectionList.tsx +375 -0
  132. package/src/components/ProvisioningProgress.tsx +730 -0
  133. package/src/components/ReactionChips.tsx +70 -0
  134. package/src/components/ReactionPicker.tsx +121 -0
  135. package/src/components/RepoAccessPanel.tsx +787 -0
  136. package/src/components/RepositoriesPanel.tsx +901 -0
  137. package/src/components/ServerCard.tsx +202 -0
  138. package/src/components/SessionExpiredModal.tsx +128 -0
  139. package/src/components/SpawnModal.test.tsx +190 -0
  140. package/src/components/SpawnModal.tsx +1001 -0
  141. package/src/components/TaskAssignmentUI.tsx +375 -0
  142. package/src/components/TerminalProviderSetup.tsx +517 -0
  143. package/src/components/ThemeProvider.tsx +159 -0
  144. package/src/components/ThinkingIndicator.tsx +231 -0
  145. package/src/components/ThreadList.tsx +198 -0
  146. package/src/components/ThreadPanel.tsx +405 -0
  147. package/src/components/TrajectoryViewer.tsx +698 -0
  148. package/src/components/TypingIndicator.tsx +69 -0
  149. package/src/components/UsageBanner.tsx +231 -0
  150. package/src/components/UserProfilePanel.tsx +233 -0
  151. package/src/components/WorkspaceContext.tsx +95 -0
  152. package/src/components/WorkspaceSelector.tsx +234 -0
  153. package/src/components/WorkspaceStatusIndicator.tsx +396 -0
  154. package/src/components/XTermInteractive.tsx +516 -0
  155. package/src/components/XTermLogViewer.tsx +719 -0
  156. package/src/components/channels/ChannelDialogs.tsx +1411 -0
  157. package/src/components/channels/ChannelHeader.tsx +317 -0
  158. package/src/components/channels/ChannelMessageList.tsx +463 -0
  159. package/src/components/channels/ChannelViewV1.tsx +146 -0
  160. package/src/components/channels/MessageInput.tsx +302 -0
  161. package/src/components/channels/SearchInput.tsx +172 -0
  162. package/src/components/channels/SearchResults.tsx +336 -0
  163. package/src/components/channels/api.test.ts +1527 -0
  164. package/src/components/channels/api.ts +703 -0
  165. package/src/components/channels/index.ts +76 -0
  166. package/src/components/channels/mockApi.ts +344 -0
  167. package/src/components/channels/types.ts +566 -0
  168. package/src/components/hooks/index.ts +58 -0
  169. package/src/components/hooks/useAgentLogs.ts +504 -0
  170. package/src/components/hooks/useAgents.ts +127 -0
  171. package/src/components/hooks/useBroadcastDedup.test.ts +371 -0
  172. package/src/components/hooks/useBroadcastDedup.ts +86 -0
  173. package/src/components/hooks/useChannelAdmin.ts +329 -0
  174. package/src/components/hooks/useChannelBrowser.ts +239 -0
  175. package/src/components/hooks/useChannelCommands.ts +138 -0
  176. package/src/components/hooks/useChannels.ts +367 -0
  177. package/src/components/hooks/useDebounce.ts +29 -0
  178. package/src/components/hooks/useDirectMessage.test.ts +952 -0
  179. package/src/components/hooks/useDirectMessage.ts +141 -0
  180. package/src/components/hooks/useMessages.ts +310 -0
  181. package/src/components/hooks/useOrchestrator.test.ts +165 -0
  182. package/src/components/hooks/useOrchestrator.ts +424 -0
  183. package/src/components/hooks/usePinnedAgents.test.ts +356 -0
  184. package/src/components/hooks/usePinnedAgents.ts +140 -0
  185. package/src/components/hooks/usePresence.test.ts +245 -0
  186. package/src/components/hooks/usePresence.ts +377 -0
  187. package/src/components/hooks/useRecentRepos.ts +130 -0
  188. package/src/components/hooks/useSession.ts +209 -0
  189. package/src/components/hooks/useThread.ts +138 -0
  190. package/src/components/hooks/useTrajectory.ts +265 -0
  191. package/src/components/hooks/useWebSocket.ts +290 -0
  192. package/src/components/hooks/useWorkspaceMembers.ts +132 -0
  193. package/src/components/hooks/useWorkspaceRepos.ts +73 -0
  194. package/src/components/hooks/useWorkspaceStatus.ts +237 -0
  195. package/src/components/index.ts +81 -0
  196. package/src/components/layout/Header.tsx +311 -0
  197. package/src/components/layout/RepoContextHeader.tsx +361 -0
  198. package/src/components/layout/Sidebar.archive.test.tsx +126 -0
  199. package/src/components/layout/Sidebar.test.tsx +691 -0
  200. package/src/components/layout/Sidebar.tsx +900 -0
  201. package/src/components/layout/index.ts +7 -0
  202. package/src/components/settings/BillingSettingsPanel.tsx +564 -0
  203. package/src/components/settings/SettingsPage.tsx +683 -0
  204. package/src/components/settings/TeamSettingsPanel.tsx +560 -0
  205. package/src/components/settings/WorkspaceSettingsPanel.tsx +1368 -0
  206. package/src/components/settings/index.ts +11 -0
  207. package/src/components/settings/types.ts +79 -0
  208. package/src/components/utils/messageFormatting.test.tsx +331 -0
  209. package/src/components/utils/messageFormatting.tsx +597 -0
  210. package/src/index.ts +63 -0
  211. package/src/landing/AboutPage.tsx +77 -0
  212. package/src/landing/BlogContent.tsx +187 -0
  213. package/src/landing/BlogPage.tsx +47 -0
  214. package/src/landing/CareersPage.tsx +53 -0
  215. package/src/landing/ChangelogPage.tsx +33 -0
  216. package/src/landing/ContactPage.tsx +41 -0
  217. package/src/landing/DocsPage.tsx +43 -0
  218. package/src/landing/LandingPage.tsx +702 -0
  219. package/src/landing/PricingPage.tsx +549 -0
  220. package/src/landing/PrivacyPage.tsx +117 -0
  221. package/src/landing/SecurityPage.tsx +42 -0
  222. package/src/landing/StaticPage.tsx +165 -0
  223. package/src/landing/TermsPage.tsx +125 -0
  224. package/src/landing/blogData.ts +312 -0
  225. package/src/landing/index.ts +18 -0
  226. package/src/landing/styles.css +3673 -0
  227. package/src/lib/agent-merge.test.ts +43 -0
  228. package/src/lib/agent-merge.ts +35 -0
  229. package/src/lib/api.ts +1294 -0
  230. package/src/lib/cloudApi.ts +893 -0
  231. package/src/lib/colors.test.ts +175 -0
  232. package/src/lib/colors.ts +218 -0
  233. package/src/lib/config.ts +109 -0
  234. package/src/lib/hierarchy.ts +242 -0
  235. package/src/lib/stuckDetection.ts +142 -0
  236. package/src/lib/useUrlRouting.ts +190 -0
  237. package/src/types/index.ts +317 -0
  238. package/src/types/threading.ts +7 -0
  239. package/out/_next/static/chunks/285-dc644487a8d6500d.js +0 -1
  240. package/out/_next/static/css/4c58d9cf493aa626.css +0 -1
  241. /package/out/_next/static/{AqelRhy1vr2nBUcU0Iqcp → IxfA6RZu4trcsEMYlkQra}/_buildManifest.js +0 -0
  242. /package/out/_next/static/{AqelRhy1vr2nBUcU0Iqcp → IxfA6RZu4trcsEMYlkQra}/_ssgManifest.js +0 -0
  243. /package/out/_next/static/chunks/{528-d375bc8b46912d2c.js → 528-f5f676996d613c25.js} +0 -0
  244. /package/out/_next/static/chunks/app/blog/let-them-cook-multi-agent-orchestration/{page-a58308f43557b908.js → page-b194f207fbd91862.js} +0 -0
@@ -0,0 +1,130 @@
1
+ /**
2
+ * useRecentRepos Hook
3
+ *
4
+ * Tracks and persists recently accessed repositories/projects.
5
+ * Stores in localStorage for persistence across sessions.
6
+ */
7
+
8
+ import { useState, useEffect, useCallback } from 'react';
9
+ import type { Project } from '../../types';
10
+
11
+ const STORAGE_KEY = 'relay:recentRepos';
12
+ const MAX_RECENT = 5;
13
+
14
+ export interface RecentRepo {
15
+ id: string;
16
+ path: string;
17
+ name?: string;
18
+ lastAccessed: number;
19
+ }
20
+
21
+ export interface UseRecentReposOptions {
22
+ /** Maximum number of recent repos to track (default: 5) */
23
+ maxRecent?: number;
24
+ }
25
+
26
+ export interface UseRecentReposReturn {
27
+ /** List of recent repos, most recent first */
28
+ recentRepos: RecentRepo[];
29
+ /** Add or update a repo in recent list */
30
+ addRecentRepo: (project: Project) => void;
31
+ /** Remove a repo from recent list */
32
+ removeRecentRepo: (id: string) => void;
33
+ /** Clear all recent repos */
34
+ clearRecentRepos: () => void;
35
+ /** Get recent repos as Project-like objects for display */
36
+ getRecentProjects: (allProjects: Project[]) => Project[];
37
+ }
38
+
39
+ /**
40
+ * Load recent repos from localStorage
41
+ */
42
+ function loadRecentRepos(): RecentRepo[] {
43
+ try {
44
+ const stored = localStorage.getItem(STORAGE_KEY);
45
+ if (!stored) return [];
46
+ const parsed = JSON.parse(stored);
47
+ if (!Array.isArray(parsed)) return [];
48
+ return parsed;
49
+ } catch {
50
+ return [];
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Save recent repos to localStorage
56
+ */
57
+ function saveRecentRepos(repos: RecentRepo[]): void {
58
+ try {
59
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(repos));
60
+ } catch {
61
+ // Silently fail if localStorage is not available
62
+ }
63
+ }
64
+
65
+ export function useRecentRepos(options: UseRecentReposOptions = {}): UseRecentReposReturn {
66
+ const maxRecent = options.maxRecent ?? MAX_RECENT;
67
+ const [recentRepos, setRecentRepos] = useState<RecentRepo[]>([]);
68
+
69
+ // Load from localStorage on mount
70
+ useEffect(() => {
71
+ setRecentRepos(loadRecentRepos());
72
+ }, []);
73
+
74
+ // Add or update a repo in recent list
75
+ const addRecentRepo = useCallback((project: Project) => {
76
+ setRecentRepos((prev) => {
77
+ // Remove if already exists
78
+ const filtered = prev.filter((r) => r.id !== project.id);
79
+
80
+ // Add to front with current timestamp
81
+ const newRepo: RecentRepo = {
82
+ id: project.id,
83
+ path: project.path,
84
+ name: project.name,
85
+ lastAccessed: Date.now(),
86
+ };
87
+
88
+ // Keep only maxRecent items
89
+ const updated = [newRepo, ...filtered].slice(0, maxRecent);
90
+
91
+ // Persist to localStorage
92
+ saveRecentRepos(updated);
93
+
94
+ return updated;
95
+ });
96
+ }, [maxRecent]);
97
+
98
+ // Remove a repo from recent list
99
+ const removeRecentRepo = useCallback((id: string) => {
100
+ setRecentRepos((prev) => {
101
+ const updated = prev.filter((r) => r.id !== id);
102
+ saveRecentRepos(updated);
103
+ return updated;
104
+ });
105
+ }, []);
106
+
107
+ // Clear all recent repos
108
+ const clearRecentRepos = useCallback(() => {
109
+ setRecentRepos([]);
110
+ saveRecentRepos([]);
111
+ }, []);
112
+
113
+ // Get recent repos as Project objects (matched against current projects)
114
+ const getRecentProjects = useCallback((allProjects: Project[]): Project[] => {
115
+ const projectMap = new Map(allProjects.map((p) => [p.id, p]));
116
+ return recentRepos
117
+ .map((r) => projectMap.get(r.id))
118
+ .filter((p): p is Project => p !== undefined);
119
+ }, [recentRepos]);
120
+
121
+ return {
122
+ recentRepos,
123
+ addRecentRepo,
124
+ removeRecentRepo,
125
+ clearRecentRepos,
126
+ getRecentProjects,
127
+ };
128
+ }
129
+
130
+ export default useRecentRepos;
@@ -0,0 +1,209 @@
1
+ /**
2
+ * useSession Hook
3
+ *
4
+ * React hook for managing cloud session state.
5
+ * Automatically detects session expiration and triggers re-login flow.
6
+ */
7
+
8
+ import { useState, useEffect, useCallback, useRef } from 'react';
9
+ import {
10
+ cloudApi,
11
+ onSessionExpired,
12
+ getCsrfToken,
13
+ type CloudUser,
14
+ type SessionError,
15
+ type SessionStatus,
16
+ } from '../../lib/cloudApi';
17
+
18
+ export interface UseSessionOptions {
19
+ /** Check session on mount (default: true) */
20
+ checkOnMount?: boolean;
21
+ /** Interval to periodically check session in ms (default: 60000, set to 0 to disable) */
22
+ checkInterval?: number;
23
+ /** Callback when session expires */
24
+ onExpired?: (error: SessionError) => void;
25
+ }
26
+
27
+ export interface UseSessionReturn {
28
+ /** Current user data (null if not authenticated) */
29
+ user: CloudUser | null;
30
+ /** Whether the session check is in progress */
31
+ isLoading: boolean;
32
+ /** Whether user is authenticated */
33
+ isAuthenticated: boolean;
34
+ /** Whether session has expired (requires re-login) */
35
+ isExpired: boolean;
36
+ /** Session error if any */
37
+ error: SessionError | null;
38
+ /** CSRF token for API requests */
39
+ csrfToken: string | null;
40
+ /** Manually check session status */
41
+ checkSession: () => Promise<SessionStatus>;
42
+ /** Clear the expired state (e.g., after dismissing modal) */
43
+ clearExpired: () => void;
44
+ /** Redirect to login page */
45
+ redirectToLogin: () => void;
46
+ /** Logout the current user */
47
+ logout: () => Promise<void>;
48
+ }
49
+
50
+ const DEFAULT_OPTIONS: Required<UseSessionOptions> = {
51
+ checkOnMount: true,
52
+ checkInterval: 60000, // 1 minute
53
+ onExpired: () => {},
54
+ };
55
+
56
+ export function useSession(options: UseSessionOptions = {}): UseSessionReturn {
57
+ const opts = { ...DEFAULT_OPTIONS, ...options };
58
+
59
+ const [user, setUser] = useState<CloudUser | null>(null);
60
+ const [isLoading, setIsLoading] = useState(true);
61
+ const [isExpired, setIsExpired] = useState(false);
62
+ const [error, setError] = useState<SessionError | null>(null);
63
+
64
+ const intervalRef = useRef<NodeJS.Timeout | null>(null);
65
+ const mountedRef = useRef(true);
66
+
67
+ // Check session status
68
+ const checkSession = useCallback(async (): Promise<SessionStatus> => {
69
+ try {
70
+ const status = await cloudApi.checkSession();
71
+
72
+ if (!mountedRef.current) return status;
73
+
74
+ if (!status.authenticated) {
75
+ setUser(null);
76
+ if (status.code) {
77
+ const sessionError: SessionError = {
78
+ error: 'Session expired',
79
+ code: status.code,
80
+ message: status.message || 'Your session has expired. Please log in again.',
81
+ };
82
+ setError(sessionError);
83
+ setIsExpired(true);
84
+ opts.onExpired(sessionError);
85
+ }
86
+ }
87
+
88
+ return status;
89
+ } catch (_e) {
90
+ return {
91
+ authenticated: false,
92
+ code: 'SESSION_ERROR',
93
+ message: 'Failed to check session',
94
+ };
95
+ }
96
+ }, [opts]);
97
+
98
+ // Fetch user data
99
+ const fetchUser = useCallback(async () => {
100
+ setIsLoading(true);
101
+ try {
102
+ const result = await cloudApi.getMe();
103
+
104
+ if (!mountedRef.current) return;
105
+
106
+ if (result.success) {
107
+ setUser(result.data);
108
+ setIsExpired(false);
109
+ setError(null);
110
+ } else if (result.sessionExpired) {
111
+ setUser(null);
112
+ setIsExpired(true);
113
+ } else {
114
+ setError({
115
+ error: result.error,
116
+ code: 'SESSION_ERROR',
117
+ message: result.error,
118
+ });
119
+ }
120
+ } finally {
121
+ if (mountedRef.current) {
122
+ setIsLoading(false);
123
+ }
124
+ }
125
+ }, []);
126
+
127
+ // Handle session expiration from any API call
128
+ useEffect(() => {
129
+ const unsubscribe = onSessionExpired((sessionError) => {
130
+ if (!mountedRef.current) return;
131
+
132
+ setUser(null);
133
+ setIsExpired(true);
134
+ setError(sessionError);
135
+ opts.onExpired(sessionError);
136
+ });
137
+
138
+ return unsubscribe;
139
+ }, [opts]);
140
+
141
+ // Check session on mount
142
+ useEffect(() => {
143
+ mountedRef.current = true;
144
+
145
+ if (opts.checkOnMount) {
146
+ fetchUser();
147
+ }
148
+
149
+ return () => {
150
+ mountedRef.current = false;
151
+ };
152
+ }, [opts.checkOnMount, fetchUser]);
153
+
154
+ // Periodic session check
155
+ useEffect(() => {
156
+ if (opts.checkInterval <= 0) return;
157
+
158
+ intervalRef.current = setInterval(() => {
159
+ // Only check if we think we're authenticated
160
+ if (user) {
161
+ checkSession();
162
+ }
163
+ }, opts.checkInterval);
164
+
165
+ return () => {
166
+ if (intervalRef.current) {
167
+ clearInterval(intervalRef.current);
168
+ intervalRef.current = null;
169
+ }
170
+ };
171
+ }, [opts.checkInterval, user, checkSession]);
172
+
173
+ // Clear expired state
174
+ const clearExpired = useCallback(() => {
175
+ setIsExpired(false);
176
+ setError(null);
177
+ }, []);
178
+
179
+ // Redirect to login
180
+ const redirectToLogin = useCallback(() => {
181
+ // Preserve current path for redirect after login
182
+ const returnTo = encodeURIComponent(window.location.pathname + window.location.search);
183
+ window.location.href = `/login?returnTo=${returnTo}`;
184
+ }, []);
185
+
186
+ // Logout
187
+ const logout = useCallback(async () => {
188
+ await cloudApi.logout();
189
+ setUser(null);
190
+ setIsExpired(false);
191
+ setError(null);
192
+ window.location.href = '/login';
193
+ }, []);
194
+
195
+ return {
196
+ user,
197
+ isLoading,
198
+ isAuthenticated: user !== null,
199
+ isExpired,
200
+ error,
201
+ csrfToken: getCsrfToken(),
202
+ checkSession,
203
+ clearExpired,
204
+ redirectToLogin,
205
+ logout,
206
+ };
207
+ }
208
+
209
+ export type { SessionError, CloudUser };
@@ -0,0 +1,138 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react';
2
+ import type { Message } from '../../types';
3
+ import { api } from '../../lib/api';
4
+
5
+ interface UseThreadOptions {
6
+ threadId: string | null;
7
+ /** Client-side fallback messages (for non-relaycast servers) */
8
+ fallbackMessages?: Message[];
9
+ }
10
+
11
+ interface UseThreadReturn {
12
+ parentMessage: Message | null;
13
+ replies: Message[];
14
+ isLoading: boolean;
15
+ hasMore: boolean;
16
+ loadMore: () => Promise<void>;
17
+ sendReply: (text: string) => Promise<boolean>;
18
+ /** Append a reply from a WebSocket event */
19
+ addReply: (reply: Message) => void;
20
+ }
21
+
22
+ export function useThread({ threadId, fallbackMessages }: UseThreadOptions): UseThreadReturn {
23
+ const [parentMessage, setParentMessage] = useState<Message | null>(null);
24
+ const [replies, setReplies] = useState<Message[]>([]);
25
+ const [isLoading, setIsLoading] = useState(false);
26
+ const [hasMore, setHasMore] = useState(false);
27
+ const [cursor, setCursor] = useState<string | undefined>();
28
+ const [useFallback, setUseFallback] = useState(false);
29
+
30
+ // Use a ref to track the active threadId for cancellation of loadMore
31
+ const activeThreadIdRef = useRef<string | null>(null);
32
+
33
+ // Fetch thread from API when threadId changes
34
+ useEffect(() => {
35
+ activeThreadIdRef.current = threadId;
36
+
37
+ if (!threadId) {
38
+ setParentMessage(null);
39
+ setReplies([]);
40
+ setHasMore(false);
41
+ setCursor(undefined);
42
+ setUseFallback(false);
43
+ setIsLoading(false);
44
+ return;
45
+ }
46
+
47
+ // Reset state immediately when switching threads to avoid stale data flash
48
+ setParentMessage(null);
49
+ setReplies([]);
50
+ setHasMore(false);
51
+ setCursor(undefined);
52
+ setUseFallback(false);
53
+
54
+ let cancelled = false;
55
+ setIsLoading(true);
56
+
57
+ api.getThread(threadId, { limit: 50 }).then((result) => {
58
+ if (cancelled) return;
59
+ setIsLoading(false);
60
+
61
+ if (result.success && result.data) {
62
+ setUseFallback(false);
63
+ const { parent, replies: fetchedReplies, nextCursor } = result.data;
64
+ setParentMessage(parent as Message);
65
+ setReplies(fetchedReplies);
66
+ setHasMore(!!nextCursor);
67
+ setCursor(nextCursor);
68
+ } else {
69
+ // API not available — fall back to client-side messages
70
+ setUseFallback(true);
71
+ }
72
+ });
73
+
74
+ return () => {
75
+ cancelled = true;
76
+ };
77
+ }, [threadId]);
78
+
79
+ // Use fallback messages when API is unavailable
80
+ // For topic threads, the threadId is not the id of any message — it's the `thread` field on replies.
81
+ // So we also check for the first reply whose thread matches.
82
+ const effectiveParent = useFallback
83
+ ? (fallbackMessages?.find((m) => m.id === threadId)
84
+ ?? fallbackMessages?.filter((m) => m.thread === threadId)
85
+ .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())[0]
86
+ ?? null)
87
+ : parentMessage;
88
+
89
+ const effectiveReplies = useFallback
90
+ ? (fallbackMessages?.filter((m) => m.thread === threadId && m.id !== effectiveParent?.id) ?? [])
91
+ : replies;
92
+
93
+ const loadMore = useCallback(async () => {
94
+ if (!threadId || !hasMore || !cursor || useFallback) return;
95
+ const loadingThreadId = threadId;
96
+ setIsLoading(true);
97
+ const result = await api.getThread(threadId, { cursor, limit: 50 });
98
+ // If thread changed while loading, discard the stale response
99
+ if (activeThreadIdRef.current !== loadingThreadId) return;
100
+ setIsLoading(false);
101
+ if (result.success && result.data) {
102
+ setReplies((prev) => [...result.data!.replies, ...prev]);
103
+ setHasMore(!!result.data.nextCursor);
104
+ setCursor(result.data.nextCursor);
105
+ }
106
+ }, [threadId, hasMore, cursor, useFallback]);
107
+
108
+ const sendReply = useCallback(
109
+ async (text: string): Promise<boolean> => {
110
+ if (!threadId) return false;
111
+ const result = await api.postReply(threadId, text);
112
+ if (result.success && result.data) {
113
+ setReplies((prev) => [...prev, result.data!]);
114
+ return true;
115
+ }
116
+ return false;
117
+ },
118
+ [threadId],
119
+ );
120
+
121
+ const addReply = useCallback((reply: Message) => {
122
+ setReplies((prev) => {
123
+ // Deduplicate
124
+ if (prev.some((m) => m.id === reply.id)) return prev;
125
+ return [...prev, reply];
126
+ });
127
+ }, []);
128
+
129
+ return {
130
+ parentMessage: effectiveParent,
131
+ replies: effectiveReplies,
132
+ isLoading,
133
+ hasMore: useFallback ? false : hasMore,
134
+ loadMore,
135
+ sendReply,
136
+ addReply,
137
+ };
138
+ }