Fix file link and add lookLikeFileLink detector and click to preview

This commit is contained in:
Mark 2026-02-19 22:50:34 -08:00
parent fcbec6c4d6
commit f960ed3030
11 changed files with 135 additions and 98 deletions

View File

@ -35,7 +35,24 @@ export async function POST(req: Request) {
? rawPath.replace(/^~/, homedir())
: rawPath;
const resolved = resolve(normalize(expanded));
let resolved = resolve(normalize(expanded));
// If the file doesn't exist and looks like a bare filename, try to locate it
// using macOS Spotlight (mdfind).
if (!existsSync(resolved) && !rawPath.includes("/")) {
const found = await new Promise<string | null>((res) => {
exec(
`mdfind -name ${JSON.stringify(rawPath)} | head -1`,
(err, stdout) => {
if (err || !stdout.trim()) {res(null);}
else {res(stdout.trim().split("\n")[0]);}
},
);
});
if (found && existsSync(found)) {
resolved = found;
}
}
if (!existsSync(resolved)) {
return Response.json(

View File

@ -1,3 +1,4 @@
import { exec } from "node:child_process";
import { existsSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { basename, normalize, resolve } from "node:path";
@ -39,7 +40,24 @@ export async function GET(req: Request) {
const expandedPath = candidatePath.startsWith("~/")
? candidatePath.replace(/^~/, homedir())
: candidatePath;
const resolvedPath = resolve(normalize(expandedPath));
let resolvedPath = resolve(normalize(expandedPath));
// If the path doesn't exist and looks like a bare filename, try to locate it
// using macOS Spotlight (mdfind).
if (!existsSync(resolvedPath) && !rawPath.includes("/")) {
const found = await new Promise<string | null>((res) => {
exec(
`mdfind -name ${JSON.stringify(rawPath)} | head -1`,
(err, stdout) => {
if (err || !stdout.trim()) {res(null);}
else {res(stdout.trim().split("\n")[0]);}
},
);
});
if (found && existsSync(found)) {
resolvedPath = found;
}
}
if (!existsSync(resolvedPath)) {
return Response.json(

View File

@ -452,17 +452,28 @@ function AttachedFilesCard({ paths }: { paths: string[] }) {
function looksLikeFilePath(text: string): boolean {
const t = text.trim();
if (!t || t.length < 3 || t.length > 500) {return false;}
// Must start with a path prefix
if (!(t.startsWith("~/") || t.startsWith("/") || t.startsWith("./") || t.startsWith("../"))) {
return false;
// Full path prefix
if (t.startsWith("~/") || t.startsWith("/") || t.startsWith("./") || t.startsWith("../")) {
const afterPrefix = t.startsWith("~/") ? t.slice(2) :
t.startsWith("../") ? t.slice(3) :
t.startsWith("./") ? t.slice(2) :
t.slice(1);
return afterPrefix.includes("/") || afterPrefix.includes(".");
}
// Must have at least one path separator beyond the prefix
// (avoids matching bare `/` or standalone commands like `/bin`)
const afterPrefix = t.startsWith("~/") ? t.slice(2) :
t.startsWith("../") ? t.slice(3) :
t.startsWith("./") ? t.slice(2) :
t.slice(1);
return afterPrefix.includes("/") || afterPrefix.includes(".");
// Bare filename with a known extension (e.g. "Rachapoom-Passport.pdf")
const fileExtPattern = /\.(pdf|docx?|xlsx?|pptx?|csv|txt|rtf|pages|numbers|key|md|json|yaml|yml|toml|xml|html?|css|jsx?|tsx?|py|rb|go|rs|java|cpp|c|h|sh|sql|swift|kt|png|jpe?g|gif|webp|svg|bmp|ico|heic|tiff|mp[34]|webm|mov|avi|mkv|flv|wav|ogg|aac|flac|m4a|zip|tar|gz|dmg)$/i;
if (fileExtPattern.test(t) && !t.includes(" ")) {
return true;
}
return false;
}
/** Check if text looks like a filename (allows spaces, used for bold text). */
function looksLikeFileName(text: string): boolean {
const t = text.trim();
if (!t || t.length < 3 || t.length > 300) {return false;}
const fileExtPattern = /\.(pdf|docx?|xlsx?|pptx?|csv|txt|rtf|pages|numbers|key|md|json|yaml|yml|toml|xml|html?|css|jsx?|tsx?|py|rb|go|rs|java|cpp|c|h|sh|sql|swift|kt|png|jpe?g|gif|webp|svg|bmp|ico|heic|tiff|mp[34]|webm|mov|avi|mkv|flv|wav|ogg|aac|flac|m4a|zip|tar|gz|dmg)$/i;
return fileExtPattern.test(t);
}
/** Open a file path using the system default application. */
@ -559,7 +570,8 @@ function FilePathCode({
return (
<code
className={`inline-flex items-center gap-[0.2em] px-[0.3em] py-0 whitespace-nowrap max-w-full overflow-hidden text-ellipsis no-underline transition-colors duration-150 rounded-md text-[color:var(--color-accent)] border border-[color:var(--color-border)] bg-white/20 hover:bg-white/40 active:bg-white ${status === "opening" ? "cursor-wait opacity-70" : "cursor-pointer"}`}
className={`px-[0.3em] no-underline transition-colors duration-150 rounded-[4px] border border-[color:var(--color-border)] bg-white/20 hover:bg-white/40 active:bg-white ${status === "opening" ? "cursor-wait opacity-70" : "cursor-pointer"}`}
style={{ color: "var(--color-accent)" }}
onClick={handleClick}
onContextMenu={handleContextMenu}
title={
@ -570,30 +582,6 @@ function FilePathCode({
: "Click to open · Right-click to reveal in Finder"
}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0 opacity-60"
>
{status === "error" ? (
<>
<circle cx="12" cy="12" r="10" />
<line x1="15" x2="9" y1="9" y2="15" />
<line x1="9" x2="15" y1="9" y2="15" />
</>
) : (
<>
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
</>
)}
</svg>
{children}
</code>
);
@ -711,6 +699,22 @@ function createMarkdownComponents(
// Regular inline code
return <code {...props}>{children}</code>;
},
// Bold text — detect filenames and make them clickable
strong: ({ children, ...props }) => {
const text = typeof children === "string" ? children
: Array.isArray(children) ? children.filter((c) => typeof c === "string").join("")
: "";
if (text && looksLikeFileName(text)) {
return (
<strong {...props}>
<FilePathCode path={text} onFilePathClick={onFilePathClick}>
{children}
</FilePathCode>
</strong>
);
}
return <strong {...props}>{children}</strong>;
},
};
}

View File

@ -674,6 +674,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
// ── Message queue (messages to send after current run completes) ──
const [queuedMessages, setQueuedMessages] = useState<QueuedMessage[]>([]);
const [rawView, setRawView] = useState(false);
const filePath = fileContext?.path ?? null;
@ -1548,6 +1549,12 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
</svg>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom">
<DropdownMenuItem
onSelect={() => setRawView((v) => !v)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" /></svg>
{rawView ? "Rendered view" : "Raw view"}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => onDeleteSession(currentSessionId)}
@ -1692,7 +1699,14 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
<div
className={`${compact ? "" : "max-w-2xl mx-auto"} py-3`}
>
{messages.map((message, i) => (
{rawView ? (
<pre
className="text-xs whitespace-pre-wrap break-all font-mono p-4 rounded-xl"
style={{ color: "var(--color-text)", background: "var(--color-surface-hover)" }}
>
{JSON.stringify(messages, null, 2)}
</pre>
) : messages.map((message, i) => (
<ChatMessage
key={message.id}
message={message}

View File

@ -211,7 +211,7 @@ export function ChatSessionsSidebar({
width: typeof width === "number" ? `${width}px` : width,
minWidth: typeof width === "number" ? `${width}px` : width,
borderColor: "var(--color-border)",
background: "var(--color-surface)",
background: "var(--color-sidebar-bg)",
}}
>
{/* Scrollable list fills the sidebar; header overlays the top with blur */}
@ -414,7 +414,7 @@ export function ChatSessionsSidebar({
style={{
height: headerHeight,
borderColor: "var(--color-border)",
background: "color-mix(in srgb, var(--color-surface) 80%, transparent)",
background: "color-mix(in srgb, var(--color-sidebar-bg) 80%, transparent)",
}}
>
<div className="min-w-0 flex-1">

View File

@ -81,14 +81,15 @@ function isSystemFile(path: string): boolean {
// --- Icons (inline SVG, zero-dep) ---
function FolderIcon({ open }: { open?: boolean }) {
return open ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" />
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>
return (
<img
src={open ? "/icons/folder-open.png" : "/icons/folder.png"}
alt=""
width={16}
height={16}
draggable={false}
style={{ flexShrink: 0 }}
/>
);
}
@ -110,17 +111,13 @@ function KanbanIcon() {
function DocumentIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M10 9H8" /><path d="M16 13H8" /><path d="M16 17H8" />
</svg>
<img src="/icons/document.png" alt="" width={16} height={16} draggable={false} style={{ flexShrink: 0, filter: "drop-shadow(0 0.5px 1.5px rgba(0,0,0,0.2))" }} />
);
}
function FileIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" />
</svg>
<img src="/icons/document.png" alt="" width={16} height={16} draggable={false} style={{ flexShrink: 0, filter: "drop-shadow(0 0.5px 1.5px rgba(0,0,0,0.2))" }} />
);
}
@ -548,7 +545,7 @@ function DraggableNode({
onCancel={onCancelRename}
/>
) : (
<span className="truncate flex-1">{node.name.replace(/\.md$/, "")}</span>
<span className="truncate flex-1">{node.name}</span>
)}
{/* Workspace badge for the workspace root entry point */}

View File

@ -68,18 +68,14 @@ function HomeIcon() {
function FolderOpenIcon() {
return (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" />
</svg>
<img
src="/icons/folder-open.png"
alt=""
width={20}
height={20}
draggable={false}
style={{ flexShrink: 0 }}
/>
);
}
@ -173,25 +169,26 @@ function SearchIcon() {
function SmallFolderIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>
<img
src="/icons/folder.png"
alt=""
width={14}
height={14}
draggable={false}
style={{ flexShrink: 0 }}
/>
);
}
function SmallFileIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" />
</svg>
<img src="/icons/document.png" alt="" width={14} height={14} draggable={false} style={{ flexShrink: 0, filter: "drop-shadow(0 0.5px 1.5px rgba(0,0,0,0.2))" }} />
);
}
function SmallDocIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M10 9H8" /><path d="M16 13H8" /><path d="M16 17H8" />
</svg>
<img src="/icons/document.png" alt="" width={14} height={14} draggable={false} style={{ flexShrink: 0, filter: "drop-shadow(0 0.5px 1.5px rgba(0,0,0,0.2))" }} />
);
}
@ -417,13 +414,13 @@ export function WorkspaceSidebar({
style={{
width: typeof width === "number" ? `${width}px` : width,
minWidth: typeof width === "number" ? `${width}px` : width,
background: "var(--color-surface)",
background: "var(--color-sidebar-bg)",
borderColor: "var(--color-border)",
}}
>
{/* Header */}
<div
className="flex items-center gap-2.5 px-4 py-3 border-b"
className="flex items-center gap-2 px-3 py-2.5 border-b"
style={{ borderColor: "var(--color-border)" }}
>
{isBrowsing ? (
@ -473,16 +470,14 @@ export function WorkspaceSidebar({
<button
type="button"
onClick={() => void onGoToChat?.()}
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 cursor-pointer transition-opacity"
className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0 cursor-pointer transition-colors hover:bg-stone-200 dark:hover:bg-stone-700"
style={{
background: "var(--color-accent-light)",
color: "var(--color-accent)",
background: "transparent",
color: "var(--color-text-muted)",
}}
title="All Chats"
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.opacity = "0.7"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.opacity = "1"; }}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
@ -495,29 +490,22 @@ export function WorkspaceSidebar({
type="button"
onClick={onClick}
disabled={switching}
className="flex-1 min-w-0 w-full flex items-center justify-between gap-2 text-left rounded-lg py-1.5 px-2 transition-colors hover:bg-(--color-surface-hover) disabled:opacity-50"
style={{ color: "var(--color-text)" }}
className="flex-1 min-w-0 w-full flex items-center justify-between gap-1.5 text-left rounded-lg py-1 px-1.5 transition-colors hover:bg-stone-100 dark:hover:bg-stone-800 disabled:opacity-50"
title="Switch workspace profile"
>
<div className="min-w-0 truncate">
<div
className="text-sm font-medium truncate"
style={{ color: "var(--color-text)" }}
className="text-[13px] font-semibold truncate text-stone-700 dark:text-stone-200"
>
{orgName || "Workspace"}
</div>
<div
className="text-[11px] flex items-center gap-1 truncate"
style={{ color: "var(--color-text-muted)" }}
className="text-[11px] flex items-center gap-1 truncate text-stone-400 dark:text-stone-500"
>
<span>Ironclaw</span>
{profileName && profileName !== "default" && (
<span
className="px-1 py-0.5 rounded text-[10px] shrink-0"
style={{
background: "var(--color-accent-light)",
color: "var(--color-accent)",
}}
className="px-1 py-0.5 rounded text-[10px] shrink-0 bg-stone-200 text-stone-500 dark:bg-stone-700 dark:text-stone-400"
>
{profileName}
</span>
@ -525,11 +513,10 @@ export function WorkspaceSidebar({
</div>
</div>
<svg
className={`w-3.5 h-3.5 shrink-0 transition-transform ${isOpen ? "rotate-180" : ""}`}
className={`w-3 h-3 shrink-0 transition-transform text-stone-400 ${isOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
style={{ color: "var(--color-text-muted)" }}
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>

View File

@ -8,7 +8,7 @@
/* Background / Surface */
--color-bg: #f5f5f4;
--color-surface: #ffffff;
--color-sidebar-bg: #fafaf9;
--color-sidebar-bg: #ffffff;
--color-main-bg: rgba(250, 250, 249, 0.5);
--color-surface-hover: #f5f4f1;
--color-surface-raised: #ffffff;

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB