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,864 @@
|
|
|
1
|
+
import React, { useRef, useEffect, useState, useMemo, useCallback } from "react";
|
|
2
|
+
import { useAppState, useAppDispatch, type ChatMessage } from "../store";
|
|
3
|
+
import type { WSMessage } from "../ws";
|
|
4
|
+
import { MessageContent } from "./MessageContent";
|
|
5
|
+
import { ModelSelect } from "./ModelSelect";
|
|
6
|
+
import { SessionTabs } from "./SessionTabs";
|
|
7
|
+
import { dlog } from "../debug-log";
|
|
8
|
+
|
|
9
|
+
type ChatWindowProps = {
|
|
10
|
+
sendMessage: (msg: WSMessage) => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Simple string hash for action prompt keys (matches MessageContent) */
|
|
14
|
+
function simpleHash(str: string): string {
|
|
15
|
+
let hash = 0;
|
|
16
|
+
for (let i = 0; i < str.length; i++) {
|
|
17
|
+
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
|
|
18
|
+
}
|
|
19
|
+
return hash.toString(36);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Skill definitions & frequency tracking (v2 – recency-aware, daily buckets)
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Storage: localStorage (per-origin). Data persists across page reloads but
|
|
26
|
+
// lives separately per origin (localhost:8787 vs localhost:3000). The v2 format
|
|
27
|
+
// stores daily usage buckets so we can weight recent activity higher.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
type Skill = { cmd: string; label: string; icon: string };
|
|
31
|
+
|
|
32
|
+
/** Default skills — shown even before any usage */
|
|
33
|
+
const DEFAULT_SKILLS: Skill[] = [
|
|
34
|
+
{ cmd: "/help", label: "Help", icon: "?" },
|
|
35
|
+
{ cmd: "/status", label: "Status", icon: "i" },
|
|
36
|
+
{ cmd: "/model", label: "Model", icon: "M" },
|
|
37
|
+
{ cmd: "/clear", label: "Clear", icon: "C" },
|
|
38
|
+
{ cmd: "/think", label: "Think", icon: "T" },
|
|
39
|
+
{ cmd: "/image", label: "Image", icon: "I" },
|
|
40
|
+
{ cmd: "/search", label: "Search", icon: "S" },
|
|
41
|
+
{ cmd: "/summarize", label: "Summarize", icon: "Σ" },
|
|
42
|
+
{ cmd: "/translate", label: "Translate", icon: "翻" },
|
|
43
|
+
{ cmd: "/reset", label: "Reset", icon: "R" },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
// --- v2 storage types ---
|
|
47
|
+
type SkillEntry = { total: number; daily: Record<string, number> };
|
|
48
|
+
type SkillStore = Record<string, SkillEntry>;
|
|
49
|
+
|
|
50
|
+
const STORE_KEY = "botschat_skill_freq_v2";
|
|
51
|
+
const V1_KEY = "botschat_skill_freq";
|
|
52
|
+
const RECENCY_DAYS = 2; // window for "recent" weighting
|
|
53
|
+
const PRUNE_DAYS = 14; // drop daily buckets older than this
|
|
54
|
+
|
|
55
|
+
function dateKey(d = new Date()): string {
|
|
56
|
+
return d.toISOString().slice(0, 10); // "YYYY-MM-DD"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Prune daily buckets older than PRUNE_DAYS in-place. */
|
|
60
|
+
function pruneStore(store: SkillStore) {
|
|
61
|
+
const cutoff = Date.now() - PRUNE_DAYS * 86_400_000;
|
|
62
|
+
for (const entry of Object.values(store)) {
|
|
63
|
+
for (const ds of Object.keys(entry.daily)) {
|
|
64
|
+
if (new Date(ds).getTime() < cutoff) delete entry.daily[ds];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Load skill frequency store, migrating from v1 if needed. */
|
|
70
|
+
function loadSkillStore(): SkillStore {
|
|
71
|
+
try {
|
|
72
|
+
// Try v2 first
|
|
73
|
+
const raw = localStorage.getItem(STORE_KEY);
|
|
74
|
+
if (raw) {
|
|
75
|
+
const store: SkillStore = JSON.parse(raw);
|
|
76
|
+
pruneStore(store);
|
|
77
|
+
return store;
|
|
78
|
+
}
|
|
79
|
+
// Auto-migrate from v1 (plain Record<string, number>)
|
|
80
|
+
const v1 = localStorage.getItem(V1_KEY);
|
|
81
|
+
if (v1) {
|
|
82
|
+
const old: Record<string, number> = JSON.parse(v1);
|
|
83
|
+
const today = dateKey();
|
|
84
|
+
const store: SkillStore = {};
|
|
85
|
+
for (const [cmd, count] of Object.entries(old)) {
|
|
86
|
+
store[cmd] = { total: count, daily: { [today]: count } };
|
|
87
|
+
}
|
|
88
|
+
saveSkillStore(store);
|
|
89
|
+
localStorage.removeItem(V1_KEY);
|
|
90
|
+
return store;
|
|
91
|
+
}
|
|
92
|
+
return {};
|
|
93
|
+
} catch { return {}; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function saveSkillStore(store: SkillStore) {
|
|
97
|
+
pruneStore(store);
|
|
98
|
+
localStorage.setItem(STORE_KEY, JSON.stringify(store));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Detect and record a /command from the message text. Returns the command if found. */
|
|
102
|
+
function recordSkillUsage(text: string): string | null {
|
|
103
|
+
const match = text.match(/^\/(\S+)/);
|
|
104
|
+
if (!match) return null;
|
|
105
|
+
const cmd = `/${match[1]}`;
|
|
106
|
+
const store = loadSkillStore();
|
|
107
|
+
const today = dateKey();
|
|
108
|
+
if (!store[cmd]) store[cmd] = { total: 0, daily: {} };
|
|
109
|
+
store[cmd].total += 1;
|
|
110
|
+
store[cmd].daily[today] = (store[cmd].daily[today] ?? 0) + 1;
|
|
111
|
+
saveSkillStore(store);
|
|
112
|
+
return cmd;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Count usages within the last RECENCY_DAYS. */
|
|
116
|
+
function recentCount(entry: SkillEntry): number {
|
|
117
|
+
const cutoff = Date.now() - RECENCY_DAYS * 86_400_000;
|
|
118
|
+
let sum = 0;
|
|
119
|
+
for (const [ds, cnt] of Object.entries(entry.daily)) {
|
|
120
|
+
if (new Date(ds).getTime() >= cutoff) sum += cnt;
|
|
121
|
+
}
|
|
122
|
+
return sum;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Composite score: recent usage * 5 + all-time total. */
|
|
126
|
+
function skillScore(entry: SkillEntry): number {
|
|
127
|
+
return recentCount(entry) * 5 + entry.total;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Return default skills + user-typed custom skills, sorted by a composite
|
|
132
|
+
* recency score (skills used more in the last 2 days float to the front).
|
|
133
|
+
*/
|
|
134
|
+
function getSortedSkills(): { skills: Skill[]; store: SkillStore } {
|
|
135
|
+
const store = loadSkillStore();
|
|
136
|
+
const defaultCmds = new Set(DEFAULT_SKILLS.map((s) => s.cmd));
|
|
137
|
+
// Build entries for user-typed skills that aren't in the default list
|
|
138
|
+
const customSkills: Skill[] = Object.keys(store)
|
|
139
|
+
.filter((cmd) => !defaultCmds.has(cmd) && cmd.startsWith("/"))
|
|
140
|
+
.map((cmd) => ({
|
|
141
|
+
cmd,
|
|
142
|
+
label: cmd.slice(1).charAt(0).toUpperCase() + cmd.slice(2),
|
|
143
|
+
icon: cmd.slice(1).charAt(0).toUpperCase(),
|
|
144
|
+
}));
|
|
145
|
+
const skills = [...DEFAULT_SKILLS, ...customSkills].sort((a, b) => {
|
|
146
|
+
const sa = store[a.cmd] ? skillScore(store[a.cmd]) : 0;
|
|
147
|
+
const sb = store[b.cmd] ? skillScore(store[b.cmd]) : 0;
|
|
148
|
+
return sb - sa;
|
|
149
|
+
});
|
|
150
|
+
return { skills, store };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Flat-row message display + composer, per design guideline section 5.2/5.6 */
|
|
154
|
+
export function ChatWindow({ sendMessage }: ChatWindowProps) {
|
|
155
|
+
const state = useAppState();
|
|
156
|
+
const dispatch = useAppDispatch();
|
|
157
|
+
const [input, setInput] = useState("");
|
|
158
|
+
const [skillVersion, setSkillVersion] = useState(0); // bump to re-sort skills
|
|
159
|
+
const [pendingImage, setPendingImage] = useState<{ file: File; preview: string } | null>(null);
|
|
160
|
+
const [imageUploading, setImageUploading] = useState(false);
|
|
161
|
+
const [dragOver, setDragOver] = useState(false);
|
|
162
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
163
|
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
164
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
165
|
+
const dropZoneRef = useRef<HTMLDivElement>(null);
|
|
166
|
+
|
|
167
|
+
const sessionKey = state.selectedSessionKey;
|
|
168
|
+
|
|
169
|
+
const { skills: sortedSkills, store: skillStore } = useMemo(
|
|
170
|
+
() => getSortedSkills(),
|
|
171
|
+
[skillVersion],
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
176
|
+
}, [state.messages]);
|
|
177
|
+
|
|
178
|
+
// Auto-resize textarea
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if (inputRef.current) {
|
|
181
|
+
inputRef.current.style.height = "auto";
|
|
182
|
+
inputRef.current.style.height = Math.min(inputRef.current.scrollHeight, 160) + "px";
|
|
183
|
+
}
|
|
184
|
+
}, [input]);
|
|
185
|
+
|
|
186
|
+
// Auto-focus the input when a session is active (page load or channel switch)
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (sessionKey && inputRef.current) {
|
|
189
|
+
// Small delay to ensure DOM is ready after render
|
|
190
|
+
requestAnimationFrame(() => {
|
|
191
|
+
inputRef.current?.focus();
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}, [sessionKey]);
|
|
195
|
+
|
|
196
|
+
// Restore per-session model from localStorage when session changes
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (!sessionKey) return;
|
|
199
|
+
try {
|
|
200
|
+
const stored = JSON.parse(localStorage.getItem("botschat:sessionModels") || "{}");
|
|
201
|
+
const saved = stored[sessionKey];
|
|
202
|
+
if (saved && saved !== state.sessionModel) {
|
|
203
|
+
dispatch({ type: "SET_SESSION_MODEL", model: saved });
|
|
204
|
+
} else if (!saved && state.sessionModel) {
|
|
205
|
+
// New session with no override — clear sessionModel so defaultModel shows
|
|
206
|
+
dispatch({ type: "SET_SESSION_MODEL", model: null });
|
|
207
|
+
}
|
|
208
|
+
} catch { /* ignore */ }
|
|
209
|
+
}, [sessionKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
210
|
+
|
|
211
|
+
const currentModel = state.sessionModel ?? state.defaultModel;
|
|
212
|
+
|
|
213
|
+
const handleModelChange = useCallback((modelId: string) => {
|
|
214
|
+
if (!modelId || !sessionKey || modelId === currentModel) return;
|
|
215
|
+
|
|
216
|
+
dlog.info("Chat", `Model change: ${currentModel ?? "none"} → ${modelId}`);
|
|
217
|
+
|
|
218
|
+
// Optimistically update the dropdown immediately
|
|
219
|
+
dispatch({ type: "SET_SESSION_MODEL", model: modelId });
|
|
220
|
+
|
|
221
|
+
// Persist per-session model to localStorage
|
|
222
|
+
try {
|
|
223
|
+
const stored = JSON.parse(localStorage.getItem("botschat:sessionModels") || "{}");
|
|
224
|
+
stored[sessionKey] = modelId;
|
|
225
|
+
localStorage.setItem("botschat:sessionModels", JSON.stringify(stored));
|
|
226
|
+
} catch { /* ignore */ }
|
|
227
|
+
|
|
228
|
+
recordSkillUsage("/model");
|
|
229
|
+
setSkillVersion((v) => v + 1);
|
|
230
|
+
|
|
231
|
+
const msg: ChatMessage = {
|
|
232
|
+
id: crypto.randomUUID(),
|
|
233
|
+
sender: "user",
|
|
234
|
+
text: `/model ${modelId}`,
|
|
235
|
+
timestamp: Date.now(),
|
|
236
|
+
};
|
|
237
|
+
dispatch({ type: "ADD_MESSAGE", message: msg });
|
|
238
|
+
sendMessage({
|
|
239
|
+
type: "user.message",
|
|
240
|
+
sessionKey,
|
|
241
|
+
text: `/model ${modelId}`,
|
|
242
|
+
userId: state.user?.id ?? "",
|
|
243
|
+
messageId: msg.id,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
requestAnimationFrame(() => {
|
|
247
|
+
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
248
|
+
});
|
|
249
|
+
}, [sessionKey, currentModel, state.user?.id, sendMessage, dispatch]);
|
|
250
|
+
|
|
251
|
+
const handleSkillClick = useCallback((cmd: string) => {
|
|
252
|
+
dlog.info("Skill", `Skill button clicked: ${cmd}`);
|
|
253
|
+
setInput((prev) => {
|
|
254
|
+
// If input already starts with this command, don't duplicate
|
|
255
|
+
if (prev.startsWith(cmd + " ") || prev === cmd) return prev;
|
|
256
|
+
return cmd + " ";
|
|
257
|
+
});
|
|
258
|
+
inputRef.current?.focus();
|
|
259
|
+
}, []);
|
|
260
|
+
|
|
261
|
+
// Image upload helpers
|
|
262
|
+
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
263
|
+
const file = e.target.files?.[0];
|
|
264
|
+
if (!file || !file.type.startsWith("image/")) return;
|
|
265
|
+
const preview = URL.createObjectURL(file);
|
|
266
|
+
setPendingImage({ file, preview });
|
|
267
|
+
e.target.value = "";
|
|
268
|
+
inputRef.current?.focus();
|
|
269
|
+
}, []);
|
|
270
|
+
|
|
271
|
+
const clearPendingImage = useCallback(() => {
|
|
272
|
+
if (pendingImage) {
|
|
273
|
+
URL.revokeObjectURL(pendingImage.preview);
|
|
274
|
+
setPendingImage(null);
|
|
275
|
+
}
|
|
276
|
+
}, [pendingImage]);
|
|
277
|
+
|
|
278
|
+
const uploadImage = useCallback(async (file: File): Promise<string | null> => {
|
|
279
|
+
const formData = new FormData();
|
|
280
|
+
formData.append("file", file);
|
|
281
|
+
const token = localStorage.getItem("botschat_token");
|
|
282
|
+
try {
|
|
283
|
+
const res = await fetch("/api/upload", {
|
|
284
|
+
method: "POST",
|
|
285
|
+
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
286
|
+
body: formData,
|
|
287
|
+
});
|
|
288
|
+
if (!res.ok) {
|
|
289
|
+
const err = await res.json().catch(() => ({ error: "Upload failed" }));
|
|
290
|
+
throw new Error((err as { error?: string }).error ?? `HTTP ${res.status}`);
|
|
291
|
+
}
|
|
292
|
+
const data = await res.json() as { url: string };
|
|
293
|
+
// Return absolute URL so OpenClaw on mini.local can fetch the image
|
|
294
|
+
const absoluteUrl = data.url.startsWith("/")
|
|
295
|
+
? `${window.location.origin}${data.url}`
|
|
296
|
+
: data.url;
|
|
297
|
+
return absoluteUrl;
|
|
298
|
+
} catch (err) {
|
|
299
|
+
dlog.error("Upload", `Image upload failed: ${err}`);
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
}, []);
|
|
303
|
+
|
|
304
|
+
// Drag & drop handlers
|
|
305
|
+
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
|
306
|
+
e.preventDefault();
|
|
307
|
+
e.stopPropagation();
|
|
308
|
+
if (e.dataTransfer.types.includes("Files")) {
|
|
309
|
+
setDragOver(true);
|
|
310
|
+
}
|
|
311
|
+
}, []);
|
|
312
|
+
|
|
313
|
+
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
314
|
+
e.preventDefault();
|
|
315
|
+
e.stopPropagation();
|
|
316
|
+
const rect = dropZoneRef.current?.getBoundingClientRect();
|
|
317
|
+
if (rect) {
|
|
318
|
+
const { clientX, clientY } = e;
|
|
319
|
+
if (clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) {
|
|
320
|
+
setDragOver(false);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}, []);
|
|
324
|
+
|
|
325
|
+
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
326
|
+
e.preventDefault();
|
|
327
|
+
e.stopPropagation();
|
|
328
|
+
}, []);
|
|
329
|
+
|
|
330
|
+
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
331
|
+
e.preventDefault();
|
|
332
|
+
e.stopPropagation();
|
|
333
|
+
setDragOver(false);
|
|
334
|
+
const file = e.dataTransfer.files?.[0];
|
|
335
|
+
if (file && file.type.startsWith("image/")) {
|
|
336
|
+
const preview = URL.createObjectURL(file);
|
|
337
|
+
setPendingImage({ file, preview });
|
|
338
|
+
inputRef.current?.focus();
|
|
339
|
+
}
|
|
340
|
+
}, []);
|
|
341
|
+
|
|
342
|
+
// Paste handler for images
|
|
343
|
+
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
|
344
|
+
const items = e.clipboardData?.items;
|
|
345
|
+
if (!items) return;
|
|
346
|
+
for (let i = 0; i < items.length; i++) {
|
|
347
|
+
if (items[i].type.startsWith("image/")) {
|
|
348
|
+
e.preventDefault();
|
|
349
|
+
const file = items[i].getAsFile();
|
|
350
|
+
if (file) {
|
|
351
|
+
const preview = URL.createObjectURL(file);
|
|
352
|
+
setPendingImage({ file, preview });
|
|
353
|
+
}
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}, []);
|
|
358
|
+
|
|
359
|
+
const handleSend = async () => {
|
|
360
|
+
if ((!input.trim() && !pendingImage) || !sessionKey) return;
|
|
361
|
+
|
|
362
|
+
const trimmed = input.trim();
|
|
363
|
+
const hasText = trimmed.length > 0;
|
|
364
|
+
const isSkill = hasText && trimmed.startsWith("/");
|
|
365
|
+
dlog.info("Chat", `Send message${isSkill ? " (skill)" : ""}${pendingImage ? " +image" : ""}: ${trimmed.length > 120 ? trimmed.slice(0, 120) + "…" : trimmed}`, { sessionKey, isSkill });
|
|
366
|
+
|
|
367
|
+
if (hasText) {
|
|
368
|
+
recordSkillUsage(trimmed);
|
|
369
|
+
setSkillVersion((v) => v + 1);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Upload image if present
|
|
373
|
+
let mediaUrl: string | undefined;
|
|
374
|
+
if (pendingImage) {
|
|
375
|
+
setImageUploading(true);
|
|
376
|
+
const url = await uploadImage(pendingImage.file);
|
|
377
|
+
setImageUploading(false);
|
|
378
|
+
if (!url) return; // Upload failed
|
|
379
|
+
mediaUrl = url;
|
|
380
|
+
clearPendingImage();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const msg: ChatMessage = {
|
|
384
|
+
id: crypto.randomUUID(),
|
|
385
|
+
sender: "user",
|
|
386
|
+
text: trimmed,
|
|
387
|
+
timestamp: Date.now(),
|
|
388
|
+
mediaUrl,
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
dispatch({ type: "ADD_MESSAGE", message: msg });
|
|
392
|
+
|
|
393
|
+
sendMessage({
|
|
394
|
+
type: "user.message",
|
|
395
|
+
sessionKey,
|
|
396
|
+
text: trimmed,
|
|
397
|
+
userId: state.user?.id ?? "",
|
|
398
|
+
messageId: msg.id,
|
|
399
|
+
...(mediaUrl ? { mediaUrl } : {}),
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
setInput("");
|
|
403
|
+
|
|
404
|
+
requestAnimationFrame(() => {
|
|
405
|
+
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
406
|
+
});
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const openThread = (messageId: string) => {
|
|
410
|
+
dlog.info("Thread", `Open thread for message: ${messageId}`);
|
|
411
|
+
dispatch({ type: "OPEN_THREAD", threadId: messageId, messages: [] });
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
/** Handle A2UI action button clicks — sends the action text as a user message */
|
|
415
|
+
const handleA2UIAction = useCallback((action: string) => {
|
|
416
|
+
if (!sessionKey) return;
|
|
417
|
+
dlog.info("A2UI", `Action triggered: ${action}`);
|
|
418
|
+
const msg: ChatMessage = {
|
|
419
|
+
id: crypto.randomUUID(),
|
|
420
|
+
sender: "user",
|
|
421
|
+
text: action,
|
|
422
|
+
timestamp: Date.now(),
|
|
423
|
+
};
|
|
424
|
+
dispatch({ type: "ADD_MESSAGE", message: msg });
|
|
425
|
+
sendMessage({
|
|
426
|
+
type: "user.message",
|
|
427
|
+
sessionKey,
|
|
428
|
+
text: action,
|
|
429
|
+
userId: state.user?.id ?? "",
|
|
430
|
+
messageId: msg.id,
|
|
431
|
+
});
|
|
432
|
+
requestAnimationFrame(() => {
|
|
433
|
+
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
434
|
+
});
|
|
435
|
+
}, [sessionKey, state.user?.id, sendMessage, dispatch]);
|
|
436
|
+
|
|
437
|
+
/** Handle ActionCard resolve — marks widget done + sends the choice as user message */
|
|
438
|
+
const handleResolveAction = useCallback((messageId: string, value: string, label: string) => {
|
|
439
|
+
if (!sessionKey) return;
|
|
440
|
+
dlog.info("ActionCard", `Resolved: "${label}" (value="${value}")`);
|
|
441
|
+
|
|
442
|
+
// Compute a simple hash from value+label for the prompt key
|
|
443
|
+
const promptHash = simpleHash(label + value);
|
|
444
|
+
|
|
445
|
+
// Mark the action as resolved in the store
|
|
446
|
+
dispatch({ type: "RESOLVE_ACTION", messageId, promptHash, value, label });
|
|
447
|
+
|
|
448
|
+
// Send the chosen label as a user message (show the readable label, not the
|
|
449
|
+
// technical value, so the chat history reads naturally)
|
|
450
|
+
const msg: ChatMessage = {
|
|
451
|
+
id: crypto.randomUUID(),
|
|
452
|
+
sender: "user",
|
|
453
|
+
text: label,
|
|
454
|
+
timestamp: Date.now(),
|
|
455
|
+
};
|
|
456
|
+
dispatch({ type: "ADD_MESSAGE", message: msg });
|
|
457
|
+
sendMessage({
|
|
458
|
+
type: "user.message",
|
|
459
|
+
sessionKey,
|
|
460
|
+
text: label,
|
|
461
|
+
userId: state.user?.id ?? "",
|
|
462
|
+
messageId: msg.id,
|
|
463
|
+
});
|
|
464
|
+
requestAnimationFrame(() => {
|
|
465
|
+
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
466
|
+
});
|
|
467
|
+
}, [sessionKey, state.user?.id, sendMessage, dispatch]);
|
|
468
|
+
|
|
469
|
+
const selectedAgent = state.agents.find((a) => a.id === state.selectedAgentId);
|
|
470
|
+
const channelName = selectedAgent?.name ?? "channel";
|
|
471
|
+
const channelId = selectedAgent?.channelId ?? null;
|
|
472
|
+
// Always show session tabs — for all channels including default (General)
|
|
473
|
+
const showSessionTabs = !!selectedAgent;
|
|
474
|
+
|
|
475
|
+
if (!sessionKey) {
|
|
476
|
+
return (
|
|
477
|
+
<div className="flex-1 flex items-center justify-center" style={{ background: "var(--bg-surface)" }}>
|
|
478
|
+
<div className="text-center">
|
|
479
|
+
<svg className="w-16 h-16 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1} style={{ color: "var(--text-muted)" }}>
|
|
480
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
|
|
481
|
+
</svg>
|
|
482
|
+
<p className="text-body font-bold" style={{ color: "var(--text-muted)" }}>
|
|
483
|
+
Select a channel to get started
|
|
484
|
+
</p>
|
|
485
|
+
<p className="text-caption mt-1" style={{ color: "var(--text-muted)" }}>
|
|
486
|
+
Choose a channel from the sidebar
|
|
487
|
+
</p>
|
|
488
|
+
</div>
|
|
489
|
+
</div>
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return (
|
|
494
|
+
<div
|
|
495
|
+
ref={dropZoneRef}
|
|
496
|
+
className="flex-1 flex flex-col min-w-0 relative"
|
|
497
|
+
style={{ background: "var(--bg-surface)" }}
|
|
498
|
+
onDragEnter={handleDragEnter}
|
|
499
|
+
onDragLeave={handleDragLeave}
|
|
500
|
+
onDragOver={handleDragOver}
|
|
501
|
+
onDrop={handleDrop}
|
|
502
|
+
>
|
|
503
|
+
{/* Drag overlay */}
|
|
504
|
+
{dragOver && (
|
|
505
|
+
<div
|
|
506
|
+
className="absolute inset-0 z-50 flex items-center justify-center"
|
|
507
|
+
style={{ background: "rgba(0,0,0,0.4)", pointerEvents: "none" }}
|
|
508
|
+
>
|
|
509
|
+
<div className="flex flex-col items-center gap-3 p-8 rounded-lg" style={{ background: "var(--bg-surface)", border: "2px dashed var(--text-link)" }}>
|
|
510
|
+
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} style={{ color: "var(--text-link)" }}>
|
|
511
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v13.5A1.5 1.5 0 003.75 21z" />
|
|
512
|
+
</svg>
|
|
513
|
+
<span className="text-body font-bold" style={{ color: "var(--text-primary)" }}>
|
|
514
|
+
Drop image here
|
|
515
|
+
</span>
|
|
516
|
+
</div>
|
|
517
|
+
</div>
|
|
518
|
+
)}
|
|
519
|
+
|
|
520
|
+
{/* Channel header */}
|
|
521
|
+
<div
|
|
522
|
+
className="flex items-center justify-between px-5"
|
|
523
|
+
style={{
|
|
524
|
+
height: 44,
|
|
525
|
+
borderBottom: "1px solid var(--border)",
|
|
526
|
+
}}
|
|
527
|
+
>
|
|
528
|
+
<div className="flex items-center gap-2">
|
|
529
|
+
<span className="text-h1" style={{ color: "var(--text-primary)" }}>
|
|
530
|
+
# {channelName}
|
|
531
|
+
</span>
|
|
532
|
+
{selectedAgent && !selectedAgent.isDefault && (
|
|
533
|
+
<span className="text-caption" style={{ color: "var(--text-secondary)" }}>
|
|
534
|
+
— custom channel
|
|
535
|
+
</span>
|
|
536
|
+
)}
|
|
537
|
+
</div>
|
|
538
|
+
<div className="flex items-center gap-1.5">
|
|
539
|
+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} style={{ color: "var(--text-muted)" }}>
|
|
540
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z" />
|
|
541
|
+
</svg>
|
|
542
|
+
<ModelSelect
|
|
543
|
+
value={currentModel ?? ""}
|
|
544
|
+
onChange={handleModelChange}
|
|
545
|
+
models={state.models}
|
|
546
|
+
disabled={!state.openclawConnected}
|
|
547
|
+
placeholder="No model"
|
|
548
|
+
compact
|
|
549
|
+
/>
|
|
550
|
+
</div>
|
|
551
|
+
</div>
|
|
552
|
+
|
|
553
|
+
{/* Session tabs — shown for all agents (including default/General) */}
|
|
554
|
+
{showSessionTabs && <SessionTabs channelId={channelId} />}
|
|
555
|
+
|
|
556
|
+
{/* Messages – flat-row layout */}
|
|
557
|
+
<div className="flex-1 overflow-y-auto">
|
|
558
|
+
{state.messages.length === 0 && (
|
|
559
|
+
<div className="py-12 px-5 text-center">
|
|
560
|
+
<p className="text-caption" style={{ color: "var(--text-muted)" }}>
|
|
561
|
+
No messages yet. Start a conversation.
|
|
562
|
+
</p>
|
|
563
|
+
</div>
|
|
564
|
+
)}
|
|
565
|
+
{state.messages.map((msg, i) => {
|
|
566
|
+
const prevMsg = i > 0 ? state.messages[i - 1] : null;
|
|
567
|
+
const isGrouped = prevMsg?.sender === msg.sender
|
|
568
|
+
&& (msg.timestamp - prevMsg.timestamp) < 300000; // 5 min
|
|
569
|
+
|
|
570
|
+
return (
|
|
571
|
+
<MessageRow
|
|
572
|
+
key={msg.id}
|
|
573
|
+
msg={msg}
|
|
574
|
+
grouped={isGrouped}
|
|
575
|
+
onOpenThread={() => openThread(msg.id)}
|
|
576
|
+
onAction={handleA2UIAction}
|
|
577
|
+
onResolveAction={(value, label) => handleResolveAction(msg.id, value, label)}
|
|
578
|
+
/>
|
|
579
|
+
);
|
|
580
|
+
})}
|
|
581
|
+
<div ref={messagesEndRef} />
|
|
582
|
+
</div>
|
|
583
|
+
|
|
584
|
+
{/* Composer (section 5.6) */}
|
|
585
|
+
<div className="px-5 pb-4 pt-2">
|
|
586
|
+
{/* Skill buttons — sorted by recency-weighted score */}
|
|
587
|
+
<div className="flex items-center gap-1.5 pb-1.5 overflow-x-auto no-scrollbar">
|
|
588
|
+
{sortedSkills.map((skill) => {
|
|
589
|
+
const entry = skillStore[skill.cmd];
|
|
590
|
+
const count = entry?.total ?? 0;
|
|
591
|
+
const recent = entry ? recentCount(entry) : 0;
|
|
592
|
+
const isActive = input.startsWith(skill.cmd + " ") || input === skill.cmd;
|
|
593
|
+
return (
|
|
594
|
+
<button
|
|
595
|
+
key={skill.cmd}
|
|
596
|
+
onClick={() => handleSkillClick(skill.cmd)}
|
|
597
|
+
className="flex items-center gap-1 px-2 py-1 rounded-md text-xs whitespace-nowrap transition-colors shrink-0"
|
|
598
|
+
style={{
|
|
599
|
+
background: isActive ? "var(--bg-active)" : "var(--bg-hover)",
|
|
600
|
+
color: isActive ? "#fff" : "var(--text-secondary)",
|
|
601
|
+
border: "1px solid transparent",
|
|
602
|
+
}}
|
|
603
|
+
title={`${skill.cmd}${count > 0 ? ` (total ${count}x${recent > 0 ? `, recent ${recent}x` : ""})` : ""}`}
|
|
604
|
+
>
|
|
605
|
+
<span className="font-mono text-[10px] opacity-70">{skill.cmd}</span>
|
|
606
|
+
{count > 0 && (
|
|
607
|
+
<span
|
|
608
|
+
className="ml-0.5 px-1 rounded-sm text-[10px] font-bold"
|
|
609
|
+
style={{
|
|
610
|
+
background: isActive ? "rgba(255,255,255,0.2)" : "var(--bg-surface)",
|
|
611
|
+
color: isActive ? "#fff" : "var(--text-muted)",
|
|
612
|
+
}}
|
|
613
|
+
>
|
|
614
|
+
{count}
|
|
615
|
+
</span>
|
|
616
|
+
)}
|
|
617
|
+
</button>
|
|
618
|
+
);
|
|
619
|
+
})}
|
|
620
|
+
</div>
|
|
621
|
+
|
|
622
|
+
<div
|
|
623
|
+
className="rounded-md"
|
|
624
|
+
style={{
|
|
625
|
+
border: "1px solid var(--border)",
|
|
626
|
+
background: "var(--bg-surface)",
|
|
627
|
+
}}
|
|
628
|
+
>
|
|
629
|
+
{/* Image preview */}
|
|
630
|
+
{pendingImage && (
|
|
631
|
+
<div className="px-3 pt-2 flex items-start gap-2">
|
|
632
|
+
<div className="relative">
|
|
633
|
+
<img
|
|
634
|
+
src={pendingImage.preview}
|
|
635
|
+
alt="Preview"
|
|
636
|
+
className="max-w-[120px] max-h-[80px] rounded-md object-contain"
|
|
637
|
+
style={{ border: "1px solid var(--border)" }}
|
|
638
|
+
/>
|
|
639
|
+
<button
|
|
640
|
+
onClick={clearPendingImage}
|
|
641
|
+
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full flex items-center justify-center text-white opacity-80 hover:opacity-100 transition-opacity"
|
|
642
|
+
style={{ background: "#e74c3c", fontSize: 11 }}
|
|
643
|
+
title="Remove image"
|
|
644
|
+
>
|
|
645
|
+
✕
|
|
646
|
+
</button>
|
|
647
|
+
</div>
|
|
648
|
+
{imageUploading && (
|
|
649
|
+
<span className="text-caption" style={{ color: "var(--text-muted)" }}>
|
|
650
|
+
Uploading…
|
|
651
|
+
</span>
|
|
652
|
+
)}
|
|
653
|
+
</div>
|
|
654
|
+
)}
|
|
655
|
+
|
|
656
|
+
{/* Textarea */}
|
|
657
|
+
<textarea
|
|
658
|
+
ref={inputRef}
|
|
659
|
+
value={input}
|
|
660
|
+
onChange={(e) => setInput(e.target.value)}
|
|
661
|
+
onKeyDown={(e) => {
|
|
662
|
+
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
|
663
|
+
e.preventDefault();
|
|
664
|
+
handleSend();
|
|
665
|
+
}
|
|
666
|
+
}}
|
|
667
|
+
onPaste={handlePaste}
|
|
668
|
+
placeholder={
|
|
669
|
+
state.openclawConnected
|
|
670
|
+
? `Message #${channelName}`
|
|
671
|
+
: "OpenClaw is offline…"
|
|
672
|
+
}
|
|
673
|
+
disabled={!state.openclawConnected}
|
|
674
|
+
rows={1}
|
|
675
|
+
className="w-full px-3 py-2.5 text-body bg-transparent resize-none focus:outline-none disabled:opacity-50 placeholder:text-[--text-muted]"
|
|
676
|
+
style={{ color: "var(--text-primary)", minHeight: 40 }}
|
|
677
|
+
/>
|
|
678
|
+
|
|
679
|
+
{/* Bottom toolbar */}
|
|
680
|
+
<div className="flex items-center justify-between px-3 pb-2">
|
|
681
|
+
<div className="flex items-center gap-1">
|
|
682
|
+
{/* Image upload button */}
|
|
683
|
+
<input
|
|
684
|
+
ref={fileInputRef}
|
|
685
|
+
type="file"
|
|
686
|
+
accept="image/*"
|
|
687
|
+
className="hidden"
|
|
688
|
+
onChange={handleFileSelect}
|
|
689
|
+
/>
|
|
690
|
+
<button
|
|
691
|
+
onClick={() => fileInputRef.current?.click()}
|
|
692
|
+
className="p-1.5 rounded hover:bg-[--bg-hover] transition-colors"
|
|
693
|
+
style={{ color: "var(--text-muted)" }}
|
|
694
|
+
title="Upload image"
|
|
695
|
+
aria-label="Upload image"
|
|
696
|
+
disabled={!state.openclawConnected}
|
|
697
|
+
>
|
|
698
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
699
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v13.5A1.5 1.5 0 003.75 21zm14.25-15.75a.75.75 0 11-1.5 0 .75.75 0 011.5 0z" />
|
|
700
|
+
</svg>
|
|
701
|
+
</button>
|
|
702
|
+
</div>
|
|
703
|
+
|
|
704
|
+
{/* Send button */}
|
|
705
|
+
<button
|
|
706
|
+
onClick={handleSend}
|
|
707
|
+
disabled={(!input.trim() && !pendingImage) || !state.openclawConnected}
|
|
708
|
+
className="px-3 py-1.5 rounded-sm text-caption font-bold text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
709
|
+
style={{ background: "var(--bg-active)" }}
|
|
710
|
+
>
|
|
711
|
+
<div className="flex items-center gap-1.5">
|
|
712
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
713
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
|
|
714
|
+
</svg>
|
|
715
|
+
Send
|
|
716
|
+
</div>
|
|
717
|
+
</button>
|
|
718
|
+
</div>
|
|
719
|
+
</div>
|
|
720
|
+
</div>
|
|
721
|
+
</div>
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** Flat-row message item (section 5.2) */
|
|
726
|
+
function MessageRow({
|
|
727
|
+
msg,
|
|
728
|
+
grouped,
|
|
729
|
+
onOpenThread,
|
|
730
|
+
onAction,
|
|
731
|
+
onResolveAction,
|
|
732
|
+
}: {
|
|
733
|
+
msg: ChatMessage;
|
|
734
|
+
grouped: boolean;
|
|
735
|
+
onOpenThread: () => void;
|
|
736
|
+
onAction?: (action: string) => void;
|
|
737
|
+
onResolveAction?: (value: string, label: string) => void;
|
|
738
|
+
}) {
|
|
739
|
+
const state = useAppState();
|
|
740
|
+
const senderLabel = msg.sender === "user" ? "You" : "OpenClaw Agent";
|
|
741
|
+
const avatarColor = msg.sender === "user" ? "#9B59B6" : "#2BAC76";
|
|
742
|
+
const initial = msg.sender === "user" ? "U" : "A";
|
|
743
|
+
const replyCount = state.threadReplyCounts[msg.id] ?? 0;
|
|
744
|
+
|
|
745
|
+
return (
|
|
746
|
+
<div
|
|
747
|
+
className="group relative px-5 hover:bg-[--bg-hover] transition-colors"
|
|
748
|
+
style={{ paddingTop: grouped ? 2 : 8, paddingBottom: 2 }}
|
|
749
|
+
>
|
|
750
|
+
<div className="flex gap-2 max-w-message">
|
|
751
|
+
{/* Avatar column */}
|
|
752
|
+
<div className="flex-shrink-0" style={{ width: 36 }}>
|
|
753
|
+
{!grouped && (
|
|
754
|
+
<div
|
|
755
|
+
className="w-9 h-9 rounded flex items-center justify-center text-white text-caption font-bold"
|
|
756
|
+
style={{ background: avatarColor }}
|
|
757
|
+
>
|
|
758
|
+
{initial}
|
|
759
|
+
</div>
|
|
760
|
+
)}
|
|
761
|
+
</div>
|
|
762
|
+
|
|
763
|
+
{/* Content column */}
|
|
764
|
+
<div className="flex-1 min-w-0">
|
|
765
|
+
{!grouped && (
|
|
766
|
+
<div className="flex items-baseline gap-2 mb-0.5">
|
|
767
|
+
<span className="text-h2" style={{ color: "var(--text-primary)" }}>
|
|
768
|
+
{senderLabel}
|
|
769
|
+
</span>
|
|
770
|
+
<span className="text-caption" style={{ color: "var(--text-secondary)" }}>
|
|
771
|
+
{new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
|
772
|
+
</span>
|
|
773
|
+
</div>
|
|
774
|
+
)}
|
|
775
|
+
<MessageContent
|
|
776
|
+
text={msg.text}
|
|
777
|
+
mediaUrl={msg.mediaUrl}
|
|
778
|
+
a2ui={msg.a2ui}
|
|
779
|
+
isStreaming={msg.isStreaming}
|
|
780
|
+
onAction={onAction}
|
|
781
|
+
onResolveAction={onResolveAction}
|
|
782
|
+
resolvedActions={msg.resolvedActions}
|
|
783
|
+
/>
|
|
784
|
+
{msg.isStreaming && (
|
|
785
|
+
<span
|
|
786
|
+
className="inline-block w-1.5 h-4 ml-0.5 rounded-sm animate-pulse"
|
|
787
|
+
style={{ background: "var(--text-link)", verticalAlign: "text-bottom" }}
|
|
788
|
+
/>
|
|
789
|
+
)}
|
|
790
|
+
|
|
791
|
+
{/* Thread bar – shown when this message has thread replies */}
|
|
792
|
+
{replyCount > 0 && (
|
|
793
|
+
<button
|
|
794
|
+
onClick={onOpenThread}
|
|
795
|
+
className="flex items-center gap-2 mt-1 py-1 px-1 -ml-1 rounded hover:bg-[--bg-hover] transition-colors cursor-pointer group/thread"
|
|
796
|
+
>
|
|
797
|
+
<span
|
|
798
|
+
className="text-caption font-bold"
|
|
799
|
+
style={{ color: "var(--text-link)" }}
|
|
800
|
+
>
|
|
801
|
+
{replyCount} {replyCount === 1 ? "reply" : "replies"}
|
|
802
|
+
</span>
|
|
803
|
+
<span
|
|
804
|
+
className="text-caption opacity-0 group-hover/thread:opacity-100 transition-opacity"
|
|
805
|
+
style={{ color: "var(--text-secondary)" }}
|
|
806
|
+
>
|
|
807
|
+
View thread
|
|
808
|
+
</span>
|
|
809
|
+
<svg
|
|
810
|
+
className="w-4 h-4 opacity-0 group-hover/thread:opacity-100 transition-opacity"
|
|
811
|
+
fill="none"
|
|
812
|
+
viewBox="0 0 24 24"
|
|
813
|
+
stroke="currentColor"
|
|
814
|
+
strokeWidth={1.5}
|
|
815
|
+
style={{ color: "var(--text-secondary)" }}
|
|
816
|
+
>
|
|
817
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
|
818
|
+
</svg>
|
|
819
|
+
</button>
|
|
820
|
+
)}
|
|
821
|
+
</div>
|
|
822
|
+
</div>
|
|
823
|
+
|
|
824
|
+
{/* Action bar (section 5.3) – appears on hover */}
|
|
825
|
+
<div
|
|
826
|
+
className="absolute top-0 right-5 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-0.5 px-1 py-0.5 rounded"
|
|
827
|
+
style={{
|
|
828
|
+
background: "var(--bg-surface)",
|
|
829
|
+
border: "1px solid var(--border)",
|
|
830
|
+
boxShadow: "var(--shadow-sm)",
|
|
831
|
+
}}
|
|
832
|
+
>
|
|
833
|
+
<ActionButton label="Reply in thread" icon={
|
|
834
|
+
<svg className="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
835
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
|
|
836
|
+
</svg>
|
|
837
|
+
} onClick={onOpenThread} />
|
|
838
|
+
</div>
|
|
839
|
+
</div>
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function ActionButton({
|
|
844
|
+
label,
|
|
845
|
+
icon,
|
|
846
|
+
onClick,
|
|
847
|
+
}: {
|
|
848
|
+
label: string;
|
|
849
|
+
icon: React.ReactNode;
|
|
850
|
+
onClick?: () => void;
|
|
851
|
+
}) {
|
|
852
|
+
return (
|
|
853
|
+
<button
|
|
854
|
+
onClick={onClick}
|
|
855
|
+
className="p-1 rounded hover:bg-[--bg-hover] transition-colors"
|
|
856
|
+
style={{ color: "var(--text-secondary)" }}
|
|
857
|
+
title={label}
|
|
858
|
+
aria-label={label}
|
|
859
|
+
>
|
|
860
|
+
{icon}
|
|
861
|
+
</button>
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
|