@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.
- package/out/404.html +1 -1
- package/out/_next/static/chunks/{118-4c8241b0218335de.js → 118-ae2b650136a5a5fc.js} +1 -1
- package/out/_next/static/chunks/407-0c82986cf79c8ecb.js +1 -0
- package/out/_next/static/chunks/app/app/[[...slug]]/{page-1e81c047cff17212.js → page-f7eca1b66fb4249b.js} +1 -1
- package/out/_next/static/chunks/app/{page-6892fe2dd07fb48b.js → page-0ee604f7070d14c0.js} +1 -1
- package/out/_next/static/css/8968d98ed4c4d33f.css +1 -0
- package/out/about.html +2 -2
- package/out/about.txt +1 -1
- package/out/app/onboarding.html +1 -1
- package/out/app/onboarding.txt +1 -1
- package/out/app.html +1 -1
- package/out/app.txt +2 -2
- package/out/blog/go-to-bed-wake-up-to-a-finished-product.html +2 -2
- package/out/blog/go-to-bed-wake-up-to-a-finished-product.txt +1 -1
- package/out/blog/let-them-cook-multi-agent-orchestration.html +2 -2
- package/out/blog/let-them-cook-multi-agent-orchestration.txt +2 -2
- package/out/blog.html +2 -2
- package/out/blog.txt +1 -1
- package/out/careers.html +2 -2
- package/out/careers.txt +1 -1
- package/out/changelog.html +2 -2
- package/out/changelog.txt +1 -1
- package/out/cloud/link.html +1 -1
- package/out/cloud/link.txt +2 -2
- package/out/complete-profile.html +2 -2
- package/out/complete-profile.txt +1 -1
- package/out/connect-repos.html +1 -1
- package/out/connect-repos.txt +1 -1
- package/out/contact.html +2 -2
- package/out/contact.txt +1 -1
- package/out/docs.html +2 -2
- package/out/docs.txt +1 -1
- package/out/history.html +1 -1
- package/out/history.txt +2 -2
- package/out/index.html +1 -1
- package/out/index.txt +2 -2
- package/out/login.html +2 -2
- package/out/login.txt +1 -1
- package/out/metrics.html +1 -1
- package/out/metrics.txt +2 -2
- package/out/pricing.html +2 -2
- package/out/pricing.txt +1 -1
- package/out/privacy.html +2 -2
- package/out/privacy.txt +1 -1
- package/out/providers/setup/claude.html +1 -1
- package/out/providers/setup/claude.txt +1 -1
- package/out/providers/setup/codex.html +1 -1
- package/out/providers/setup/codex.txt +1 -1
- package/out/providers/setup/cursor.html +1 -1
- package/out/providers/setup/cursor.txt +1 -1
- package/out/providers.html +1 -1
- package/out/providers.txt +1 -1
- package/out/security.html +2 -2
- package/out/security.txt +1 -1
- package/out/signup.html +2 -2
- package/out/signup.txt +1 -1
- package/out/terms.html +2 -2
- package/out/terms.txt +1 -1
- package/package.json +7 -1
- package/src/app/about/page.tsx +7 -0
- package/src/app/app/[[...slug]]/DashboardPageClient.tsx +853 -0
- package/src/app/app/[[...slug]]/page.tsx +23 -0
- package/src/app/app/onboarding/page.tsx +394 -0
- package/src/app/apple-icon.png +0 -0
- package/src/app/blog/go-to-bed-wake-up-to-a-finished-product/page.tsx +88 -0
- package/src/app/blog/let-them-cook-multi-agent-orchestration/page.tsx +93 -0
- package/src/app/blog/page.tsx +15 -0
- package/src/app/careers/page.tsx +7 -0
- package/src/app/changelog/page.tsx +7 -0
- package/src/app/cloud/link/page.tsx +464 -0
- package/src/app/complete-profile/page.tsx +204 -0
- package/src/app/connect-repos/page.tsx +410 -0
- package/src/app/contact/page.tsx +7 -0
- package/src/app/docs/page.tsx +7 -0
- package/src/app/favicon.png +0 -0
- package/src/app/globals.css +200 -0
- package/src/app/history/page.tsx +658 -0
- package/src/app/layout.tsx +25 -0
- package/src/app/login/page.tsx +424 -0
- package/src/app/metrics/page.tsx +781 -0
- package/src/app/page.tsx +59 -0
- package/src/app/pricing/page.tsx +7 -0
- package/src/app/privacy/page.tsx +7 -0
- package/src/app/providers/page.tsx +193 -0
- package/src/app/providers/setup/[provider]/ProviderSetupClient.tsx +197 -0
- package/src/app/providers/setup/[provider]/constants.ts +35 -0
- package/src/app/providers/setup/[provider]/page.tsx +42 -0
- package/src/app/security/page.tsx +7 -0
- package/src/app/signup/page.tsx +533 -0
- package/src/app/terms/page.tsx +7 -0
- package/src/components/ActivityFeed.tsx +216 -0
- package/src/components/AddWorkspaceModal.tsx +170 -0
- package/src/components/AgentCard.test.tsx +134 -0
- package/src/components/AgentCard.tsx +585 -0
- package/src/components/AgentList.test.tsx +147 -0
- package/src/components/AgentList.tsx +419 -0
- package/src/components/AgentLogPreview.tsx +173 -0
- package/src/components/AgentProfilePanel.tsx +569 -0
- package/src/components/App.tsx +3424 -0
- package/src/components/BillingPanel.tsx +922 -0
- package/src/components/BillingResult.tsx +447 -0
- package/src/components/BroadcastComposer.tsx +690 -0
- package/src/components/ChannelAdminPanel.tsx +773 -0
- package/src/components/ChannelBrowser.tsx +385 -0
- package/src/components/ChannelChat.tsx +261 -0
- package/src/components/ChannelSidebar.tsx +399 -0
- package/src/components/CloudSessionProvider.tsx +130 -0
- package/src/components/CommandPalette.tsx +815 -0
- package/src/components/ConfirmationDialog.tsx +133 -0
- package/src/components/ConversationHistory.tsx +518 -0
- package/src/components/CoordinatorPanel.tsx +956 -0
- package/src/components/DecisionQueue.tsx +717 -0
- package/src/components/DirectMessageView.tsx +164 -0
- package/src/components/FileAutocomplete.tsx +368 -0
- package/src/components/FleetOverview.tsx +278 -0
- package/src/components/LogViewer.tsx +310 -0
- package/src/components/LogViewerPanel.tsx +482 -0
- package/src/components/Logo.tsx +284 -0
- package/src/components/MentionAutocomplete.tsx +384 -0
- package/src/components/MessageComposer.tsx +473 -0
- package/src/components/MessageList.tsx +725 -0
- package/src/components/MessageSenderName.tsx +91 -0
- package/src/components/MessageStatusIndicator.tsx +142 -0
- package/src/components/NewConversationModal.tsx +400 -0
- package/src/components/NotificationToast.tsx +488 -0
- package/src/components/OnlineUsersIndicator.tsx +164 -0
- package/src/components/Pagination.tsx +124 -0
- package/src/components/PricingPlans.tsx +386 -0
- package/src/components/ProjectList.tsx +711 -0
- package/src/components/ProviderAuthFlow.tsx +343 -0
- package/src/components/ProviderConnectionList.tsx +375 -0
- package/src/components/ProvisioningProgress.tsx +730 -0
- package/src/components/ReactionChips.tsx +70 -0
- package/src/components/ReactionPicker.tsx +121 -0
- package/src/components/RepoAccessPanel.tsx +787 -0
- package/src/components/RepositoriesPanel.tsx +901 -0
- package/src/components/ServerCard.tsx +202 -0
- package/src/components/SessionExpiredModal.tsx +128 -0
- package/src/components/SpawnModal.test.tsx +190 -0
- package/src/components/SpawnModal.tsx +1001 -0
- package/src/components/TaskAssignmentUI.tsx +375 -0
- package/src/components/TerminalProviderSetup.tsx +517 -0
- package/src/components/ThemeProvider.tsx +159 -0
- package/src/components/ThinkingIndicator.tsx +231 -0
- package/src/components/ThreadList.tsx +198 -0
- package/src/components/ThreadPanel.tsx +405 -0
- package/src/components/TrajectoryViewer.tsx +698 -0
- package/src/components/TypingIndicator.tsx +69 -0
- package/src/components/UsageBanner.tsx +231 -0
- package/src/components/UserProfilePanel.tsx +233 -0
- package/src/components/WorkspaceContext.tsx +95 -0
- package/src/components/WorkspaceSelector.tsx +234 -0
- package/src/components/WorkspaceStatusIndicator.tsx +396 -0
- package/src/components/XTermInteractive.tsx +516 -0
- package/src/components/XTermLogViewer.tsx +719 -0
- package/src/components/channels/ChannelDialogs.tsx +1411 -0
- package/src/components/channels/ChannelHeader.tsx +317 -0
- package/src/components/channels/ChannelMessageList.tsx +463 -0
- package/src/components/channels/ChannelViewV1.tsx +146 -0
- package/src/components/channels/MessageInput.tsx +302 -0
- package/src/components/channels/SearchInput.tsx +172 -0
- package/src/components/channels/SearchResults.tsx +336 -0
- package/src/components/channels/api.test.ts +1527 -0
- package/src/components/channels/api.ts +703 -0
- package/src/components/channels/index.ts +76 -0
- package/src/components/channels/mockApi.ts +344 -0
- package/src/components/channels/types.ts +566 -0
- package/src/components/hooks/index.ts +58 -0
- package/src/components/hooks/useAgentLogs.ts +504 -0
- package/src/components/hooks/useAgents.ts +127 -0
- package/src/components/hooks/useBroadcastDedup.test.ts +371 -0
- package/src/components/hooks/useBroadcastDedup.ts +86 -0
- package/src/components/hooks/useChannelAdmin.ts +329 -0
- package/src/components/hooks/useChannelBrowser.ts +239 -0
- package/src/components/hooks/useChannelCommands.ts +138 -0
- package/src/components/hooks/useChannels.ts +367 -0
- package/src/components/hooks/useDebounce.ts +29 -0
- package/src/components/hooks/useDirectMessage.test.ts +952 -0
- package/src/components/hooks/useDirectMessage.ts +141 -0
- package/src/components/hooks/useMessages.ts +310 -0
- package/src/components/hooks/useOrchestrator.test.ts +165 -0
- package/src/components/hooks/useOrchestrator.ts +424 -0
- package/src/components/hooks/usePinnedAgents.test.ts +356 -0
- package/src/components/hooks/usePinnedAgents.ts +140 -0
- package/src/components/hooks/usePresence.test.ts +245 -0
- package/src/components/hooks/usePresence.ts +377 -0
- package/src/components/hooks/useRecentRepos.ts +130 -0
- package/src/components/hooks/useSession.ts +209 -0
- package/src/components/hooks/useThread.ts +138 -0
- package/src/components/hooks/useTrajectory.ts +265 -0
- package/src/components/hooks/useWebSocket.ts +290 -0
- package/src/components/hooks/useWorkspaceMembers.ts +132 -0
- package/src/components/hooks/useWorkspaceRepos.ts +73 -0
- package/src/components/hooks/useWorkspaceStatus.ts +237 -0
- package/src/components/index.ts +81 -0
- package/src/components/layout/Header.tsx +311 -0
- package/src/components/layout/RepoContextHeader.tsx +361 -0
- package/src/components/layout/Sidebar.archive.test.tsx +126 -0
- package/src/components/layout/Sidebar.test.tsx +691 -0
- package/src/components/layout/Sidebar.tsx +900 -0
- package/src/components/layout/index.ts +7 -0
- package/src/components/settings/BillingSettingsPanel.tsx +564 -0
- package/src/components/settings/SettingsPage.tsx +683 -0
- package/src/components/settings/TeamSettingsPanel.tsx +560 -0
- package/src/components/settings/WorkspaceSettingsPanel.tsx +1368 -0
- package/src/components/settings/index.ts +11 -0
- package/src/components/settings/types.ts +79 -0
- package/src/components/utils/messageFormatting.test.tsx +331 -0
- package/src/components/utils/messageFormatting.tsx +597 -0
- package/src/index.ts +63 -0
- package/src/landing/AboutPage.tsx +77 -0
- package/src/landing/BlogContent.tsx +187 -0
- package/src/landing/BlogPage.tsx +47 -0
- package/src/landing/CareersPage.tsx +53 -0
- package/src/landing/ChangelogPage.tsx +33 -0
- package/src/landing/ContactPage.tsx +41 -0
- package/src/landing/DocsPage.tsx +43 -0
- package/src/landing/LandingPage.tsx +702 -0
- package/src/landing/PricingPage.tsx +549 -0
- package/src/landing/PrivacyPage.tsx +117 -0
- package/src/landing/SecurityPage.tsx +42 -0
- package/src/landing/StaticPage.tsx +165 -0
- package/src/landing/TermsPage.tsx +125 -0
- package/src/landing/blogData.ts +312 -0
- package/src/landing/index.ts +18 -0
- package/src/landing/styles.css +3673 -0
- package/src/lib/agent-merge.test.ts +43 -0
- package/src/lib/agent-merge.ts +35 -0
- package/src/lib/api.ts +1294 -0
- package/src/lib/cloudApi.ts +893 -0
- package/src/lib/colors.test.ts +175 -0
- package/src/lib/colors.ts +218 -0
- package/src/lib/config.ts +109 -0
- package/src/lib/hierarchy.ts +242 -0
- package/src/lib/stuckDetection.ts +142 -0
- package/src/lib/useUrlRouting.ts +190 -0
- package/src/types/index.ts +317 -0
- package/src/types/threading.ts +7 -0
- package/out/_next/static/chunks/285-dc644487a8d6500d.js +0 -1
- package/out/_next/static/css/4c58d9cf493aa626.css +0 -1
- /package/out/_next/static/{AqelRhy1vr2nBUcU0Iqcp → IxfA6RZu4trcsEMYlkQra}/_buildManifest.js +0 -0
- /package/out/_next/static/{AqelRhy1vr2nBUcU0Iqcp → IxfA6RZu4trcsEMYlkQra}/_ssgManifest.js +0 -0
- /package/out/_next/static/chunks/{528-d375bc8b46912d2c.js → 528-f5f676996d613c25.js} +0 -0
- /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
|
+
}
|