diff --git a/apps/web/app/api/memories/route.ts b/apps/web/app/api/memories/route.ts index 8d1cc30919a..a42a614b7b7 100644 --- a/apps/web/app/api/memories/route.ts +++ b/apps/web/app/api/memories/route.ts @@ -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"); diff --git a/apps/web/app/api/sessions/[sessionId]/route.ts b/apps/web/app/api/sessions/[sessionId]/route.ts index 0d69a5309d8..b2d4b68944e 100644 --- a/apps/web/app/api/sessions/[sessionId]/route.ts +++ b/apps/web/app/api/sessions/[sessionId]/route.ts @@ -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, diff --git a/apps/web/app/api/sessions/route.ts b/apps/web/app/api/sessions/route.ts index 418de60d145..87cae3d7697 100644 --- a/apps/web/app/api/sessions/route.ts +++ b/apps/web/app/api/sessions/route.ts @@ -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; 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, diff --git a/apps/web/app/api/workspace/copy/route.ts b/apps/web/app/api/workspace/copy/route.ts index 9623f8f2b2c..6431f3eafe4 100644 --- a/apps/web/app/api/workspace/copy/route.ts +++ b/apps/web/app/api/workspace/copy/route.ts @@ -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"; diff --git a/apps/web/app/api/workspace/move/route.ts b/apps/web/app/api/workspace/move/route.ts index 4223b260990..a2670a60240 100644 --- a/apps/web/app/api/workspace/move/route.ts +++ b/apps/web/app/api/workspace/move/route.ts @@ -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"; diff --git a/apps/web/app/api/workspace/objects/[name]/entries/[id]/route.ts b/apps/web/app/api/workspace/objects/[name]/entries/[id]/route.ts index 374ea7722d8..6a6ee61cb36 100644 --- a/apps/web/app/api/workspace/objects/[name]/entries/[id]/route.ts +++ b/apps/web/app/api/workspace/objects/[name]/entries/[id]/route.ts @@ -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; diff --git a/apps/web/app/api/workspace/objects/[name]/route.ts b/apps/web/app/api/workspace/objects/[name]/route.ts index 1c8c7381133..d51d724e2dc 100644 --- a/apps/web/app/api/workspace/objects/[name]/route.ts +++ b/apps/web/app/api/workspace/objects/[name]/route.ts @@ -171,8 +171,18 @@ function resolveRelationLabels( const entryIds = new Set(); 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); } } diff --git a/apps/web/app/api/workspace/rename/route.ts b/apps/web/app/api/workspace/rename/route.ts index e97f7f8ac8d..43066d2c316 100644 --- a/apps/web/app/api/workspace/rename/route.ts +++ b/apps/web/app/api/workspace/rename/route.ts @@ -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"; diff --git a/apps/web/app/api/workspace/suggest-files/route.ts b/apps/web/app/api/workspace/suggest-files/route.ts index 1c8d7e4788d..57bd1dfd45b 100644 --- a/apps/web/app/api/workspace/suggest-files/route.ts +++ b/apps/web/app/api/workspace/suggest-files/route.ts @@ -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); diff --git a/apps/web/app/api/workspace/upload/route.ts b/apps/web/app/api/workspace/upload/route.ts index aeabef0585b..04909bf2e62 100644 --- a/apps/web/app/api/workspace/upload/route.ts +++ b/apps/web/app/api/workspace/upload/route.ts @@ -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 }, ); } diff --git a/apps/web/app/components/chain-of-thought.tsx b/apps/web/app/components/chain-of-thought.tsx index af9ad676f18..2870b31904e 100644 --- a/apps/web/app/components/chain-of-thought.tsx +++ b/apps/web/app/components/chain-of-thought.tsx @@ -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 ( - 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 : ""} )} diff --git a/apps/web/app/components/charts/chart-panel.tsx b/apps/web/app/components/charts/chart-panel.tsx index 3c38ad49768..ecba8e87a20 100644 --- a/apps/web/app/components/charts/chart-panel.tsx +++ b/apps/web/app/components/charts/chart-panel.tsx @@ -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); diff --git a/apps/web/app/components/charts/filter-bar.tsx b/apps/web/app/components/charts/filter-bar.tsx index 7113387c346..c1d8a13715b 100644 --- a/apps/web/app/components/charts/filter-bar.tsx +++ b/apps/web/app/components/charts/filter-bar.tsx @@ -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( diff --git a/apps/web/app/components/charts/report-card.tsx b/apps/web/app/components/charts/report-card.tsx index 18ba1f3d9d9..670764e040d 100644 --- a/apps/web/app/components/charts/report-card.tsx +++ b/apps/web/app/components/charts/report-card.tsx @@ -97,7 +97,7 @@ export function ReportCard({ config }: ReportCardProps) { }, [visiblePanels]); useEffect(() => { - executePanels(); + void executePanels(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/apps/web/app/components/charts/report-viewer.tsx b/apps/web/app/components/charts/report-viewer.tsx index cd251b3cb14..72690e76e1d 100644 --- a/apps/web/app/components/charts/report-viewer.tsx +++ b/apps/web/app/components/charts/report-viewer.tsx @@ -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(() => { diff --git a/apps/web/app/components/chat-panel.tsx b/apps/web/app/components/chat-panel.tsx index ca1adbbea1b..1acf109c8d8 100644 --- a/apps/web/app/components/chat-panel.tsx +++ b/apps/web/app/components/chat-panel.tsx @@ -234,46 +234,55 @@ function AttachmentStrip({ -
-
- -
-
-

+ {af.name} { + (e.currentTarget as HTMLImageElement).style.display = "none"; }} - title={ - af.path - } + /> +

{af.name}

-

- {short} -

-
+ ) : ( +
+
+ +
+
+

+ {af.name} +

+

+ {short} +

+
+
+ )} ); })} @@ -625,7 +634,7 @@ export const ChatPanel = forwardRef( // 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( 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( 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(
{/* Header — sticky glass bar */}
( {/* Input — sticky glass bar at bottom */}
( 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( } 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 && ( -
+
( onChange={(isEmpty) => setEditorEmpty(isEmpty) } + onNativeFileDrop={uploadAndAttachNativeFiles} placeholder={ compact && fileContext ? `Ask about ${fileContext.isDirectory ? "this folder" : fileContext.filename}...` diff --git a/apps/web/app/components/cron/cron-dashboard.tsx b/apps/web/app/components/cron/cron-dashboard.tsx index 86e39b3be6e..e21a5270767 100644 --- a/apps/web/app/components/cron/cron-dashboard.tsx +++ b/apps/web/app/components/cron/cron-dashboard.tsx @@ -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]); diff --git a/apps/web/app/components/cron/cron-run-chat.tsx b/apps/web/app/components/cron/cron-run-chat.tsx index 76ac44859d9..9a50ae79d05 100644 --- a/apps/web/app/components/cron/cron-run-chat.tsx +++ b/apps/web/app/components/cron/cron-run-chat.tsx @@ -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) { diff --git a/apps/web/app/components/file-picker-modal.tsx b/apps/web/app/components/file-picker-modal.tsx index f000823c378..5bdb7862cb6 100644 --- a/apps/web/app/components/file-picker-modal.tsx +++ b/apps/web/app/components/file-picker-modal.tsx @@ -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 */}
{ diff --git a/apps/web/app/components/syntax-block.tsx b/apps/web/app/components/syntax-block.tsx index 12c07e3cd78..ea3f0a77453 100644 --- a/apps/web/app/components/syntax-block.tsx +++ b/apps/web/app/components/syntax-block.tsx @@ -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, { diff --git a/apps/web/app/components/tiptap/chat-editor.tsx b/apps/web/app/components/tiptap/chat-editor.tsx index 39c8889c3ff..a8d8fd237fd 100644 --- a/apps/web/app/components/tiptap/chat-editor.tsx +++ b/apps/web/app/components/tiptap/chat-editor.tsx @@ -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( - 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(null); @@ -261,35 +266,53 @@ export const ChatEditor = forwardRef( 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; }, }, }, diff --git a/apps/web/app/components/tiptap/file-mention-list.tsx b/apps/web/app/components/tiptap/file-mention-list.tsx index 20f2b7d7ca0..aa64ff8b767 100644 --- a/apps/web/app/components/tiptap/file-mention-list.tsx +++ b/apps/web/app/components/tiptap/file-mention-list.tsx @@ -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); }); diff --git a/apps/web/app/components/workspace/breadcrumbs.tsx b/apps/web/app/components/workspace/breadcrumbs.tsx index 26093289c13..e589f81567e 100644 --- a/apps/web/app/components/workspace/breadcrumbs.tsx +++ b/apps/web/app/components/workspace/breadcrumbs.tsx @@ -10,7 +10,7 @@ export function Breadcrumbs({ path, onNavigate }: BreadcrumbsProps) { const segments = path.split("/").filter(Boolean); return ( -
) : (
{ 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 ? ( { 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 && ( - Created: {String(data.entry.created_at as string)} + Created: {safeString(data.entry.created_at)} )} {data.entry.updated_at != null && ( - Updated: {String(data.entry.updated_at as string)} + Updated: {safeString(data.entry.updated_at)} )}
)} diff --git a/apps/web/app/components/workspace/markdown-editor.tsx b/apps/web/app/components/workspace/markdown-editor.tsx index 050de331fea..bc7f71929b0 100644 --- a/apps/web/app/components/workspace/markdown-editor.tsx +++ b/apps/web/app/components/workspace/markdown-editor.tsx @@ -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({ ); }) diff --git a/apps/web/app/components/workspace/slash-command.tsx b/apps/web/app/components/workspace/slash-command.tsx index 22456c190b6..5cf8fb159f7 100644 --- a/apps/web/app/components/workspace/slash-command.tsx +++ b/apps/web/app/components/workspace/slash-command.tsx @@ -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( 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 ( - - - - - - - ); -} - function HomeIcon() { return (
); + + if (!mobile) { return sidebar; } + + return ( +
void onClose?.()}> + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} +
e.stopPropagation()} className="fixed inset-y-0 left-0 z-50"> + {sidebar} +
+
+ ); } diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 51fd1501aaf..4634a344973 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -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; +} diff --git a/apps/web/app/hooks/use-mobile.ts b/apps/web/app/hooks/use-mobile.ts new file mode 100644 index 00000000000..2f4eeb96d5e --- /dev/null +++ b/apps/web/app/hooks/use-mobile.ts @@ -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; +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index afb0b5cfa81..2c7f6138995 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -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, }: { diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 8a5dceb87d3..ea68bd11104 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -87,10 +87,10 @@ export default function Home() { } `} -
- {/* Claw slash marks as full background */} +
+ {/* Claw slash marks as full background — hidden on small screens */}
-
+
{IRONCLAW_ASCII.join("\n")}
+

+ IRONCLAW +

enter the app → diff --git a/apps/web/app/workspace/page.tsx b/apps/web/app/workspace/page.tsx index e5f7ad62903..e514f605645 100644 --- a/apps/web/app/workspace/page.tsx +++ b/apps/web/app/workspace/page.tsx @@ -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
- {/* Sidebar */} - + {/* Sidebar — static on desktop, drawer overlay on mobile */} + {isMobile ? ( + sidebarOpen && ( + { 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)} + /> + ) + ) : ( + + )} {/* Main content */}
- {/* When a file is selected: show top bar with breadcrumbs */} - {activePath && content.kind !== "none" && ( + {/* Mobile top bar — always visible on mobile */} + {isMobile && ( +
+ +
+ {activePath ? activePath.split("/").pop() : (context?.organization?.name || "Workspace")} +
+
+ {activePath && content.kind !== "none" && ( + + )} + {showMainChat && ( + + )} +
+
+ )} + + {/* When a file is selected: show top bar with breadcrumbs (desktop only, mobile has unified top bar) */} + {!isMobile && activePath && content.kind !== "none" && (
- { - 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 && ( + { + 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)} + /> + ) + ) : ( + { + 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() { />
- {/* 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 && (