diff --git a/packages/app/src/app/components/session/artifact-markdown-editor.tsx b/packages/app/src/app/components/session/artifact-markdown-editor.tsx index 1370194b..3f26426f 100644 --- a/packages/app/src/app/components/session/artifact-markdown-editor.tsx +++ b/packages/app/src/app/components/session/artifact-markdown-editor.tsx @@ -67,7 +67,9 @@ export default function ArtifactMarkdownEditor(props: ArtifactMarkdownEditorProp setError(writeDisabledReason()); return; } - if (!target) return; + if (!target) { + return; + } if (!isMarkdown(target)) { setError("Only markdown files are supported."); return; @@ -193,8 +195,7 @@ export default function ArtifactMarkdownEditor(props: ArtifactMarkdownEditorProp } const target = path(); - if (!target) return; - if (loading() || pendingReason() === "switch") return; + if (!target || loading() || pendingReason() === "switch") return; const active = loadedPath(); if (!active) { diff --git a/packages/app/src/app/pages/session.tsx b/packages/app/src/app/pages/session.tsx index e9c74304..b570be9e 100644 --- a/packages/app/src/app/pages/session.tsx +++ b/packages/app/src/app/pages/session.tsx @@ -722,6 +722,17 @@ export default function SessionView(props: SessionViewProps) { return ""; } + if (root) { + const rootSegments = root.split("/").filter(Boolean); + const workspaceFolderName = rootSegments[rootSegments.length - 1]?.toLowerCase(); + if (workspaceFolderName) { + const workspaceMarker = `workspaces/${workspaceFolderName}/`; + const markerIndex = fileKey.indexOf(workspaceMarker); + if (markerIndex >= 0) return normalized.slice(markerIndex + workspaceMarker.length); + if (fileKey.endsWith(`workspaces/${workspaceFolderName}`)) return ""; + } + } + let relative = normalized.replace(/^\.\/+/, ""); if (!relative) return ""; @@ -739,15 +750,28 @@ export default function SessionView(props: SessionViewProps) { if (/^\/+workspace\//i.test(relative)) { relative = relative.replace(/^\/+workspace\//i, ""); } + if (relative.startsWith("/") || relative.startsWith("~") || /^[a-zA-Z]:\//.test(relative)) return ""; if (relative.split("/").some((part) => part === "." || part === "..")) return ""; + + if (/com\.[^/]+\.(openwork|opencode)/i.test(relative)) return ""; + return relative; }; const openMarkdownEditor = (file: string) => { + if (!props.openworkServerClient) { + setToastMessage("Cannot open file: not connected to OpenWork server."); + return; + } + if (!props.openworkServerWorkspaceId) { + setToastMessage("Cannot open file: no workspace selected."); + return; + } + const relative = toWorkspaceRelativeForApi(file); if (!relative) { - setToastMessage("Only worker-relative files can be opened here."); + setToastMessage(`Cannot open file: path "${file}" is not within the workspace.`); return; } if (!/\.(md|mdx|markdown)$/i.test(relative)) { diff --git a/packages/app/src/app/utils/index.ts b/packages/app/src/app/utils/index.ts index a53c9df0..98efd229 100644 --- a/packages/app/src/app/utils/index.ts +++ b/packages/app/src/app/utils/index.ts @@ -800,6 +800,44 @@ const ARTIFACT_PATH_PATTERN = const ARTIFACT_OUTPUT_SCAN_LIMIT = 4000; const ARTIFACT_OUTPUT_SKIP_TOOLS = new Set(["webfetch"]); +// Patterns that indicate a path is a truncated system/absolute path rather than a workspace-relative path +const TRUNCATED_SYSTEM_PATH_PATTERNS = [ + /com\.[^/]+\.(openwork|opencode)/i, // macOS app bundle identifiers + /\.openwork\.dev\//i, // OpenWork dev paths + /Application Support\//i, // macOS Application Support + /AppData[/\\]/i, // Windows AppData + /\.local\/share\//i, // Linux XDG data + /workspaces\/[^/]+\/workspaces\//i, // Nested workspaces paths (clearly malformed) +]; + +/** + * Clean up an artifact path to extract the workspace-relative portion. + * Returns null if the path should be rejected entirely. + */ +function cleanArtifactPath(rawPath: string): string | null { + const normalized = rawPath.trim().replace(/[\\/]+/g, "/"); + if (!normalized) return null; + + // Check if this looks like a truncated system path + for (const pattern of TRUNCATED_SYSTEM_PATH_PATTERNS) { + if (pattern.test(normalized)) { + // Try to extract just the relative part after "workspaces//" + const workspacesMatch = normalized.match(/workspaces\/[^/]+\/(.+)$/i); + if (workspacesMatch && workspacesMatch[1]) { + const relative = workspacesMatch[1]; + // Validate the extracted path doesn't still contain system patterns + if (!TRUNCATED_SYSTEM_PATH_PATTERNS.some((p) => p.test(relative))) { + return relative; + } + } + // Reject the path entirely if we can't extract a clean relative path + return null; + } + } + + return normalized; +} + type DeriveArtifactsOptions = { maxMessages?: number; }; @@ -906,19 +944,19 @@ export function deriveArtifacts(list: MessageWithParts[], options: DeriveArtifac if (matches.size === 0) return; matches.forEach((match) => { - const normalizedPath = match.trim().replace(/[\\/]+/g, "/"); - if (!normalizedPath) return; + const cleanedPath = cleanArtifactPath(match); + if (!cleanedPath) return; - const key = normalizedPath.toLowerCase(); - const name = normalizedPath.split("/").pop() ?? normalizedPath; - const id = `artifact-${encodeURIComponent(normalizedPath)}`; + 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: normalizedPath, + path: cleanedPath, kind: "file" as const, size: state.size ? String(state.size) : undefined, messageId: messageId || undefined,