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.
Files changed (88) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +213 -0
  3. package/migrations/0001_initial.sql +88 -0
  4. package/migrations/0002_rename_projects_to_channels.sql +53 -0
  5. package/migrations/0003_messages.sql +14 -0
  6. package/migrations/0004_jobs.sql +15 -0
  7. package/migrations/0005_deleted_cron_jobs.sql +6 -0
  8. package/migrations/0006_tasks_add_model.sql +2 -0
  9. package/migrations/0007_sessions.sql +25 -0
  10. package/migrations/0008_remove_openclaw_fields.sql +8 -0
  11. package/package.json +53 -0
  12. package/packages/api/package.json +17 -0
  13. package/packages/api/src/do/connection-do.ts +929 -0
  14. package/packages/api/src/env.ts +8 -0
  15. package/packages/api/src/index.ts +297 -0
  16. package/packages/api/src/routes/agents.ts +68 -0
  17. package/packages/api/src/routes/auth.ts +105 -0
  18. package/packages/api/src/routes/channels.ts +185 -0
  19. package/packages/api/src/routes/jobs.ts +65 -0
  20. package/packages/api/src/routes/models.ts +22 -0
  21. package/packages/api/src/routes/pairing.ts +76 -0
  22. package/packages/api/src/routes/projects.ts +177 -0
  23. package/packages/api/src/routes/sessions.ts +171 -0
  24. package/packages/api/src/routes/tasks.ts +375 -0
  25. package/packages/api/src/routes/upload.ts +52 -0
  26. package/packages/api/src/utils/auth.ts +101 -0
  27. package/packages/api/src/utils/id.ts +19 -0
  28. package/packages/api/tsconfig.json +18 -0
  29. package/packages/plugin/dist/index.d.ts +19 -0
  30. package/packages/plugin/dist/index.d.ts.map +1 -0
  31. package/packages/plugin/dist/index.js +17 -0
  32. package/packages/plugin/dist/index.js.map +1 -0
  33. package/packages/plugin/dist/src/accounts.d.ts +12 -0
  34. package/packages/plugin/dist/src/accounts.d.ts.map +1 -0
  35. package/packages/plugin/dist/src/accounts.js +103 -0
  36. package/packages/plugin/dist/src/accounts.js.map +1 -0
  37. package/packages/plugin/dist/src/channel.d.ts +206 -0
  38. package/packages/plugin/dist/src/channel.d.ts.map +1 -0
  39. package/packages/plugin/dist/src/channel.js +1248 -0
  40. package/packages/plugin/dist/src/channel.js.map +1 -0
  41. package/packages/plugin/dist/src/runtime.d.ts +3 -0
  42. package/packages/plugin/dist/src/runtime.d.ts.map +1 -0
  43. package/packages/plugin/dist/src/runtime.js +18 -0
  44. package/packages/plugin/dist/src/runtime.js.map +1 -0
  45. package/packages/plugin/dist/src/types.d.ts +179 -0
  46. package/packages/plugin/dist/src/types.d.ts.map +1 -0
  47. package/packages/plugin/dist/src/types.js +6 -0
  48. package/packages/plugin/dist/src/types.js.map +1 -0
  49. package/packages/plugin/dist/src/ws-client.d.ts +51 -0
  50. package/packages/plugin/dist/src/ws-client.d.ts.map +1 -0
  51. package/packages/plugin/dist/src/ws-client.js +170 -0
  52. package/packages/plugin/dist/src/ws-client.js.map +1 -0
  53. package/packages/plugin/openclaw.plugin.json +11 -0
  54. package/packages/plugin/package.json +39 -0
  55. package/packages/plugin/tsconfig.json +20 -0
  56. package/packages/web/dist/assets/index-C-wI8eHy.css +1 -0
  57. package/packages/web/dist/assets/index-CbPEKHLG.js +93 -0
  58. package/packages/web/dist/index.html +17 -0
  59. package/packages/web/index.html +16 -0
  60. package/packages/web/package.json +29 -0
  61. package/packages/web/postcss.config.js +6 -0
  62. package/packages/web/src/App.tsx +827 -0
  63. package/packages/web/src/api.ts +242 -0
  64. package/packages/web/src/components/ChatWindow.tsx +864 -0
  65. package/packages/web/src/components/CronDetail.tsx +943 -0
  66. package/packages/web/src/components/CronSidebar.tsx +123 -0
  67. package/packages/web/src/components/DebugLogPanel.tsx +258 -0
  68. package/packages/web/src/components/IconRail.tsx +163 -0
  69. package/packages/web/src/components/JobList.tsx +120 -0
  70. package/packages/web/src/components/LoginPage.tsx +178 -0
  71. package/packages/web/src/components/MessageContent.tsx +1082 -0
  72. package/packages/web/src/components/ModelSelect.tsx +87 -0
  73. package/packages/web/src/components/ScheduleEditor.tsx +403 -0
  74. package/packages/web/src/components/SessionTabs.tsx +246 -0
  75. package/packages/web/src/components/Sidebar.tsx +331 -0
  76. package/packages/web/src/components/TaskBar.tsx +413 -0
  77. package/packages/web/src/components/ThreadPanel.tsx +212 -0
  78. package/packages/web/src/debug-log.ts +58 -0
  79. package/packages/web/src/index.css +170 -0
  80. package/packages/web/src/main.tsx +10 -0
  81. package/packages/web/src/store.ts +492 -0
  82. package/packages/web/src/ws.ts +99 -0
  83. package/packages/web/tailwind.config.js +65 -0
  84. package/packages/web/tsconfig.json +18 -0
  85. package/packages/web/vite.config.ts +20 -0
  86. package/scripts/dev.sh +122 -0
  87. package/tsconfig.json +18 -0
  88. 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
+ }