🚀 RELEASE: file attachments

This commit is contained in:
kumarabhirup 2026-02-13 15:52:13 -08:00
parent 989c55534b
commit b86f5cf441
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
11 changed files with 1274 additions and 117 deletions

View 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 });
}

View File

@ -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 });
}

View File

@ -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>
);

View File

@ -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

View File

@ -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 }) {

View File

@ -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 }) {

View File

@ -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

View File

@ -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>

View File

@ -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 };
}

View File

@ -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