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.
- package/README.md +82 -23
- package/defaults/agents/summarizer.md +16 -0
- package/defaults/agents/trace-extractor.md +84 -0
- package/defaults/bin/claude +45 -0
- package/defaults/bin/claude-revive +17 -0
- package/defaults/extensions/README.md +70 -0
- package/defaults/extensions/selftest.ext.ts +43 -0
- package/defaults/extensions/stripe-webhook.ext.ts +58 -0
- package/defaults/gmail-triage-prompt.md +42 -0
- package/defaults/scripts/README +4 -0
- package/defaults/scripts/agent-once.ts +67 -0
- package/defaults/scripts/backfill-slack-usernames.ts +82 -0
- package/defaults/scripts/notifier-throttle.ts +44 -0
- package/defaults/scripts/summarize-conversations.ts +5 -0
- package/defaults/shraga.config.ts +29 -0
- package/defaults/skills/add-skill.md +14 -0
- package/defaults/skills/artifacts.md +116 -0
- package/defaults/skills/code-review.md +26 -0
- package/defaults/skills/communications.md +54 -0
- package/defaults/skills/context-audit.md +87 -0
- package/defaults/skills/debug.md +10 -0
- package/defaults/skills/garden.md +179 -0
- package/defaults/skills/github-contributor.md +35 -0
- package/defaults/skills/identity.md +30 -0
- package/defaults/skills/mcp-server.md +62 -0
- package/defaults/skills/mcps-sync.md +105 -0
- package/defaults/skills/plan.md +9 -0
- package/defaults/skills/platform.md +177 -0
- package/defaults/skills/reconcile.md +239 -0
- package/defaults/skills/scheduler.md +192 -0
- package/defaults/skills/self-aware.md +136 -0
- package/defaults/skills/shraga-know.md +333 -0
- package/defaults/skills/stripe.md +55 -0
- package/defaults/skills/write-tests.md +10 -0
- package/defaults/skills-defaults.json +1 -0
- package/defaults/system-prompt.md +46 -0
- package/defaults/workspace/context.md +28 -0
- package/defaults/workspace.md +50 -0
- package/defaults/zdotdir/.gitignore +8 -0
- package/defaults/zdotdir/.zlogin +3 -0
- package/defaults/zdotdir/.zprofile +1 -0
- package/defaults/zdotdir/.zshenv +4 -0
- package/defaults/zdotdir/.zshrc +3 -0
- package/dist/client/assets/index-BoHttkMt.js +1940 -0
- package/dist/client/assets/index-DdibEb2O.css +10 -0
- package/dist/client/index.html +22 -0
- package/package.json +59 -14
- package/src/cli.ts +71 -46
- package/src/client/App.tsx +510 -0
- package/src/client/components/ArtifactCard.tsx +26 -0
- package/src/client/components/ArtifactPanel.tsx +138 -0
- package/src/client/components/AuthedImage.tsx +85 -0
- package/src/client/components/AutocompleteTextarea.tsx +149 -0
- package/src/client/components/ChatView.tsx +866 -0
- package/src/client/components/CliAuthConsent.tsx +98 -0
- package/src/client/components/ConfigPanel.tsx +328 -0
- package/src/client/components/ConversationHeader.tsx +156 -0
- package/src/client/components/ConversationPane.tsx +277 -0
- package/src/client/components/LoginPage.tsx +81 -0
- package/src/client/components/MachineStats.tsx +77 -0
- package/src/client/components/McpManager.tsx +209 -0
- package/src/client/components/MessageInput.tsx +263 -0
- package/src/client/components/OAuthConsent.tsx +103 -0
- package/src/client/components/SchedulesManager.tsx +99 -0
- package/src/client/components/Sidebar.tsx +235 -0
- package/src/client/components/SkillsManager.tsx +280 -0
- package/src/client/components/SmartChart.tsx +167 -0
- package/src/client/components/Toast.tsx +54 -0
- package/src/client/components/WorkspaceTree.tsx +313 -0
- package/src/client/components/ZoomableImage.tsx +123 -0
- package/src/client/components/artifact-presets.ts +10 -0
- package/src/client/components/schedules/ScheduleEditor.tsx +264 -0
- package/src/client/components/schedules/ScheduleList.tsx +271 -0
- package/src/client/components/ui/accordion.tsx +50 -0
- package/src/client/components/ui/button.tsx +43 -0
- package/src/client/components/ui/dialog.tsx +82 -0
- package/src/client/components/ui/input.tsx +19 -0
- package/src/client/components/ui/scroll-area.tsx +39 -0
- package/src/client/components/ui/textarea.tsx +18 -0
- package/src/client/globals.css +51 -0
- package/src/client/hooks/useAgentSocket.ts +79 -0
- package/src/client/hooks/useArtifacts.ts +89 -0
- package/src/client/hooks/useAuth.ts +127 -0
- package/src/client/hooks/useConversation.ts +412 -0
- package/src/client/hooks/useDarkMode.ts +57 -0
- package/src/client/hooks/useIsMobile.ts +23 -0
- package/src/client/hooks/usePush.ts +127 -0
- package/src/client/hooks/useSchedules.ts +73 -0
- package/src/client/hooks/useUnread.ts +238 -0
- package/src/client/lib/desktopAttention.ts +75 -0
- package/src/client/lib/firebase.ts +32 -0
- package/src/client/lib/googleAuthNative.ts +94 -0
- package/src/client/lib/native.ts +43 -0
- package/src/client/lib/schedule-types.ts +34 -0
- package/src/client/lib/sessionApi.ts +58 -0
- package/src/client/lib/slots.tsx +79 -0
- package/src/client/lib/storage.ts +39 -0
- package/src/client/lib/utils.ts +26 -0
- package/src/client/lib/workspaceContext.tsx +54 -0
- package/src/client/lib/ws.ts +203 -0
- package/src/client/main.tsx +14 -0
- package/src/mcp-stdio-bridge.ts +70 -0
- package/src/scripts/summarize-conversations.ts +5 -0
- package/src/scripts/typecheck.ts +43 -0
- package/src/server/agents.ts +54 -0
- package/src/server/api-keys.ts +63 -0
- package/src/server/artifacts/artifacts.export.ts +85 -0
- package/src/server/artifacts/artifacts.handler.ts +93 -0
- package/src/server/artifacts/artifacts.routes.ts +43 -0
- package/src/server/artifacts/artifacts.service.ts +100 -0
- package/src/server/artifacts/artifacts.types.ts +31 -0
- package/src/server/auth.ts +262 -0
- package/src/server/claude.ts +394 -0
- package/src/server/commands.ts +21 -0
- package/src/server/contacts.ts +177 -0
- package/src/server/conversation-summarizer.ts +204 -0
- package/src/server/data-sync.ts +664 -0
- package/src/server/directives.ts +91 -0
- package/src/server/engine/claude-code.ts +514 -0
- package/src/server/engine/index.ts +41 -0
- package/src/server/engine/registry.ts +21 -0
- package/src/server/engine/shared.ts +47 -0
- package/src/server/engine/types.ts +48 -0
- package/src/server/env-resolve.ts +71 -0
- package/src/server/env-sanitize.ts +9 -0
- package/src/server/events/bus.ts +29 -0
- package/src/server/events/dispatcher.ts +48 -0
- package/src/server/events/routes.ts +19 -0
- package/src/server/events/types.ts +9 -0
- package/src/server/extensions.ts +101 -0
- package/src/server/features.ts +109 -0
- package/src/server/file-inject.ts +45 -0
- package/src/server/hooks.ts +142 -0
- package/src/server/idempotency.ts +25 -0
- package/src/server/index.ts +1715 -0
- package/src/server/integrity-audit.ts +132 -0
- package/src/server/mcp-catalog.ts +70 -0
- package/src/server/mcp-oauth.ts +198 -0
- package/src/server/mcp-progress.ts +45 -0
- package/src/server/mcp-server.ts +456 -0
- package/src/server/mcp-sidecar.ts +87 -0
- package/src/server/mcp.ts +291 -0
- package/src/server/model-aliases.ts +76 -0
- package/src/server/paths.ts +24 -0
- package/src/server/polls.ts +175 -0
- package/src/server/push/apns.ts +113 -0
- package/src/server/push/fcm.ts +108 -0
- package/src/server/push/push.ts +66 -0
- package/src/server/push/store.ts +84 -0
- package/src/server/push/triggers.ts +99 -0
- package/src/server/scheduler/builtins.ts +157 -0
- package/src/server/scheduler/engine.ts +432 -0
- package/src/server/scheduler/index.ts +4 -0
- package/src/server/scheduler/runner.ts +334 -0
- package/src/server/scheduler/storage.ts +98 -0
- package/src/server/scheduler/timing.ts +70 -0
- package/src/server/scheduler/types.ts +62 -0
- package/src/server/sdk-utils.ts +45 -0
- package/src/server/seed.ts +174 -0
- package/src/server/session-bus.ts +18 -0
- package/src/server/sessions.ts +559 -0
- package/src/server/shraga-config.ts +167 -0
- package/src/server/skills.ts +372 -0
- package/src/server/slack/api.ts +37 -0
- package/src/server/slack/bot.ts +391 -0
- package/src/server/slack/context-cache.ts +42 -0
- package/src/server/slack/feature.ts +59 -0
- package/src/server/slack/mention-rewrite.ts +59 -0
- package/src/server/slack/oauth.ts +102 -0
- package/src/server/slack/questions.ts +112 -0
- package/src/server/slack/sessions.ts +139 -0
- package/src/server/stats.ts +106 -0
- package/src/server/summarize.ts +11 -0
- package/src/server/turn-context.ts +61 -0
- package/src/server/unclaw-config.ts +19 -0
- package/src/server/unread.ts +79 -0
- package/src/server/user-context.ts +33 -0
- package/src/server/vendor-sync.ts +52 -0
- package/src/server/voice-provider.ts +74 -0
- 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
|
+
}
|