diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index 9c793349f..ca20f8e0c 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -2076,6 +2076,7 @@ export default function App() { refreshHubSkills, refreshPlugins, addPlugin, + removePlugin, importLocalSkill, installSkillCreator, installHubSkill, @@ -5636,6 +5637,7 @@ export default function App() { isPluginInstalled: isPluginInstalledByName, suggestedPlugins: SUGGESTED_PLUGINS, addPlugin, + removePlugin, createSessionAndOpen, setPrompt, selectSession: selectSession, diff --git a/packages/app/src/app/components/session/artifact-markdown-editor.tsx b/packages/app/src/app/components/session/artifact-markdown-editor.tsx deleted file mode 100644 index 3f26426f2..000000000 --- a/packages/app/src/app/components/session/artifact-markdown-editor.tsx +++ /dev/null @@ -1,395 +0,0 @@ -import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; -import { FileText, RefreshCcw, Save, X } from "lucide-solid"; - -import Button from "../button"; -import LiveMarkdownEditor from "../live-markdown-editor"; -import type { OpenworkServerClient, OpenworkWorkspaceFileContent, OpenworkWorkspaceFileWriteResult } from "../../lib/openwork-server"; -import { OpenworkServerError } from "../../lib/openwork-server"; - -export type ArtifactMarkdownEditorProps = { - open: boolean; - path: string | null; - workspaceId: string | null; - client: OpenworkServerClient | null; - onClose: () => void; - onToast?: (message: string) => void; -}; - -const isMarkdown = (value: string) => /\.(md|mdx|markdown)$/i.test(value); -const basename = (value: string) => value.split(/[/\\]/).filter(Boolean).pop() ?? value; - -export default function ArtifactMarkdownEditor(props: ArtifactMarkdownEditorProps) { - const [loading, setLoading] = createSignal(false); - const [saving, setSaving] = createSignal(false); - const [error, setError] = createSignal(null); - const [original, setOriginal] = createSignal(""); - const [draft, setDraft] = createSignal(""); - const [loadedPath, setLoadedPath] = createSignal(null); - const [resolvedPath, setResolvedPath] = createSignal(null); - const [baseUpdatedAt, setBaseUpdatedAt] = createSignal(null); - - const [confirmDiscardClose, setConfirmDiscardClose] = createSignal(false); - const [confirmOverwrite, setConfirmOverwrite] = createSignal(false); - - const [pendingPath, setPendingPath] = createSignal(null); - const [pendingReason, setPendingReason] = createSignal<"switch" | null>(null); - - const path = createMemo(() => props.path?.trim() ?? ""); - const title = createMemo(() => (path() ? basename(path()) : "Artifact")); - const dirty = createMemo(() => draft() !== original()); - const canWrite = createMemo(() => Boolean(props.client && props.workspaceId)); - const canSave = createMemo(() => dirty() && !saving() && canWrite()); - const writeDisabledReason = createMemo(() => { - if (canWrite()) return null; - return "Connect to an OpenWork server worker to edit files."; - }); - - const resetState = () => { - setLoading(false); - setSaving(false); - setError(null); - setOriginal(""); - setDraft(""); - setLoadedPath(null); - setResolvedPath(null); - setBaseUpdatedAt(null); - setConfirmDiscardClose(false); - setConfirmOverwrite(false); - setPendingPath(null); - setPendingReason(null); - }; - - const load = async (target: string) => { - const client = props.client; - const workspaceId = props.workspaceId; - - if (!client || !workspaceId) { - setError(writeDisabledReason()); - return; - } - if (!target) { - return; - } - if (!isMarkdown(target)) { - setError("Only markdown files are supported."); - return; - } - - setLoading(true); - setError(null); - setResolvedPath(null); - try { - let result: OpenworkWorkspaceFileContent; - let actualPath = target; - try { - result = (await client.readWorkspaceFile(workspaceId, target)) as OpenworkWorkspaceFileContent; - } 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/`. - // If the first lookup fails, retry there. - const candidateOutbox = `.opencode/openwork/outbox/${target}`.replace(/\/+/g, "/"); - const shouldTryOutbox = - !(target.startsWith(".opencode/openwork/outbox/") || target.startsWith("./.opencode/openwork/outbox/")) && - err instanceof OpenworkServerError && - err.status === 404; - - if (!shouldTryOutbox) { - throw err; - } - - actualPath = candidateOutbox; - try { - result = (await client.readWorkspaceFile(workspaceId, actualPath)) as OpenworkWorkspaceFileContent; - } catch (second) { - if (second instanceof OpenworkServerError && second.status === 404) { - throw new OpenworkServerError(404, "file_not_found", "File not found (workspace root or outbox)."); - } - throw second; - } - } - - setOriginal(result.content ?? ""); - setDraft(result.content ?? ""); - setLoadedPath(target); - setResolvedPath(actualPath); - setBaseUpdatedAt(typeof result.updatedAt === "number" ? result.updatedAt : null); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to load file"; - setError(message); - setLoadedPath(target); - } finally { - setLoading(false); - } - }; - - const save = async (options?: { force?: boolean }) => { - const client = props.client; - const workspaceId = props.workspaceId; - const target = resolvedPath() ?? path(); - if (!client || !workspaceId || !target) { - props.onToast?.("Cannot save: OpenWork server not connected"); - return; - } - if (!isMarkdown(target)) { - props.onToast?.("Only markdown files are supported"); - return; - } - if (!dirty()) return; - - setSaving(true); - setError(null); - try { - const result = (await client.writeWorkspaceFile(workspaceId, { - path: target, - content: draft(), - baseUpdatedAt: baseUpdatedAt(), - force: options?.force ?? false, - })) as OpenworkWorkspaceFileWriteResult; - - setOriginal(draft()); - setBaseUpdatedAt(typeof result.updatedAt === "number" ? result.updatedAt : null); - - if (pendingPath() && pendingReason() === "switch") { - const next = pendingPath(); - setPendingPath(null); - setPendingReason(null); - if (next) void load(next); - } - } catch (err) { - if (err instanceof OpenworkServerError && err.status === 409) { - setConfirmOverwrite(true); - return; - } - const message = err instanceof Error ? err.message : "Failed to save"; - setError(message); - props.onToast?.(message); - } finally { - setSaving(false); - } - }; - - const requestClose = () => { - if (!dirty()) { - resetState(); - props.onClose(); - return; - } - setConfirmDiscardClose(true); - }; - - const requestReload = () => { - const target = path(); - if (!target) return; - if (!dirty()) { - void load(target); - return; - } - // Reload is destructive; reuse the close-discard banner semantics. - setError("Discard changes to reload from disk (close and reopen), or save first."); - }; - - createEffect(() => { - if (!props.open) { - resetState(); - return; - } - - const target = path(); - if (!target || loading() || pendingReason() === "switch") return; - - const active = loadedPath(); - if (!active) { - void load(target); - return; - } - if (target === active) return; - - if (!dirty()) { - void load(target); - return; - } - - setPendingPath(target); - setPendingReason("switch"); - }); - - createEffect(() => { - if (!props.open) return; - const onKeyDown = (event: KeyboardEvent) => { - if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") { - event.preventDefault(); - if (canSave()) void save(); - return; - } - if (event.key === "Escape") { - event.preventDefault(); - requestClose(); - } - }; - window.addEventListener("keydown", onKeyDown); - onCleanup(() => window.removeEventListener("keydown", onKeyDown)); - }); - - return ( - -
-
-
- -
-
-
{title()}
- - - Unsaved - - -
-
- {path()} -
-
-
- -
- - - -
-
- - - {(reason) => ( -
- {reason()} -
- )} -
- - - {(message) => ( -
- {message()} -
- )} -
- - -
-
File changed since load. Overwrite anyway?
-
- - -
-
-
- - -
-
Discard unsaved changes and close?
-
- - -
-
-
- - -
-
- Switch to {pendingPath()} -
-
- - - -
-
-
- -
- -
-
-
- ); -} diff --git a/packages/app/src/app/components/session/artifacts-panel.tsx b/packages/app/src/app/components/session/artifacts-panel.tsx index 1502a69c8..99409bf79 100644 --- a/packages/app/src/app/components/session/artifacts-panel.tsx +++ b/packages/app/src/app/components/session/artifacts-panel.tsx @@ -4,8 +4,9 @@ import { Paperclip } from "lucide-solid"; export type ArtifactsPanelProps = { files: string[]; workspaceRoot?: string; - onOpenMarkdown?: (path: string) => void; - onOpenImage?: (path: string) => void; + onRevealArtifact?: (path: string) => void; + onOpenInObsidian?: (path: string) => void; + obsidianAvailable?: boolean; maxPreview?: number; id?: string; }; @@ -88,8 +89,10 @@ export default function ArtifactsPanel(props: ArtifactsPanelProps) { return Math.max(0, total - shown); }); - const canOpenMarkdown = createMemo(() => typeof props.onOpenMarkdown === "function"); - const canOpenImage = createMemo(() => typeof props.onOpenImage === "function"); + const canRevealArtifact = createMemo(() => typeof props.onRevealArtifact === "function"); + const canOpenObsidian = createMemo( + () => Boolean(props.obsidianAvailable) && typeof props.onOpenInObsidian === "function", + ); const prettyPath = (file: string) => toWorkspaceRelative(file, props.workspaceRoot); return ( @@ -115,25 +118,10 @@ export default function ArtifactsPanel(props: ArtifactsPanelProps) { const dir = () => getDirname(display()); const md = () => artifact.kind === "markdown"; const img = () => artifact.kind === "image"; - const openable = () => (md() ? canOpenMarkdown() : img() ? canOpenImage() : false); - const tooltip = () => { - if (md()) return display(); - if (img() && !canOpenImage()) return `${display()} (image preview coming soon)`; - return display(); - }; return ( - +
+ + + + + + +
+ ); }} diff --git a/packages/app/src/app/components/session/workspace-session-list.tsx b/packages/app/src/app/components/session/workspace-session-list.tsx index d444a0b93..81be13ad5 100644 --- a/packages/app/src/app/components/session/workspace-session-list.tsx +++ b/packages/app/src/app/components/session/workspace-session-list.tsx @@ -4,7 +4,7 @@ import { ChevronDown, ChevronRight, HeartPulse, Loader2, MoreHorizontal, Plus } import type { OpenworkSoulStatus } from "../../lib/openwork-server"; import type { WorkspaceInfo } from "../../lib/tauri"; import type { WorkspaceSessionGroup } from "../../types"; -import { formatRelativeTime, getWorkspaceTaskLoadErrorDisplay } from "../../utils"; +import { formatRelativeTime, getWorkspaceTaskLoadErrorDisplay, isWindowsPlatform } from "../../utils"; type Props = { workspaceSessionGroups: WorkspaceSessionGroup[]; @@ -20,6 +20,7 @@ type Props = { onOpenRenameWorkspace: (workspaceId: string) => void; onShareWorkspace: (workspaceId: string) => void; onOpenSoul: (workspaceId: string) => void; + onRevealWorkspace: (workspaceId: string) => void; onTestWorkspaceConnection: (workspaceId: string) => Promise | boolean | void; onEditWorkspaceConnection: (workspaceId: string) => void; onForgetWorkspace: (workspaceId: string) => void; @@ -48,6 +49,7 @@ const workspaceKindLabel = (workspace: WorkspaceInfo) => : "Local"; export default function WorkspaceSessionList(props: Props) { + const revealLabel = isWindowsPlatform() ? "Reveal in Explorer" : "Reveal in Finder"; const [expandedWorkspaceIds, setExpandedWorkspaceIds] = createSignal>(new Set()); const [previewCountByWorkspaceId, setPreviewCountByWorkspaceId] = createSignal>({}); const [workspaceMenuId, setWorkspaceMenuId] = createSignal(null); @@ -287,6 +289,18 @@ export default function WorkspaceSessionList(props: Props) { > {soulEnabled() ? "Soul settings" : "Enable soul"} + + + + )} diff --git a/packages/app/src/app/pages/session.tsx b/packages/app/src/app/pages/session.tsx index d6a5e1a16..ae1111138 100644 --- a/packages/app/src/app/pages/session.tsx +++ b/packages/app/src/app/pages/session.tsx @@ -20,7 +20,13 @@ import type { WorkspaceSessionGroup, } from "../types"; -import type { EngineInfo, OpenworkServerInfo, WorkspaceInfo } from "../lib/tauri"; +import { + obsidianIsAvailable, + openInObsidian, + type EngineInfo, + type OpenworkServerInfo, + type WorkspaceInfo, +} from "../lib/tauri"; import { Box, @@ -69,6 +75,7 @@ import { DEFAULT_OPENWORK_PUBLISHER_BASE_URL, publishOpenworkBundleJson } from " import { join } from "@tauri-apps/api/path"; import { isTauriRuntime, + isWindowsPlatform, normalizeDirectoryPath, parseTemplateFrontmatter, } from "../utils"; @@ -85,7 +92,6 @@ import FlyoutItem from "../components/flyout-item"; import QuestionModal from "../components/question-modal"; import ArtifactsPanel from "../components/session/artifacts-panel"; import InboxPanel from "../components/session/inbox-panel"; -import ArtifactMarkdownEditor from "../components/session/artifact-markdown-editor"; export type SessionViewProps = { selectedSessionId: string | null; @@ -318,8 +324,7 @@ export default function SessionView(props: SessionViewProps) { const [messageWindowSessionId, setMessageWindowSessionId] = createSignal(null); const [messageWindowExpanded, setMessageWindowExpanded] = createSignal(false); - const [markdownEditorOpen, setMarkdownEditorOpen] = createSignal(false); - const [markdownEditorPath, setMarkdownEditorPath] = createSignal(null); + const [obsidianAvailable, setObsidianAvailable] = createSignal(false); // When a session is selected (i.e. we are in SessionView), the right sidebar is // navigation-only. Avoid showing any tab as "selected" to reduce confusion. @@ -327,6 +332,25 @@ export default function SessionView(props: SessionViewProps) { let commandPaletteInputEl: HTMLInputElement | undefined; const commandPaletteOptionRefs: HTMLButtonElement[] = []; + createEffect(() => { + if (!isTauriRuntime()) { + setObsidianAvailable(false); + return; + } + let cancelled = false; + void (async () => { + try { + const available = await obsidianIsAvailable(); + if (!cancelled) setObsidianAvailable(available); + } catch { + if (!cancelled) setObsidianAvailable(false); + } + })(); + onCleanup(() => { + cancelled = true; + }); + }); + const agentLabel = createMemo(() => props.selectedSessionAgent ?? "Default agent"); const workspaceLabel = (workspace: WorkspaceInfo) => workspace.displayName?.trim() || @@ -730,86 +754,91 @@ export default function SessionView(props: SessionViewProps) { return out; }); - const normalizeSidebarPath = (value: string) => String(value ?? "").trim().replace(/[\\/]+/g, "/"); + const resolveArtifactLocalPath = async (file: string) => { + const trimmed = file.trim(); + if (!trimmed) return null; + const root = props.activeWorkspaceRoot.trim(); + if (!isAbsolutePath(trimmed) && !root) return null; + return !isAbsolutePath(trimmed) && root ? await join(root, trimmed) : trimmed; + }; - const toWorkspaceRelativeForApi = (file: string) => { - const normalized = normalizeSidebarPath(file).replace(/^file:\/\//i, ""); - if (!normalized) return ""; - - const root = normalizeSidebarPath(props.activeWorkspaceRoot).replace(/\/+$/, ""); - const rootKey = root.toLowerCase(); - const fileKey = normalized.toLowerCase(); - - if (root && fileKey.startsWith(`${rootKey}/`)) { - return normalized.slice(root.length + 1); + const revealArtifact = async (file: string) => { + if (props.activeWorkspaceDisplay.workspaceType === "remote") { + setToastMessage("Reveal is unavailable for remote workers."); + return; } - if (root && fileKey === rootKey) { - return ""; + if (!isTauriRuntime()) { + setToastMessage("Reveal is available in the desktop app."); + 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 ""; + try { + const target = await resolveArtifactLocalPath(file); + if (!target) { + setToastMessage("Pick a worker to reveal files."); + return; } + const { openPath, revealItemInDir } = await import("@tauri-apps/plugin-opener"); + if (isWindowsPlatform()) { + await openPath(target); + } else { + await revealItemInDir(target); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Unable to reveal file"; + setToastMessage(message); } - - let relative = normalized.replace(/^\.\/+/, ""); - if (!relative) return ""; - - // Tool output paths sometimes carry git-style prefixes (a/ or b/). - if (/^[ab]\/.+\.(md|mdx|markdown)$/i.test(relative)) { - relative = relative.slice(2); - } - - // Some tool outputs include a leading "workspace/" prefix. - if (/^workspace\//i.test(relative)) { - relative = relative.replace(/^workspace\//i, ""); - } - - // Other surfaces include an absolute-style "/workspace/" prefix. - 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."); + const openArtifactInObsidian = async (file: string) => { + if (!/\.(md|mdx|markdown)$/i.test(file)) return; + if (!obsidianAvailable()) { + setToastMessage("Obsidian is not available on this system."); return; } - if (!props.openworkServerWorkspaceId) { - setToastMessage("Cannot open file: no workspace selected."); + if (props.activeWorkspaceDisplay.workspaceType === "remote") { + setToastMessage("Open in Obsidian is unavailable for remote workers."); return; } - - const relative = toWorkspaceRelativeForApi(file); - if (!relative) { - setToastMessage(`Cannot open file: path "${file}" is not within the workspace.`); + if (!isTauriRuntime()) { + setToastMessage("Open in Obsidian is available in the desktop app."); return; } - if (!/\.(md|mdx|markdown)$/i.test(relative)) { - setToastMessage("Only markdown files can be edited here right now."); - return; + try { + const target = await resolveArtifactLocalPath(file); + if (!target) { + setToastMessage("Pick a worker to open files."); + return; + } + await openInObsidian(target); + } catch (error) { + const message = error instanceof Error ? error.message : "Unable to open file in Obsidian"; + setToastMessage(message); } - setMarkdownEditorPath(relative); - setMarkdownEditorOpen(true); }; - const closeMarkdownEditor = () => { - setMarkdownEditorOpen(false); - setMarkdownEditorPath(null); + const revealWorkspaceInFinder = async (workspaceId: string) => { + const workspace = props.workspaces.find((entry) => entry.id === workspaceId) ?? null; + if (!workspace || workspace.workspaceType !== "local") return; + const target = workspace.path?.trim() ?? ""; + if (!target) { + setToastMessage("Workspace path is unavailable."); + return; + } + if (!isTauriRuntime()) { + setToastMessage("Reveal is available in the desktop app."); + return; + } + try { + const { openPath, revealItemInDir } = await import("@tauri-apps/plugin-opener"); + if (isWindowsPlatform()) { + await openPath(target); + } else { + await revealItemInDir(target); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Unable to reveal workspace"; + setToastMessage(message); + } }; const todoLabel = createMemo(() => { const total = todoCount(); @@ -2687,6 +2716,7 @@ export default function SessionView(props: SessionViewProps) { onOpenRenameWorkspace={props.openRenameWorkspace} onShareWorkspace={(workspaceId) => setShareWorkspaceId(workspaceId)} onOpenSoul={openSoul} + onRevealWorkspace={revealWorkspaceInFinder} onTestWorkspaceConnection={props.testWorkspaceConnection} onEditWorkspaceConnection={props.editWorkspaceConnection} onForgetWorkspace={props.forgetWorkspace} @@ -3049,18 +3079,6 @@ export default function SessionView(props: SessionViewProps) { - - - 0}> @@ -3284,7 +3302,9 @@ export default function SessionView(props: SessionViewProps) { id="sidebar-artifacts" files={touchedFiles()} workspaceRoot={props.activeWorkspaceRoot} - onOpenMarkdown={openMarkdownEditor} + onRevealArtifact={revealArtifact} + onOpenInObsidian={openArtifactInObsidian} + obsidianAvailable={obsidianAvailable()} /> diff --git a/packages/desktop/src-tauri/src/commands/misc.rs b/packages/desktop/src-tauri/src/commands/misc.rs index bc7891d3a..6e6e3f69d 100644 --- a/packages/desktop/src-tauri/src/commands/misc.rs +++ b/packages/desktop/src-tauri/src/commands/misc.rs @@ -271,6 +271,62 @@ pub fn app_build_info(app: AppHandle) -> AppBuildInfo { } } +#[tauri::command] +pub fn obsidian_is_available() -> bool { + #[cfg(target_os = "macos")] + { + let mut candidates = vec![PathBuf::from("/Applications/Obsidian.app")]; + if let Some(home) = home_dir() { + candidates.push(home.join("Applications").join("Obsidian.app")); + } + return candidates.into_iter().any(|path| path.exists()); + } + + #[cfg(not(target_os = "macos"))] + { + false + } +} + +#[tauri::command] +pub fn open_in_obsidian(file_path: String) -> Result<(), String> { + let trimmed = file_path.trim(); + if trimmed.is_empty() { + return Err("file_path is required".to_string()); + } + + let path = PathBuf::from(trimmed); + if !path.is_absolute() { + return Err("file_path must be an absolute path".to_string()); + } + if !path.exists() { + return Err(format!("File does not exist: {}", path.display())); + } + + #[cfg(target_os = "macos")] + { + if !obsidian_is_available() { + return Err("Obsidian is not installed.".to_string()); + } + + let status = std::process::Command::new("open") + .arg("-a") + .arg("Obsidian") + .arg(&path) + .status() + .map_err(|e| format!("Failed to launch Obsidian: {e}"))?; + if status.success() { + return Ok(()); + } + return Err(format!("Failed to launch Obsidian (exit status: {status}).")); + } + + #[cfg(not(target_os = "macos"))] + { + Err("Open in Obsidian is currently supported on macOS only.".to_string()) + } +} + #[tauri::command] pub fn opencode_db_migrate( app: AppHandle, diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 97b36c237..10e5aabc5 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -22,8 +22,8 @@ use commands::command_files::{ use commands::config::{read_opencode_config, write_opencode_config}; use commands::engine::{engine_doctor, engine_info, engine_install, engine_start, engine_stop}; use commands::misc::{ - app_build_info, opencode_db_migrate, opencode_mcp_auth, reset_opencode_cache, - reset_openwork_state, + app_build_info, obsidian_is_available, open_in_obsidian, opencode_db_migrate, opencode_mcp_auth, + reset_opencode_cache, reset_openwork_state, }; use commands::opencode_router::{ opencodeRouter_config_set, opencodeRouter_info, opencodeRouter_start, opencodeRouter_status, @@ -134,6 +134,8 @@ pub fn run() { write_opencode_config, updater_environment, app_build_info, + obsidian_is_available, + open_in_obsidian, reset_openwork_state, reset_opencode_cache, opencode_db_migrate,