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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions .agents/skills/review-pr/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
name: review-pr
description: Review a pull request or local branch diff for correctness, security, lifecycle, error handling, tests, and meaningful performance risks. Use when asked to review PR changes, a branch, a commit range, or the current working tree and return a structured review.
---

# Review PR

Review the requested change set and return one structured JSON review as the final response.

## Establish scope

1. Use the base branch, commit, or range named by the user.
2. Otherwise determine the merge base with the repository's default branch.
3. Include committed branch changes plus relevant staged, unstaged, and untracked files.
4. Use available change descriptions, specifications, and review context.
5. Focus on changed code and the unchanged call sites needed to prove or disprove a finding.

## Review priorities

Prioritize:

1. Correctness, data loss, security, crashes, and broken user behavior.
2. Lifecycle, concurrency, stale state, cleanup, and error-handling problems.
3. Material performance regressions on demonstrated hot paths.
4. Missing tests only when they cover a distinct behavior or edge case.

Avoid speculative findings. Trace relevant callers, state transitions, cleanup paths, and existing tests before reporting an issue. Do not flag untouched code unless the change makes it unsafe.

Treat robustness ideas as suggestions unless they present a concrete correctness, security, or data-loss risk. Do not request tests that merely vary constructor inputs or fields already covered by the same behavior.

## Finding rules

Use these severities:

- `critical`: security issue, data loss, crash, or broadly broken behavior.
- `important`: concrete logic, lifecycle, edge-case, or error-handling bug.
- `suggestion`: worthwhile non-blocking improvement.
- `nit`: precise cleanup with a concrete replacement.

For every finding:

- Cite a repository-relative path.
- Cite an exact changed line when possible; otherwise use `null`.
- Explain the failure mode and when it occurs.
- Give a concrete fix direction.
- Keep the body concise and actionable.

Return `REQUEST_CHANGES` only when at least one `critical` or `important` finding remains. Suggestions and nits do not block approval.

## Output

Return only valid JSON with this shape. Do not wrap it in a Markdown fence and do not write it to disk.

```json
{
"verdict": "REQUEST_CHANGES",
"overview": "Short description of the reviewed change and overall assessment.",
"findings": [
{
"severity": "important",
"path": "path/to/file.ts",
"line": 42,
"title": "Short finding title",
"body": "Failure mode, evidence, and concrete fix direction."
}
],
"counts": {
"critical": 0,
"important": 1,
"suggestion": 0,
"nit": 0
}
}
```

`verdict` must be exactly `APPROVE` or `REQUEST_CHANGES`. `findings` must be empty when no issues remain. Counts must match the findings array.

Do not modify the repository or publish the review unless the user separately asks.
1 change: 1 addition & 0 deletions .claude/skills/review-pr
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,8 @@ jobs:
- name: Build packages
run: pnpm --filter "./packages/**" --if-present build

- name: Audit React Compiler coverage
run: pnpm check:react-compiler

- name: Run tests
run: pnpm test
10 changes: 7 additions & 3 deletions .github/workflows/review-pull-requests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ name: Review Pull Requests

on:
pull_request:
types: [opened, reopened, ready_for_review]
types: [opened, reopened, ready_for_review, synchronize]
workflow_dispatch:
inputs:
pr_number:
Expand All @@ -28,6 +28,10 @@ jobs:
head_sha: ${{ steps.pr.outputs.head_sha }}
pr_number: ${{ steps.pr.outputs.pr_number }}
steps:
- name: Wait for pushes to settle
if: github.event_name == 'pull_request'
run: sleep 60

- name: Resolve PR metadata
id: pr
env:
Expand Down Expand Up @@ -294,8 +298,8 @@ jobs:
raise SystemExit(f"Comment {index} has invalid range")
if start_line not in locations.get(path, {}).get(side, set()):
raise SystemExit(f"Comment {index} range starts outside the diff")
if start_line > line or line - start_line >= 10:
raise SystemExit(f"Comment {index} range exceeds 10 lines")
if start_line > line:
raise SystemExit(f"Comment {index} has an inverted range")
normalized.append(item)

payload = {
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com).

### Fixed

