@chaaskit/client 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.
Files changed (135) hide show
  1. package/dist/favicon.svg +11 -0
  2. package/dist/index.html +17 -0
  3. package/dist/lib/LoadingSkeletons-IcIC2JPq.js +132 -0
  4. package/dist/lib/LoadingSkeletons-IcIC2JPq.js.map +1 -0
  5. package/dist/lib/ServerThemeProvider-DNF0LAyk.js +42 -0
  6. package/dist/lib/ServerThemeProvider-DNF0LAyk.js.map +1 -0
  7. package/dist/lib/extensions.js +10 -0
  8. package/dist/lib/extensions.js.map +1 -0
  9. package/dist/lib/favicon.svg +11 -0
  10. package/dist/lib/index.js +74126 -0
  11. package/dist/lib/index.js.map +1 -0
  12. package/dist/lib/logo.svg +12 -0
  13. package/dist/lib/routes/AcceptInviteRoute.js +19 -0
  14. package/dist/lib/routes/AcceptInviteRoute.js.map +1 -0
  15. package/dist/lib/routes/AdminDashboardRoute.js +19 -0
  16. package/dist/lib/routes/AdminDashboardRoute.js.map +1 -0
  17. package/dist/lib/routes/AdminTeamRoute.js +19 -0
  18. package/dist/lib/routes/AdminTeamRoute.js.map +1 -0
  19. package/dist/lib/routes/AdminTeamsRoute.js +19 -0
  20. package/dist/lib/routes/AdminTeamsRoute.js.map +1 -0
  21. package/dist/lib/routes/AdminUsersRoute.js +19 -0
  22. package/dist/lib/routes/AdminUsersRoute.js.map +1 -0
  23. package/dist/lib/routes/ApiKeysRoute.js +19 -0
  24. package/dist/lib/routes/ApiKeysRoute.js.map +1 -0
  25. package/dist/lib/routes/AutomationsRoute.js +19 -0
  26. package/dist/lib/routes/AutomationsRoute.js.map +1 -0
  27. package/dist/lib/routes/ChatRoute.js +19 -0
  28. package/dist/lib/routes/ChatRoute.js.map +1 -0
  29. package/dist/lib/routes/DocumentsRoute.js +19 -0
  30. package/dist/lib/routes/DocumentsRoute.js.map +1 -0
  31. package/dist/lib/routes/OAuthConsentRoute.js +19 -0
  32. package/dist/lib/routes/OAuthConsentRoute.js.map +1 -0
  33. package/dist/lib/routes/PricingRoute.js +19 -0
  34. package/dist/lib/routes/PricingRoute.js.map +1 -0
  35. package/dist/lib/routes/PrivacyRoute.js +19 -0
  36. package/dist/lib/routes/PrivacyRoute.js.map +1 -0
  37. package/dist/lib/routes/TeamSettingsRoute.js +19 -0
  38. package/dist/lib/routes/TeamSettingsRoute.js.map +1 -0
  39. package/dist/lib/routes/TermsRoute.js +19 -0
  40. package/dist/lib/routes/TermsRoute.js.map +1 -0
  41. package/dist/lib/routes/VerifyEmailRoute.js +19 -0
  42. package/dist/lib/routes/VerifyEmailRoute.js.map +1 -0
  43. package/dist/lib/routes.js +79 -0
  44. package/dist/lib/routes.js.map +1 -0
  45. package/dist/lib/ssr-utils.js +29 -0
  46. package/dist/lib/ssr-utils.js.map +1 -0
  47. package/dist/lib/ssr.js +60 -0
  48. package/dist/lib/ssr.js.map +1 -0
  49. package/dist/lib/styles.css +2410 -0
  50. package/dist/lib/useExtensions-B5nX_8XD.js +155 -0
  51. package/dist/lib/useExtensions-B5nX_8XD.js.map +1 -0
  52. package/dist/logo.svg +12 -0
  53. package/package.json +84 -0
  54. package/src/components/AgentSelector.tsx +90 -0
  55. package/src/components/BranchModal.tsx +129 -0
  56. package/src/components/ClientOnly.tsx +27 -0
  57. package/src/components/ExportMenu.tsx +122 -0
  58. package/src/components/LoadingSkeletons.tsx +110 -0
  59. package/src/components/MCPCredentialsSection.tsx +309 -0
  60. package/src/components/MentionChip.tsx +149 -0
  61. package/src/components/MentionDropdown.tsx +175 -0
  62. package/src/components/MentionInput.tsx +293 -0
  63. package/src/components/MessageItem.tsx +300 -0
  64. package/src/components/MessageList.tsx +159 -0
  65. package/src/components/OAuthAppsSection.tsx +124 -0
  66. package/src/components/ProjectFolder.tsx +141 -0
  67. package/src/components/ProjectModal.tsx +296 -0
  68. package/src/components/SSRMessageList.tsx +153 -0
  69. package/src/components/SearchModal.tsx +173 -0
  70. package/src/components/SettingsModal.tsx +412 -0
  71. package/src/components/ShareModal.tsx +280 -0
  72. package/src/components/Sidebar.tsx +491 -0
  73. package/src/components/TeamSwitcher.tsx +273 -0
  74. package/src/components/ToolCallDisplay.tsx +473 -0
  75. package/src/components/ToolConfirmationModal.tsx +130 -0
  76. package/src/components/UsageChart.tsx +177 -0
  77. package/src/components/content/CodeBlock.tsx +69 -0
  78. package/src/components/content/MarkdownRenderer.tsx +64 -0
  79. package/src/components/content/SSRMarkdownRenderer.tsx +158 -0
  80. package/src/contexts/AuthContext.tsx +119 -0
  81. package/src/contexts/ConfigContext.tsx +214 -0
  82. package/src/contexts/ProjectContext.tsx +167 -0
  83. package/src/contexts/ServerConfigProvider.tsx +41 -0
  84. package/src/contexts/ServerThemeProvider.tsx +47 -0
  85. package/src/contexts/TeamContext.tsx +255 -0
  86. package/src/contexts/ThemeContext.tsx +113 -0
  87. package/src/extensions/index.ts +15 -0
  88. package/src/extensions/registry.ts +187 -0
  89. package/src/extensions/useExtensions.ts +52 -0
  90. package/src/hooks/useAppPath.ts +34 -0
  91. package/src/hooks/useBasePath.ts +13 -0
  92. package/src/hooks/useKeyboardShortcuts.ts +50 -0
  93. package/src/hooks/useMentionSearch.ts +106 -0
  94. package/src/index.tsx +116 -0
  95. package/src/layouts/MainLayout.tsx +98 -0
  96. package/src/pages/AcceptInvitePage.tsx +175 -0
  97. package/src/pages/AdminDashboardPage.tsx +362 -0
  98. package/src/pages/AdminTeamPage.tsx +304 -0
  99. package/src/pages/AdminTeamsPage.tsx +242 -0
  100. package/src/pages/AdminUsersPage.tsx +385 -0
  101. package/src/pages/ApiKeysPage.tsx +449 -0
  102. package/src/pages/ChatPage.tsx +310 -0
  103. package/src/pages/DocumentsPage.tsx +577 -0
  104. package/src/pages/LoginPage.tsx +232 -0
  105. package/src/pages/OAuthConsentPage.tsx +234 -0
  106. package/src/pages/PricingPage.tsx +314 -0
  107. package/src/pages/PrivacyPage.tsx +65 -0
  108. package/src/pages/RegisterPage.tsx +153 -0
  109. package/src/pages/ScheduledPromptsPage.tsx +702 -0
  110. package/src/pages/SharedThreadPage.tsx +116 -0
  111. package/src/pages/TeamSettingsPage.tsx +1085 -0
  112. package/src/pages/TermsPage.tsx +82 -0
  113. package/src/pages/VerifyEmailPage.tsx +202 -0
  114. package/src/routes/AcceptInviteRoute.tsx +24 -0
  115. package/src/routes/AdminDashboardRoute.tsx +24 -0
  116. package/src/routes/AdminTeamRoute.tsx +24 -0
  117. package/src/routes/AdminTeamsRoute.tsx +24 -0
  118. package/src/routes/AdminUsersRoute.tsx +24 -0
  119. package/src/routes/ApiKeysRoute.tsx +24 -0
  120. package/src/routes/AutomationsRoute.tsx +24 -0
  121. package/src/routes/ChatRoute.tsx +28 -0
  122. package/src/routes/DocumentsRoute.tsx +24 -0
  123. package/src/routes/OAuthConsentRoute.tsx +24 -0
  124. package/src/routes/PricingRoute.tsx +24 -0
  125. package/src/routes/PrivacyRoute.tsx +24 -0
  126. package/src/routes/TeamSettingsRoute.tsx +24 -0
  127. package/src/routes/TermsRoute.tsx +24 -0
  128. package/src/routes/VerifyEmailRoute.tsx +24 -0
  129. package/src/routes/index.ts +57 -0
  130. package/src/ssr-utils.tsx +84 -0
  131. package/src/ssr.ts +123 -0
  132. package/src/stores/chatStore.ts +670 -0
  133. package/src/styles/index.css +254 -0
  134. package/src/utils/api.ts +78 -0
  135. package/src/vite-env.d.ts +13 -0
@@ -0,0 +1,255 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useState,
5
+ useEffect,
6
+ useCallback,
7
+ useRef,
8
+ type ReactNode,
9
+ } from 'react';
10
+ import { useSearchParams } from 'react-router';
11
+ import type { TeamWithRole, TeamDetails, TeamInvite, TeamRole } from '@chaaskit/shared';
12
+ import { api } from '../utils/api';
13
+ import { useAuth } from './AuthContext';
14
+ import { useConfig, useConfigLoaded } from './ConfigContext';
15
+
16
+ interface TeamContextType {
17
+ teams: TeamWithRole[];
18
+ currentTeam: TeamDetails | null;
19
+ currentTeamId: string | null;
20
+ isLoadingTeams: boolean;
21
+ isLoadingTeamDetails: boolean;
22
+ loadTeams: () => Promise<void>;
23
+ loadTeamDetails: (teamId: string) => Promise<void>;
24
+ setCurrentTeamId: (teamId: string | null) => void;
25
+ createTeam: (name: string) => Promise<TeamDetails>;
26
+ updateTeam: (teamId: string, updates: { name?: string; context?: string | null }) => Promise<void>;
27
+ archiveTeam: (teamId: string) => Promise<void>;
28
+ inviteMember: (teamId: string, email: string, role: 'admin' | 'member' | 'viewer') => Promise<{ invite: TeamInvite; inviteUrl: string }>;
29
+ removeMember: (teamId: string, userId: string) => Promise<void>;
30
+ updateMemberRole: (teamId: string, userId: string, role: TeamRole) => Promise<void>;
31
+ leaveTeam: (teamId: string) => Promise<void>;
32
+ cancelInvite: (teamId: string, inviteId: string) => Promise<void>;
33
+ getCurrentTeamRole: () => TeamRole | null;
34
+ }
35
+
36
+ const TeamContext = createContext<TeamContextType | undefined>(undefined);
37
+
38
+ export function TeamProvider({ children }: { children: ReactNode }) {
39
+ const { user } = useAuth();
40
+ const config = useConfig();
41
+ const configLoaded = useConfigLoaded();
42
+ const teamsEnabled = configLoaded ? (config.teams?.enabled ?? true) : false;
43
+ const [searchParams, setSearchParams] = useSearchParams();
44
+ const [teams, setTeams] = useState<TeamWithRole[]>([]);
45
+ const [currentTeam, setCurrentTeam] = useState<TeamDetails | null>(null);
46
+ const [currentTeamId, setCurrentTeamId] = useState<string | null>(() => {
47
+ // URL takes priority over localStorage
48
+ const urlTeam = searchParams.get('team');
49
+ if (urlTeam) return urlTeam;
50
+ return localStorage.getItem('currentTeamId');
51
+ });
52
+ const [isLoadingTeams, setIsLoadingTeams] = useState(false);
53
+ const [isLoadingTeamDetails, setIsLoadingTeamDetails] = useState(false);
54
+
55
+ const clearTeamSelection = useCallback(() => {
56
+ setCurrentTeamId(null);
57
+ setCurrentTeam(null);
58
+ localStorage.removeItem('currentTeamId');
59
+ setSearchParams((prev) => {
60
+ prev.delete('team');
61
+ return prev;
62
+ }, { replace: true });
63
+ }, [setSearchParams]);
64
+
65
+ const loadTeams = useCallback(async () => {
66
+ if (!user || !teamsEnabled) {
67
+ setTeams([]);
68
+ return;
69
+ }
70
+
71
+ setIsLoadingTeams(true);
72
+ try {
73
+ const response = await api.get<{ teams: TeamWithRole[] }>('/api/teams');
74
+ setTeams(response.teams);
75
+
76
+ // If current team is no longer available (invalid/archived or user not a member), clear it
77
+ if (currentTeamId && !response.teams.some((t) => t.id === currentTeamId)) {
78
+ clearTeamSelection();
79
+ }
80
+ } catch (error) {
81
+ console.error('Failed to load teams:', error);
82
+ setTeams([]);
83
+ } finally {
84
+ setIsLoadingTeams(false);
85
+ }
86
+ }, [user, teamsEnabled, currentTeamId, clearTeamSelection]);
87
+
88
+ const loadTeamDetails = useCallback(async (teamId: string) => {
89
+ if (!teamsEnabled) {
90
+ setCurrentTeam(null);
91
+ return;
92
+ }
93
+
94
+ setIsLoadingTeamDetails(true);
95
+ try {
96
+ const response = await api.get<{ team: TeamDetails }>(`/api/teams/${teamId}`);
97
+ setCurrentTeam(response.team);
98
+ } catch (error) {
99
+ console.error('Failed to load team details:', error);
100
+ setCurrentTeam(null);
101
+ } finally {
102
+ setIsLoadingTeamDetails(false);
103
+ }
104
+ }, [teamsEnabled]);
105
+
106
+ const handleSetCurrentTeamId = useCallback((teamId: string | null) => {
107
+ setCurrentTeamId(teamId);
108
+
109
+ // Update URL and localStorage
110
+ if (teamId) {
111
+ localStorage.setItem('currentTeamId', teamId);
112
+ setSearchParams((prev) => {
113
+ prev.set('team', teamId);
114
+ return prev;
115
+ }, { replace: true });
116
+ } else {
117
+ localStorage.removeItem('currentTeamId');
118
+ setCurrentTeam(null);
119
+ setSearchParams((prev) => {
120
+ prev.delete('team');
121
+ return prev;
122
+ }, { replace: true });
123
+ }
124
+ }, [setSearchParams]);
125
+
126
+ const createTeam = useCallback(async (name: string): Promise<TeamDetails> => {
127
+ const response = await api.post<{ team: TeamDetails }>('/api/teams', { name });
128
+ await loadTeams();
129
+ return response.team;
130
+ }, [loadTeams]);
131
+
132
+ const updateTeam = useCallback(async (teamId: string, updates: { name?: string; context?: string | null }) => {
133
+ await api.patch(`/api/teams/${teamId}`, updates);
134
+ await loadTeams();
135
+ if (currentTeamId === teamId) {
136
+ await loadTeamDetails(teamId);
137
+ }
138
+ }, [loadTeams, loadTeamDetails, currentTeamId]);
139
+
140
+ const archiveTeam = useCallback(async (teamId: string) => {
141
+ await api.post(`/api/teams/${teamId}/archive`, {});
142
+ if (currentTeamId === teamId) {
143
+ handleSetCurrentTeamId(null);
144
+ }
145
+ await loadTeams();
146
+ }, [loadTeams, currentTeamId, handleSetCurrentTeamId]);
147
+
148
+ const inviteMember = useCallback(async (
149
+ teamId: string,
150
+ email: string,
151
+ role: 'admin' | 'member' | 'viewer'
152
+ ): Promise<{ invite: TeamInvite; inviteUrl: string }> => {
153
+ const response = await api.post<{ invite: TeamInvite; inviteUrl: string }>(
154
+ `/api/teams/${teamId}/invite`,
155
+ { email, role }
156
+ );
157
+ if (currentTeamId === teamId) {
158
+ await loadTeamDetails(teamId);
159
+ }
160
+ return response;
161
+ }, [loadTeamDetails, currentTeamId]);
162
+
163
+ const removeMember = useCallback(async (teamId: string, userId: string) => {
164
+ await api.delete(`/api/teams/${teamId}/members/${userId}`);
165
+ await loadTeams();
166
+ if (currentTeamId === teamId) {
167
+ await loadTeamDetails(teamId);
168
+ }
169
+ }, [loadTeams, loadTeamDetails, currentTeamId]);
170
+
171
+ const updateMemberRole = useCallback(async (teamId: string, userId: string, role: TeamRole) => {
172
+ await api.patch(`/api/teams/${teamId}/members/${userId}`, { role });
173
+ if (currentTeamId === teamId) {
174
+ await loadTeamDetails(teamId);
175
+ }
176
+ }, [loadTeamDetails, currentTeamId]);
177
+
178
+ const leaveTeam = useCallback(async (teamId: string) => {
179
+ await api.post(`/api/teams/${teamId}/leave`, {});
180
+ if (currentTeamId === teamId) {
181
+ handleSetCurrentTeamId(null);
182
+ }
183
+ await loadTeams();
184
+ }, [loadTeams, currentTeamId, handleSetCurrentTeamId]);
185
+
186
+ const cancelInvite = useCallback(async (teamId: string, inviteId: string) => {
187
+ await api.delete(`/api/teams/${teamId}/invite/${inviteId}`);
188
+ if (currentTeamId === teamId) {
189
+ await loadTeamDetails(teamId);
190
+ }
191
+ }, [loadTeamDetails, currentTeamId]);
192
+
193
+ const getCurrentTeamRole = useCallback((): TeamRole | null => {
194
+ if (!currentTeamId) return null;
195
+ const team = teams.find((t) => t.id === currentTeamId);
196
+ return team?.role || null;
197
+ }, [teams, currentTeamId]);
198
+
199
+ // Track if user was ever logged in to distinguish logout from initial mount
200
+ const wasLoggedIn = useRef(false);
201
+
202
+ // Load teams when user changes and config is loaded
203
+ useEffect(() => {
204
+ if (user && configLoaded) {
205
+ wasLoggedIn.current = true;
206
+ loadTeams();
207
+ } else if (wasLoggedIn.current && !user) {
208
+ // Only clear on actual logout, not on initial mount
209
+ setTeams([]);
210
+ setCurrentTeam(null);
211
+ handleSetCurrentTeamId(null);
212
+ }
213
+ }, [user, configLoaded, loadTeams, handleSetCurrentTeamId]);
214
+
215
+ // Load team details when current team changes
216
+ useEffect(() => {
217
+ if (currentTeamId && configLoaded && teamsEnabled) {
218
+ loadTeamDetails(currentTeamId);
219
+ }
220
+ }, [currentTeamId, configLoaded, teamsEnabled, loadTeamDetails]);
221
+
222
+ return (
223
+ <TeamContext.Provider
224
+ value={{
225
+ teams,
226
+ currentTeam,
227
+ currentTeamId,
228
+ isLoadingTeams,
229
+ isLoadingTeamDetails,
230
+ loadTeams,
231
+ loadTeamDetails,
232
+ setCurrentTeamId: handleSetCurrentTeamId,
233
+ createTeam,
234
+ updateTeam,
235
+ archiveTeam,
236
+ inviteMember,
237
+ removeMember,
238
+ updateMemberRole,
239
+ leaveTeam,
240
+ cancelInvite,
241
+ getCurrentTeamRole,
242
+ }}
243
+ >
244
+ {children}
245
+ </TeamContext.Provider>
246
+ );
247
+ }
248
+
249
+ export function useTeam() {
250
+ const context = useContext(TeamContext);
251
+ if (context === undefined) {
252
+ throw new Error('useTeam must be used within a TeamProvider');
253
+ }
254
+ return context;
255
+ }
@@ -0,0 +1,113 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useState,
5
+ useEffect,
6
+ type ReactNode,
7
+ } from 'react';
8
+ import { useConfig } from './ConfigContext';
9
+
10
+ interface ThemeContextType {
11
+ theme: string;
12
+ setTheme: (theme: string) => void;
13
+ availableThemes: string[];
14
+ }
15
+
16
+ const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
17
+
18
+ export function ThemeProvider({ children }: { children: ReactNode }) {
19
+ const config = useConfig();
20
+ const [theme, setThemeState] = useState(() => {
21
+ // Check localStorage first, then config default
22
+ const stored = localStorage.getItem('theme');
23
+ if (stored && config.theming.themes[stored]) {
24
+ return stored;
25
+ }
26
+ return config.theming.defaultTheme;
27
+ });
28
+
29
+ const availableThemes = Object.keys(config.theming.themes);
30
+
31
+ useEffect(() => {
32
+ // Apply theme to document
33
+ document.documentElement.setAttribute('data-theme', theme);
34
+ localStorage.setItem('theme', theme);
35
+ // Set cookie for SSR to read
36
+ document.cookie = `theme=${theme};path=/;max-age=31536000;SameSite=Lax`;
37
+
38
+ // Apply theme colors as CSS variables
39
+ const themeConfig = config.theming.themes[theme];
40
+ if (themeConfig) {
41
+ const root = document.documentElement;
42
+ const colors = themeConfig.colors;
43
+
44
+ // Convert hex colors to RGB values
45
+ Object.entries(colors).forEach(([key, value]) => {
46
+ const cssKey = `--color-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
47
+ const rgb = hexToRgb(value);
48
+ if (rgb) {
49
+ root.style.setProperty(cssKey, `${rgb.r} ${rgb.g} ${rgb.b}`);
50
+ }
51
+ });
52
+ }
53
+
54
+ // Apply fonts
55
+ document.documentElement.style.setProperty(
56
+ '--font-sans',
57
+ config.theming.fonts.sans
58
+ );
59
+ document.documentElement.style.setProperty(
60
+ '--font-mono',
61
+ config.theming.fonts.mono
62
+ );
63
+
64
+ // Apply border radius
65
+ document.documentElement.style.setProperty(
66
+ '--radius-sm',
67
+ config.theming.borderRadius.sm
68
+ );
69
+ document.documentElement.style.setProperty(
70
+ '--radius-md',
71
+ config.theming.borderRadius.md
72
+ );
73
+ document.documentElement.style.setProperty(
74
+ '--radius-lg',
75
+ config.theming.borderRadius.lg
76
+ );
77
+ document.documentElement.style.setProperty(
78
+ '--radius-full',
79
+ config.theming.borderRadius.full
80
+ );
81
+ }, [theme, config.theming]);
82
+
83
+ function setTheme(newTheme: string) {
84
+ if (config.theming.themes[newTheme]) {
85
+ setThemeState(newTheme);
86
+ }
87
+ }
88
+
89
+ return (
90
+ <ThemeContext.Provider value={{ theme, setTheme, availableThemes }}>
91
+ {children}
92
+ </ThemeContext.Provider>
93
+ );
94
+ }
95
+
96
+ export function useTheme() {
97
+ const context = useContext(ThemeContext);
98
+ if (context === undefined) {
99
+ throw new Error('useTheme must be used within a ThemeProvider');
100
+ }
101
+ return context;
102
+ }
103
+
104
+ function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
105
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
106
+ return result
107
+ ? {
108
+ r: parseInt(result[1]!, 16),
109
+ g: parseInt(result[2]!, 16),
110
+ b: parseInt(result[3]!, 16),
111
+ }
112
+ : null;
113
+ }
@@ -0,0 +1,15 @@
1
+ // Client extension system for @chaaskit/client
2
+ export {
3
+ clientRegistry,
4
+ type PageExtension,
5
+ type ToolExtension,
6
+ type ComponentOverride,
7
+ } from './registry';
8
+
9
+ export {
10
+ useExtensionPages,
11
+ useSidebarPages,
12
+ useExtensionTools,
13
+ useToolRenderer,
14
+ useComponentOverride,
15
+ } from './useExtensions';
@@ -0,0 +1,187 @@
1
+ import type { ComponentType } from 'react';
2
+
3
+ /**
4
+ * Page extension configuration
5
+ */
6
+ export interface PageExtension {
7
+ /** Unique identifier for the page */
8
+ id: string;
9
+ /** URL path for the page (e.g., "/analytics") */
10
+ path: string;
11
+ /** Display label for navigation */
12
+ label: string;
13
+ /** Optional icon name from lucide-react */
14
+ icon?: string;
15
+ /** React component to render for this page */
16
+ component: ComponentType;
17
+ /** Whether to show in the sidebar navigation */
18
+ showInSidebar?: boolean;
19
+ /** Whether this page requires authentication */
20
+ requiresAuth?: boolean;
21
+ /** Whether this page requires admin access */
22
+ requiresAdmin?: boolean;
23
+ }
24
+
25
+ /**
26
+ * Tool result renderer extension
27
+ */
28
+ export interface ToolExtension {
29
+ /** Tool name to match */
30
+ name: string;
31
+ /** Description of the tool */
32
+ description: string;
33
+ /** Custom renderer for tool results */
34
+ resultRenderer?: ComponentType<{ result: unknown }>;
35
+ }
36
+
37
+ /**
38
+ * Component override extension
39
+ */
40
+ export interface ComponentOverride {
41
+ /** Component slot to override */
42
+ slot: 'header' | 'footer' | 'sidebar-header' | 'sidebar-footer' | 'message-actions';
43
+ /** Component to render in the slot */
44
+ component: ComponentType<Record<string, unknown>>;
45
+ }
46
+
47
+ /**
48
+ * Client-side extension registry for customizing the ChaasKit UI
49
+ *
50
+ * Note: getPages(), getSidebarPages(), and getTools() cache their results
51
+ * to support React's useSyncExternalStore which requires stable references.
52
+ */
53
+ class ClientExtensionRegistry {
54
+ private pages: Map<string, PageExtension> = new Map();
55
+ private tools: Map<string, ToolExtension> = new Map();
56
+ private overrides: Map<string, ComponentOverride> = new Map();
57
+ private listeners: Set<() => void> = new Set();
58
+
59
+ // Cached arrays for useSyncExternalStore compatibility
60
+ // These are only updated when the underlying data changes
61
+ private cachedPages: PageExtension[] = [];
62
+ private cachedSidebarPages: PageExtension[] = [];
63
+ private cachedTools: ToolExtension[] = [];
64
+
65
+ /**
66
+ * Register a custom page
67
+ */
68
+ registerPage(page: PageExtension): void {
69
+ this.pages.set(page.id, page);
70
+ this.invalidateCaches();
71
+ this.notifyListeners();
72
+ }
73
+
74
+ /**
75
+ * Unregister a custom page
76
+ */
77
+ unregisterPage(id: string): boolean {
78
+ const result = this.pages.delete(id);
79
+ if (result) {
80
+ this.invalidateCaches();
81
+ this.notifyListeners();
82
+ }
83
+ return result;
84
+ }
85
+
86
+ /**
87
+ * Get all registered pages
88
+ * Returns a cached array for useSyncExternalStore compatibility
89
+ */
90
+ getPages(): PageExtension[] {
91
+ return this.cachedPages;
92
+ }
93
+
94
+ /**
95
+ * Get pages that should appear in the sidebar
96
+ * Returns a cached array for useSyncExternalStore compatibility
97
+ */
98
+ getSidebarPages(): PageExtension[] {
99
+ return this.cachedSidebarPages;
100
+ }
101
+
102
+ /**
103
+ * Register a custom tool renderer
104
+ */
105
+ registerTool(tool: ToolExtension): void {
106
+ this.tools.set(tool.name, tool);
107
+ this.invalidateCaches();
108
+ this.notifyListeners();
109
+ }
110
+
111
+ /**
112
+ * Unregister a tool renderer
113
+ */
114
+ unregisterTool(name: string): boolean {
115
+ const result = this.tools.delete(name);
116
+ if (result) {
117
+ this.invalidateCaches();
118
+ this.notifyListeners();
119
+ }
120
+ return result;
121
+ }
122
+
123
+ /**
124
+ * Get all registered tools
125
+ * Returns a cached array for useSyncExternalStore compatibility
126
+ */
127
+ getTools(): ToolExtension[] {
128
+ return this.cachedTools;
129
+ }
130
+
131
+ /**
132
+ * Get a specific tool by name
133
+ */
134
+ getTool(name: string): ToolExtension | undefined {
135
+ return this.tools.get(name);
136
+ }
137
+
138
+ /**
139
+ * Register a component override
140
+ */
141
+ registerOverride(override: ComponentOverride): void {
142
+ this.overrides.set(override.slot, override);
143
+ this.notifyListeners();
144
+ }
145
+
146
+ /**
147
+ * Get a component override for a slot
148
+ */
149
+ getOverride(slot: string): ComponentOverride | undefined {
150
+ return this.overrides.get(slot);
151
+ }
152
+
153
+ /**
154
+ * Subscribe to registry changes
155
+ */
156
+ subscribe(listener: () => void): () => void {
157
+ this.listeners.add(listener);
158
+ return () => this.listeners.delete(listener);
159
+ }
160
+
161
+ private invalidateCaches(): void {
162
+ this.cachedPages = Array.from(this.pages.values());
163
+ this.cachedSidebarPages = this.cachedPages.filter(p => p.showInSidebar);
164
+ this.cachedTools = Array.from(this.tools.values());
165
+ }
166
+
167
+ private notifyListeners(): void {
168
+ this.listeners.forEach(listener => listener());
169
+ }
170
+
171
+ /**
172
+ * Clear all registered extensions
173
+ */
174
+ clear(): void {
175
+ this.pages.clear();
176
+ this.tools.clear();
177
+ this.overrides.clear();
178
+ this.invalidateCaches();
179
+ this.notifyListeners();
180
+ }
181
+ }
182
+
183
+ // Singleton instance
184
+ export const clientRegistry = new ClientExtensionRegistry();
185
+
186
+ // Export for use in user extensions
187
+ export default clientRegistry;
@@ -0,0 +1,52 @@
1
+ import { useSyncExternalStore, useCallback } from 'react';
2
+ import { clientRegistry, type PageExtension, type ToolExtension, type ComponentOverride } from './registry';
3
+
4
+ /**
5
+ * Hook to access extension pages with automatic updates
6
+ */
7
+ export function useExtensionPages(): PageExtension[] {
8
+ const getSnapshot = useCallback(() => clientRegistry.getPages(), []);
9
+ const subscribe = useCallback((callback: () => void) => clientRegistry.subscribe(callback), []);
10
+
11
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
12
+ }
13
+
14
+ /**
15
+ * Hook to access sidebar pages with automatic updates
16
+ */
17
+ export function useSidebarPages(): PageExtension[] {
18
+ const getSnapshot = useCallback(() => clientRegistry.getSidebarPages(), []);
19
+ const subscribe = useCallback((callback: () => void) => clientRegistry.subscribe(callback), []);
20
+
21
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
22
+ }
23
+
24
+ /**
25
+ * Hook to access extension tools with automatic updates
26
+ */
27
+ export function useExtensionTools(): ToolExtension[] {
28
+ const getSnapshot = useCallback(() => clientRegistry.getTools(), []);
29
+ const subscribe = useCallback((callback: () => void) => clientRegistry.subscribe(callback), []);
30
+
31
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
32
+ }
33
+
34
+ /**
35
+ * Hook to get a specific tool renderer
36
+ */
37
+ export function useToolRenderer(toolName: string): ToolExtension | undefined {
38
+ const getSnapshot = useCallback(() => clientRegistry.getTool(toolName), [toolName]);
39
+ const subscribe = useCallback((callback: () => void) => clientRegistry.subscribe(callback), []);
40
+
41
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
42
+ }
43
+
44
+ /**
45
+ * Hook to get a component override
46
+ */
47
+ export function useComponentOverride(slot: string): ComponentOverride | undefined {
48
+ const getSnapshot = useCallback(() => clientRegistry.getOverride(slot), [slot]);
49
+ const subscribe = useCallback((callback: () => void) => clientRegistry.subscribe(callback), []);
50
+
51
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
52
+ }
@@ -0,0 +1,34 @@
1
+ import { useCallback } from 'react';
2
+ import { useConfig } from '../contexts/ConfigContext';
3
+
4
+ /**
5
+ * Returns a memoized function to build paths with the configured basePath.
6
+ * Use this for all internal navigation within the authenticated app.
7
+ *
8
+ * @example
9
+ * const appPath = useAppPath();
10
+ * navigate(appPath('/')); // Goes to /chat (or whatever basePath is configured)
11
+ * navigate(appPath('/thread/123')); // Goes to /chat/thread/123
12
+ */
13
+ export function useAppPath(): (path: string) => string {
14
+ const config = useConfig();
15
+ // Default to /chat if basePath is undefined, null, or empty string
16
+ const configBasePath = config?.app?.basePath;
17
+ const basePath = configBasePath && configBasePath.trim() !== '' ? configBasePath : '/chat';
18
+
19
+ return useCallback(
20
+ (path: string) => {
21
+ // Remove leading slash from path if basePath already ends with one
22
+ const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
23
+ const normalizedBase = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath;
24
+
25
+ // Handle root path
26
+ if (!normalizedPath || normalizedPath === '') {
27
+ return normalizedBase;
28
+ }
29
+
30
+ return `${normalizedBase}/${normalizedPath}`;
31
+ },
32
+ [basePath]
33
+ );
34
+ }
@@ -0,0 +1,13 @@
1
+ import { useConfig } from '../contexts/ConfigContext';
2
+
3
+ /**
4
+ * Returns the configured basePath for the application.
5
+ * Useful for building absolute URLs or for components that need
6
+ * to know the base path for external navigation.
7
+ */
8
+ export function useBasePath(): string {
9
+ const config = useConfig();
10
+ // Return basePath if configured and non-empty, otherwise default to /chat
11
+ const configBasePath = config?.app?.basePath;
12
+ return configBasePath && configBasePath.trim() !== '' ? configBasePath : '/chat';
13
+ }