🚀 RELEASE: file attachments
This commit is contained in:
parent
989c55534b
commit
b86f5cf441
315
apps/web/app/api/cron/runs/search-transcript/route.ts
Normal file
315
apps/web/app/api/cron/runs/search-transcript/route.ts
Normal file
@ -0,0 +1,315 @@
|
||||
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const AGENTS_DIR = join(homedir(), ".openclaw", "agents");
|
||||
|
||||
type MessagePart =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "thinking"; thinking: string }
|
||||
| { type: "tool-call"; toolName: string; toolCallId: string; args?: unknown; output?: string };
|
||||
|
||||
type ParsedMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
parts: MessagePart[];
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Search for the actual agent transcript for a cron run.
|
||||
*
|
||||
* For main-target cron runs, the agent response lives in the main session
|
||||
* transcript files. This endpoint searches session files for the cron payload
|
||||
* text near the run timestamp and returns the matching conversation
|
||||
* (user message + assistant response).
|
||||
*/
|
||||
|
||||
/** Try to find a cron-specific session from sessions.json. */
|
||||
function findCronSessionId(jobId: string): string | null {
|
||||
if (!existsSync(AGENTS_DIR)) {return null;}
|
||||
try {
|
||||
const agentDirs = readdirSync(AGENTS_DIR, { withFileTypes: true });
|
||||
for (const agentDir of agentDirs) {
|
||||
if (!agentDir.isDirectory()) {continue;}
|
||||
const sessionsJsonPath = join(AGENTS_DIR, agentDir.name, "sessions", "sessions.json");
|
||||
if (!existsSync(sessionsJsonPath)) {continue;}
|
||||
try {
|
||||
const store = JSON.parse(readFileSync(sessionsJsonPath, "utf-8"));
|
||||
// Look for cron session key matching this job
|
||||
for (const [key, entry] of Object.entries(store)) {
|
||||
if (key.includes(`:cron:${jobId}`) && !key.includes(":run:")) {
|
||||
const sessionId = (entry as { sessionId?: string })?.sessionId;
|
||||
if (typeof sessionId === "string" && sessionId.trim()) {
|
||||
// Verify the session file actually exists
|
||||
const sessionFile = join(AGENTS_DIR, agentDir.name, "sessions", `${sessionId}.jsonl`);
|
||||
if (existsSync(sessionFile)) {
|
||||
return sessionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip malformed sessions.json
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Find session files that might contain the cron run's transcript. */
|
||||
function findCandidateSessionFiles(runAtMs: number): string[] {
|
||||
const candidates: Array<{ path: string; mtimeMs: number }> = [];
|
||||
if (!existsSync(AGENTS_DIR)) {return [];}
|
||||
|
||||
try {
|
||||
const agentDirs = readdirSync(AGENTS_DIR, { withFileTypes: true });
|
||||
for (const agentDir of agentDirs) {
|
||||
if (!agentDir.isDirectory()) {continue;}
|
||||
const sessionsDir = join(AGENTS_DIR, agentDir.name, "sessions");
|
||||
if (!existsSync(sessionsDir)) {continue;}
|
||||
try {
|
||||
const files = readdirSync(sessionsDir);
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".jsonl")) {continue;}
|
||||
const filePath = join(sessionsDir, file);
|
||||
try {
|
||||
const stat = statSync(filePath);
|
||||
// Only consider files modified within ±2 hours of the run
|
||||
const windowMs = 2 * 60 * 60 * 1000;
|
||||
if (Math.abs(stat.mtimeMs - runAtMs) < windowMs) {
|
||||
candidates.push({ path: filePath, mtimeMs: stat.mtimeMs });
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Sort by closest modification time to runAtMs
|
||||
candidates.sort((a, b) => Math.abs(a.mtimeMs - runAtMs) - Math.abs(b.mtimeMs - runAtMs));
|
||||
|
||||
// Limit to 10 most likely candidates
|
||||
return candidates.slice(0, 10).map((c) => c.path);
|
||||
}
|
||||
|
||||
/** Parse message entries from a JSONL transcript, optionally filtered by time range. */
|
||||
function parseMessagesInRange(
|
||||
content: string,
|
||||
opts?: { afterMs?: number; beforeMs?: number },
|
||||
): ParsedMessage[] {
|
||||
const lines = content.trim().split("\n").filter((l) => l.trim());
|
||||
const messages: ParsedMessage[] = [];
|
||||
const pendingToolCalls = new Map<string, { toolName: string; args?: unknown }>();
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type !== "message" || !entry.message) {continue;}
|
||||
|
||||
// Filter by timestamp if provided
|
||||
if (opts?.afterMs || opts?.beforeMs) {
|
||||
const ts = entry.timestamp ? new Date(entry.timestamp).getTime() : (entry.ts ?? 0);
|
||||
if (opts.afterMs && ts < opts.afterMs) {continue;}
|
||||
if (opts.beforeMs && ts > opts.beforeMs) {continue;}
|
||||
}
|
||||
|
||||
const msg = entry.message;
|
||||
const role = msg.role as "user" | "assistant" | "system";
|
||||
const parts: MessagePart[] = [];
|
||||
|
||||
if (Array.isArray(msg.content)) {
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "text" && typeof part.text === "string" && part.text.trim()) {
|
||||
parts.push({ type: "text", text: part.text });
|
||||
} else if (part.type === "thinking" && typeof part.thinking === "string" && part.thinking.trim()) {
|
||||
parts.push({ type: "thinking", thinking: part.thinking });
|
||||
} else if (part.type === "tool_use" || part.type === "tool-call") {
|
||||
const toolName = part.name ?? part.toolName ?? "unknown";
|
||||
const toolCallId = part.id ?? part.toolCallId ?? `tool-${Date.now()}`;
|
||||
pendingToolCalls.set(toolCallId, { toolName, args: part.input ?? part.args });
|
||||
parts.push({ type: "tool-call", toolName, toolCallId, args: part.input ?? part.args });
|
||||
} else if (part.type === "tool_result" || part.type === "tool-result") {
|
||||
const toolCallId = part.tool_use_id ?? part.toolCallId ?? "";
|
||||
const pending = pendingToolCalls.get(toolCallId);
|
||||
const outputText = typeof part.content === "string"
|
||||
? part.content
|
||||
: Array.isArray(part.content)
|
||||
? part.content.filter((c: { type: string }) => c.type === "text").map((c: { text: string }) => c.text).join("\n")
|
||||
: typeof part.output === "string"
|
||||
? part.output
|
||||
: JSON.stringify(part.output ?? part.content ?? "");
|
||||
|
||||
if (pending) {
|
||||
const existingMsg = messages[messages.length - 1];
|
||||
if (existingMsg) {
|
||||
const tc = existingMsg.parts.find(
|
||||
(p) => p.type === "tool-call" && (p as { toolCallId: string }).toolCallId === toolCallId,
|
||||
);
|
||||
if (tc && tc.type === "tool-call") {
|
||||
(tc as { output?: string }).output = outputText.slice(0, 5000);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
parts.push({ type: "tool-call", toolName: pending.toolName, toolCallId, args: pending.args, output: outputText.slice(0, 5000) });
|
||||
} else {
|
||||
parts.push({ type: "tool-call", toolName: "tool", toolCallId, output: outputText.slice(0, 5000) });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (typeof msg.content === "string" && msg.content.trim()) {
|
||||
parts.push({ type: "text", text: msg.content });
|
||||
}
|
||||
|
||||
if (parts.length > 0) {
|
||||
messages.push({
|
||||
id: entry.id ?? `msg-${messages.length}`,
|
||||
role,
|
||||
parts,
|
||||
timestamp: entry.timestamp ?? new Date(entry.ts ?? Date.now()).toISOString(),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/** Extract text content from message parts. */
|
||||
function getMessageText(msg: ParsedMessage): string {
|
||||
return msg.parts
|
||||
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Search session files for the cron run's conversation.
|
||||
* Matches by finding a user message containing the summary text near runAtMs,
|
||||
* then returns that message + all following messages until the next user message.
|
||||
*/
|
||||
function searchForRunTranscript(
|
||||
sessionFiles: string[],
|
||||
summary: string,
|
||||
runAtMs: number,
|
||||
): { messages: ParsedMessage[]; sessionFile: string } | null {
|
||||
// Use a distinctive portion of the summary for matching (first 80 chars)
|
||||
const searchText = summary.slice(0, 80);
|
||||
// Search window: from 5s before run to 10 minutes after (heartbeat delay)
|
||||
const afterMs = runAtMs - 5_000;
|
||||
const beforeMs = runAtMs + 10 * 60_000;
|
||||
|
||||
for (const filePath of sessionFiles) {
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
if (!content.includes(searchText.slice(0, 40))) {
|
||||
// Quick pre-check: skip files that don't contain the text at all
|
||||
continue;
|
||||
}
|
||||
|
||||
const allMessages = parseMessagesInRange(content);
|
||||
|
||||
// Find user messages containing the summary text within the time window
|
||||
for (let i = 0; i < allMessages.length; i++) {
|
||||
const msg = allMessages[i];
|
||||
if (msg.role !== "user") {continue;}
|
||||
|
||||
const msgTs = new Date(msg.timestamp).getTime();
|
||||
if (msgTs < afterMs || msgTs > beforeMs) {continue;}
|
||||
|
||||
const text = getMessageText(msg);
|
||||
if (!text.includes(searchText.slice(0, 40))) {continue;}
|
||||
|
||||
// Found the user message! Collect it + all following messages
|
||||
// until the next user message (the full agent turn).
|
||||
const conversation: ParsedMessage[] = [msg];
|
||||
for (let j = i + 1; j < allMessages.length; j++) {
|
||||
const next = allMessages[j];
|
||||
if (next.role === "user") {break;}
|
||||
conversation.push(next);
|
||||
}
|
||||
|
||||
return { messages: conversation, sessionFile: filePath };
|
||||
}
|
||||
} catch {
|
||||
// skip unreadable files
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/cron/runs/search-transcript?jobId=X&runAtMs=Y&summary=Z
|
||||
*
|
||||
* Search for the actual agent transcript for a cron run that doesn't have
|
||||
* a direct sessionId. Tries:
|
||||
* 1. Sessions.json lookup for a cron-specific session
|
||||
* 2. Time-based search of session files near the run timestamp
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const jobId = url.searchParams.get("jobId");
|
||||
const runAtMsStr = url.searchParams.get("runAtMs");
|
||||
const summary = url.searchParams.get("summary");
|
||||
|
||||
if (!jobId || !runAtMsStr) {
|
||||
return Response.json({ error: "jobId and runAtMs are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const runAtMs = Number(runAtMsStr);
|
||||
if (!Number.isFinite(runAtMs)) {
|
||||
return Response.json({ error: "Invalid runAtMs" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Strategy 1: Look for a cron-specific session in sessions.json
|
||||
const cronSessionId = findCronSessionId(jobId);
|
||||
if (cronSessionId) {
|
||||
try {
|
||||
const agentDirs = readdirSync(AGENTS_DIR, { withFileTypes: true });
|
||||
for (const agentDir of agentDirs) {
|
||||
if (!agentDir.isDirectory()) {continue;}
|
||||
const sessionFile = join(AGENTS_DIR, agentDir.name, "sessions", `${cronSessionId}.jsonl`);
|
||||
if (!existsSync(sessionFile)) {continue;}
|
||||
|
||||
const content = readFileSync(sessionFile, "utf-8");
|
||||
const messages = parseMessagesInRange(content);
|
||||
if (messages.length > 0) {
|
||||
return Response.json({
|
||||
sessionId: cronSessionId,
|
||||
messages,
|
||||
source: "cron-session",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through to search
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Search session files near the run timestamp
|
||||
if (summary) {
|
||||
const candidates = findCandidateSessionFiles(runAtMs);
|
||||
const result = searchForRunTranscript(candidates, summary, runAtMs);
|
||||
if (result) {
|
||||
return Response.json({
|
||||
messages: result.messages,
|
||||
source: "main-session-search",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ error: "Transcript not found" }, { status: 404 });
|
||||
}
|
||||
@ -302,6 +302,8 @@ function buildMemoriesVirtualFolder(): TreeNode | null {
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const home = homedir();
|
||||
const openclawDir = join(home, ".openclaw");
|
||||
const root = resolveDenchRoot();
|
||||
if (!root) {
|
||||
// Even without a dench workspace, return virtual folders if they exist
|
||||
@ -311,7 +313,7 @@ export async function GET() {
|
||||
if (skillsFolder) {tree.push(skillsFolder);}
|
||||
const memoriesFolder = buildMemoriesVirtualFolder();
|
||||
if (memoriesFolder) {tree.push(memoriesFolder);}
|
||||
return Response.json({ tree, exists: false, workspaceRoot: null });
|
||||
return Response.json({ tree, exists: false, workspaceRoot: null, openclawDir });
|
||||
}
|
||||
|
||||
// Load objects from DuckDB for smart directory detection
|
||||
@ -332,5 +334,5 @@ export async function GET() {
|
||||
const memoriesFolder = buildMemoriesVirtualFolder();
|
||||
if (memoriesFolder) {tree.push(memoriesFolder);}
|
||||
|
||||
return Response.json({ tree, exists: true, workspaceRoot: root });
|
||||
return Response.json({ tree, exists: true, workspaceRoot: root, openclawDir });
|
||||
}
|
||||
|
||||
@ -153,6 +153,245 @@ function asRecord(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/* ─── Attachment parsing for sent messages ─── */
|
||||
|
||||
function parseAttachments(
|
||||
text: string,
|
||||
): { paths: string[]; message: string } | null {
|
||||
const match = text.match(/\[Attached files: (.+?)\]/);
|
||||
if (!match) {return null;}
|
||||
const afterIdx = (match.index ?? 0) + match[0].length;
|
||||
const message = text.slice(afterIdx).trim();
|
||||
const paths = match[1]
|
||||
.split(", ")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
return { paths, message };
|
||||
}
|
||||
|
||||
function getCategoryFromPath(
|
||||
filePath: string,
|
||||
): "image" | "video" | "audio" | "pdf" | "code" | "document" | "other" {
|
||||
const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
|
||||
if (
|
||||
[
|
||||
"jpg", "jpeg", "png", "gif", "webp", "svg", "bmp",
|
||||
"ico", "tiff", "heic",
|
||||
].includes(ext)
|
||||
)
|
||||
{return "image";}
|
||||
if (["mp4", "webm", "mov", "avi", "mkv", "flv"].includes(ext))
|
||||
{return "video";}
|
||||
if (["mp3", "wav", "ogg", "aac", "flac", "m4a"].includes(ext))
|
||||
{return "audio";}
|
||||
if (ext === "pdf") {return "pdf";}
|
||||
if (
|
||||
[
|
||||
"js", "ts", "tsx", "jsx", "py", "rb", "go", "rs",
|
||||
"java", "cpp", "c", "h", "css", "html", "json",
|
||||
"yaml", "yml", "toml", "md", "sh", "bash", "sql",
|
||||
"swift", "kt",
|
||||
].includes(ext)
|
||||
)
|
||||
{return "code";}
|
||||
if (
|
||||
[
|
||||
"doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt",
|
||||
"rtf", "csv", "pages", "numbers", "key",
|
||||
].includes(ext)
|
||||
)
|
||||
{return "document";}
|
||||
return "other";
|
||||
}
|
||||
|
||||
function shortenPath(path: string): string {
|
||||
return path
|
||||
.replace(/^\/Users\/[^/]+/, "~")
|
||||
.replace(/^\/home\/[^/]+/, "~")
|
||||
.replace(/^[A-Z]:\\Users\\[^\\]+/, "~");
|
||||
}
|
||||
|
||||
const attachCategoryMeta: Record<string, { bg: string; fg: string }> = {
|
||||
image: { bg: "rgba(16, 185, 129, 0.15)", fg: "#10b981" },
|
||||
video: { bg: "rgba(139, 92, 246, 0.15)", fg: "#8b5cf6" },
|
||||
audio: { bg: "rgba(245, 158, 11, 0.15)", fg: "#f59e0b" },
|
||||
pdf: { bg: "rgba(239, 68, 68, 0.15)", fg: "#ef4444" },
|
||||
code: { bg: "rgba(59, 130, 246, 0.15)", fg: "#3b82f6" },
|
||||
document: { bg: "rgba(107, 114, 128, 0.15)", fg: "#6b7280" },
|
||||
other: { bg: "rgba(107, 114, 128, 0.10)", fg: "#9ca3af" },
|
||||
};
|
||||
|
||||
function AttachFileIcon({ category }: { category: string }) {
|
||||
const props = {
|
||||
width: 14,
|
||||
height: 14,
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 2,
|
||||
strokeLinecap: "round" as const,
|
||||
strokeLinejoin: "round" as const,
|
||||
};
|
||||
switch (category) {
|
||||
case "image":
|
||||
return (
|
||||
<svg {...props}>
|
||||
<rect
|
||||
width="18"
|
||||
height="18"
|
||||
x="3"
|
||||
y="3"
|
||||
rx="2"
|
||||
ry="2"
|
||||
/>
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
||||
</svg>
|
||||
);
|
||||
case "video":
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" />
|
||||
<rect x="2" y="6" width="14" height="12" rx="2" />
|
||||
</svg>
|
||||
);
|
||||
case "audio":
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
);
|
||||
case "pdf":
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
<path d="M10 13h4" />
|
||||
<path d="M10 17h4" />
|
||||
</svg>
|
||||
);
|
||||
case "code":
|
||||
return (
|
||||
<svg {...props}>
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
</svg>
|
||||
);
|
||||
case "document":
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
<path d="M16 13H8" />
|
||||
<path d="M16 17H8" />
|
||||
<path d="M10 9H8" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function AttachedFilesCard({ paths }: { paths: string[] }) {
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ opacity: 0.5 }}
|
||||
>
|
||||
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
||||
</svg>
|
||||
<span
|
||||
className="text-[11px] font-medium uppercase tracking-wider"
|
||||
style={{ opacity: 0.5 }}
|
||||
>
|
||||
{paths.length}{" "}
|
||||
{paths.length === 1 ? "file" : "files"}{" "}
|
||||
attached
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{paths.map((filePath, i) => {
|
||||
const category =
|
||||
getCategoryFromPath(filePath);
|
||||
const filename =
|
||||
filePath.split("/").pop() ??
|
||||
filePath;
|
||||
const meta =
|
||||
attachCategoryMeta[category] ??
|
||||
attachCategoryMeta.other;
|
||||
const short = shortenPath(filePath);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-shrink-0 rounded-lg"
|
||||
style={{
|
||||
background:
|
||||
"rgba(0,0,0,0.04)",
|
||||
border: "1px solid rgba(0,0,0,0.06)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-2.5 py-1.5">
|
||||
<div
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
|
||||
style={{
|
||||
background:
|
||||
meta.bg,
|
||||
color: meta.fg,
|
||||
}}
|
||||
>
|
||||
<AttachFileIcon
|
||||
category={
|
||||
category
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className="text-[12px] font-medium truncate max-w-[160px]"
|
||||
title={
|
||||
filePath
|
||||
}
|
||||
>
|
||||
{filename}
|
||||
</p>
|
||||
<p
|
||||
className="text-[10px] truncate max-w-[160px]"
|
||||
style={{
|
||||
opacity: 0.45,
|
||||
}}
|
||||
title={
|
||||
filePath
|
||||
}
|
||||
>
|
||||
{short}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Markdown component overrides for chat ─── */
|
||||
|
||||
const mdComponents: Components = {
|
||||
@ -195,6 +434,9 @@ export function ChatMessage({ message }: { message: UIMessage }) {
|
||||
.map((s) => s.text)
|
||||
.join("\n");
|
||||
|
||||
// Parse attachment prefix from sent messages
|
||||
const attachmentInfo = parseAttachments(textContent);
|
||||
|
||||
return (
|
||||
<div className="flex justify-end py-2">
|
||||
<div
|
||||
@ -204,7 +446,26 @@ export function ChatMessage({ message }: { message: UIMessage }) {
|
||||
color: "var(--color-user-bubble-text)",
|
||||
}}
|
||||
>
|
||||
<p className="whitespace-pre-wrap">{textContent}</p>
|
||||
{attachmentInfo ? (
|
||||
<>
|
||||
<AttachedFilesCard
|
||||
paths={
|
||||
attachmentInfo.paths
|
||||
}
|
||||
/>
|
||||
{attachmentInfo.message && (
|
||||
<p className="whitespace-pre-wrap">
|
||||
{
|
||||
attachmentInfo.message
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap">
|
||||
{textContent}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -13,6 +13,330 @@ import {
|
||||
} from "react";
|
||||
import { ChatMessage } from "./chat-message";
|
||||
|
||||
// ── Attachment types & helpers ──
|
||||
|
||||
type AttachedFile = {
|
||||
id: string;
|
||||
file: File;
|
||||
/** Full filesystem path when available (Electron/Chromium), otherwise filename. */
|
||||
path: string;
|
||||
previewUrl: string | null;
|
||||
};
|
||||
|
||||
function getFileCategory(
|
||||
file: File,
|
||||
): "image" | "video" | "audio" | "pdf" | "code" | "document" | "other" {
|
||||
const mime = file.type;
|
||||
if (mime.startsWith("image/")) {return "image";}
|
||||
if (mime.startsWith("video/")) {return "video";}
|
||||
if (mime.startsWith("audio/")) {return "audio";}
|
||||
if (mime === "application/pdf") {return "pdf";}
|
||||
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
|
||||
if (
|
||||
[
|
||||
"js", "ts", "tsx", "jsx", "py", "rb", "go", "rs", "java",
|
||||
"cpp", "c", "h", "css", "html", "json", "yaml", "yml",
|
||||
"toml", "md", "sh", "bash", "sql", "swift", "kt",
|
||||
].includes(ext)
|
||||
)
|
||||
{return "code";}
|
||||
if (
|
||||
[
|
||||
"doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt",
|
||||
"rtf", "csv", "pages", "numbers", "key",
|
||||
].includes(ext)
|
||||
)
|
||||
{return "document";}
|
||||
return "other";
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) {return bytes + " B";}
|
||||
if (bytes < 1024 * 1024) {return (bytes / 1024).toFixed(1) + " KB";}
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
||||
}
|
||||
|
||||
const categoryMeta: Record<string, { bg: string; fg: string }> = {
|
||||
image: { bg: "rgba(16, 185, 129, 0.12)", fg: "#10b981" },
|
||||
video: { bg: "rgba(139, 92, 246, 0.12)", fg: "#8b5cf6" },
|
||||
audio: { bg: "rgba(245, 158, 11, 0.12)", fg: "#f59e0b" },
|
||||
pdf: { bg: "rgba(239, 68, 68, 0.12)", fg: "#ef4444" },
|
||||
code: { bg: "rgba(59, 130, 246, 0.12)", fg: "#3b82f6" },
|
||||
document: { bg: "rgba(107, 114, 128, 0.12)", fg: "#6b7280" },
|
||||
other: { bg: "rgba(107, 114, 128, 0.08)", fg: "#9ca3af" },
|
||||
};
|
||||
|
||||
function FileTypeIcon({ category }: { category: string }) {
|
||||
const props = {
|
||||
width: 16,
|
||||
height: 16,
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 2,
|
||||
strokeLinecap: "round" as const,
|
||||
strokeLinejoin: "round" as const,
|
||||
};
|
||||
switch (category) {
|
||||
case "image":
|
||||
return (
|
||||
<svg {...props}>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
||||
</svg>
|
||||
);
|
||||
case "video":
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" />
|
||||
<rect x="2" y="6" width="14" height="12" rx="2" />
|
||||
</svg>
|
||||
);
|
||||
case "audio":
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
);
|
||||
case "pdf":
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
<path d="M10 13h4" />
|
||||
<path d="M10 17h4" />
|
||||
</svg>
|
||||
);
|
||||
case "code":
|
||||
return (
|
||||
<svg {...props}>
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
</svg>
|
||||
);
|
||||
case "document":
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
<path d="M16 13H8" />
|
||||
<path d="M16 17H8" />
|
||||
<path d="M10 9H8" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function AttachmentStrip({
|
||||
files,
|
||||
compact,
|
||||
onRemove,
|
||||
onClearAll,
|
||||
}: {
|
||||
files: AttachedFile[];
|
||||
compact?: boolean;
|
||||
onRemove: (id: string) => void;
|
||||
onClearAll: () => void;
|
||||
}) {
|
||||
if (files.length === 0) {return null;}
|
||||
|
||||
return (
|
||||
<div className={`${compact ? "px-2" : "px-3"} pb-2`}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span
|
||||
className="text-[10px] font-medium uppercase tracking-wider"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{files.length}{" "}
|
||||
{files.length === 1 ? "file" : "files"} attached
|
||||
</span>
|
||||
{files.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearAll}
|
||||
className="text-[10px] font-medium px-1.5 py-0.5 rounded hover:opacity-80 transition-opacity"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="flex gap-2 overflow-x-auto pb-1"
|
||||
style={{ scrollbarWidth: "thin" }}
|
||||
>
|
||||
{files.map((af) => {
|
||||
const category = getFileCategory(af.file);
|
||||
const meta =
|
||||
categoryMeta[category] ??
|
||||
categoryMeta.other;
|
||||
const isMedia =
|
||||
(category === "image" ||
|
||||
category === "video") &&
|
||||
af.previewUrl;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={af.id}
|
||||
className="relative group flex-shrink-0 rounded-xl overflow-hidden"
|
||||
style={{
|
||||
background:
|
||||
"var(--color-surface-hover)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{/* Remove button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(af.id)}
|
||||
className="absolute top-1 right-1 z-10 w-[18px] h-[18px] rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
style={{
|
||||
background:
|
||||
"rgba(0,0,0,0.55)",
|
||||
color: "white",
|
||||
backdropFilter:
|
||||
"blur(4px)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="8"
|
||||
height="8"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isMedia ? (
|
||||
<div>
|
||||
{category === "image" ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={
|
||||
af.previewUrl!
|
||||
}
|
||||
alt={
|
||||
af.file
|
||||
.name
|
||||
}
|
||||
className="w-[100px] h-[64px] object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="relative w-[100px] h-[64px]">
|
||||
<video
|
||||
src={
|
||||
af.previewUrl!
|
||||
}
|
||||
className="w-full h-full object-cover"
|
||||
muted
|
||||
preload="metadata"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
|
||||
<div className="w-6 h-6 rounded-full bg-black/50 flex items-center justify-center backdrop-blur-sm">
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="white"
|
||||
>
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="px-2 py-1.5">
|
||||
<p
|
||||
className="text-[10px] font-medium truncate w-[84px]"
|
||||
style={{
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
title={
|
||||
af.file
|
||||
.name
|
||||
}
|
||||
>
|
||||
{af.file.name}
|
||||
</p>
|
||||
<p
|
||||
className="text-[9px]"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{formatFileSize(
|
||||
af.file
|
||||
.size,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2.5 px-3 py-2.5">
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{
|
||||
background:
|
||||
meta.bg,
|
||||
color: meta.fg,
|
||||
}}
|
||||
>
|
||||
<FileTypeIcon
|
||||
category={
|
||||
category
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 max-w-[120px]">
|
||||
<p
|
||||
className="text-[11px] font-medium truncate"
|
||||
style={{
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
title={
|
||||
af.file
|
||||
.name
|
||||
}
|
||||
>
|
||||
{af.file.name}
|
||||
</p>
|
||||
<p
|
||||
className="text-[9px]"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{formatFileSize(
|
||||
af.file
|
||||
.size,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── SSE stream parser for reconnection ──
|
||||
// Converts raw SSE events (AI SDK v6 wire format) into UIMessage parts.
|
||||
|
||||
@ -197,6 +521,12 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
const [startingNewSession, setStartingNewSession] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ── Attachment state ──
|
||||
const [attachedFiles, setAttachedFiles] = useState<
|
||||
AttachedFile[]
|
||||
>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ── Reconnection state ──
|
||||
const [isReconnecting, setIsReconnecting] = useState(false);
|
||||
const reconnectAbortRef = useRef<AbortController | null>(null);
|
||||
@ -552,13 +882,25 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || isStreaming) {
|
||||
const hasText = input.trim().length > 0;
|
||||
const hasFiles = attachedFiles.length > 0;
|
||||
if ((!hasText && !hasFiles) || isStreaming) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userText = input.trim();
|
||||
const currentAttachments = [...attachedFiles];
|
||||
setInput("");
|
||||
|
||||
// Clear attachments and revoke preview URLs
|
||||
if (currentAttachments.length > 0) {
|
||||
for (const f of currentAttachments) {
|
||||
if (f.previewUrl)
|
||||
{URL.revokeObjectURL(f.previewUrl);}
|
||||
}
|
||||
setAttachedFiles([]);
|
||||
}
|
||||
|
||||
if (userText.toLowerCase() === "/new") {
|
||||
handleNewSession();
|
||||
return;
|
||||
@ -566,10 +908,12 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
|
||||
let sessionId = currentSessionId;
|
||||
if (!sessionId) {
|
||||
const titleSource =
|
||||
userText || "File attachment";
|
||||
const title =
|
||||
userText.length > 60
|
||||
? userText.slice(0, 60) + "..."
|
||||
: userText;
|
||||
titleSource.length > 60
|
||||
? titleSource.slice(0, 60) + "..."
|
||||
: titleSource;
|
||||
sessionId = await createSession(title);
|
||||
setCurrentSessionId(sessionId);
|
||||
sessionIdRef.current = sessionId;
|
||||
@ -585,9 +929,20 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
}
|
||||
}
|
||||
|
||||
// Build message with optional attachment prefix
|
||||
let messageText = userText;
|
||||
if (currentAttachments.length > 0) {
|
||||
const filePaths = currentAttachments
|
||||
.map((f) => f.path)
|
||||
.join(", ");
|
||||
const prefix = `[Attached files: ${filePaths}]`;
|
||||
messageText = messageText
|
||||
? `${prefix}\n\n${messageText}`
|
||||
: prefix;
|
||||
}
|
||||
|
||||
if (fileContext && isFirstFileMessageRef.current) {
|
||||
messageText = `[Context: workspace file '${fileContext.path}']\n\n${userText}`;
|
||||
messageText = `[Context: workspace file '${fileContext.path}']\n\n${messageText}`;
|
||||
isFirstFileMessageRef.current = false;
|
||||
}
|
||||
|
||||
@ -735,6 +1090,73 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
stop();
|
||||
}, [currentSessionId, stop]);
|
||||
|
||||
// ── Attachment handlers ──
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) {return;}
|
||||
|
||||
const newFiles: AttachedFile[] = Array.from(
|
||||
files,
|
||||
).map((file) => {
|
||||
const cat = getFileCategory(file);
|
||||
const previewUrl =
|
||||
cat === "image" || cat === "video"
|
||||
? URL.createObjectURL(file)
|
||||
: null;
|
||||
// Chromium/Electron exposes the full filesystem path
|
||||
const fullPath =
|
||||
(file as File & { path?: string })
|
||||
.path ||
|
||||
file.webkitRelativePath ||
|
||||
file.name;
|
||||
return {
|
||||
id: `${file.name}-${file.size}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
file,
|
||||
path: fullPath,
|
||||
previewUrl,
|
||||
};
|
||||
});
|
||||
|
||||
setAttachedFiles((prev) => [...prev, ...newFiles]);
|
||||
e.target.value = "";
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const removeAttachment = useCallback((id: string) => {
|
||||
setAttachedFiles((prev) => {
|
||||
const found = prev.find((f) => f.id === id);
|
||||
if (found?.previewUrl) {
|
||||
URL.revokeObjectURL(found.previewUrl);
|
||||
}
|
||||
return prev.filter((f) => f.id !== id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearAllAttachments = useCallback(() => {
|
||||
setAttachedFiles((prev) => {
|
||||
for (const f of prev) {
|
||||
if (f.previewUrl)
|
||||
{URL.revokeObjectURL(f.previewUrl);}
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Cleanup preview URLs on unmount
|
||||
const attachedFilesRef = useRef(attachedFiles);
|
||||
attachedFilesRef.current = attachedFiles;
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const f of attachedFilesRef.current) {
|
||||
if (f.previewUrl)
|
||||
{URL.revokeObjectURL(f.previewUrl);}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Status label ──
|
||||
|
||||
const statusLabel = startingNewSession
|
||||
@ -1030,11 +1452,14 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
onChange={(e) =>
|
||||
setInput(e.target.value)
|
||||
}
|
||||
placeholder={
|
||||
compact && fileContext
|
||||
? `Ask about ${fileContext.filename}...`
|
||||
placeholder={
|
||||
compact && fileContext
|
||||
? `Ask about ${fileContext.filename}...`
|
||||
: attachedFiles.length >
|
||||
0
|
||||
? "Add a message or send files..."
|
||||
: "Ask anything..."
|
||||
}
|
||||
}
|
||||
disabled={
|
||||
isStreaming ||
|
||||
loadingSession ||
|
||||
@ -1045,21 +1470,48 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
{/* Toolbar row */}
|
||||
</form>
|
||||
|
||||
{/* Attachment preview strip */}
|
||||
<AttachmentStrip
|
||||
files={attachedFiles}
|
||||
compact={compact}
|
||||
onRemove={removeAttachment}
|
||||
onClearAll={
|
||||
clearAllAttachments
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Toolbar row */}
|
||||
<div
|
||||
className={`flex items-center justify-between ${compact ? "px-2 pb-1.5" : "px-3 pb-2.5"}`}
|
||||
>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* Placeholder toolbar icons */}
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-lg"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
title="Attach"
|
||||
>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* File input (hidden) */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={
|
||||
handleFileSelect
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
className="p-1.5 rounded-lg hover:opacity-80 transition-opacity"
|
||||
style={{
|
||||
color:
|
||||
attachedFiles.length >
|
||||
0
|
||||
? "var(--color-accent)"
|
||||
: "var(--color-text-muted)",
|
||||
}}
|
||||
title="Attach files"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
@ -1074,24 +1526,28 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/* Send button */}
|
||||
<button
|
||||
type="submit"
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
!input.trim() ||
|
||||
isStreaming ||
|
||||
loadingSession ||
|
||||
startingNewSession
|
||||
}
|
||||
className={`${compact ? "w-6 h-6" : "w-7 h-7"} rounded-full flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed`}
|
||||
style={{
|
||||
background:
|
||||
input.trim()
|
||||
? "var(--color-accent)"
|
||||
: "var(--color-border-strong)",
|
||||
color: "white",
|
||||
}}
|
||||
{/* Send button */}
|
||||
<button
|
||||
type="submit"
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
(!input.trim() &&
|
||||
attachedFiles.length ===
|
||||
0) ||
|
||||
isStreaming ||
|
||||
loadingSession ||
|
||||
startingNewSession
|
||||
}
|
||||
className={`${compact ? "w-6 h-6" : "w-7 h-7"} rounded-full flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed`}
|
||||
style={{
|
||||
background:
|
||||
input.trim() ||
|
||||
attachedFiles.length >
|
||||
0
|
||||
? "var(--color-accent)"
|
||||
: "var(--color-border-strong)",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{isStreaming ? (
|
||||
<div
|
||||
|
||||
@ -4,7 +4,7 @@ import { useEffect, useState, useCallback } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { CronJob, CronRunLogEntry, CronRunsResponse } from "../../types/cron";
|
||||
import { CronRunChat } from "./cron-run-chat";
|
||||
import { CronRunChat, CronRunTranscriptSearch } from "./cron-run-chat";
|
||||
|
||||
/* ─── Helpers ─── */
|
||||
|
||||
@ -75,7 +75,7 @@ export function CronJobDetail({
|
||||
}, [job.id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRuns();
|
||||
void fetchRuns();
|
||||
const id = setInterval(fetchRuns, 15_000);
|
||||
return () => clearInterval(id);
|
||||
}, [fetchRuns]);
|
||||
@ -379,31 +379,14 @@ function RunCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session transcript (full chat) or summary fallback */}
|
||||
{/* Session transcript */}
|
||||
{run.sessionId ? (
|
||||
<div className="mt-4">
|
||||
<CronRunChat sessionId={run.sessionId} />
|
||||
</div>
|
||||
) : run.summary ? (
|
||||
<div className="mt-3">
|
||||
<div
|
||||
className="text-[11px] uppercase tracking-wider font-medium mb-2"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Run Output
|
||||
</div>
|
||||
<div
|
||||
className="chat-prose text-sm"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{run.summary}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
No output recorded for this run.
|
||||
<div className="mt-4">
|
||||
<RunTranscriptOrSummary run={run} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -412,6 +395,39 @@ function RunCard({
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Transcript search with summary fallback ─── */
|
||||
|
||||
function RunTranscriptOrSummary({ run }: { run: CronRunLogEntry }) {
|
||||
const summaryFallback = run.summary ? (
|
||||
<div>
|
||||
<div
|
||||
className="text-[11px] uppercase tracking-wider font-medium mb-2"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Run Output
|
||||
</div>
|
||||
<div className="chat-prose text-sm" style={{ color: "var(--color-text)" }}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{run.summary}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
No output recorded for this run.
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<CronRunTranscriptSearch
|
||||
jobId={run.jobId}
|
||||
runAtMs={run.runAtMs}
|
||||
summary={run.summary}
|
||||
fallback={summaryFallback}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Subcomponents ─── */
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
|
||||
@ -75,6 +75,85 @@ export function CronRunChat({ sessionId }: { sessionId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Transcript search fallback (no sessionId) ─── */
|
||||
|
||||
export function CronRunTranscriptSearch({
|
||||
jobId,
|
||||
runAtMs,
|
||||
summary,
|
||||
fallback,
|
||||
}: {
|
||||
jobId: string;
|
||||
runAtMs?: number;
|
||||
summary?: string;
|
||||
fallback?: React.ReactNode;
|
||||
}) {
|
||||
const [messages, setMessages] = useState<SessionMessage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
|
||||
const fetchTranscript = useCallback(async () => {
|
||||
if (!runAtMs || !summary) {
|
||||
setLoading(false);
|
||||
setNotFound(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
jobId,
|
||||
runAtMs: String(runAtMs),
|
||||
summary,
|
||||
});
|
||||
const res = await fetch(`/api/cron/runs/search-transcript?${params}`);
|
||||
if (!res.ok) {
|
||||
setNotFound(true);
|
||||
return;
|
||||
}
|
||||
const data = await res.json() as { messages?: SessionMessage[] };
|
||||
if (data.messages && data.messages.length > 0) {
|
||||
setMessages(data.messages);
|
||||
} else {
|
||||
setNotFound(true);
|
||||
}
|
||||
} catch {
|
||||
setNotFound(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [jobId, runAtMs, summary]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTranscript();
|
||||
}, [fetchTranscript]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-4">
|
||||
<div
|
||||
className="w-4 h-4 border-[1.5px] rounded-full animate-spin"
|
||||
style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
|
||||
/>
|
||||
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>Searching for transcript...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (notFound || messages.length === 0) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-[11px] uppercase tracking-wider font-medium mb-2" style={{ color: "var(--color-text-muted)" }}>
|
||||
Session Transcript
|
||||
</div>
|
||||
{messages.map((msg) => (
|
||||
<CronChatMessage key={msg.id} message={msg} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Message rendering ─── */
|
||||
|
||||
function CronChatMessage({ message }: { message: SessionMessage }) {
|
||||
|
||||
@ -606,7 +606,7 @@ function flattenVisible(tree: TreeNode[], expanded: Set<string>): TreeNode[] {
|
||||
|
||||
// --- Main Exported Component ---
|
||||
|
||||
export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact, parentDir, onNavigateUp, browseDir }: FileManagerTreeProps) {
|
||||
export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact, parentDir, onNavigateUp, browseDir: _browseDir }: FileManagerTreeProps) {
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set());
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [renamingPath, setRenamingPath] = useState<string | null>(null);
|
||||
@ -934,10 +934,10 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
if (e.key === "c" && curNode) {
|
||||
e.preventDefault();
|
||||
navigator.clipboard.writeText(curNode.path);
|
||||
void navigator.clipboard.writeText(curNode.path);
|
||||
} else if (e.key === "d" && curNode && !isSystemFile(curNode.path)) {
|
||||
e.preventDefault();
|
||||
apiDuplicate(curNode.path).then(() => onRefresh());
|
||||
void apiDuplicate(curNode.path).then(() => onRefresh());
|
||||
} else if (e.key === "n") {
|
||||
e.preventDefault();
|
||||
const parent = curNode
|
||||
|
||||
@ -283,26 +283,14 @@ export function WorkspaceSidebar({
|
||||
className="px-3 py-2.5 border-t flex items-center justify-between"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
{isBrowsing && onGoHome ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onGoHome}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<WorkspaceLogo />
|
||||
Workspace
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href="/"
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<HomeIcon />
|
||||
Home
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href="/"
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<HomeIcon />
|
||||
Home
|
||||
</a>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@ -24,9 +24,10 @@ export function useWorkspaceWatcher() {
|
||||
const [exists, setExists] = useState(false);
|
||||
|
||||
// Browse mode state
|
||||
const [browseDir, setBrowseDir] = useState<string | null>(null);
|
||||
const [browseDirRaw, setBrowseDirRaw] = useState<string | null>(null);
|
||||
const [parentDir, setParentDir] = useState<string | null>(null);
|
||||
const [workspaceRoot, setWorkspaceRoot] = useState<string | null>(null);
|
||||
const [openclawDir, setOpenclawDir] = useState<string | null>(null);
|
||||
|
||||
const mountedRef = useRef(true);
|
||||
const retryDelayRef = useRef(1000);
|
||||
@ -40,6 +41,7 @@ export function useWorkspaceWatcher() {
|
||||
setTree(data.tree ?? []);
|
||||
setExists(data.exists ?? false);
|
||||
setWorkspaceRoot(data.workspaceRoot ?? null);
|
||||
setOpenclawDir(data.openclawDir ?? null);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
@ -64,24 +66,38 @@ export function useWorkspaceWatcher() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Smart setBrowseDir: auto-return to workspace mode when navigating to the
|
||||
// workspace root, so all virtual folders (Chats, Cron, etc.) and DuckDB
|
||||
// object detection are restored.
|
||||
const setBrowseDir = useCallback((dir: string | null) => {
|
||||
if (dir != null && workspaceRoot && dir === workspaceRoot) {
|
||||
setBrowseDirRaw(null);
|
||||
} else {
|
||||
setBrowseDirRaw(dir);
|
||||
}
|
||||
}, [workspaceRoot]);
|
||||
|
||||
// Expose the raw value for reads
|
||||
const browseDir = browseDirRaw;
|
||||
|
||||
// Unified fetch based on current mode
|
||||
const fetchTree = useCallback(async () => {
|
||||
if (browseDir) {
|
||||
await fetchBrowseTree(browseDir);
|
||||
if (browseDirRaw) {
|
||||
await fetchBrowseTree(browseDirRaw);
|
||||
} else {
|
||||
await fetchWorkspaceTree();
|
||||
}
|
||||
}, [browseDir, fetchBrowseTree, fetchWorkspaceTree]);
|
||||
}, [browseDirRaw, fetchBrowseTree, fetchWorkspaceTree]);
|
||||
|
||||
// Manual refresh for use after mutations
|
||||
const refresh = useCallback(() => {
|
||||
fetchTree();
|
||||
void fetchTree();
|
||||
}, [fetchTree]);
|
||||
|
||||
// Re-fetch when browseDir changes
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
fetchTree();
|
||||
void fetchTree();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
@ -89,7 +105,7 @@ export function useWorkspaceWatcher() {
|
||||
|
||||
// SSE subscription -- only active in workspace mode (not browse mode)
|
||||
useEffect(() => {
|
||||
if (browseDir) {return;}
|
||||
if (browseDirRaw) {return;}
|
||||
|
||||
let eventSource: EventSource | null = null;
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
@ -101,7 +117,7 @@ export function useWorkspaceWatcher() {
|
||||
function debouncedRefetch() {
|
||||
if (debounceTimer) {clearTimeout(debounceTimer);}
|
||||
debounceTimer = setTimeout(() => {
|
||||
if (alive) {fetchWorkspaceTree();}
|
||||
if (alive) {void fetchWorkspaceTree();}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
@ -131,12 +147,6 @@ export function useWorkspaceWatcher() {
|
||||
eventSource = null;
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
eventSource.onerror = () => {
|
||||
eventSource?.close();
|
||||
eventSource = null;
|
||||
scheduleReconnect();
|
||||
};
|
||||
} catch {
|
||||
// SSE not supported or network error -- fall back to polling
|
||||
startPolling();
|
||||
@ -161,7 +171,7 @@ export function useWorkspaceWatcher() {
|
||||
function startPolling() {
|
||||
if (pollInterval || !alive) {return;}
|
||||
pollInterval = setInterval(() => {
|
||||
if (alive) {fetchWorkspaceTree();}
|
||||
if (alive) {void fetchWorkspaceTree();}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
@ -174,7 +184,7 @@ export function useWorkspaceWatcher() {
|
||||
if (reconnectTimeout) {clearTimeout(reconnectTimeout);}
|
||||
if (debounceTimer) {clearTimeout(debounceTimer);}
|
||||
};
|
||||
}, [browseDir, fetchWorkspaceTree]);
|
||||
}, [browseDirRaw, fetchWorkspaceTree]);
|
||||
|
||||
return { tree, loading, exists, refresh, browseDir, setBrowseDir, parentDir, workspaceRoot };
|
||||
return { tree, loading, exists, refresh, browseDir, setBrowseDir, parentDir, workspaceRoot, openclawDir };
|
||||
}
|
||||
|
||||
@ -217,7 +217,7 @@ function WorkspacePageInner() {
|
||||
// Live-reactive tree via SSE watcher (with browse-mode support)
|
||||
const {
|
||||
tree, loading: treeLoading, exists: workspaceExists, refresh: refreshTree,
|
||||
browseDir, setBrowseDir, parentDir: browseParentDir, workspaceRoot,
|
||||
browseDir, setBrowseDir, parentDir: browseParentDir, workspaceRoot, openclawDir,
|
||||
} = useWorkspaceWatcher();
|
||||
|
||||
// Search index for @ mention fuzzy search (files + entries)
|
||||
@ -276,7 +276,7 @@ function WorkspacePageInner() {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
loadContext();
|
||||
void loadContext();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
@ -292,7 +292,7 @@ function WorkspacePageInner() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessions();
|
||||
void fetchSessions();
|
||||
}, [fetchSessions, sidebarRefreshKey]);
|
||||
|
||||
const refreshSessions = useCallback(() => {
|
||||
@ -311,7 +311,7 @@ function WorkspacePageInner() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCronJobs();
|
||||
void fetchCronJobs();
|
||||
const id = setInterval(fetchCronJobs, 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [fetchCronJobs]);
|
||||
@ -377,13 +377,43 @@ function WorkspacePageInner() {
|
||||
|
||||
const handleNodeSelect = useCallback(
|
||||
(node: TreeNode) => {
|
||||
// --- Browse-mode: detect special OpenClaw directories ---
|
||||
// When the user clicks a known OpenClaw folder while browsing the
|
||||
// filesystem, switch back to workspace mode or show the appropriate
|
||||
// dashboard instead of showing raw files.
|
||||
if (browseDir && isAbsolutePath(node.path)) {
|
||||
// Clicking the dench workspace root → restore full workspace mode
|
||||
if (workspaceRoot && node.path === workspaceRoot) {
|
||||
setBrowseDir(null);
|
||||
return;
|
||||
}
|
||||
if (openclawDir) {
|
||||
// Clicking the cron directory → show cron dashboard
|
||||
if (node.path === openclawDir + "/cron") {
|
||||
setBrowseDir(null);
|
||||
setActivePath("~cron");
|
||||
setContent({ kind: "cron-dashboard" });
|
||||
return;
|
||||
}
|
||||
// Clicking the web-chat directory → switch to workspace mode & open chats
|
||||
if (node.path === openclawDir + "/web-chat") {
|
||||
setBrowseDir(null);
|
||||
setActivePath(null);
|
||||
setContent({ kind: "none" });
|
||||
void chatRef.current?.newSession();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Virtual path handlers (workspace mode) ---
|
||||
// Intercept chat folder item clicks
|
||||
if (node.path.startsWith("~chats/")) {
|
||||
const sessionId = node.path.slice("~chats/".length);
|
||||
setActivePath(null);
|
||||
setContent({ kind: "none" });
|
||||
setActiveSessionId(sessionId);
|
||||
chatRef.current?.loadSession(sessionId);
|
||||
void chatRef.current?.loadSession(sessionId);
|
||||
// URL is synced by the activeSessionId effect
|
||||
return;
|
||||
}
|
||||
@ -391,7 +421,7 @@ function WorkspacePageInner() {
|
||||
if (node.path === "~chats") {
|
||||
setActivePath(null);
|
||||
setContent({ kind: "none" });
|
||||
chatRef.current?.newSession();
|
||||
void chatRef.current?.newSession();
|
||||
router.replace("/workspace", { scroll: false });
|
||||
return;
|
||||
}
|
||||
@ -413,9 +443,9 @@ function WorkspacePageInner() {
|
||||
router.replace("/workspace", { scroll: false });
|
||||
return;
|
||||
}
|
||||
loadContent(node);
|
||||
void loadContent(node);
|
||||
},
|
||||
[loadContent, router, cronJobs],
|
||||
[loadContent, router, cronJobs, browseDir, workspaceRoot, openclawDir, setBrowseDir],
|
||||
);
|
||||
|
||||
// Build the enhanced tree: real tree + Chats + Cron virtual folders at the bottom
|
||||
@ -550,7 +580,7 @@ function WorkspacePageInner() {
|
||||
const node = resolveNode(tree, pathParam);
|
||||
if (node) {
|
||||
initialPathHandled.current = true;
|
||||
loadContent(node);
|
||||
void loadContent(node);
|
||||
}
|
||||
} else if (chatParam) {
|
||||
// Restore the active chat session from URL
|
||||
@ -558,7 +588,7 @@ function WorkspacePageInner() {
|
||||
setActiveSessionId(chatParam);
|
||||
setActivePath(null);
|
||||
setContent({ kind: "none" });
|
||||
chatRef.current?.loadSession(chatParam);
|
||||
void chatRef.current?.loadSession(chatParam);
|
||||
}
|
||||
|
||||
// Also open entry modal from URL if present
|
||||
@ -579,7 +609,7 @@ function WorkspacePageInner() {
|
||||
}
|
||||
const node = resolveNode(tree, path);
|
||||
if (node) {
|
||||
loadContent(node);
|
||||
void loadContent(node);
|
||||
}
|
||||
},
|
||||
[tree, loadContent],
|
||||
@ -601,7 +631,7 @@ function WorkspacePageInner() {
|
||||
return null;
|
||||
}
|
||||
const node = findObjectNode(tree);
|
||||
if (node) {loadContent(node);}
|
||||
if (node) {void loadContent(node);}
|
||||
},
|
||||
[tree, loadContent],
|
||||
);
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user