diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 40f0f91b..4ae7c995 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -27,7 +27,7 @@ jobs: cache: "npm" - run: npm ci - run: npm run lint - - run: npm run build --if-present + - run: npm run test:mobile-load - run: npm test - name: Run Integration tests # Only run integration tests for trusted events (push) or when secrets are available diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 961afe67..ca19cbe6 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -41,6 +41,28 @@ if (typeof import_meta === 'undefined') { url: typeof __filename !== 'undefined' ? 'file://' + __filename : 'file:///obsidian-plugin' }; } + +// Minimal \`process\` shim so the bundle survives MODULE EVALUATION on Obsidian +// mobile, whose WebView has no Node \`process\` global. Desktop-only code (Agent +// Mode) is statically imported and therefore evaluated on every platform; many +// of those modules — and bundled Node deps — read \`process.platform\`/\`.env\` at +// module scope, which throws "Can't find variable: process" on mobile and kills +// the whole plugin at load. On desktop (Electron) \`globalThis.process\` exists +// and is used as-is, so this only stubs mobile, where those code paths never run. +var process = globalThis.process || { + env: {}, + platform: '', + arch: '', + argv: [], + version: '', + versions: {}, + cwd: function () { return '/'; }, + nextTick: function (cb) { Promise.resolve().then(cb); }, + on: function () {}, + off: function () {}, + once: function () {}, + emit: function () { return false; }, +}; `; const prod = process.argv[2] === "production"; diff --git a/package.json b/package.json index a842fdba..b1380c6f 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "format:check": "prettier --check 'src/**/*.{js,ts,tsx,md}'", "version": "node version-bump.mjs", "test": "jest --testPathIgnorePatterns=src/integration_tests/", + "test:mobile-load": "npm run build && node scripts/mobile-load-smoke.cjs", "test:integration": "jest src/integration_tests/", "prepare": "husky", "prompt:debug": "node scripts/printPromptDebug.js", diff --git a/scripts/mobile-load-smoke.cjs b/scripts/mobile-load-smoke.cjs new file mode 100644 index 00000000..b35dd7b9 --- /dev/null +++ b/scripts/mobile-load-smoke.cjs @@ -0,0 +1,370 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); + +const repoRoot = path.resolve(__dirname, ".."); +const failures = []; + +const loadCriticalFiles = [ + "src/main.ts", + "src/commands/index.ts", + "src/settings/SettingsPage.tsx", + "src/settings/v2/SettingsMainV2.tsx", + "src/settings/v2/components/AdvancedSettings.tsx", + "src/components/chat-components/plugins/SlashCommandPlugin.tsx", + "src/components/chat-components/plugins/slashMenuItems.ts", +]; + +const nodeModuleIds = new Set([ + "async_hooks", + "buffer", + "child_process", + "crypto", + "electron", + "events", + "fs", + "fs/promises", + "module", + "os", + "path", + "process", + "readline", + "stream", + "url", + "util", + "node:async_hooks", + "node:buffer", + "node:child_process", + "node:crypto", + "node:events", + "node:fs", + "node:fs/promises", + "node:module", + "node:os", + "node:path", + "node:process", + "node:readline", + "node:stream", + "node:url", + "node:util", +]); + +function readRepoFile(relativePath) { + return fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); +} + +function fail(message) { + failures.push(message); +} + +function formatError(error) { + if (!(error instanceof Error)) return String(error); + + const stackLines = (error.stack ?? "") + .split("\n") + .filter((line) => !line.includes("main.js:1:")) + .slice(0, 6); + + return [`${error.name}: ${error.message}`, ...stackLines.slice(1)].join("\n"); +} + +function isTypeOnlyImport(importStatement) { + if (/^import\s+type\b/.test(importStatement.trim())) return true; + const named = importStatement.match(/import\s*\{([^}]*)\}\s*from/); + if (!named) return false; + const specifiers = named[1] + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + return specifiers.length > 0 && specifiers.every((part) => part.startsWith("type ")); +} + +function checkAgentModeImportBoundaries() { + const staticAgentModeImport = + /import\s+(?:type\s+)?[^;]+?\s+from\s+["']@\/agentMode(?:\/[^"']*)?["']\s*;?/g; + const dynamicAgentModeImport = /import\s*\(\s*["']@\/agentMode(?:\/[^"']*)?["']\s*\)/g; + + for (const relativePath of loadCriticalFiles) { + const source = readRepoFile(relativePath); + + for (const match of source.matchAll(staticAgentModeImport)) { + const statement = match[0]; + if (!isTypeOnlyImport(statement)) { + fail(`${relativePath}: value import from Agent Mode is on the mobile load path.`); + } + } + + const dynamicImports = Array.from(source.matchAll(dynamicAgentModeImport)); + if (dynamicImports.length > 0) { + if (!source.includes("Platform.isDesktopApp")) { + fail(`${relativePath}: dynamic Agent Mode import must be gated by Platform.isDesktopApp.`); + } + if (source.includes("Platform.isMobile")) { + fail( + `${relativePath}: Agent Mode gates should use Platform.isDesktopApp, not Platform.isMobile.` + ); + } + } + } +} + +function createCallableStub(name) { + function Stub() {} + Object.defineProperty(Stub, "name", { value: name.replace(/[^A-Za-z0-9_$]/g, "_") || "Stub" }); + return new Proxy(Stub, { + apply() { + return undefined; + }, + construct() { + return {}; + }, + get(target, prop) { + if (prop === "prototype") return target.prototype; + if (prop === Symbol.toStringTag) return name; + return createCallableStub(`${name}.${String(prop)}`); + }, + }); +} + +function createPoisonModule(id) { + return new Proxy( + {}, + { + get(_target, prop) { + if (prop === Symbol.toStringTag) return id; + throw new Error( + `mobile-load-smoke: desktop-only module '${id}' was accessed at '${String(prop)}'.` + ); + }, + } + ); +} + +function isObsidianBrowserExternal(id) { + return id.startsWith("@codemirror/") || id.startsWith("@lezer/"); +} + +function createObsidianStub() { + class Component { + registerEvent() {} + registerDomEvent() {} + registerInterval() {} + load() {} + unload() {} + } + + class Plugin extends Component { + constructor(app, manifest) { + super(); + this.app = app; + this.manifest = manifest ?? { id: "copilot", version: "mobile-load-smoke" }; + } + + addCommand() {} + addRibbonIcon() { + return { addClass() {}, removeClass() {}, setAttribute() {} }; + } + addSettingTab() {} + loadData() { + return Promise.resolve(null); + } + saveData() { + return Promise.resolve(); + } + registerView() {} + registerEditorExtension() {} + } + + class PluginSettingTab { + constructor(app, plugin) { + this.app = app; + this.plugin = plugin; + this.containerEl = { empty() {}, addClass() {}, createDiv: () => ({}) }; + } + } + + class ItemView extends Component { + constructor(leaf) { + super(); + this.leaf = leaf; + this.containerEl = {}; + this.contentEl = {}; + } + } + + class Modal extends Component { + constructor(app) { + super(); + this.app = app; + } + open() {} + close() {} + } + + class Notice { + constructor() {} + } + + class TFile {} + class TFolder {} + class FileSystemAdapter {} + class MarkdownView {} + + const obsidian = { + App: class App {}, + Component, + FileSystemAdapter, + ItemView, + MarkdownView, + Modal, + Notice, + Platform: Object.freeze({ + isDesktop: false, + isDesktopApp: false, + isMobile: true, + isMobileApp: true, + isMacOS: false, + isPhone: true, + isTablet: false, + isWin: false, + }), + Plugin, + PluginSettingTab, + TFile, + TFolder, + WorkspaceLeaf: class WorkspaceLeaf {}, + MarkdownRenderer: { render: async () => {} }, + addIcon() {}, + debounce(fn) { + return fn; + }, + moment: () => ({ format: () => "" }), + normalizePath(value) { + return String(value).replace(/\\/g, "/"); + }, + parseYaml() { + return {}; + }, + requestUrl: async () => ({ json: {}, text: "", status: 200 }), + stringifyYaml() { + return ""; + }, + }; + + return new Proxy(obsidian, { + get(target, prop) { + if (prop in target) return target[prop]; + return createCallableStub(`obsidian.${String(prop)}`); + }, + }); +} + +class SmokeEvent { + constructor(type, init = {}) { + this.type = type; + Object.assign(this, init); + } +} + +class SmokeCustomEvent extends SmokeEvent { + constructor(type, init = {}) { + super(type, init); + this.detail = init.detail; + } +} + +class SmokeEventTarget { + addEventListener() {} + removeEventListener() {} + dispatchEvent() { + return true; + } +} + +function runBundleEvaluationSmoke() { + const bundlePath = path.join(repoRoot, "main.js"); + if (!fs.existsSync(bundlePath)) { + fail("main.js is missing. Run npm run build before the mobile-load smoke test."); + return; + } + + const source = fs.readFileSync(bundlePath, "utf8"); + const module = { exports: {} }; + const context = { + AbortController, + clearInterval, + clearTimeout, + console, + crypto: { + getRandomValues(array) { + array.fill(7); + return array; + }, + randomUUID() { + return "00000000-0000-4000-8000-000000000000"; + }, + }, + CustomEvent: SmokeCustomEvent, + Event: SmokeEvent, + EventTarget: SmokeEventTarget, + fetch: async () => ({ ok: true, json: async () => ({}), text: async () => "", body: null }), + Blob, + module, + exports: module.exports, + FormData, + Headers, + navigator: { userAgent: "ObsidianMobileSmoke/1.0" }, + Promise, + queueMicrotask, + ReadableStream, + Request, + Response, + require(id) { + if (id === "obsidian") return createObsidianStub(); + if (isObsidianBrowserExternal(id)) return createCallableStub(id); + if (nodeModuleIds.has(id)) return createPoisonModule(id); + throw new Error(`mobile-load-smoke: unexpected external require '${id}'.`); + }, + setInterval, + setTimeout, + TextDecoder, + TextEncoder, + TransformStream, + URL, + URLSearchParams, + WritableStream, + }; + context.globalThis = context; + context.self = context; + context.window = context; + + try { + vm.runInNewContext(source, context, { + filename: "main.js", + timeout: 5000, + }); + } catch (error) { + fail(`main.js failed mobile bundle evaluation: ${formatError(error)}`); + return; + } + + const pluginExport = module.exports.default ?? module.exports; + if (typeof pluginExport !== "function") { + fail("main.js did not export the plugin class."); + } +} + +checkAgentModeImportBoundaries(); +runBundleEvaluationSmoke(); + +if (failures.length > 0) { + console.error("Mobile load smoke test failed:"); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); +} + +console.log("Mobile load smoke test passed."); diff --git a/scripts/test-vault.sh b/scripts/test-vault.sh index c5448baa..4b41a41e 100755 --- a/scripts/test-vault.sh +++ b/scripts/test-vault.sh @@ -59,6 +59,12 @@ DEPLOY_MODE="link" if [[ "$(uname -r 2>/dev/null)" == *[Mm]icrosoft* ]] && [[ "$VAULT_REAL" == /mnt/* ]]; then DEPLOY_MODE="copy" fi +# iCloud-synced vaults (Obsidian on iOS/iPadOS) can't use symlinks: iCloud +# uploads file contents, not link targets, so a symlinked main.js never reaches +# the phone. Copy real files so the mobile device receives the build. +if [[ "$VAULT_REAL" == *"/Mobile Documents/"* ]]; then + DEPLOY_MODE="copy" +fi echo "==> Installing dependencies" npm install --prefer-offline --no-audit --no-fund diff --git a/src/agentMode/index.ts b/src/agentMode/index.ts index 7a6a7e7a..1dfb3c1e 100644 --- a/src/agentMode/index.ts +++ b/src/agentMode/index.ts @@ -23,7 +23,7 @@ import { createDefaultPermissionPrompter, } from "./ui/permissionPrompter"; -export { AGENT_CHAT_MODE } from "./session/AgentChatPersistenceManager"; +export { AGENT_CHAT_MODE } from "@/constants"; export { AgentModeChat } from "./ui/AgentModeChat"; export { default as CopilotAgentView } from "./ui/CopilotAgentView"; export { @@ -61,7 +61,7 @@ export { PlanPreviewView, PLAN_PREVIEW_VIEW_TYPE } from "./ui/PlanPreviewView"; export type { PlanPreviewViewState } from "./ui/PlanPreviewView"; export { getActiveBackendDescriptor, listBackendDescriptors } from "./backends/registry"; export { frameSink as acpFrameSink, setFrameSinkVaultBasePath } from "./session/debugSink"; -export { SkillManager, SkillsSettings, useManagedSkills } from "./skills"; +export { getManagedSkills, SkillManager, SkillsSettings, useManagedSkills } from "./skills"; export type { Skill } from "./skills"; /** diff --git a/src/agentMode/session/AgentChatPersistenceManager.ts b/src/agentMode/session/AgentChatPersistenceManager.ts index 1526977c..f0795402 100644 --- a/src/agentMode/session/AgentChatPersistenceManager.ts +++ b/src/agentMode/session/AgentChatPersistenceManager.ts @@ -1,4 +1,4 @@ -import { AI_SENDER, USER_SENDER } from "@/constants"; +import { AGENT_CHAT_MODE, AI_SENDER, USER_SENDER } from "@/constants"; import { logError, logInfo, logWarn } from "@/logger"; import { getSettings } from "@/settings/model"; import { FormattedDateTime } from "@/types/message"; @@ -23,7 +23,6 @@ import type { AgentChatMessage, BackendId } from "./types"; const SAFE_FILENAME_BYTE_LIMIT = 100; export const AGENT_FILENAME_PREFIX = "agent__"; -export const AGENT_CHAT_MODE = "agent"; /** * Result of `loadFile` — restores display-only Agent Mode messages plus diff --git a/src/commands/index.ts b/src/commands/index.ts index be487f80..b34dece3 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -27,8 +27,7 @@ import { shouldUseMiyo } from "@/miyo/miyoUtils"; import { getAllQAMarkdownContent } from "@/search/searchUtils"; import { NoteSelectedTextContext, WebSelectedTextContext } from "@/types/message"; import { ensureFolderExists, isSourceModeOn } from "@/utils"; -import { Editor, MarkdownView, Notice, TFile } from "obsidian"; -import { isAgentModeEnabled } from "@/agentMode"; +import { Editor, MarkdownView, Notice, Platform, TFile } from "obsidian"; import { v4 as uuidv4 } from "uuid"; import { COMMAND_IDS, COMMAND_ICONS, COMMAND_NAMES, CommandId } from "@/constants"; import { setSelectedTextContexts } from "@/aiParams"; @@ -129,7 +128,7 @@ export function registerCommands(plugin: CopilotPlugin) { // Agent Mode is always on, but requires subprocess support — register the // agent commands on desktop only. - if (isAgentModeEnabled()) { + if (Platform.isDesktopApp) { addCommand(plugin, COMMAND_IDS.OPEN_AGENT_CHAT_WINDOW, () => { void plugin.activateAgentView(); }); diff --git a/src/components/chat-components/plugins/SlashCommandPlugin.tsx b/src/components/chat-components/plugins/SlashCommandPlugin.tsx index 0930d283..f759a50d 100644 --- a/src/components/chat-components/plugins/SlashCommandPlugin.tsx +++ b/src/components/chat-components/plugins/SlashCommandPlugin.tsx @@ -1,13 +1,13 @@ -import React, { useCallback, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { $getSelection, $isRangeSelection, TextNode } from "lexical"; import fuzzysort from "fuzzysort"; +import { Platform } from "obsidian"; -import { listBackendDescriptors, useIsAgentModeEnabled, useManagedSkills } from "@/agentMode"; -import type { BackendId } from "@/agentMode"; import { useCustomCommands } from "@/commands/state"; import { CustomCommandManager } from "@/commands/customCommandManager"; import { sortSlashCommands } from "@/commands/customCommandUtils"; +import { logError } from "@/logger"; import { useSettingsValue } from "@/settings/model"; import { TypeaheadMenuPortal } from "@/components/chat-components/TypeaheadMenuPortal"; import { TypeaheadOption } from "@/components/chat-components/TypeaheadMenuContent"; @@ -15,6 +15,7 @@ import { useTypeaheadPlugin, TypeaheadState, } from "@/components/chat-components/hooks/useTypeaheadPlugin"; +import type { BackendId, Skill } from "@/agentMode"; import { composeSlashMenuItems, type SlashMenuItem } from "./slashMenuItems"; @@ -22,6 +23,63 @@ interface SlashCommandOption extends TypeaheadOption { item: SlashMenuItem; } +interface AgentSlashData { + enabled: boolean; + skills: Skill[]; + backendIds: ReadonlySet | null; +} + +const EMPTY_SKILLS = Object.freeze([]) as unknown as Skill[]; +const EMPTY_AGENT_SLASH_DATA: AgentSlashData = Object.freeze({ + enabled: false, + skills: EMPTY_SKILLS, + backendIds: null, +}); + +function useAgentSlashData(): AgentSlashData { + const [data, setData] = useState(EMPTY_AGENT_SLASH_DATA); + + useEffect(() => { + if (!Platform.isDesktopApp) return; + + let cancelled = false; + let unsubscribe: (() => void) | undefined; + + async function loadAgentSlashData(): Promise { + const { getManagedSkills, SkillManager, listBackendDescriptors } = + await import("@/agentMode"); + + const update = () => { + if (cancelled) return; + setData({ + enabled: true, + skills: getManagedSkills(), + backendIds: new Set(listBackendDescriptors().map((descriptor) => descriptor.id)), + }); + }; + + update(); + + if (SkillManager.hasInstance()) { + unsubscribe = SkillManager.getInstance().subscribeToSkillSetChange(update); + } + } + + void loadAgentSlashData().catch((error) => { + if (!cancelled) { + logError("Failed to load Agent Mode slash commands.", error); + } + }); + + return () => { + cancelled = true; + unsubscribe?.(); + }; + }, []); + + return data; +} + /** * Resolve the active backend id for slash-menu filtering. Returns `null` * when Agent Mode is disabled, or when `activeBackend` is unset/unknown — @@ -29,14 +87,16 @@ interface SlashCommandOption extends TypeaheadOption { * skill) rather than silently filtering everything out, which is the * behaviour a typoed or stale setting would otherwise produce. */ -function useActiveSlashBackend(): BackendId | null { +function useActiveSlashBackend( + agentModeEnabled: boolean, + backendIds: ReadonlySet | null +): BackendId | null { const settings = useSettingsValue(); - const agentModeEnabled = useIsAgentModeEnabled(); if (!agentModeEnabled) return null; const activeBackend = settings.agentMode?.activeBackend; if (typeof activeBackend !== "string" || activeBackend.length === 0) return null; - const known = listBackendDescriptors().some((d) => d.id === activeBackend); - return known ? activeBackend : null; + if (!backendIds?.has(activeBackend)) return null; + return activeBackend; } /** @@ -55,8 +115,8 @@ function useActiveSlashBackend(): BackendId | null { export function SlashCommandPlugin(): JSX.Element { const [editor] = useLexicalComposerContext(); const commands = useCustomCommands(); - const skills = useManagedSkills(); - const activeBackend = useActiveSlashBackend(); + const agentSlashData = useAgentSlashData(); + const activeBackend = useActiveSlashBackend(agentSlashData.enabled, agentSlashData.backendIds); const [currentQuery, setCurrentQuery] = useState(""); // Recency / strategy sort happens before composition so the slash plugin @@ -64,7 +124,7 @@ export function SlashCommandPlugin(): JSX.Element { const sortedCommands = useMemo(() => sortSlashCommands(commands), [commands]); const allOptions = useMemo(() => { - const items = composeSlashMenuItems(skills, sortedCommands, activeBackend); + const items = composeSlashMenuItems(agentSlashData.skills, sortedCommands, activeBackend); return items.map((item) => ({ key: item.key, title: item.name, @@ -74,7 +134,7 @@ export function SlashCommandPlugin(): JSX.Element { content: item.body, item, })); - }, [skills, sortedCommands, activeBackend]); + }, [agentSlashData.skills, sortedCommands, activeBackend]); const filteredOptions = useMemo(() => { if (!currentQuery) return allOptions; diff --git a/src/constants.ts b/src/constants.ts index 391507c5..7049c506 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -8,6 +8,7 @@ export const BREVILABS_API_BASE_URL = "https://api.brevilabs.com/v1"; export const BREVILABS_MODELS_BASE_URL = "https://models.brevilabs.com/v1"; export const CHAT_VIEWTYPE = "copilot-chat-view"; export const CHAT_AGENT_VIEWTYPE = "copilot-agent-chat-view"; +export const AGENT_CHAT_MODE = "agent"; export const RELEVANT_NOTES_VIEWTYPE = "copilot-relevant-notes-view"; // Custom Obsidian icon for Agent Mode surfaces (view tab, ribbon, commands). diff --git a/src/main.ts b/src/main.ts index eae81321..574adf3b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,15 +1,4 @@ -import { - AGENT_CHAT_MODE, - CopilotAgentView, - createAgentSessionManager, - isAgentModeEnabled, - PlanPreviewView, - PLAN_PREVIEW_VIEW_TYPE, - setFrameSinkVaultBasePath, - SkillManager, - type AgentSessionManager, -} from "@/agentMode"; -import { wireAgentModelDiscovery } from "@/agentMode/agentModelDiscovery"; +import type { AgentSessionManager } from "@/agentMode"; import { BrevilabsClient } from "@/LLMProviders/brevilabsClient"; import ProjectManager from "@/LLMProviders/projectManager"; import { @@ -33,6 +22,7 @@ import { SystemPromptRegister } from "@/system-prompts/systemPromptRegister"; import { ProjectRegister } from "@/projects/projectRegister"; import { ABORT_REASON, + AGENT_CHAT_MODE, CHAT_AGENT_VIEWTYPE, CHAT_VIEWTYPE, COPILOT_AGENT_ICON_ID, @@ -134,6 +124,9 @@ export default class CopilotPlugin extends Plugin { settingsUnsubscriber?: () => void; chatUIState: ChatManagerChatUIState; agentSessionManager?: AgentSessionManager; + private CopilotAgentView?: typeof import("@/agentMode").CopilotAgentView; + private PlanPreviewView?: typeof import("@/agentMode").PlanPreviewView; + private planPreviewViewType?: typeof import("@/agentMode").PLAN_PREVIEW_VIEW_TYPE; private agentModelDiscoveryUnsubscriber?: () => void; modelManagement!: ModelManagementApi; private ribbonIconEl?: HTMLElement; @@ -248,7 +241,19 @@ export default class CopilotPlugin extends Plugin { this.projectManager = ProjectManager.getInstance(this.app, this); // Initialize Agent Mode coordinator (desktop only — ACP needs subprocess support). - if (!Platform.isMobile) { + if (Platform.isDesktopApp) { + const { + CopilotAgentView, + PlanPreviewView, + PLAN_PREVIEW_VIEW_TYPE, + createAgentSessionManager, + setFrameSinkVaultBasePath, + } = await import("@/agentMode"); + const { wireAgentModelDiscovery } = await import("@/agentMode/agentModelDiscovery"); + this.CopilotAgentView = CopilotAgentView; + this.PlanPreviewView = PlanPreviewView; + this.planPreviewViewType = PLAN_PREVIEW_VIEW_TYPE; + // Seed the frame-log sink with the vault base path (desktop FileSystemAdapter only). const adapter = this.app.vault.adapter; setFrameSinkVaultBasePath( @@ -314,14 +319,21 @@ export default class CopilotPlugin extends Plugin { RELEVANT_NOTES_VIEWTYPE, (leaf: WorkspaceLeaf) => new RelevantNotesView(leaf, this) ); - if (!Platform.isMobile) { + if ( + Platform.isDesktopApp && + this.CopilotAgentView && + this.PlanPreviewView && + this.planPreviewViewType + ) { + const AgentView = this.CopilotAgentView; + const PreviewView = this.PlanPreviewView; this.safeRegisterView( CHAT_AGENT_VIEWTYPE, - (leaf: WorkspaceLeaf) => new CopilotAgentView(leaf, this) + (leaf: WorkspaceLeaf) => new AgentView(leaf, this) ); this.safeRegisterView( - PLAN_PREVIEW_VIEW_TYPE, - (leaf: WorkspaceLeaf) => new PlanPreviewView(leaf) + this.planPreviewViewType, + (leaf: WorkspaceLeaf) => new PreviewView(leaf) ); } @@ -459,8 +471,11 @@ export default class CopilotPlugin extends Plugin { this.settingsUnsubscriber?.(); // Tear down skills vault watchers + debounce timers. - if (SkillManager.hasInstance()) { - SkillManager.getInstance().dispose(); + if (Platform.isDesktopApp) { + const { SkillManager } = await import("@/agentMode"); + if (SkillManager.hasInstance()) { + SkillManager.getInstance().dispose(); + } } // Cleanup selection handler @@ -556,8 +571,8 @@ export default class CopilotPlugin extends Plugin { .getLeavesOfType(viewType) .map((leaf) => leaf.view) .find( - (v): v is CopilotView | CopilotAgentView => - v instanceof CopilotView || v instanceof CopilotAgentView + (v): v is CopilotView | InstanceType> => + v instanceof CopilotView || this.isCopilotAgentView(v) ); if (view) { @@ -864,7 +879,7 @@ export default class CopilotPlugin extends Plugin { // mount-timing guess. Also covers the already-open-and-active case, where // revealLeaf fires no active-leaf-change to drive focus. const view = leaf?.view; - if (view instanceof CopilotAgentView) { + if (this.isCopilotAgentView(view)) { view.eventTarget.queueVisible(); } return leaf; @@ -899,11 +914,13 @@ export default class CopilotPlugin extends Plugin { if (!leaf) return; this.app.workspace.revealLeaf(leaf); - const view = leaf.view as CopilotView | CopilotAgentView; + const view = leaf.view; // The bus latches the text if the view's React tree hasn't mounted its // listener yet, so a freshly-opened view drains it on mount — delivery no // longer depends on guessing how long mounting takes. - view.eventTarget.queueInsertText(text); + if (view instanceof CopilotView || this.isCopilotAgentView(view)) { + view.eventTarget.queueInsertText(text); + } } async newAgentChat(): Promise { @@ -934,7 +951,7 @@ export default class CopilotPlugin extends Plugin { } private requireAgentView(): AgentSessionManager | null { - if (Platform.isMobile) { + if (!Platform.isDesktopApp) { new Notice("Agent Chat is not available on mobile."); return null; } @@ -946,7 +963,14 @@ export default class CopilotPlugin extends Plugin { } private canUseAgentView(): boolean { - return !!this.agentSessionManager && isAgentModeEnabled(); + return !!this.agentSessionManager && Platform.isDesktopApp; + } + + private isCopilotAgentView( + view: unknown + ): view is InstanceType> { + const AgentView = this.CopilotAgentView; + return !!AgentView && view instanceof AgentView; } async loadSettings() { @@ -1147,7 +1171,9 @@ export default class CopilotPlugin extends Plugin { await manager.loadSessionFromHistory(file); void this.touchChatHistoryLastAccessedAt(file); - (leaf.view as CopilotAgentView | undefined)?.updateView(); + if (this.isCopilotAgentView(leaf.view)) { + leaf.view.updateView(); + } } async openChatSourceFile(fileId: string): Promise { diff --git a/src/settings/v2/SettingsMainV2.tsx b/src/settings/v2/SettingsMainV2.tsx index 40ef6f63..724457ad 100644 --- a/src/settings/v2/SettingsMainV2.tsx +++ b/src/settings/v2/SettingsMainV2.tsx @@ -8,11 +8,10 @@ import CopilotPlugin from "@/main"; import { ByokPanel, ModelManagementProvider } from "@/modelManagement"; import { resetSettings } from "@/settings/model"; import { CommandSettings } from "@/settings/v2/components/CommandSettings"; -import { SkillsSettings } from "@/agentMode"; import { Bot, Cog, Command, Cpu, Database, Sparkle, Sparkles, Wrench } from "lucide-react"; +import { Platform } from "obsidian"; import React from "react"; import { AdvancedSettings } from "./components/AdvancedSettings"; -import { AgentSettings } from "./components/AgentSettings"; import { BasicSettings } from "./components/BasicSettings"; import { CopilotPlusSettings } from "./components/CopilotPlusSettings"; import { QASettings } from "./components/QASettings"; @@ -20,6 +19,37 @@ import { QASettings } from "./components/QASettings"; const TAB_IDS = ["basic", "byok", "agent", "QA", "command", "skills", "plus", "advanced"] as const; type TabId = (typeof TAB_IDS)[number]; +const LazyAgentSettings = React.lazy(() => + import("./components/AgentSettings").then((module) => ({ default: module.AgentSettings })) +); +const LazySkillsSettings = React.lazy(() => + import("@/agentMode").then((module) => ({ default: module.SkillsSettings })) +); + +const DesktopOnlySettingsPanel: React.FC = () => ( +
+ Agent settings are available on desktop. +
+); + +const AgentSettingsPanel: React.FC = () => { + if (!Platform.isDesktopApp) return ; + return ( + + + + ); +}; + +const SkillsSettingsPanel: React.FC = () => { + if (!Platform.isDesktopApp) return ; + return ( + + + + ); +}; + // tab icons const icons: Record = { basic: , @@ -36,10 +66,10 @@ const icons: Record = { const components: Record = { basic: () => , byok: () => , - agent: () => , + agent: AgentSettingsPanel, QA: () => , command: () => , - skills: () => , + skills: SkillsSettingsPanel, plus: () => , advanced: () => , }; diff --git a/src/settings/v2/components/AdvancedSettings.tsx b/src/settings/v2/components/AdvancedSettings.tsx index d0450ec0..424aeb33 100644 --- a/src/settings/v2/components/AdvancedSettings.tsx +++ b/src/settings/v2/components/AdvancedSettings.tsx @@ -22,15 +22,16 @@ import { updateSetting, useSettingsValue, } from "@/settings/model"; -import { acpFrameSink } from "@/agentMode"; import { ArrowUpRight, Info, Plus, ShieldCheck, Trash2, Unlock } from "lucide-react"; import { ConfirmModal } from "@/components/modals/ConfirmModal"; import { MigrateConfirmModal } from "@/components/modals/MigrateConfirmModal"; -import { type App, Notice } from "obsidian"; -import React, { useCallback, useState } from "react"; +import { type App, Notice, Platform } from "obsidian"; +import React, { useCallback, useEffect, useState } from "react"; import { getPromptFilePath, SystemPromptAddModal } from "@/system-prompts"; import { useSystemPrompts } from "@/system-prompts/state"; +const DESKTOP_UNAVAILABLE_FRAME_LOG_PATH = "(Agent Mode frame logs are desktop-only)"; + /** * Returns a `saveData` callback bound to the loaded Copilot plugin instance. * @@ -58,6 +59,22 @@ export const AdvancedSettings: React.FC = () => { const prompts = useSystemPrompts(); const [forgetting, setForgetting] = useState(false); const [migrating, setMigrating] = useState(false); + const [frameLogPath, setFrameLogPath] = useState(DESKTOP_UNAVAILABLE_FRAME_LOG_PATH); + + useEffect(() => { + if (!Platform.isDesktopApp) return; + + let cancelled = false; + void import("@/agentMode").then(({ acpFrameSink }) => { + if (!cancelled) { + setFrameLogPath(acpFrameSink.getPath()); + } + }); + + return () => { + cancelled = true; + }; + }, []); const keychainAvailable = KeychainService.getInstance().isAvailable(); const keychainOnly = isKeychainOnly(settings); @@ -455,7 +472,7 @@ export const AdvancedSettings: React.FC = () => { { setSettings((cur) => ({ @@ -467,15 +484,21 @@ export const AdvancedSettings: React.FC = () => {