- Switching between notes now keeps navigation controls stable and saves pending edits to the correct file. [#167](https://github.com/bholmesdev/hubble.md/pull/167)
- macOS text context menus now include Writing Tools, text services, and spelling suggestions. Thanks [@noahpatterson](https://github.com/noahpatterson) for the suggestion! [#164](https://github.com/bholmesdev/hubble.md/pull/164)
- Update-check failures now show a concise, unobtrusive message instead of a raw error trace.

Expand Down
7 changes: 6 additions & 1 deletion apps/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
import icons from "unplugin-icons/vite";
import { reactCompilerPlugin } from "../../config/react-compiler-audit";

const devPort = Number(process.env.PORT ?? 1420);

Expand Down Expand Up @@ -30,7 +31,11 @@ export default defineConfig({
renderer: {
root: ".",
plugins: [
react(),
react({
babel: {
plugins: [reactCompilerPlugin("desktop")],
},
}),
icons({
compiler: "jsx",
jsx: "react",
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"babel-plugin-react-compiler": "^1.0.0",
"chokidar": "^4.0.3",
"electron": "^42.3.1",
"electron-builder": "^26.8.1",
Expand Down
128 changes: 59 additions & 69 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from "@hubble.md/ui";
import { useStoreValue } from "@simplestack/store/react";
import { keymatch } from "keymatch";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import MingcutePencilLine from "~icons/mingcute/pencil-line";
import { HtmlAppEmptyState } from "./components/HtmlAppEmptyState";
Expand Down Expand Up @@ -103,6 +103,18 @@ async function revealPath(path: string | null) {
}
}

async function openFilePicker() {
const currentPath = viewerStore.get().currentPath;
const defaultPath =
(isChangelogPath(currentPath) ? null : currentPath) ??
workspaceStore.get().workspacePath ??
undefined;
const selected = await desktopApi.openFilePicker({ defaultPath });
if (typeof selected === "string") {
await loadPath(selected);
}
}

let nextSearchRequestId = 0;

/**
Expand Down Expand Up @@ -141,15 +153,11 @@ function App() {
const [dismissedVersion, setDismissedVersion] = useState<string | null>(null);
const [searchOpen, setSearchOpen] = useState(false);
const workspaceFiles = useStoreValue(workspaceStore).files;
const paletteFiles: PaletteFile[] = useMemo(
() =>
workspaceFiles.map((file) => ({
path: file.path,
relativePath: relativeWorkspacePath(file.path, workspacePath ?? null),
modifiedAt: file.modified_at,
})),
[workspaceFiles, workspacePath],
);
const paletteFiles: PaletteFile[] = workspaceFiles.map((file) => ({
path: file.path,
relativePath: relativeWorkspacePath(file.path, workspacePath ?? null),
modifiedAt: file.modified_at,
}));
const lastSeenVersion = useStoreValue(lastSeenVersionStore);

const readyVersion =
Expand All @@ -168,9 +176,9 @@ function App() {
lastSeenVersion !== currentVersion
? currentVersion
: null;
const markWhatsNewSeen = useCallback(() => {
const markWhatsNewSeen = () => {
if (currentVersion) setLastSeenVersion(currentVersion);
}, [currentVersion]);
};

useEffect(() => {
// First install has no update to announce; just record the version.
Expand All @@ -179,32 +187,29 @@ function App() {
}
}, [currentVersion, lastSeenVersion]);

const openSettings = useCallback(() => {
setSettingsOpen(true);
}, []);
const openWhatsNew = useCallback(() => {
const openWhatsNew = () => {
setSettingsOpen(false);
void openChangelog();
}, []);
};

const installUpdate = useCallback(async () => {
const installUpdate = async () => {
try {
await desktopApi.installUpdate();
} catch (error) {
toast.error("Failed to install update", {
description: error instanceof Error ? error.message : String(error),
});
}
}, []);
};

const triggerPrimaryUpdateAction = useCallback(async () => {
const triggerPrimaryUpdateAction = async () => {
if (!updateState?.isSupported) return;
if (updateState.status === "ready") {
await installUpdate();
return;
}
await desktopApi.checkForUpdates();
}, [installUpdate, updateState]);
};

useEffect(() => {
const currentPath = state.currentPath;
Expand Down Expand Up @@ -247,18 +252,6 @@ function App() {
};
}, [state.currentPath]);

const openFilePicker = useCallback(async () => {
const currentPath = viewerStore.get().currentPath;
const defaultPath =
(isChangelogPath(currentPath) ? null : currentPath) ??
workspaceStore.get().workspacePath ??
undefined;
const selected = await desktopApi.openFilePicker({ defaultPath });
if (typeof selected === "string") {
await loadPath(selected);
}
}, []);

useEffect(() => {
const currentPath = state.currentPath;
void desktopApi.setMenuState({
Expand Down Expand Up @@ -296,7 +289,7 @@ function App() {
await createMarkdownFile();
} else if (keymatch(event, "CmdOrCtrl+,")) {
event.preventDefault();
openSettings();
setSettingsOpen(true);
} else if (keymatch(event, "CmdOrCtrl+Shift+O")) {
if (!workspaceStore.get().workspacePath) return;
event.preventDefault();
Expand Down Expand Up @@ -341,7 +334,7 @@ function App() {
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [focusedSidebarPath, openFilePicker, openSettings]);
}, [focusedSidebarPath]);

useEffect(() => {
let active = true;
Expand Down Expand Up @@ -372,8 +365,11 @@ function App() {
desktopApi.onMenuCreateHtmlFile(() => void createHtmlFile()),
desktopApi.onMenuOpenFile(() => void openFilePicker()),
desktopApi.onMenuOpenFolder(() => void openWorkspaceWithSidebar()),
desktopApi.onMenuOpenSettings(() => openSettings()),
desktopApi.onMenuOpenChangelog(openWhatsNew),
desktopApi.onMenuOpenSettings(() => setSettingsOpen(true)),
desktopApi.onMenuOpenChangelog(() => {
setSettingsOpen(false);
void openChangelog();
}),
desktopApi.onMenuCopyAsMarkdown(() =>
setCopyAsMarkdownRequest((request) => request + 1),
),
Expand All @@ -399,7 +395,7 @@ function App() {
return () => {
for (const dispose of disposers) dispose();
};
}, [openFilePicker, openSettings, openWhatsNew]);
}, []);

useEffect(() => {
// Window focus can fire in bursts when switching apps, so debounce the
Expand Down Expand Up @@ -731,40 +727,34 @@ function MarkdownEditor({
title: wikiDisplayNameForTarget(target),
};
});
const openExternalLink = useCallback(
async (href: string) => {
if (classifyHref(href) === "external") {
await desktopApi.openExternalUrl(href);
const openExternalLink = async (href: string) => {
if (classifyHref(href) === "external") {
await desktopApi.openExternalUrl(href);
return;
}
const resolved = resolveRelativeLinkPath({
href,
currentFilePath: path,
workspacePath: workspace.workspacePath,
});
try {
const result = await desktopApi.openPathFromLink(resolved);
if (result.kind === "markdown") await loadPath(result.path);
} catch (error) {
if (error instanceof Error && error.message.includes("Open cancelled")) {
return;
}
const resolved = resolveRelativeLinkPath({
href,
currentFilePath: path,
workspacePath: workspace.workspacePath,
});
try {
const result = await desktopApi.openPathFromLink(resolved);
if (result.kind === "markdown") await loadPath(result.path);
} catch (error) {
if (
error instanceof Error &&
error.message.includes("Open cancelled")
) {
return;
}
if (
hasMarkdownExtension(resolved) &&
error instanceof Error &&
error.message.includes("FILE_NOT_FOUND")
) {
toast.error(`File not found: ${href.split("#", 1)[0] ?? href}`);
return;
}
throw error;
if (
hasMarkdownExtension(resolved) &&
error instanceof Error &&
error.message.includes("FILE_NOT_FOUND")
) {
toast.error(`File not found: ${href.split("#", 1)[0] ?? href}`);
return;
}
},
[path, workspace.workspacePath],
);
throw error;
}
};
return (
<EditorView
path={path}
Expand Down
Loading
Loading