diff --git a/apps/app/package.json b/apps/app/package.json index 2838d2a8d..0f04d68e8 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -52,6 +52,7 @@ "jsonc-parser": "^3.2.1", "lucide-solid": "^0.562.0", "marked": "^17.0.1", + "remend": "^1.3.0", "solid-js": "^1.9.0" }, "devDependencies": { diff --git a/apps/app/pr/session-rich-file-card.png b/apps/app/pr/session-rich-file-card.png new file mode 100644 index 000000000..43130bcc4 Binary files /dev/null and b/apps/app/pr/session-rich-file-card.png differ diff --git a/apps/app/src/app/components/session/file-card.tsx b/apps/app/src/app/components/session/file-card.tsx new file mode 100644 index 000000000..cf29a2491 --- /dev/null +++ b/apps/app/src/app/components/session/file-card.tsx @@ -0,0 +1,178 @@ +import { Match, Show, Switch, createMemo, createResource } from "solid-js"; +import { + Archive, + Database, + File, + FileCode2, + FileSpreadsheet, + FileText, + FolderOpen, + ImageIcon, + PlaySquare, +} from "lucide-solid"; +import { usePlatform } from "../../context/platform"; +import { + getDefaultDesktopFileAssociation, + resolvePrimaryLocalFile, + runLocalFileAction, +} from "../../session/file-actions"; +import { + filePresentationForReference, + type FileReferenceCard, +} from "../../session/file-presentations"; +import { isTauriRuntime } from "../../utils"; + +type Props = { + reference: FileReferenceCard; + tone?: "light" | "dark"; + workspaceRoot?: string; +}; + +export default function FileCard(props: Props) { + const platform = usePlatform(); + const tone = () => props.tone ?? "light"; + const workspaceRoot = () => props.workspaceRoot ?? ""; + const presentation = createMemo(() => + filePresentationForReference({ + path: props.reference.path, + title: props.reference.title, + detail: props.reference.detail, + mime: props.reference.mime, + }), + ); + const [resolvedFile] = createResource( + () => `${props.reference.path}\u0000${workspaceRoot()}`, + async () => resolvePrimaryLocalFile(props.reference.path, workspaceRoot()), + ); + const [defaultApp] = createResource( + () => `${props.reference.path}\u0000${workspaceRoot()}`, + async () => getDefaultDesktopFileAssociation(props.reference.path, workspaceRoot()), + ); + + const surfaceClass = () => + tone() === "dark" + ? "border-gray-6 bg-gray-1/60" + : "border-gray-6/70 bg-gray-2/30"; + const chipClass = () => + tone() === "dark" + ? "bg-gray-12/10 text-gray-12/80" + : "bg-gray-1/70 text-gray-9"; + const iconWrapClass = () => + tone() === "dark" + ? "bg-gray-12/10 text-gray-12" + : "bg-gray-1 text-gray-11"; + + const openLabel = createMemo(() => { + const app = defaultApp(); + if (app?.name?.trim()) { + return `Open in ${app.name.replace(/\.app$/i, "")}`; + } + return "Open"; + }); + + const canUseDesktopActions = createMemo(() => + Boolean(isTauriRuntime() && resolvedFile()), + ); + + const openReference = async () => { + if (!resolvedFile()) return; + if (!isTauriRuntime()) { + platform.openLink(`file://${resolvedFile()}`); + return; + } + + const { openPath } = await import("@tauri-apps/plugin-opener"); + await runLocalFileAction({ + file: resolvedFile()!, + workspaceRoot: workspaceRoot(), + action: async (candidate) => { + await openPath(candidate); + }, + }); + }; + + const revealReference = async () => { + if (!resolvedFile() || !isTauriRuntime()) return; + const { openPath, revealItemInDir } = await import("@tauri-apps/plugin-opener"); + await runLocalFileAction({ + file: resolvedFile()!, + workspaceRoot: workspaceRoot(), + action: async (candidate) => { + await revealItemInDir(candidate).catch(() => openPath(candidate)); + }, + }); + }; + + const FileGlyph = () => ( + }> + + + + + + + + + + + + + + + + + + + + + + + ); + + return ( +
+
+
+ +
+
+
+
+ {presentation().title} +
+
+ {presentation().typeLabel} +
+
+ +
{presentation().detail}
+
+
+ + + + +
+
+
+
+ ); +} diff --git a/apps/app/src/app/components/session/message-content.tsx b/apps/app/src/app/components/session/message-content.tsx new file mode 100644 index 000000000..800af91e1 --- /dev/null +++ b/apps/app/src/app/components/session/message-content.tsx @@ -0,0 +1,611 @@ +import { + For, + Match, + Show, + Switch, + createEffect, + createMemo, + createSignal, + onCleanup, +} from "solid-js"; +import { marked } from "marked"; +import type { Part } from "@opencode-ai/sdk/v2/client"; +import { Check, Copy } from "lucide-solid"; +import type { ArtifactItem } from "../../types"; +import { usePlatform } from "../../context/platform"; +import { splitSessionContentBlocks, type SessionContentBlock } from "../../session/content-blocks"; +import { + dedupeFileReferenceCards, + fileReferenceCardFromArtifact, + type FileReferenceCard, +} from "../../session/file-presentations"; +import { + normalizeFilePath, + parseLinkFromToken, + splitTextTokens, +} from "../../session/file-links"; +import { perfNow, recordPerfLog } from "../../lib/perf-log"; +import { isTauriRuntime } from "../../utils"; +import FileCard from "./file-card"; + +type Props = { + part: Part; + developerMode?: boolean; + tone?: "light" | "dark"; + workspaceRoot?: string; + renderMarkdown?: boolean; + markdownThrottleMs?: number; + highlightQuery?: string; + artifacts?: ArtifactItem[]; +}; + +function escapeHtml(value: string) { + return value.replace(/&/g, "&").replace(//g, ">"); +} + +function useThrottledValue(value: () => T, delayMs: number | (() => number) = 48) { + const [state, setState] = createSignal(value()); + let timer: ReturnType | undefined; + let hasEmitted = false; + + createEffect(() => { + const next = value(); + const delay = typeof delayMs === "function" ? delayMs() : delayMs; + if (!delay || !hasEmitted) { + hasEmitted = true; + setState(() => next); + return; + } + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + setState(() => next); + timer = undefined; + }, delay); + }); + + onCleanup(() => { + if (timer) clearTimeout(timer); + }); + + return state; +} + +const MARKDOWN_CACHE_MAX_ENTRIES = 300; +const LARGE_TEXT_COLLAPSE_CHAR_THRESHOLD = 12_000; +const LARGE_TEXT_PREVIEW_CHARS = 3_200; +const SEARCH_HIGHLIGHT_MARK_ATTR = "data-openwork-highlight"; +const markdownHtmlCache = new Map(); +const expandedLargeTextPartIds = new Set(); +const rendererByTone = new Map<"light" | "dark", ReturnType>(); + +function markdownCacheKey(tone: "light" | "dark", text: string) { + return `${tone}\u0000${text}`; +} + +function readMarkdownCache(key: string) { + const cached = markdownHtmlCache.get(key); + if (cached === undefined) return undefined; + markdownHtmlCache.delete(key); + markdownHtmlCache.set(key, cached); + return cached; +} + +function writeMarkdownCache(key: string, html: string) { + if (markdownHtmlCache.has(key)) markdownHtmlCache.delete(key); + markdownHtmlCache.set(key, html); + while (markdownHtmlCache.size > MARKDOWN_CACHE_MAX_ENTRIES) { + const oldest = markdownHtmlCache.keys().next().value; + if (!oldest) break; + markdownHtmlCache.delete(oldest); + } +} + +function rendererForTone(tone: "light" | "dark") { + const cached = rendererByTone.get(tone); + if (cached) return cached; + const next = createCustomRenderer(tone); + rendererByTone.set(tone, next); + return next; +} + +function createCustomRenderer(tone: "light" | "dark") { + const renderer = new marked.Renderer(); + const inlineCodeClass = + tone === "dark" + ? "bg-gray-12/15 text-gray-12" + : "bg-gray-2/70 text-gray-12"; + + const isSafeUrl = (url: string) => { + const normalized = (url || "").trim().toLowerCase(); + if (normalized.startsWith("javascript:")) return false; + if (normalized.startsWith("data:")) return normalized.startsWith("data:image/"); + return true; + }; + + renderer.html = ({ text }) => escapeHtml(text); + renderer.codespan = ({ text }) => { + return `${escapeHtml( + text, + )}`; + }; + renderer.link = ({ href, title, text }) => { + const safeHref = isSafeUrl(href ?? "") ? escapeHtml(href ?? "#") : "#"; + const safeTitle = title ? escapeHtml(title) : ""; + return ` + + ${text} + + `; + }; + renderer.image = ({ href, title, text }) => { + const safeHref = isSafeUrl(href ?? "") ? escapeHtml(href ?? "") : ""; + const safeTitle = title ? escapeHtml(title) : ""; + return ` + ${escapeHtml(text || + `; + }; + + return renderer; +} + +function applyTextHighlights(root: HTMLElement, query: string) { + root.querySelectorAll(`[${SEARCH_HIGHLIGHT_MARK_ATTR}]`).forEach((mark) => { + const parent = mark.parentNode; + if (!parent) return; + parent.replaceChild(document.createTextNode(mark.textContent ?? ""), mark); + parent.normalize(); + }); + + const needle = query.trim().toLowerCase(); + if (!needle) return; + + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + const parent = node.parentElement; + if (!parent) return NodeFilter.FILTER_REJECT; + if (parent.closest("script, style, button, mark")) return NodeFilter.FILTER_REJECT; + if (!node.textContent?.trim()) return NodeFilter.FILTER_REJECT; + return NodeFilter.FILTER_ACCEPT; + }, + }); + + const textNodes: Text[] = []; + let node = walker.nextNode(); + while (node) { + textNodes.push(node as Text); + node = walker.nextNode(); + } + + textNodes.forEach((textNode) => { + const text = textNode.textContent ?? ""; + const lower = text.toLowerCase(); + let searchIndex = 0; + let matchIndex = lower.indexOf(needle, searchIndex); + if (matchIndex === -1) return; + + const fragment = document.createDocumentFragment(); + while (matchIndex !== -1) { + if (matchIndex > searchIndex) { + fragment.appendChild(document.createTextNode(text.slice(searchIndex, matchIndex))); + } + const mark = document.createElement("mark"); + mark.setAttribute(SEARCH_HIGHLIGHT_MARK_ATTR, "true"); + mark.className = "rounded px-0.5 bg-amber-4/70 text-current"; + mark.textContent = text.slice(matchIndex, matchIndex + needle.length); + fragment.appendChild(mark); + searchIndex = matchIndex + needle.length; + matchIndex = lower.indexOf(needle, searchIndex); + if (matchIndex === -1) { + fragment.appendChild(document.createTextNode(text.slice(searchIndex))); + } + } + textNode.parentNode?.replaceChild(fragment, textNode); + }); +} + +function markdownHtmlForBlock(tone: "light" | "dark", raw: string, developerMode: boolean) { + const key = markdownCacheKey(tone, raw); + const cached = readMarkdownCache(key); + if (cached !== undefined) return cached; + + const startedAt = perfNow(); + const html = String( + marked.parse(raw, { + breaks: true, + gfm: true, + renderer: rendererForTone(tone), + async: false, + }) ?? "", + ); + const parseMs = Math.round((perfNow() - startedAt) * 100) / 100; + if (developerMode && (parseMs >= 8 || raw.length >= 2_500)) { + recordPerfLog(true, "session.render", "markdown-block-parse", { + chars: raw.length, + ms: parseMs, + }); + } + writeMarkdownCache(key, html); + return html; +} + +type MarkdownBlockViewProps = { + block: Extract; + tone: "light" | "dark"; + developerMode: boolean; + onLinkClick: (event: MouseEvent) => void; +}; + +function MarkdownBlockView(props: MarkdownBlockViewProps) { + const html = createMemo(() => markdownHtmlForBlock(props.tone, props.block.raw, props.developerMode)); + return ( +
+ ); +} + +function CodeBlockView(props: { block: Extract; tone: "light" | "dark" }) { + const [copied, setCopied] = createSignal(false); + let copyTimer: number | undefined; + + onCleanup(() => { + if (copyTimer !== undefined) { + window.clearTimeout(copyTimer); + } + }); + + const copyCode = async () => { + try { + await navigator.clipboard.writeText(props.block.code); + setCopied(true); + if (copyTimer !== undefined) window.clearTimeout(copyTimer); + copyTimer = window.setTimeout(() => { + setCopied(false); + copyTimer = undefined; + }, 1800); + } catch { + // ignore + } + }; + + const shellClass = () => + props.tone === "dark" + ? "border-gray-11/20 bg-gray-12/10 text-gray-12" + : "border-gray-6/70 bg-gray-1/70 text-gray-12"; + + return ( +
+
+
+ {props.block.lang || "code"} +
+ +
+
+        {props.block.code}
+      
+
+ ); +} + +export default function MessageContent(props: Props) { + const platform = usePlatform(); + const tone = () => props.tone ?? "light"; + const textClass = () => "text-gray-12"; + const part = () => props.part; + const renderMarkdown = () => props.renderMarkdown ?? false; + const markdownThrottleMs = () => Math.max(0, props.markdownThrottleMs ?? 48); + const rawText = createMemo(() => { + if (part().type !== "text") return ""; + return String((part() as { text?: string }).text ?? ""); + }); + const textPartStableId = createMemo(() => { + if (part().type !== "text") return ""; + const record = part() as { id?: string | number; messageID?: string | number }; + const partId = record.id; + if (typeof partId === "string") return partId; + if (typeof partId === "number") return String(partId); + const messageId = record.messageID; + if (typeof messageId === "string") return `msg:${messageId}`; + if (typeof messageId === "number") return `msg:${String(messageId)}`; + return ""; + }); + const isPersistedExpanded = () => { + const id = textPartStableId(); + return Boolean(id && expandedLargeTextPartIds.has(id)); + }; + const [expandedLongText, setExpandedLongText] = createSignal(isPersistedExpanded()); + createEffect(() => { + if (!isPersistedExpanded() || expandedLongText()) return; + setExpandedLongText(true); + }); + const shouldCollapseLongText = createMemo( + () => renderMarkdown() && part().type === "text" && rawText().length >= LARGE_TEXT_COLLAPSE_CHAR_THRESHOLD, + ); + const collapsedLongText = createMemo( + () => shouldCollapseLongText() && !(expandedLongText() || isPersistedExpanded()), + ); + const collapsedPreviewText = createMemo(() => { + const text = rawText(); + if (!collapsedLongText()) return text; + if (text.length <= LARGE_TEXT_PREVIEW_CHARS) return text; + return `${text.slice(0, LARGE_TEXT_PREVIEW_CHARS)}\n\n...`; + }); + const markdownSource = createMemo(() => { + if (!renderMarkdown() || part().type !== "text") return ""; + if (collapsedLongText()) return ""; + return rawText(); + }); + const throttledMarkdownSource = useThrottledValue(markdownSource, markdownThrottleMs); + let previousBlocks: SessionContentBlock[] = []; + const contentBlocks = createMemo(() => { + if (!renderMarkdown() || part().type !== "text") return [] as SessionContentBlock[]; + const text = throttledMarkdownSource(); + if (!text.trim()) { + previousBlocks = []; + return [] as SessionContentBlock[]; + } + const startedAt = perfNow(); + const next = splitSessionContentBlocks(text, previousBlocks); + previousBlocks = next; + const elapsedMs = Math.round((perfNow() - startedAt) * 100) / 100; + if ((props.developerMode ?? false) && (elapsedMs >= 6 || text.length >= 6_000)) { + recordPerfLog(true, "session.render", "content-blocks", { + chars: text.length, + blocks: next.length, + ms: elapsedMs, + }); + } + return next; + }); + const inlineFileCards = createMemo(() => + contentBlocks() + .filter((block): block is Extract => block.kind === "file") + .map((block) => ({ + id: block.id, + path: block.path, + title: block.label, + detail: block.path, + source: "text" as const, + })), + ); + const mentionedFileCards = createMemo(() => { + if (!renderMarkdown() || part().type !== "text") return []; + return splitTextTokens(rawText()) + .filter((token): token is Extract[number], { kind: "link" }> => token.kind === "link") + .filter((token) => token.type === "file") + .map((token) => ({ + id: `mention:${token.href}`, + path: token.href, + detail: token.href, + source: "text" as const, + })); + }); + const artifactCards = createMemo(() => + (props.artifacts ?? []) + .map((artifact) => fileReferenceCardFromArtifact(artifact)) + .filter((value): value is FileReferenceCard => Boolean(value)), + ); + const visibleArtifactCards = createMemo(() => + dedupeFileReferenceCards([...inlineFileCards(), ...mentionedFileCards(), ...artifactCards()]), + ); + let textContainerEl: HTMLDivElement | undefined; + + const openLink = async (href: string, type: "url" | "file") => { + if (type === "url") { + platform.openLink(href); + return; + } + + const filePath = normalizeFilePath(href, props.workspaceRoot ?? ""); + if (!filePath) return; + if (!isTauriRuntime()) { + platform.openLink(href.startsWith("file://") ? href : `file://${filePath}`); + return; + } + + const { openPath, revealItemInDir } = await import("@tauri-apps/plugin-opener"); + await revealItemInDir(filePath).catch(() => openPath(filePath)); + }; + + const openMarkdownLink = async (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + const anchor = target.closest("a"); + if (!(anchor instanceof HTMLAnchorElement)) return; + const href = anchor.getAttribute("href")?.trim(); + if (!href) return; + const link = parseLinkFromToken(href); + if (!link) return; + event.preventDefault(); + event.stopPropagation(); + await openLink(link.href, link.type); + }; + + const renderTextWithLinks = () => { + const text = rawText(); + if (!text) return {""}; + const tokens = splitTextTokens(text); + return ( + + + {(token) => + token.kind === "link" ? ( + { + event.preventDefault(); + event.stopPropagation(); + void openLink(token.href, token.type); + }} + > + {token.value} + + ) : ( + token.value + ) + } + + + ); + }; + + createEffect(() => { + const root = textContainerEl; + if (!root) return; + const query = props.highlightQuery ?? ""; + const signature = `${rawText().length}:${contentBlocks().length}:${visibleArtifactCards().length}:${query}`; + void signature; + queueMicrotask(() => { + if (!textContainerEl || textContainerEl !== root) return; + applyTextHighlights(textContainerEl, query); + }); + }); + + if (part().type === "file") { + const file = part() as { + id?: string | number; + filename?: string; + url?: string; + mime?: string; + source?: { + path?: string; + name?: string; + uri?: string; + }; + }; + const sourcePath = typeof file.source?.path === "string" ? file.source.path : ""; + const sourceUri = typeof file.source?.uri === "string" ? file.source.uri : ""; + const path = sourcePath || file.url || sourceUri; + return ( + + ); + } + + return ( +
{ + textContainerEl = el; + }} + class={textClass()} + > + +
+
+ {collapsedPreviewText()} +
+ +
+
+ + + {renderTextWithLinks()}
} + > +
+ + {(block) => ( + + + } + tone={tone()} + developerMode={props.developerMode ?? false} + onLinkClick={openMarkdownLink} + /> + + + } + tone={tone()} + /> + + +
+ ).path, + title: (block as Extract).label, + detail: (block as Extract).path, + source: "text", + }} + tone={tone()} + workspaceRoot={props.workspaceRoot} + /> +
+
+
+ )} +
+
+ + + 0}> +
+ + {(reference) => ( + + )} + +
+
+ +
+ ); +} diff --git a/apps/app/src/app/components/session/message-list.tsx b/apps/app/src/app/components/session/message-list.tsx index 4c3605304..c41072f9d 100644 --- a/apps/app/src/app/components/session/message-list.tsx +++ b/apps/app/src/app/components/session/message-list.tsx @@ -25,11 +25,12 @@ import { type StepGroupMode, } from "../../types"; import { + deriveArtifactsFromParts, groupMessageParts, isUserVisiblePart, summarizeStep, } from "../../utils"; -import PartView from "../part-view"; +import MessageContent from "./message-content"; import { perfNow, recordPerfLog } from "../../lib/perf-log"; export type MessageListProps = { @@ -61,6 +62,7 @@ type StepClusterBlock = { id: string; stepGroups: StepTimelineGroup[]; messageIds: string[]; + artifacts: MessageBlock["artifacts"]; isUser: boolean; }; @@ -74,6 +76,14 @@ type MessageBlock = { kind: "message"; message: MessageWithParts; renderableParts: Part[]; + artifacts: Array<{ + id: string; + name: string; + path?: string; + kind: "file" | "text"; + size?: string; + messageId?: string; + }>; attachments: Array<{ url: string; filename: string; @@ -89,6 +99,35 @@ type MessageBlockItem = MessageBlock | StepClusterBlock; const VIRTUALIZATION_THRESHOLD = 500; const VIRTUAL_OVERSCAN = 4; +function mergeArtifacts( + ...lists: Array< + Array<{ + id: string; + name: string; + path?: string; + kind: "file" | "text"; + size?: string; + messageId?: string; + }> + > +) { + const merged = new Map(); + for (const list of lists) { + list.forEach((artifact) => { + const key = (artifact.path || artifact.id || artifact.name).toLowerCase(); + merged.set(key, artifact); + }); + } + return Array.from(merged.values()); +} + function normalizePath(path: string) { const normalized = path.replace(/\\/g, "/").trim().replace(/\/+/g, "/"); if (!normalized || normalized === "/") return normalized; @@ -410,6 +449,7 @@ export default function MessageList(props: MessageListProps) { const blocks: MessageBlockItem[] = []; const nextMessagePartCountById = new Map(); const nextMessageBlockById = new Map(); + let pendingAssistantArtifacts: MessageBlock["artifacts"] = []; let changedMessageCount = 0; let addedMessageCount = 0; let toolPartCount = 0; @@ -461,6 +501,10 @@ export default function MessageList(props: MessageListProps) { const groupId = String((message.info as any).id ?? "message"); const attachments = attachmentsForParts(renderableParts); const nonAttachmentParts = renderableParts.filter((part) => !isAttachmentPart(part)); + const ownArtifacts = deriveArtifactsFromParts(renderableParts, messageId || undefined); + const artifacts = !isUser + ? mergeArtifacts(pendingAssistantArtifacts, ownArtifacts) + : ownArtifacts; const groups = groupMessageParts(nonAttachmentParts, groupId); const isStepsOnly = groups.length > 0 && groups.every((group) => group.kind === "steps"); @@ -479,6 +523,11 @@ export default function MessageList(props: MessageListProps) { ); if (isStepsOnly) { + if (!isUser && artifacts.length > 0) { + pendingAssistantArtifacts = artifacts; + } else if (isUser) { + pendingAssistantArtifacts = []; + } blocks.push({ kind: "steps-cluster", id: stepGroups[0].id, @@ -488,15 +537,19 @@ export default function MessageList(props: MessageListProps) { mode: group.mode, })), messageIds: [messageId], + artifacts, isUser, }); return; } + pendingAssistantArtifacts = []; + const block: MessageBlock = { kind: "message", message, renderableParts, + artifacts, attachments, groups, isUser, @@ -983,6 +1036,24 @@ export default function MessageList(props: MessageListProps) { ); } + const carriedArtifacts = + !block.isUser && block.artifacts.length === 0 + ? (() => { + for (let cursor = blockIndex - 1; cursor >= 0; cursor -= 1) { + const previous = messageBlocks()[cursor]; + if (!previous) continue; + if (previous.kind === "steps-cluster") { + if (!previous.isUser && previous.artifacts.length > 0) { + return previous.artifacts; + } + continue; + } + break; + } + return [] as MessageBlock["artifacts"]; + })() + : block.artifacts; + const groupSpacing = block.isUser ? "mb-3" : "mb-4"; const isSyntheticSessionError = !block.isUser && @@ -1079,30 +1150,32 @@ export default function MessageList(props: MessageListProps) { > {(() => { + const textGroup = group as { + kind: "text"; + part: Part; + segment: "intent" | "result"; + }; const isStreamingLatestAssistant = !block.isUser && props.isStreaming && block.messageId === latestAssistantMessageId(); const markdownThrottleMs = isStreamingLatestAssistant - ? 120 - : 100; + ? 48 + : 0; + const isFinalTextGroup = idx() === block.groups.length - 1; + const artifacts = + !block.isUser && isFinalTextGroup + ? carriedArtifacts + : []; return ( - { return invoke("app_build_info"); } +export async function desktopGetDefaultAppForFile( + path: string, +): Promise { + return invoke("desktop_default_app_for_file", { + path, + }); +} + export async function nukeOpenworkAndOpencodeConfigAndExit(): Promise { return invoke("nuke_openwork_and_opencode_config_and_exit"); } diff --git a/apps/app/src/app/pages/session.tsx b/apps/app/src/app/pages/session.tsx index f41e7b20d..ad13a34cf 100644 --- a/apps/app/src/app/pages/session.tsx +++ b/apps/app/src/app/pages/session.tsx @@ -75,7 +75,6 @@ import type { OpenworkServerSettings, OpenworkServerStatus, } from "../lib/openwork-server"; -import { join } from "@tauri-apps/api/path"; import { isUserVisiblePart, isTauriRuntime, @@ -83,13 +82,16 @@ import { normalizeDirectoryPath, } from "../utils"; import { finishPerf, perfNow, recordPerfLog } from "../lib/perf-log"; -import { normalizeLocalFilePath } from "../lib/local-file-path"; import { defaultBlueprintCopyForPreset, defaultBlueprintStartersForPreset, } from "../lib/workspace-blueprints"; import { DEFAULT_SESSION_TITLE, getDisplaySessionTitle } from "../lib/session-title"; import { useSessionDisplayPreferences } from "../app-settings/session-display-preferences"; +import { + runLocalFileAction as runLocalFileActionAcrossWorkspace, + type LocalFileActionResult, +} from "../session/file-actions"; import MessageList from "../components/session/message-list"; import Composer from "../components/session/composer"; @@ -256,7 +258,6 @@ const INITIAL_MESSAGE_WINDOW = 140; const MESSAGE_WINDOW_LOAD_CHUNK = 120; const MAX_SEARCH_MESSAGE_CHARS = 4_000; const MAX_SEARCH_HITS = 2_000; -const STREAM_RENDER_BATCH_MS = 48; const MAIN_THREAD_LAG_INTERVAL_MS = 200; const MAIN_THREAD_LAG_WARN_MS = 180; @@ -320,6 +321,13 @@ export default function SessionView(props: SessionViewProps) { let streamRenderBatchTimer: number | undefined; let streamRenderBatchQueuedAt = 0; let streamRenderBatchReschedules = 0; + let pendingRenderedMessageBatch: + | { + messages: MessageWithParts[]; + sourceMessageCount: number; + sourcePartCount: number; + } + | null = null; const [composerNotice, setComposerNotice] = createSignal( null, @@ -655,9 +663,10 @@ export default function SessionView(props: SessionViewProps) { const sourcePartCount = totalPartCount(); if (props.sessionStatus === "idle") { if (streamRenderBatchTimer !== undefined) { - window.clearTimeout(streamRenderBatchTimer); + window.cancelAnimationFrame(streamRenderBatchTimer); streamRenderBatchTimer = undefined; } + pendingRenderedMessageBatch = null; setBatchedRenderedMessages(next); streamRenderBatchQueuedAt = 0; streamRenderBatchReschedules = 0; @@ -670,14 +679,25 @@ export default function SessionView(props: SessionViewProps) { streamRenderBatchReschedules += 1; } + pendingRenderedMessageBatch = { + messages: next, + sourceMessageCount, + sourcePartCount, + }; + if (streamRenderBatchTimer !== undefined) { - window.clearTimeout(streamRenderBatchTimer); - streamRenderBatchTimer = undefined; + return; } - streamRenderBatchTimer = window.setTimeout(() => { + streamRenderBatchTimer = window.requestAnimationFrame(() => { const applyStartedAt = perfNow(); - setBatchedRenderedMessages(next); + const batch = pendingRenderedMessageBatch ?? { + messages: next, + sourceMessageCount, + sourcePartCount, + }; + pendingRenderedMessageBatch = null; + setBatchedRenderedMessages(batch.messages); streamRenderBatchTimer = undefined; const applyMs = Math.round((perfNow() - applyStartedAt) * 100) / 100; const queuedMs = @@ -704,14 +724,14 @@ export default function SessionView(props: SessionViewProps) { reschedules, sessionID: props.selectedSessionId, status: props.sessionStatus, - sourceMessageCount, - sourcePartCount, - renderedMessageCount: next.length, + sourceMessageCount: batch.sourceMessageCount, + sourcePartCount: batch.sourcePartCount, + renderedMessageCount: batch.messages.length, }); } }); } - }, STREAM_RENDER_BATCH_MS); + }); }); createEffect(() => { @@ -850,139 +870,52 @@ export default function SessionView(props: SessionViewProps) { const canCompactSession = createMemo( () => Boolean(props.selectedSessionId) && hasUserMessages(), ); - - const resolveLocalFileCandidates = async (file: string) => { - const trimmed = normalizeLocalFilePath(file).trim(); - if (!trimmed) return []; - if (isAbsolutePath(trimmed)) return [trimmed]; - - const root = props.selectedWorkspaceRoot.trim(); - if (!root) return []; - - const normalized = trimmed - .replace(/[\\/]+/g, "/") - .replace(/^\.\/+/, "") - .replace(/\/+$/, ""); - const candidates: string[] = []; - const seen = new Set(); - - const pushCandidate = (value: string) => { - const key = value - .trim() - .replace(/[\\/]+/g, "/") - .toLowerCase(); - if (!key || seen.has(key)) return; - seen.add(key); - candidates.push(value); - }; - - pushCandidate(await join(root, normalized)); - - if (normalized.startsWith(".opencode/openwork/outbox/")) { - return candidates; - } - - if (normalized.startsWith("openwork/outbox/")) { - const suffix = normalized.slice("openwork/outbox/".length); - if (suffix) { - pushCandidate( - await join(root, ".opencode", "openwork", "outbox", suffix), - ); - } - return candidates; - } - - if (normalized.startsWith("outbox/")) { - const suffix = normalized.slice("outbox/".length); - if (suffix) { - pushCandidate( - await join(root, ".opencode", "openwork", "outbox", suffix), - ); - } - return candidates; - } - - if (!normalized.startsWith(".opencode/")) { - pushCandidate( - await join(root, ".opencode", "openwork", "outbox", normalized), - ); - } - - return candidates; - }; - const runLocalFileAction = async ( file: string, mode: "open" | "reveal", action: (candidate: string) => Promise, - ) => { - const candidates = await resolveLocalFileCandidates(file); - if (!candidates.length) { - return { ok: false as const, reason: "missing-root" as const }; - } - - let lastError: unknown = null; - for (let index = 0; index < candidates.length; index += 1) { - const candidate = candidates[index]; - const startedAt = perfNow(); - try { + ): Promise => { + let lastAttemptedCandidate: string | null = null; + const result = await runLocalFileActionAcrossWorkspace({ + file, + workspaceRoot: props.selectedWorkspaceRoot, + action: async (candidate) => { + lastAttemptedCandidate = candidate; + const startedAt = perfNow(); recordPerfLog(props.developerMode, "session.file-open", "attempt", { mode, input: file, target: candidate, - candidateIndex: index, - candidateCount: candidates.length, }); - await action(candidate); - finishPerf( - props.developerMode, - "session.file-open", - "success", - startedAt, - { + try { + await action(candidate); + finishPerf(props.developerMode, "session.file-open", "success", startedAt, { mode, input: file, target: candidate, - candidateIndex: index, - candidateCount: candidates.length, - }, - ); - return { ok: true as const, path: candidate }; - } catch (error) { - lastError = error; - console.warn("[session.file-open] candidate failed", { - mode, - input: file, - target: candidate, - candidateIndex: index, - candidateCount: candidates.length, - error: error instanceof Error ? error.message : String(error), - }); - finishPerf( - props.developerMode, - "session.file-open", - "candidate-failed", - startedAt, - { + }); + } catch (error) { + finishPerf(props.developerMode, "session.file-open", "candidate-failed", startedAt, { mode, input: file, target: candidate, - candidateIndex: index, - candidateCount: candidates.length, error: error instanceof Error ? error.message : String(error), - }, - ); - } + }); + throw error; + } + }, + }); + + if (!result.ok && result.reason !== "missing-root") { + console.warn("[session.file-open] failed", { + mode, + input: file, + target: lastAttemptedCandidate, + error: result.reason, + }); } - const suffix = - candidates.length > 1 - ? ` (tried ${candidates.length} paths: workspace root and outbox fallbacks)` - : ""; - return { - ok: false as const, - reason: `${lastError instanceof Error ? lastError.message : "File open failed"}${suffix}`, - }; + return result; }; const revealWorkspaceInFinder = async (workspaceId: string) => { @@ -1045,9 +978,10 @@ export default function SessionView(props: SessionViewProps) { jumpControlsSuppressTimer = undefined; } if (streamRenderBatchTimer !== undefined) { - window.clearTimeout(streamRenderBatchTimer); + window.cancelAnimationFrame(streamRenderBatchTimer); streamRenderBatchTimer = undefined; } + pendingRenderedMessageBatch = null; streamRenderBatchQueuedAt = 0; streamRenderBatchReschedules = 0; }); @@ -1096,9 +1030,6 @@ export default function SessionView(props: SessionViewProps) { setMessageWindowStart(count); }); - const isAbsolutePath = (value: string) => - /^(?:[a-zA-Z]:[\\/]|\\\\|\/|~\/)/.test(value.trim()); - const handleWorkingFileClick = async (file: string) => { const trimmed = file.trim(); if (!trimmed) return; diff --git a/apps/app/src/app/session/content-blocks.ts b/apps/app/src/app/session/content-blocks.ts new file mode 100644 index 000000000..4bc16b597 --- /dev/null +++ b/apps/app/src/app/session/content-blocks.ts @@ -0,0 +1,104 @@ +import { marked } from "marked"; +import remend from "remend"; +import { detectStandaloneFileReference } from "./file-links"; + +export type SessionContentBlock = + | { + kind: "markdown"; + id: string; + raw: string; + } + | { + kind: "code"; + id: string; + raw: string; + code: string; + lang: string; + } + | { + kind: "file"; + id: string; + raw: string; + path: string; + label?: string; + }; + +type MarkedToken = { + type?: string; + raw?: string; + text?: string; + lang?: string; +}; + +const blockId = (kind: SessionContentBlock["kind"], index: number, raw: string) => + `${kind}:${index}:${raw.slice(0, 80)}`; + +const reuseBlock = (previous: SessionContentBlock[] | undefined, index: number, next: SessionContentBlock) => { + const existing = previous?.[index]; + if (!existing) return next; + + if (existing.kind !== next.kind) return next; + if (existing.raw !== next.raw) return next; + if (existing.kind === "code" && next.kind === "code") { + if (existing.code !== next.code || existing.lang !== next.lang) return next; + } + if (existing.kind === "file" && next.kind === "file") { + if (existing.path !== next.path || existing.label !== next.label) return next; + } + return existing; +}; + +const tokenRaw = (token: MarkedToken) => (typeof token.raw === "string" ? token.raw : ""); + +const tokenToBlock = (token: MarkedToken, index: number): SessionContentBlock | null => { + const raw = tokenRaw(token); + if (!raw.trim()) return null; + + if (token.type === "code") { + const code = typeof token.text === "string" ? token.text : raw; + const lang = typeof token.lang === "string" ? token.lang : ""; + return { + kind: "code", + id: blockId("code", index, raw), + raw, + code, + lang, + }; + } + + if (token.type === "paragraph") { + const reference = detectStandaloneFileReference(raw); + if (reference) { + return { + kind: "file", + id: blockId("file", index, raw), + raw, + path: reference.path, + label: reference.label, + }; + } + } + + return { + kind: "markdown", + id: blockId("markdown", index, raw), + raw, + }; +}; + +export const splitSessionContentBlocks = ( + value: string, + previous: SessionContentBlock[] = [], +): SessionContentBlock[] => { + const healed = remend(value, { inlineKatex: false }); + const tokens = marked.lexer(healed, { gfm: true, breaks: true }) as MarkedToken[]; + const next: SessionContentBlock[] = []; + + tokens.forEach((token, index) => { + const block = tokenToBlock(token, index); + if (!block) return; + next.push(reuseBlock(previous, next.length, block)); + }); + + return next; +}; diff --git a/apps/app/src/app/session/file-actions.ts b/apps/app/src/app/session/file-actions.ts new file mode 100644 index 000000000..b7f6d2d12 --- /dev/null +++ b/apps/app/src/app/session/file-actions.ts @@ -0,0 +1,108 @@ +import { join } from "@tauri-apps/api/path"; +import { normalizeLocalFilePath } from "../lib/local-file-path"; +import { desktopGetDefaultAppForFile, type DesktopFileAssociation } from "../lib/tauri"; +import { isTauriRuntime } from "../utils"; + +export type LocalFileActionMode = "open" | "reveal"; + +export type LocalFileActionResult = + | { ok: true; path: string } + | { ok: false; reason: "missing-root" | string }; + +export const isAbsoluteLocalPath = (value: string) => + /^(?:[a-zA-Z]:[\\/]|\\\\|\/|~\/)/.test(value.trim()); + +export const resolveLocalFileCandidates = async (file: string, workspaceRoot: string) => { + const trimmed = normalizeLocalFilePath(file).trim(); + if (!trimmed) return []; + if (isAbsoluteLocalPath(trimmed)) return [trimmed]; + + const root = workspaceRoot.trim(); + if (!root) return []; + + const normalized = trimmed + .replace(/[\\/]+/g, "/") + .replace(/^\.\/+/, "") + .replace(/\/+$/, ""); + const candidates: string[] = []; + const seen = new Set(); + + const pushCandidate = (value: string) => { + const key = value + .trim() + .replace(/[\\/]+/g, "/") + .toLowerCase(); + if (!key || seen.has(key)) return; + seen.add(key); + candidates.push(value); + }; + + pushCandidate(await join(root, normalized)); + + if (normalized.startsWith(".opencode/openwork/outbox/")) { + return candidates; + } + + if (normalized.startsWith("openwork/outbox/")) { + const suffix = normalized.slice("openwork/outbox/".length); + if (suffix) { + pushCandidate(await join(root, ".opencode", "openwork", "outbox", suffix)); + } + return candidates; + } + + if (normalized.startsWith("outbox/")) { + const suffix = normalized.slice("outbox/".length); + if (suffix) { + pushCandidate(await join(root, ".opencode", "openwork", "outbox", suffix)); + } + return candidates; + } + + if (!normalized.startsWith(".opencode/")) { + pushCandidate(await join(root, ".opencode", "openwork", "outbox", normalized)); + } + + return candidates; +}; + +export const runLocalFileAction = async (options: { + file: string; + workspaceRoot: string; + action: (candidate: string) => Promise; +}): Promise => { + const candidates = await resolveLocalFileCandidates(options.file, options.workspaceRoot); + if (!candidates.length) { + return { ok: false, reason: "missing-root" }; + } + + let lastError: unknown = null; + for (const candidate of candidates) { + try { + await options.action(candidate); + return { ok: true, path: candidate }; + } catch (error) { + lastError = error; + } + } + + return { + ok: false, + reason: lastError instanceof Error ? lastError.message : "File action failed", + }; +}; + +export const resolvePrimaryLocalFile = async (file: string, workspaceRoot: string) => { + const candidates = await resolveLocalFileCandidates(file, workspaceRoot); + return candidates[0] ?? null; +}; + +export const getDefaultDesktopFileAssociation = async ( + file: string, + workspaceRoot: string, +): Promise => { + if (!isTauriRuntime()) return null; + const resolved = await resolvePrimaryLocalFile(file, workspaceRoot); + if (!resolved) return null; + return desktopGetDefaultAppForFile(resolved); +}; diff --git a/apps/app/src/app/session/file-links.ts b/apps/app/src/app/session/file-links.ts new file mode 100644 index 000000000..a370677a8 --- /dev/null +++ b/apps/app/src/app/session/file-links.ts @@ -0,0 +1,259 @@ +type LinkType = "url" | "file"; + +export type TextSegment = + | { kind: "text"; value: string } + | { kind: "link"; value: string; href: string; type: LinkType }; + +export type LinkDetectionOptions = { + allowFilePaths?: boolean; +}; + +const WEB_LINK_RE = /^(?:https?:\/\/|www\.)/i; +const FILE_URI_RE = /^file:\/\//i; +const URI_SCHEME_RE = /^[A-Za-z][A-Za-z0-9+.-]*:/; +const WINDOWS_PATH_RE = /^[A-Za-z]:[\\/][^\s"'`\)\]\}>]+$/; +const POSIX_PATH_RE = /^\/(?!\/)[^\s"'`\)\]\}>][^\s"'`\)\]\}>]*$/; +const TILDE_PATH_RE = /^~\/[^\s"'`\)\]\}>][^\s"'`\)\]\}>]*$/; +const BARE_FILENAME_RE = /^(?!\.)(?!.*\.\.)(?:[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+)$/; +const SAFE_PATH_CHAR_RE = /[^\s"'`\)\]\}>]/; +const LEADING_PUNCTU = /["'`\(\[\{<]/; +const TRAILING_PUNCTU = /["'`\)\]}>.,:;!?]/; + +export const stripFileReferenceSuffix = (value: string) => { + const withoutQueryOrFragment = value.replace(/[?#].*$/, "").trim(); + if (!withoutQueryOrFragment) return ""; + return withoutQueryOrFragment.replace(/:(\d+)(?::\d+)?$/, ""); +}; + +export const isWorkspaceRelativeFilePath = (value: string) => { + const stripped = stripFileReferenceSuffix(value); + if (!stripped) return false; + + const normalized = stripped.replace(/\\/g, "/"); + if (!normalized.includes("/")) return false; + if (normalized.startsWith("/") || normalized.startsWith("~/") || normalized.startsWith("//")) { + return false; + } + if (URI_SCHEME_RE.test(normalized)) return false; + if (/^[A-Za-z]:\//.test(normalized)) return false; + + const segments = normalized.split("/"); + if (!segments.length) return false; + return segments.every((segment) => segment.length > 0 && segment !== "." && segment !== ".."); +}; + +export const isRelativeFilePath = (value: string) => { + if (value === "." || value === "..") return false; + + const normalized = value.replace(/\\/g, "/"); + const segments = normalized.split("/"); + const hasNonTraversalSegment = segments.some((segment) => segment && segment !== "." && segment !== ".."); + + if (normalized.startsWith("./") || normalized.startsWith("../")) { + return hasNonTraversalSegment; + } + + const [firstSegment, secondSegment] = normalized.split("/"); + if (!secondSegment || firstSegment.length <= 1) return false; + if (secondSegment === "." || secondSegment === "..") return false; + return firstSegment.startsWith(".") && SAFE_PATH_CHAR_RE.test(secondSegment); +}; + +export const isBareRelativeFilePath = (value: string) => { + if (value.includes("/") || value.includes("\\") || value.includes(":")) return false; + if (!BARE_FILENAME_RE.test(value)) return false; + + const extension = value.split(".").pop() ?? ""; + if (!/[A-Za-z]/.test(extension)) return false; + + const dotCount = (value.match(/\./g) ?? []).length; + if (dotCount === 1 && !value.includes("_") && !value.includes("-")) { + const [name, tld] = value.split("."); + if (/^[A-Za-z]{2,24}$/.test(name ?? "") && /^[A-Za-z]{2,10}$/.test(tld ?? "")) { + return false; + } + } + + return true; +}; + +export const isLikelyWebLink = (value: string) => WEB_LINK_RE.test(value); + +export const isLikelyFilePath = (value: string) => { + if (FILE_URI_RE.test(value)) return true; + if (WINDOWS_PATH_RE.test(value)) return true; + if (POSIX_PATH_RE.test(value)) return true; + if (TILDE_PATH_RE.test(value)) return true; + if (isRelativeFilePath(value)) return true; + if (isBareRelativeFilePath(value)) return true; + if (isWorkspaceRelativeFilePath(value)) return true; + + return false; +}; + +export const parseLinkFromToken = ( + token: string, + options: LinkDetectionOptions = {}, +): { href: string; type: LinkType; value: string } | null => { + let start = 0; + let end = token.length; + + while (start < end && LEADING_PUNCTU.test(token[start] ?? "")) { + start += 1; + } + + while (end > start && TRAILING_PUNCTU.test(token[end - 1] ?? "")) { + end -= 1; + } + + const value = token.slice(start, end); + if (!value) return null; + + if (isLikelyWebLink(value)) { + return { + value, + type: "url", + href: value.toLowerCase().startsWith("www.") ? `https://${value}` : value, + }; + } + + if ((options.allowFilePaths ?? true) && isLikelyFilePath(value)) { + return { + value, + type: "file", + href: value, + }; + } + + return null; +}; + +export const splitTextTokens = (text: string, options: LinkDetectionOptions = {}): TextSegment[] => { + const tokens: TextSegment[] = []; + const matches = text.matchAll(/\S+/g); + let position = 0; + + for (const match of matches) { + const token = match[0] ?? ""; + const index = match.index ?? 0; + + if (index > position) { + tokens.push({ kind: "text", value: text.slice(position, index) }); + } + + const link = parseLinkFromToken(token, options); + if (!link) { + tokens.push({ kind: "text", value: token }); + } else { + const start = token.indexOf(link.value); + if (start > 0) { + tokens.push({ kind: "text", value: token.slice(0, start) }); + } + tokens.push({ kind: "link", value: link.value, href: link.href, type: link.type }); + const end = start + link.value.length; + if (end < token.length) { + tokens.push({ kind: "text", value: token.slice(end) }); + } + } + + position = index + token.length; + } + + if (position < text.length) { + tokens.push({ kind: "text", value: text.slice(position) }); + } + + return tokens; +}; + +export const normalizeRelativePath = (relativePath: string, workspaceRoot: string) => { + const root = workspaceRoot.trim().replace(/\\/g, "/").replace(/\/+$/g, ""); + if (!root) return null; + + const relative = relativePath.trim().replace(/\\/g, "/"); + if (!relative) return null; + + const isPosixRoot = root.startsWith("/"); + const rootValue = isPosixRoot ? root.slice(1) : root; + const rootParts = rootValue.split("/").filter((value) => value.length > 0); + const isWindowsDrive = /^[A-Za-z]:$/.test(rootParts[0] ?? ""); + const resolved: string[] = [...rootParts]; + const segments = relative.split("/"); + + for (const segment of segments) { + if (!segment || segment === ".") continue; + + if (segment === "..") { + if (!(isWindowsDrive && resolved.length === 1)) { + resolved.pop(); + } + continue; + } + + resolved.push(segment); + } + + const normalized = resolved.join("/"); + if (isPosixRoot) return `/${normalized || ""}` || "/"; + return normalized; +}; + +export const normalizeFilePath = (href: string, workspaceRoot: string): string | null => { + const strippedHref = stripFileReferenceSuffix(href); + if (!strippedHref) return null; + + if (FILE_URI_RE.test(href)) { + try { + const parsed = new URL(href); + if (parsed.protocol !== "file:") return null; + const raw = decodeURIComponent(parsed.pathname || ""); + if (!raw) return null; + if (/^\/[A-Za-z]:\//.test(raw)) { + return raw.slice(1); + } + if (parsed.hostname && !parsed.pathname.startsWith(`/${parsed.hostname}`) && !raw.startsWith("/")) { + return `/${parsed.hostname}${raw}`; + } + return raw; + } catch { + const raw = decodeURIComponent(href.replace(/^file:\/\//, "")); + if (!raw) return null; + return raw; + } + } + + const trimmed = strippedHref.trim(); + if (isRelativeFilePath(trimmed) || isBareRelativeFilePath(trimmed) || isWorkspaceRelativeFilePath(trimmed)) { + if (!workspaceRoot) return null; + return normalizeRelativePath(trimmed, workspaceRoot); + } + + return trimmed || null; +}; + +export type DetectedLocalFileReference = { + path: string; + label?: string; +}; + +export const detectStandaloneFileReference = (raw: string): DetectedLocalFileReference | null => { + const trimmed = raw.trim(); + if (!trimmed || trimmed.includes("\n\n")) return null; + + const markdownLink = trimmed.match(/^\[([^\]]+)\]\(([^)]+)\)$/); + if (markdownLink) { + const [, label, href] = markdownLink; + const cleanHref = href.trim(); + if (isLikelyFilePath(cleanHref)) { + return { path: cleanHref, label: label.trim() || undefined }; + } + return null; + } + + const stripped = trimmed.replace(/^`([^`]+)`$/, "$1").replace(/^\*\*([^*]+)\*\*$/, "$1"); + if (!stripped.includes(" ") && isLikelyFilePath(stripped)) { + return { path: stripped }; + } + + return null; +}; diff --git a/apps/app/src/app/session/file-presentations.ts b/apps/app/src/app/session/file-presentations.ts new file mode 100644 index 000000000..5587a5416 --- /dev/null +++ b/apps/app/src/app/session/file-presentations.ts @@ -0,0 +1,134 @@ +import type { ArtifactItem } from "../types"; + +export type FilePresentation = { + title: string; + detail?: string; + typeLabel: string; + extension: string; + category: "spreadsheet" | "document" | "image" | "code" | "archive" | "media" | "data" | "file"; +}; + +export type FileReferenceCard = { + id: string; + path: string; + title?: string; + detail?: string; + mime?: string; + source: "artifact" | "file-part" | "text"; +}; + +const EXTENSION_LABELS: Record = { + csv: { typeLabel: "Table · CSV", category: "spreadsheet" }, + tsv: { typeLabel: "Table · TSV", category: "spreadsheet" }, + xlsx: { typeLabel: "Spreadsheet · XLSX", category: "spreadsheet" }, + xls: { typeLabel: "Spreadsheet · XLS", category: "spreadsheet" }, + numbers: { typeLabel: "Spreadsheet · Numbers", category: "spreadsheet" }, + md: { typeLabel: "Document · Markdown", category: "document" }, + txt: { typeLabel: "Document · Text", category: "document" }, + pdf: { typeLabel: "Document · PDF", category: "document" }, + doc: { typeLabel: "Document · Word", category: "document" }, + docx: { typeLabel: "Document · Word", category: "document" }, + html: { typeLabel: "Document · HTML", category: "document" }, + json: { typeLabel: "Data · JSON", category: "data" }, + jsonl: { typeLabel: "Data · JSONL", category: "data" }, + yaml: { typeLabel: "Data · YAML", category: "data" }, + yml: { typeLabel: "Data · YAML", category: "data" }, + xml: { typeLabel: "Data · XML", category: "data" }, + ts: { typeLabel: "Code · TypeScript", category: "code" }, + tsx: { typeLabel: "Code · TSX", category: "code" }, + js: { typeLabel: "Code · JavaScript", category: "code" }, + jsx: { typeLabel: "Code · JSX", category: "code" }, + py: { typeLabel: "Code · Python", category: "code" }, + rs: { typeLabel: "Code · Rust", category: "code" }, + go: { typeLabel: "Code · Go", category: "code" }, + sh: { typeLabel: "Script · Shell", category: "code" }, + bash: { typeLabel: "Script · Bash", category: "code" }, + css: { typeLabel: "Code · CSS", category: "code" }, + png: { typeLabel: "Image · PNG", category: "image" }, + jpg: { typeLabel: "Image · JPEG", category: "image" }, + jpeg: { typeLabel: "Image · JPEG", category: "image" }, + gif: { typeLabel: "Image · GIF", category: "image" }, + webp: { typeLabel: "Image · WebP", category: "image" }, + svg: { typeLabel: "Image · SVG", category: "image" }, + zip: { typeLabel: "Archive · ZIP", category: "archive" }, + tar: { typeLabel: "Archive · TAR", category: "archive" }, + gz: { typeLabel: "Archive · GZip", category: "archive" }, + mp4: { typeLabel: "Media · MP4", category: "media" }, + mov: { typeLabel: "Media · MOV", category: "media" }, + mp3: { typeLabel: "Media · MP3", category: "media" }, + wav: { typeLabel: "Media · WAV", category: "media" }, +}; + +const MIME_PREFIX_PRESENTATIONS: Array<{ + prefix: string; + typeLabel: string; + category: FilePresentation["category"]; +}> = [ + { prefix: "image/", typeLabel: "Image", category: "image" }, + { prefix: "audio/", typeLabel: "Media · Audio", category: "media" }, + { prefix: "video/", typeLabel: "Media · Video", category: "media" }, + { prefix: "text/", typeLabel: "Document · Text", category: "document" }, +]; + +export const normalizeReferencePath = (value: string) => + value.trim().replace(/[\\/]+/g, "/"); + +export const referenceKey = (value: string) => normalizeReferencePath(value).toLowerCase(); + +export const filenameFromPath = (value: string) => { + const normalized = normalizeReferencePath(value); + const segments = normalized.split("/").filter(Boolean); + return segments[segments.length - 1] ?? normalized; +}; + +export const extensionFromPath = (value: string) => { + const filename = filenameFromPath(value); + const extension = filename.split(".").pop() ?? ""; + return extension.toLowerCase(); +}; + +export const filePresentationForReference = (options: { + path: string; + title?: string; + detail?: string; + mime?: string; +}): FilePresentation => { + const normalizedPath = normalizeReferencePath(options.path); + const extension = extensionFromPath(normalizedPath); + const titled = options.title?.trim(); + const mime = options.mime?.trim().toLowerCase() ?? ""; + const explicit = extension ? EXTENSION_LABELS[extension] : undefined; + const mimeMatch = MIME_PREFIX_PRESENTATIONS.find((entry) => mime.startsWith(entry.prefix)); + const typeLabel = explicit?.typeLabel ?? mimeMatch?.typeLabel ?? (extension ? `File · ${extension.toUpperCase()}` : "File"); + const category = explicit?.category ?? mimeMatch?.category ?? "file"; + const filename = filenameFromPath(normalizedPath); + return { + title: titled || filename, + detail: options.detail?.trim() || normalizedPath, + typeLabel, + extension, + category, + }; +}; + +export const fileReferenceCardFromArtifact = (artifact: ArtifactItem): FileReferenceCard | null => { + const path = artifact.path?.trim(); + if (!path) return null; + return { + id: artifact.id, + path, + title: artifact.name, + detail: artifact.path, + source: "artifact", + }; +}; + +export const dedupeFileReferenceCards = (items: FileReferenceCard[]) => { + const next = new Map(); + for (const item of items) { + const key = referenceKey(item.path); + if (!key) continue; + next.set(key, item); + } + return Array.from(next.values()); +}; diff --git a/apps/app/src/app/utils/index.ts b/apps/app/src/app/utils/index.ts index 7ba440f60..df49f362b 100644 --- a/apps/app/src/app/utils/index.ts +++ b/apps/app/src/app/utils/index.ts @@ -1021,6 +1021,91 @@ export function summarizeStep(part: Part): { title: string; detail?: string; isS return { title: "Step", toolCategory: "tool" }; } +function collectArtifactsFromParts(results: Map, parts: Part[], messageId?: string) { + parts.forEach((part) => { + if (part.type !== "tool") return; + const record = part as any; + const state = record.state ?? {}; + const input = state.input && typeof state.input === "object" + ? (state.input as Record) + : {}; + const matches = new Set(); + + const explicit = [ + state.path, + state.file, + ...(Array.isArray(state.files) ? state.files : []), + input.filePath, + input.path, + input.file, + ...(Array.isArray(input.files) ? input.files : []), + ]; + + explicit.forEach((f) => { + if (typeof f === "string") { + const trimmed = f.trim(); + if ( + trimmed.length > 0 && + trimmed.length <= 500 && + trimmed.includes(".") && + !/^\.{2,}$/.test(trimmed) + ) { + matches.add(trimmed); + } + } + }); + + const toolName = + typeof record.tool === "string" && record.tool.trim() + ? record.tool.trim().toLowerCase() + : ""; + const titleText = typeof state.title === "string" ? state.title : ""; + const outputText = + typeof state.output === "string" && !ARTIFACT_OUTPUT_SKIP_TOOLS.has(toolName) + ? state.output.slice(0, ARTIFACT_OUTPUT_SCAN_LIMIT) + : ""; + + const text = [titleText, outputText] + .filter((v): v is string => Boolean(v)) + .join(" "); + + if (text) { + ARTIFACT_PATH_PATTERN.lastIndex = 0; + Array.from(text.matchAll(ARTIFACT_PATH_PATTERN)) + .map((m) => m[1]) + .filter((f) => f && f.length <= 500) + .forEach((f) => matches.add(f)); + } + + if (matches.size === 0) return; + + matches.forEach((match) => { + const cleanedPath = cleanArtifactPath(match); + if (!cleanedPath) return; + + const key = cleanedPath.toLowerCase(); + const name = cleanedPath.split("/").pop() ?? cleanedPath; + const id = `artifact-${encodeURIComponent(cleanedPath)}`; + + if (results.has(key)) results.delete(key); + results.set(key, { + id, + name, + path: cleanedPath, + kind: "file" as const, + size: state.size ? String(state.size) : undefined, + messageId: messageId || undefined, + }); + }); + }); +} + +export function deriveArtifactsFromParts(parts: Part[], messageId?: string): ArtifactItem[] { + const results = new Map(); + collectArtifactsFromParts(results, parts, messageId); + return Array.from(results.values()); +} + export function deriveArtifacts(list: MessageWithParts[], options: DeriveArtifactsOptions = {}): ArtifactItem[] { const results = new Map(); const maxMessages = @@ -1031,77 +1116,7 @@ export function deriveArtifacts(list: MessageWithParts[], options: DeriveArtifac source.forEach((message) => { const messageId = String((message.info as any)?.id ?? ""); - - message.parts.forEach((part) => { - if (part.type !== "tool") return; - const record = part as any; - const state = record.state ?? {}; - const matches = new Set(); - - const explicit = [ - state.path, - state.file, - ...(Array.isArray(state.files) ? state.files : []), - ]; - - explicit.forEach((f) => { - if (typeof f === "string") { - const trimmed = f.trim(); - if ( - trimmed.length > 0 && - trimmed.length <= 500 && - trimmed.includes(".") && - !/^\.{2,}$/.test(trimmed) - ) { - matches.add(trimmed); - } - } - }); - - const toolName = - typeof record.tool === "string" && record.tool.trim() - ? record.tool.trim().toLowerCase() - : ""; - const titleText = typeof state.title === "string" ? state.title : ""; - const outputText = - typeof state.output === "string" && !ARTIFACT_OUTPUT_SKIP_TOOLS.has(toolName) - ? state.output.slice(0, ARTIFACT_OUTPUT_SCAN_LIMIT) - : ""; - - const text = [titleText, outputText] - .filter((v): v is string => Boolean(v)) - .join(" "); - - if (text) { - ARTIFACT_PATH_PATTERN.lastIndex = 0; - Array.from(text.matchAll(ARTIFACT_PATH_PATTERN)) - .map((m) => m[1]) - .filter((f) => f && f.length <= 500) - .forEach((f) => matches.add(f)); - } - - if (matches.size === 0) return; - - matches.forEach((match) => { - const cleanedPath = cleanArtifactPath(match); - if (!cleanedPath) return; - - const key = cleanedPath.toLowerCase(); - const name = cleanedPath.split("/").pop() ?? cleanedPath; - const id = `artifact-${encodeURIComponent(cleanedPath)}`; - - // Delete and re-add to move to end (most recent) - if (results.has(key)) results.delete(key); - results.set(key, { - id, - name, - path: cleanedPath, - kind: "file" as const, - size: state.size ? String(state.size) : undefined, - messageId: messageId || undefined, - }); - }); - }); + collectArtifactsFromParts(results, message.parts, messageId || undefined); }); return Array.from(results.values()); diff --git a/apps/desktop/src-tauri/src/commands/misc.rs b/apps/desktop/src-tauri/src/commands/misc.rs index e44f88610..27771303d 100644 --- a/apps/desktop/src-tauri/src/commands/misc.rs +++ b/apps/desktop/src-tauri/src/commands/misc.rs @@ -1,6 +1,7 @@ use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; +use std::process::Command; use crate::engine::doctor::resolve_engine_path; use crate::engine::manager::EngineManager; @@ -48,6 +49,13 @@ pub struct AppBuildInfo { pub openwork_dev_mode: bool, } +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopFileAssociation { + pub name: String, + pub path: Option, +} + fn env_truthy(key: &str) -> bool { matches!( std::env::var(key) @@ -228,6 +236,67 @@ fn validate_server_name(name: &str) -> Result { Ok(trimmed.to_string()) } +#[cfg(target_os = "macos")] +fn default_app_for_file(path: &Path) -> Result, String> { + if !path.exists() { + return Ok(None); + } + + let display_target = path.to_string_lossy().replace('"', "\\\""); + let path_script = format!( + "set fileInfo to info for POSIX file \"{}\"\nPOSIX path of (default application of fileInfo)", + display_target + ); + let path_output = Command::new("osascript") + .arg("-e") + .arg(path_script) + .output() + .map_err(|error| format!("Failed to read default app path: {error}"))?; + + if !path_output.status.success() { + return Ok(None); + } + + let app_path = String::from_utf8_lossy(&path_output.stdout) + .trim() + .to_string(); + if app_path.is_empty() { + return Ok(None); + } + + let app_target = app_path.replace('"', "\\\""); + let name_script = format!( + "name of (info for POSIX file \"{}\")", + app_target.trim_end_matches('/') + ); + let name_output = Command::new("osascript") + .arg("-e") + .arg(name_script) + .output() + .map_err(|error| format!("Failed to read default app name: {error}"))?; + + if !name_output.status.success() { + return Ok(None); + } + + let name = String::from_utf8_lossy(&name_output.stdout) + .trim() + .to_string(); + if name.is_empty() { + return Ok(None); + } + + Ok(Some(DesktopFileAssociation { + name, + path: Some(app_path), + })) +} + +#[cfg(not(target_os = "macos"))] +fn default_app_for_file(_path: &Path) -> Result, String> { + Ok(None) +} + fn read_workspace_openwork_config( workspace_path: &Path, ) -> Result { @@ -436,6 +505,18 @@ pub fn app_build_info(app: AppHandle) -> AppBuildInfo { } } +#[tauri::command] +pub fn desktop_default_app_for_file( + path: String, +) -> Result, String> { + let path = PathBuf::from(path.trim()); + if path.as_os_str().is_empty() { + return Ok(None); + } + + default_app_for_file(&path) +} + #[tauri::command] pub fn nuke_openwork_and_opencode_config_and_exit( app: AppHandle, diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 4e8f74969..b608999b4 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -24,8 +24,8 @@ use commands::engine::{ engine_doctor, engine_info, engine_install, engine_restart, engine_start, engine_stop, }; use commands::misc::{ - app_build_info, nuke_openwork_and_opencode_config_and_exit, opencode_mcp_auth, - reset_opencode_cache, reset_openwork_state, + app_build_info, desktop_default_app_for_file, nuke_openwork_and_opencode_config_and_exit, + opencode_mcp_auth, reset_opencode_cache, reset_openwork_state, }; use commands::opencode_router::{ opencodeRouter_config_set, opencodeRouter_info, opencodeRouter_start, opencodeRouter_status, @@ -213,6 +213,7 @@ pub fn run() { write_opencode_config, updater_environment, app_build_info, + desktop_default_app_for_file, nuke_openwork_and_opencode_config_and_exit, reset_openwork_state, reset_opencode_cache, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 598ff4539..ee3d8f646 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,6 +85,9 @@ importers: marked: specifier: ^17.0.1 version: 17.0.1 + remend: + specifier: ^1.3.0 + version: 1.3.0 solid-js: specifier: ^1.9.0 version: 1.9.9 @@ -4454,6 +4457,9 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + remend@1.3.0: + resolution: {integrity: sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -9086,6 +9092,8 @@ snapshots: real-require@0.2.0: {} + remend@1.3.0: {} + require-directory@2.1.1: {} require-in-the-middle@8.0.1: