fix(mobile): guard Agent Mode off the mobile load path (#2577)

* fix(mobile): shim `process` so the bundle survives module-eval on mobile

Follow-up to #2576 (preview issue #125). After the EventEmitter fix, mobile now
crashes at load with "ReferenceError: Can't find variable: process".

Cause: Agent Mode is desktop-only but its modules are statically imported, so
they're evaluated on every platform. Several read the Node global `process` at
module scope (e.g. `InstallCommandRow.tsx`'s `const DEFAULT_LABEL =
process.platform === "win32" ? ...`, the codex/claude/opencode descriptors),
and bundled Node deps read `process.env` at init. Mobile's WebView has no
`process`, so evaluating any of them throws during import and kills the plugin.

Fix: prepend a minimal `process` shim in the esbuild banner —
`var process = globalThis.process || { env:{}, platform:"", ... }`. Desktop
(Electron) has a real `globalThis.process` and uses it unchanged; mobile gets
the stub, so module evaluation no longer throws. The desktop-only code paths
that actually use `process` never run on mobile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(test-vault): copy-deploy into iCloud-synced vaults for mobile testing

`npm run test:vault` symlinks main.js/styles.css by default, but iCloud syncs
file contents, not link targets, so a symlinked build never reaches the phone.
Detect iCloud vaults (path under "/Mobile Documents/") and copy real files
instead, so deploying to an iCloud vault makes the build testable on Obsidian
mobile. Mirrors the existing WSL copy-mode rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mobile): keep agent mode off load path

* test(mobile): add load smoke check

* fix(mobile): share agent chat mode constant

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Logan Yang 2026-06-06 21:56:58 -07:00 committed by GitHub
parent c62211628c
commit 775d832ee5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 598 additions and 55 deletions

View file

@ -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

View file

@ -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";

View file

@ -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",

View file

@ -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.");

View file

@ -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

View file

@ -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";
/**

View file

@ -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

View file

@ -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();
});

View file

@ -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<BackendId> | 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<AgentSlashData>(EMPTY_AGENT_SLASH_DATA);
useEffect(() => {
if (!Platform.isDesktopApp) return;
let cancelled = false;
let unsubscribe: (() => void) | undefined;
async function loadAgentSlashData(): Promise<void> {
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<BackendId> | 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<SlashCommandOption[]>(() => {
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;

View file

@ -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).

View file

@ -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<NonNullable<typeof this.CopilotAgentView>> =>
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<void> {
@ -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<NonNullable<typeof this.CopilotAgentView>> {
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<void> {

View file

@ -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 = () => (
<section className="tw-rounded-md tw-border tw-border-solid tw-border-border tw-p-4 tw-text-sm tw-text-muted">
Agent settings are available on desktop.
</section>
);
const AgentSettingsPanel: React.FC = () => {
if (!Platform.isDesktopApp) return <DesktopOnlySettingsPanel />;
return (
<React.Suspense fallback={null}>
<LazyAgentSettings />
</React.Suspense>
);
};
const SkillsSettingsPanel: React.FC = () => {
if (!Platform.isDesktopApp) return <DesktopOnlySettingsPanel />;
return (
<React.Suspense fallback={null}>
<LazySkillsSettings />
</React.Suspense>
);
};
// tab icons
const icons: Record<TabId, JSX.Element> = {
basic: <Cog className="tw-size-5" />,
@ -36,10 +66,10 @@ const icons: Record<TabId, JSX.Element> = {
const components: Record<TabId, React.FC> = {
basic: () => <BasicSettings />,
byok: () => <ByokPanel />,
agent: () => <AgentSettings />,
agent: AgentSettingsPanel,
QA: () => <QASettings />,
command: () => <CommandSettings />,
skills: () => <SkillsSettings />,
skills: SkillsSettingsPanel,
plus: () => <CopilotPlusSettings />,
advanced: () => <AdvancedSettings />,
};

View file

@ -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 = () => {
<SettingItem
type="switch"
title="Log Full Agent Mode Frames"
description={`Writes diagnostic Agent Mode frames as NDJSON outside your vault at ${acpFrameSink.getPath()}. Frames include prompts, tool inputs/outputs, and attachments; oversized frames are summarized to avoid runaway logs. Sensitive content lands on disk in plaintext. Leave off unless actively debugging.`}
description={`Writes diagnostic Agent Mode frames as NDJSON outside your vault at ${frameLogPath}. Frames include prompts, tool inputs/outputs, and attachments; oversized frames are summarized to avoid runaway logs. Sensitive content lands on disk in plaintext. Leave off unless actively debugging.`}
checked={settings.agentMode.debugFullFrames}
onCheckedChange={(checked) => {
setSettings((cur) => ({
@ -467,15 +484,21 @@ export const AdvancedSettings: React.FC = () => {
<SettingItem
type="custom"
title="Agent Mode Frame Log"
description={`Open or clear the Agent Mode frame log (${acpFrameSink.getPath()}).`}
description={`Open or clear the Agent Mode frame log (${frameLogPath}).`}
>
<div className="tw-flex tw-gap-2">
<Button
variant="secondary"
size="sm"
onClick={async () => {
if (!Platform.isDesktopApp) {
new Notice("Agent Mode frame logs are available on desktop only.");
return;
}
try {
const { acpFrameSink } = await import("@/agentMode");
await acpFrameSink.open();
setFrameLogPath(acpFrameSink.getPath());
} catch {
new Notice("Failed to open Agent Mode frame log.");
}
@ -487,8 +510,14 @@ export const AdvancedSettings: React.FC = () => {
variant="secondary"
size="sm"
onClick={async () => {
if (!Platform.isDesktopApp) {
new Notice("Agent Mode frame logs are available on desktop only.");
return;
}
try {
const { acpFrameSink } = await import("@/agentMode");
await acpFrameSink.clear();
setFrameLogPath(acpFrameSink.getPath());
new Notice("Agent Mode frame log cleared.");
} catch {
new Notice("Failed to clear Agent Mode frame log.");