Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,19 @@ export default function ArtifactMarkdownEditor(props: ArtifactMarkdownEditorProp
const client = props.client;
const workspaceId = props.workspaceId;

console.debug("[ArtifactMarkdownEditor] load requested:", { target, workspaceId, hasClient: !!client });

if (!client || !workspaceId) {
console.warn("[ArtifactMarkdownEditor] load blocked: no client or workspaceId", { workspaceId, hasClient: !!client });
setError(writeDisabledReason());
return;
}
if (!target) return;
if (!target) {
console.warn("[ArtifactMarkdownEditor] load blocked: empty target path");
return;
}
if (!isMarkdown(target)) {
console.warn("[ArtifactMarkdownEditor] load blocked: not a markdown file:", target);
setError("Only markdown files are supported.");
return;
}
Expand All @@ -80,7 +87,9 @@ export default function ArtifactMarkdownEditor(props: ArtifactMarkdownEditorProp
let result: OpenworkWorkspaceFileContent;
let actualPath = target;
try {
console.debug("[ArtifactMarkdownEditor] fetching file:", { workspaceId, target });
result = (await client.readWorkspaceFile(workspaceId, target)) as OpenworkWorkspaceFileContent;
console.debug("[ArtifactMarkdownEditor] file loaded successfully:", target);
} catch (err) {
// Artifacts are frequently referenced as workspace-relative paths (e.g. `learned/foo.md`),
// but on disk they may live under the OpenWork outbox dir: `.opencode/openwork/outbox/`.
Expand All @@ -91,14 +100,29 @@ export default function ArtifactMarkdownEditor(props: ArtifactMarkdownEditorProp
err instanceof OpenworkServerError &&
err.status === 404;

console.debug("[ArtifactMarkdownEditor] primary fetch failed:", {
target,
error: err instanceof Error ? err.message : String(err),
status: err instanceof OpenworkServerError ? err.status : null,
willTryOutbox: shouldTryOutbox,
candidateOutbox,
});

if (!shouldTryOutbox) {
throw err;
}

actualPath = candidateOutbox;
try {
console.debug("[ArtifactMarkdownEditor] trying outbox path:", actualPath);
result = (await client.readWorkspaceFile(workspaceId, actualPath)) as OpenworkWorkspaceFileContent;
console.debug("[ArtifactMarkdownEditor] outbox file loaded successfully:", actualPath);
} catch (second) {
console.warn("[ArtifactMarkdownEditor] outbox fetch also failed:", {
actualPath,
error: second instanceof Error ? second.message : String(second),
status: second instanceof OpenworkServerError ? second.status : null,
});
if (second instanceof OpenworkServerError && second.status === 404) {
throw new OpenworkServerError(404, "file_not_found", "File not found (workspace root or outbox).");
}
Expand All @@ -113,6 +137,7 @@ export default function ArtifactMarkdownEditor(props: ArtifactMarkdownEditorProp
setBaseUpdatedAt(typeof result.updatedAt === "number" ? result.updatedAt : null);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to load file";
console.error("[ArtifactMarkdownEditor] load error:", { target, error: message, rawError: err });
setError(message);
setLoadedPath(target);
} finally {
Expand Down Expand Up @@ -193,21 +218,37 @@ export default function ArtifactMarkdownEditor(props: ArtifactMarkdownEditorProp
}

const target = path();
if (!target) return;
if (loading() || pendingReason() === "switch") return;
if (!target) {
console.debug("[ArtifactMarkdownEditor] effect: no target path, skipping");
return;
}
if (loading()) {
console.debug("[ArtifactMarkdownEditor] effect: already loading, skipping");
return;
}
if (pendingReason() === "switch") {
console.debug("[ArtifactMarkdownEditor] effect: pending switch, skipping");
return;
}

const active = loadedPath();
if (!active) {
console.debug("[ArtifactMarkdownEditor] effect: no active path, triggering load for:", target);
void load(target);
return;
}
if (target === active) return;
if (target === active) {
console.debug("[ArtifactMarkdownEditor] effect: target matches active, no-op");
return;
}

if (!dirty()) {
console.debug("[ArtifactMarkdownEditor] effect: different target, not dirty, loading:", target);
void load(target);
return;
}

console.debug("[ArtifactMarkdownEditor] effect: different target with dirty content, prompting switch");
setPendingPath(target);
setPendingReason("switch");
});
Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/app/components/session/artifacts-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ export default function ArtifactsPanel(props: ArtifactsPanelProps) {
openable() ? "hover:bg-dls-active" : "cursor-default"
}`}
onClick={() => {
console.debug("[ArtifactsPanel] click:", {
path: artifact.path,
kind: artifact.kind,
hasOnOpenMarkdown: typeof props.onOpenMarkdown === "function",
hasOnOpenImage: typeof props.onOpenImage === "function",
});
if (md()) props.onOpenMarkdown?.(artifact.path);
else if (img()) props.onOpenImage?.(artifact.path);
}}
Expand Down
104 changes: 98 additions & 6 deletions packages/app/src/app/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -709,51 +709,143 @@ export default function SessionView(props: SessionViewProps) {

const toWorkspaceRelativeForApi = (file: string) => {
const normalized = normalizeSidebarPath(file).replace(/^file:\/\//i, "");
if (!normalized) return "";
if (!normalized) {
console.debug("[toWorkspaceRelativeForApi] empty after normalization:", { original: file });
return "";
}

const root = normalizeSidebarPath(props.activeWorkspaceRoot).replace(/\/+$/, "");
const rootKey = root.toLowerCase();
const fileKey = normalized.toLowerCase();

console.debug("[toWorkspaceRelativeForApi] processing:", { file, normalized, root, rootKey, fileKey });

// Case 1: Full absolute path that starts with workspace root
if (root && fileKey.startsWith(`${rootKey}/`)) {
return normalized.slice(root.length + 1);
const result = normalized.slice(root.length + 1);
console.debug("[toWorkspaceRelativeForApi] stripped workspace root, result:", result);
return result;
}
if (root && fileKey === rootKey) {
console.debug("[toWorkspaceRelativeForApi] path equals workspace root, returning empty");
return "";
}

// Case 2: Truncated absolute path - check if any suffix of the workspace root
// matches a prefix of the file path. This handles cases like:
// - root: "/Users/foo/Library/Application Support/com.differentai.openwork.dev/workspaces/starter"
// - file: "Support/com.differentai.openwork.dev/workspaces/starter/test.md"
if (root) {
const rootSegments = root.split("/").filter(Boolean);
const fileSegments = normalized.split("/");

// Try to find where the file path overlaps with the workspace root
for (let i = 1; i < rootSegments.length; i++) {
const rootSuffix = rootSegments.slice(i).join("/").toLowerCase();
const rootSuffixWithSlash = `${rootSuffix}/`;

if (fileKey.startsWith(rootSuffixWithSlash)) {
const result = normalized.slice(rootSuffix.length + 1);
console.debug("[toWorkspaceRelativeForApi] found overlapping suffix, stripped:", {
rootSuffix,
result,
});
return result;
}
if (fileKey === rootSuffix) {
console.debug("[toWorkspaceRelativeForApi] file matches root suffix exactly");
return "";
}
}

// Also check for workspace name/id match in file path segments
// e.g., path contains "workspaces/starter/" where "starter" is the workspace folder name
const workspaceFolderName = rootSegments[rootSegments.length - 1]?.toLowerCase();
if (workspaceFolderName) {
const workspacesPattern = new RegExp(`workspaces/${workspaceFolderName}/`, "i");
const match = normalized.match(workspacesPattern);
if (match && match.index !== undefined) {
const result = normalized.slice(match.index + match[0].length);
console.debug("[toWorkspaceRelativeForApi] found workspaces/name pattern, result:", result);
return result;
}
}
}

let relative = normalized.replace(/^\.\/+/, "");
if (!relative) return "";
if (!relative) {
console.debug("[toWorkspaceRelativeForApi] empty after stripping ./");
return "";
}

// Tool output paths sometimes carry git-style prefixes (a/ or b/).
if (/^[ab]\/.+\.(md|mdx|markdown)$/i.test(relative)) {
relative = relative.slice(2);
console.debug("[toWorkspaceRelativeForApi] stripped git prefix (a/ or b/):", relative);
}

// Some tool outputs include a leading "workspace/" prefix.
if (/^workspace\//i.test(relative)) {
relative = relative.replace(/^workspace\//i, "");
console.debug("[toWorkspaceRelativeForApi] stripped workspace/ prefix:", relative);
}

// Other surfaces include an absolute-style "/workspace/<path>" prefix.
if (/^\/+workspace\//i.test(relative)) {
relative = relative.replace(/^\/+workspace\//i, "");
console.debug("[toWorkspaceRelativeForApi] stripped /workspace/ prefix:", relative);
}
if (relative.startsWith("/") || relative.startsWith("~") || /^[a-zA-Z]:\//.test(relative)) return "";
if (relative.split("/").some((part) => part === "." || part === "..")) return "";

if (relative.startsWith("/") || relative.startsWith("~") || /^[a-zA-Z]:\//.test(relative)) {
console.debug("[toWorkspaceRelativeForApi] rejected absolute path:", relative);
return "";
}
if (relative.split("/").some((part) => part === "." || part === "..")) {
console.debug("[toWorkspaceRelativeForApi] rejected path with . or .. segments:", relative);
return "";
}

// Reject paths that look like truncated system paths (contain app bundle identifiers)
if (/com\.[^/]+\.(openwork|opencode)/i.test(relative)) {
console.debug("[toWorkspaceRelativeForApi] rejected truncated system path:", relative);
return "";
}

console.debug("[toWorkspaceRelativeForApi] final result:", relative);
return relative;
};

const openMarkdownEditor = (file: string) => {
console.debug("[openMarkdownEditor] requested file:", file, {
workspaceRoot: props.activeWorkspaceRoot,
hasClient: !!props.openworkServerClient,
workspaceId: props.openworkServerWorkspaceId,
});

// Check prerequisites before attempting to open
if (!props.openworkServerClient) {
console.warn("[openMarkdownEditor] no openwork server client available");
setToastMessage("Cannot open file: not connected to OpenWork server.");
return;
}
if (!props.openworkServerWorkspaceId) {
console.warn("[openMarkdownEditor] no workspace ID available");
setToastMessage("Cannot open file: no workspace selected.");
return;
}

const relative = toWorkspaceRelativeForApi(file);
if (!relative) {
setToastMessage("Only worker-relative files can be opened here.");
console.warn("[openMarkdownEditor] path resolution failed for:", file);
setToastMessage(`Cannot open file: path "${file}" is not within the workspace.`);
return;
}
if (!/\.(md|mdx|markdown)$/i.test(relative)) {
console.warn("[openMarkdownEditor] not a markdown file:", relative);
setToastMessage("Only markdown files can be edited here right now.");
return;
}
console.debug("[openMarkdownEditor] opening resolved path:", relative);
setMarkdownEditorPath(relative);
setMarkdownEditorOpen(true);
};
Expand Down
50 changes: 44 additions & 6 deletions packages/app/src/app/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/"
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;
};
Expand Down Expand Up @@ -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,
Expand Down
Loading