mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
feat(agent-mode): one-click "Report an Issue" flow with screenshot + frame log (#2614)
* feat(agent-mode): one-click "Report an Issue" flow with screenshot + frame log
Reporting an Agent Mode bug was high-friction: the diagnostic frame log
defaulted off (so it usually wasn't capturing when a problem hit), and there
was no in-product path to assemble a report.
- Default `agentMode.debugFullFrames` to ON for new installs so the frame log
is already capturing when a bug occurs. The existing sanitize migration
preserves an explicit prior choice (a user who turned it off stays off).
- Add a "Report an Issue" button to the Agent Mode control bar. It opens a
modal for a note, captures a screenshot of the chat surface (Electron
capturePage, popout-aware, degrades gracefully), bundles it with the current
frame log into a timestamped folder, reveals the folder, and opens a
prefilled GitHub issue.
- Optionally include the OpenCode log when that backend is active, and show an
in-flow privacy disclosure shown only when the user chooses to share.
Desktop-only; mobile and capture failures degrade to a report without a
screenshot. Adds unit tests for the new default, the preserve-explicit-choice
migration, the bundle assembler, and the opencode-log locator.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(agent-mode): make opencode log attachment opt-in by default
The Report-issue flow bundles opencode's newest *global* log, which may
belong to an unrelated CLI/Desktop session for another project. Defaulting
the checkbox to checked could silently attach that log, exposing unrelated
prompts and tool output. Default it to unchecked so the user opts in.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(agent-mode): cap report issue URL length and document the flow
- buildReportIssueUrl: truncate the prefilled body so the assembled URL
stays under Electron's ~2081-char openExternal limit on Windows. Without
this, a long note made openExternal reject silently while the success
notice still claimed the issue page opened. The full report is preserved
in report.md on disk, and the truncated body points the user there.
- Document the Report an Issue flow (bundle contents, drag-drop attach,
privacy note, opt-in OpenCode log) in docs/agent-mode-and-tools.md per
DOCS_GUIDE.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(agent-mode): skip frame log in report when logging is disabled
If a user turns off "Log Full Agent Mode Frames" but a stale
acp-frames.ndjson still exists in the temp dir, the report assembler would
copy that old file into the bundle, leaking plaintext prompts/tool output
despite logging being opted out. Only bundle the frame log when
debugFullFrames is currently enabled.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(agent-mode): file reports to public repo; clarify report UX and settings
Addresses review feedback on the Report an Issue flow:
- File user reports to the PUBLIC logancyang/obsidian-copilot repo (label
"bug"), never the private preview repo, which users can't see.
- Report modal: state plainly that the screenshot is of the Agent Mode chat
pane (not the whole screen), spell out what "Prepare report" does (save
files to a folder, open it, open a prefilled GitHub issue to attach them),
and replace the obscure muted disclaimer with a prominent warning callout.
- Advanced settings: split Agent Mode logging into its own "Agent Mode
debugging" section with plain-language copy (renamed to "Keep an Agent Mode
activity log" / "Agent Mode activity log file"), and clarify that the legacy
Debug Mode / Create Log File tools are for the regular chat, not Agent Mode.
- Update docs to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(agent-mode): move Report an Issue to the Advanced settings section
Per UX feedback, the entry point moves out of the agent chat control bar into
Settings → Advanced → Agent Mode debugging, alongside the activity-log
controls it relates to.
- Remove the bug-icon button and its wiring from AgentChatControls/AgentHome.
- Add a "Report an Issue" button to the Agent Mode debugging settings section.
- ReportIssueModal now resolves its screenshot target lazily via
resolveCaptureTarget(): the settings caller closes the Settings window and
reveals the agent pane first, so the screenshot is still the chat surface
(not the dialog). No agent pane open -> screenshot is skipped gracefully.
- Export ReportIssueModal from the agentMode barrel so host code can reach it
without crossing the ui boundary.
- Update docs to point at the new location.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(agent-mode): honor opencode env overrides when finding its log
OpencodeBackend spawns opencode with `{ ...process.env, ...envOverrides }`, so a
user who relocates opencode's data dir via XDG_DATA_HOME/HOME overrides has its
logs written there. The report's opencode-log lookup only consulted ambient
process.env/homedir, so it could attach an unrelated global log or miss the
active session's. Resolve the log path from the same merged env + HOME override.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(agent-mode): report uses active session backend; gate import on desktop
Two review fixes for the settings-based Report an Issue button:
- Read the backend from the active Agent Mode session, not the persisted
default. Switching tabs across backends changes the active session without
touching settings.agentMode.activeBackend, so the modal could hide/show the
OpenCode-log option for the wrong tab and name the wrong backend in the env
block.
- Check isDesktopRuntime() before importing the @/agentMode barrel. On mobile
the barrel evaluates Node-only modules; importing before the modal's own
desktop guard could reject during module load instead of showing the
desktop-only notice. Mirrors the existing frame-log buttons.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
decca79498
commit
f75c2ebbee
12 changed files with 1051 additions and 8 deletions
|
|
@ -183,6 +183,29 @@ If you trust the agent and don't want to review every file change, enable **Auto
|
|||
|
||||
---
|
||||
|
||||
## Reporting an Issue
|
||||
|
||||
When something goes wrong in Agent Mode, the **Report an Issue** button under **Settings → Copilot → Advanced → Agent Mode debugging** bundles everything a maintainer needs to diagnose it.
|
||||
|
||||
Clicking it opens a short form where you describe what happened. When you submit, Copilot:
|
||||
|
||||
1. Saves a screenshot of the **Agent Mode chat pane** (just the agent panel, not your whole screen).
|
||||
2. Saves a recent **Agent Mode log** of the behind-the-scenes messages between Copilot and the agent. This log is captured automatically so a report always has recent activity to include; you can turn it off under **Settings → Copilot → Advanced → Keep an Agent Mode activity log**.
|
||||
3. Opens the folder containing those files in your file manager.
|
||||
4. Opens a prefilled GitHub issue in your browser.
|
||||
|
||||
The files are **not uploaded for you** — drag them from the opened folder into the GitHub issue to attach them.
|
||||
|
||||
> **Privacy note:** The Agent Mode log can contain your prompts, note contents, and tool inputs/outputs in plaintext. Review the saved files before sharing them publicly.
|
||||
|
||||
### Including the OpenCode log
|
||||
|
||||
When the **OpenCode** backend is active, the form shows an optional checkbox to include OpenCode's own log. It is **off by default** because OpenCode's log is shared across all of your OpenCode sessions, so it may contain activity from unrelated projects. Turn it on only when the issue involves the OpenCode backend itself.
|
||||
|
||||
This feature is available on desktop only.
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [Copilot Plus and Self-Host](copilot-plus-and-self-host.md) — Licensing and memory
|
||||
|
|
|
|||
|
|
@ -258,6 +258,8 @@ export default [
|
|||
"src/utils/binaryPath.ts",
|
||||
"src/utils/nodeToolBinDirs.ts",
|
||||
"src/utils/rendererEventsShim.ts",
|
||||
"src/utils/issueReport.ts",
|
||||
"src/utils/opencodeLog.ts",
|
||||
],
|
||||
rules: {
|
||||
"import/no-nodejs-modules": "off",
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ export { ModelEnableList } from "./ui/ModelEnableList";
|
|||
export type { ModelEnableGroup, ModelEnableRow } from "./ui/ModelEnableList";
|
||||
export { PlanPreviewView, PLAN_PREVIEW_VIEW_TYPE } from "./ui/PlanPreviewView";
|
||||
export type { PlanPreviewViewState } from "./ui/PlanPreviewView";
|
||||
export { ReportIssueModal } from "./ui/ReportIssueModal";
|
||||
export type { ReportIssueModalParams } from "./ui/ReportIssueModal";
|
||||
export { getActiveBackendDescriptor, listBackendDescriptors } from "./backends/registry";
|
||||
export { frameSink as acpFrameSink, setFrameSinkVaultBasePath } from "./session/debugSink";
|
||||
export { getManagedSkills, SkillManager, SkillsSettings, useManagedSkills } from "./skills";
|
||||
|
|
|
|||
273
src/agentMode/ui/ReportIssueModal.tsx
Normal file
273
src/agentMode/ui/ReportIssueModal.tsx
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
import { frameSink } from "@/agentMode/session/debugSink";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { logError, logInfo } from "@/logger";
|
||||
import { captureViewScreenshot } from "@/utils/captureViewScreenshot";
|
||||
import { isDesktopRuntime } from "@/utils/desktopRuntime";
|
||||
import { assembleReportBundle, type ReportEnvInfo } from "@/utils/issueReport";
|
||||
import { findLatestOpencodeLog } from "@/utils/opencodeLog";
|
||||
import { createPluginRoot } from "@/utils/react/createPluginRoot";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getSettings } from "@/settings/model";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { App, Modal, Notice, apiVersion } from "obsidian";
|
||||
import React from "react";
|
||||
import { Root } from "react-dom/client";
|
||||
|
||||
const OPENCODE_BACKEND_ID = "opencode";
|
||||
|
||||
export interface ReportIssueModalParams {
|
||||
app: App;
|
||||
/**
|
||||
* Resolves the element to screenshot, called after this modal closes and just
|
||||
* before capture. Lets the caller first dismiss any overlay (e.g. the Settings
|
||||
* window) and reveal the Agent Mode pane so the shot is the chat surface, not
|
||||
* the dialog. Returns `null` to skip the screenshot (e.g. no agent pane open).
|
||||
*/
|
||||
resolveCaptureTarget: () => Promise<HTMLElement | null> | HTMLElement | null;
|
||||
/** Active backend id — gates the opencode-log option. */
|
||||
activeBackend: string;
|
||||
/** Plugin version for the report's environment block. */
|
||||
pluginVersion: string;
|
||||
}
|
||||
|
||||
interface ElectronShell {
|
||||
openPath?: (path: string) => Promise<string>;
|
||||
openExternal?: (url: string) => Promise<void>;
|
||||
showItemInFolder?: (path: string) => void;
|
||||
}
|
||||
|
||||
function getElectronShell(): ElectronShell | null {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const electron = require("electron") as
|
||||
| { shell?: ElectronShell; remote?: { shell?: ElectronShell } }
|
||||
| undefined;
|
||||
return electron?.shell ?? electron?.remote?.shell ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function reportsRootDir(): string | null {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const os = require("node:os") as typeof import("node:os");
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const path = require("node:path") as typeof import("node:path");
|
||||
return path.join(os.tmpdir(), "obsidian-copilot", "reports");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** `YYYYMMDD-HHmmss` in local time, for a sortable, filesystem-safe folder name. */
|
||||
function formatTimestamp(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return (
|
||||
`${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` +
|
||||
`-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
|
||||
);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
interface ReportIssueContentProps {
|
||||
showOpencodeOption: boolean;
|
||||
onSubmit: (note: string, includeOpencodeLog: boolean) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function ReportIssueContent({ showOpencodeOption, onSubmit, onCancel }: ReportIssueContentProps) {
|
||||
const [note, setNote] = React.useState("");
|
||||
// Opt-in by default: the bundled log is opencode's newest *global* log, which
|
||||
// may belong to an unrelated CLI/Desktop session, so don't attach it silently.
|
||||
const [includeOpencodeLog, setIncludeOpencodeLog] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className="tw-flex tw-flex-col tw-gap-4">
|
||||
<div className="tw-flex tw-flex-col tw-gap-1">
|
||||
<span className="tw-text-sm tw-font-medium">What went wrong?</span>
|
||||
<Textarea
|
||||
autoFocus
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Describe what you were doing and what happened…"
|
||||
className="tw-min-h-24"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showOpencodeOption && (
|
||||
<label className="tw-flex tw-items-start tw-gap-2 tw-text-sm">
|
||||
<Checkbox
|
||||
checked={includeOpencodeLog}
|
||||
onCheckedChange={(checked) => setIncludeOpencodeLog(checked === true)}
|
||||
className="tw-mt-0.5"
|
||||
/>
|
||||
<span>Include the OpenCode log (helps diagnose backend issues)</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="tw-flex tw-flex-col tw-gap-1.5 tw-text-sm">
|
||||
<span className="tw-font-medium">When you click “Prepare report”</span>
|
||||
<ul className="tw-m-0 tw-flex tw-flex-col tw-gap-1 tw-pl-5 tw-text-muted">
|
||||
<li>
|
||||
A screenshot of the <strong className="tw-text-normal">Agent Mode chat pane</strong>{" "}
|
||||
(not your whole screen) and a recent activity log are saved to a folder on your
|
||||
computer.
|
||||
</li>
|
||||
<li>That folder opens, and a pre-filled GitHub issue opens in your browser.</li>
|
||||
<li>Drag the saved files into the issue to attach them, then submit.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"tw-flex tw-items-start tw-gap-2.5 tw-rounded-md tw-border tw-border-solid tw-border-warning/100",
|
||||
"tw-bg-callout-warning/20 tw-px-3.5 tw-py-2.5 tw-text-sm tw-text-warning"
|
||||
)}
|
||||
role="alert"
|
||||
>
|
||||
<AlertTriangle className="tw-mt-0.5 tw-size-4 tw-shrink-0" aria-hidden="true" />
|
||||
<div className="tw-flex-1">
|
||||
<span className="tw-block tw-font-semibold">Before you share these files</span>
|
||||
<span className="tw-mt-0.5 tw-block tw-text-normal">
|
||||
The activity log can include your prompts, note contents, and tool inputs/outputs in
|
||||
plain text. Review the saved files and remove anything sensitive before posting them
|
||||
publicly.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tw-flex tw-justify-end tw-gap-2">
|
||||
<Button variant="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="default" onClick={() => onSubmit(note, includeOpencodeLog)}>
|
||||
Prepare report
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-click "Report an issue" flow for Agent Mode. Collects a note (and, for
|
||||
* opencode, an optional backend-log opt-in), then on submit captures the chat
|
||||
* surface, bundles it with the frame log, reveals the folder, and opens a
|
||||
* prefilled GitHub issue. Desktop-only; the bundle assembly runs after the
|
||||
* modal closes so the screenshot reflects the unobstructed view.
|
||||
*/
|
||||
export class ReportIssueModal extends Modal {
|
||||
private root: Root | null = null;
|
||||
|
||||
constructor(private readonly params: ReportIssueModalParams) {
|
||||
super(params.app);
|
||||
// @ts-ignore — setTitle exists at runtime (see ConfirmModal).
|
||||
this.setTitle("Report an Agent Mode issue");
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
if (!isDesktopRuntime()) {
|
||||
new Notice("Reporting an issue is available on desktop only.");
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
this.root = createPluginRoot(this.contentEl, this.app);
|
||||
this.root.render(
|
||||
<ReportIssueContent
|
||||
showOpencodeOption={this.params.activeBackend === OPENCODE_BACKEND_ID}
|
||||
onSubmit={(note, includeOpencodeLog) => {
|
||||
this.close();
|
||||
void this.prepareReport(note, includeOpencodeLog);
|
||||
}}
|
||||
onCancel={() => this.close()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
this.root?.unmount();
|
||||
this.root = null;
|
||||
this.contentEl.empty();
|
||||
}
|
||||
|
||||
private async prepareReport(note: string, includeOpencodeLog: boolean): Promise<void> {
|
||||
const root = reportsRootDir();
|
||||
if (!root) {
|
||||
new Notice("Could not prepare the report (filesystem unavailable).");
|
||||
return;
|
||||
}
|
||||
new Notice("Preparing issue report…");
|
||||
|
||||
try {
|
||||
// Let this modal tear down, then let the caller dismiss any overlay (e.g.
|
||||
// the Settings window) and reveal the agent pane. Wait again so the
|
||||
// revealed pane is painted before capturePage reads its pixels.
|
||||
await sleep(200);
|
||||
const captureTargetEl = await this.params.resolveCaptureTarget();
|
||||
await sleep(250);
|
||||
const screenshotPng = captureTargetEl ? await captureViewScreenshot(captureTargetEl) : null;
|
||||
|
||||
// Only bundle the frame log when logging is currently enabled. A user who
|
||||
// opted out may still have a stale acp-frames.ndjson on disk; honoring the
|
||||
// opt-out keeps that old plaintext out of the report.
|
||||
let frameLogPath: string | null = null;
|
||||
if (getSettings().agentMode.debugFullFrames) {
|
||||
await frameSink.flush();
|
||||
frameLogPath = frameSink.getPath();
|
||||
}
|
||||
|
||||
const opencodeLogPath =
|
||||
includeOpencodeLog && this.params.activeBackend === OPENCODE_BACKEND_ID
|
||||
? await resolveOpencodeLogPath()
|
||||
: null;
|
||||
|
||||
const env: ReportEnvInfo = {
|
||||
pluginVersion: this.params.pluginVersion,
|
||||
platform: process.platform,
|
||||
obsidianVersion: apiVersion,
|
||||
activeBackend: this.params.activeBackend,
|
||||
};
|
||||
|
||||
const report = await assembleReportBundle({
|
||||
note,
|
||||
env,
|
||||
screenshotPng,
|
||||
frameLogPath,
|
||||
opencodeLogPath,
|
||||
reportsRootDir: root,
|
||||
timestamp: formatTimestamp(new Date()),
|
||||
});
|
||||
|
||||
const shell = getElectronShell();
|
||||
shell?.openPath?.(report.folderPath).catch(() => {});
|
||||
shell?.openExternal?.(report.issueUrl).catch(() => {});
|
||||
|
||||
logInfo(`[ReportIssue] bundle written to ${report.folderPath} (${report.files.join(", ")})`);
|
||||
new Notice("Report ready. Attach the saved files to the GitHub issue that just opened.");
|
||||
} catch (err) {
|
||||
logError("[ReportIssue] failed to prepare report:", err);
|
||||
new Notice("Failed to prepare the issue report. See the console for details.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveOpencodeLogPath(): Promise<string | null> {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const os = require("node:os") as typeof import("node:os");
|
||||
// Resolve the log dir from the same env OpencodeBackend spawns opencode with:
|
||||
// user env overrides (e.g. XDG_DATA_HOME / HOME) relocate opencode's data dir,
|
||||
// so the log lives wherever the merged env points, not the ambient one.
|
||||
const envOverrides = getSettings().agentMode?.backends?.opencode?.envOverrides ?? {};
|
||||
const env = { ...process.env, ...envOverrides };
|
||||
const homeDir = envOverrides.HOME ?? os.homedir();
|
||||
return await findLatestOpencodeLog(env, homeDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1059,7 +1059,13 @@ export const DEFAULT_SETTINGS: CopilotSettings = {
|
|||
mcpServers: [],
|
||||
activeBackend: "opencode",
|
||||
backends: {},
|
||||
debugFullFrames: false,
|
||||
// On by default so the diagnostic frame log is already capturing when a
|
||||
// user hits a bug and clicks "Report an issue" (it can't capture
|
||||
// retroactively). The migration in src/settings/model.ts preserves an
|
||||
// explicit prior choice, so anyone who turned it off stays off. The privacy
|
||||
// disclosure lives in the Report-issue modal, shown only when the user
|
||||
// chooses to share the log.
|
||||
debugFullFrames: true,
|
||||
skills: {
|
||||
folder: DEFAULT_SKILLS_FOLDER,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -191,11 +191,57 @@ describe("sanitizeSettings - agentMode shape migration", () => {
|
|||
mcpServers: [],
|
||||
activeBackend: "opencode",
|
||||
backends: {},
|
||||
debugFullFrames: false,
|
||||
debugFullFrames: true,
|
||||
skills: { folder: "copilot/skills" },
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults debugFullFrames to on for new installs", () => {
|
||||
expect(DEFAULT_SETTINGS.agentMode.debugFullFrames).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves an explicit debugFullFrames=false (a user who turned it off stays off)", () => {
|
||||
const sanitized = sanitizeSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
agentMode: {
|
||||
byok: {},
|
||||
mcpServers: [],
|
||||
activeBackend: "opencode",
|
||||
backends: {},
|
||||
debugFullFrames: false,
|
||||
},
|
||||
} as unknown as CopilotSettings);
|
||||
expect(sanitized.agentMode.debugFullFrames).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves an explicit debugFullFrames=true", () => {
|
||||
const sanitized = sanitizeSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
agentMode: {
|
||||
byok: {},
|
||||
mcpServers: [],
|
||||
activeBackend: "opencode",
|
||||
backends: {},
|
||||
debugFullFrames: true,
|
||||
},
|
||||
} as unknown as CopilotSettings);
|
||||
expect(sanitized.agentMode.debugFullFrames).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to the on-by-default when debugFullFrames is absent or non-boolean", () => {
|
||||
const sanitized = sanitizeSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
agentMode: {
|
||||
byok: {},
|
||||
mcpServers: [],
|
||||
activeBackend: "opencode",
|
||||
backends: {},
|
||||
debugFullFrames: "yes" as unknown as boolean,
|
||||
},
|
||||
} as unknown as CopilotSettings);
|
||||
expect(sanitized.agentMode.debugFullFrames).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves backends empty when no legacy fields and no existing slice", () => {
|
||||
const sanitized = sanitizeSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { CHAT_AGENT_VIEWTYPE } from "@/constants";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingItem } from "@/components/ui/setting-item";
|
||||
import { ObsidianNativeSelect } from "@/components/ui/obsidian-native-select";
|
||||
|
|
@ -139,6 +140,54 @@ export const AdvancedSettings: React.FC = () => {
|
|||
modal.open();
|
||||
};
|
||||
|
||||
const handleReportIssue = useCallback(() => {
|
||||
// Gate before importing the agentMode barrel: on mobile the barrel pulls in
|
||||
// Node-only modules that throw during evaluation, so the desktop check must
|
||||
// happen first (mirrors the frame-log buttons below).
|
||||
if (!isDesktopRuntime()) {
|
||||
new Notice("Reporting an issue is available on desktop only.");
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
const { ReportIssueModal } = await import("@/agentMode");
|
||||
const copilotPlugin = (
|
||||
app as unknown as {
|
||||
plugins: {
|
||||
getPlugin: (id: string) => {
|
||||
manifest?: { version?: string };
|
||||
agentSessionManager?: { getActiveSession?: () => { backendId?: string } | null };
|
||||
} | null;
|
||||
};
|
||||
}
|
||||
).plugins.getPlugin("copilot");
|
||||
// Prefer the active session's backend: switching Agent Mode tabs changes
|
||||
// the active session without touching the persisted default backend, so
|
||||
// settings.agentMode.activeBackend can name the wrong pane.
|
||||
const activeBackend =
|
||||
copilotPlugin?.agentSessionManager?.getActiveSession?.()?.backendId ??
|
||||
settings.agentMode.activeBackend;
|
||||
new ReportIssueModal({
|
||||
app,
|
||||
activeBackend,
|
||||
pluginVersion: copilotPlugin?.manifest?.version ?? "unknown",
|
||||
// Resolve at capture time so we can close this Settings window and
|
||||
// reveal the agent pane first — the screenshot should be the chat
|
||||
// surface, not the settings dialog. Null when no agent pane is open.
|
||||
resolveCaptureTarget: () => {
|
||||
(app as unknown as { setting: { close: () => void } }).setting.close();
|
||||
const leaf = app.workspace.getLeavesOfType(CHAT_AGENT_VIEWTYPE)[0];
|
||||
if (!leaf) return null;
|
||||
app.workspace.revealLeaf(leaf);
|
||||
const view = leaf.view as unknown as {
|
||||
contentEl?: HTMLElement;
|
||||
containerEl?: HTMLElement;
|
||||
};
|
||||
return view.contentEl ?? view.containerEl ?? null;
|
||||
},
|
||||
}).open();
|
||||
})();
|
||||
}, [app, settings.agentMode.activeBackend]);
|
||||
|
||||
const handleForgetAllSecrets = useCallback(async () => {
|
||||
if (forgetting) return;
|
||||
|
||||
|
|
@ -445,7 +494,7 @@ export const AdvancedSettings: React.FC = () => {
|
|||
<SettingItem
|
||||
type="switch"
|
||||
title="Debug Mode"
|
||||
description="Debug mode will log some debug message to the console."
|
||||
description="Logs Copilot chat activity to the developer console (View → Toggle Developer Tools). For troubleshooting the regular chat — Agent Mode has its own log below."
|
||||
checked={settings.debug}
|
||||
onCheckedChange={(checked) => updateSetting("debug", checked)}
|
||||
/>
|
||||
|
|
@ -453,7 +502,7 @@ export const AdvancedSettings: React.FC = () => {
|
|||
<SettingItem
|
||||
type="custom"
|
||||
title="Create Log File"
|
||||
description={`Open the Copilot log file (${logFileManager.getLogPath()}) for easy sharing when reporting issues.`}
|
||||
description={`Save and open the regular Copilot chat log (${logFileManager.getLogPath()}) to share when reporting a chat issue. Agent Mode issues are handled by the "Report an Issue" button in the agent pane instead.`}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
|
@ -469,11 +518,30 @@ export const AdvancedSettings: React.FC = () => {
|
|||
Create Log File
|
||||
</Button>
|
||||
</SettingItem>
|
||||
</section>
|
||||
|
||||
{/* Agent Mode debugging Section */}
|
||||
<section className="tw-space-y-4 tw-rounded-lg tw-border tw-p-4">
|
||||
<div className="tw-text-xl tw-font-bold">Agent Mode debugging</div>
|
||||
<div className="tw-text-sm tw-text-muted">
|
||||
Tools for diagnosing Agent Mode problems, separate from the regular Copilot chat logs
|
||||
above.
|
||||
</div>
|
||||
|
||||
<SettingItem
|
||||
type="custom"
|
||||
title="Report an Issue"
|
||||
description="Bundles a screenshot of the Agent Mode chat pane and a recent activity log into a folder, then opens a prefilled GitHub issue for you to attach them to."
|
||||
>
|
||||
<Button variant="secondary" size="sm" onClick={handleReportIssue}>
|
||||
Report an Issue
|
||||
</Button>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
type="switch"
|
||||
title="Log Full Agent Mode Frames"
|
||||
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.`}
|
||||
title="Keep an Agent Mode activity log"
|
||||
description="Records the behind-the-scenes messages between Copilot and the agent so the Report an Issue button always has recent activity to attach. Stored on this device only, outside your vault, and can include your prompts and note contents in plain text. On by default; turn off to stop logging."
|
||||
checked={settings.agentMode.debugFullFrames}
|
||||
onCheckedChange={(checked) => {
|
||||
setSettings((cur) => ({
|
||||
|
|
@ -484,8 +552,8 @@ export const AdvancedSettings: React.FC = () => {
|
|||
|
||||
<SettingItem
|
||||
type="custom"
|
||||
title="Agent Mode Frame Log"
|
||||
description={`Open or clear the Agent Mode frame log (${frameLogPath}).`}
|
||||
title="Agent Mode activity log file"
|
||||
description={`Open or clear the log file on disk (${frameLogPath}).`}
|
||||
>
|
||||
<div className="tw-flex tw-gap-2">
|
||||
<Button
|
||||
|
|
|
|||
105
src/utils/captureViewScreenshot.ts
Normal file
105
src/utils/captureViewScreenshot.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { logWarn } from "@/logger";
|
||||
|
||||
interface ElectronRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface ElectronBrowserWindow {
|
||||
getBounds: () => ElectronRect;
|
||||
webContents: {
|
||||
capturePage: (rect?: ElectronRect) => Promise<{ toPNG: () => Uint8Array }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface ElectronRemote {
|
||||
getCurrentWindow?: () => ElectronBrowserWindow;
|
||||
BrowserWindow?: { getAllWindows?: () => ElectronBrowserWindow[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a screenshot of a DOM element's on-screen region as PNG bytes via
|
||||
* Electron's `webContents.capturePage`. Desktop-only; returns `null` on mobile,
|
||||
* when Electron is unavailable, or on any capture failure so callers can
|
||||
* degrade gracefully (the report flow proceeds without a screenshot).
|
||||
*
|
||||
* Popout-aware: the element may live in a detached Obsidian window, which is a
|
||||
* separate Electron `BrowserWindow`. Since plugin code runs in the main
|
||||
* renderer, `getCurrentWindow()` only ever returns the main window, so for a
|
||||
* popout we match the owning DOM window against all Electron windows by screen
|
||||
* bounds and capture that one.
|
||||
*/
|
||||
export async function captureViewScreenshot(el: HTMLElement): Promise<Uint8Array | null> {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const electron = require("electron") as { remote?: ElectronRemote } | undefined;
|
||||
const remote = electron?.remote;
|
||||
if (!remote) return null;
|
||||
|
||||
const targetWindow = el.ownerDocument.defaultView;
|
||||
if (!targetWindow) return null;
|
||||
|
||||
const browserWindow = resolveBrowserWindow(remote, targetWindow);
|
||||
if (!browserWindow) return null;
|
||||
|
||||
const bounds = el.getBoundingClientRect();
|
||||
if (bounds.width <= 0 || bounds.height <= 0) return null;
|
||||
|
||||
const rect: ElectronRect = {
|
||||
x: Math.max(0, Math.floor(bounds.left)),
|
||||
y: Math.max(0, Math.floor(bounds.top)),
|
||||
width: Math.ceil(bounds.width),
|
||||
height: Math.ceil(bounds.height),
|
||||
};
|
||||
|
||||
const image = await browserWindow.webContents.capturePage(rect);
|
||||
const png = image.toPNG();
|
||||
return png.length > 0 ? png : null;
|
||||
} catch (err) {
|
||||
logWarn("captureViewScreenshot failed:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Electron `BrowserWindow` hosting `domWindow`. For the main
|
||||
* renderer this is `getCurrentWindow()`; for a popout we pick the Electron
|
||||
* window whose screen bounds best match the popout's `screenX/screenY/outer*`.
|
||||
*/
|
||||
function resolveBrowserWindow(
|
||||
remote: ElectronRemote,
|
||||
domWindow: Window
|
||||
): ElectronBrowserWindow | null {
|
||||
const current = remote.getCurrentWindow?.() ?? null;
|
||||
|
||||
// The main renderer's window — capture it directly.
|
||||
if (domWindow === window) return current;
|
||||
|
||||
const all = remote.BrowserWindow?.getAllWindows?.() ?? [];
|
||||
if (all.length === 0) return current;
|
||||
if (all.length === 1) return all[0];
|
||||
|
||||
// Match by screen position. Obsidian popouts are top-level OS windows, so the
|
||||
// DOM window's screen coordinates line up with the Electron window bounds.
|
||||
let best: ElectronBrowserWindow | null = null;
|
||||
let bestDelta = Number.POSITIVE_INFINITY;
|
||||
for (const w of all) {
|
||||
try {
|
||||
const b = w.getBounds();
|
||||
const delta =
|
||||
Math.abs(b.x - domWindow.screenX) +
|
||||
Math.abs(b.y - domWindow.screenY) +
|
||||
Math.abs(b.width - domWindow.outerWidth) +
|
||||
Math.abs(b.height - domWindow.outerHeight);
|
||||
if (delta < bestDelta) {
|
||||
bestDelta = delta;
|
||||
best = w;
|
||||
}
|
||||
} catch {
|
||||
// A window may have been destroyed mid-iteration; skip it.
|
||||
}
|
||||
}
|
||||
return best ?? current;
|
||||
}
|
||||
164
src/utils/issueReport.test.ts
Normal file
164
src/utils/issueReport.test.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import {
|
||||
assembleReportBundle,
|
||||
buildReportIssueUrl,
|
||||
buildReportMarkdown,
|
||||
type ReportInput,
|
||||
type ReportRuntime,
|
||||
} from "@/utils/issueReport";
|
||||
|
||||
function makeRuntime(overrides: Partial<ReportRuntime> = {}) {
|
||||
const writes: Array<{ path: string; data: string | Uint8Array }> = [];
|
||||
const copies: Array<{ src: string; dest: string }> = [];
|
||||
const mkdirs: string[] = [];
|
||||
const runtime: ReportRuntime = {
|
||||
join: (...parts) => parts.join("/"),
|
||||
mkdir: async (p) => {
|
||||
mkdirs.push(p);
|
||||
},
|
||||
writeFile: async (p, data) => {
|
||||
writes.push({ path: p, data });
|
||||
},
|
||||
copyFile: async (src, dest) => {
|
||||
copies.push({ src, dest });
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
return { runtime, writes, copies, mkdirs };
|
||||
}
|
||||
|
||||
const baseInput: ReportInput = {
|
||||
note: "Agent crashed when I clicked run",
|
||||
env: {
|
||||
pluginVersion: "1.2.3",
|
||||
platform: "darwin",
|
||||
obsidianVersion: "1.5.0",
|
||||
activeBackend: "opencode",
|
||||
},
|
||||
screenshotPng: new Uint8Array([1, 2, 3]),
|
||||
frameLogPath: "/tmp/acp-frames.ndjson",
|
||||
opencodeLogPath: "/tmp/opencode/log/session.log",
|
||||
reportsRootDir: "/tmp/reports",
|
||||
timestamp: "20260615-101500",
|
||||
};
|
||||
|
||||
describe("assembleReportBundle", () => {
|
||||
it("creates a timestamped folder and writes all files when everything is present", async () => {
|
||||
const { runtime, writes, copies, mkdirs } = makeRuntime();
|
||||
const result = await assembleReportBundle(baseInput, runtime);
|
||||
|
||||
expect(result.folderPath).toBe("/tmp/reports/report-20260615-101500");
|
||||
expect(mkdirs).toContain("/tmp/reports/report-20260615-101500");
|
||||
expect(result.files).toEqual([
|
||||
"report.md",
|
||||
"screenshot.png",
|
||||
"acp-frames.ndjson",
|
||||
"opencode.log",
|
||||
]);
|
||||
|
||||
expect(writes.map((w) => w.path)).toContain(
|
||||
"/tmp/reports/report-20260615-101500/screenshot.png"
|
||||
);
|
||||
expect(writes.map((w) => w.path)).toContain("/tmp/reports/report-20260615-101500/report.md");
|
||||
expect(copies).toEqual([
|
||||
{
|
||||
src: "/tmp/acp-frames.ndjson",
|
||||
dest: "/tmp/reports/report-20260615-101500/acp-frames.ndjson",
|
||||
},
|
||||
{
|
||||
src: "/tmp/opencode/log/session.log",
|
||||
dest: "/tmp/reports/report-20260615-101500/opencode.log",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips the screenshot when none was captured", async () => {
|
||||
const { runtime, writes } = makeRuntime();
|
||||
const result = await assembleReportBundle({ ...baseInput, screenshotPng: null }, runtime);
|
||||
|
||||
expect(result.files).not.toContain("screenshot.png");
|
||||
expect(writes.map((w) => w.path)).not.toContain(
|
||||
"/tmp/reports/report-20260615-101500/screenshot.png"
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the opencode log when not provided", async () => {
|
||||
const { runtime, copies } = makeRuntime();
|
||||
const result = await assembleReportBundle({ ...baseInput, opencodeLogPath: null }, runtime);
|
||||
|
||||
expect(result.files).not.toContain("opencode.log");
|
||||
expect(copies.map((c) => c.dest)).not.toContain(
|
||||
"/tmp/reports/report-20260615-101500/opencode.log"
|
||||
);
|
||||
});
|
||||
|
||||
it("skips a frame log that fails to copy instead of failing the whole report", async () => {
|
||||
const { runtime } = makeRuntime({
|
||||
copyFile: async (src) => {
|
||||
if (src.includes("acp-frames")) throw new Error("ENOENT");
|
||||
},
|
||||
});
|
||||
const result = await assembleReportBundle(baseInput, runtime);
|
||||
|
||||
expect(result.files).toContain("report.md");
|
||||
expect(result.files).not.toContain("acp-frames.ndjson");
|
||||
expect(result.files).toContain("opencode.log");
|
||||
});
|
||||
|
||||
it("always writes report.md even when nothing else is captured", async () => {
|
||||
const { runtime, writes } = makeRuntime();
|
||||
const result = await assembleReportBundle(
|
||||
{ ...baseInput, screenshotPng: null, frameLogPath: null, opencodeLogPath: null },
|
||||
runtime
|
||||
);
|
||||
|
||||
expect(result.files).toEqual(["report.md"]);
|
||||
const report = writes.find((w) => w.path.endsWith("report.md"));
|
||||
expect(report).toBeDefined();
|
||||
expect(String(report?.data)).toContain("Agent crashed when I clicked run");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildReportMarkdown", () => {
|
||||
it("includes the note, environment, and attachment list", () => {
|
||||
const md = buildReportMarkdown(baseInput, ["report.md", "screenshot.png"]);
|
||||
expect(md).toContain("Agent crashed when I clicked run");
|
||||
expect(md).toContain("- Plugin version: 1.2.3");
|
||||
expect(md).toContain("- Active backend: opencode");
|
||||
expect(md).toContain("- Platform: darwin");
|
||||
expect(md).toContain("- Obsidian: 1.5.0");
|
||||
expect(md).toContain("- screenshot.png");
|
||||
});
|
||||
|
||||
it("falls back to a placeholder when the note is empty", () => {
|
||||
const md = buildReportMarkdown({ ...baseInput, note: " " }, []);
|
||||
expect(md).toContain("_No description provided._");
|
||||
expect(md).toContain("(none captured)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildReportIssueUrl", () => {
|
||||
it("targets the public repo with a prefilled, encoded title and body", () => {
|
||||
const url = buildReportIssueUrl(baseInput, ["report.md"]);
|
||||
expect(url.startsWith("https://github.com/logancyang/obsidian-copilot/issues/new?")).toBe(true);
|
||||
expect(url.includes("obsidian-copilot-preview")).toBe(false);
|
||||
const params = new URLSearchParams(url.split("?")[1]);
|
||||
expect(params.get("title")).toBe("[Agent Mode] Agent crashed when I clicked run");
|
||||
expect(params.get("labels")).toBe("bug");
|
||||
expect(params.get("body")).toContain("Agent crashed when I clicked run");
|
||||
});
|
||||
|
||||
it("uses a generic title when the note is blank", () => {
|
||||
const url = buildReportIssueUrl({ ...baseInput, note: "" }, []);
|
||||
const params = new URLSearchParams(url.split("?")[1]);
|
||||
expect(params.get("title")).toBe("[Agent Mode] Issue report");
|
||||
});
|
||||
|
||||
it("truncates the body so the URL stays under the Windows openExternal limit", () => {
|
||||
const longNote = "x".repeat(10000);
|
||||
const url = buildReportIssueUrl({ ...baseInput, note: longNote }, ["report.md"]);
|
||||
// Comfortably under Electron's ~2081-char Windows ceiling for openExternal.
|
||||
expect(url.length).toBeLessThanOrEqual(2081);
|
||||
const params = new URLSearchParams(url.split("?")[1]);
|
||||
expect(params.get("body")).toContain("report.md");
|
||||
});
|
||||
});
|
||||
193
src/utils/issueReport.ts
Normal file
193
src/utils/issueReport.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
/**
|
||||
* Assembles a self-contained Agent Mode bug-report bundle on disk (note,
|
||||
* screenshot, frame log, optional opencode log) and builds a prefilled GitHub
|
||||
* issue URL. GitHub can't attach binaries via a URL, so the files are written
|
||||
* to a folder the caller reveals in the OS file manager and the user drag-drops
|
||||
* into the issue.
|
||||
*
|
||||
* Pure of singletons: the Node runtime is injectable so the assembler is
|
||||
* unit-testable without touching the real filesystem.
|
||||
*/
|
||||
|
||||
/**
|
||||
* End-user reports go to the PUBLIC repo. The private `obsidian-copilot-preview`
|
||||
* repo is for internal triage/BRAT only and must never receive user issues
|
||||
* (users can't see it, and routing them there would lose the report).
|
||||
*/
|
||||
const REPORT_REPO = "logancyang/obsidian-copilot";
|
||||
const SCREENSHOT_NAME = "screenshot.png";
|
||||
const FRAME_LOG_NAME = "acp-frames.ndjson";
|
||||
const OPENCODE_LOG_NAME = "opencode.log";
|
||||
const REPORT_NOTE_NAME = "report.md";
|
||||
|
||||
export interface ReportEnvInfo {
|
||||
pluginVersion: string;
|
||||
platform: string;
|
||||
obsidianVersion?: string;
|
||||
activeBackend: string;
|
||||
}
|
||||
|
||||
export interface ReportInput {
|
||||
/** Free-text description the user typed in the modal. */
|
||||
note: string;
|
||||
env: ReportEnvInfo;
|
||||
/** PNG bytes of the captured view, or null when capture was unavailable. */
|
||||
screenshotPng: Uint8Array | null;
|
||||
/** Absolute path to the current Agent Mode frame log, if any. */
|
||||
frameLogPath: string | null;
|
||||
/** Absolute path to the latest opencode log, included only when provided. */
|
||||
opencodeLogPath: string | null;
|
||||
/** Root dir bundles are written under (one timestamped subfolder per report). */
|
||||
reportsRootDir: string;
|
||||
/** Pre-formatted timestamp (e.g. `20260615-101500`) used for the subfolder. */
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface AssembledReport {
|
||||
/** Absolute path to the created bundle folder. */
|
||||
folderPath: string;
|
||||
/** Basenames of the files written into the folder. */
|
||||
files: string[];
|
||||
/** Prefilled GitHub "new issue" URL for the user to open. */
|
||||
issueUrl: string;
|
||||
}
|
||||
|
||||
export interface ReportRuntime {
|
||||
join: (...parts: string[]) => string;
|
||||
mkdir: (path: string, opts: { recursive: boolean }) => Promise<void>;
|
||||
writeFile: (path: string, data: string | Uint8Array) => Promise<void>;
|
||||
copyFile: (src: string, dest: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the report bundle to `<reportsRootDir>/report-<timestamp>/` and return
|
||||
* its path, the file basenames written, and a prefilled issue URL. Best-effort
|
||||
* per file: a missing/unreadable frame or opencode log is skipped rather than
|
||||
* failing the whole report.
|
||||
*/
|
||||
export async function assembleReportBundle(
|
||||
input: ReportInput,
|
||||
runtime: ReportRuntime = getNodeReportRuntime()
|
||||
): Promise<AssembledReport> {
|
||||
const folderPath = runtime.join(input.reportsRootDir, `report-${input.timestamp}`);
|
||||
await runtime.mkdir(folderPath, { recursive: true });
|
||||
|
||||
const files: string[] = [];
|
||||
|
||||
if (input.screenshotPng && input.screenshotPng.length > 0) {
|
||||
try {
|
||||
await runtime.writeFile(runtime.join(folderPath, SCREENSHOT_NAME), input.screenshotPng);
|
||||
files.push(SCREENSHOT_NAME);
|
||||
} catch {
|
||||
// Screenshot is optional; keep going without it.
|
||||
}
|
||||
}
|
||||
|
||||
if (input.frameLogPath) {
|
||||
try {
|
||||
await runtime.copyFile(input.frameLogPath, runtime.join(folderPath, FRAME_LOG_NAME));
|
||||
files.push(FRAME_LOG_NAME);
|
||||
} catch {
|
||||
// Frame log may not exist yet (logging just enabled); skip it.
|
||||
}
|
||||
}
|
||||
|
||||
if (input.opencodeLogPath) {
|
||||
try {
|
||||
await runtime.copyFile(input.opencodeLogPath, runtime.join(folderPath, OPENCODE_LOG_NAME));
|
||||
files.push(OPENCODE_LOG_NAME);
|
||||
} catch {
|
||||
// opencode log may be absent; skip it.
|
||||
}
|
||||
}
|
||||
|
||||
const noteMarkdown = buildReportMarkdown(input, files);
|
||||
await runtime.writeFile(runtime.join(folderPath, REPORT_NOTE_NAME), noteMarkdown);
|
||||
files.unshift(REPORT_NOTE_NAME);
|
||||
|
||||
return {
|
||||
folderPath,
|
||||
files,
|
||||
issueUrl: buildReportIssueUrl(input, files),
|
||||
};
|
||||
}
|
||||
|
||||
/** Markdown report body, mirrored both into `report.md` and the issue prefill. */
|
||||
export function buildReportMarkdown(input: ReportInput, attachedFiles: string[]): string {
|
||||
const note = input.note.trim() || "_No description provided._";
|
||||
const attachments = attachedFiles.length > 0 ? attachedFiles : ["(none captured)"];
|
||||
return [
|
||||
"## What went wrong",
|
||||
"",
|
||||
note,
|
||||
"",
|
||||
"## Environment",
|
||||
"",
|
||||
`- Plugin version: ${input.env.pluginVersion}`,
|
||||
`- Active backend: ${input.env.activeBackend}`,
|
||||
`- Platform: ${input.env.platform}`,
|
||||
...(input.env.obsidianVersion ? [`- Obsidian: ${input.env.obsidianVersion}`] : []),
|
||||
"",
|
||||
"## Attached files",
|
||||
"",
|
||||
...attachments.map((f) => `- ${f}`),
|
||||
"",
|
||||
"> These files were saved to the bundle folder that just opened. Drag them",
|
||||
"> into the GitHub issue to attach them.",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* `shell.openExternal` silently rejects URLs over ~2081 chars on Windows, which
|
||||
* would skip opening the issue page while the caller still reports success. Cap
|
||||
* the assembled URL well under that so the page always opens; the full report
|
||||
* already lives in `report.md` on disk for the user to paste in.
|
||||
*/
|
||||
const MAX_ISSUE_URL_LENGTH = 1800;
|
||||
const BODY_TRUNCATION_NOTE =
|
||||
"\n\n_…report truncated. The full report is saved as `report.md` in the bundle " +
|
||||
"folder that just opened — paste it here._";
|
||||
|
||||
/**
|
||||
* Build a prefilled GitHub "new issue" URL. The body carries the note and
|
||||
* environment; the saved files must be drag-dropped by the user since a URL
|
||||
* cannot upload binaries. The body is truncated when needed to keep the URL
|
||||
* within `MAX_ISSUE_URL_LENGTH`.
|
||||
*/
|
||||
export function buildReportIssueUrl(input: ReportInput, attachedFiles: string[]): string {
|
||||
const firstLine = input.note.trim().split("\n")[0]?.slice(0, 80).trim();
|
||||
const title = firstLine ? `[Agent Mode] ${firstLine}` : "[Agent Mode] Issue report";
|
||||
const body = buildReportMarkdown(input, attachedFiles);
|
||||
const base = `https://github.com/${REPORT_REPO}/issues/new?`;
|
||||
|
||||
const build = (b: string) =>
|
||||
base + new URLSearchParams({ title, body: b, labels: "bug" }).toString();
|
||||
|
||||
if (build(body).length <= MAX_ISSUE_URL_LENGTH) return build(body);
|
||||
|
||||
// URL-encoding expands characters non-linearly, so shrink the kept slice
|
||||
// until the fully-encoded URL fits rather than estimating a byte budget.
|
||||
let keep = body.length;
|
||||
let truncated = build(body.slice(0, keep) + BODY_TRUNCATION_NOTE);
|
||||
while (keep > 0 && truncated.length > MAX_ISSUE_URL_LENGTH) {
|
||||
keep = Math.max(0, keep - Math.ceil((truncated.length - MAX_ISSUE_URL_LENGTH) / 3));
|
||||
truncated = build(body.slice(0, keep) + BODY_TRUNCATION_NOTE);
|
||||
}
|
||||
return truncated;
|
||||
}
|
||||
|
||||
function getNodeReportRuntime(): ReportRuntime {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const fs = require("node:fs/promises") as typeof import("node:fs/promises");
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const path = require("node:path") as typeof import("node:path");
|
||||
return {
|
||||
join: (...parts: string[]) => path.join(...parts),
|
||||
mkdir: async (p, opts) => {
|
||||
await fs.mkdir(p, opts);
|
||||
},
|
||||
writeFile: (p, data) => fs.writeFile(p, data),
|
||||
copyFile: (src, dest) => fs.copyFile(src, dest),
|
||||
};
|
||||
}
|
||||
84
src/utils/opencodeLog.test.ts
Normal file
84
src/utils/opencodeLog.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import {
|
||||
findLatestOpencodeLog,
|
||||
opencodeLogDir,
|
||||
type OpencodeLogRuntime,
|
||||
} from "@/utils/opencodeLog";
|
||||
|
||||
const join = (...parts: string[]) => parts.join("/");
|
||||
|
||||
describe("opencodeLogDir", () => {
|
||||
it("uses XDG_DATA_HOME when set", () => {
|
||||
expect(opencodeLogDir({ XDG_DATA_HOME: "/xdg/data" }, "/home/me", join)).toBe(
|
||||
"/xdg/data/opencode/log"
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to ~/.local/share when XDG_DATA_HOME is unset", () => {
|
||||
expect(opencodeLogDir({}, "/home/me", join)).toBe("/home/me/.local/share/opencode/log");
|
||||
});
|
||||
|
||||
it("ignores a whitespace-only XDG_DATA_HOME", () => {
|
||||
expect(opencodeLogDir({ XDG_DATA_HOME: " " }, "/home/me", join)).toBe(
|
||||
"/home/me/.local/share/opencode/log"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findLatestOpencodeLog", () => {
|
||||
function makeRuntime(
|
||||
files: string[],
|
||||
mtimes: Record<string, number>,
|
||||
overrides: Partial<OpencodeLogRuntime> = {}
|
||||
): OpencodeLogRuntime {
|
||||
return {
|
||||
join,
|
||||
readdir: async () => files,
|
||||
stat: async (p) => ({ mtimeMs: mtimes[p] ?? 0 }),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("returns the newest .log file by mtime", async () => {
|
||||
const runtime = makeRuntime(["a.log", "b.log", "notes.txt"], {
|
||||
"/home/me/.local/share/opencode/log/a.log": 100,
|
||||
"/home/me/.local/share/opencode/log/b.log": 200,
|
||||
});
|
||||
const result = await findLatestOpencodeLog({}, "/home/me", runtime);
|
||||
expect(result).toBe("/home/me/.local/share/opencode/log/b.log");
|
||||
});
|
||||
|
||||
it("returns null when there are no .log files", async () => {
|
||||
const runtime = makeRuntime(["readme.md", "x.json"], {});
|
||||
expect(await findLatestOpencodeLog({}, "/home/me", runtime)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the directory cannot be read", async () => {
|
||||
const runtime = makeRuntime(
|
||||
[],
|
||||
{},
|
||||
{
|
||||
readdir: async () => {
|
||||
throw new Error("ENOENT");
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(await findLatestOpencodeLog({}, "/home/me", runtime)).toBeNull();
|
||||
});
|
||||
|
||||
it("skips files that vanish between readdir and stat", async () => {
|
||||
const runtime = makeRuntime(
|
||||
["gone.log", "here.log"],
|
||||
{
|
||||
"/home/me/.local/share/opencode/log/here.log": 50,
|
||||
},
|
||||
{
|
||||
stat: async (p) => {
|
||||
if (p.endsWith("gone.log")) throw new Error("ENOENT");
|
||||
return { mtimeMs: 50 };
|
||||
},
|
||||
}
|
||||
);
|
||||
const result = await findLatestOpencodeLog({}, "/home/me", runtime);
|
||||
expect(result).toBe("/home/me/.local/share/opencode/log/here.log");
|
||||
});
|
||||
});
|
||||
77
src/utils/opencodeLog.ts
Normal file
77
src/utils/opencodeLog.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Locates the opencode CLI's own diagnostic log so the Report-issue flow can
|
||||
* optionally bundle it (Zero's request on logancyang/obsidian-copilot-preview#155).
|
||||
*
|
||||
* opencode writes session logs under its XDG data dir at `opencode/log/*.log`
|
||||
* (honoring `XDG_DATA_HOME`, else `~/.local/share`). We don't override that at
|
||||
* spawn time, so this resolves opencode's default location. Best-effort: the
|
||||
* directory may not exist, in which case the caller proceeds without the log.
|
||||
*/
|
||||
|
||||
export interface OpencodeLogRuntime {
|
||||
join: (...parts: string[]) => string;
|
||||
readdir: (dir: string) => Promise<string[]>;
|
||||
stat: (path: string) => Promise<{ mtimeMs: number }>;
|
||||
}
|
||||
|
||||
/** Resolve opencode's default log directory for the given env/home. */
|
||||
export function opencodeLogDir(
|
||||
env: Record<string, string | undefined>,
|
||||
homeDir: string,
|
||||
join: (...parts: string[]) => string
|
||||
): string {
|
||||
const xdgDataHome = env.XDG_DATA_HOME?.trim();
|
||||
const dataRoot = xdgDataHome ? xdgDataHome : join(homeDir, ".local", "share");
|
||||
return join(dataRoot, "opencode", "log");
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the absolute path of the most recently modified `.log` file in
|
||||
* opencode's log directory, or `null` when the directory is missing/empty or
|
||||
* the runtime is unavailable.
|
||||
*/
|
||||
export async function findLatestOpencodeLog(
|
||||
env: Record<string, string | undefined>,
|
||||
homeDir: string,
|
||||
runtime: OpencodeLogRuntime = getNodeOpencodeLogRuntime()
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const dir = opencodeLogDir(env, homeDir, runtime.join);
|
||||
const entries = await runtime.readdir(dir);
|
||||
const logs = entries.filter((name) => name.endsWith(".log"));
|
||||
if (logs.length === 0) return null;
|
||||
|
||||
let newestPath: string | null = null;
|
||||
let newestMtime = Number.NEGATIVE_INFINITY;
|
||||
for (const name of logs) {
|
||||
const full = runtime.join(dir, name);
|
||||
try {
|
||||
const { mtimeMs } = await runtime.stat(full);
|
||||
if (mtimeMs > newestMtime) {
|
||||
newestMtime = mtimeMs;
|
||||
newestPath = full;
|
||||
}
|
||||
} catch {
|
||||
// File vanished between readdir and stat; skip it.
|
||||
}
|
||||
}
|
||||
return newestPath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getNodeOpencodeLogRuntime(): OpencodeLogRuntime {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const fs = require("node:fs/promises") as typeof import("node:fs/promises");
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const path = require("node:path") as typeof import("node:path");
|
||||
return {
|
||||
join: (...parts: string[]) => path.join(...parts),
|
||||
readdir: (dir: string) => fs.readdir(dir),
|
||||
stat: async (p: string) => {
|
||||
const s = await fs.stat(p);
|
||||
return { mtimeMs: s.mtimeMs };
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue