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:
kumarabhirup 2026-02-16 00:30:13 -08:00
parent 312fb33859
commit dee323b7ad
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
50 changed files with 756 additions and 1451 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -97,7 +97,7 @@ export function ReportCard({ config }: ReportCardProps) {
}, [visiblePanels]);
useEffect(() => {
executePanels();
void executePanels();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

View File

@ -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(() => {

View File

@ -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}...`

View File

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

View File

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

View File

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

View File

@ -384,7 +384,7 @@ export function Sidebar({
setLoading(false);
}
}
load();
void load();
}, [refreshKey]);
const refreshWorkspace = useCallback(async () => {

View File

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

View File

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

View File

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

View File

@ -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("")}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -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,
}: {

View File

@ -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 &rarr;

View File

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

View File

@ -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", () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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