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 `
+
+ `;
+ };
+
+ 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