shraga 0.0.3 → 0.1.1

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 (180) hide show
  1. package/README.md +82 -23
  2. package/defaults/agents/summarizer.md +16 -0
  3. package/defaults/agents/trace-extractor.md +84 -0
  4. package/defaults/bin/claude +45 -0
  5. package/defaults/bin/claude-revive +17 -0
  6. package/defaults/extensions/README.md +70 -0
  7. package/defaults/extensions/selftest.ext.ts +43 -0
  8. package/defaults/extensions/stripe-webhook.ext.ts +58 -0
  9. package/defaults/gmail-triage-prompt.md +42 -0
  10. package/defaults/scripts/README +4 -0
  11. package/defaults/scripts/agent-once.ts +67 -0
  12. package/defaults/scripts/backfill-slack-usernames.ts +82 -0
  13. package/defaults/scripts/notifier-throttle.ts +44 -0
  14. package/defaults/scripts/summarize-conversations.ts +5 -0
  15. package/defaults/shraga.config.ts +29 -0
  16. package/defaults/skills/add-skill.md +14 -0
  17. package/defaults/skills/artifacts.md +116 -0
  18. package/defaults/skills/code-review.md +26 -0
  19. package/defaults/skills/communications.md +54 -0
  20. package/defaults/skills/context-audit.md +87 -0
  21. package/defaults/skills/debug.md +10 -0
  22. package/defaults/skills/garden.md +179 -0
  23. package/defaults/skills/github-contributor.md +35 -0
  24. package/defaults/skills/identity.md +30 -0
  25. package/defaults/skills/mcp-server.md +62 -0
  26. package/defaults/skills/mcps-sync.md +105 -0
  27. package/defaults/skills/plan.md +9 -0
  28. package/defaults/skills/platform.md +177 -0
  29. package/defaults/skills/reconcile.md +239 -0
  30. package/defaults/skills/scheduler.md +192 -0
  31. package/defaults/skills/self-aware.md +136 -0
  32. package/defaults/skills/shraga-know.md +333 -0
  33. package/defaults/skills/stripe.md +55 -0
  34. package/defaults/skills/write-tests.md +10 -0
  35. package/defaults/skills-defaults.json +1 -0
  36. package/defaults/system-prompt.md +46 -0
  37. package/defaults/workspace/context.md +28 -0
  38. package/defaults/workspace.md +50 -0
  39. package/defaults/zdotdir/.gitignore +8 -0
  40. package/defaults/zdotdir/.zlogin +3 -0
  41. package/defaults/zdotdir/.zprofile +1 -0
  42. package/defaults/zdotdir/.zshenv +4 -0
  43. package/defaults/zdotdir/.zshrc +3 -0
  44. package/dist/client/assets/index-BoHttkMt.js +1940 -0
  45. package/dist/client/assets/index-DdibEb2O.css +10 -0
  46. package/dist/client/index.html +22 -0
  47. package/package.json +59 -14
  48. package/src/cli.ts +71 -46
  49. package/src/client/App.tsx +510 -0
  50. package/src/client/components/ArtifactCard.tsx +26 -0
  51. package/src/client/components/ArtifactPanel.tsx +138 -0
  52. package/src/client/components/AuthedImage.tsx +85 -0
  53. package/src/client/components/AutocompleteTextarea.tsx +149 -0
  54. package/src/client/components/ChatView.tsx +866 -0
  55. package/src/client/components/CliAuthConsent.tsx +98 -0
  56. package/src/client/components/ConfigPanel.tsx +328 -0
  57. package/src/client/components/ConversationHeader.tsx +156 -0
  58. package/src/client/components/ConversationPane.tsx +277 -0
  59. package/src/client/components/LoginPage.tsx +81 -0
  60. package/src/client/components/MachineStats.tsx +77 -0
  61. package/src/client/components/McpManager.tsx +209 -0
  62. package/src/client/components/MessageInput.tsx +263 -0
  63. package/src/client/components/OAuthConsent.tsx +103 -0
  64. package/src/client/components/SchedulesManager.tsx +99 -0
  65. package/src/client/components/Sidebar.tsx +235 -0
  66. package/src/client/components/SkillsManager.tsx +280 -0
  67. package/src/client/components/SmartChart.tsx +167 -0
  68. package/src/client/components/Toast.tsx +54 -0
  69. package/src/client/components/WorkspaceTree.tsx +313 -0
  70. package/src/client/components/ZoomableImage.tsx +123 -0
  71. package/src/client/components/artifact-presets.ts +10 -0
  72. package/src/client/components/schedules/ScheduleEditor.tsx +264 -0
  73. package/src/client/components/schedules/ScheduleList.tsx +271 -0
  74. package/src/client/components/ui/accordion.tsx +50 -0
  75. package/src/client/components/ui/button.tsx +43 -0
  76. package/src/client/components/ui/dialog.tsx +82 -0
  77. package/src/client/components/ui/input.tsx +19 -0
  78. package/src/client/components/ui/scroll-area.tsx +39 -0
  79. package/src/client/components/ui/textarea.tsx +18 -0
  80. package/src/client/globals.css +51 -0
  81. package/src/client/hooks/useAgentSocket.ts +79 -0
  82. package/src/client/hooks/useArtifacts.ts +89 -0
  83. package/src/client/hooks/useAuth.ts +127 -0
  84. package/src/client/hooks/useConversation.ts +412 -0
  85. package/src/client/hooks/useDarkMode.ts +57 -0
  86. package/src/client/hooks/useIsMobile.ts +23 -0
  87. package/src/client/hooks/usePush.ts +127 -0
  88. package/src/client/hooks/useSchedules.ts +73 -0
  89. package/src/client/hooks/useUnread.ts +238 -0
  90. package/src/client/lib/desktopAttention.ts +75 -0
  91. package/src/client/lib/firebase.ts +32 -0
  92. package/src/client/lib/googleAuthNative.ts +94 -0
  93. package/src/client/lib/native.ts +43 -0
  94. package/src/client/lib/schedule-types.ts +34 -0
  95. package/src/client/lib/sessionApi.ts +58 -0
  96. package/src/client/lib/slots.tsx +79 -0
  97. package/src/client/lib/storage.ts +39 -0
  98. package/src/client/lib/utils.ts +26 -0
  99. package/src/client/lib/workspaceContext.tsx +54 -0
  100. package/src/client/lib/ws.ts +203 -0
  101. package/src/client/main.tsx +14 -0
  102. package/src/mcp-stdio-bridge.ts +70 -0
  103. package/src/scripts/summarize-conversations.ts +5 -0
  104. package/src/scripts/typecheck.ts +43 -0
  105. package/src/server/agents.ts +54 -0
  106. package/src/server/api-keys.ts +63 -0
  107. package/src/server/artifacts/artifacts.export.ts +85 -0
  108. package/src/server/artifacts/artifacts.handler.ts +93 -0
  109. package/src/server/artifacts/artifacts.routes.ts +43 -0
  110. package/src/server/artifacts/artifacts.service.ts +100 -0
  111. package/src/server/artifacts/artifacts.types.ts +31 -0
  112. package/src/server/auth.ts +262 -0
  113. package/src/server/claude.ts +394 -0
  114. package/src/server/commands.ts +21 -0
  115. package/src/server/contacts.ts +177 -0
  116. package/src/server/conversation-summarizer.ts +204 -0
  117. package/src/server/data-sync.ts +664 -0
  118. package/src/server/directives.ts +91 -0
  119. package/src/server/engine/claude-code.ts +514 -0
  120. package/src/server/engine/index.ts +41 -0
  121. package/src/server/engine/registry.ts +21 -0
  122. package/src/server/engine/shared.ts +47 -0
  123. package/src/server/engine/types.ts +48 -0
  124. package/src/server/env-resolve.ts +71 -0
  125. package/src/server/env-sanitize.ts +9 -0
  126. package/src/server/events/bus.ts +29 -0
  127. package/src/server/events/dispatcher.ts +48 -0
  128. package/src/server/events/routes.ts +19 -0
  129. package/src/server/events/types.ts +9 -0
  130. package/src/server/extensions.ts +101 -0
  131. package/src/server/features.ts +109 -0
  132. package/src/server/file-inject.ts +45 -0
  133. package/src/server/hooks.ts +142 -0
  134. package/src/server/idempotency.ts +25 -0
  135. package/src/server/index.ts +1715 -0
  136. package/src/server/integrity-audit.ts +132 -0
  137. package/src/server/mcp-catalog.ts +70 -0
  138. package/src/server/mcp-oauth.ts +198 -0
  139. package/src/server/mcp-progress.ts +45 -0
  140. package/src/server/mcp-server.ts +456 -0
  141. package/src/server/mcp-sidecar.ts +87 -0
  142. package/src/server/mcp.ts +291 -0
  143. package/src/server/model-aliases.ts +76 -0
  144. package/src/server/paths.ts +24 -0
  145. package/src/server/polls.ts +175 -0
  146. package/src/server/push/apns.ts +113 -0
  147. package/src/server/push/fcm.ts +108 -0
  148. package/src/server/push/push.ts +66 -0
  149. package/src/server/push/store.ts +84 -0
  150. package/src/server/push/triggers.ts +99 -0
  151. package/src/server/scheduler/builtins.ts +157 -0
  152. package/src/server/scheduler/engine.ts +432 -0
  153. package/src/server/scheduler/index.ts +4 -0
  154. package/src/server/scheduler/runner.ts +334 -0
  155. package/src/server/scheduler/storage.ts +98 -0
  156. package/src/server/scheduler/timing.ts +70 -0
  157. package/src/server/scheduler/types.ts +62 -0
  158. package/src/server/sdk-utils.ts +45 -0
  159. package/src/server/seed.ts +174 -0
  160. package/src/server/session-bus.ts +18 -0
  161. package/src/server/sessions.ts +559 -0
  162. package/src/server/shraga-config.ts +167 -0
  163. package/src/server/skills.ts +372 -0
  164. package/src/server/slack/api.ts +37 -0
  165. package/src/server/slack/bot.ts +391 -0
  166. package/src/server/slack/context-cache.ts +42 -0
  167. package/src/server/slack/feature.ts +59 -0
  168. package/src/server/slack/mention-rewrite.ts +59 -0
  169. package/src/server/slack/oauth.ts +102 -0
  170. package/src/server/slack/questions.ts +112 -0
  171. package/src/server/slack/sessions.ts +139 -0
  172. package/src/server/stats.ts +106 -0
  173. package/src/server/summarize.ts +11 -0
  174. package/src/server/turn-context.ts +61 -0
  175. package/src/server/unclaw-config.ts +19 -0
  176. package/src/server/unread.ts +79 -0
  177. package/src/server/user-context.ts +33 -0
  178. package/src/server/vendor-sync.ts +52 -0
  179. package/src/server/voice-provider.ts +74 -0
  180. package/src/server/workspace.ts +249 -0
@@ -0,0 +1,510 @@
1
+ import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { useAuth } from '@/hooks/useAuth';
4
+ import { useAgentSocket } from '@/hooks/useAgentSocket';
5
+ import { usePush } from '@/hooks/usePush';
6
+ import { initDesktopAttention, requestDesktopAttention } from '@/lib/desktopAttention';
7
+ import { apiFetch } from '@/lib/sessionApi';
8
+ import { WorkspaceProvider, type AgentConfig, type WorkspaceContextValue } from '@/lib/workspaceContext';
9
+ import { SlotsProvider, useSlots } from '@/lib/slots';
10
+ import { LoginPage } from '@/components/LoginPage';
11
+ import { OAuthConsent } from '@/components/OAuthConsent';
12
+ import { CliAuthConsent } from '@/components/CliAuthConsent';
13
+ import { Sidebar } from '@/components/Sidebar';
14
+ import { ConversationPane } from '@/components/ConversationPane';
15
+ import { McpManager } from '@/components/McpManager';
16
+ import { SkillsManager } from '@/components/SkillsManager';
17
+ import { SchedulesManager, type SchedulesManagerHandle } from '@/components/SchedulesManager';
18
+ import type { ServerEvent } from '@/lib/ws';
19
+ import type { WorkspaceEntry } from '@/components/WorkspaceTree';
20
+ import { Button } from '@/components/ui/button';
21
+ import {
22
+ LogOut,
23
+ PanelLeftClose,
24
+ PanelLeft,
25
+ Sun,
26
+ Moon,
27
+ Clock,
28
+ EllipsisVertical,
29
+ Settings,
30
+ BookOpen,
31
+ SquarePen,
32
+ } from 'lucide-react';
33
+ import { useDarkMode } from '@/hooks/useDarkMode';
34
+ import { useUnread } from '@/hooks/useUnread';
35
+ import { ToastStack } from '@/components/Toast';
36
+
37
+ function getSessionFromUrl(): string | undefined {
38
+ return new URLSearchParams(window.location.search).get('session') || undefined;
39
+ }
40
+
41
+ function syncSessionToUrl(id: string | undefined) {
42
+ const url = new URL(window.location.href);
43
+ if (id) url.searchParams.set('session', id);
44
+ else url.searchParams.delete('session');
45
+ const changed = url.toString() !== window.location.href;
46
+ if (id && changed) window.history.pushState(null, '', url.toString());
47
+ else window.history.replaceState(null, '', url.toString());
48
+ }
49
+
50
+ export function App() {
51
+ // The core ships an empty slot set → no add-on module is imported, so the built bundle carries no
52
+ // add-on code (multi-pane layout, extra input controls, …). A build swaps this value.
53
+ return (
54
+ <SlotsProvider value={{}}>
55
+ <AppInner />
56
+ </SlotsProvider>
57
+ );
58
+ }
59
+
60
+ function AppInner() {
61
+ const slots = useSlots();
62
+ const { dark, toggle: toggleDark } = useDarkMode();
63
+ const { user, token, getToken, loading, mode, needsSetup, loginLocal, registerLocal, logout } = useAuth();
64
+ const [sidebarOpen, _setSidebarOpen] = useState(() => localStorage.getItem('shraga:sidebarOpen') !== 'false');
65
+ const setSidebarOpen = useCallback((v: boolean | ((prev: boolean) => boolean)) => {
66
+ _setSidebarOpen((prev) => {
67
+ const next = typeof v === 'function' ? v(prev) : v;
68
+ localStorage.setItem('shraga:sidebarOpen', String(next));
69
+ return next;
70
+ });
71
+ }, []);
72
+ const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
73
+ const [mobileMenuPos, setMobileMenuPos] = useState<{ top: number; right: number }>({ top: 52, right: 8 });
74
+ const mobileMenuBtnRef = useRef<HTMLButtonElement>(null);
75
+ const openMobileMenu = useCallback(() => {
76
+ const r = mobileMenuBtnRef.current?.getBoundingClientRect();
77
+ if (r) setMobileMenuPos({ top: r.bottom + 4, right: Math.max(4, window.innerWidth - r.right) });
78
+ setMobileMenuOpen((v) => !v);
79
+ }, []);
80
+ const [agentConfig, setAgentConfig] = useState<AgentConfig>({});
81
+ const schedulesRef = useRef<SchedulesManagerHandle>(null);
82
+ const [sidebarRefresh, setSidebarRefresh] = useState(0);
83
+ const [scheduleRefresh, setScheduleRefresh] = useState(0);
84
+ const [runningScheduleIds, setRunningScheduleIds] = useState<Set<string>>(new Set());
85
+ const [skills, setSkills] = useState<string[]>([]);
86
+ const [workspaceEntries, setWorkspaceEntries] = useState<WorkspaceEntry[]>([]);
87
+ const [workspaceRefreshKey, setWorkspaceRefreshKey] = useState(0);
88
+ const [busySessions, setBusySessions] = useState<Set<string>>(new Set());
89
+ const [pushEnabled, setPushEnabled] = useState(false);
90
+ // Optimistic auth: if this device authorized before, render immediately on reload instead of
91
+ // blocking behind "Verifying access…"; the revalidation below still flips to false on a real 403.
92
+ const [authorized, setAuthorized] = useState<boolean | null>(
93
+ () => (localStorage.getItem('shraga:authorized') === '1' ? true : null),
94
+ );
95
+
96
+ // Core session selection: a single conversation pane, so "the active session" is plain local state
97
+ // (no tabs / multi-pane layout). A fresh chat is `undefined`; the pane adopts a real id on first turn.
98
+ const [activeSessionId, setActiveSessionId] = useState<string | undefined>(() => getSessionFromUrl());
99
+ const openSession = useCallback((id: string, _title?: string) => setActiveSessionId(id), []);
100
+ const assignSession = useCallback((_nodeId: string, id: string) => setActiveSessionId(id), []);
101
+ const renameTab = useCallback((_nodeId: string, _title: string) => {}, []); // no tabs in this build
102
+
103
+ const bumpSidebar = useCallback(() => setSidebarRefresh((n) => n + 1), []);
104
+
105
+ // Verify server-side authorization before showing main UI
106
+ const wasAuthorized = useRef(false);
107
+ useEffect(() => {
108
+ if (!token) {
109
+ if (!wasAuthorized.current && localStorage.getItem('shraga:authorized') !== '1') setAuthorized(null);
110
+ return;
111
+ }
112
+ getToken().then(async (t) => {
113
+ if (!t) return setAuthorized(false);
114
+ try {
115
+ const res = await fetch('/api/config', { headers: { Authorization: `Bearer ${t}` } });
116
+ if (res.ok) {
117
+ wasAuthorized.current = true;
118
+ localStorage.setItem('shraga:authorized', '1');
119
+ return setAuthorized(true);
120
+ }
121
+ const body = await res.json().catch(() => ({}));
122
+ if (res.status === 401 && (body.error?.includes('audience') || body.error?.includes('aud'))) {
123
+ console.warn('[auth] Token audience mismatch — signing out stale session');
124
+ logout();
125
+ return;
126
+ }
127
+ if (res.status === 403 && body.error?.toLowerCase().includes('whitelist')) {
128
+ localStorage.removeItem('shraga:authorized');
129
+ return setAuthorized(false);
130
+ }
131
+ if (wasAuthorized.current) return;
132
+ setAuthorized(res.status !== 401 && res.status !== 403);
133
+ } catch {
134
+ if (wasAuthorized.current) return;
135
+ setAuthorized(true);
136
+ }
137
+ });
138
+ }, [token, getToken]);
139
+
140
+ const refreshWorkspace = useCallback(() => {
141
+ if (!token) return;
142
+ setWorkspaceRefreshKey((n) => n + 1);
143
+ apiFetch('/api/workspace', getToken)
144
+ .then((r) => r.json())
145
+ .then((data: { entries: WorkspaceEntry[] }) => setWorkspaceEntries(data.entries ?? []))
146
+ .catch((err) => console.warn('[workspace] refresh failed', err));
147
+ }, [token, getToken]);
148
+
149
+ const handleScheduleEvent = useCallback((event: Extract<ServerEvent, { type: `schedule:${string}` }>) => {
150
+ setScheduleRefresh((n) => n + 1);
151
+ if (event.type === 'schedule:run_started') {
152
+ setRunningScheduleIds((prev) => new Set(prev).add(event.scheduleId));
153
+ bumpSidebar();
154
+ } else if (event.type === 'schedule:run_finished') {
155
+ setRunningScheduleIds((prev) => {
156
+ const next = new Set(prev);
157
+ next.delete(event.scheduleId);
158
+ return next;
159
+ });
160
+ bumpSidebar();
161
+ } else if (event.type === 'schedule:deleted') {
162
+ setRunningScheduleIds((prev) => {
163
+ const next = new Set(prev);
164
+ next.delete(event.id);
165
+ return next;
166
+ });
167
+ }
168
+ }, [bumpSidebar]);
169
+
170
+ const markReadRef = useRef<((sid: string) => void) | null>(null);
171
+ const unreadEventRef = useRef<((event: ServerEvent) => void) | null>(null);
172
+ const activeSessionRef = useRef(activeSessionId);
173
+ activeSessionRef.current = activeSessionId;
174
+
175
+ // App-global socket-event sink: sidebar / unread / schedules / busy-set. Per-conversation message
176
+ // state and the model pill are handled by useConversation inside the ConversationPane.
177
+ const handleGlobalEvent = useCallback(
178
+ (event: ServerEvent) => {
179
+ unreadEventRef.current?.(event);
180
+ if (event.type.startsWith('schedule:')) {
181
+ handleScheduleEvent(event as Extract<ServerEvent, { type: `schedule:${string}` }>);
182
+ return;
183
+ }
184
+ if (event.type === 'permission_request') requestDesktopAttention(true);
185
+ switch (event.type) {
186
+ case 'workspace_change':
187
+ refreshWorkspace();
188
+ break;
189
+ case 'session_title_updated':
190
+ bumpSidebar();
191
+ break;
192
+ case 'session_messages_changed':
193
+ bumpSidebar();
194
+ if (event.sessionId === activeSessionRef.current) markReadRef.current?.(event.sessionId);
195
+ break;
196
+ case 'session_busy':
197
+ setBusySessions((prev) => {
198
+ const next = new Set(prev);
199
+ if (event.busy) next.add(event.sessionId);
200
+ else next.delete(event.sessionId);
201
+ return next;
202
+ });
203
+ if (!event.busy) bumpSidebar();
204
+ break;
205
+ }
206
+ },
207
+ [handleScheduleEvent, refreshWorkspace, bumpSidebar],
208
+ );
209
+
210
+ const { socket, connectionStatus, authError } = useAgentSocket(token, getToken, handleGlobalEvent);
211
+
212
+ const { unreads, toasts, markRead, dismissToast, addToast, handleUnreadEvent } = useUnread(socket, activeSessionId);
213
+ unreadEventRef.current = handleUnreadEvent;
214
+ markReadRef.current = markRead;
215
+
216
+ // Native push + client presence. No-op in a plain browser (initNative resolves false). This build
217
+ // has no multi-instance switching, so a foreign-instance tap just opens the session here.
218
+ usePush({
219
+ socket,
220
+ getToken,
221
+ pushEnabled,
222
+ activeSessionId,
223
+ switchTo: () => {},
224
+ openSession,
225
+ onForegroundMessage: (m) => {
226
+ const sid = typeof m.data?.session === 'string' ? m.data.session : undefined;
227
+ if (sid) addToast({ sessionId: sid, preview: m.body || 'New message', title: m.title });
228
+ },
229
+ });
230
+
231
+ useEffect(() => {
232
+ initDesktopAttention();
233
+ }, []);
234
+
235
+ // Active session → shareable URL (+ mark its conversation read).
236
+ useEffect(() => {
237
+ syncSessionToUrl(activeSessionId);
238
+ if (activeSessionId) markRead(activeSessionId);
239
+ }, [activeSessionId, markRead]);
240
+
241
+ // Browser back/forward → select that session.
242
+ useEffect(() => {
243
+ const onPopState = () => setActiveSessionId(getSessionFromUrl());
244
+ window.addEventListener('popstate', onPopState);
245
+ return () => window.removeEventListener('popstate', onPopState);
246
+ }, []);
247
+
248
+ // Load config / skills / workspace / features
249
+ useEffect(() => {
250
+ if (!token) return;
251
+ apiFetch('/api/config', getToken).then((r) => r.json()).then(setAgentConfig).catch(() => {});
252
+ apiFetch('/api/skills', getToken)
253
+ .then((r) => r.json())
254
+ .then((data: { skills: string[]; builtins: string[] }) => setSkills(data.skills))
255
+ .catch(() => {});
256
+ apiFetch('/api/features', getToken)
257
+ .then((r) => r.json())
258
+ .then((f: { push?: boolean }) => setPushEnabled(!!f.push))
259
+ .catch(() => {});
260
+ }, [token, getToken]);
261
+ useEffect(() => {
262
+ refreshWorkspace();
263
+ }, [refreshWorkspace]);
264
+
265
+ const newChat = useCallback(() => {
266
+ setActiveSessionId(undefined);
267
+ if (window.innerWidth < 640) setSidebarOpen(false);
268
+ }, [setSidebarOpen]);
269
+
270
+ const workspaceFiles = useMemo(
271
+ () => workspaceEntries.filter((e) => e.type === 'file').map((e) => e.path),
272
+ [workspaceEntries],
273
+ );
274
+
275
+ const workspaceCtx = useMemo<WorkspaceContextValue>(
276
+ () => ({
277
+ socket,
278
+ connectionStatus,
279
+ getToken,
280
+ token,
281
+ agentConfig,
282
+ setAgentConfig,
283
+ activeSessionId,
284
+ skills,
285
+ workspaceFiles,
286
+ openSchedules: () => schedulesRef.current?.open(),
287
+ markRead,
288
+ bumpSidebar,
289
+ assignSession,
290
+ renameTab,
291
+ openSession,
292
+ }),
293
+ [socket, connectionStatus, getToken, token, agentConfig, activeSessionId, skills, workspaceFiles, markRead, bumpSidebar, assignSession, renameTab, openSession],
294
+ );
295
+
296
+ if (loading) {
297
+ return <div className="flex h-full items-center justify-center text-muted-foreground text-sm">Loading…</div>;
298
+ }
299
+
300
+ if (!user || !token) return <LoginPage mode={mode} needsSetup={needsSetup} onLoginLocal={loginLocal} onRegisterLocal={registerLocal} />;
301
+
302
+ if (authorized === null) {
303
+ return <div className="flex h-full items-center justify-center text-muted-foreground text-sm">Verifying access…</div>;
304
+ }
305
+
306
+ if (!authorized || authError) {
307
+ return (
308
+ <div className="flex h-full items-center justify-center bg-background">
309
+ <div className="flex flex-col items-center gap-4 p-8 rounded-xl border bg-card shadow-sm w-full max-w-sm text-center">
310
+ <div className="flex items-center justify-center w-12 h-12 rounded-full bg-destructive/10 text-destructive">
311
+ <LogOut className="w-6 h-6" />
312
+ </div>
313
+ <h2 className="text-lg font-semibold">Access Denied</h2>
314
+ <p className="text-sm text-muted-foreground">Your account is not authorized to use this app.</p>
315
+ <p className="text-xs text-muted-foreground">Signed in as {user.email}</p>
316
+ <Button variant="outline" onClick={() => logout()}>
317
+ Sign out
318
+ </Button>
319
+ </div>
320
+ </div>
321
+ );
322
+ }
323
+
324
+ if (window.location.pathname === '/oauth/authorize') {
325
+ return <OAuthConsent getToken={getToken} userEmail={user.email ?? ''} />;
326
+ }
327
+
328
+ if (window.location.pathname === '/cli-auth') {
329
+ return <CliAuthConsent getToken={getToken} userEmail={user.email ?? ''} />;
330
+ }
331
+
332
+ return (
333
+ <div className="flex h-full">
334
+ {/* Sidebar */}
335
+ {sidebarOpen && (
336
+ <div className="fixed inset-0 z-50 bg-background sm:relative sm:inset-auto sm:z-auto sm:w-64 sm:shrink-0 sm:border-r flex flex-col">
337
+ <div
338
+ className="flex items-center justify-between px-3 py-2 border-b sm:hidden"
339
+ style={{ paddingTop: 'max(0.5rem, env(safe-area-inset-top))' }}
340
+ >
341
+ <span className="text-sm font-medium">Sessions</span>
342
+ <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setSidebarOpen(false)}>
343
+ <PanelLeftClose className="w-4 h-4" />
344
+ </Button>
345
+ </div>
346
+ <div className="flex-1 min-h-0">
347
+ <Sidebar
348
+ getToken={getToken}
349
+ activeSessionId={activeSessionId}
350
+ onSelect={(id, title) => {
351
+ openSession(id, title);
352
+ markRead(id);
353
+ if (window.innerWidth < 640) setSidebarOpen(false);
354
+ }}
355
+ onNew={newChat}
356
+ refreshKey={sidebarRefresh}
357
+ workspaceRefreshKey={workspaceRefreshKey}
358
+ onRefreshWorkspace={refreshWorkspace}
359
+ userUid={user.uid}
360
+ userEmail={user.email || ''}
361
+ unreads={unreads}
362
+ busySessions={busySessions}
363
+ socket={socket}
364
+ />
365
+ </div>
366
+ </div>
367
+ )}
368
+
369
+ {/* Main */}
370
+ <div className="flex-1 flex flex-col min-w-0">
371
+ <header
372
+ className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
373
+ style={{ paddingTop: 'env(safe-area-inset-top)' }}
374
+ >
375
+ <div className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5">
376
+ <Button
377
+ variant="ghost"
378
+ size="icon"
379
+ className="h-9 w-9 sm:h-8 sm:w-8 shrink-0"
380
+ onClick={() => setSidebarOpen(!sidebarOpen)}
381
+ title="Toggle sidebar"
382
+ >
383
+ {sidebarOpen ? <PanelLeftClose className="w-4 h-4" /> : <PanelLeft className="w-4 h-4" />}
384
+ </Button>
385
+ <Button variant="ghost" size="icon" className="h-9 w-9 sm:h-8 sm:w-8 shrink-0" onClick={newChat} title="New chat (⌘N)">
386
+ <SquarePen className="w-4 h-4" />
387
+ </Button>
388
+
389
+ <div className="flex-1" />
390
+
391
+ {/* Desktop: inline global tools */}
392
+ <div className="hidden sm:flex items-center gap-0.5 shrink-0">
393
+ <Button variant="ghost" size="icon" className="h-8 w-8" onClick={toggleDark} title={dark ? 'Light mode' : 'Dark mode'}>
394
+ {dark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
395
+ </Button>
396
+ {slots.headerActions?.()}
397
+ <McpManager getToken={getToken} />
398
+ <SkillsManager getToken={getToken} onSkillsChange={setSkills} />
399
+ <SchedulesManager
400
+ ref={schedulesRef}
401
+ getToken={getToken}
402
+ refreshKey={scheduleRefresh}
403
+ runningIds={runningScheduleIds}
404
+ onOpenSession={(sid) => openSession(sid)}
405
+ skills={skills}
406
+ workspaceFiles={workspaceFiles}
407
+ />
408
+ <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => logout()} title="Sign out">
409
+ <LogOut className="w-4 h-4" />
410
+ </Button>
411
+ </div>
412
+ {/* Mobile: 3-dot menu */}
413
+ <div className="relative sm:hidden shrink-0">
414
+ <Button ref={mobileMenuBtnRef} variant="ghost" size="icon" className="h-9 w-9" onClick={openMobileMenu}>
415
+ <EllipsisVertical className="w-4 h-4" />
416
+ </Button>
417
+ {mobileMenuOpen && createPortal(
418
+ <>
419
+ <div className="fixed inset-0 z-[1090] cursor-pointer" onClick={() => setMobileMenuOpen(false)} />
420
+ <div
421
+ className="fixed z-[1100] border rounded-lg shadow-lg p-1 min-w-[170px] bg-white dark:bg-zinc-900 whitespace-nowrap"
422
+ style={{ top: mobileMenuPos.top, right: mobileMenuPos.right }}
423
+ >
424
+ {slots.headerActions?.()}
425
+ <McpManager
426
+ getToken={getToken}
427
+ trigger={
428
+ <button className="flex w-full items-center gap-2.5 rounded-md px-3 py-2.5 text-sm hover:bg-accent">
429
+ <Settings className="w-3.5 h-3.5" /> MCPs
430
+ </button>
431
+ }
432
+ />
433
+ <SkillsManager
434
+ getToken={getToken}
435
+ onSkillsChange={setSkills}
436
+ trigger={
437
+ <button className="flex w-full items-center gap-2.5 rounded-md px-3 py-2.5 text-sm hover:bg-accent">
438
+ <BookOpen className="w-3.5 h-3.5" /> Skills
439
+ </button>
440
+ }
441
+ />
442
+ <SchedulesManager
443
+ getToken={getToken}
444
+ refreshKey={scheduleRefresh}
445
+ runningIds={runningScheduleIds}
446
+ onOpenSession={(sid) => openSession(sid)}
447
+ skills={skills}
448
+ workspaceFiles={workspaceFiles}
449
+ trigger={
450
+ <button className="flex w-full items-center gap-2.5 rounded-md px-3 py-2.5 text-sm hover:bg-accent">
451
+ <Clock className="w-3.5 h-3.5" /> Schedules
452
+ </button>
453
+ }
454
+ />
455
+ <div className="border-t my-1" />
456
+ <button
457
+ className="flex w-full items-center gap-2.5 rounded-md px-3 py-2.5 text-sm hover:bg-accent"
458
+ onClick={() => {
459
+ setMobileMenuOpen(false);
460
+ toggleDark();
461
+ }}
462
+ >
463
+ {dark ? <Sun className="w-3.5 h-3.5" /> : <Moon className="w-3.5 h-3.5" />}
464
+ {dark ? 'Light mode' : 'Dark mode'}
465
+ </button>
466
+ <button
467
+ className="flex w-full items-center gap-2.5 rounded-md px-3 py-2.5 text-sm hover:bg-accent"
468
+ onClick={() => logout()}
469
+ >
470
+ <LogOut className="w-3.5 h-3.5" /> Sign out
471
+ </button>
472
+ </div>
473
+ </>,
474
+ document.body,
475
+ )}
476
+ </div>
477
+ </div>
478
+ </header>
479
+
480
+ {connectionStatus === 'update_available' && (
481
+ <div className="flex items-center justify-center gap-3 py-2.5 px-4 bg-amber-500 dark:bg-amber-600 text-white text-sm font-medium animate-pulse">
482
+ <span>A new version is available</span>
483
+ <button
484
+ onClick={() => window.location.reload()}
485
+ className="px-3 py-1 bg-white text-amber-700 rounded font-semibold text-xs hover:bg-amber-50 transition-colors"
486
+ >
487
+ Reload now
488
+ </button>
489
+ </div>
490
+ )}
491
+
492
+ <WorkspaceProvider value={workspaceCtx}>
493
+ {/* Core chat-only: a single conversation pane for the active session (no tabs / multi-pane layout). */}
494
+ <div className="flex-1 min-h-0">
495
+ <ConversationPane key={activeSessionId ?? 'new'} nodeId="main" sessionId={activeSessionId} />
496
+ </div>
497
+ </WorkspaceProvider>
498
+ </div>
499
+
500
+ <ToastStack
501
+ toasts={toasts}
502
+ onDismiss={dismissToast}
503
+ onOpen={(sid) => {
504
+ openSession(sid);
505
+ markRead(sid);
506
+ }}
507
+ />
508
+ </div>
509
+ );
510
+ }
@@ -0,0 +1,26 @@
1
+ import { Image } from 'lucide-react';
2
+ import type { ArtifactMeta } from '@/hooks/useArtifacts';
3
+
4
+ interface Props {
5
+ artifact: ArtifactMeta;
6
+ onClick: () => void;
7
+ }
8
+
9
+ export function ArtifactCard({ artifact, onClick }: Props) {
10
+ return (
11
+ <button
12
+ onClick={onClick}
13
+ className="flex items-center gap-2 px-3 py-2 rounded-lg border bg-card hover:bg-accent/50 transition-colors text-left w-full max-w-xs"
14
+ >
15
+ <div className="flex items-center justify-center w-8 h-8 rounded bg-muted shrink-0">
16
+ <Image className="w-4 h-4 text-muted-foreground" />
17
+ </div>
18
+ <div className="min-w-0 flex-1">
19
+ <p className="text-sm font-medium truncate">{artifact.title}</p>
20
+ <p className="text-[10px] text-muted-foreground">
21
+ {artifact.dimensions[0]}×{artifact.dimensions[1]} · v{artifact.version}
22
+ </p>
23
+ </div>
24
+ </button>
25
+ );
26
+ }
@@ -0,0 +1,138 @@
1
+ import { useState, useRef, useEffect, useCallback } from 'react';
2
+ import { X, Maximize2, Minimize2, Image, ArrowLeft } from 'lucide-react';
3
+ import { Button } from '@/components/ui/button';
4
+ import { cn } from '@/lib/utils';
5
+ import type { ArtifactMeta } from '@/hooks/useArtifacts';
6
+ import { DIMENSION_PRESETS } from './artifact-presets';
7
+
8
+ interface Props {
9
+ artifacts: ArtifactMeta[];
10
+ selectedId: string | null;
11
+ sessionId: string;
12
+ getToken: () => Promise<string | null>;
13
+ onSelect: (id: string) => void;
14
+ onClose: () => void;
15
+ }
16
+
17
+ export function ArtifactPanel({ artifacts, selectedId, sessionId, getToken, onSelect, onClose }: Props) {
18
+ const [expanded, setExpanded] = useState(false);
19
+ const [exporting, setExporting] = useState(false);
20
+ const [scale, setScale] = useState(1);
21
+ const containerRef = useRef<HTMLDivElement>(null);
22
+ const selected = artifacts.find(a => a.id === selectedId);
23
+
24
+ const recalcScale = useCallback(() => {
25
+ if (!containerRef.current || !selected) return;
26
+ const { clientWidth, clientHeight } = containerRef.current;
27
+ const pad = 32;
28
+ const availW = clientWidth - pad;
29
+ const availH = clientHeight - pad;
30
+ const [w, h] = selected.dimensions;
31
+ setScale(Math.min(availW / w, availH / h, 1));
32
+ }, [selected]);
33
+
34
+ useEffect(() => {
35
+ recalcScale();
36
+ const el = containerRef.current;
37
+ if (!el) return;
38
+ const ro = new ResizeObserver(recalcScale);
39
+ ro.observe(el);
40
+ return () => ro.disconnect();
41
+ }, [recalcScale]);
42
+
43
+ const handleExport = async () => {
44
+ if (!selected) return;
45
+ setExporting(true);
46
+ try {
47
+ const token = await getToken();
48
+ const res = await fetch(`/api/artifacts/${sessionId}/${selected.id}/export`, {
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
51
+ body: JSON.stringify({ dimensions: selected.dimensions }),
52
+ });
53
+ if (!res.ok) throw new Error(await res.text());
54
+ const blob = await res.blob();
55
+ const url = URL.createObjectURL(blob);
56
+ const a = document.createElement('a');
57
+ a.href = url;
58
+ a.download = `${selected.title.replace(/\s+/g, '-').toLowerCase()}.png`;
59
+ a.click();
60
+ URL.revokeObjectURL(url);
61
+ } catch (err) {
62
+ console.error('[artifact] export failed:', err);
63
+ } finally {
64
+ setExporting(false);
65
+ }
66
+ };
67
+
68
+ const presetLabel = selected
69
+ ? Object.entries(DIMENSION_PRESETS).find(([, d]) => d[0] === selected.dimensions[0] && d[1] === selected.dimensions[1])?.[0]
70
+ : null;
71
+
72
+ const [w, h] = selected?.dimensions ?? [0, 0];
73
+ const scaledW = w * scale;
74
+ const scaledH = h * scale;
75
+
76
+ return (
77
+ <div className={cn(
78
+ 'flex flex-col border-l bg-background transition-all',
79
+ expanded
80
+ ? 'fixed inset-0 z-50'
81
+ : 'fixed inset-0 z-50 sm:relative sm:inset-auto sm:z-auto sm:w-[480px] sm:shrink-0',
82
+ )}>
83
+ {/* Toolbar */}
84
+ <div className="flex items-center gap-1.5 px-2 py-1.5 border-b bg-muted/30">
85
+ <Button variant="ghost" size="icon" className="h-7 w-7 sm:hidden shrink-0" onClick={onClose} title="Back to chat">
86
+ <ArrowLeft className="w-4 h-4" />
87
+ </Button>
88
+ {artifacts.length > 1 && (
89
+ <select
90
+ className="text-xs bg-transparent border rounded px-1 py-0.5 max-w-[160px] truncate"
91
+ value={selectedId ?? ''}
92
+ onChange={e => onSelect(e.target.value)}
93
+ >
94
+ {artifacts.map(a => (
95
+ <option key={a.id} value={a.id}>{a.title}</option>
96
+ ))}
97
+ </select>
98
+ )}
99
+ {artifacts.length <= 1 && selected && (
100
+ <span className="text-xs font-medium truncate max-w-[160px]">{selected.title}</span>
101
+ )}
102
+ {selected && (
103
+ <span className="text-[10px] text-muted-foreground">
104
+ {presetLabel ?? `${w}×${h}`}
105
+ {scale < 1 && ` · ${Math.round(scale * 100)}%`}
106
+ </span>
107
+ )}
108
+ <div className="flex-1" />
109
+ <Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleExport} disabled={!selected || exporting} title="Export PNG">
110
+ {exporting ? <span className="w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin" /> : <Image className="w-3.5 h-3.5" />}
111
+ </Button>
112
+ <Button variant="ghost" size="icon" className="h-6 w-6 hidden sm:inline-flex" onClick={() => setExpanded(!expanded)} title={expanded ? 'Minimize' : 'Maximize'}>
113
+ {expanded ? <Minimize2 className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />}
114
+ </Button>
115
+ <Button variant="ghost" size="icon" className="h-6 w-6 hidden sm:inline-flex" onClick={onClose} title="Close">
116
+ <X className="w-3.5 h-3.5" />
117
+ </Button>
118
+ </div>
119
+
120
+ {/* Iframe container — scaled to fit */}
121
+ <div ref={containerRef} className="flex-1 overflow-hidden flex items-center justify-center bg-muted/10">
122
+ {selected ? (
123
+ <div style={{ width: scaledW, height: scaledH }}>
124
+ <iframe
125
+ src={`/api/artifacts/${sessionId}/${selected.id}`}
126
+ sandbox="allow-scripts"
127
+ className="border shadow-sm bg-white origin-top-left"
128
+ style={{ width: w, height: h, transform: `scale(${scale})` }}
129
+ title={selected.title}
130
+ />
131
+ </div>
132
+ ) : (
133
+ <p className="text-sm text-muted-foreground">No artifact selected</p>
134
+ )}
135
+ </div>
136
+ </div>
137
+ );
138
+ }