style: update skills and UI overhaul

This commit is contained in:
Kamran Ahmed
2026-03-16 22:18:53 +00:00
parent 4a37eb433a
commit 7dd452dd4b
38 changed files with 959 additions and 331 deletions
+141
View File
@@ -0,0 +1,141 @@
# diffity
A git diff viewer that runs in your browser. Split or unified view, syntax highlighting, and inline code review with AI agent support.
## Install
```bash
npm install -g diffity
```
Install the AI review skills for your coding agent (Claude Code, Cursor, Codex, etc.):
```bash
npx skills add kamrify/diffity
```
Verify everything works:
```bash
diffity doctor
```
## Usage
Run `diffity` inside any git repository:
```bash
# Working tree changes
diffity
# Staged changes
diffity --staged
# Last commit
diffity HEAD~1
# Last 3 commits
diffity HEAD~3
# Compare branches
diffity main..feature
# Compare tags
diffity v1.0.0..v2.0.0
```
Your browser opens automatically with a GitHub-style diff view.
### Options
```
--staged Show staged changes
--port <port> Custom port (default: 5391)
--no-open Don't open browser
--dark Dark mode
--unified Unified view (default: split)
--quiet Minimal terminal output
```
## Code Review
Diffity has a built-in review system. Comments are stored locally in SQLite and appear in the browser in real time.
### With AI agents
Use the slash commands inside Claude Code, Cursor, or any supported agent:
**`/diffity-start`** — Launch diffity and open the browser
**`/diffity-review`** — AI reviews your diff and leaves inline comments with severity tags:
- `[must-fix]` — Bugs, security issues
- `[suggestion]` — Meaningful improvements
- `[nit]` — Style preferences
- `[question]` — Needs clarification
You can focus the review: `/diffity-review security` or `/diffity-review performance`
**`/diffity-resolve`** — AI reads the review comments, makes the code fixes, and marks threads as resolved
The typical workflow:
```
/diffity-start # open the diff viewer
/diffity-review # get a code review
# read comments in browser, decide what to fix
/diffity-resolve # AI fixes the flagged issues
```
### With the CLI
You can also manage comments directly from the terminal:
```bash
# List open threads
diffity agent list --status open
# Leave a comment
diffity agent comment --file src/app.ts --line 42 --body "[must-fix] Missing null check"
# Leave a general comment (not tied to a line)
diffity agent general-comment --body "Looks good overall, one issue to fix"
# Reply to a thread
diffity agent reply abc123 --body "Good catch, fixed"
# Mark as resolved
diffity agent resolve abc123 --summary "Added null check"
# Mark as won't fix
diffity agent dismiss abc123 --reason "Intentional behavior"
# Get full JSON output for scripting
diffity agent list --json
```
Thread IDs accept 8-character prefixes — you don't need to type the full UUID.
## Other Commands
```bash
# Check for issues
diffity doctor
# Update to latest version
diffity update
# Remove all diffity data (~/.diffity)
diffity prune
```
## How It Works
Diffity starts a local HTTP server that serves a React app. The server reads git data from your repository and the browser renders the diff with syntax highlighting (via Shiki).
Review comments are stored in a SQLite database at `~/.diffity/`. The browser polls for new comments every 2 seconds, so comments from the CLI or AI agents appear almost instantly.
Each `diffity` session is tied to a specific git HEAD hash. If you make new commits, start a new session to get a fresh review context.
## License
[PolyForm Shield 1.0.0](./LICENSE) © [Kamran Ahmed](https://x.com/kamrify)
+3 -3
View File
@@ -4286,9 +4286,9 @@
"license": "MIT"
},
"node_modules/node-abi": {
"version": "3.88.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.88.0.tgz",
"integrity": "sha512-At6b4UqIEVudaqPsXjmUO1r/N5BUr4yhDGs5PkBE8/oG5+TfLPhFechiskFsnT6Ql0VfUXbalUUCbfXxtj7K+w==",
"version": "3.89.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
"integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
+72 -1
View File
@@ -1,21 +1,26 @@
#!/usr/bin/env node
import { Command } from 'commander';
import { execSync } from 'node:child_process';
import { rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { createRequire } from 'node:module';
import open from 'open';
import pc from 'picocolors';
import { isGitRepo } from '@diffity/git';
import { startServer } from './server.js';
import { registerAgentCommands } from './agent.js';
const require = createRequire(import.meta.url);
const pkg = require('../package.json');
const program = new Command();
program
.name('diffity')
.description('GitHub-style git diff viewer in the browser')
.version('0.1.0')
.version(pkg.version)
.argument('[refs...]', 'Git refs to diff (e.g. HEAD~3, main, main..feature)')
.option('--staged', 'Show staged changes (git diff --staged)')
.option('--port <port>', 'Port to use', '5391')
@@ -130,6 +135,72 @@ program
console.log(pc.green('Pruned all diffity data (~/.diffity).'));
});
program
.command('update')
.description('Update diffity to the latest version')
.action(() => {
try {
const registry = execSync('npm view diffity version', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
if (registry === pkg.version) {
console.log(pc.green(`Already on the latest version (${pkg.version}).`));
return;
}
console.log(`${pc.dim(`Current: ${pkg.version}`)}${pc.bold(registry)}`);
console.log(pc.dim('Updating...'));
execSync('npm install -g diffity@latest', { stdio: 'inherit' });
console.log(pc.green(`Updated to ${registry}.`));
} catch {
console.error(pc.red('Failed to update. Try running: npm install -g diffity@latest'));
process.exit(1);
}
});
program
.command('doctor')
.description('Check that diffity can run correctly')
.action(() => {
let ok = true;
process.stdout.write(' git ');
try {
const gitVersion = execSync('git --version', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
console.log(pc.green(`${gitVersion}`));
} catch {
console.log(pc.red('✗ git not found'));
ok = false;
}
process.stdout.write(' git repo ');
if (isGitRepo()) {
console.log(pc.green('✓ inside a git repository'));
} else {
console.log(pc.yellow('- not inside a git repository'));
}
process.stdout.write(' node ');
console.log(pc.green(`${process.version}`));
process.stdout.write(' sqlite ');
try {
require('better-sqlite3');
console.log(pc.green('✓ better-sqlite3 loaded'));
} catch {
console.log(pc.red('✗ better-sqlite3 failed to load (native module issue)'));
ok = false;
}
process.stdout.write(' version ');
console.log(pc.green(`✓ diffity ${pkg.version}`));
console.log('');
if (ok) {
console.log(pc.green(' All checks passed.'));
} else {
console.log(pc.red(' Some checks failed. Fix the issues above and try again.'));
process.exit(1);
}
});
registerAgentCommands(program);
program.parse();
+150
View File
@@ -0,0 +1,150 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import {
createThread,
getThreadsForSession,
addReply,
updateThreadStatus,
deleteThread,
deleteAllThreadsForSession,
deleteComment,
type ThreadStatus,
} from './threads.js';
import { getCurrentSession } from './session.js';
function sendJson(res: ServerResponse, data: unknown) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
function sendError(res: ServerResponse, status: number, message: string) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: message }));
}
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks).toString()));
req.on('error', reject);
});
}
export function handleReviewRoute(req: IncomingMessage, res: ServerResponse, pathname: string, url: URL): boolean {
if (pathname === '/api/sessions/current' && req.method === 'GET') {
const session = getCurrentSession();
sendJson(res, session);
return true;
}
if (pathname === '/api/threads' && req.method === 'GET') {
const sid = url.searchParams.get('session');
if (!sid) {
sendError(res, 400, 'Missing session parameter');
return true;
}
const status = url.searchParams.get('status') as ThreadStatus | null;
const threads = getThreadsForSession(sid, status || undefined);
sendJson(res, threads);
return true;
}
if (pathname === '/api/threads' && req.method === 'DELETE') {
readBody(req).then((raw) => {
try {
const body = JSON.parse(raw);
const { sessionId: sid } = body;
if (!sid) {
sendError(res, 400, 'Missing sessionId');
return;
}
deleteAllThreadsForSession(sid);
sendJson(res, { ok: true });
} catch (err) {
sendError(res, 500, `Failed to delete all threads: ${err}`);
}
});
return true;
}
if (pathname === '/api/threads' && req.method === 'POST') {
readBody(req).then((raw) => {
try {
const body = JSON.parse(raw);
const { sessionId: sid, filePath, side, startLine, endLine, body: commentBody, author, anchorContent } = body;
if (!sid || !filePath || !side || typeof startLine !== 'number' || typeof endLine !== 'number' || !commentBody || !author) {
sendError(res, 400, 'Missing required fields');
return;
}
const thread = createThread(sid, filePath, side, startLine, endLine, commentBody, author, anchorContent);
sendJson(res, thread);
} catch (err) {
sendError(res, 500, `Failed to create thread: ${err}`);
}
});
return true;
}
const threadReplyMatch = pathname.match(/^\/api\/threads\/([^/]+)\/reply$/);
if (threadReplyMatch && req.method === 'POST') {
readBody(req).then((raw) => {
try {
const body = JSON.parse(raw);
const { body: commentBody, author } = body;
if (!commentBody || !author) {
sendError(res, 400, 'Missing body or author');
return;
}
const comment = addReply(threadReplyMatch[1], commentBody, author);
sendJson(res, comment);
} catch (err) {
sendError(res, 500, `Failed to add reply: ${err}`);
}
});
return true;
}
const threadStatusMatch = pathname.match(/^\/api\/threads\/([^/]+)\/status$/);
if (threadStatusMatch && req.method === 'PATCH') {
readBody(req).then((raw) => {
try {
const body = JSON.parse(raw);
const { status, summary } = body;
if (!status) {
sendError(res, 400, 'Missing status');
return;
}
const summaryAuthor = summary ? { name: 'System', type: 'user' as const } : undefined;
updateThreadStatus(threadStatusMatch[1], status, summary, summaryAuthor);
sendJson(res, { ok: true });
} catch (err) {
sendError(res, 500, `Failed to update thread status: ${err}`);
}
});
return true;
}
const threadDeleteMatch = pathname.match(/^\/api\/threads\/([^/]+)$/);
if (threadDeleteMatch && req.method === 'DELETE') {
try {
deleteThread(threadDeleteMatch[1]);
sendJson(res, { ok: true });
} catch (err) {
sendError(res, 500, `Failed to delete thread: ${err}`);
}
return true;
}
const commentDeleteMatch = pathname.match(/^\/api\/comments\/([^/]+)$/);
if (commentDeleteMatch && req.method === 'DELETE') {
try {
deleteComment(commentDeleteMatch[1]);
sendJson(res, { ok: true });
} catch (err) {
sendError(res, 500, `Failed to delete comment: ${err}`);
}
return true;
}
return false;
}
+4 -118
View File
@@ -22,17 +22,8 @@ import {
revertHunk,
isActionableRef,
} from '@diffity/git';
import { findOrCreateSession, getCurrentSession } from './session.js';
import {
createThread,
getThreadsForSession,
addReply,
updateThreadStatus,
deleteThread,
deleteAllThreadsForSession,
deleteComment,
type ThreadStatus,
} from './threads.js';
import { findOrCreateSession } from './session.js';
import { handleReviewRoute } from './review-routes.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -228,7 +219,7 @@ export function startServer(options: ServerOptions): Promise<ServerResult> {
fileMap.set(f, 'staged');
}
for (const f of unstaged) {
fileMap.set(f, fileMap.has(f) ? 'modified' : 'modified');
fileMap.set(f, 'modified');
}
for (const f of untracked) {
fileMap.set(f, 'added');
@@ -337,112 +328,7 @@ export function startServer(options: ServerOptions): Promise<ServerResult> {
return;
}
// --- Review API endpoints ---
if (pathname === '/api/sessions/current' && req.method === 'GET') {
const session = getCurrentSession();
sendJson(res, session);
return;
}
if (pathname === '/api/threads' && req.method === 'GET') {
const sid = url.searchParams.get('session');
if (!sid) {
sendError(res, 400, 'Missing session parameter');
return;
}
const status = url.searchParams.get('status') as ThreadStatus | null;
const threads = getThreadsForSession(sid, status || undefined);
sendJson(res, threads);
return;
}
if (pathname === '/api/threads' && req.method === 'DELETE') {
try {
const body = JSON.parse(await readBody(req));
const { sessionId: sid } = body;
if (!sid) {
sendError(res, 400, 'Missing sessionId');
return;
}
deleteAllThreadsForSession(sid);
sendJson(res, { ok: true });
} catch (err) {
sendError(res, 500, `Failed to delete all threads: ${err}`);
}
return;
}
if (pathname === '/api/threads' && req.method === 'POST') {
try {
const body = JSON.parse(await readBody(req));
const { sessionId: sid, filePath, side, startLine, endLine, body: commentBody, author, anchorContent } = body;
if (!sid || !filePath || !side || typeof startLine !== 'number' || typeof endLine !== 'number' || !commentBody || !author) {
sendError(res, 400, 'Missing required fields');
return;
}
const thread = createThread(sid, filePath, side, startLine, endLine, commentBody, author, anchorContent);
sendJson(res, thread);
} catch (err) {
sendError(res, 500, `Failed to create thread: ${err}`);
}
return;
}
const threadReplyMatch = pathname.match(/^\/api\/threads\/([^/]+)\/reply$/);
if (threadReplyMatch && req.method === 'POST') {
try {
const body = JSON.parse(await readBody(req));
const { body: commentBody, author } = body;
if (!commentBody || !author) {
sendError(res, 400, 'Missing body or author');
return;
}
const comment = addReply(threadReplyMatch[1], commentBody, author);
sendJson(res, comment);
} catch (err) {
sendError(res, 500, `Failed to add reply: ${err}`);
}
return;
}
const threadStatusMatch = pathname.match(/^\/api\/threads\/([^/]+)\/status$/);
if (threadStatusMatch && req.method === 'PATCH') {
try {
const body = JSON.parse(await readBody(req));
const { status, summary } = body;
if (!status) {
sendError(res, 400, 'Missing status');
return;
}
const summaryAuthor = summary ? { name: 'System', type: 'user' as const } : undefined;
updateThreadStatus(threadStatusMatch[1], status, summary, summaryAuthor);
sendJson(res, { ok: true });
} catch (err) {
sendError(res, 500, `Failed to update thread status: ${err}`);
}
return;
}
const threadDeleteMatch = pathname.match(/^\/api\/threads\/([^/]+)$/);
if (threadDeleteMatch && req.method === 'DELETE') {
try {
deleteThread(threadDeleteMatch[1]);
sendJson(res, { ok: true });
} catch (err) {
sendError(res, 500, `Failed to delete thread: ${err}`);
}
return;
}
const commentDeleteMatch = pathname.match(/^\/api\/comments\/([^/]+)$/);
if (commentDeleteMatch && req.method === 'DELETE') {
try {
deleteComment(commentDeleteMatch[1]);
sendJson(res, { ok: true });
} catch (err) {
sendError(res, 500, `Failed to delete comment: ${err}`);
}
if (handleReviewRoute(req, res, pathname, url)) {
return;
}
+22 -5
View File
@@ -76,12 +76,28 @@ function rowToComment(row: CommentRow): ThreadComment {
};
}
function getCommentsForThread(threadId: string): ThreadComment[] {
function getCommentsForThreads(threadIds: string[]): Map<string, ThreadComment[]> {
if (threadIds.length === 0) {
return new Map();
}
const db = getDb();
const placeholders = threadIds.map(() => '?').join(', ');
const rows = db.prepare(
'SELECT * FROM comments WHERE thread_id = ? ORDER BY created_at ASC'
).all(threadId) as CommentRow[];
return rows.map(rowToComment);
`SELECT * FROM comments WHERE thread_id IN (${placeholders}) ORDER BY created_at ASC`
).all(...threadIds) as CommentRow[];
const map = new Map<string, ThreadComment[]>();
for (const row of rows) {
const comments = map.get(row.thread_id) ?? [];
comments.push(rowToComment(row));
map.set(row.thread_id, comments);
}
return map;
}
function getCommentsForThread(threadId: string): ThreadComment[] {
const map = getCommentsForThreads([threadId]);
return map.get(threadId) ?? [];
}
export function createThread(
@@ -140,7 +156,8 @@ export function getThreadsForSession(sessionId: string, status?: ThreadStatus):
).all(sessionId) as ThreadRow[];
}
return rows.map(row => rowToThread(row, getCommentsForThread(row.id)));
const commentsByThread = getCommentsForThreads(rows.map(r => r.id));
return rows.map(row => rowToThread(row, commentsByThread.get(row.id) ?? []));
}
export function getThread(idOrPrefix: string): Thread | null {
+1 -1
View File
@@ -31,7 +31,7 @@ You are reading open review comments and resolving them by making the requested
## Prerequisites
1. Check that `{{binary}}` is available: run `which {{binary}}`. If not found, tell the user to run `npm run dev` from the diffity repo root to link the CLI.
1. Check that `{{binary}}` is available: run `which {{binary}}`. If not found, {{install_hint}}.
2. Check that a review session exists: run `cat .diffity/current-session`. If the file doesn't exist or is stale, tell the user to start diffity first.
## Instructions
+1 -1
View File
@@ -31,7 +31,7 @@ You are reviewing a diff and leaving inline comments using the `{{binary}} agent
## Prerequisites
1. Check that `{{binary}}` is available: run `which {{binary}}`. If not found, tell the user to run `npm run dev` from the diffity repo root to link the CLI.
1. Check that `{{binary}}` is available: run `which {{binary}}`. If not found, {{install_hint}}.
2. Check that a review session exists: run `cat .diffity/current-session`. If the file doesn't exist or is stale, tell the user to start diffity first (e.g. `{{binary}}` or `{{binary}} --staged`).
## Instructions
+1 -1
View File
@@ -10,7 +10,7 @@ You are starting the diffity diff viewer so the user can see their changes in th
## Instructions
1. Check that `{{binary}}` is available: run `which {{binary}}`. If not found, tell the user to run `npm run dev` from the diffity repo root to link the CLI.
1. Check that `{{binary}}` is available: run `which {{binary}}`. If not found, {{install_hint}}.
2. Determine which mode to start in:
- If the user said "staged" or there are staged changes they want to review: `{{binary}} --staged`
- Otherwise default to: `{{binary}}`
@@ -41,7 +41,7 @@ export function CommentFormRow(props: CommentFormRowProps) {
) : (
<>
<td colSpan={colSpan} className="bg-bg-secondary"></td>
<td colSpan={colSpan} className="px-4 py-3 bg-bg-secondary">{formContent}</td>
<td colSpan={colSpan} className="px-4 py-3 bg-bg-secondary border-l border-border">{formContent}</td>
</>
)}
</tr>
+71 -38
View File
@@ -7,6 +7,7 @@ import { CommentBubble } from './comment-bubble';
import { TrashIcon } from './icons/trash-icon';
import { CommentIcon } from './icons/comment-icon';
import { ThreadBadge } from './ui/thread-badge';
import { cn } from '../lib/cn';
interface CommentThreadProps {
thread: CommentThreadType;
@@ -27,34 +28,48 @@ function StatusBadge(props: { status: string }) {
switch (status) {
case 'resolved':
return <ThreadBadge variant="resolved" />;
return <ThreadBadge variant='resolved' />;
case 'dismissed':
return <ThreadBadge variant="dismissed" />;
return <ThreadBadge variant='dismissed' />;
default:
return null;
}
}
export function CommentThread(props: CommentThreadProps) {
const { thread, onReply, onResolve, onUnresolve, onDeleteComment, onDeleteThread, currentAuthor, colSpan, viewMode, side, currentCode } = props;
const {
thread,
onReply,
onResolve,
onUnresolve,
onDeleteComment,
onDeleteThread,
currentAuthor,
colSpan,
viewMode,
side,
currentCode,
} = props;
const [showReply, setShowReply] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(isThreadResolved(thread));
const isOutdated = thread.anchorContent && currentCode && thread.anchorContent !== currentCode;
const isOutdated =
thread.anchorContent && currentCode && thread.anchorContent !== currentCode;
if (isCollapsed) {
const collapsedContent = (
<td colSpan={colSpan} className="px-4 py-2 border-l border-border">
<td colSpan={colSpan} className='px-4 py-2 border-l border-border'>
<button
onClick={() => setIsCollapsed(false)}
className="flex items-center gap-2 text-xs text-text-muted hover:text-text-secondary transition-colors cursor-pointer"
className='flex items-center gap-2 text-xs text-text-muted hover:text-text-secondary transition-colors cursor-pointer'
>
<CommentIcon className="w-4 h-4" />
<span>{thread.comments.length} comment{thread.comments.length !== 1 ? 's' : ''}</span>
<CommentIcon className='w-4 h-4' />
<span>
{thread.comments.length} comment
{thread.comments.length !== 1 ? 's' : ''}
</span>
<StatusBadge status={thread.status} />
{isOutdated && (
<ThreadBadge variant="outdated" />
)}
{isOutdated && <ThreadBadge variant='outdated' />}
</button>
</td>
);
@@ -63,9 +78,15 @@ export function CommentThread(props: CommentThreadProps) {
return (
<tr data-thread-id={thread.id}>
{side === 'old' ? (
<>{collapsedContent}<td colSpan={colSpan}></td></>
<>
{collapsedContent}
<td colSpan={colSpan}></td>
</>
) : (
<><td colSpan={colSpan}></td>{collapsedContent}</>
<>
<td colSpan={colSpan}></td>
{collapsedContent}
</>
)}
</tr>
);
@@ -74,28 +95,34 @@ export function CommentThread(props: CommentThreadProps) {
return <tr data-thread-id={thread.id}>{collapsedContent}</tr>;
}
const lineLabel = thread.startLine === thread.endLine
? `Line ${thread.startLine}`
: `Lines ${thread.startLine}${thread.endLine}`;
const lineLabel =
thread.startLine === thread.endLine
? `Line ${thread.startLine}`
: `Lines ${thread.startLine}${thread.endLine}`;
const resolved = isThreadResolved(thread);
const threadContent = (
<td colSpan={colSpan} className="px-4 py-3 border-l border-border">
<div className="border border-border rounded-lg overflow-hidden max-w-[700px]">
<div className="flex items-center justify-between px-3 py-1.5 bg-bg-secondary border-b border-border">
<div className="flex items-center gap-2">
<span className="text-[11px] text-text-muted font-mono">{lineLabel}</span>
<td
colSpan={colSpan}
className={cn('px-4 py-3', {
'border-l border-border': side === 'new' && viewMode === 'split',
})}
>
<div className='border border-border rounded-lg overflow-hidden max-w-[700px]'>
<div className='flex items-center justify-between px-3 py-1.5 bg-bg-secondary border-b border-border'>
<div className='flex items-center gap-2'>
<span className='text-[11px] text-text-muted font-mono'>
{lineLabel}
</span>
<StatusBadge status={thread.status} />
{isOutdated && (
<ThreadBadge variant="outdated" />
)}
{isOutdated && <ThreadBadge variant='outdated' />}
</div>
<div className="flex items-center gap-1">
<div className='flex items-center gap-1'>
{resolved ? (
<button
onClick={() => onUnresolve(thread.id)}
className="text-[11px] text-text-muted hover:text-text-secondary transition-colors cursor-pointer"
className='text-[11px] text-text-muted hover:text-text-secondary transition-colors cursor-pointer'
>
Reopen
</button>
@@ -105,23 +132,23 @@ export function CommentThread(props: CommentThreadProps) {
onResolve(thread.id);
setIsCollapsed(true);
}}
className="text-[11px] text-text-muted hover:text-text-secondary transition-colors cursor-pointer"
className='text-[11px] text-text-muted hover:text-text-secondary transition-colors cursor-pointer'
>
Resolve
</button>
)}
<button
onClick={() => setIsCollapsed(true)}
className="text-[11px] text-text-muted hover:text-text-secondary transition-colors cursor-pointer ml-2"
className='text-[11px] text-text-muted hover:text-text-secondary transition-colors cursor-pointer ml-2'
>
Collapse
</button>
<button
onClick={() => onDeleteThread(thread.id)}
className="text-text-muted hover:text-deleted transition-colors cursor-pointer ml-1"
title="Delete thread"
className='text-text-muted hover:text-deleted transition-colors cursor-pointer ml-1'
title='Delete thread'
>
<TrashIcon className="w-3.5 h-3.5" />
<TrashIcon className='w-3.5 h-3.5' />
</button>
</div>
</div>
@@ -135,22 +162,22 @@ export function CommentThread(props: CommentThreadProps) {
))}
</div>
{showReply ? (
<div className="px-3 py-2 border-t border-border">
<div className='px-3 py-2 border-t border-border'>
<CommentForm
onSubmit={(body) => {
onReply(thread.id, body, currentAuthor);
setShowReply(false);
}}
onCancel={() => setShowReply(false)}
placeholder="Reply..."
submitLabel="Reply"
placeholder='Reply...'
submitLabel='Reply'
/>
</div>
) : (
<div className="px-3 py-2 border-t border-border">
<div className='px-3 py-2 border-t border-border'>
<button
onClick={() => setShowReply(true)}
className="text-xs text-accent hover:text-accent-hover transition-colors cursor-pointer"
className='text-xs text-accent hover:text-accent-hover transition-colors cursor-pointer'
>
Reply
</button>
@@ -164,9 +191,15 @@ export function CommentThread(props: CommentThreadProps) {
return (
<tr data-thread-id={thread.id}>
{side === 'old' ? (
<>{threadContent}<td colSpan={colSpan}></td></>
<>
{threadContent}
<td colSpan={colSpan}></td>
</>
) : (
<><td colSpan={colSpan}></td>{threadContent}</>
<>
<td colSpan={colSpan}></td>
{threadContent}
</>
)}
</tr>
);
+10 -9
View File
@@ -256,21 +256,21 @@ export function DiffPage(props: DiffPageProps) {
if (diff && diff.files.length === 0 && !diffLoading) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-bg text-text font-sans gap-3">
<div className="text-added opacity-50 mb-2">
<div className="flex flex-col items-center justify-center min-h-screen bg-bg text-text font-sans gap-2">
<div className="text-added opacity-40 mb-1">
<CheckCircleIcon />
</div>
<h2 className="text-xl text-text-secondary">No changes found</h2>
<p className="text-text-muted">There are no differences to display.</p>
<div className="mt-4 flex flex-col gap-2 items-center">
<p className="text-sm text-text-muted mb-1">Try one of these</p>
<code className="inline-block px-3 py-1 bg-bg-secondary border border-border rounded-md font-mono text-sm text-text">
<h2 className="text-base font-medium text-text-secondary">No changes found</h2>
<p className="text-xs text-text-muted">There are no differences to display.</p>
<div className="mt-4 flex flex-col gap-1.5 items-center">
<p className="text-xs text-text-muted mb-1">Try one of these</p>
<code className="inline-block px-3 py-1 bg-bg-secondary border border-border rounded-md font-mono text-xs text-text">
diffity --staged
</code>
<code className="inline-block px-3 py-1 bg-bg-secondary border border-border rounded-md font-mono text-sm text-text">
<code className="inline-block px-3 py-1 bg-bg-secondary border border-border rounded-md font-mono text-xs text-text">
diffity HEAD~1
</code>
<code className="inline-block px-3 py-1 bg-bg-secondary border border-border rounded-md font-mono text-sm text-text">
<code className="inline-block px-3 py-1 bg-bg-secondary border border-border rounded-md font-mono text-xs text-text">
diffity main..feature
</code>
</div>
@@ -293,6 +293,7 @@ export function DiffPage(props: DiffPageProps) {
onHideWhitespaceChange={setHideWhitespace}
theme={theme}
onToggleTheme={toggleTheme}
onShowHelp={() => setShowHelp(true)}
diff={diff || undefined}
diffRef={refParam}
threads={threads}
+15 -15
View File
@@ -331,22 +331,22 @@ export function FileBlock(props: FileBlockProps) {
return (
<div
className={`border rounded-lg mx-4 my-4 overflow-hidden ${highlighted ? 'animate-flash-highlight-border' : 'border-border'}`}
className={`border rounded-lg mx-4 my-3 overflow-hidden ${highlighted ? 'animate-flash-highlight-border' : 'border-border'}`}
id={`file-${encodeURIComponent(filePath)}`}
onAnimationEnd={onHighlightEnd}
>
<div
className={`flex items-center gap-2 px-3 py-2 border-b border-border text-sm sticky top-0 z-10 shadow-sticky ${highlighted ? 'animate-flash-highlight' : 'bg-bg-secondary'}`}
className={`flex items-center gap-2 px-3 py-1.5 border-border text-xs sticky top-0 z-10 shadow-sticky ${highlighted ? 'animate-flash-highlight' : 'bg-bg-secondary'}`}
>
<IconButton
className="text-[10px] w-5 h-5 shrink-0"
className="text-[10px] w-4 h-4 shrink-0"
onClick={() => onToggleCollapse(filePath)}
title={collapsed ? 'Expand' : 'Collapse'}
>
{collapsed ? '\u25b6' : '\u25bc'}
</IconButton>
<button
className="font-mono text-sm truncate text-left cursor-pointer hover:text-text-link transition-colors"
className="font-mono text-xs truncate text-left cursor-pointer hover:text-accent transition-colors"
onClick={() => onToggleCollapse(filePath)}
>
{showRename ? (
@@ -361,21 +361,21 @@ export function FileBlock(props: FileBlockProps) {
</button>
<button
onClick={() => copyPath(filePath)}
className="shrink-0 text-text-muted hover:text-text transition-colors cursor-pointer"
className="shrink-0 text-text-muted hover:text-text transition-colors cursor-pointer opacity-0 group-hover:opacity-100"
title="Copy file path"
>
{pathCopied ? (
<CheckIcon className="w-3.5 h-3.5 text-added" />
<CheckIcon className="w-3 h-3 text-added" />
) : (
<CopyIcon className="w-3.5 h-3.5" />
<CopyIcon className="w-3 h-3" />
)}
</button>
{file.status !== 'modified' && <StatusBadge status={file.status} />}
{file.isBinary && <Badge className="bg-bg-tertiary text-text-muted">Binary</Badge>}
<div className="ml-auto flex items-center gap-3 shrink-0">
<div className="ml-auto flex items-center gap-2.5 shrink-0">
{(fileThreads.length + orphanedThreads.length) > 0 && (
<span className="text-xs text-text-muted flex items-center gap-1">
<CommentIcon className="w-3.5 h-3.5" />
<span className="text-[11px] text-text-muted flex items-center gap-1">
<CommentIcon className="w-3 h-3" />
{fileThreads.length + orphanedThreads.length}
{orphanedThreads.length > 0 && (
<ThreadBadge variant="outdated" size="sm">
@@ -388,22 +388,22 @@ export function FileBlock(props: FileBlockProps) {
<DiffStats additions={file.additions} deletions={file.deletions} />
<div className="flex gap-px">
{Array.from({ length: addBlocks }).map((_, i) => (
<span key={`a${i}`} className="w-2 h-2 rounded-[1px] bg-added" />
<span key={`a${i}`} className="w-1.5 h-1.5 rounded-sm bg-added" />
))}
{Array.from({ length: delBlocks }).map((_, i) => (
<span key={`d${i}`} className="w-2 h-2 rounded-[1px] bg-deleted" />
<span key={`d${i}`} className="w-1.5 h-1.5 rounded-sm bg-deleted" />
))}
{Array.from({ length: neutralBlocks }).map((_, i) => (
<span key={`n${i}`} className="w-2 h-2 rounded-[1px] bg-border" />
<span key={`n${i}`} className="w-1.5 h-1.5 rounded-sm bg-border" />
))}
</div>
</div>
<label className="flex items-center gap-1.5 text-xs text-text-muted cursor-pointer select-none hover:text-text transition-colors">
<label className="flex items-center gap-1.5 text-[11px] text-text-muted cursor-pointer select-none hover:text-text transition-colors">
<input
type="checkbox"
checked={reviewed}
onChange={() => onReviewedChange(filePath, !reviewed)}
className="accent-added cursor-pointer"
className="accent-added cursor-pointer w-3 h-3"
/>
Viewed
</label>
@@ -4,7 +4,6 @@ import { DiffStats } from './diff-stats';
import { StatusBadge } from './ui/status-badge';
import { ChevronIcon } from './icons/chevron-icon';
import { FolderIcon } from './icons/folder-icon';
import { FileIcon } from './icons/file-icon';
import { CommentIcon } from './icons/comment-icon';
interface FileTreeItemProps {
@@ -27,7 +26,7 @@ export function FileTreeItem(props: FileTreeItemProps) {
return (
<>
<button
className="flex items-center gap-1.5 w-full py-0.5 pr-2 text-left text-sm hover:bg-hover cursor-pointer"
className="flex items-center gap-1.5 w-full py-1 pr-2 text-left text-[13px] hover:bg-hover cursor-pointer"
style={{ paddingLeft: `${paddingLeft}px` }}
onClick={() => onToggleDir(node.path)}
>
@@ -59,25 +58,24 @@ export function FileTreeItem(props: FileTreeItemProps) {
return (
<button
className={cn(
'flex items-center gap-1.5 w-full py-0.5 pr-2 text-left text-sm cursor-pointer border-l-2',
'flex items-center gap-1.5 w-full py-1 pr-2 text-left text-[13px] cursor-pointer border-l-2',
isActive
? 'bg-active border-l-accent'
: 'border-l-transparent hover:bg-hover',
isReviewed && 'opacity-60'
isReviewed && 'opacity-50'
)}
style={{ paddingLeft: `${paddingLeft + 15}px` }}
onClick={() => onFileClick(node.path)}
>
<FileIcon />
<StatusBadge status={node.file.status} compact />
<span className={cn('flex-1 min-w-0 truncate', isReviewed && 'line-through')}>
{node.name}
</span>
{hasComments && (
<CommentIcon className="w-3.5 h-3.5 text-accent shrink-0" />
<CommentIcon className="w-3 h-3 text-accent shrink-0" />
)}
{isReviewed ? (
<span className="text-added text-xs shrink-0" title="Viewed">&#10003;</span>
<span className="text-added text-[10px] shrink-0" title="Viewed">&#10003;</span>
) : (
<DiffStats additions={node.file.additions} deletions={node.file.deletions} />
)}
@@ -0,0 +1,10 @@
import type { SVGProps } from 'react';
export function EyeIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8s-2.5 4.5-6.5 4.5S1.5 8 1.5 8z" />
<circle cx="8" cy="8" r="2" />
</svg>
);
}
@@ -0,0 +1,12 @@
import type { SVGProps } from 'react';
export function EyeOffIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M2 2l12 12" />
<path d="M6.5 6.5a2 2 0 0 0 2.83 2.83" />
<path d="M4.2 4.2C2.7 5.3 1.5 8 1.5 8s2.5 4.5 6.5 4.5c1.3 0 2.5-.4 3.5-1" />
<path d="M12.5 10.5c1-1.1 2-2.5 2-2.5s-2.5-4.5-6.5-4.5c-.5 0-1 .05-1.5.15" />
</svg>
);
}
@@ -0,0 +1,13 @@
import type { SVGProps } from 'react';
export function GitBranchIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" {...props}>
<line x1="5" y1="3.5" x2="5" y2="12.5" />
<circle cx="5" cy="3.5" r="1.5" />
<circle cx="5" cy="12.5" r="1.5" />
<circle cx="11" cy="5.5" r="1.5" />
<path d="M11 7c0 2-2 3.5-6 4" />
</svg>
);
}
@@ -0,0 +1,13 @@
import type { SVGProps } from 'react';
export function KeyboardIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" {...props}>
<rect x="1" y="3.5" width="14" height="9" rx="2" />
<line x1="4" y1="6.5" x2="5" y2="6.5" />
<line x1="7.5" y1="6.5" x2="8.5" y2="6.5" />
<line x1="11" y1="6.5" x2="12" y2="6.5" />
<line x1="5" y1="9.5" x2="11" y2="9.5" />
</svg>
);
}
@@ -0,0 +1,9 @@
import type { SVGProps } from 'react';
export function MoonIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M13.5 8.5a5.5 5.5 0 0 1-7-7 5.5 5.5 0 1 0 7 7z" />
</svg>
);
}
@@ -0,0 +1,10 @@
import type { SVGProps } from 'react';
export function SearchIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" {...props}>
<circle cx="7" cy="7" r="4.5" />
<line x1="10.2" y1="10.2" x2="14" y2="14" />
</svg>
);
}
@@ -0,0 +1,10 @@
import type { SVGProps } from 'react';
export function SidebarIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" {...props}>
<rect x="1.5" y="2.5" width="13" height="11" rx="2" />
<line x1="5.5" y1="2.5" x2="5.5" y2="13.5" />
</svg>
);
}
@@ -0,0 +1,10 @@
import type { SVGProps } from 'react';
export function SplitViewIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" {...props}>
<rect x="1.5" y="1.5" width="13" height="13" rx="2" />
<line x1="8" y1="1.5" x2="8" y2="14.5" />
</svg>
);
}
@@ -0,0 +1,17 @@
import type { SVGProps } from 'react';
export function SunIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" {...props}>
<circle cx="8" cy="8" r="3" />
<line x1="8" y1="1" x2="8" y2="2.5" />
<line x1="8" y1="13.5" x2="8" y2="15" />
<line x1="1" y1="8" x2="2.5" y2="8" />
<line x1="13.5" y1="8" x2="15" y2="8" />
<line x1="3.05" y1="3.05" x2="4.1" y2="4.1" />
<line x1="11.9" y1="11.9" x2="12.95" y2="12.95" />
<line x1="3.05" y1="12.95" x2="4.1" y2="11.9" />
<line x1="11.9" y1="4.1" x2="12.95" y2="3.05" />
</svg>
);
}
@@ -0,0 +1,12 @@
import type { SVGProps } from 'react';
export function UnifiedViewIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" {...props}>
<rect x="1.5" y="1.5" width="13" height="13" rx="2" />
<line x1="4.5" y1="5.5" x2="11.5" y2="5.5" />
<line x1="4.5" y1="8" x2="11.5" y2="8" />
<line x1="4.5" y1="10.5" x2="11.5" y2="10.5" />
</svg>
);
}
@@ -0,0 +1,10 @@
import type { SVGProps } from 'react';
export function XIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...props}>
<line x1="4" y1="4" x2="12" y2="12" />
<line x1="12" y1="4" x2="4" y2="12" />
</svg>
);
}
+17 -14
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react';
import { IconButton } from './ui/icon-button';
import { XIcon } from './icons/x-icon';
interface ShortcutModalProps {
onClose: () => void;
@@ -52,7 +52,7 @@ export function ShortcutModal(props: ShortcutModalProps) {
return (
<dialog
ref={dialogRef}
className="bg-bg border border-border rounded-lg shadow-md w-[480px] max-w-[90vw] max-h-[80vh] overflow-y-auto backdrop:bg-black/50 p-0"
className="bg-bg text-text border border-border rounded-xl shadow-md w-[420px] max-w-[90vw] max-h-[80vh] overflow-y-auto backdrop:bg-black/60 backdrop:backdrop-blur-sm p-0 m-auto fixed inset-0 h-fit"
onClose={onClose}
onClick={(e) => {
if (e.target === dialogRef.current) {
@@ -60,25 +60,28 @@ export function ShortcutModal(props: ShortcutModalProps) {
}
}}
>
<div className="flex items-center justify-between p-4 border-b border-border">
<h2 className="text-lg font-semibold">Keyboard shortcuts</h2>
<IconButton className="text-2xl w-8 h-8" onClick={onClose}>
&times;
</IconButton>
<div className="flex items-center justify-between px-5 py-3.5 border-b border-border">
<h2 className="text-sm font-semibold">Keyboard shortcuts</h2>
<button
className="p-1 rounded-md text-text-muted hover:text-text hover:bg-hover cursor-pointer"
onClick={onClose}
>
<XIcon className="w-4 h-4" />
</button>
</div>
<div className="p-4">
<div className="px-5 py-4">
{shortcuts.map(group => (
<div key={group.category} className="mb-4 last:mb-0">
<h3 className="text-sm font-semibold text-text-secondary mb-2 uppercase tracking-wide">
<div key={group.category} className="mb-5 last:mb-0">
<h3 className="text-[10px] font-semibold text-text-muted mb-2.5 uppercase tracking-widest">
{group.category}
</h3>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1.5">
{group.items.map(item => (
<div key={item.key} className="flex items-center gap-3">
<kbd className="inline-flex items-center justify-center min-w-7 h-6 px-1.5 bg-bg-secondary border border-border rounded font-mono text-xs font-semibold shadow-[inset_0_-1px_0_var(--color-border)]">
<div key={item.key} className="flex items-center justify-between py-0.5">
<span className="text-xs text-text-secondary">{item.description}</span>
<kbd className="inline-flex items-center justify-center min-w-6 h-5 px-1.5 bg-bg-secondary border border-border rounded font-mono text-[11px] text-text-muted shadow-[inset_0_-1px_0_var(--color-border)]">
{item.key}
</kbd>
<span className="text-sm text-text-secondary">{item.description}</span>
</div>
))}
</div>
+17 -13
View File
@@ -1,6 +1,9 @@
import { useState } from 'react';
import type { DiffFile } from '@diffity/parser';
import { FileTree } from './file-tree';
import { SidebarIcon } from './icons/sidebar-icon';
import { SearchIcon } from './icons/search-icon';
import { XIcon } from './icons/x-icon';
interface SidebarProps {
files: DiffFile[];
@@ -17,38 +20,39 @@ export function Sidebar(props: SidebarProps) {
if (collapsed) {
return (
<div className="w-8 min-w-8 border-r border-border bg-bg-secondary flex items-start justify-center pt-2">
<div className="w-10 min-w-10 border-r border-border bg-bg-secondary flex items-start justify-center pt-3">
<button
className="text-lg p-1 text-text-muted hover:text-text cursor-pointer"
className="p-1.5 rounded-md text-text-muted hover:text-text hover:bg-hover cursor-pointer"
onClick={() => setCollapsed(false)}
title="Show sidebar"
>
&rsaquo;
<SidebarIcon className="w-4 h-4" />
</button>
</div>
);
}
return (
<aside className="w-75 min-w-75 border-r border-border bg-bg-secondary flex flex-col overflow-hidden">
<div className="flex items-center justify-between px-3 py-3 border-b border-border">
<span className="font-semibold text-sm flex items-center gap-2">
Files changed
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 bg-bg-tertiary rounded-full text-xs font-semibold text-text-secondary">
<aside className="w-72 min-w-72 border-r border-border bg-bg-secondary flex flex-col overflow-hidden">
<div className="flex items-center justify-between px-3 py-2.5 border-b border-border">
<span className="text-xs font-medium text-text-secondary flex items-center gap-2 uppercase tracking-wider">
Files
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 bg-bg-tertiary rounded-full text-[10px] font-semibold text-text-muted">
{reviewedFiles.size > 0 ? `${reviewedFiles.size}/${files.length}` : files.length}
</span>
</span>
<button
className="text-lg p-1 text-text-muted hover:text-text cursor-pointer"
className="p-1 rounded-md text-text-muted hover:text-text hover:bg-hover cursor-pointer"
onClick={() => setCollapsed(true)}
title="Hide sidebar"
>
&lsaquo;
<SidebarIcon className="w-3.5 h-3.5" />
</button>
</div>
<div className="relative px-3 py-2">
<SearchIcon className="absolute left-5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-muted pointer-events-none" />
<input
className="w-full px-2 py-1 border border-border rounded-md bg-bg text-sm outline-none focus:border-accent focus:ring-2 focus:ring-accent/30"
className="w-full pl-7 pr-7 py-1.5 border border-border rounded-md bg-bg text-xs outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 placeholder:text-text-muted"
type="text"
placeholder="Filter files..."
value={search}
@@ -56,10 +60,10 @@ export function Sidebar(props: SidebarProps) {
/>
{search && (
<button
className="absolute right-4 top-1/2 -translate-y-1/2 text-text-muted text-base cursor-pointer"
className="absolute right-5 top-1/2 -translate-y-1/2 text-text-muted hover:text-text cursor-pointer"
onClick={() => setSearch('')}
>
&times;
<XIcon className="w-3 h-3" />
</button>
)}
</div>
@@ -6,15 +6,15 @@ export function StaleDiffBanner(props: StaleDiffBannerProps) {
const { onRefresh } = props;
return (
<div className="sticky top-0 z-30 flex items-center justify-center gap-3 px-4 py-2 bg-accent/10 border-b border-accent/20 text-sm animate-slide-down">
<div className="sticky top-0 z-30 flex items-center justify-center gap-3 px-4 py-1.5 bg-accent/10 border-b border-accent/20 text-xs animate-slide-down">
<span className="text-accent font-medium">
Files have changed since this diff was loaded
</span>
<button
onClick={onRefresh}
className="px-3 py-1 bg-accent text-white rounded-md text-xs font-medium hover:bg-accent-hover transition-colors cursor-pointer"
className="px-2.5 py-0.5 bg-accent text-white rounded-md text-[11px] font-medium hover:bg-accent-hover transition-colors cursor-pointer"
>
Refresh diff
Refresh
</button>
</div>
);
+9 -7
View File
@@ -1,5 +1,6 @@
import type { ParsedDiff } from '@diffity/parser';
import { DiffStats } from './diff-stats';
import { GitBranchIcon } from './icons/git-branch-icon';
interface SummaryBarProps {
diff: ParsedDiff | null;
@@ -12,11 +13,12 @@ export function SummaryBar(props: SummaryBarProps) {
const { diff, repoName, branch, description } = props;
return (
<div className="flex items-center justify-between px-4 py-3 bg-bg-secondary border-b border-border font-sans text-sm min-h-11">
<div className="flex items-center gap-2">
{repoName && <span className="font-semibold text-accent">{repoName}</span>}
<div className="flex items-center justify-between px-4 py-2.5 bg-bg-secondary border-b border-border font-sans text-sm">
<div className="flex items-center gap-2.5">
{repoName && <span className="font-semibold text-text">{repoName}</span>}
{branch && (
<span className="px-2 py-0.5 bg-diff-hunk-bg text-diff-hunk-text rounded-md font-mono text-xs">
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-diff-hunk-bg text-diff-hunk-text rounded-md font-mono text-xs">
<GitBranchIcon className="w-3 h-3" />
{branch}
</span>
)}
@@ -24,13 +26,13 @@ export function SummaryBar(props: SummaryBarProps) {
</div>
{diff ? (
<div className="flex items-center gap-3">
<span className="text-text-secondary">
{diff.stats.filesChanged} file{diff.stats.filesChanged !== 1 ? 's' : ''} changed
<span className="text-text-muted text-xs">
{diff.stats.filesChanged} file{diff.stats.filesChanged !== 1 ? 's' : ''}
</span>
<DiffStats additions={diff.stats.totalAdditions} deletions={diff.stats.totalDeletions} />
</div>
) : (
<div className="w-48 h-5 bg-bg-tertiary rounded animate-pulse" />
<div className="w-48 h-4 bg-bg-tertiary rounded animate-pulse" />
)}
</div>
);
+51 -34
View File
@@ -9,6 +9,13 @@ import { CheckIcon } from './icons/check-icon';
import { ChevronUpIcon } from './icons/chevron-up-icon';
import { ChevronDownIcon } from './icons/chevron-down-icon';
import { TrashIcon } from './icons/trash-icon';
import { UnifiedViewIcon } from './icons/unified-view-icon';
import { SplitViewIcon } from './icons/split-view-icon';
import { SunIcon } from './icons/sun-icon';
import { MoonIcon } from './icons/moon-icon';
import { EyeIcon } from './icons/eye-icon';
import { EyeOffIcon } from './icons/eye-off-icon';
import { KeyboardIcon } from './icons/keyboard-icon';
import { ConfirmDialog } from './ui/confirm-dialog';
import { GENERAL_THREAD_FILE_PATH } from '../types/comment';
import type { ViewMode } from '../lib/diff-utils';
@@ -22,6 +29,7 @@ interface ToolbarProps {
onHideWhitespaceChange: (hide: boolean) => void;
theme: 'light' | 'dark';
onToggleTheme: () => void;
onShowHelp: () => void;
diff?: ParsedDiff;
diffRef?: string;
threads: CommentThread[];
@@ -109,6 +117,7 @@ export function Toolbar(props: ToolbarProps) {
onHideWhitespaceChange,
theme,
onToggleTheme,
onShowHelp,
diff,
diffRef,
threads,
@@ -119,93 +128,101 @@ export function Toolbar(props: ToolbarProps) {
const { copied, copy } = useCopy();
const { currentIndex, count: unresolvedCount, goToPrevious, goToNext } = useThreadNavigation(threads, onScrollToThread);
const baseBtn = 'px-3 py-1 border border-border text-sm text-text-secondary transition-colors duration-150 cursor-pointer';
const activeBtn = 'bg-accent text-white border-accent';
const baseBtn = 'px-2.5 py-1 text-xs text-text-secondary transition-colors duration-150 cursor-pointer';
const activeBtn = 'bg-accent text-white';
const inactiveBtn = 'bg-bg hover:bg-hover hover:text-text';
const iconBtn = 'p-1.5 rounded-md text-text-muted hover:text-text hover:bg-hover transition-colors cursor-pointer';
return (
<div className="flex items-center gap-4 px-4 py-2 bg-bg-secondary border-b border-border font-sans text-sm">
<div className="flex items-center gap-px">
<div className="flex items-center gap-3 px-4 py-1.5 bg-bg-secondary border-b border-border font-sans text-xs">
<div className="flex items-center border border-border rounded-md overflow-hidden">
<button
className={cn(baseBtn, 'rounded-l-md', viewMode === 'unified' ? activeBtn : inactiveBtn)}
className={cn(baseBtn, 'flex items-center gap-1.5', viewMode === 'unified' ? activeBtn : inactiveBtn)}
onClick={() => onViewModeChange('unified')}
title="Unified view (u)"
>
<UnifiedViewIcon className="w-3.5 h-3.5" />
Unified
</button>
<button
className={cn(baseBtn, 'rounded-r-md border-l-0', viewMode === 'split' ? activeBtn : inactiveBtn)}
className={cn(baseBtn, 'flex items-center gap-1.5', viewMode === 'split' ? activeBtn : inactiveBtn)}
onClick={() => onViewModeChange('split')}
title="Split view (s)"
>
<SplitViewIcon className="w-3.5 h-3.5" />
Split
</button>
</div>
<div className="flex items-center gap-px">
<label className="flex items-center gap-2 text-text-secondary cursor-pointer">
<input
type="checkbox"
checked={hideWhitespace}
onChange={e => onHideWhitespaceChange(e.target.checked)}
className="cursor-pointer"
/>
<span>Hide whitespace</span>
</label>
</div>
<div className="flex items-center gap-px">
<button
className={cn(iconBtn, 'flex items-center gap-1.5 text-xs', hideWhitespace && 'text-accent')}
onClick={() => onHideWhitespaceChange(!hideWhitespace)}
title={hideWhitespace ? 'Show whitespace' : 'Hide whitespace'}
>
{hideWhitespace ? <EyeOffIcon className="w-3.5 h-3.5" /> : <EyeIcon className="w-3.5 h-3.5" />}
<span className="text-text-secondary">Whitespace</span>
</button>
<div className="flex items-center gap-0.5">
<button
className={cn(baseBtn, 'rounded-md', inactiveBtn)}
className={iconBtn}
onClick={onToggleTheme}
title="Toggle theme"
title={theme === 'light' ? 'Switch to dark mode' : 'Switch to light mode'}
>
{theme === 'light' ? 'Dark' : 'Light'}
{theme === 'light' ? <MoonIcon className="w-3.5 h-3.5" /> : <SunIcon className="w-3.5 h-3.5" />}
</button>
<button
className={iconBtn}
onClick={onShowHelp}
title="Keyboard shortcuts (?)"
>
<KeyboardIcon className="w-3.5 h-3.5" />
</button>
</div>
{unresolvedCount > 0 && (
<div className="flex items-center gap-2 ml-auto">
<div className="flex items-center border border-border rounded-md overflow-hidden">
<span className="text-xs text-text-muted px-2.5 py-1">
{currentIndex >= 0 ? `${currentIndex + 1}/${unresolvedCount}` : unresolvedCount} comment{unresolvedCount !== 1 ? 's' : ''}
<span className="text-xs text-text-muted px-2 py-1">
{currentIndex >= 0 ? `${currentIndex + 1}/${unresolvedCount}` : unresolvedCount}
</span>
<button
onClick={goToPrevious}
className="px-1.5 py-1 border-l border-border text-text-secondary hover:bg-hover hover:text-text transition-colors cursor-pointer"
className="px-1 py-1 border-l border-border text-text-muted hover:bg-hover hover:text-text transition-colors cursor-pointer"
title="Previous comment"
>
<ChevronUpIcon className="w-3.5 h-3.5" />
<ChevronUpIcon className="w-3 h-3" />
</button>
<button
onClick={goToNext}
className="px-1.5 py-1 border-l border-border text-text-secondary hover:bg-hover hover:text-text transition-colors cursor-pointer"
className="px-1 py-1 border-l border-border text-text-muted hover:bg-hover hover:text-text transition-colors cursor-pointer"
title="Next comment"
>
<ChevronDownIcon className="w-3.5 h-3.5" />
<ChevronDownIcon className="w-3 h-3" />
</button>
</div>
<div className="flex items-center">
<div className="flex items-center border border-border rounded-md overflow-hidden">
<button
onClick={() => copy(formatThreadsForCopy(threads, diff, diffRef))}
className={cn(baseBtn, 'rounded-l-md flex items-center gap-1.5', inactiveBtn)}
className={cn(baseBtn, 'flex items-center gap-1.5', inactiveBtn)}
title="Copy unresolved comments to clipboard"
>
{copied ? (
<>
<CheckIcon className="w-3.5 h-3.5 text-added" />
<CheckIcon className="w-3 h-3 text-added" />
Copied
</>
) : (
<>
<CopyIcon className="w-3.5 h-3.5" />
Copy comments
<CopyIcon className="w-3 h-3" />
Copy
</>
)}
</button>
<button
onClick={() => setShowDeleteConfirm(true)}
className={cn(baseBtn, 'rounded-r-md border-l-0 flex items-center self-stretch', inactiveBtn)}
className={cn(baseBtn, 'flex items-center border-l border-border', inactiveBtn)}
title="Delete all comments"
>
<TrashIcon className="w-3.5 h-3.5" />
<TrashIcon className="w-3 h-3" />
</button>
</div>
</div>
@@ -24,24 +24,24 @@ export function ConfirmDialog(props: ConfirmDialogProps) {
}, [onCancel]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onCancel}>
<div
className="bg-bg border border-border rounded-lg shadow-lg p-5 max-w-sm w-full mx-4"
className="bg-bg border border-border rounded-xl shadow-lg p-5 max-w-sm w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-sm font-semibold text-text mb-2">{title}</h3>
<p className="text-sm text-text-muted mb-4">{message}</p>
<h3 className="text-sm font-semibold text-text mb-1.5">{title}</h3>
<p className="text-xs text-text-muted mb-4 leading-relaxed">{message}</p>
<div className="flex justify-end gap-2">
<button
ref={cancelRef}
onClick={onCancel}
className="px-3 py-1.5 text-sm rounded border border-border text-text hover:bg-hover cursor-pointer"
className="px-3 py-1.5 text-xs rounded-md border border-border text-text hover:bg-hover cursor-pointer"
>
Cancel
</button>
<button
onClick={onConfirm}
className="px-3 py-1.5 text-sm rounded bg-deleted text-white hover:opacity-90 cursor-pointer"
className="px-3 py-1.5 text-xs rounded-md bg-deleted text-white hover:opacity-90 cursor-pointer"
>
{confirmLabel}
</button>
+24 -2
View File
@@ -1,3 +1,4 @@
import { useEffect, useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
interface KeyboardActions {
@@ -17,7 +18,15 @@ interface KeyboardActions {
const HOTKEY_OPTIONS = { preventDefault: true };
function isInputFocused(): boolean {
const tag = document.activeElement?.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA';
}
export function useKeyboard(actions: KeyboardActions) {
const actionsRef = useRef(actions);
actionsRef.current = actions;
useHotkeys('j', actions.onNextFile, HOTKEY_OPTIONS);
useHotkeys('k', actions.onPrevFile, HOTKEY_OPTIONS);
useHotkeys('n', actions.onNextHunk, HOTKEY_OPTIONS);
@@ -27,7 +36,20 @@ export function useKeyboard(actions: KeyboardActions) {
useHotkeys('r', actions.onToggleReviewed, HOTKEY_OPTIONS);
useHotkeys('u', actions.onUnifiedView, HOTKEY_OPTIONS);
useHotkeys('s', actions.onSplitView, HOTKEY_OPTIONS);
useHotkeys('shift+/', actions.onShowHelp, HOTKEY_OPTIONS);
useHotkeys('/', actions.onFocusSearch, HOTKEY_OPTIONS);
useHotkeys('escape', actions.onEscape, { enableOnFormTags: ['INPUT', 'TEXTAREA'] });
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === '/' && !isInputFocused()) {
e.preventDefault();
actionsRef.current.onFocusSearch();
}
if (e.key === '?' && !isInputFocused()) {
e.preventDefault();
actionsRef.current.onShowHelp();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
}
+37 -32
View File
@@ -40,47 +40,47 @@
--color-hover: rgba(208, 215, 222, 0.32);
--color-active: rgba(208, 215, 222, 0.48);
--shadow-sticky: 0 1px 3px rgba(27, 31, 36, 0.12);
--shadow-md: 0 3px 6px rgba(140, 149, 159, 0.15);
--shadow-sticky: 0 1px 2px rgba(27, 31, 36, 0.08);
--shadow-md: 0 2px 8px rgba(140, 149, 159, 0.12);
}
[data-theme='dark'] {
--color-bg: #0d1117;
--color-bg-secondary: #161b22;
--color-bg-tertiary: #21262d;
--color-border: #30363d;
--color-border-muted: #21262d;
--color-bg: #171717;
--color-bg-secondary: #1a1a1a;
--color-bg-tertiary: #262626;
--color-border: #262626;
--color-border-muted: #1f1f1f;
--color-text: #e6edf3;
--color-text-secondary: #8b949e;
--color-text-muted: #6e7681;
--color-text: #e5e5e5;
--color-text-secondary: #a3a3a3;
--color-text-muted: #737373;
--color-diff-add-bg: rgba(46, 160, 67, 0.15);
--color-diff-add-line: rgba(46, 160, 67, 0.25);
--color-diff-add-word: rgba(46, 160, 67, 0.4);
--color-diff-del-bg: rgba(248, 81, 73, 0.15);
--color-diff-del-line: rgba(248, 81, 73, 0.25);
--color-diff-del-word: rgba(248, 81, 73, 0.4);
--color-diff-hunk-bg: rgba(56, 139, 253, 0.15);
--color-diff-hunk-text: #58a6ff;
--color-diff-expanded-bg: rgba(56, 139, 253, 0.07);
--color-diff-expanded-gutter: rgba(56, 139, 253, 0.10);
--color-diff-comment-bg: rgba(210, 153, 34, 0.15);
--color-diff-comment-gutter: rgba(210, 153, 34, 0.4);
--color-diff-add-bg: rgba(34, 197, 94, 0.1);
--color-diff-add-line: rgba(34, 197, 94, 0.18);
--color-diff-add-word: rgba(34, 197, 94, 0.35);
--color-diff-del-bg: rgba(239, 68, 68, 0.1);
--color-diff-del-line: rgba(239, 68, 68, 0.18);
--color-diff-del-word: rgba(239, 68, 68, 0.35);
--color-diff-hunk-bg: rgba(96, 165, 250, 0.1);
--color-diff-hunk-text: #60a5fa;
--color-diff-expanded-bg: rgba(96, 165, 250, 0.05);
--color-diff-expanded-gutter: rgba(96, 165, 250, 0.08);
--color-diff-comment-bg: rgba(234, 179, 8, 0.1);
--color-diff-comment-gutter: rgba(234, 179, 8, 0.3);
--color-accent: #58a6ff;
--color-accent-hover: #79c0ff;
--color-accent: #60a5fa;
--color-accent-hover: #93c5fd;
--color-added: #3fb950;
--color-deleted: #f85149;
--color-modified: #d29922;
--color-renamed: #58a6ff;
--color-added: #4ade80;
--color-deleted: #f87171;
--color-modified: #facc15;
--color-renamed: #60a5fa;
--color-hover: rgba(177, 186, 196, 0.12);
--color-active: rgba(177, 186, 196, 0.2);
--color-hover: rgba(161, 161, 170, 0.1);
--color-active: rgba(161, 161, 170, 0.18);
--shadow-sticky: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-md: 0 3px 6px rgba(0, 0, 0, 0.3);
--shadow-sticky: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.5);
}
* {
@@ -88,6 +88,11 @@
scrollbar-color: var(--color-border) var(--color-bg-secondary);
}
dialog {
background: var(--color-bg);
color: var(--color-text);
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
+8 -18
View File
@@ -1,36 +1,26 @@
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { readSkills, cleanDir } from './lib/utils.js';
import { claudeCode, cursor, codex } from './lib/transformers/index.js';
import type { Skill, TransformOptions } from './lib/utils.js';
import { readSkills, renderSkill, writeFile, cleanDir } from './lib/utils.js';
import { claudeCode } from './lib/transformers/index.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, '..');
const sourceDir = join(rootDir, 'packages', 'skills');
const outputDir = join(rootDir, 'dist', 'skills');
const outputDir = join(rootDir, 'skills');
const localClaudeSkillsDir = join(rootDir, '.claude', 'skills');
const transformers: { name: string; fn: (skill: Skill, outputDir: string, options: TransformOptions) => void }[] = [
{ name: 'claude-code', fn: claudeCode },
{ name: 'cursor', fn: cursor },
{ name: 'codex', fn: codex },
];
const skills = readSkills(sourceDir);
console.log(`Found ${skills.length} skills`);
cleanDir(outputDir);
for (const transformer of transformers) {
const providerDir = join(outputDir, transformer.name);
for (const skill of skills) {
transformer.fn(skill, providerDir, { binary: 'diffity' });
}
console.log(`Built ${skills.length} skills for ${transformer.name}`);
for (const skill of skills) {
const content = renderSkill(skill, { binary: 'diffity' });
writeFile(join(outputDir, skill.name, 'SKILL.md'), content);
}
console.log(`Built ${skills.length} skills to skills/`);
cleanDir(localClaudeSkillsDir);
for (const skill of skills) {
claudeCode(skill, rootDir, { binary: 'diffity-dev', namePrefix: 'diffity-dev', slashPrefix: '/diffity-dev-' });
claudeCode(skill, rootDir, { binary: 'diffity-dev', namePrefix: 'diffity-dev', slashPrefix: '/diffity-dev-', installHint: 'run `npm run dev` from the diffity repo root to link the CLI' });
}
console.log(`Synced ${skills.length} dev skills to .claude/skills/`);
+5 -2
View File
@@ -18,6 +18,7 @@ export interface TransformOptions {
binary: string;
namePrefix?: string;
slashPrefix?: string;
installHint?: string;
}
export function readSkills(sourceDir: string): Skill[] {
@@ -42,11 +43,13 @@ export function readSkills(sourceDir: string): Skill[] {
return skills;
}
export function renderSkill(skill: Skill, { binary, namePrefix, slashPrefix }: TransformOptions): string {
export function renderSkill(skill: Skill, { binary, namePrefix, slashPrefix, installHint }: TransformOptions): string {
const slash = slashPrefix ?? '/diffity-';
const hint = installHint ?? 'install it with `npm install -g diffity`';
const body = skill.content
.replaceAll('{{binary}}', binary)
.replaceAll('{{slash}}', slash);
.replaceAll('{{slash}}', slash)
.replaceAll('{{install_hint}}', hint);
const data = { ...skill.data };
if (namePrefix) {
data.name = data.name.replace('diffity-', `${namePrefix}-`);
+55
View File
@@ -0,0 +1,55 @@
---
name: diffity-resolve
description: Read open review comments and resolve them by making code fixes
user-invokable: true
---
# Diffity Resolve Skill
You are reading open review comments and resolving them by making the requested code changes.
## Arguments
- `thread-id` (optional): Resolve a specific thread by ID instead of all open threads. Example: `/diffity-resolve abc123`
## CLI Reference
```
diffity agent list [--status open|resolved|dismissed] [--json]
diffity agent comment --file <path> --line <n> [--end-line <n>] [--side new|old] --body "<text>"
diffity agent general-comment --body "<text>"
diffity agent resolve <id> [--summary "<text>"]
diffity agent dismiss <id> [--reason "<text>"]
diffity agent reply <id> --body "<text>"
```
- `--file`, `--line`, `--body` are required for `comment`
- `--end-line` defaults to `--line` (single-line comment)
- `--side` defaults to `new`
- `general-comment` creates a diff-level comment not tied to any file or line
- `<id>` accepts full UUID or 8-char prefix
## Prerequisites
1. Check that `diffity` is available: run `which diffity`. If not found, install it with `npm install -g diffity`.
2. Check that a review session exists: run `cat .diffity/current-session`. If the file doesn't exist or is stale, tell the user to start diffity first.
## Instructions
1. List open comment threads with full details:
```
diffity agent list --status open --json
```
If a `thread-id` argument was provided, filter to just that thread. The JSON output includes the full comment body, file path, line numbers, and side for each thread.
2. If there are no open threads, tell the user there's nothing to resolve.
3. For each open thread:
a. **Skip** general comments (filePath `__general__`) — these are summaries, not actionable code changes.
b. **Skip** threads tagged `[question]` or `[nit]` — these don't require code changes. Tell the user you skipped them and why.
c. Read the comment body from the JSON output and understand what change is requested.
d. Read the relevant source file to understand the full context around the commented lines, then make the requested code change using the Edit tool.
e. After making the change, resolve the thread with a summary:
```
diffity agent resolve <thread-id> --summary "Fixed: <brief description of what was changed>"
```
4. After resolving all applicable threads, run `diffity agent list` to confirm status.
5. Tell the user to check the browser — resolved status will appear within 2 seconds via polling.
+74
View File
@@ -0,0 +1,74 @@
---
name: diffity-review
description: Review current diff and leave comments using diffity agent commands
user-invokable: true
---
# Diffity Review Skill
You are reviewing a diff and leaving inline comments using the `diffity agent` CLI.
## Arguments
- `focus` (optional): Focus the review on a specific area. One of: `security`, `performance`, `naming`, `errors`, `types`, `logic`. If omitted, review everything.
## CLI Reference
```
diffity agent list [--status open|resolved|dismissed] [--json]
diffity agent comment --file <path> --line <n> [--end-line <n>] [--side new|old] --body "<text>"
diffity agent general-comment --body "<text>"
diffity agent resolve <id> [--summary "<text>"]
diffity agent dismiss <id> [--reason "<text>"]
diffity agent reply <id> --body "<text>"
```
- `--file`, `--line`, `--body` are required for `comment`
- `--end-line` defaults to `--line` (single-line comment)
- `--side` defaults to `new`
- `general-comment` creates a diff-level comment not tied to any file or line
- `<id>` accepts full UUID or 8-char prefix
## Prerequisites
1. Check that `diffity` is available: run `which diffity`. If not found, install it with `npm install -g diffity`.
2. Check that a review session exists: run `cat .diffity/current-session`. If the file doesn't exist or is stale, tell the user to start diffity first (e.g. `diffity` or `diffity --staged`).
## Instructions
1. Read the current diff using `git diff` (for working tree changes) or `git diff --staged` (for staged changes). Check `.diffity/current-session` to determine which ref is active.
2. For each changed file, read the **entire file** (not just the diff hunks) to understand the full context. This prevents false positives from missing surrounding code.
3. Analyze the code changes thoroughly. If a `focus` argument was provided, concentrate on that area. Otherwise look for:
- Bugs, logic errors, off-by-one errors
- Security issues (injection, XSS, auth bypass)
- Performance problems
- Missing error handling at system boundaries
- Race conditions
- API contract violations
- Unclear or misleading naming
4. **Only comment on code that was changed in the diff.** Do not flag pre-existing issues in unchanged code — this is a review of the diff, not an audit of the entire file. The only exception is if a change in the diff introduces a bug in combination with existing code.
5. **Prioritize signal over volume.** A clean diff should get a clean review. Do not manufacture findings to appear thorough. If a diff with 5 changed lines only has 1 real issue, leave 1 comment.
6. **Do not repeat the same issue across files.** If the same pattern appears in multiple places, leave one inline comment on the first occurrence and mention it in the general summary instead of commenting on every instance.
7. Categorize each finding with a severity prefix in the comment body:
- `[must-fix]` — Bugs, security issues, data loss risks. These must be addressed.
- `[suggestion]` — Improvements that would meaningfully improve the code.
- `[nit]` — Style or preference. Fine to ignore.
- `[question]` — Something unclear that needs clarification from the author.
8. For each finding, leave a comment using:
```
diffity agent comment --file <path> --line <n> [--end-line <n>] [--side new] --body "<comment>"
```
- Use `--side new` (default) for comments on added/modified code
- Use `--side old` for comments on removed code
- Use `--end-line` when the issue spans multiple lines
- Be specific and actionable in your comments
9. After leaving all inline comments, write a general comment that summarizes your overall assessment of the diff. This should cover:
- Overall quality verdict (e.g. "Looks good with minor issues" or "Needs significant changes before merging")
- Cross-cutting concerns that don't belong on any single line (architecture, naming consistency across files, missing tests, etc.)
- A count of findings by severity (e.g. "2 must-fix, 3 suggestions, 1 nit")
```
diffity agent general-comment --body "<overall review summary>"
```
If there are no inline findings, still leave a general comment with your assessment (e.g. "Clean diff — no issues found").
10. Run `diffity agent list` to confirm all comments were created.
11. Tell the user to check the browser — comments will appear within 2 seconds via polling.
+30
View File
@@ -0,0 +1,30 @@
---
name: diffity-start
description: >-
Start the diffity diff viewer server for the current working tree or staged
changes
user-invokable: true
---
# Diffity Start Skill
You are starting the diffity diff viewer so the user can see their changes in the browser.
## Instructions
1. Check that `diffity` is available: run `which diffity`. If not found, install it with `npm install -g diffity`.
2. Determine which mode to start in:
- If the user said "staged" or there are staged changes they want to review: `diffity --staged`
- Otherwise default to: `diffity`
3. Start the server using the Bash tool with `run_in_background: true`:
- Command: `diffity` (or `diffity --staged`)
- Do NOT use `&` or `--quiet` — let the Bash tool handle backgrounding
- The browser will open automatically and the session is auto-created on startup
4. Wait 2 seconds, then verify the session exists by checking that `.diffity/current-session` file is present.
5. Tell the user diffity is running and show them what they can do next. Keep it short — don't show session IDs, hashes, or other internals. Example:
> Diffity is running — check your browser.
>
> Here's what you can do:
> - **/diffity-review** — get a code review on your changes
> - **/diffity-resolve** — fix issues from review comments