botschat 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +213 -0
- package/migrations/0001_initial.sql +88 -0
- package/migrations/0002_rename_projects_to_channels.sql +53 -0
- package/migrations/0003_messages.sql +14 -0
- package/migrations/0004_jobs.sql +15 -0
- package/migrations/0005_deleted_cron_jobs.sql +6 -0
- package/migrations/0006_tasks_add_model.sql +2 -0
- package/migrations/0007_sessions.sql +25 -0
- package/migrations/0008_remove_openclaw_fields.sql +8 -0
- package/package.json +53 -0
- package/packages/api/package.json +17 -0
- package/packages/api/src/do/connection-do.ts +929 -0
- package/packages/api/src/env.ts +8 -0
- package/packages/api/src/index.ts +297 -0
- package/packages/api/src/routes/agents.ts +68 -0
- package/packages/api/src/routes/auth.ts +105 -0
- package/packages/api/src/routes/channels.ts +185 -0
- package/packages/api/src/routes/jobs.ts +65 -0
- package/packages/api/src/routes/models.ts +22 -0
- package/packages/api/src/routes/pairing.ts +76 -0
- package/packages/api/src/routes/projects.ts +177 -0
- package/packages/api/src/routes/sessions.ts +171 -0
- package/packages/api/src/routes/tasks.ts +375 -0
- package/packages/api/src/routes/upload.ts +52 -0
- package/packages/api/src/utils/auth.ts +101 -0
- package/packages/api/src/utils/id.ts +19 -0
- package/packages/api/tsconfig.json +18 -0
- package/packages/plugin/dist/index.d.ts +19 -0
- package/packages/plugin/dist/index.d.ts.map +1 -0
- package/packages/plugin/dist/index.js +17 -0
- package/packages/plugin/dist/index.js.map +1 -0
- package/packages/plugin/dist/src/accounts.d.ts +12 -0
- package/packages/plugin/dist/src/accounts.d.ts.map +1 -0
- package/packages/plugin/dist/src/accounts.js +103 -0
- package/packages/plugin/dist/src/accounts.js.map +1 -0
- package/packages/plugin/dist/src/channel.d.ts +206 -0
- package/packages/plugin/dist/src/channel.d.ts.map +1 -0
- package/packages/plugin/dist/src/channel.js +1248 -0
- package/packages/plugin/dist/src/channel.js.map +1 -0
- package/packages/plugin/dist/src/runtime.d.ts +3 -0
- package/packages/plugin/dist/src/runtime.d.ts.map +1 -0
- package/packages/plugin/dist/src/runtime.js +18 -0
- package/packages/plugin/dist/src/runtime.js.map +1 -0
- package/packages/plugin/dist/src/types.d.ts +179 -0
- package/packages/plugin/dist/src/types.d.ts.map +1 -0
- package/packages/plugin/dist/src/types.js +6 -0
- package/packages/plugin/dist/src/types.js.map +1 -0
- package/packages/plugin/dist/src/ws-client.d.ts +51 -0
- package/packages/plugin/dist/src/ws-client.d.ts.map +1 -0
- package/packages/plugin/dist/src/ws-client.js +170 -0
- package/packages/plugin/dist/src/ws-client.js.map +1 -0
- package/packages/plugin/openclaw.plugin.json +11 -0
- package/packages/plugin/package.json +39 -0
- package/packages/plugin/tsconfig.json +20 -0
- package/packages/web/dist/assets/index-C-wI8eHy.css +1 -0
- package/packages/web/dist/assets/index-CbPEKHLG.js +93 -0
- package/packages/web/dist/index.html +17 -0
- package/packages/web/index.html +16 -0
- package/packages/web/package.json +29 -0
- package/packages/web/postcss.config.js +6 -0
- package/packages/web/src/App.tsx +827 -0
- package/packages/web/src/api.ts +242 -0
- package/packages/web/src/components/ChatWindow.tsx +864 -0
- package/packages/web/src/components/CronDetail.tsx +943 -0
- package/packages/web/src/components/CronSidebar.tsx +123 -0
- package/packages/web/src/components/DebugLogPanel.tsx +258 -0
- package/packages/web/src/components/IconRail.tsx +163 -0
- package/packages/web/src/components/JobList.tsx +120 -0
- package/packages/web/src/components/LoginPage.tsx +178 -0
- package/packages/web/src/components/MessageContent.tsx +1082 -0
- package/packages/web/src/components/ModelSelect.tsx +87 -0
- package/packages/web/src/components/ScheduleEditor.tsx +403 -0
- package/packages/web/src/components/SessionTabs.tsx +246 -0
- package/packages/web/src/components/Sidebar.tsx +331 -0
- package/packages/web/src/components/TaskBar.tsx +413 -0
- package/packages/web/src/components/ThreadPanel.tsx +212 -0
- package/packages/web/src/debug-log.ts +58 -0
- package/packages/web/src/index.css +170 -0
- package/packages/web/src/main.tsx +10 -0
- package/packages/web/src/store.ts +492 -0
- package/packages/web/src/ws.ts +99 -0
- package/packages/web/tailwind.config.js +65 -0
- package/packages/web/tsconfig.json +18 -0
- package/packages/web/vite.config.ts +20 -0
- package/scripts/dev.sh +122 -0
- package/tsconfig.json +18 -0
- package/wrangler.toml +40 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect, useCallback } from "react";
|
|
2
|
+
import { useAppState, useAppDispatch } from "../store";
|
|
3
|
+
import { sessionsApi, channelsApi, agentsApi } from "../api";
|
|
4
|
+
import { dlog } from "../debug-log";
|
|
5
|
+
|
|
6
|
+
type SessionTabsProps = {
|
|
7
|
+
channelId: string | null;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function SessionTabs({ channelId }: SessionTabsProps) {
|
|
11
|
+
const state = useAppState();
|
|
12
|
+
const dispatch = useAppDispatch();
|
|
13
|
+
const [editingId, setEditingId] = useState<string | null>(null);
|
|
14
|
+
const [editValue, setEditValue] = useState("");
|
|
15
|
+
const editRef = useRef<HTMLInputElement>(null);
|
|
16
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
17
|
+
|
|
18
|
+
const sessions = state.sessions;
|
|
19
|
+
const selectedId = state.selectedSessionId;
|
|
20
|
+
|
|
21
|
+
// Focus input when editing
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (editingId && editRef.current) {
|
|
24
|
+
editRef.current.focus();
|
|
25
|
+
editRef.current.select();
|
|
26
|
+
}
|
|
27
|
+
}, [editingId]);
|
|
28
|
+
|
|
29
|
+
const handleSelect = useCallback(
|
|
30
|
+
(sessionId: string) => {
|
|
31
|
+
if (sessionId === selectedId) return;
|
|
32
|
+
const session = sessions.find((s) => s.id === sessionId);
|
|
33
|
+
if (!session) return;
|
|
34
|
+
dlog.info("Session", `Switched to session: ${session.name} (${session.id})`);
|
|
35
|
+
dispatch({
|
|
36
|
+
type: "SELECT_SESSION",
|
|
37
|
+
sessionId: session.id,
|
|
38
|
+
sessionKey: session.sessionKey,
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
[selectedId, sessions, dispatch],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const handleCreate = useCallback(async () => {
|
|
45
|
+
try {
|
|
46
|
+
let effectiveChannelId = channelId;
|
|
47
|
+
|
|
48
|
+
// Auto-create a "General" channel for the default agent (no channelId yet)
|
|
49
|
+
if (!effectiveChannelId) {
|
|
50
|
+
dlog.info("Session", "No channel for default agent — auto-creating General channel");
|
|
51
|
+
const channel = await channelsApi.create({ name: "General", openclawAgentId: "main" });
|
|
52
|
+
effectiveChannelId = channel.id;
|
|
53
|
+
// Reload agents and channels so the default agent picks up the new channelId
|
|
54
|
+
const [{ agents }, { channels: chs }] = await Promise.all([
|
|
55
|
+
agentsApi.list(),
|
|
56
|
+
channelsApi.list(),
|
|
57
|
+
]);
|
|
58
|
+
dispatch({ type: "SET_AGENTS", agents });
|
|
59
|
+
dispatch({ type: "SET_CHANNELS", channels: chs });
|
|
60
|
+
// Channel creation auto-creates a "Session 1" — load and select it
|
|
61
|
+
const { sessions: newSessions } = await sessionsApi.list(effectiveChannelId);
|
|
62
|
+
dispatch({ type: "SET_SESSIONS", sessions: newSessions });
|
|
63
|
+
if (newSessions.length > 0) {
|
|
64
|
+
dispatch({
|
|
65
|
+
type: "SELECT_SESSION",
|
|
66
|
+
sessionId: newSessions[0].id,
|
|
67
|
+
sessionKey: newSessions[0].sessionKey,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const session = await sessionsApi.create(effectiveChannelId);
|
|
74
|
+
dlog.info("Session", `Created session: ${session.name} (${session.id})`);
|
|
75
|
+
dispatch({ type: "ADD_SESSION", session });
|
|
76
|
+
dispatch({
|
|
77
|
+
type: "SELECT_SESSION",
|
|
78
|
+
sessionId: session.id,
|
|
79
|
+
sessionKey: session.sessionKey,
|
|
80
|
+
});
|
|
81
|
+
// Scroll to the end to show the new tab
|
|
82
|
+
requestAnimationFrame(() => {
|
|
83
|
+
if (scrollRef.current) {
|
|
84
|
+
scrollRef.current.scrollLeft = scrollRef.current.scrollWidth;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
} catch (err) {
|
|
88
|
+
dlog.error("Session", `Failed to create session: ${err}`);
|
|
89
|
+
}
|
|
90
|
+
}, [channelId, dispatch]);
|
|
91
|
+
|
|
92
|
+
const handleDelete = useCallback(
|
|
93
|
+
async (sessionId: string) => {
|
|
94
|
+
if (sessions.length <= 1 || !channelId) return; // can't delete last session
|
|
95
|
+
try {
|
|
96
|
+
await sessionsApi.delete(channelId, sessionId);
|
|
97
|
+
dlog.info("Session", `Deleted session: ${sessionId}`);
|
|
98
|
+
dispatch({ type: "REMOVE_SESSION", sessionId });
|
|
99
|
+
// If deleted the selected session, switch to the first remaining
|
|
100
|
+
if (selectedId === sessionId) {
|
|
101
|
+
const remaining = sessions.filter((s) => s.id !== sessionId);
|
|
102
|
+
if (remaining.length > 0) {
|
|
103
|
+
dispatch({
|
|
104
|
+
type: "SELECT_SESSION",
|
|
105
|
+
sessionId: remaining[0].id,
|
|
106
|
+
sessionKey: remaining[0].sessionKey,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} catch (err) {
|
|
111
|
+
dlog.error("Session", `Failed to delete session: ${err}`);
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
[channelId, sessions, selectedId, dispatch],
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const startRename = useCallback((sessionId: string, currentName: string) => {
|
|
118
|
+
setEditingId(sessionId);
|
|
119
|
+
setEditValue(currentName);
|
|
120
|
+
}, []);
|
|
121
|
+
|
|
122
|
+
const commitRename = useCallback(async () => {
|
|
123
|
+
if (!editingId || !editValue.trim() || !channelId) {
|
|
124
|
+
setEditingId(null);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
await sessionsApi.rename(channelId, editingId, editValue.trim());
|
|
129
|
+
dlog.info("Session", `Renamed session ${editingId} to: ${editValue.trim()}`);
|
|
130
|
+
dispatch({ type: "RENAME_SESSION", sessionId: editingId, name: editValue.trim() });
|
|
131
|
+
} catch (err) {
|
|
132
|
+
dlog.error("Session", `Failed to rename session: ${err}`);
|
|
133
|
+
}
|
|
134
|
+
setEditingId(null);
|
|
135
|
+
}, [channelId, editingId, editValue, dispatch]);
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div
|
|
139
|
+
className="flex items-center gap-0 px-3"
|
|
140
|
+
style={{
|
|
141
|
+
height: 36,
|
|
142
|
+
borderBottom: "1px solid var(--border)",
|
|
143
|
+
background: "var(--bg-surface)",
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
{/* Scrollable tab list + new session button together */}
|
|
147
|
+
<div
|
|
148
|
+
ref={scrollRef}
|
|
149
|
+
className="flex items-center gap-0.5 overflow-x-auto no-scrollbar"
|
|
150
|
+
>
|
|
151
|
+
{sessions.map((session) => {
|
|
152
|
+
const isActive = session.id === selectedId;
|
|
153
|
+
const isEditing = session.id === editingId;
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<div
|
|
157
|
+
key={session.id}
|
|
158
|
+
className="group relative flex items-center shrink-0"
|
|
159
|
+
>
|
|
160
|
+
{isEditing ? (
|
|
161
|
+
<input
|
|
162
|
+
ref={editRef}
|
|
163
|
+
value={editValue}
|
|
164
|
+
onChange={(e) => setEditValue(e.target.value)}
|
|
165
|
+
onBlur={commitRename}
|
|
166
|
+
onKeyDown={(e) => {
|
|
167
|
+
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
|
|
168
|
+
e.preventDefault();
|
|
169
|
+
commitRename();
|
|
170
|
+
}
|
|
171
|
+
if (e.key === "Escape") {
|
|
172
|
+
setEditingId(null);
|
|
173
|
+
}
|
|
174
|
+
}}
|
|
175
|
+
className="px-2.5 py-1 text-caption rounded-t-md focus:outline-none"
|
|
176
|
+
style={{
|
|
177
|
+
background: "var(--bg-hover)",
|
|
178
|
+
color: "var(--text-primary)",
|
|
179
|
+
border: "1px solid var(--bg-active)",
|
|
180
|
+
borderBottom: "none",
|
|
181
|
+
minWidth: 60,
|
|
182
|
+
maxWidth: 140,
|
|
183
|
+
}}
|
|
184
|
+
/>
|
|
185
|
+
) : (
|
|
186
|
+
<button
|
|
187
|
+
onClick={() => handleSelect(session.id)}
|
|
188
|
+
onDoubleClick={() => startRename(session.id, session.name)}
|
|
189
|
+
className="flex items-center gap-1 px-2.5 py-1 text-caption rounded-t-md transition-colors whitespace-nowrap"
|
|
190
|
+
style={{
|
|
191
|
+
background: isActive ? "var(--bg-hover)" : "transparent",
|
|
192
|
+
color: isActive ? "var(--text-primary)" : "var(--text-secondary)",
|
|
193
|
+
fontWeight: isActive ? 700 : 400,
|
|
194
|
+
borderBottom: isActive ? "2px solid var(--bg-active)" : "2px solid transparent",
|
|
195
|
+
marginBottom: -1,
|
|
196
|
+
}}
|
|
197
|
+
title={`${session.name} (double-click to rename)`}
|
|
198
|
+
>
|
|
199
|
+
<span className="max-w-[120px] truncate">{session.name}</span>
|
|
200
|
+
|
|
201
|
+
{/* Close button — only show on hover, not for last session */}
|
|
202
|
+
{sessions.length > 1 && (
|
|
203
|
+
<span
|
|
204
|
+
onClick={(e) => {
|
|
205
|
+
e.stopPropagation();
|
|
206
|
+
handleDelete(session.id);
|
|
207
|
+
}}
|
|
208
|
+
className="ml-0.5 w-4 h-4 flex items-center justify-center rounded-sm opacity-0 group-hover:opacity-100 transition-opacity hover:bg-[--bg-hover]"
|
|
209
|
+
style={{ color: "var(--text-muted)" }}
|
|
210
|
+
title="Close session"
|
|
211
|
+
>
|
|
212
|
+
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
213
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
214
|
+
</svg>
|
|
215
|
+
</span>
|
|
216
|
+
)}
|
|
217
|
+
</button>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
);
|
|
221
|
+
})}
|
|
222
|
+
|
|
223
|
+
{/* New session button — inline right after tabs */}
|
|
224
|
+
<button
|
|
225
|
+
onClick={handleCreate}
|
|
226
|
+
className="shrink-0 w-7 h-7 flex items-center justify-center rounded-md transition-colors ml-0.5"
|
|
227
|
+
style={{ color: "var(--text-muted)" }}
|
|
228
|
+
title="New session"
|
|
229
|
+
aria-label="New session"
|
|
230
|
+
onMouseEnter={(e) => {
|
|
231
|
+
e.currentTarget.style.background = "var(--bg-hover)";
|
|
232
|
+
e.currentTarget.style.color = "var(--text-primary)";
|
|
233
|
+
}}
|
|
234
|
+
onMouseLeave={(e) => {
|
|
235
|
+
e.currentTarget.style.background = "";
|
|
236
|
+
e.currentTarget.style.color = "var(--text-muted)";
|
|
237
|
+
}}
|
|
238
|
+
>
|
|
239
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
240
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
|
241
|
+
</svg>
|
|
242
|
+
</button>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect } from "react";
|
|
2
|
+
import { useAppState, useAppDispatch } from "../store";
|
|
3
|
+
import { agentsApi, channelsApi } from "../api";
|
|
4
|
+
import { dlog } from "../debug-log";
|
|
5
|
+
|
|
6
|
+
export function Sidebar() {
|
|
7
|
+
const state = useAppState();
|
|
8
|
+
const dispatch = useAppDispatch();
|
|
9
|
+
const [showCreate, setShowCreate] = useState(false);
|
|
10
|
+
const [newName, setNewName] = useState("");
|
|
11
|
+
const [newDesc, setNewDesc] = useState("");
|
|
12
|
+
const [channelsExpanded, setChannelsExpanded] = useState(true);
|
|
13
|
+
|
|
14
|
+
const handleCreate = async () => {
|
|
15
|
+
if (!newName.trim()) return;
|
|
16
|
+
dlog.info("Channel", `Creating channel: "${newName}"${newDesc ? ` (${newDesc})` : ""}`);
|
|
17
|
+
try {
|
|
18
|
+
await channelsApi.create({ name: newName, description: newDesc });
|
|
19
|
+
const { agents } = await agentsApi.list();
|
|
20
|
+
const { channels } = await channelsApi.list();
|
|
21
|
+
dispatch({ type: "SET_AGENTS", agents });
|
|
22
|
+
dispatch({ type: "SET_CHANNELS", channels });
|
|
23
|
+
const created = agents.find((a) => a.name === newName.trim());
|
|
24
|
+
if (created) {
|
|
25
|
+
dlog.info("Channel", `Channel created → agent ${created.id}, auto-selected`);
|
|
26
|
+
dispatch({
|
|
27
|
+
type: "SELECT_AGENT",
|
|
28
|
+
agentId: created.id,
|
|
29
|
+
sessionKey: created.sessionKey,
|
|
30
|
+
});
|
|
31
|
+
try { localStorage.setItem("botschat_last_agent", created.id); } catch { /* ignore */ }
|
|
32
|
+
}
|
|
33
|
+
setShowCreate(false);
|
|
34
|
+
setNewName("");
|
|
35
|
+
setNewDesc("");
|
|
36
|
+
} catch (err) {
|
|
37
|
+
dlog.error("Channel", `Failed to create channel: ${err}`);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
|
42
|
+
|
|
43
|
+
const handleSelectAgent = (agentId: string, sessionKey: string) => {
|
|
44
|
+
// Skip if already selected – avoids clearing messages for no reason
|
|
45
|
+
if (state.selectedAgentId === agentId) return;
|
|
46
|
+
const agent = state.agents.find((a) => a.id === agentId);
|
|
47
|
+
dlog.info("Channel", `Selected channel: ${agent?.name ?? agentId} (session=${sessionKey})`);
|
|
48
|
+
dispatch({ type: "SELECT_AGENT", agentId, sessionKey });
|
|
49
|
+
// Persist last selected channel so it survives page refresh
|
|
50
|
+
try { localStorage.setItem("botschat_last_agent", agentId); } catch { /* ignore */ }
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const handleDeleteChannel = async (channelId: string) => {
|
|
54
|
+
const channel = state.channels.find((c) => c.id === channelId);
|
|
55
|
+
dlog.info("Channel", `Deleting channel: ${channel?.name ?? channelId}`);
|
|
56
|
+
try {
|
|
57
|
+
await channelsApi.delete(channelId);
|
|
58
|
+
dlog.info("Channel", `Channel deleted: ${channel?.name ?? channelId}`);
|
|
59
|
+
// If the deleted channel's agent is currently selected, clear selection
|
|
60
|
+
const deletedAgent = state.agents.find((a) => a.channelId === channelId);
|
|
61
|
+
if (deletedAgent && state.selectedAgentId === deletedAgent.id) {
|
|
62
|
+
dispatch({ type: "SELECT_AGENT", agentId: null, sessionKey: null });
|
|
63
|
+
}
|
|
64
|
+
// Refresh agents and channels
|
|
65
|
+
const { agents } = await agentsApi.list();
|
|
66
|
+
const { channels } = await channelsApi.list();
|
|
67
|
+
dispatch({ type: "SET_AGENTS", agents });
|
|
68
|
+
dispatch({ type: "SET_CHANNELS", channels });
|
|
69
|
+
} catch (err) {
|
|
70
|
+
dlog.error("Channel", `Failed to delete channel: ${err}`);
|
|
71
|
+
} finally {
|
|
72
|
+
setConfirmDeleteId(null);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Split agents: default agent vs channel agents
|
|
77
|
+
// Hide the auto-created "Default" channel (used only for cron import) from Messages view
|
|
78
|
+
const defaultAgents = state.agents.filter((a) => a.isDefault);
|
|
79
|
+
const channelAgents = state.agents.filter((a) => !a.isDefault && a.name !== "Default");
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div
|
|
83
|
+
className="flex flex-col h-full"
|
|
84
|
+
style={{ width: 220, minWidth: 160, background: "var(--bg-secondary)", borderRight: "1px solid var(--border)" }}
|
|
85
|
+
>
|
|
86
|
+
{/* Workspace Switcher */}
|
|
87
|
+
<div className="px-4 py-3 flex items-center gap-2">
|
|
88
|
+
<span className="text-[--text-sidebar-active] font-bold text-h2 truncate flex-1">
|
|
89
|
+
BotsChat
|
|
90
|
+
</span>
|
|
91
|
+
<svg className="w-3 h-3 text-[--text-sidebar]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
92
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
93
|
+
</svg>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
{/* Connection status */}
|
|
97
|
+
<div className="px-4 pb-2">
|
|
98
|
+
<div className="flex items-center gap-1.5">
|
|
99
|
+
<div
|
|
100
|
+
className="w-2 h-2 rounded-full"
|
|
101
|
+
style={{ background: state.openclawConnected ? "var(--accent-green)" : "var(--accent-red)" }}
|
|
102
|
+
/>
|
|
103
|
+
<span className="text-tiny text-[--text-muted]">
|
|
104
|
+
{state.openclawConnected ? "OpenClaw connected" : "OpenClaw offline"}
|
|
105
|
+
</span>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
{/* Navigation list */}
|
|
110
|
+
<div className="flex-1 overflow-y-auto sidebar-scroll">
|
|
111
|
+
{/* Channels section */}
|
|
112
|
+
<SectionHeader
|
|
113
|
+
label="Channels"
|
|
114
|
+
expanded={channelsExpanded}
|
|
115
|
+
onToggle={() => setChannelsExpanded(!channelsExpanded)}
|
|
116
|
+
/>
|
|
117
|
+
{channelsExpanded && (
|
|
118
|
+
<div>
|
|
119
|
+
{defaultAgents.map((a) => (
|
|
120
|
+
<SidebarItem
|
|
121
|
+
key={a.id}
|
|
122
|
+
label={`# ${a.name}`}
|
|
123
|
+
active={state.selectedAgentId === a.id}
|
|
124
|
+
onClick={() => handleSelectAgent(a.id, a.sessionKey)}
|
|
125
|
+
/>
|
|
126
|
+
))}
|
|
127
|
+
{channelAgents.map((a) => (
|
|
128
|
+
<SidebarItem
|
|
129
|
+
key={a.id}
|
|
130
|
+
label={`# ${a.name}`}
|
|
131
|
+
active={state.selectedAgentId === a.id}
|
|
132
|
+
onClick={() => handleSelectAgent(a.id, a.sessionKey)}
|
|
133
|
+
showDelete
|
|
134
|
+
confirmDelete={confirmDeleteId === a.channelId}
|
|
135
|
+
onDeleteClick={(e) => {
|
|
136
|
+
e.stopPropagation();
|
|
137
|
+
setConfirmDeleteId(a.channelId);
|
|
138
|
+
}}
|
|
139
|
+
onDeleteConfirm={(e) => {
|
|
140
|
+
e.stopPropagation();
|
|
141
|
+
if (a.channelId) handleDeleteChannel(a.channelId);
|
|
142
|
+
}}
|
|
143
|
+
onDeleteCancel={(e) => {
|
|
144
|
+
e.stopPropagation();
|
|
145
|
+
setConfirmDeleteId(null);
|
|
146
|
+
}}
|
|
147
|
+
/>
|
|
148
|
+
))}
|
|
149
|
+
{channelAgents.length === 0 && defaultAgents.length === 0 && (
|
|
150
|
+
<div className="px-8 py-2 text-tiny text-[--text-muted]">
|
|
151
|
+
Loading channels…
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
{/* Create channel */}
|
|
159
|
+
{showCreate ? (
|
|
160
|
+
<div className="p-3 space-y-2" style={{ borderTop: "1px solid var(--sidebar-border)" }}>
|
|
161
|
+
<input
|
|
162
|
+
type="text"
|
|
163
|
+
placeholder="Channel name"
|
|
164
|
+
value={newName}
|
|
165
|
+
onChange={(e) => setNewName(e.target.value)}
|
|
166
|
+
onKeyDown={(e) => e.key === "Enter" && !e.nativeEvent.isComposing && handleCreate()}
|
|
167
|
+
className="w-full px-2 py-1.5 text-caption text-[--text-sidebar] rounded-sm focus:outline-none placeholder:text-[--text-muted]"
|
|
168
|
+
style={{ background: "var(--sidebar-hover)", border: "1px solid var(--sidebar-border)" }}
|
|
169
|
+
autoFocus
|
|
170
|
+
/>
|
|
171
|
+
<input
|
|
172
|
+
type="text"
|
|
173
|
+
placeholder="Description (optional)"
|
|
174
|
+
value={newDesc}
|
|
175
|
+
onChange={(e) => setNewDesc(e.target.value)}
|
|
176
|
+
className="w-full px-2 py-1.5 text-caption text-[--text-sidebar] rounded-sm focus:outline-none placeholder:text-[--text-muted]"
|
|
177
|
+
style={{ background: "var(--sidebar-hover)", border: "1px solid var(--sidebar-border)" }}
|
|
178
|
+
/>
|
|
179
|
+
<div className="flex gap-2">
|
|
180
|
+
<button
|
|
181
|
+
onClick={handleCreate}
|
|
182
|
+
className="flex-1 px-3 py-1.5 text-caption bg-[--bg-active] text-white rounded-sm font-bold hover:brightness-110"
|
|
183
|
+
>
|
|
184
|
+
Create
|
|
185
|
+
</button>
|
|
186
|
+
<button
|
|
187
|
+
onClick={() => setShowCreate(false)}
|
|
188
|
+
className="px-3 py-1.5 text-caption text-[--text-muted] hover:text-[--text-sidebar]"
|
|
189
|
+
>
|
|
190
|
+
Cancel
|
|
191
|
+
</button>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
) : (
|
|
195
|
+
<div className="p-3" style={{ borderTop: "1px solid var(--sidebar-border)" }}>
|
|
196
|
+
<button
|
|
197
|
+
onClick={() => setShowCreate(true)}
|
|
198
|
+
className="w-full px-3 py-1.5 text-caption text-[--text-sidebar] hover:text-[--text-sidebar-active] rounded-sm border border-dashed transition-colors"
|
|
199
|
+
style={{ borderColor: "var(--sidebar-divider)" }}
|
|
200
|
+
>
|
|
201
|
+
+ New channel
|
|
202
|
+
</button>
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function SectionHeader({
|
|
210
|
+
label,
|
|
211
|
+
expanded,
|
|
212
|
+
onToggle,
|
|
213
|
+
}: {
|
|
214
|
+
label: string;
|
|
215
|
+
expanded: boolean;
|
|
216
|
+
onToggle: () => void;
|
|
217
|
+
}) {
|
|
218
|
+
return (
|
|
219
|
+
<button
|
|
220
|
+
onClick={onToggle}
|
|
221
|
+
className="w-full flex items-center gap-1 px-4 py-1.5 text-tiny uppercase tracking-wider text-[--text-sidebar] hover:text-[--text-sidebar-active] transition-colors"
|
|
222
|
+
>
|
|
223
|
+
<svg
|
|
224
|
+
className={`w-3 h-3 transition-transform ${expanded ? "rotate-0" : "-rotate-90"}`}
|
|
225
|
+
fill="none"
|
|
226
|
+
viewBox="0 0 24 24"
|
|
227
|
+
stroke="currentColor"
|
|
228
|
+
strokeWidth={2}
|
|
229
|
+
>
|
|
230
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
231
|
+
</svg>
|
|
232
|
+
{label}
|
|
233
|
+
</button>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function SidebarItem({
|
|
238
|
+
label,
|
|
239
|
+
active,
|
|
240
|
+
onClick,
|
|
241
|
+
showDelete,
|
|
242
|
+
confirmDelete,
|
|
243
|
+
onDeleteClick,
|
|
244
|
+
onDeleteConfirm,
|
|
245
|
+
onDeleteCancel,
|
|
246
|
+
}: {
|
|
247
|
+
label: string;
|
|
248
|
+
active: boolean;
|
|
249
|
+
onClick: () => void;
|
|
250
|
+
showDelete?: boolean;
|
|
251
|
+
confirmDelete?: boolean;
|
|
252
|
+
onDeleteClick?: (e: React.MouseEvent) => void;
|
|
253
|
+
onDeleteConfirm?: (e: React.MouseEvent) => void;
|
|
254
|
+
onDeleteCancel?: (e: React.MouseEvent) => void;
|
|
255
|
+
}) {
|
|
256
|
+
const [hovered, setHovered] = useState(false);
|
|
257
|
+
const confirmRef = useRef<HTMLDivElement>(null);
|
|
258
|
+
|
|
259
|
+
// Close confirmation when clicking outside
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
if (!confirmDelete) return;
|
|
262
|
+
const handler = (e: MouseEvent) => {
|
|
263
|
+
if (confirmRef.current && !confirmRef.current.contains(e.target as Node)) {
|
|
264
|
+
onDeleteCancel?.(e as unknown as React.MouseEvent);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
document.addEventListener("mousedown", handler);
|
|
268
|
+
return () => document.removeEventListener("mousedown", handler);
|
|
269
|
+
}, [confirmDelete, onDeleteCancel]);
|
|
270
|
+
|
|
271
|
+
if (confirmDelete) {
|
|
272
|
+
return (
|
|
273
|
+
<div
|
|
274
|
+
ref={confirmRef}
|
|
275
|
+
className="px-4 py-1.5 flex items-center gap-1.5"
|
|
276
|
+
style={{ paddingLeft: 32, background: "var(--sidebar-hover)" }}
|
|
277
|
+
>
|
|
278
|
+
<span className="text-caption text-[--text-sidebar] truncate flex-1">Delete?</span>
|
|
279
|
+
<button
|
|
280
|
+
onClick={onDeleteConfirm}
|
|
281
|
+
className="px-1.5 py-0.5 text-tiny rounded-sm font-bold text-white"
|
|
282
|
+
style={{ background: "var(--accent-red, #e53935)" }}
|
|
283
|
+
>
|
|
284
|
+
Yes
|
|
285
|
+
</button>
|
|
286
|
+
<button
|
|
287
|
+
onClick={onDeleteCancel}
|
|
288
|
+
className="px-1.5 py-0.5 text-tiny rounded-sm text-[--text-muted] hover:text-[--text-sidebar]"
|
|
289
|
+
>
|
|
290
|
+
No
|
|
291
|
+
</button>
|
|
292
|
+
</div>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
<div
|
|
298
|
+
className="relative group"
|
|
299
|
+
onMouseEnter={() => setHovered(true)}
|
|
300
|
+
onMouseLeave={() => setHovered(false)}
|
|
301
|
+
>
|
|
302
|
+
<button
|
|
303
|
+
onClick={onClick}
|
|
304
|
+
className="w-full text-left py-[5px] text-body truncate transition-colors"
|
|
305
|
+
onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = "var(--sidebar-hover)"; }}
|
|
306
|
+
onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = active ? "var(--bg-hover)" : ""; }}
|
|
307
|
+
style={{
|
|
308
|
+
paddingLeft: active ? 29 : 32,
|
|
309
|
+
paddingRight: showDelete ? 28 : undefined,
|
|
310
|
+
background: active ? "var(--bg-hover)" : undefined,
|
|
311
|
+
borderLeft: active ? "3px solid var(--bg-active)" : "3px solid transparent",
|
|
312
|
+
color: active ? "var(--text-sidebar-active)" : "var(--text-sidebar)",
|
|
313
|
+
fontWeight: active ? 700 : undefined,
|
|
314
|
+
}}
|
|
315
|
+
>
|
|
316
|
+
{label}
|
|
317
|
+
</button>
|
|
318
|
+
{showDelete && hovered && (
|
|
319
|
+
<button
|
|
320
|
+
onClick={onDeleteClick}
|
|
321
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 flex items-center justify-center rounded-sm text-[--text-muted] hover:text-[--accent-red] hover:bg-[--sidebar-hover] transition-colors"
|
|
322
|
+
title="Delete channel"
|
|
323
|
+
>
|
|
324
|
+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
325
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
326
|
+
</svg>
|
|
327
|
+
</button>
|
|
328
|
+
)}
|
|
329
|
+
</div>
|
|
330
|
+
);
|
|
331
|
+
}
|