fix lint/build errors and bump to 2026.2.15-1.4
- Fix all oxlint errors (curly, no-unused-vars, no-base-to-string, no-floating-promises, approx-constant, restrict-template-expressions) - Fix TS build errors: rewrite update-cli.ts as thin wrapper over submodules, restore missing chat abort helpers in chat.ts - Fix web build: wrap handleNewSession in async for ChatPanelHandle, add missing safeString helper to entry-detail-modal - Bump version to 2026.2.15-1.4 and publish Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
312fb33859
commit
dee323b7ad
@ -34,7 +34,9 @@ export async function GET() {
|
||||
try {
|
||||
const entries = readdirSync(memoryDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
||||
if (!entry.isFile() || !entry.name.endsWith(".md")) {
|
||||
continue;
|
||||
}
|
||||
const filePath = join(memoryDir, entry.name);
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
|
||||
@ -37,7 +37,9 @@ function findSessionFile(sessionId: string): string | null {
|
||||
try {
|
||||
const agentDirs = readdirSync(agentsDir, { withFileTypes: true });
|
||||
for (const agentDir of agentDirs) {
|
||||
if (!agentDir.isDirectory()) continue;
|
||||
if (!agentDir.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sessionFile = join(
|
||||
agentsDir,
|
||||
|
||||
@ -54,17 +54,23 @@ export async function GET() {
|
||||
try {
|
||||
const entries = readdirSync(agentsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
agentIds.push(entry.name);
|
||||
|
||||
const storePath = join(agentsDir, entry.name, "sessions", "sessions.json");
|
||||
if (!existsSync(storePath)) continue;
|
||||
if (!existsSync(storePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = readFileSync(storePath, "utf-8");
|
||||
const store = JSON.parse(raw) as Record<string, SessionEntry>;
|
||||
for (const [key, session] of Object.entries(store)) {
|
||||
if (!session || typeof session !== "object") continue;
|
||||
if (!session || typeof session !== "object") {
|
||||
continue;
|
||||
}
|
||||
allSessions.push({
|
||||
key,
|
||||
sessionId: session.sessionId,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { cpSync, existsSync, statSync } from "node:fs";
|
||||
import { dirname, basename, extname, join } from "node:path";
|
||||
import { safeResolvePath, safeResolveNewPath, isSystemFile } from "@/lib/workspace";
|
||||
import { dirname, basename, extname } from "node:path";
|
||||
import { safeResolvePath, safeResolveNewPath } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { renameSync, existsSync, statSync } from "node:fs";
|
||||
import { join, basename } from "node:path";
|
||||
import { safeResolvePath, safeResolveNewPath, isSystemFile } from "@/lib/workspace";
|
||||
import { safeResolvePath, isSystemFile } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@ -201,7 +201,15 @@ export async function GET(
|
||||
continue;
|
||||
}
|
||||
|
||||
const ids = parseRelationValue(String(val));
|
||||
const valStr =
|
||||
typeof val === "object" && val !== null
|
||||
? JSON.stringify(val)
|
||||
: typeof val === "string"
|
||||
? val
|
||||
: typeof val === "number" || typeof val === "boolean"
|
||||
? String(val)
|
||||
: "";
|
||||
const ids = parseRelationValue(valStr);
|
||||
if (ids.length === 0) {
|
||||
relationLabels[rf.name] = {};
|
||||
continue;
|
||||
|
||||
@ -171,8 +171,18 @@ function resolveRelationLabels(
|
||||
const entryIds = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
const val = entry[rf.name];
|
||||
if (val == null || val === "") {continue;}
|
||||
for (const id of parseRelationValue(String(val))) {
|
||||
if (val == null || val === "") {
|
||||
continue;
|
||||
}
|
||||
const valStr =
|
||||
typeof val === "object" && val !== null
|
||||
? JSON.stringify(val)
|
||||
: typeof val === "string"
|
||||
? val
|
||||
: typeof val === "number" || typeof val === "boolean"
|
||||
? String(val)
|
||||
: "";
|
||||
for (const id of parseRelationValue(valStr)) {
|
||||
entryIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { renameSync, existsSync } from "node:fs";
|
||||
import { join, dirname, basename } from "node:path";
|
||||
import { join, dirname } from "node:path";
|
||||
import { safeResolvePath, safeResolveNewPath, isSystemFile } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@ -241,7 +241,7 @@ function readObjectIcon(workspaceRoot: string, objName: string): string | undefi
|
||||
const yamlPath = join(dir, entry.name, ".object.yaml");
|
||||
if (existsSync(yamlPath)) {
|
||||
const parsed = parseSimpleYaml(readFileSync(yamlPath, "utf-8"));
|
||||
if (parsed.icon) {return String(parsed.icon);}
|
||||
if (parsed.icon) {return dbStr(parsed.icon);}
|
||||
}
|
||||
}
|
||||
const found = walk(join(dir, entry.name), depth + 1);
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname, extname } from "node:path";
|
||||
import { join, dirname } from "node:path";
|
||||
import { resolveWorkspaceRoot, safeResolveNewPath } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const ALLOWED_EXTENSIONS = new Set([
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico",
|
||||
]);
|
||||
|
||||
const MAX_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
const MAX_SIZE = 25 * 1024 * 1024; // 25 MB
|
||||
|
||||
/**
|
||||
* POST /api/workspace/upload
|
||||
@ -41,19 +37,10 @@ export async function POST(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Validate extension
|
||||
const ext = extname(file.name).toLowerCase();
|
||||
if (!ALLOWED_EXTENSIONS.has(ext)) {
|
||||
return Response.json(
|
||||
{ error: `File type ${ext} is not allowed` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate size
|
||||
if (file.size > MAX_SIZE) {
|
||||
return Response.json(
|
||||
{ error: "File is too large (max 10 MB)" },
|
||||
{ error: "File is too large (max 25 MB)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
@ -774,7 +774,7 @@ export function ChainOfThought({ parts, isStreaming }: { parts: ChainPart[]; isS
|
||||
|
||||
function ReasoningBlock({
|
||||
text,
|
||||
isStreaming,
|
||||
isStreaming: _isStreaming,
|
||||
}: {
|
||||
text: string;
|
||||
isStreaming: boolean;
|
||||
@ -845,7 +845,7 @@ function FetchGroup({ items }: { items: ToolPart[] }) {
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{items.map((tool, i) => {
|
||||
{items.map((tool) => {
|
||||
const { domain, url } = getFetchDomainAndUrl(tool.args, tool.output);
|
||||
return (
|
||||
<a
|
||||
@ -1301,7 +1301,7 @@ function ToolStep({
|
||||
: "var(--color-error)",
|
||||
}}
|
||||
>
|
||||
exit {String(output.exitCode)}
|
||||
exit {typeof output.exitCode === "object" && output.exitCode != null ? JSON.stringify(output.exitCode) : typeof output.exitCode === "number" ? String(output.exitCode) : typeof output.exitCode === "string" ? output.exitCode : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -80,19 +80,31 @@ function tooltipStyle() {
|
||||
|
||||
// --- Formatters ---
|
||||
|
||||
/** Safe string conversion for chart values (handles objects via JSON.stringify). */
|
||||
function toDisplayStr(val: unknown): string {
|
||||
if (val == null) {return "";}
|
||||
if (typeof val === "object") {return JSON.stringify(val);}
|
||||
if (typeof val === "string") {return val;}
|
||||
if (typeof val === "number" || typeof val === "boolean") {return String(val);}
|
||||
// symbol, bigint, function — val is narrowed (object already handled above)
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
return String(val);
|
||||
}
|
||||
|
||||
function formatValue(val: unknown): string {
|
||||
if (val === null || val === undefined) {return "";}
|
||||
if (typeof val === "object") {return JSON.stringify(val);}
|
||||
if (typeof val === "number") {
|
||||
if (Math.abs(val) >= 1_000_000) {return `${(val / 1_000_000).toFixed(1)}M`;}
|
||||
if (Math.abs(val) >= 1_000) {return `${(val / 1_000).toFixed(1)}K`;}
|
||||
return Number.isInteger(val) ? String(val) : val.toFixed(2);
|
||||
}
|
||||
return String(val);
|
||||
return toDisplayStr(val);
|
||||
}
|
||||
|
||||
function formatLabel(val: unknown): string {
|
||||
if (val === null || val === undefined) {return "";}
|
||||
const str = String(val);
|
||||
const str = toDisplayStr(val);
|
||||
// Truncate long date strings
|
||||
if (str.length > 16 && !isNaN(Date.parse(str))) {
|
||||
return str.slice(0, 10);
|
||||
|
||||
@ -234,7 +234,11 @@ export function FilterBar({ filters, value, onChange }: FilterBarProps) {
|
||||
const opts = rows
|
||||
.map((r) => {
|
||||
const vals = Object.values(r);
|
||||
return vals[0] != null ? String(vals[0]) : null;
|
||||
const v = vals[0];
|
||||
if (v == null) {return null;}
|
||||
if (typeof v === "object") {return JSON.stringify(v);}
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- v narrowed, object handled above
|
||||
return typeof v === "string" ? v : (typeof v === "number" || typeof v === "boolean" ? String(v) : String(v));
|
||||
})
|
||||
.filter((v): v is string => v !== null);
|
||||
results[f.id] = opts;
|
||||
@ -247,7 +251,7 @@ export function FilterBar({ filters, value, onChange }: FilterBarProps) {
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOptions();
|
||||
void fetchOptions();
|
||||
}, [fetchOptions]);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
|
||||
@ -97,7 +97,7 @@ export function ReportCard({ config }: ReportCardProps) {
|
||||
}, [visiblePanels]);
|
||||
|
||||
useEffect(() => {
|
||||
executePanels();
|
||||
void executePanels();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
|
||||
@ -192,7 +192,7 @@ export function ReportViewer({ config: propConfig, reportPath }: ReportViewerPro
|
||||
|
||||
// Re-execute when config, filters, or refresh key changes
|
||||
useEffect(() => {
|
||||
executeAllPanels();
|
||||
void executeAllPanels();
|
||||
}, [executeAllPanels, refreshKey]);
|
||||
|
||||
const totalRows = useMemo(() => {
|
||||
|
||||
@ -234,46 +234,55 @@ function AttachmentStrip({
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<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-[140px]">
|
||||
<p
|
||||
className="text-[11px] font-medium truncate"
|
||||
style={{
|
||||
color: "var(--color-text)",
|
||||
{category === "image" ? (
|
||||
/* Image thumbnail preview */
|
||||
<div className="flex flex-col items-center" style={{ width: 96 }}>
|
||||
<img
|
||||
src={`/api/workspace/raw-file?path=${encodeURIComponent(af.path)}`}
|
||||
alt={af.name}
|
||||
className="w-full rounded-t-xl object-cover"
|
||||
style={{ height: 56, background: "var(--color-bg-secondary)" }}
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
title={
|
||||
af.path
|
||||
}
|
||||
/>
|
||||
<p
|
||||
className="text-[10px] font-medium truncate w-full px-2 py-1.5 text-center"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
title={af.path}
|
||||
>
|
||||
{af.name}
|
||||
</p>
|
||||
<p
|
||||
className="text-[9px] truncate"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
title={
|
||||
af.path
|
||||
}
|
||||
>
|
||||
{short}
|
||||
</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-[140px]">
|
||||
<p
|
||||
className="text-[11px] font-medium truncate"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
title={af.path}
|
||||
>
|
||||
{af.name}
|
||||
</p>
|
||||
<p
|
||||
className="text-[9px] truncate"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title={af.path}
|
||||
>
|
||||
{short}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -625,7 +634,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
// period), skip the expensive SSE replay -- the
|
||||
// persisted messages we already loaded are final.
|
||||
if (res.headers.get("X-Run-Active") === "false") {
|
||||
res.body.cancel();
|
||||
void res.body.cancel();
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -1135,10 +1144,10 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
loadSession: handleSessionSelect,
|
||||
newSession: handleNewSession,
|
||||
loadSession: handleSessionSelect,
|
||||
newSession: async () => { handleNewSession(); },
|
||||
sendNewMessage: async (text: string) => {
|
||||
await handleNewSession();
|
||||
handleNewSession();
|
||||
const title =
|
||||
text.length > 60 ? text.slice(0, 60) + "..." : text;
|
||||
const sessionId = await createSession(title);
|
||||
@ -1231,6 +1240,37 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
setAttachedFiles([]);
|
||||
}, []);
|
||||
|
||||
/** Upload native files (e.g. dropped from Finder/Desktop) and attach them. */
|
||||
const uploadAndAttachNativeFiles = useCallback(
|
||||
async (files: FileList) => {
|
||||
const uploaded: AttachedFile[] = [];
|
||||
for (const file of Array.from(files)) {
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await fetch("/api/workspace/upload", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) { continue; }
|
||||
const json = (await res.json()) as { ok?: boolean; path?: string };
|
||||
if (!json.ok || !json.path) { continue; }
|
||||
uploaded.push({
|
||||
id: `${json.path}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
name: file.name,
|
||||
path: json.path,
|
||||
});
|
||||
} catch {
|
||||
// skip files that fail to upload
|
||||
}
|
||||
}
|
||||
if (uploaded.length > 0) {
|
||||
setAttachedFiles((prev) => [...prev, ...uploaded]);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// ── Status label ──
|
||||
|
||||
const statusLabel = loadingSession
|
||||
@ -1257,7 +1297,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
<div className="flex flex-col min-h-full">
|
||||
{/* Header — sticky glass bar */}
|
||||
<header
|
||||
className={`${compact ? "px-3 py-2" : "px-6 py-3"} flex items-center justify-between sticky top-0 z-20 backdrop-blur-md`}
|
||||
className={`${compact ? "px-3 py-2" : "px-3 py-2 md:px-6 md:py-3"} flex items-center justify-between sticky top-0 z-20 backdrop-blur-md`}
|
||||
style={{
|
||||
background: "var(--color-bg-glass)",
|
||||
}}
|
||||
@ -1493,7 +1533,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
|
||||
{/* Input — sticky glass bar at bottom */}
|
||||
<div
|
||||
className={`${compact ? "px-3 py-2" : "px-6 pb-5 pt-0"} sticky bottom-0 z-20 backdrop-blur-md`}
|
||||
className={`${compact ? "px-3 py-2" : "px-3 pb-3 pt-0 md:px-6 md:pb-5"} sticky bottom-0 z-20 backdrop-blur-md`}
|
||||
style={{ background: "var(--color-bg-glass)" }}
|
||||
>
|
||||
<div
|
||||
@ -1508,15 +1548,29 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
border: "1px solid var(--color-border)",
|
||||
boxShadow: "0 0 32px rgba(0,0,0,0.07)",
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer?.types.includes("application/x-file-mention")) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
const data = e.dataTransfer?.getData("application/x-file-mention");
|
||||
if (!data) {return;}
|
||||
onDragOver={(e) => {
|
||||
if (
|
||||
e.dataTransfer?.types.includes("application/x-file-mention") ||
|
||||
e.dataTransfer?.types.includes("Files")
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
// visual feedback
|
||||
(e.currentTarget as HTMLElement).setAttribute("data-drag-hover", "");
|
||||
}
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
// Only remove when leaving the container itself (not entering a child)
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
(e.currentTarget as HTMLElement).removeAttribute("data-drag-hover");
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
(e.currentTarget as HTMLElement).removeAttribute("data-drag-hover");
|
||||
|
||||
// Sidebar file mention drop
|
||||
const data = e.dataTransfer?.getData("application/x-file-mention");
|
||||
if (data) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
@ -1527,11 +1581,21 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
} catch {
|
||||
// ignore malformed data
|
||||
}
|
||||
}}
|
||||
return;
|
||||
}
|
||||
|
||||
// Native file drop (from OS file manager / Desktop)
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void uploadAndAttachNativeFiles(files);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Queued messages indicator */}
|
||||
{queuedMessages.length > 0 && (
|
||||
<div className={`${compact ? "px-2 pt-2" : "px-3 pt-3"}`}>
|
||||
<div className={compact ? "px-2 pt-2" : "px-3 pt-3"}>
|
||||
<div
|
||||
className="rounded-xl overflow-hidden"
|
||||
style={{
|
||||
@ -1599,6 +1663,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
onChange={(isEmpty) =>
|
||||
setEditorEmpty(isEmpty)
|
||||
}
|
||||
onNativeFileDrop={uploadAndAttachNativeFiles}
|
||||
placeholder={
|
||||
compact && fileContext
|
||||
? `Ask about ${fileContext.isDirectory ? "this folder" : fileContext.filename}...`
|
||||
|
||||
@ -111,8 +111,8 @@ export function CronDashboard({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const id = setInterval(fetchData, 30_000);
|
||||
void fetchData();
|
||||
const id = setInterval(() => void fetchData(), 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [fetchData]);
|
||||
|
||||
|
||||
@ -29,7 +29,7 @@ export function CronRunChat({ sessionId }: { sessionId: string }) {
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSession();
|
||||
void fetchSession();
|
||||
}, [fetchSession]);
|
||||
|
||||
if (loading) {
|
||||
@ -123,7 +123,7 @@ export function CronRunTranscriptSearch({
|
||||
}, [jobId, runAtMs, summary]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTranscript();
|
||||
void fetchTranscript();
|
||||
}, [fetchTranscript]);
|
||||
|
||||
if (loading) {
|
||||
|
||||
@ -252,7 +252,7 @@ export function FilePickerModal({
|
||||
|
||||
// Fetch on open and navigation
|
||||
useEffect(() => {
|
||||
if (open) {fetchDir(currentDir);}
|
||||
if (open) { void fetchDir(currentDir); }
|
||||
}, [open, currentDir, fetchDir]);
|
||||
|
||||
// Escape key
|
||||
@ -301,7 +301,7 @@ export function FilePickerModal({
|
||||
});
|
||||
setCreatingFolder(false);
|
||||
setNewFolderName("");
|
||||
fetchDir(currentDir);
|
||||
void fetchDir(currentDir);
|
||||
} catch {
|
||||
setError("Failed to create folder");
|
||||
}
|
||||
@ -356,9 +356,8 @@ export function FilePickerModal({
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
className="relative flex flex-col rounded-2xl shadow-2xl overflow-hidden"
|
||||
className="relative flex flex-col rounded-2xl shadow-2xl overflow-hidden w-[calc(100%-2rem)] max-w-[540px]"
|
||||
style={{
|
||||
width: 540,
|
||||
maxHeight: "70vh",
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
@ -690,8 +689,9 @@ export function FilePickerModal({
|
||||
if (
|
||||
e.key ===
|
||||
"Enter"
|
||||
)
|
||||
{handleCreateFolder();}
|
||||
) {
|
||||
void handleCreateFolder();
|
||||
}
|
||||
if (
|
||||
e.key ===
|
||||
"Escape"
|
||||
|
||||
@ -384,7 +384,7 @@ export function Sidebar({
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
void load();
|
||||
}, [refreshKey]);
|
||||
|
||||
const refreshWorkspace = useCallback(async () => {
|
||||
|
||||
@ -42,7 +42,7 @@ export function SyntaxBlock({ code, lang }: SyntaxBlockProps) {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getHighlighter().then((hl) => {
|
||||
void getHighlighter().then((hl) => {
|
||||
if (cancelled) {return;}
|
||||
try {
|
||||
const result = hl.codeToHtml(code, {
|
||||
|
||||
@ -38,6 +38,8 @@ type ChatEditorProps = {
|
||||
onSubmit: (text: string, mentionedFiles: Array<{ name: string; path: string }>) => void;
|
||||
/** Called on every content change. */
|
||||
onChange?: (isEmpty: boolean) => void;
|
||||
/** Called when native files (e.g. from Finder/Desktop) are dropped onto the editor. */
|
||||
onNativeFileDrop?: (files: FileList) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
compact?: boolean;
|
||||
@ -207,10 +209,13 @@ function createChatFileMentionSuggestion() {
|
||||
// ── Main component ──
|
||||
|
||||
export const ChatEditor = forwardRef<ChatEditorHandle, ChatEditorProps>(
|
||||
function ChatEditor({ onSubmit, onChange, placeholder, disabled, compact }, ref) {
|
||||
function ChatEditor({ onSubmit, onChange, onNativeFileDrop, placeholder, disabled, compact }, ref) {
|
||||
const submitRef = useRef(onSubmit);
|
||||
submitRef.current = onSubmit;
|
||||
|
||||
const nativeFileDropRef = useRef(onNativeFileDrop);
|
||||
nativeFileDropRef.current = onNativeFileDrop;
|
||||
|
||||
// Ref to access the TipTap editor from within ProseMirror's handleDOMEvents
|
||||
// (the handlers are defined at useEditor() call time, before the editor exists).
|
||||
const editorRefInternal = useRef<Editor | null>(null);
|
||||
@ -261,35 +266,53 @@ export const ChatEditor = forwardRef<ChatEditorHandle, ChatEditorProps>(
|
||||
de.dataTransfer.dropEffect = "copy";
|
||||
return true;
|
||||
}
|
||||
// Accept native file drops (e.g. from Finder/Desktop)
|
||||
if (de.dataTransfer?.types.includes("Files")) {
|
||||
de.preventDefault();
|
||||
de.dataTransfer.dropEffect = "copy";
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
drop: (_view, event) => {
|
||||
const de = event;
|
||||
|
||||
// Sidebar file mention drop
|
||||
const data = de.dataTransfer?.getData("application/x-file-mention");
|
||||
if (!data) {return false;}
|
||||
|
||||
de.preventDefault();
|
||||
de.stopPropagation();
|
||||
|
||||
try {
|
||||
const { name, path } = JSON.parse(data) as { name: string; path: string };
|
||||
if (name && path) {
|
||||
editorRefInternal.current
|
||||
?.chain()
|
||||
.focus()
|
||||
.insertContent([
|
||||
{
|
||||
type: "chatFileMention",
|
||||
attrs: { label: name, path },
|
||||
},
|
||||
{ type: "text", text: " " },
|
||||
])
|
||||
.run();
|
||||
if (data) {
|
||||
de.preventDefault();
|
||||
de.stopPropagation();
|
||||
try {
|
||||
const { name, path } = JSON.parse(data) as { name: string; path: string };
|
||||
if (name && path) {
|
||||
editorRefInternal.current
|
||||
?.chain()
|
||||
.focus()
|
||||
.insertContent([
|
||||
{
|
||||
type: "chatFileMention",
|
||||
attrs: { label: name, path },
|
||||
},
|
||||
{ type: "text", text: " " },
|
||||
])
|
||||
.run();
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed data
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed data
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
|
||||
// Native file drop (from OS file manager)
|
||||
const files = de.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
de.preventDefault();
|
||||
de.stopPropagation();
|
||||
nativeFileDropRef.current?.(files);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -490,7 +490,7 @@ export function createFileMentionRenderer() {
|
||||
function debouncedFetch(query: string) {
|
||||
if (debounceTimer) {clearTimeout(debounceTimer);}
|
||||
debounceTimer = setTimeout(() => {
|
||||
fetchSuggestions(query);
|
||||
void fetchSuggestions(query);
|
||||
}, 120);
|
||||
}
|
||||
|
||||
@ -506,7 +506,7 @@ export function createFileMentionRenderer() {
|
||||
latestClientRect = props.clientRect ?? null;
|
||||
currentQuery = props.query;
|
||||
|
||||
import("react-dom/client").then(({ createRoot }) => {
|
||||
void import("react-dom/client").then(({ createRoot }) => {
|
||||
root = createRoot(container!);
|
||||
debouncedFetch(currentQuery);
|
||||
});
|
||||
|
||||
@ -10,7 +10,7 @@ export function Breadcrumbs({ path, onNavigate }: BreadcrumbsProps) {
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-1 text-sm py-2">
|
||||
<nav className="flex items-center gap-1 text-xs md:text-sm py-2 overflow-x-auto min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate("")}
|
||||
|
||||
@ -19,6 +19,10 @@ type ChatSessionsSidebarProps = {
|
||||
streamingSessionIds?: Set<string>;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onNewSession: () => void;
|
||||
/** When true, renders as a mobile overlay drawer instead of a static sidebar. */
|
||||
mobile?: boolean;
|
||||
/** Close the mobile drawer. */
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
/** Format a timestamp into a human-readable relative time string. */
|
||||
@ -80,24 +84,27 @@ export function ChatSessionsSidebar({
|
||||
streamingSessionIds,
|
||||
onSelectSession,
|
||||
onNewSession,
|
||||
mobile,
|
||||
onClose,
|
||||
}: ChatSessionsSidebarProps) {
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
onSelectSession(id);
|
||||
onClose?.();
|
||||
},
|
||||
[onSelectSession],
|
||||
[onSelectSession, onClose],
|
||||
);
|
||||
|
||||
// Group sessions: today, yesterday, this week, this month, older
|
||||
const grouped = groupSessions(sessions);
|
||||
|
||||
return (
|
||||
const sidebar = (
|
||||
<aside
|
||||
className="flex flex-col h-full border-l flex-shrink-0"
|
||||
className={`flex flex-col h-full flex-shrink-0 ${mobile ? "drawer-right" : "border-l"}`}
|
||||
style={{
|
||||
width: 260,
|
||||
width: mobile ? "280px" : 260,
|
||||
borderColor: "var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
}}
|
||||
@ -234,6 +241,17 @@ export function ChatSessionsSidebar({
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
if (!mobile) { return sidebar; }
|
||||
|
||||
return (
|
||||
<div className="drawer-backdrop" onClick={() => void onClose?.()}>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div onClick={(e) => e.stopPropagation()} className="fixed inset-y-0 right-0 z-50">
|
||||
{sidebar}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Grouping helpers ──
|
||||
|
||||
@ -141,7 +141,7 @@ function HighlightedCode({
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getHighlighter().then((highlighter) => {
|
||||
void getHighlighter().then((highlighter) => {
|
||||
if (cancelled) {return;}
|
||||
const result = highlighter.codeToHtml(content, {
|
||||
lang: lang === "text" ? "text" : lang,
|
||||
|
||||
@ -194,7 +194,7 @@ export function ContextMenu({ x, y, target, onAction, onClose }: ContextMenuProp
|
||||
);
|
||||
}
|
||||
|
||||
const menuItem = item as Exclude<ContextMenuItem, { separator: true }>;
|
||||
const menuItem = item;
|
||||
const isDisabled = menuItem.disabled;
|
||||
|
||||
return (
|
||||
|
||||
@ -511,7 +511,7 @@ export function DataTable<TData, TValue>({
|
||||
{/* Table */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="flex-1 overflow-auto"
|
||||
className="flex-1 overflow-auto min-w-0"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{loading ? (
|
||||
|
||||
@ -99,6 +99,15 @@ function SortIndicator({ active, direction }: { active: boolean; direction: "asc
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
/** Safely convert unknown (DuckDB) value to string for display/sort. */
|
||||
function safeString(val: unknown): string {
|
||||
if (val == null) {return "";}
|
||||
if (typeof val === "object") {return JSON.stringify(val);}
|
||||
if (typeof val === "string") {return val;}
|
||||
if (typeof val === "number" || typeof val === "boolean" || typeof val === "bigint") {return String(val);}
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatRowCount(n: number): string {
|
||||
if (n >= 1_000_000) {return `${(n / 1_000_000).toFixed(1)}M`;}
|
||||
if (n >= 1_000) {return `${(n / 1_000).toFixed(1)}K`;}
|
||||
@ -246,7 +255,7 @@ export function DatabaseViewer({ dbPath, filename }: DatabaseViewerProps) {
|
||||
}
|
||||
}
|
||||
|
||||
introspect();
|
||||
void introspect();
|
||||
return () => { cancelled = true; };
|
||||
}, [dbPath]);
|
||||
|
||||
@ -280,7 +289,7 @@ export function DatabaseViewer({ dbPath, filename }: DatabaseViewerProps) {
|
||||
useEffect(() => {
|
||||
if (selectedTable) {
|
||||
setSort(null);
|
||||
fetchTableData(selectedTable, page * pageSize);
|
||||
void fetchTableData(selectedTable, page * pageSize);
|
||||
}
|
||||
}, [selectedTable, page, fetchTableData]);
|
||||
|
||||
@ -320,8 +329,8 @@ export function DatabaseViewer({ dbPath, filename }: DatabaseViewerProps) {
|
||||
const data = queryMode && queryResult ? queryResult : tableData;
|
||||
if (!sort) {return data;}
|
||||
return [...data].toSorted((a, b) => {
|
||||
const aVal = String(a[sort.column] ?? "");
|
||||
const bVal = String(b[sort.column] ?? "");
|
||||
const aVal = safeString(a[sort.column]);
|
||||
const bVal = safeString(b[sort.column]);
|
||||
const cmp = aVal.localeCompare(bVal, undefined, { numeric: true });
|
||||
return sort.direction === "asc" ? cmp : -cmp;
|
||||
});
|
||||
@ -965,7 +974,7 @@ function CellContent({ value }: { value: unknown }) {
|
||||
return <span className="tabular-nums">{value}</span>;
|
||||
}
|
||||
|
||||
const str = String(value);
|
||||
const str = safeString(value);
|
||||
|
||||
// Truncate very long values
|
||||
if (str.length > 120) {
|
||||
|
||||
@ -4,6 +4,14 @@ import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { RelationSelect } from "./relation-select";
|
||||
|
||||
|
||||
function safeString(val: unknown): string {
|
||||
if (val == null) {return "";}
|
||||
if (typeof val === "object") {return JSON.stringify(val);}
|
||||
if (typeof val === "string") {return val;}
|
||||
if (typeof val === "number" || typeof val === "boolean" || typeof val === "bigint") {return String(val);}
|
||||
return "";
|
||||
}
|
||||
|
||||
// --- Types ---
|
||||
|
||||
type Field = {
|
||||
@ -246,7 +254,7 @@ function FieldValue({
|
||||
case "enum":
|
||||
return (
|
||||
<EnumBadge
|
||||
value={String(value)}
|
||||
value={safeString(value)}
|
||||
enumValues={field.enum_values}
|
||||
enumColors={field.enum_colors}
|
||||
/>
|
||||
@ -268,18 +276,18 @@ function FieldValue({
|
||||
);
|
||||
case "email":
|
||||
return (
|
||||
<a href={`mailto:${value}`} className="underline underline-offset-2" style={{ color: "#60a5fa" }}>
|
||||
{String(value)}
|
||||
<a href={`mailto:${safeString(value)}`} className="underline underline-offset-2" style={{ color: "#60a5fa" }}>
|
||||
{safeString(value)}
|
||||
</a>
|
||||
);
|
||||
case "richtext":
|
||||
return <span className="whitespace-pre-wrap">{String(value)}</span>;
|
||||
return <span className="whitespace-pre-wrap">{safeString(value)}</span>;
|
||||
case "number":
|
||||
return <span className="tabular-nums">{String(value)}</span>;
|
||||
return <span className="tabular-nums">{safeString(value)}</span>;
|
||||
case "date":
|
||||
return <span>{String(value)}</span>;
|
||||
return <span>{safeString(value)}</span>;
|
||||
default:
|
||||
return <span>{String(value)}</span>;
|
||||
return <span>{safeString(value)}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
@ -335,7 +343,7 @@ export function EntryDetailModal({
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
void load();
|
||||
return () => { cancelled = true; };
|
||||
}, [objectName, entryId]);
|
||||
|
||||
@ -398,8 +406,8 @@ export function EntryDetailModal({
|
||||
|
||||
const displayField = data?.effectiveDisplayField;
|
||||
const title = displayField && data?.entry[displayField]
|
||||
? String(data.entry[displayField])
|
||||
: `${objectName} entry`;
|
||||
? safeString(data.entry[displayField])
|
||||
: `${String(objectName)} entry`;
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -409,23 +417,23 @@ export function EntryDetailModal({
|
||||
style={{ background: "rgba(0, 0, 0, 0.5)", backdropFilter: "blur(2px)" }}
|
||||
>
|
||||
<div
|
||||
className="relative mt-12 mb-12 w-full max-w-2xl rounded-2xl overflow-hidden shadow-2xl flex flex-col"
|
||||
className="relative mt-4 mb-4 mx-3 md:mt-12 md:mb-12 md:mx-0 w-full max-w-2xl rounded-2xl overflow-hidden shadow-2xl flex flex-col"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
maxHeight: "calc(100vh - 6rem)",
|
||||
maxHeight: "calc(100vh - 2rem)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-6 py-4 border-b flex-shrink-0"
|
||||
className="flex items-center justify-between px-4 py-3 md:px-6 md:py-4 border-b flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{/* Object badge */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigateObject?.(objectName)}
|
||||
onClick={() => void onNavigateObject?.(objectName)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium capitalize transition-colors hover:opacity-80 flex-shrink-0"
|
||||
style={{
|
||||
background: "var(--color-accent-light)",
|
||||
@ -449,8 +457,8 @@ export function EntryDetailModal({
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{/* Delete button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
type="button"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={deleting}
|
||||
className="p-1.5 rounded-lg flex-shrink-0"
|
||||
style={{ color: "var(--color-error)" }}
|
||||
@ -517,9 +525,9 @@ export function EntryDetailModal({
|
||||
<div className="flex-1">
|
||||
<RelationSelect
|
||||
relatedObjectName={field.related_object_name}
|
||||
value={String(value ?? "")}
|
||||
value={safeString(value)}
|
||||
multiple={field.relationship_type === "many_to_many"}
|
||||
onChange={(v) => { handleSaveField(field.name, v); }}
|
||||
onChange={(v) => { void handleSaveField(field.name, v); }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@ -529,13 +537,13 @@ export function EntryDetailModal({
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); handleSaveField(field.name, editValue); }}
|
||||
onSubmit={(e) => { e.preventDefault(); void handleSaveField(field.name, editValue); }}
|
||||
className="flex items-center gap-2 w-full"
|
||||
>
|
||||
{field.type === "enum" && field.enum_values ? (
|
||||
<select
|
||||
value={editValue}
|
||||
onChange={(e) => { setEditValue(e.target.value); handleSaveField(field.name, e.target.value); }}
|
||||
onChange={(e) => { setEditValue(e.target.value); void handleSaveField(field.name, e.target.value); }}
|
||||
autoFocus
|
||||
className="flex-1 px-2 py-1 text-sm rounded-lg outline-none"
|
||||
style={{ background: "var(--color-surface-hover)", color: "var(--color-text)", border: "2px solid var(--color-accent)" }}
|
||||
@ -546,7 +554,7 @@ export function EntryDetailModal({
|
||||
) : field.type === "boolean" ? (
|
||||
<select
|
||||
value={editValue}
|
||||
onChange={(e) => { setEditValue(e.target.value); handleSaveField(field.name, e.target.value); }}
|
||||
onChange={(e) => { setEditValue(e.target.value); void handleSaveField(field.name, e.target.value); }}
|
||||
autoFocus
|
||||
className="flex-1 px-2 py-1 text-sm rounded-lg outline-none"
|
||||
style={{ background: "var(--color-surface-hover)", color: "var(--color-text)", border: "2px solid var(--color-accent)" }}
|
||||
@ -580,7 +588,7 @@ export function EntryDetailModal({
|
||||
onClick={() => {
|
||||
if (!["user"].includes(field.type)) {
|
||||
setEditingField(field.name);
|
||||
setEditValue(String(value ?? ""));
|
||||
setEditValue(safeString(value));
|
||||
}
|
||||
}}
|
||||
title={!["user"].includes(field.type) ? "Click to edit" : undefined}
|
||||
@ -628,10 +636,10 @@ export function EntryDetailModal({
|
||||
style={{ borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{data.entry.created_at != null && (
|
||||
<span>Created: {String(data.entry.created_at as string)}</span>
|
||||
<span>Created: {safeString(data.entry.created_at)}</span>
|
||||
)}
|
||||
{data.entry.updated_at != null && (
|
||||
<span>Updated: {String(data.entry.updated_at as string)}</span>
|
||||
<span>Updated: {safeString(data.entry.updated_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -170,7 +170,7 @@ export function MarkdownEditor({
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
insertUploadedImages(imageFiles);
|
||||
void insertUploadedImages(imageFiles);
|
||||
};
|
||||
|
||||
// Also prevent dragover so the browser doesn't hijack the drop
|
||||
@ -190,7 +190,7 @@ export function MarkdownEditor({
|
||||
if (imageFiles.length > 0) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
insertUploadedImages(imageFiles);
|
||||
void insertUploadedImages(imageFiles);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -312,7 +312,7 @@ export function MarkdownEditor({
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
void handleSave();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
@ -369,7 +369,7 @@ export function MarkdownEditor({
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving || !isDirty}
|
||||
className="editor-save-button"
|
||||
>
|
||||
|
||||
@ -43,6 +43,15 @@ type ObjectKanbanProps = {
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
/** Safely convert unknown (DuckDB) value to string for display. */
|
||||
function safeString(val: unknown): string {
|
||||
if (val == null) {return "";}
|
||||
if (typeof val === "object") {return JSON.stringify(val);}
|
||||
if (typeof val === "string") {return val;}
|
||||
if (typeof val === "number" || typeof val === "boolean" || typeof val === "bigint") {return String(val);}
|
||||
return "";
|
||||
}
|
||||
|
||||
function parseRelationValue(value: string | null | undefined): string[] {
|
||||
if (!value) {return [];}
|
||||
const trimmed = value.trim();
|
||||
@ -65,8 +74,8 @@ function getEntryTitle(entry: Record<string, unknown>, fields: Field[]): string
|
||||
f.name.toLowerCase().includes("title"),
|
||||
);
|
||||
return titleField
|
||||
? String(entry[titleField.name] ?? "Untitled")
|
||||
: String(entry[fields[0]?.name] ?? "Untitled");
|
||||
? safeString(entry[titleField.name]) || "Untitled"
|
||||
: safeString(entry[fields[0]?.name]) || "Untitled";
|
||||
}
|
||||
|
||||
// --- Draggable Card ---
|
||||
@ -84,7 +93,7 @@ function DraggableCard({
|
||||
relationLabels?: Record<string, Record<string, string>>;
|
||||
onEntryClick?: (entryId: string) => void;
|
||||
}) {
|
||||
const entryId = String(entry.entry_id ?? "");
|
||||
const entryId = safeString(entry.entry_id) || "";
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: entryId,
|
||||
data: { entry },
|
||||
@ -167,7 +176,7 @@ function CardContent({
|
||||
const val = entry[field.name];
|
||||
if (!val) {return null;}
|
||||
|
||||
let displayVal = String(val);
|
||||
let displayVal = safeString(val);
|
||||
if (field.type === "user") {
|
||||
const member = members?.find((m) => m.id === displayVal);
|
||||
if (member) {displayVal = member.name;}
|
||||
@ -185,7 +194,7 @@ function CardContent({
|
||||
</span>
|
||||
{field.type === "enum" ? (
|
||||
<EnumBadgeMini
|
||||
value={String(val)}
|
||||
value={safeString(val)}
|
||||
enumValues={field.enum_values}
|
||||
enumColors={field.enum_colors}
|
||||
/>
|
||||
@ -349,7 +358,7 @@ function DroppableColumn({
|
||||
onChange={(e) => setNameValue(e.target.value)}
|
||||
onBlur={handleRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {handleRename();}
|
||||
if (e.key === "Enter") {void handleRename();}
|
||||
if (e.key === "Escape") {
|
||||
setNameValue(columnName);
|
||||
setEditingName(false);
|
||||
@ -403,7 +412,7 @@ function DroppableColumn({
|
||||
) : (
|
||||
items.map((entry, idx) => (
|
||||
<DraggableCard
|
||||
key={String(entry.entry_id ?? idx)}
|
||||
key={safeString(entry.entry_id) || String(idx)}
|
||||
entry={entry}
|
||||
fields={cardFields}
|
||||
members={members}
|
||||
@ -473,7 +482,7 @@ export function ObjectKanban({
|
||||
const unique = new Set<string>();
|
||||
for (const e of localEntries) {
|
||||
const val = groupField ? e[groupField.name] : undefined;
|
||||
if (val) {unique.add(String(val));}
|
||||
if (val) {unique.add(safeString(val));}
|
||||
}
|
||||
return Array.from(unique).map((v) => ({ name: v, color: "#94a3b8" }));
|
||||
}, [statuses, groupField, localEntries]);
|
||||
@ -485,7 +494,7 @@ export function ObjectKanban({
|
||||
groups["_ungrouped"] = [];
|
||||
|
||||
for (const entry of localEntries) {
|
||||
const val = groupField ? String(entry[groupField.name] ?? "") : "";
|
||||
const val = groupField ? safeString(entry[groupField.name]) : "";
|
||||
if (groups[val]) {
|
||||
groups[val].push(entry);
|
||||
} else {
|
||||
@ -535,7 +544,7 @@ export function ObjectKanban({
|
||||
const entry = localEntries.find((e) => String(e.entry_id) === entryId);
|
||||
if (!entry) {return;}
|
||||
|
||||
const currentValue = String(entry[groupField.name] ?? "");
|
||||
const currentValue = safeString(entry[groupField.name]);
|
||||
if (currentValue === targetColumn) {return;}
|
||||
|
||||
// Optimistic update
|
||||
@ -658,7 +667,7 @@ export function ObjectKanban({
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{grouped["_ungrouped"].map((entry, idx) => (
|
||||
<DraggableCard
|
||||
key={String(entry.entry_id ?? idx)}
|
||||
key={safeString(entry.entry_id) || String(idx)}
|
||||
entry={entry}
|
||||
fields={cardFields}
|
||||
members={members}
|
||||
|
||||
@ -44,6 +44,16 @@ type EntryRow = Record<string, unknown> & { entry_id?: string };
|
||||
|
||||
/* ─── Helpers ─── */
|
||||
|
||||
/** Safely convert unknown (DuckDB) value to string for display. */
|
||||
function safeString(val: unknown): string {
|
||||
if (val == null) {return "";}
|
||||
if (typeof val === "object") {return JSON.stringify(val);}
|
||||
if (typeof val === "string") {return val;}
|
||||
if (typeof val === "number" || typeof val === "boolean" || typeof val === "bigint") {return String(val);}
|
||||
// symbol, function
|
||||
return "";
|
||||
}
|
||||
|
||||
function parseRelationValue(value: string | null | undefined): string[] {
|
||||
if (!value) {return [];}
|
||||
const trimmed = value.trim();
|
||||
@ -169,13 +179,13 @@ function EditableCell({
|
||||
onSaved?: () => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [localValue, setLocalValue] = useState(String(initialValue ?? ""));
|
||||
const [localValue, setLocalValue] = useState(safeString(initialValue));
|
||||
const inputRef = useRef<HTMLInputElement | HTMLSelectElement>(null);
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Sync with prop changes
|
||||
useEffect(() => {
|
||||
if (!editing) {setLocalValue(String(initialValue ?? ""));}
|
||||
if (!editing) {setLocalValue(safeString(initialValue));}
|
||||
}, [initialValue, editing]);
|
||||
|
||||
// Focus input on edit start
|
||||
@ -205,19 +215,19 @@ function EditableCell({
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
if (saveTimerRef.current) { clearTimeout(saveTimerRef.current); save(localValue); }
|
||||
if (saveTimerRef.current) { clearTimeout(saveTimerRef.current); void save(localValue); }
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") { handleBlur(); }
|
||||
if (e.key === "Escape") { setEditing(false); setLocalValue(String(initialValue ?? "")); }
|
||||
if (e.key === "Escape") { setEditing(false); setLocalValue(safeString(initialValue)); }
|
||||
};
|
||||
|
||||
// Read-only display for non-editable types
|
||||
if (!isEditable) {
|
||||
if (field.type === "user") {return <UserCell value={initialValue} members={members} />;}
|
||||
return <span className="truncate block max-w-[300px]">{String(initialValue ?? "")}</span>;
|
||||
return <span className="truncate block max-w-[300px]">{safeString(initialValue)}</span>;
|
||||
}
|
||||
|
||||
// Editing mode — Excel-style seamless inline editing
|
||||
@ -234,9 +244,9 @@ function EditableCell({
|
||||
>
|
||||
<RelationSelect
|
||||
relatedObjectName={field.related_object_name!}
|
||||
value={String(initialValue ?? "")}
|
||||
value={safeString(initialValue)}
|
||||
multiple={field.relationship_type === "many_to_many"}
|
||||
onChange={(v) => { save(v); setEditing(false); }}
|
||||
onChange={(v) => { void save(v); setEditing(false); }}
|
||||
variant="inline"
|
||||
autoFocus
|
||||
/>
|
||||
@ -327,17 +337,17 @@ function EditableCell({
|
||||
{displayValue === null || displayValue === undefined || displayValue === "" ? (
|
||||
<span style={{ color: "var(--color-text-muted)", opacity: 0.5 }}>--</span>
|
||||
) : field.type === "enum" ? (
|
||||
<EnumBadge value={String(displayValue)} enumValues={field.enum_values} enumColors={field.enum_colors} />
|
||||
<EnumBadge value={safeString(displayValue)} enumValues={field.enum_values} enumColors={field.enum_colors} />
|
||||
) : field.type === "boolean" ? (
|
||||
<BooleanCell value={displayValue} />
|
||||
) : field.type === "email" ? (
|
||||
<a href={`mailto:${displayValue}`} className="underline underline-offset-2" style={{ color: "var(--color-accent)" }} onClick={(e) => e.stopPropagation()}>
|
||||
{String(displayValue)}
|
||||
<a href={`mailto:${safeString(displayValue)}`} className="underline underline-offset-2" style={{ color: "var(--color-accent)" }} onClick={(e) => e.stopPropagation()}>
|
||||
{safeString(displayValue)}
|
||||
</a>
|
||||
) : field.type === "number" ? (
|
||||
<span className="tabular-nums">{String(displayValue)}</span>
|
||||
<span className="tabular-nums">{safeString(displayValue)}</span>
|
||||
) : (
|
||||
<span className="truncate block max-w-[300px]">{String(displayValue)}</span>
|
||||
<span className="truncate block max-w-[300px]">{safeString(displayValue)}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@ -380,12 +390,13 @@ export function ObjectTable({
|
||||
</span>
|
||||
),
|
||||
cell: (info: CellContext<EntryRow, unknown>) => {
|
||||
const entryId = String(info.row.original.entry_id ?? "");
|
||||
const eid = info.row.original.entry_id;
|
||||
const entryId = String(eid != null && typeof eid === "object" ? JSON.stringify(eid) : (eid ?? ""));
|
||||
|
||||
// First column (sticky): bold link that opens the entry detail modal
|
||||
if (fieldIdx === 0 && onEntryClick) {
|
||||
const val = info.getValue();
|
||||
const displayVal = val === null || val === undefined || val === "" ? "--" : String(val);
|
||||
const displayVal = val === null || val === undefined || val === "" ? "--" : safeString(val);
|
||||
const isEmpty = displayVal === "--";
|
||||
return (
|
||||
<span
|
||||
@ -433,7 +444,8 @@ export function ObjectTable({
|
||||
</span>
|
||||
),
|
||||
cell: (info: CellContext<EntryRow, unknown>) => {
|
||||
const entryId = String(info.row.original.entry_id ?? "");
|
||||
const eid = info.row.original.entry_id;
|
||||
const entryId = String(eid != null && typeof eid === "object" ? JSON.stringify(eid) : (eid ?? ""));
|
||||
const links = rr.entries[entryId] ?? [];
|
||||
return <ReverseRelationCell links={links} sourceObjectName={rr.sourceObjectName} onNavigate={onNavigateToObject} />;
|
||||
},
|
||||
@ -454,7 +466,7 @@ export function ObjectTable({
|
||||
const handleBulkDelete = useCallback(async () => {
|
||||
const selectedIds = Object.keys(rowSelection)
|
||||
.filter((k) => rowSelection[k])
|
||||
.map((idx) => String(entries[Number(idx)]?.entry_id ?? ""))
|
||||
.map((idx) => safeString(entries[Number(idx)]?.entry_id))
|
||||
.filter(Boolean);
|
||||
|
||||
if (selectedIds.length === 0) {return;}
|
||||
@ -473,7 +485,8 @@ export function ObjectTable({
|
||||
|
||||
// Single delete handler
|
||||
const handleDeleteEntry = useCallback(async (entry: EntryRow) => {
|
||||
const entryId = String(entry.entry_id ?? "");
|
||||
const eid = entry.entry_id;
|
||||
const entryId = String(eid != null && typeof eid === "object" ? JSON.stringify(eid) : (eid ?? ""));
|
||||
if (!entryId) {return;}
|
||||
if (!confirm("Delete this entry?")) {return;}
|
||||
try {
|
||||
@ -485,8 +498,8 @@ export function ObjectTable({
|
||||
}, [objectName, onRefresh]);
|
||||
|
||||
// Row actions
|
||||
const getRowActions = useCallback(
|
||||
(row: EntryRow): RowAction<EntryRow>[] => {
|
||||
const getRowActions = useCallback(
|
||||
(_row: EntryRow): RowAction<EntryRow>[] => {
|
||||
const actions: RowAction<EntryRow>[] = [];
|
||||
if (onEntryClick) {
|
||||
actions.push({
|
||||
@ -529,7 +542,7 @@ export function ObjectTable({
|
||||
const bulkActions = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBulkDelete}
|
||||
onClick={() => void handleBulkDelete()}
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium"
|
||||
style={{ background: "rgba(220, 38, 38, 0.08)", color: "var(--color-error)", border: "1px solid rgba(220, 38, 38, 0.2)" }}
|
||||
>
|
||||
@ -655,7 +668,7 @@ function AddEntryModal({
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); handleSave(); }}
|
||||
onSubmit={(e) => { e.preventDefault(); void handleSave(); }}
|
||||
className="flex-1 overflow-y-auto px-6 py-5 space-y-4"
|
||||
>
|
||||
{fields.map((field) => {
|
||||
@ -778,7 +791,7 @@ function AddEntryModal({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg"
|
||||
style={{ background: "var(--color-accent)", color: "white", opacity: saving ? 0.7 : 1 }}
|
||||
|
||||
@ -76,7 +76,7 @@ export function RelationSelect({
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchOptions(search);
|
||||
void fetchOptions(search);
|
||||
}
|
||||
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@ -134,7 +134,8 @@ export function RelationSelect({
|
||||
// Find labels for currently selected IDs (from loaded options, fallback to ID)
|
||||
const selectedLabels = selectedIds.map((id) => {
|
||||
const opt = options.find((o) => o.id === id);
|
||||
return { id, label: opt?.label ?? id };
|
||||
const rawLabel = opt?.label ?? id;
|
||||
return { id, label: String(rawLabel != null && typeof rawLabel === "object" ? JSON.stringify(rawLabel) : (rawLabel ?? "")) };
|
||||
});
|
||||
|
||||
const isInline = variant === "inline";
|
||||
@ -162,7 +163,7 @@ export function RelationSelect({
|
||||
border: "1px solid rgba(96, 165, 250, 0.2)",
|
||||
}}
|
||||
>
|
||||
<span className="truncate max-w-[160px]">{label}</span>
|
||||
<span className="truncate max-w-[160px]">{String(label ?? "")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); removeId(id); }}
|
||||
@ -269,7 +270,7 @@ export function RelationSelect({
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="truncate">{opt.label}</span>
|
||||
<span className="truncate">{String(opt.label != null && typeof opt.label === "object" ? JSON.stringify(opt.label) : (opt.label ?? ""))}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
|
||||
@ -502,7 +502,7 @@ function createSuggestionRenderer() {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
|
||||
import("react-dom/client").then(({ createRoot }) => {
|
||||
void import("react-dom/client").then(({ createRoot }) => {
|
||||
root = createRoot(container!);
|
||||
root.render(
|
||||
<SlashPopupRenderer
|
||||
|
||||
@ -33,28 +33,12 @@ type WorkspaceSidebarProps = {
|
||||
onGoToChat?: () => void;
|
||||
/** Called when a tree node is dragged and dropped onto an external target (e.g. chat input). */
|
||||
onExternalDrop?: (node: TreeNode) => void;
|
||||
/** When true, renders as a mobile overlay drawer instead of a static sidebar. */
|
||||
mobile?: boolean;
|
||||
/** Close the mobile drawer. */
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
function WorkspaceLogo() {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect width="7" height="7" x="3" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="14" rx="1" />
|
||||
<rect width="7" height="7" x="3" y="14" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeIcon() {
|
||||
return (
|
||||
<svg
|
||||
@ -407,14 +391,16 @@ export function WorkspaceSidebar({
|
||||
workspaceRoot,
|
||||
onGoToChat,
|
||||
onExternalDrop,
|
||||
mobile,
|
||||
onClose,
|
||||
}: WorkspaceSidebarProps) {
|
||||
const isBrowsing = browseDir != null;
|
||||
|
||||
return (
|
||||
const sidebar = (
|
||||
<aside
|
||||
className="flex flex-col h-screen border-r flex-shrink-0"
|
||||
className={`flex flex-col h-screen flex-shrink-0 ${mobile ? "drawer-left" : "border-r"}`}
|
||||
style={{
|
||||
width: "260px",
|
||||
width: mobile ? "280px" : "260px",
|
||||
background: "var(--color-surface)",
|
||||
borderColor: "var(--color-border)",
|
||||
}}
|
||||
@ -470,7 +456,7 @@ export function WorkspaceSidebar({
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onGoToChat}
|
||||
onClick={() => void onGoToChat?.()}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 cursor-pointer transition-opacity"
|
||||
style={{
|
||||
background: "var(--color-accent-light)",
|
||||
@ -556,4 +542,15 @@ export function WorkspaceSidebar({
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
if (!mobile) { return sidebar; }
|
||||
|
||||
return (
|
||||
<div className="drawer-backdrop" onClick={() => void onClose?.()}>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div onClick={(e) => e.stopPropagation()} className="fixed inset-y-0 left-0 z-50">
|
||||
{sidebar}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1243,3 +1243,44 @@ a,
|
||||
.code-viewer-highlighted .line:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Mobile Drawer & Overlay
|
||||
============================================================ */
|
||||
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(2px);
|
||||
animation: drawer-fade-in 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes drawer-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes drawer-slide-left {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes drawer-slide-right {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
.drawer-left {
|
||||
animation: drawer-slide-left 200ms ease-out;
|
||||
}
|
||||
|
||||
.drawer-right {
|
||||
animation: drawer-slide-right 200ms ease-out;
|
||||
}
|
||||
|
||||
/* Prevent horizontal overflow on mobile */
|
||||
html, body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
21
apps/web/app/hooks/use-mobile.ts
Normal file
21
apps/web/app/hooks/use-mobile.ts
Normal file
@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Returns true when the viewport is narrower than `breakpoint` (default 768px).
|
||||
* Uses `matchMedia` for efficiency; falls back to `false` during SSR.
|
||||
*/
|
||||
export function useIsMobile(breakpoint = 768): boolean {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
|
||||
const update = () => setIsMobile(mql.matches);
|
||||
update();
|
||||
mql.addEventListener("change", update);
|
||||
return () => mql.removeEventListener("change", update);
|
||||
}, [breakpoint]);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@ -7,6 +7,12 @@ export const metadata: Metadata = {
|
||||
"AI Workspace with an agent that connects to your apps and does the work for you",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
|
||||
@ -87,10 +87,10 @@ export default function Home() {
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="relative flex flex-col items-center justify-center min-h-screen bg-stone-50 overflow-hidden">
|
||||
{/* Claw slash marks as full background */}
|
||||
<div className="relative flex flex-col items-center justify-center min-h-screen bg-stone-50 overflow-hidden px-4">
|
||||
{/* Claw slash marks as full background — hidden on small screens */}
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center select-none pointer-events-none text-stone-200/40"
|
||||
className="absolute inset-0 hidden md:flex items-center justify-center select-none pointer-events-none text-stone-200/40"
|
||||
style={{
|
||||
fontFamily: '"SF Mono", "Fira Code", "Fira Mono", "Roboto Mono", "Courier New", monospace',
|
||||
whiteSpace: "pre",
|
||||
@ -103,12 +103,15 @@ export default function Home() {
|
||||
|
||||
{/* Foreground content */}
|
||||
<div className="relative z-10 flex flex-col items-center">
|
||||
<div className="ascii-banner select-none" aria-label="IRONCLAW">
|
||||
<div className="ascii-banner select-none hidden sm:block" aria-label="IRONCLAW">
|
||||
{IRONCLAW_ASCII.join("\n")}
|
||||
</div>
|
||||
<h1 className="sm:hidden text-3xl font-bold text-stone-600" style={{ fontFamily: "monospace" }}>
|
||||
IRONCLAW
|
||||
</h1>
|
||||
<Link
|
||||
href="/workspace"
|
||||
className="mt-10 text-lg text-stone-400 hover:text-stone-600 transition-all"
|
||||
className="mt-10 text-lg text-stone-400 hover:text-stone-600 transition-all min-h-[44px] flex items-center"
|
||||
style={{ fontFamily: "monospace" }}
|
||||
>
|
||||
enter the app →
|
||||
|
||||
@ -24,6 +24,7 @@ import { isCodeFile } from "@/lib/report-utils";
|
||||
import { CronDashboard } from "../components/cron/cron-dashboard";
|
||||
import { CronJobDetail } from "../components/cron/cron-job-detail";
|
||||
import type { CronJob, CronJobsResponse } from "../types/cron";
|
||||
import { useIsMobile } from "../hooks/use-mobile";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@ -248,6 +249,11 @@ function WorkspacePageInner() {
|
||||
entryId: string;
|
||||
} | null>(null);
|
||||
|
||||
// Mobile responsive state
|
||||
const isMobile = useIsMobile();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [chatSessionsOpen, setChatSessionsOpen] = useState(false);
|
||||
|
||||
// Derive file context for chat sidebar directly from activePath (stable across loading).
|
||||
// Exclude reserved virtual paths (~chats, ~cron, etc.) where file-scoped chat is irrelevant.
|
||||
const fileContext = useMemo(() => {
|
||||
@ -833,28 +839,106 @@ function WorkspacePageInner() {
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div className="flex h-screen" style={{ background: "var(--color-bg)" }} onClick={handleContainerClick}>
|
||||
{/* Sidebar */}
|
||||
<WorkspaceSidebar
|
||||
tree={enhancedTree}
|
||||
activePath={activePath}
|
||||
onSelect={handleNodeSelect}
|
||||
onRefresh={refreshTree}
|
||||
orgName={context?.organization?.name}
|
||||
loading={treeLoading}
|
||||
browseDir={browseDir}
|
||||
parentDir={effectiveParentDir}
|
||||
onNavigateUp={handleNavigateUp}
|
||||
onGoHome={handleGoHome}
|
||||
onFileSearchSelect={handleFileSearchSelect}
|
||||
workspaceRoot={workspaceRoot}
|
||||
onGoToChat={handleGoToChat}
|
||||
onExternalDrop={handleSidebarExternalDrop}
|
||||
/>
|
||||
{/* Sidebar — static on desktop, drawer overlay on mobile */}
|
||||
{isMobile ? (
|
||||
sidebarOpen && (
|
||||
<WorkspaceSidebar
|
||||
tree={enhancedTree}
|
||||
activePath={activePath}
|
||||
onSelect={(node) => { handleNodeSelect(node); setSidebarOpen(false); }}
|
||||
onRefresh={refreshTree}
|
||||
orgName={context?.organization?.name}
|
||||
loading={treeLoading}
|
||||
browseDir={browseDir}
|
||||
parentDir={effectiveParentDir}
|
||||
onNavigateUp={handleNavigateUp}
|
||||
onGoHome={handleGoHome}
|
||||
onFileSearchSelect={(item) => { handleFileSearchSelect?.(item); setSidebarOpen(false); }}
|
||||
workspaceRoot={workspaceRoot}
|
||||
onGoToChat={() => { handleGoToChat(); setSidebarOpen(false); }}
|
||||
onExternalDrop={handleSidebarExternalDrop}
|
||||
mobile
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<WorkspaceSidebar
|
||||
tree={enhancedTree}
|
||||
activePath={activePath}
|
||||
onSelect={handleNodeSelect}
|
||||
onRefresh={refreshTree}
|
||||
orgName={context?.organization?.name}
|
||||
loading={treeLoading}
|
||||
browseDir={browseDir}
|
||||
parentDir={effectiveParentDir}
|
||||
onNavigateUp={handleNavigateUp}
|
||||
onGoHome={handleGoHome}
|
||||
onFileSearchSelect={handleFileSearchSelect}
|
||||
workspaceRoot={workspaceRoot}
|
||||
onGoToChat={handleGoToChat}
|
||||
onExternalDrop={handleSidebarExternalDrop}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
{/* When a file is selected: show top bar with breadcrumbs */}
|
||||
{activePath && content.kind !== "none" && (
|
||||
{/* Mobile top bar — always visible on mobile */}
|
||||
{isMobile && (
|
||||
<div
|
||||
className="px-3 py-2 border-b flex-shrink-0 flex items-center justify-between gap-2"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="p-2 rounded-lg flex-shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Open sidebar"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="4" x2="20" y1="12" y2="12" /><line x1="4" x2="20" y1="6" y2="6" /><line x1="4" x2="20" y1="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex-1 min-w-0 text-sm font-medium truncate" style={{ color: "var(--color-text)" }}>
|
||||
{activePath ? activePath.split("/").pop() : (context?.organization?.name || "Workspace")}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{activePath && content.kind !== "none" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActivePath(null);
|
||||
setContent({ kind: "none" });
|
||||
router.replace("/workspace", { scroll: false });
|
||||
}}
|
||||
className="p-2 rounded-lg flex-shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Back to chat"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m12 19-7-7 7-7" /><path d="M19 12H5" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{showMainChat && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChatSessionsOpen(true)}
|
||||
className="p-2 rounded-lg flex-shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Chat sessions"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* When a file is selected: show top bar with breadcrumbs (desktop only, mobile has unified top bar) */}
|
||||
{!isMobile && activePath && content.kind !== "none" && (
|
||||
<div
|
||||
className="px-6 border-b flex-shrink-0 flex items-center justify-between"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
@ -915,23 +999,48 @@ function WorkspacePageInner() {
|
||||
setActiveSessionId(id);
|
||||
}}
|
||||
onSessionsChange={refreshSessions}
|
||||
compact={isMobile}
|
||||
/>
|
||||
</div>
|
||||
<ChatSessionsSidebar
|
||||
sessions={sessions}
|
||||
activeSessionId={activeSessionId}
|
||||
activeSessionTitle={activeSessionTitle}
|
||||
streamingSessionIds={streamingSessionIds}
|
||||
onSelectSession={(sessionId) => {
|
||||
setActiveSessionId(sessionId);
|
||||
void chatRef.current?.loadSession(sessionId);
|
||||
}}
|
||||
onNewSession={() => {
|
||||
setActiveSessionId(null);
|
||||
void chatRef.current?.newSession();
|
||||
router.replace("/workspace", { scroll: false });
|
||||
}}
|
||||
/>
|
||||
{/* Chat sessions sidebar — static on desktop, drawer overlay on mobile */}
|
||||
{isMobile ? (
|
||||
chatSessionsOpen && (
|
||||
<ChatSessionsSidebar
|
||||
sessions={sessions}
|
||||
activeSessionId={activeSessionId}
|
||||
activeSessionTitle={activeSessionTitle}
|
||||
streamingSessionIds={streamingSessionIds}
|
||||
onSelectSession={(sessionId) => {
|
||||
setActiveSessionId(sessionId);
|
||||
void chatRef.current?.loadSession(sessionId);
|
||||
}}
|
||||
onNewSession={() => {
|
||||
setActiveSessionId(null);
|
||||
void chatRef.current?.newSession();
|
||||
router.replace("/workspace", { scroll: false });
|
||||
setChatSessionsOpen(false);
|
||||
}}
|
||||
mobile
|
||||
onClose={() => setChatSessionsOpen(false)}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<ChatSessionsSidebar
|
||||
sessions={sessions}
|
||||
activeSessionId={activeSessionId}
|
||||
activeSessionTitle={activeSessionTitle}
|
||||
streamingSessionIds={streamingSessionIds}
|
||||
onSelectSession={(sessionId) => {
|
||||
setActiveSessionId(sessionId);
|
||||
void chatRef.current?.loadSession(sessionId);
|
||||
}}
|
||||
onNewSession={() => {
|
||||
setActiveSessionId(null);
|
||||
void chatRef.current?.newSession();
|
||||
router.replace("/workspace", { scroll: false });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@ -957,8 +1066,8 @@ function WorkspacePageInner() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chat sidebar (file/folder-scoped) — hidden for reserved paths */}
|
||||
{fileContext && showChatSidebar && (
|
||||
{/* Chat sidebar (file/folder-scoped) — hidden for reserved paths, hidden on mobile */}
|
||||
{!isMobile && fileContext && showChatSidebar && (
|
||||
<aside
|
||||
className="flex-shrink-0 border-l"
|
||||
style={{
|
||||
|
||||
@ -171,7 +171,7 @@ describe("formatChartValue", () => {
|
||||
});
|
||||
|
||||
it("formats floats to 2 decimal places", () => {
|
||||
expect(formatChartValue(3.14159)).toBe("3.14");
|
||||
expect(formatChartValue(Math.PI)).toBe("3.14");
|
||||
});
|
||||
|
||||
it("formats zero as integer", () => {
|
||||
|
||||
@ -76,17 +76,29 @@ export function panelColSpan(size?: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Safe string conversion for display (handles objects via JSON.stringify). */
|
||||
function toDisplayStr(val: unknown): string {
|
||||
if (val == null) {return "";}
|
||||
if (typeof val === "object") {return JSON.stringify(val);}
|
||||
if (typeof val === "string") {return val;}
|
||||
if (typeof val === "number" || typeof val === "boolean") {return String(val);}
|
||||
// symbol, bigint, function — val is narrowed (object already handled above)
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
return String(val);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a numeric value for chart display.
|
||||
*/
|
||||
export function formatChartValue(val: unknown): string {
|
||||
if (val === null || val === undefined) {return "";}
|
||||
if (typeof val === "object") {return JSON.stringify(val);}
|
||||
if (typeof val === "number") {
|
||||
if (Math.abs(val) >= 1_000_000) {return `${(val / 1_000_000).toFixed(1)}M`;}
|
||||
if (Math.abs(val) >= 1_000) {return `${(val / 1_000).toFixed(1)}K`;}
|
||||
return Number.isInteger(val) ? String(val) : val.toFixed(2);
|
||||
}
|
||||
return String(val);
|
||||
return toDisplayStr(val);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -94,7 +106,7 @@ export function formatChartValue(val: unknown): string {
|
||||
*/
|
||||
export function formatChartLabel(val: unknown): string {
|
||||
if (val === null || val === undefined) {return "";}
|
||||
const str = String(val);
|
||||
const str = toDisplayStr(val);
|
||||
if (str.length > 16 && !isNaN(Date.parse(str))) {
|
||||
return str.slice(0, 10);
|
||||
}
|
||||
|
||||
@ -73,7 +73,7 @@ export function useSearchIndex(refreshSignal?: number) {
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
fetchIndex();
|
||||
void fetchIndex();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ironclaw",
|
||||
"version": "2026.2.15-1.2",
|
||||
"version": "2026.2.15-1.4",
|
||||
"description": "AI-powered CRM platform with multi-channel agent gateway, DuckDB workspace, and knowledge management",
|
||||
"keywords": [],
|
||||
"license": "MIT",
|
||||
|
||||
@ -9,7 +9,6 @@ import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { CliBackendConfig } from "../../config/types.js";
|
||||
import type { EmbeddedContextFile } from "../pi-embedded-helpers.js";
|
||||
import { resolveCliName } from "../../cli/cli-name.js";
|
||||
import { runExec } from "../../process/exec.js";
|
||||
import { buildTtsSystemPromptHint } from "../../tts/tts.js";
|
||||
import { isRecord } from "../../utils.js";
|
||||
import { buildModelAliasLines } from "../model-alias-lines.js";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -272,8 +272,11 @@ async function agentViaGatewayStreamJson(opts: AgentCliOpts, _runtime: RuntimeEn
|
||||
// Capture runId from the first event that carries one (lifecycle/accepted).
|
||||
if (!capturedRunId) {
|
||||
const payload = evt.payload as Record<string, unknown> | undefined;
|
||||
if (payload?.runId) {
|
||||
capturedRunId = String(payload.runId);
|
||||
const rid = payload?.runId;
|
||||
if (typeof rid === "string" && rid.trim()) {
|
||||
capturedRunId = rid.trim();
|
||||
} else if (typeof rid === "number") {
|
||||
capturedRunId = String(rid);
|
||||
}
|
||||
}
|
||||
// Emit each gateway event as an NDJSON line (chat deltas, agent tool/lifecycle events).
|
||||
|
||||
@ -305,6 +305,103 @@ function appendUserTranscriptMessage(params: {
|
||||
return { ok: true, messageId, message: transcriptEntry.message };
|
||||
}
|
||||
|
||||
function collectSessionAbortPartials(params: {
|
||||
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
|
||||
chatRunBuffers: Map<string, string>;
|
||||
sessionKey: string;
|
||||
abortOrigin: AbortOrigin;
|
||||
}): AbortedPartialSnapshot[] {
|
||||
const out: AbortedPartialSnapshot[] = [];
|
||||
for (const [runId, active] of params.chatAbortControllers) {
|
||||
if (active.sessionKey !== params.sessionKey) {
|
||||
continue;
|
||||
}
|
||||
const text = params.chatRunBuffers.get(runId);
|
||||
if (!text || !text.trim()) {
|
||||
continue;
|
||||
}
|
||||
out.push({
|
||||
runId,
|
||||
sessionId: active.sessionId,
|
||||
text,
|
||||
abortOrigin: params.abortOrigin,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function persistAbortedPartials(params: {
|
||||
context: Pick<GatewayRequestContext, "logGateway">;
|
||||
sessionKey: string;
|
||||
snapshots: AbortedPartialSnapshot[];
|
||||
}) {
|
||||
if (params.snapshots.length === 0) {
|
||||
return;
|
||||
}
|
||||
const { storePath, entry } = loadSessionEntry(params.sessionKey);
|
||||
for (const snapshot of params.snapshots) {
|
||||
const sessionId = entry?.sessionId ?? snapshot.sessionId ?? snapshot.runId;
|
||||
const appended = appendAssistantTranscriptMessage({
|
||||
message: snapshot.text,
|
||||
sessionId,
|
||||
storePath,
|
||||
sessionFile: entry?.sessionFile,
|
||||
createIfMissing: true,
|
||||
idempotencyKey: `${snapshot.runId}:assistant`,
|
||||
abortMeta: {
|
||||
aborted: true,
|
||||
origin: snapshot.abortOrigin,
|
||||
runId: snapshot.runId,
|
||||
},
|
||||
});
|
||||
if (!appended.ok) {
|
||||
params.context.logGateway.warn(
|
||||
`chat.abort transcript append failed: ${appended.error ?? "unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createChatAbortOps(context: GatewayRequestContext): ChatAbortOps {
|
||||
return {
|
||||
chatAbortControllers: context.chatAbortControllers,
|
||||
chatRunBuffers: context.chatRunBuffers,
|
||||
chatDeltaSentAt: context.chatDeltaSentAt,
|
||||
chatAbortedRuns: context.chatAbortedRuns,
|
||||
removeChatRun: context.removeChatRun,
|
||||
agentRunSeq: context.agentRunSeq,
|
||||
broadcast: context.broadcast,
|
||||
nodeSendToSession: context.nodeSendToSession,
|
||||
};
|
||||
}
|
||||
|
||||
function abortChatRunsForSessionKeyWithPartials(params: {
|
||||
context: GatewayRequestContext;
|
||||
ops: ChatAbortOps;
|
||||
sessionKey: string;
|
||||
abortOrigin: AbortOrigin;
|
||||
stopReason?: string;
|
||||
}) {
|
||||
const snapshots = collectSessionAbortPartials({
|
||||
chatAbortControllers: params.context.chatAbortControllers,
|
||||
chatRunBuffers: params.context.chatRunBuffers,
|
||||
sessionKey: params.sessionKey,
|
||||
abortOrigin: params.abortOrigin,
|
||||
});
|
||||
const res = abortChatRunsForSessionKey(params.ops, {
|
||||
sessionKey: params.sessionKey,
|
||||
stopReason: params.stopReason,
|
||||
});
|
||||
if (res.aborted) {
|
||||
persistAbortedPartials({
|
||||
context: params.context,
|
||||
sessionKey: params.sessionKey,
|
||||
snapshots,
|
||||
});
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
function nextChatSeq(context: { agentRunSeq: Map<string, number> }, runId: string) {
|
||||
const next = (context.agentRunSeq.get(runId) ?? 0) + 1;
|
||||
context.agentRunSeq.set(runId, next);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user