claude-world-studio 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +30 -0
- package/.mcp.json +51 -0
- package/README.md +224 -0
- package/client/App.tsx +446 -0
- package/client/components/ChatWindow.tsx +790 -0
- package/client/components/FileExplorer.tsx +218 -0
- package/client/components/FilePreviewModal.tsx +179 -0
- package/client/components/PublishDialog.tsx +307 -0
- package/client/components/SettingsPage.tsx +452 -0
- package/client/components/Sidebar.tsx +198 -0
- package/client/components/ToolUseBlock.tsx +140 -0
- package/client/index.html +12 -0
- package/client/index.tsx +10 -0
- package/client/styles/globals.css +48 -0
- package/demo/01-welcome.png +0 -0
- package/demo/02-pipeline-cards.png +0 -0
- package/demo/03-custom-topic-fill.png +0 -0
- package/demo/04-topic-typed.png +0 -0
- package/demo/05-loading-state.png +0 -0
- package/demo/06-tool-calls.png +0 -0
- package/demo/07-history-rich.png +0 -0
- package/demo/09-en-cards.png +0 -0
- package/demo/10-ja-cards.png +0 -0
- package/demo/capture-remaining.mjs +73 -0
- package/demo/capture.mjs +110 -0
- package/demo/demo-walkthrough-2.webm +0 -0
- package/demo/demo-walkthrough.webm +0 -0
- package/package.json +48 -0
- package/postcss.config.js +6 -0
- package/scripts/threads_api.py +536 -0
- package/server/ai-client.ts +356 -0
- package/server/db.ts +299 -0
- package/server/mcp-config.ts +85 -0
- package/server/routes/accounts.ts +88 -0
- package/server/routes/files.ts +175 -0
- package/server/routes/publish.ts +77 -0
- package/server/routes/sessions.ts +59 -0
- package/server/routes/settings.ts +220 -0
- package/server/server.ts +261 -0
- package/server/services/social-publisher.ts +74 -0
- package/server/services/studio-mcp.ts +107 -0
- package/server/session.ts +167 -0
- package/server/types.ts +86 -0
- package/tailwind.config.js +8 -0
- package/tsconfig.json +16 -0
- package/vite.config.ts +19 -0
package/client/App.tsx
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
|
+
import useWebSocket, { ReadyState } from "react-use-websocket";
|
|
3
|
+
import { Sidebar } from "./components/Sidebar";
|
|
4
|
+
import { ChatWindow } from "./components/ChatWindow";
|
|
5
|
+
import { FileExplorer } from "./components/FileExplorer";
|
|
6
|
+
import { FilePreviewModal } from "./components/FilePreviewModal";
|
|
7
|
+
import { PublishDialog } from "./components/PublishDialog";
|
|
8
|
+
import { SettingsPage } from "./components/SettingsPage";
|
|
9
|
+
|
|
10
|
+
export type Language = "zh-TW" | "en" | "ja";
|
|
11
|
+
|
|
12
|
+
interface Session {
|
|
13
|
+
id: string;
|
|
14
|
+
title: string;
|
|
15
|
+
workspace_path: string;
|
|
16
|
+
created_at: string;
|
|
17
|
+
updated_at: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Message {
|
|
21
|
+
id: string;
|
|
22
|
+
role: "user" | "assistant" | "tool_use" | "tool_result" | "result";
|
|
23
|
+
content: string | null;
|
|
24
|
+
created_at?: string;
|
|
25
|
+
timestamp?: string;
|
|
26
|
+
toolName?: string;
|
|
27
|
+
tool_name?: string;
|
|
28
|
+
toolInput?: Record<string, unknown>;
|
|
29
|
+
tool_input?: string;
|
|
30
|
+
toolId?: string;
|
|
31
|
+
tool_id?: string;
|
|
32
|
+
cost_usd?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Discriminated union for server→client WebSocket messages */
|
|
36
|
+
type ServerWSMessage =
|
|
37
|
+
| { type: "connected" }
|
|
38
|
+
| { type: "history"; messages: Message[]; sessionId: string }
|
|
39
|
+
| { type: "user_message"; content: string; sessionId: string }
|
|
40
|
+
| { type: "assistant_message"; content: string; sessionId: string }
|
|
41
|
+
| { type: "tool_use"; toolName: string; toolId: string; toolInput: Record<string, unknown>; sessionId: string }
|
|
42
|
+
| { type: "tool_result"; toolId: string; content: string; sessionId: string }
|
|
43
|
+
| { type: "result"; success: boolean; cost?: number; duration?: number; sessionId: string }
|
|
44
|
+
| { type: "interrupted"; sessionId: string }
|
|
45
|
+
| { type: "error"; error: string; sessionId?: string };
|
|
46
|
+
|
|
47
|
+
const API_BASE = "/api";
|
|
48
|
+
|
|
49
|
+
function getWsUrl(): string {
|
|
50
|
+
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
|
51
|
+
return `${proto}://${window.location.host}/ws`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default function App() {
|
|
55
|
+
const [sessions, setSessions] = useState<Session[]>([]);
|
|
56
|
+
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
|
|
57
|
+
const selectedSessionRef = useRef<string | null>(null);
|
|
58
|
+
const [messages, setMessages] = useState<Message[]>([]);
|
|
59
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
60
|
+
const [showFiles, setShowFiles] = useState(false);
|
|
61
|
+
const [showSettings, setShowSettings] = useState(false);
|
|
62
|
+
const [showPublish, setShowPublish] = useState(false);
|
|
63
|
+
const [previewFile, setPreviewFile] = useState<{ relativePath: string; sessionId: string } | null>(null);
|
|
64
|
+
const [defaultWorkspace, setDefaultWorkspace] = useState("");
|
|
65
|
+
const [language, setLanguage] = useState<Language>("zh-TW");
|
|
66
|
+
|
|
67
|
+
// Keep ref in sync for use inside WS callback
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
selectedSessionRef.current = selectedSessionId;
|
|
70
|
+
}, [selectedSessionId]);
|
|
71
|
+
|
|
72
|
+
const handleWSMessage = useCallback((message: ServerWSMessage) => {
|
|
73
|
+
// Ignore messages from sessions we're not currently viewing
|
|
74
|
+
const msgSessionId = "sessionId" in message ? message.sessionId : undefined;
|
|
75
|
+
if (msgSessionId && msgSessionId !== selectedSessionRef.current) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
switch (message.type) {
|
|
80
|
+
case "connected":
|
|
81
|
+
break;
|
|
82
|
+
|
|
83
|
+
case "history": {
|
|
84
|
+
const history = message.messages || [];
|
|
85
|
+
// Merge: use history as base, append any optimistic local messages not in history
|
|
86
|
+
setMessages((prev) => {
|
|
87
|
+
if (prev.length === 0) return history;
|
|
88
|
+
// Find messages added locally after the last history entry
|
|
89
|
+
const historyIds = new Set(history.map((m: { id: string }) => m.id));
|
|
90
|
+
const localOnly = prev.filter((m) => !historyIds.has(m.id));
|
|
91
|
+
return [...history, ...localOnly];
|
|
92
|
+
});
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
case "user_message":
|
|
97
|
+
// Render user messages from other tabs/subscribers
|
|
98
|
+
if (message.content) {
|
|
99
|
+
setMessages((prev) => {
|
|
100
|
+
// Skip if already present (optimistic local insert)
|
|
101
|
+
if (prev.some((m) => m.role === "user" && m.content === message.content &&
|
|
102
|
+
Date.now() - new Date(m.timestamp || 0).getTime() < 5000)) {
|
|
103
|
+
return prev;
|
|
104
|
+
}
|
|
105
|
+
return [...prev, {
|
|
106
|
+
id: crypto.randomUUID(),
|
|
107
|
+
role: "user",
|
|
108
|
+
content: message.content,
|
|
109
|
+
timestamp: new Date().toISOString(),
|
|
110
|
+
}];
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
break;
|
|
114
|
+
|
|
115
|
+
case "assistant_message":
|
|
116
|
+
setMessages((prev) => [
|
|
117
|
+
...prev,
|
|
118
|
+
{
|
|
119
|
+
id: crypto.randomUUID(),
|
|
120
|
+
role: "assistant",
|
|
121
|
+
content: message.content,
|
|
122
|
+
timestamp: new Date().toISOString(),
|
|
123
|
+
},
|
|
124
|
+
]);
|
|
125
|
+
break;
|
|
126
|
+
|
|
127
|
+
case "tool_use":
|
|
128
|
+
setMessages((prev) => [
|
|
129
|
+
...prev,
|
|
130
|
+
{
|
|
131
|
+
id: `tu_${message.toolId || crypto.randomUUID()}`,
|
|
132
|
+
role: "tool_use",
|
|
133
|
+
content: null,
|
|
134
|
+
timestamp: new Date().toISOString(),
|
|
135
|
+
toolName: message.toolName,
|
|
136
|
+
toolInput: message.toolInput,
|
|
137
|
+
toolId: message.toolId,
|
|
138
|
+
},
|
|
139
|
+
]);
|
|
140
|
+
break;
|
|
141
|
+
|
|
142
|
+
case "tool_result":
|
|
143
|
+
setMessages((prev) => [
|
|
144
|
+
...prev,
|
|
145
|
+
{
|
|
146
|
+
id: `tr_${message.toolId || crypto.randomUUID()}`,
|
|
147
|
+
role: "tool_result",
|
|
148
|
+
content: message.content,
|
|
149
|
+
timestamp: new Date().toISOString(),
|
|
150
|
+
toolId: message.toolId,
|
|
151
|
+
},
|
|
152
|
+
]);
|
|
153
|
+
break;
|
|
154
|
+
|
|
155
|
+
case "result":
|
|
156
|
+
setIsLoading(false);
|
|
157
|
+
setMessages((prev) => [
|
|
158
|
+
...prev,
|
|
159
|
+
{
|
|
160
|
+
id: crypto.randomUUID(),
|
|
161
|
+
role: "result",
|
|
162
|
+
content: JSON.stringify({
|
|
163
|
+
success: message.success,
|
|
164
|
+
cost: message.cost,
|
|
165
|
+
duration: message.duration,
|
|
166
|
+
}),
|
|
167
|
+
timestamp: new Date().toISOString(),
|
|
168
|
+
},
|
|
169
|
+
]);
|
|
170
|
+
fetchSessions();
|
|
171
|
+
break;
|
|
172
|
+
|
|
173
|
+
case "interrupted":
|
|
174
|
+
setIsLoading(false);
|
|
175
|
+
break;
|
|
176
|
+
|
|
177
|
+
case "error":
|
|
178
|
+
console.error("Server error:", message.error);
|
|
179
|
+
setIsLoading(false);
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}, []);
|
|
183
|
+
|
|
184
|
+
const { sendJsonMessage, readyState, lastJsonMessage } = useWebSocket(getWsUrl, {
|
|
185
|
+
shouldReconnect: () => true,
|
|
186
|
+
reconnectAttempts: 10,
|
|
187
|
+
reconnectInterval: 3000,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const isConnected = readyState === ReadyState.OPEN;
|
|
191
|
+
|
|
192
|
+
// Re-subscribe to the active session after reconnect
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (isConnected && selectedSessionRef.current) {
|
|
195
|
+
setIsLoading(false); // reset stale loading state from before disconnect
|
|
196
|
+
sendJsonMessage({ type: "subscribe", sessionId: selectedSessionRef.current });
|
|
197
|
+
}
|
|
198
|
+
}, [isConnected, sendJsonMessage]);
|
|
199
|
+
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
if (lastJsonMessage) {
|
|
202
|
+
handleWSMessage(lastJsonMessage as ServerWSMessage);
|
|
203
|
+
}
|
|
204
|
+
}, [lastJsonMessage, handleWSMessage]);
|
|
205
|
+
|
|
206
|
+
const fetchSessions = async () => {
|
|
207
|
+
try {
|
|
208
|
+
const res = await fetch(`${API_BASE}/sessions`);
|
|
209
|
+
if (!res.ok) return;
|
|
210
|
+
const data = await res.json();
|
|
211
|
+
setSessions(data);
|
|
212
|
+
} catch {
|
|
213
|
+
// Network error — ignore silently
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const fetchSettings = async () => {
|
|
218
|
+
try {
|
|
219
|
+
const res = await fetch(`${API_BASE}/settings`);
|
|
220
|
+
if (!res.ok) return;
|
|
221
|
+
const data = await res.json();
|
|
222
|
+
setDefaultWorkspace(data.defaultWorkspace || "");
|
|
223
|
+
setLanguage(data.language || "zh-TW");
|
|
224
|
+
} catch {
|
|
225
|
+
// ignore
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const handleLanguageChange = async (lang: Language) => {
|
|
230
|
+
const prev = language;
|
|
231
|
+
setLanguage(lang);
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const res = await fetch(`${API_BASE}/settings`, {
|
|
235
|
+
method: "PUT",
|
|
236
|
+
headers: { "Content-Type": "application/json" },
|
|
237
|
+
body: JSON.stringify({ language: lang }),
|
|
238
|
+
});
|
|
239
|
+
if (!res.ok) throw new Error("Save failed");
|
|
240
|
+
} catch {
|
|
241
|
+
setLanguage(prev);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (selectedSessionId && messages.length > 0 && isConnected) {
|
|
246
|
+
const SWITCH_MSG: Record<Language, string> = {
|
|
247
|
+
"zh-TW": "[系統] 使用者已將語言切換為繁體中文。從現在起,請用繁體中文(台灣用語)回覆所有訊息。",
|
|
248
|
+
"en": "[System] User switched language to English. From now on, please respond in English for all messages.",
|
|
249
|
+
"ja": "[システム] ユーザーが言語を日本語に切り替えました。これ以降、すべてのメッセージを日本語で回答してください。",
|
|
250
|
+
};
|
|
251
|
+
sendJsonMessage({
|
|
252
|
+
type: "chat",
|
|
253
|
+
content: SWITCH_MSG[lang],
|
|
254
|
+
sessionId: selectedSessionId,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const createSession = async () => {
|
|
260
|
+
try {
|
|
261
|
+
const res = await fetch(`${API_BASE}/sessions`, {
|
|
262
|
+
method: "POST",
|
|
263
|
+
headers: { "Content-Type": "application/json" },
|
|
264
|
+
body: JSON.stringify({ workspacePath: defaultWorkspace || undefined }),
|
|
265
|
+
});
|
|
266
|
+
if (!res.ok) return;
|
|
267
|
+
const session = await res.json();
|
|
268
|
+
setSessions((prev) => [session, ...prev]);
|
|
269
|
+
selectSession(session.id);
|
|
270
|
+
setShowSettings(false);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
console.error("Failed to create session:", error);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const deleteSession = async (sessionId: string) => {
|
|
277
|
+
try {
|
|
278
|
+
const res = await fetch(`${API_BASE}/sessions/${sessionId}`, { method: "DELETE" });
|
|
279
|
+
if (!res.ok) return;
|
|
280
|
+
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
|
|
281
|
+
if (selectedSessionId === sessionId) {
|
|
282
|
+
setSelectedSessionId(null);
|
|
283
|
+
setMessages([]);
|
|
284
|
+
}
|
|
285
|
+
} catch (error) {
|
|
286
|
+
console.error("Failed to delete session:", error);
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const selectSession = (sessionId: string) => {
|
|
291
|
+
// Update ref BEFORE subscribe so WS message filter accepts the new session
|
|
292
|
+
selectedSessionRef.current = sessionId;
|
|
293
|
+
setSelectedSessionId(sessionId);
|
|
294
|
+
setMessages([]);
|
|
295
|
+
setIsLoading(false);
|
|
296
|
+
setShowSettings(false);
|
|
297
|
+
if (isConnected) {
|
|
298
|
+
sendJsonMessage({ type: "subscribe", sessionId });
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const handleSendMessage = (content: string) => {
|
|
303
|
+
if (!selectedSessionId || !isConnected) return;
|
|
304
|
+
|
|
305
|
+
setMessages((prev) => {
|
|
306
|
+
// Auto-update session title from first user message
|
|
307
|
+
if (prev.length === 0) {
|
|
308
|
+
const title = content.slice(0, 80).replace(/\n/g, " ");
|
|
309
|
+
setSessions((ss) =>
|
|
310
|
+
ss.map((s) => s.id === selectedSessionId ? { ...s, title } : s)
|
|
311
|
+
);
|
|
312
|
+
// Persist title to server (fire-and-forget)
|
|
313
|
+
fetch(`${API_BASE}/sessions/${selectedSessionId}`, {
|
|
314
|
+
method: "PATCH",
|
|
315
|
+
headers: { "Content-Type": "application/json" },
|
|
316
|
+
body: JSON.stringify({ title }),
|
|
317
|
+
}).catch(() => {});
|
|
318
|
+
}
|
|
319
|
+
return [
|
|
320
|
+
...prev,
|
|
321
|
+
{
|
|
322
|
+
id: crypto.randomUUID(),
|
|
323
|
+
role: "user",
|
|
324
|
+
content,
|
|
325
|
+
timestamp: new Date().toISOString(),
|
|
326
|
+
},
|
|
327
|
+
];
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
setIsLoading(true);
|
|
331
|
+
|
|
332
|
+
sendJsonMessage({
|
|
333
|
+
type: "chat",
|
|
334
|
+
content,
|
|
335
|
+
sessionId: selectedSessionId,
|
|
336
|
+
});
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const handleInterrupt = () => {
|
|
340
|
+
if (!selectedSessionId) return;
|
|
341
|
+
sendJsonMessage({
|
|
342
|
+
type: "interrupt",
|
|
343
|
+
sessionId: selectedSessionId,
|
|
344
|
+
});
|
|
345
|
+
setIsLoading(false);
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// Preview a file — accepts absolute or relative path (must be within workspace)
|
|
349
|
+
const handlePreviewFile = (filePath: string) => {
|
|
350
|
+
if (!selectedSessionId) return;
|
|
351
|
+
const session = sessions.find((s) => s.id === selectedSessionId);
|
|
352
|
+
if (filePath.startsWith("/")) {
|
|
353
|
+
// Absolute path — must be within workspace, otherwise reject
|
|
354
|
+
if (!session) return;
|
|
355
|
+
const wsBase = session.workspace_path.replace(/\/$/, "");
|
|
356
|
+
if (!filePath.startsWith(wsBase + "/")) return; // outside workspace, ignore
|
|
357
|
+
const relativePath = filePath.slice(wsBase.length + 1);
|
|
358
|
+
setPreviewFile({ relativePath, sessionId: selectedSessionId });
|
|
359
|
+
} else {
|
|
360
|
+
// Already relative
|
|
361
|
+
setPreviewFile({ relativePath: filePath, sessionId: selectedSessionId });
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
// Preview from file explorer (already relative)
|
|
366
|
+
const handleExplorerPreview = (relativePath: string) => {
|
|
367
|
+
if (!selectedSessionId) return;
|
|
368
|
+
setPreviewFile({ relativePath, sessionId: selectedSessionId });
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
useEffect(() => {
|
|
372
|
+
fetchSessions();
|
|
373
|
+
fetchSettings();
|
|
374
|
+
}, []);
|
|
375
|
+
|
|
376
|
+
return (
|
|
377
|
+
<div className="flex h-screen bg-gray-50">
|
|
378
|
+
{/* Sidebar */}
|
|
379
|
+
<div className="w-64 shrink-0">
|
|
380
|
+
<Sidebar
|
|
381
|
+
sessions={sessions}
|
|
382
|
+
selectedSessionId={selectedSessionId}
|
|
383
|
+
onSelectSession={selectSession}
|
|
384
|
+
onNewSession={createSession}
|
|
385
|
+
onDeleteSession={deleteSession}
|
|
386
|
+
onShowSettings={() => setShowSettings(true)}
|
|
387
|
+
defaultWorkspace={defaultWorkspace}
|
|
388
|
+
isConnected={isConnected}
|
|
389
|
+
language={language}
|
|
390
|
+
onLanguageChange={handleLanguageChange}
|
|
391
|
+
/>
|
|
392
|
+
</div>
|
|
393
|
+
|
|
394
|
+
{/* Main content */}
|
|
395
|
+
{showSettings ? (
|
|
396
|
+
<SettingsPage
|
|
397
|
+
isVisible={showSettings}
|
|
398
|
+
onClose={() => setShowSettings(false)}
|
|
399
|
+
language={language}
|
|
400
|
+
onLanguageChange={handleLanguageChange}
|
|
401
|
+
/>
|
|
402
|
+
) : (
|
|
403
|
+
<>
|
|
404
|
+
<ChatWindow
|
|
405
|
+
sessionId={selectedSessionId}
|
|
406
|
+
messages={messages}
|
|
407
|
+
isConnected={isConnected}
|
|
408
|
+
isLoading={isLoading}
|
|
409
|
+
onSendMessage={handleSendMessage}
|
|
410
|
+
onInterrupt={handleInterrupt}
|
|
411
|
+
onShowFiles={() => setShowFiles(!showFiles)}
|
|
412
|
+
onShowPublish={() => setShowPublish(true)}
|
|
413
|
+
onNewSession={createSession}
|
|
414
|
+
onPreviewFile={handlePreviewFile}
|
|
415
|
+
workspacePath={sessions.find((s) => s.id === selectedSessionId)?.workspace_path}
|
|
416
|
+
showFilesActive={showFiles}
|
|
417
|
+
language={language}
|
|
418
|
+
/>
|
|
419
|
+
|
|
420
|
+
<FileExplorer
|
|
421
|
+
sessionId={selectedSessionId}
|
|
422
|
+
isVisible={showFiles}
|
|
423
|
+
onToggle={() => setShowFiles(!showFiles)}
|
|
424
|
+
onPreviewFile={handleExplorerPreview}
|
|
425
|
+
/>
|
|
426
|
+
</>
|
|
427
|
+
)}
|
|
428
|
+
|
|
429
|
+
{/* File preview modal */}
|
|
430
|
+
{previewFile && (
|
|
431
|
+
<FilePreviewModal
|
|
432
|
+
sessionId={previewFile.sessionId}
|
|
433
|
+
filePath={previewFile.relativePath}
|
|
434
|
+
onClose={() => setPreviewFile(null)}
|
|
435
|
+
/>
|
|
436
|
+
)}
|
|
437
|
+
|
|
438
|
+
{/* Publish dialog */}
|
|
439
|
+
<PublishDialog
|
|
440
|
+
isOpen={showPublish}
|
|
441
|
+
onClose={() => setShowPublish(false)}
|
|
442
|
+
sessionId={selectedSessionId}
|
|
443
|
+
/>
|
|
444
|
+
</div>
|
|
445
|
+
);
|
|
446
|
+
}
|