shraga 0.0.3 → 0.1.2
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 -27
- 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,54 @@
|
|
|
1
|
+
import { X, MessageSquare } from 'lucide-react';
|
|
2
|
+
import { cn } from '@/lib/utils';
|
|
3
|
+
import type { ToastItem } from '@/hooks/useUnread';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
toasts: ToastItem[];
|
|
7
|
+
onDismiss: (id: string) => void;
|
|
8
|
+
onOpen: (sessionId: string) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function ToastStack({ toasts, onDismiss, onOpen }: Props) {
|
|
12
|
+
if (toasts.length === 0) return null;
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div className="fixed bottom-4 right-4 z-[100] flex flex-col-reverse gap-2 max-w-sm w-full pointer-events-none">
|
|
16
|
+
{toasts.map((toast, i) => (
|
|
17
|
+
<div
|
|
18
|
+
key={toast.id}
|
|
19
|
+
className={cn(
|
|
20
|
+
'pointer-events-auto bg-white dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-600 rounded-lg shadow-[0_8px_30px_rgb(0,0,0,0.12)] dark:shadow-[0_8px_30px_rgb(0,0,0,0.5)] p-3 pr-7 cursor-pointer relative',
|
|
21
|
+
'transform transition-all duration-300 ease-out',
|
|
22
|
+
'hover:bg-zinc-50 dark:hover:bg-zinc-700 group',
|
|
23
|
+
i === 0 ? 'animate-in slide-in-from-bottom-2 fade-in' : '',
|
|
24
|
+
)}
|
|
25
|
+
onClick={() => {
|
|
26
|
+
onOpen(toast.sessionId);
|
|
27
|
+
onDismiss(toast.id);
|
|
28
|
+
}}
|
|
29
|
+
>
|
|
30
|
+
<button
|
|
31
|
+
onClick={(e) => {
|
|
32
|
+
e.stopPropagation();
|
|
33
|
+
onDismiss(toast.id);
|
|
34
|
+
}}
|
|
35
|
+
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity p-0.5 hover:bg-muted rounded"
|
|
36
|
+
>
|
|
37
|
+
<X className="w-3 h-3 text-muted-foreground" />
|
|
38
|
+
</button>
|
|
39
|
+
<div className="flex items-start gap-2.5">
|
|
40
|
+
<MessageSquare className="w-4 h-4 mt-0.5 shrink-0 text-blue-500" />
|
|
41
|
+
<div className="flex-1 min-w-0">
|
|
42
|
+
<div className="text-xs font-medium text-foreground truncate">
|
|
43
|
+
{toast.title || `Session ${toast.sessionId.slice(0, 8)}`}
|
|
44
|
+
</div>
|
|
45
|
+
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
|
46
|
+
{toast.preview}
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
))}
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { ChevronRight, FileText, Folder, RefreshCw, History, Maximize2, Minimize2, ScanSearch, Move, Download } from 'lucide-react';
|
|
3
|
+
import ReactMarkdown from 'react-markdown';
|
|
4
|
+
import remarkGfm from 'remark-gfm';
|
|
5
|
+
import rehypeHighlight from 'rehype-highlight';
|
|
6
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
|
7
|
+
import { cn } from '@/lib/utils';
|
|
8
|
+
|
|
9
|
+
export interface WorkspaceEntry {
|
|
10
|
+
path: string;
|
|
11
|
+
type: 'file' | 'dir';
|
|
12
|
+
size?: number;
|
|
13
|
+
oneLiner?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
getToken: () => Promise<string | null>;
|
|
18
|
+
onRefresh?: () => void;
|
|
19
|
+
refreshKey?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico', 'bmp', 'avif']);
|
|
23
|
+
const EMBED_EXTS = new Set(['pdf', 'mp4', 'webm', 'mp3', 'ogg', 'wav']);
|
|
24
|
+
|
|
25
|
+
async function fetchDir(dir: string, getToken: () => Promise<string | null>): Promise<WorkspaceEntry[]> {
|
|
26
|
+
const token = await getToken();
|
|
27
|
+
if (!token) return [];
|
|
28
|
+
const res = await fetch(`/api/workspace/ls?path=${encodeURIComponent(dir)}`, {
|
|
29
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
30
|
+
});
|
|
31
|
+
if (!res.ok) return [];
|
|
32
|
+
const data = await res.json();
|
|
33
|
+
return data.entries ?? [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function WorkspaceTree({ getToken, onRefresh, refreshKey }: Props) {
|
|
37
|
+
const [dirEntries, setDirEntries] = useState<Map<string, WorkspaceEntry[]>>(new Map());
|
|
38
|
+
const [openDirs, setOpenDirs] = useState<Set<string>>(() => new Set());
|
|
39
|
+
const [preview, setPreview] = useState<{ path: string; content: string; binary: boolean; html?: boolean; image?: boolean } | null>(null);
|
|
40
|
+
const [maximized, setMaximized] = useState(false);
|
|
41
|
+
const [fitMode, setFitMode] = useState<'fit' | 'scroll'>('fit');
|
|
42
|
+
const [iframeScale, setIframeScale] = useState<{ scale: number; w: number; h: number } | null>(null);
|
|
43
|
+
const [contentSize, setContentSize] = useState<{ w: number; h: number } | null>(null);
|
|
44
|
+
const iframeContainerRef = useRef<HTMLDivElement>(null);
|
|
45
|
+
const [cachedToken, setCachedToken] = useState<string | null>(null);
|
|
46
|
+
const [showLog, setShowLog] = useState(false);
|
|
47
|
+
const [logEntries, setLogEntries] = useState<{ hash: string; date: string; author: string; message: string; files: string[] }[]>([]);
|
|
48
|
+
|
|
49
|
+
const loadDir = useCallback(async (dir: string) => {
|
|
50
|
+
const entries = await fetchDir(dir, getToken);
|
|
51
|
+
setDirEntries(prev => new Map(prev).set(dir, entries));
|
|
52
|
+
}, [getToken]);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
loadDir('');
|
|
56
|
+
openDirs.forEach(d => loadDir(d));
|
|
57
|
+
}, [loadDir, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
58
|
+
|
|
59
|
+
const rootEntries = dirEntries.get('') ?? [];
|
|
60
|
+
|
|
61
|
+
const detectContentSize = useCallback((iframe: HTMLIFrameElement) => {
|
|
62
|
+
try {
|
|
63
|
+
const doc = iframe.contentDocument;
|
|
64
|
+
if (!doc?.body) return;
|
|
65
|
+
const contentW = Math.max(doc.body.offsetWidth, doc.body.scrollWidth);
|
|
66
|
+
const contentH = Math.max(doc.body.offsetHeight, doc.body.scrollHeight);
|
|
67
|
+
if (contentW && contentH) setContentSize({ w: contentW, h: contentH });
|
|
68
|
+
} catch { /* cross-origin */ }
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
const recalcIframeScale = useCallback(() => {
|
|
72
|
+
const container = iframeContainerRef.current;
|
|
73
|
+
const size = contentSize;
|
|
74
|
+
if (!container || !size) return;
|
|
75
|
+
const pad = 16;
|
|
76
|
+
const availW = container.clientWidth - pad;
|
|
77
|
+
const availH = container.clientHeight - pad;
|
|
78
|
+
const scale = Math.min(availW / size.w, availH / size.h, 1);
|
|
79
|
+
setIframeScale({ scale, w: size.w, h: size.h });
|
|
80
|
+
}, [contentSize]);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!preview?.html || fitMode !== 'fit') { setIframeScale(null); return; }
|
|
84
|
+
const container = iframeContainerRef.current;
|
|
85
|
+
if (!container) return;
|
|
86
|
+
const ro = new ResizeObserver(() => requestAnimationFrame(recalcIframeScale));
|
|
87
|
+
ro.observe(container);
|
|
88
|
+
return () => ro.disconnect();
|
|
89
|
+
}, [preview?.html, maximized, fitMode, recalcIframeScale]);
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!showLog) return;
|
|
93
|
+
getToken().then(token => {
|
|
94
|
+
if (!token) return;
|
|
95
|
+
fetch('/api/data-sync/log', { headers: { Authorization: `Bearer ${token}` } })
|
|
96
|
+
.then(r => r.json())
|
|
97
|
+
.then(setLogEntries)
|
|
98
|
+
.catch(() => {});
|
|
99
|
+
});
|
|
100
|
+
}, [showLog]);
|
|
101
|
+
|
|
102
|
+
const toggleDir = (p: string) => {
|
|
103
|
+
setOpenDirs((prev) => {
|
|
104
|
+
const next = new Set(prev);
|
|
105
|
+
if (next.has(p)) { next.delete(p); } else {
|
|
106
|
+
next.add(p);
|
|
107
|
+
if (!dirEntries.has(p)) loadDir(p);
|
|
108
|
+
}
|
|
109
|
+
return next;
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const rawUrl = (filePath: string, dl?: boolean) => {
|
|
114
|
+
const q = new URLSearchParams({ path: filePath });
|
|
115
|
+
if (cachedToken) q.set('token', cachedToken);
|
|
116
|
+
if (dl) q.set('dl', '1');
|
|
117
|
+
return `/api/workspace/raw?${q}`;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const openFile = async (p: string) => {
|
|
121
|
+
const token = await getToken();
|
|
122
|
+
if (!token) return;
|
|
123
|
+
setCachedToken(token);
|
|
124
|
+
const ext = p.split('.').pop()?.toLowerCase() ?? '';
|
|
125
|
+
if (ext === 'html' || ext === 'htm') {
|
|
126
|
+
setPreview({ path: p, content: '', binary: false, html: true });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (IMAGE_EXTS.has(ext)) {
|
|
130
|
+
setPreview({ path: p, content: '', binary: false, image: true });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (EMBED_EXTS.has(ext)) {
|
|
134
|
+
setPreview({ path: p, content: '', binary: false, html: true });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const res = await fetch(`/api/workspace/file?path=${encodeURIComponent(p)}`, {
|
|
139
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
140
|
+
});
|
|
141
|
+
if (!res.ok) return;
|
|
142
|
+
const data = await res.json();
|
|
143
|
+
setPreview({ path: p, content: data.content, binary: !!data.binary });
|
|
144
|
+
} catch {}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const renderEntries = (entries: WorkspaceEntry[], depth: number) => (
|
|
148
|
+
<>
|
|
149
|
+
{entries.map((e) => {
|
|
150
|
+
const name = e.path.split('/').pop()!;
|
|
151
|
+
return (
|
|
152
|
+
<div key={e.path}>
|
|
153
|
+
{e.type === 'dir' ? (
|
|
154
|
+
<button
|
|
155
|
+
onClick={() => toggleDir(e.path)}
|
|
156
|
+
className="w-full flex items-center gap-1 px-2 py-1 rounded hover:bg-accent/50 text-left text-sm"
|
|
157
|
+
style={{ paddingLeft: 8 + depth * 12 }}
|
|
158
|
+
>
|
|
159
|
+
<ChevronRight className={cn('w-3 h-3 shrink-0 transition-transform', openDirs.has(e.path) && 'rotate-90')} />
|
|
160
|
+
<Folder className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
|
161
|
+
<span className="truncate">{name}</span>
|
|
162
|
+
</button>
|
|
163
|
+
) : (
|
|
164
|
+
<button
|
|
165
|
+
onClick={() => openFile(e.path)}
|
|
166
|
+
className="w-full flex items-center gap-1 px-2 py-1 rounded hover:bg-accent/50 text-left text-sm"
|
|
167
|
+
style={{ paddingLeft: 8 + depth * 12 + 12 }}
|
|
168
|
+
title={e.oneLiner || e.path}
|
|
169
|
+
>
|
|
170
|
+
<FileText className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
|
171
|
+
<span className="truncate">{name}</span>
|
|
172
|
+
</button>
|
|
173
|
+
)}
|
|
174
|
+
{e.type === 'dir' && openDirs.has(e.path) && renderEntries(dirEntries.get(e.path) ?? [], depth + 1)}
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
})}
|
|
178
|
+
</>
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<>
|
|
183
|
+
<div className="flex items-center justify-between px-3 pt-2 pb-1 shrink-0">
|
|
184
|
+
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Workspace</span>
|
|
185
|
+
<div className="flex items-center gap-0.5">
|
|
186
|
+
<button onClick={() => setShowLog(true)} className="p-0.5 rounded hover:bg-accent text-muted-foreground" title="Activity log">
|
|
187
|
+
<History className="w-3 h-3" />
|
|
188
|
+
</button>
|
|
189
|
+
<button onClick={() => { loadDir(''); openDirs.forEach(d => loadDir(d)); onRefresh?.(); }} className="p-0.5 rounded hover:bg-accent text-muted-foreground" title="Refresh">
|
|
190
|
+
<RefreshCw className="w-3 h-3" />
|
|
191
|
+
</button>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
<div className="px-1 pb-2 overflow-y-auto min-h-0">
|
|
195
|
+
{rootEntries.length === 0 ? (
|
|
196
|
+
<p className="text-xs text-muted-foreground px-3 py-2">Empty. Drop .md files into data/workspace/ or ask the agent to create some.</p>
|
|
197
|
+
) : renderEntries(rootEntries, 0)}
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<Dialog open={!!preview} onOpenChange={(o) => { if (!o) { setPreview(null); setMaximized(false); setFitMode('fit'); setContentSize(null); } }}>
|
|
201
|
+
<DialogContent className={cn(
|
|
202
|
+
preview?.html
|
|
203
|
+
? cn('!flex !flex-col !p-0 !gap-0 overflow-hidden', maximized ? 'max-w-none w-[100vw] h-[100vh] rounded-none border-0' : 'max-w-[90vw] w-[90vw] h-[85vh]')
|
|
204
|
+
: 'max-w-3xl max-h-[80vh] !flex !flex-col overflow-hidden',
|
|
205
|
+
)}>
|
|
206
|
+
<div className="absolute right-12 top-4 flex items-center gap-1.5 z-10">
|
|
207
|
+
{preview?.html && (
|
|
208
|
+
<>
|
|
209
|
+
<button onClick={() => setFitMode(m => m === 'fit' ? 'scroll' : 'fit')} className="rounded-sm opacity-70 hover:opacity-100 text-muted-foreground" title={fitMode === 'fit' ? 'Switch to scrollable (1:1)' : 'Switch to scale-to-fit'}>
|
|
210
|
+
{fitMode === 'fit' ? <Move className="w-4 h-4" /> : <ScanSearch className="w-4 h-4" />}
|
|
211
|
+
</button>
|
|
212
|
+
<button onClick={() => setMaximized(m => !m)} className="rounded-sm opacity-70 hover:opacity-100 text-muted-foreground" title={maximized ? 'Restore' : 'Maximize'}>
|
|
213
|
+
{maximized ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
|
214
|
+
</button>
|
|
215
|
+
</>
|
|
216
|
+
)}
|
|
217
|
+
{preview && (
|
|
218
|
+
<a href={rawUrl(preview.path, true)} download className="rounded-sm opacity-70 hover:opacity-100 text-muted-foreground" title="Download">
|
|
219
|
+
<Download className="w-4 h-4" />
|
|
220
|
+
</a>
|
|
221
|
+
)}
|
|
222
|
+
</div>
|
|
223
|
+
<DialogHeader className={cn(preview?.html && 'px-4 py-3 shrink-0 border-b')}>
|
|
224
|
+
<DialogTitle className="font-mono text-sm truncate pr-16">{preview?.path}</DialogTitle>
|
|
225
|
+
</DialogHeader>
|
|
226
|
+
<div ref={preview?.html ? iframeContainerRef : undefined} className={cn(preview?.html ? cn('flex-1 min-h-0 bg-muted/10', fitMode === 'fit' ? 'overflow-hidden flex items-center justify-center' : 'overflow-auto') : 'flex-1 min-h-0 overflow-y-auto')}>
|
|
227
|
+
{preview?.html ? (
|
|
228
|
+
fitMode === 'fit' ? (
|
|
229
|
+
<div style={iframeScale ? { width: iframeScale.w * iframeScale.scale, height: iframeScale.h * iframeScale.scale } : { width: '100%', height: '100%' }}>
|
|
230
|
+
<iframe
|
|
231
|
+
src={rawUrl(preview.path)}
|
|
232
|
+
className="border-0 bg-white origin-top-left"
|
|
233
|
+
title={preview.path}
|
|
234
|
+
onLoad={(e) => { detectContentSize(e.currentTarget); recalcIframeScale(); }}
|
|
235
|
+
style={iframeScale
|
|
236
|
+
? { width: iframeScale.w, height: iframeScale.h, transform: `scale(${iframeScale.scale})` }
|
|
237
|
+
: { width: '100%', height: '100%' }
|
|
238
|
+
}
|
|
239
|
+
/>
|
|
240
|
+
</div>
|
|
241
|
+
) : (
|
|
242
|
+
<iframe
|
|
243
|
+
src={rawUrl(preview.path)}
|
|
244
|
+
className="border-0 bg-white mx-auto block"
|
|
245
|
+
title={preview.path}
|
|
246
|
+
onLoad={(e) => detectContentSize(e.currentTarget)}
|
|
247
|
+
style={{ width: contentSize?.w || '100%', height: contentSize?.h || '100%' }}
|
|
248
|
+
/>
|
|
249
|
+
)
|
|
250
|
+
) : preview?.image ? (
|
|
251
|
+
<div className="flex items-center justify-center p-4">
|
|
252
|
+
<img
|
|
253
|
+
src={rawUrl(preview.path)}
|
|
254
|
+
alt={preview.path}
|
|
255
|
+
className="max-w-full max-h-[70vh] object-contain"
|
|
256
|
+
/>
|
|
257
|
+
</div>
|
|
258
|
+
) : preview?.binary ? (
|
|
259
|
+
<p className="text-sm text-muted-foreground">(binary file — preview unavailable)</p>
|
|
260
|
+
) : preview?.path.toLowerCase().endsWith('.md') || preview?.path.toLowerCase().endsWith('.markdown') ? (
|
|
261
|
+
<div className="text-sm">
|
|
262
|
+
<ReactMarkdown
|
|
263
|
+
remarkPlugins={[remarkGfm]}
|
|
264
|
+
rehypePlugins={[rehypeHighlight]}
|
|
265
|
+
className="prose prose-sm max-w-none dark:prose-invert prose-pre:bg-muted prose-pre:border prose-code:before:content-none prose-code:after:content-none"
|
|
266
|
+
>
|
|
267
|
+
{preview.content}
|
|
268
|
+
</ReactMarkdown>
|
|
269
|
+
</div>
|
|
270
|
+
) : (
|
|
271
|
+
<pre className="text-xs whitespace-pre-wrap break-all">{preview?.content}</pre>
|
|
272
|
+
)}
|
|
273
|
+
</div>
|
|
274
|
+
</DialogContent>
|
|
275
|
+
</Dialog>
|
|
276
|
+
|
|
277
|
+
<Dialog open={showLog} onOpenChange={setShowLog}>
|
|
278
|
+
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
|
279
|
+
<DialogHeader>
|
|
280
|
+
<DialogTitle className="text-sm">Activity Log</DialogTitle>
|
|
281
|
+
</DialogHeader>
|
|
282
|
+
<div className="overflow-y-auto flex-1 space-y-2">
|
|
283
|
+
{logEntries.map(e => (
|
|
284
|
+
<div key={e.hash} className="border-b border-border/50 pb-2 last:border-0">
|
|
285
|
+
<div className="flex items-baseline justify-between gap-2">
|
|
286
|
+
<span className="text-sm text-foreground">{e.message}</span>
|
|
287
|
+
<span className="text-[10px] text-muted-foreground shrink-0">{formatDate(e.date)}</span>
|
|
288
|
+
</div>
|
|
289
|
+
{e.files.length > 0 && (
|
|
290
|
+
<div className="text-[11px] text-muted-foreground mt-0.5">
|
|
291
|
+
{e.files.map(f => <span key={f} className="inline-block mr-2 font-mono">{f}</span>)}
|
|
292
|
+
</div>
|
|
293
|
+
)}
|
|
294
|
+
</div>
|
|
295
|
+
))}
|
|
296
|
+
{logEntries.length === 0 && <p className="text-sm text-muted-foreground py-4 text-center">No activity yet</p>}
|
|
297
|
+
</div>
|
|
298
|
+
</DialogContent>
|
|
299
|
+
</Dialog>
|
|
300
|
+
</>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function formatDate(iso: string): string {
|
|
305
|
+
const d = new Date(iso);
|
|
306
|
+
const now = new Date();
|
|
307
|
+
const diff = now.getTime() - d.getTime();
|
|
308
|
+
const mins = Math.floor(diff / 60000);
|
|
309
|
+
if (mins < 60) return `${mins}m ago`;
|
|
310
|
+
const hrs = Math.floor(mins / 60);
|
|
311
|
+
if (hrs < 24) return `${hrs}h ago`;
|
|
312
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
313
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react';
|
|
2
|
+
import { ZoomIn, ZoomOut, Maximize2 } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
const MIN_SCALE = 1;
|
|
5
|
+
const MAX_SCALE = 10;
|
|
6
|
+
|
|
7
|
+
type View = { scale: number; tx: number; ty: number };
|
|
8
|
+
const RESET: View = { scale: 1, tx: 0, ty: 0 };
|
|
9
|
+
|
|
10
|
+
const clampScale = (s: number) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, s));
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A pan/zoom viewer for a single image. Wheel/pinch zooms toward the cursor,
|
|
14
|
+
* drag pans when zoomed in, double-click toggles a 2.5x zoom at the point.
|
|
15
|
+
* Used by the chat lightbox so large images (e.g. dense diagrams) can be
|
|
16
|
+
* inspected at full resolution instead of being fit-to-viewport.
|
|
17
|
+
*/
|
|
18
|
+
export function ZoomableImage({ src, alt = '' }: { src: string; alt?: string }) {
|
|
19
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
20
|
+
const drag = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
|
|
21
|
+
const [view, setView] = useState<View>(RESET);
|
|
22
|
+
const [panning, setPanning] = useState(false);
|
|
23
|
+
|
|
24
|
+
// cursor position relative to the container's center (screen px)
|
|
25
|
+
const relToCenter = (clientX: number, clientY: number) => {
|
|
26
|
+
const rect = containerRef.current?.getBoundingClientRect();
|
|
27
|
+
if (!rect) return { cx: 0, cy: 0 };
|
|
28
|
+
return { cx: clientX - rect.left - rect.width / 2, cy: clientY - rect.top - rect.height / 2 };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Zoom to an absolute scale while keeping the point under (cx, cy) fixed.
|
|
32
|
+
const zoomTo = useCallback((nextScale: number, cx: number, cy: number) => {
|
|
33
|
+
setView((prev) => {
|
|
34
|
+
const s2 = clampScale(nextScale);
|
|
35
|
+
if (s2 === prev.scale) return prev;
|
|
36
|
+
if (s2 <= MIN_SCALE) return RESET;
|
|
37
|
+
const k = s2 / prev.scale;
|
|
38
|
+
return { scale: s2, tx: cx - k * (cx - prev.tx), ty: cy - k * (cy - prev.ty) };
|
|
39
|
+
});
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
const onWheel = (e: React.WheelEvent) => {
|
|
43
|
+
e.preventDefault();
|
|
44
|
+
const { cx, cy } = relToCenter(e.clientX, e.clientY);
|
|
45
|
+
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
|
|
46
|
+
setView((prev) => {
|
|
47
|
+
const s2 = clampScale(prev.scale * factor);
|
|
48
|
+
if (s2 === prev.scale) return prev;
|
|
49
|
+
if (s2 <= MIN_SCALE) return RESET;
|
|
50
|
+
const k = s2 / prev.scale;
|
|
51
|
+
return { scale: s2, tx: cx - k * (cx - prev.tx), ty: cy - k * (cy - prev.ty) };
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const onPointerDown = (e: React.PointerEvent) => {
|
|
56
|
+
if (view.scale <= 1) return;
|
|
57
|
+
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
58
|
+
drag.current = { x: e.clientX, y: e.clientY, tx: view.tx, ty: view.ty };
|
|
59
|
+
setPanning(true);
|
|
60
|
+
};
|
|
61
|
+
const onPointerMove = (e: React.PointerEvent) => {
|
|
62
|
+
const d = drag.current;
|
|
63
|
+
if (!d) return;
|
|
64
|
+
setView((prev) => ({ ...prev, tx: d.tx + (e.clientX - d.x), ty: d.ty + (e.clientY - d.y) }));
|
|
65
|
+
};
|
|
66
|
+
const endDrag = () => { drag.current = null; setPanning(false); };
|
|
67
|
+
|
|
68
|
+
const onDoubleClick = (e: React.MouseEvent) => {
|
|
69
|
+
const { cx, cy } = relToCenter(e.clientX, e.clientY);
|
|
70
|
+
if (view.scale > 1) setView(RESET);
|
|
71
|
+
else zoomTo(2.5, cx, cy);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const step = (factor: number) => zoomTo(view.scale * factor, 0, 0);
|
|
75
|
+
const zoomed = view.scale > 1;
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className="relative w-full h-full flex items-center justify-center">
|
|
79
|
+
<div
|
|
80
|
+
ref={containerRef}
|
|
81
|
+
className="w-full h-full overflow-hidden flex items-center justify-center"
|
|
82
|
+
onWheel={onWheel}
|
|
83
|
+
onPointerDown={onPointerDown}
|
|
84
|
+
onPointerMove={onPointerMove}
|
|
85
|
+
onPointerUp={endDrag}
|
|
86
|
+
onPointerCancel={endDrag}
|
|
87
|
+
onDoubleClick={onDoubleClick}
|
|
88
|
+
style={{ cursor: zoomed ? (panning ? 'grabbing' : 'grab') : 'zoom-in', touchAction: 'none' }}
|
|
89
|
+
>
|
|
90
|
+
<img
|
|
91
|
+
src={src}
|
|
92
|
+
alt={alt}
|
|
93
|
+
draggable={false}
|
|
94
|
+
className="max-w-full max-h-[85vh] object-contain rounded-lg select-none"
|
|
95
|
+
style={{
|
|
96
|
+
transform: `translate(${view.tx}px, ${view.ty}px) scale(${view.scale})`,
|
|
97
|
+
transition: panning ? 'none' : 'transform 0.08s ease-out',
|
|
98
|
+
transformOrigin: 'center center',
|
|
99
|
+
}}
|
|
100
|
+
/>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
{/* Controls */}
|
|
104
|
+
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-background/90 border shadow-sm px-1.5 py-1 backdrop-blur">
|
|
105
|
+
<button type="button" onClick={() => step(1 / 1.4)} title="Zoom out"
|
|
106
|
+
className="p-1.5 rounded-full hover:bg-muted transition-colors disabled:opacity-40" disabled={!zoomed}>
|
|
107
|
+
<ZoomOut className="w-4 h-4" />
|
|
108
|
+
</button>
|
|
109
|
+
<span className="text-xs tabular-nums w-11 text-center text-muted-foreground select-none">
|
|
110
|
+
{Math.round(view.scale * 100)}%
|
|
111
|
+
</span>
|
|
112
|
+
<button type="button" onClick={() => step(1.4)} title="Zoom in"
|
|
113
|
+
className="p-1.5 rounded-full hover:bg-muted transition-colors disabled:opacity-40" disabled={view.scale >= MAX_SCALE}>
|
|
114
|
+
<ZoomIn className="w-4 h-4" />
|
|
115
|
+
</button>
|
|
116
|
+
<button type="button" onClick={() => setView(RESET)} title="Reset"
|
|
117
|
+
className="p-1.5 rounded-full hover:bg-muted transition-colors disabled:opacity-40" disabled={!zoomed}>
|
|
118
|
+
<Maximize2 className="w-4 h-4" />
|
|
119
|
+
</button>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const DIMENSION_PRESETS: Record<string, [number, number]> = {
|
|
2
|
+
'fb-feed': [1080, 1080],
|
|
3
|
+
'fb-story': [1080, 1920],
|
|
4
|
+
'fb-landscape': [1200, 628],
|
|
5
|
+
'ig-square': [1080, 1080],
|
|
6
|
+
'ig-story': [1080, 1920],
|
|
7
|
+
'ig-landscape': [1080, 566],
|
|
8
|
+
'banner-leaderboard': [728, 90],
|
|
9
|
+
'banner-medium': [300, 250],
|
|
10
|
+
};
|