Skip to content
Open
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
2 changes: 1 addition & 1 deletion dist/server/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export function createApp(manager) {
.listComments()
.filter((c) => c.parentId == null && c.status !== 'dismissed');
const claudeCommentCount = topLevel.filter((c) => c.author === 'claude').length;
const userCommentCount = topLevel.filter((c) => c.author === 'user').length;
const userCommentCount = topLevel.filter((c) => c.author === 'user' && c.status !== 'resolved').length;
return { ...p, repoName, branch, baseBranch, pr, claudeCommentCount, userCommentCount };
}));
res.json({ projects });
Expand Down
2 changes: 1 addition & 1 deletion dist/server/comment-store.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export declare class CommentStore {
private _comments;
add(input: CreateComment): Comment;
get(id: string): Comment | undefined;
update(id: string, fields: Partial<Pick<Comment, 'text' | 'status'>>): Comment | undefined;
update(id: string, fields: Partial<Pick<Comment, 'text' | 'status' | 'resolution'>>): Comment | undefined;
delete(id: string): boolean;
list(filter?: CommentFilter): Comment[];
toJSON(): Comment[];
Expand Down
3 changes: 3 additions & 0 deletions dist/server/comment-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export class CommentStore {
...input,
id: crypto.randomUUID(),
status: 'active',
createdAt: input.createdAt ?? new Date().toISOString(),
};
this._comments.push(comment);
return comment;
Expand All @@ -21,6 +22,8 @@ export class CommentStore {
comment.text = fields.text;
if (fields.status !== undefined)
comment.status = fields.status;
if (fields.resolution !== undefined)
comment.resolution = fields.resolution;
return comment;
}
delete(id) {
Expand Down
4 changes: 4 additions & 0 deletions dist/server/comment-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ export interface Comment {
author: 'user' | 'claude';
text: string;
status: 'active' | 'resolved' | 'dismissed';
/** ISO timestamp. Absent on comments persisted before timestamps existed. */
createdAt?: string;
/** How a resolved comment was addressed. Set by the resolve_comments MCP tool. */
resolution?: string;
parentId?: string;
item: string;
file?: string;
Expand Down
89 changes: 78 additions & 11 deletions dist/server/mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,63 @@ function renderFileAnalysisMarkdown(files) {
return `## ${path}\n- priority: ${f.priority}\n- phase: ${f.phase}\n- category: ${f.category}\n\n${f.summary}\n`;
}).join('\n');
}
// Select the user's top-level review comments for read_feedback. Pending
// (status "active") only by default; includeResolved keeps resolved ones too.
function selectFeedback(session, includeResolved, item) {
const feedback = session
.listComments(item !== undefined ? { item } : undefined)
.filter(c => c.author === 'user' && !c.parentId && c.mode !== 'direct' && c.status !== 'dismissed');
return includeResolved ? feedback : feedback.filter(c => c.status === 'active');
}
// Render review comments as markdown, one heading per file/document, each
// comment tagged with its id so it can be passed to resolve_comments.
function renderCommentGroups(wanted) {
const groups = new Map();
for (const c of wanted) {
const key = c.file ?? c.item;
(groups.get(key) ?? groups.set(key, []).get(key)).push(c);
}
let out = '';
for (const [scope, cs] of groups) {
out += `## ${scope}\n\n`;
cs.sort((a, b) => (a.line ?? a.block ?? 0) - (b.line ?? b.block ?? 0));
for (const c of cs) {
const where = c.line != null ? `Line ${c.line}` : (c.block != null ? `Block ${c.block}` : 'General');
const status = c.status === 'resolved' ? ' [resolved]' : '';
out += `${where} (id: ${c.id})${status}\n`;
out += `> ${c.text}\n`;
if (c.status === 'resolved' && c.resolution)
out += `Resolution: ${c.resolution}\n`;
out += '\n';
}
}
return out;
}
// Split feedback around the current review claim so the reading session can
// tell fresh comments from a backlog left before it claimed the review.
// Comments without createdAt (persisted before timestamps existed) are always
// treated as earlier.
function renderFeedbackMarkdown(session, includeResolved, slug, item) {
const wanted = selectFeedback(session, includeResolved, item);
if (wanted.length === 0) {
return includeResolved ? 'No feedback submitted yet.' : 'No pending feedback.';
}
const claim = getProjectClaim(slug);
if (!claim)
return renderCommentGroups(wanted);
const isFresh = (c) => c.createdAt !== undefined && c.createdAt > claim.claimedAt;
const fresh = wanted.filter(isFresh);
const earlier = wanted.filter(c => !isFresh(c));
if (earlier.length === 0)
return renderCommentGroups(fresh);
const earlierNote = `Submitted before this session claimed the review (${claim.claimedAt}) — ` +
'possibly stale or left for a previous session. Confirm with the user before answering or resolving these.';
if (fresh.length === 0) {
return `# Earlier pending comments (${earlier.length})\n\n${earlierNote}\n\n${renderCommentGroups(earlier)}`;
}
return (`# New since this session claimed the review (${fresh.length})\n\n${renderCommentGroups(fresh)}\n` +
`# Earlier pending comments (${earlier.length})\n\n${earlierNote}\n\n${renderCommentGroups(earlier)}`);
}
function createMcpServer(manager) {
const server = new McpServer({ name: 'lgtm', version: '0.1.0' }, { capabilities: { experimental: { 'claude/channel': {} } } });
server.tool('add_document', 'Add a document (spec, design doc, markdown file) as a reviewable tab alongside the diff. The user can comment on it in the review UI. Auto-registers the project if needed.', {
Expand Down Expand Up @@ -60,19 +117,29 @@ function createMcpServer(manager) {
console.log(`MCP_COMMENT slug=${found.slug} item=${itemId} count=${count}`);
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, count }) }] };
});
server.tool('read_feedback', 'Read the review feedback the user submitted via the review UI. Returns markdown-formatted comments with file paths, line numbers, and the user\'s notes. Call this after the user says they submitted a review.', {
server.tool('read_feedback', 'Read the review feedback the user submitted via the review UI. Returns markdown-formatted comments with file paths, line numbers, the user\'s notes, and each comment\'s id (pass those ids to resolve_comments once addressed). By default returns only pending (unresolved) comments; pass includeResolved=true to also see resolved comments with their resolution notes. Comments submitted before this session claimed the review are listed under a separate "Earlier pending comments" section — treat those as a backlog from previous sessions and confirm with the user before answering or resolving them. Call this after the user says they submitted a review.', {
repoPath: z.string().describe('Absolute path to the git repository'),
}, async ({ repoPath }) => {
includeResolved: z.boolean().optional().describe('Include resolved comments and their resolution notes (default false)'),
item: z.string().optional().describe('Only return feedback on this item/tab: "diff" or a document id from add_document (default: all items)'),
}, async ({ repoPath, includeResolved, item }) => {
const { found } = resolveProject(manager, repoPath, server);
let feedback = '';
try {
feedback = readFileSync(found.session.outputPath, 'utf-8');
}
catch {
// no feedback yet
}
console.log(`MCP_READ_FEEDBACK slug=${found.slug} bytes=${feedback.length}`);
return { content: [{ type: 'text', text: feedback || 'No feedback submitted yet.' }] };
const feedback = renderFeedbackMarkdown(found.session, includeResolved ?? false, found.slug, item);
console.log(`MCP_READ_FEEDBACK slug=${found.slug} includeResolved=${includeResolved ?? false} item=${item ?? '-'} bytes=${feedback.length}`);
return { content: [{ type: 'text', text: feedback }] };
});
server.tool('resolve_comments', 'Mark review comments as resolved after addressing them in code. For each comment id (from read_feedback), record a short note describing how it was addressed. Resolved comments drop out of read_feedback by default and collapse into a "Resolved" section in the review UI.', {
repoPath: z.string().describe('Absolute path to the git repository'),
resolutions: z.array(z.object({
id: z.string().describe('The comment id to resolve (from read_feedback)'),
note: z.string().describe('How the comment was addressed'),
})).describe('Comments to resolve, each with a resolution note'),
}, async ({ repoPath, resolutions }) => {
const { found } = resolveProject(manager, repoPath, server);
const { resolved, notFound } = found.session.resolveComments(resolutions);
if (resolved.length)
found.session.broadcast('comments_changed', { resolved: resolved.length });
console.log(`MCP_RESOLVE_COMMENTS slug=${found.slug} resolved=${resolved.length} notFound=${notFound.length}`);
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, resolved: resolved.length, notFound }) }] };
});
server.tool('stop', 'Stop a review session and close it. The review UI will no longer be accessible for this repo.', {
repoPath: z.string().describe('Absolute path to the git repository'),
Expand Down
10 changes: 9 additions & 1 deletion dist/server/session.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,15 @@ export declare class Session {
}[]): number;
getComment(id: string): Comment | undefined;
listComments(filter?: CommentFilter): Comment[];
updateComment(id: string, fields: Partial<Pick<Comment, 'text' | 'status'>>): Comment | undefined;
updateComment(id: string, fields: Partial<Pick<Comment, 'text' | 'status' | 'resolution'>>): Comment | undefined;
/** Mark comments resolved with a note describing how each was addressed. Persists once. */
resolveComments(resolutions: {
id: string;
note: string;
}[]): {
resolved: string[];
notFound: string[];
};
deleteComment(itemId: string, commentId: string): boolean;
clearComments(itemId?: string): void;
get userReviewedFiles(): string[];
Expand Down
15 changes: 15 additions & 0 deletions dist/server/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,21 @@ export class Session {
this.persist();
return result;
}
/** Mark comments resolved with a note describing how each was addressed. Persists once. */
resolveComments(resolutions) {
const resolved = [];
const notFound = [];
for (const { id, note } of resolutions) {
const updated = this._commentStore.update(id, { status: 'resolved', resolution: note });
if (updated)
resolved.push(id);
else
notFound.push(id);
}
if (resolved.length)
this.persist();
return { resolved, notFound };
}
deleteComment(itemId, commentId) {
const result = this._commentStore.delete(commentId);
if (result)
Expand Down
2 changes: 1 addition & 1 deletion frontend/dist/assets/index.css

Large diffs are not rendered by default.

84 changes: 42 additions & 42 deletions frontend/dist/assets/index.js

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions frontend/src/comment-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ export interface Comment {
author: 'user' | 'claude';
text: string;
status: 'active' | 'resolved' | 'dismissed';
/** How a resolved comment was addressed. Set by the resolve_comments MCP tool. */
resolution?: string;
parentId?: string;
item: string;
file?: string;
Expand Down
16 changes: 15 additions & 1 deletion frontend/src/components/comments/CommentRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,14 @@ export default function CommentRow(props: Props) {
<Show when={props.comment.mode === 'direct'}>
<span class="ask-claude-badge">Asked Claude</span>
</Show>
<Show when={props.comment.author === 'user' && props.comment.mode !== 'direct' && !props.comment.parentId}>
<Show
when={
props.comment.author === 'user' &&
props.comment.mode !== 'direct' &&
!props.comment.parentId &&
!isResolved()
}
>
<span class="pending-badge">Pending</span>
</Show>

Expand Down Expand Up @@ -138,6 +145,13 @@ export default function CommentRow(props: Props) {
<CommentTextarea initialText={props.comment.text} onSave={handleEdit} onCancel={() => setEditing(false)} />
</Show>

<Show when={isResolved() && props.comment.resolution}>
<div class="resolution-note">
<span class="resolution-label">Resolution</span>
<div class="resolution-text" innerHTML={renderMd(props.comment.resolution!)} />
</div>
</Show>

<For each={replies()}>
{(reply) => (
<div class="claude-reply">
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/components/comments/ResolvedSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { createSignal, For, Show } from 'solid-js';
import type { Comment } from '../../comment-types';
import CommentRow from './CommentRow';

// GitHub-style collapsible for resolved review comments. Hidden by default;
// expands to show each resolved comment (with its resolution note, rendered by
// CommentRow).
export default function ResolvedSection(props: { comments: Comment[] }) {
const [open, setOpen] = createSignal(false);

return (
<div class="resolved-section">
<button type="button" class="resolved-toggle" aria-expanded={open()} onClick={() => setOpen(!open())}>
<span class="resolved-caret">{open() ? '▾' : '▸'}</span>
Resolved ({props.comments.length})
</button>
<Show when={open()}>
<div class="resolved-body">
<For each={props.comments}>{(comment) => <CommentRow comment={comment} />}</For>
</div>
</Show>
</div>
);
}
14 changes: 13 additions & 1 deletion frontend/src/components/diff/DiffLine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import type { Comment } from '../../comment-types';
import type { DiffLine as DiffLineType } from '../../state';
import CommentRow from '../comments/CommentRow';
import ResolvedSection from '../comments/ResolvedSection';
import CommentTextarea from '../comments/CommentTextarea';
import PeekPanel from './PeekPanel';

Expand Down Expand Up @@ -78,6 +79,9 @@ export default function DiffLine(props: Props) {
c.status !== 'dismissed',
);

const activeLineComments = () => lineComments().filter((c) => c.status !== 'resolved');
const resolvedLineComments = () => lineComments().filter((c) => c.status === 'resolved');

function getWordAtClick(e: MouseEvent): { word: string; character: number } | null {
const sel =
document.caretPositionFromPoint?.(e.clientX, e.clientY) ??
Expand Down Expand Up @@ -190,7 +194,7 @@ export default function DiffLine(props: Props) {
<PeekPanel />
</Show>

<For each={lineComments()}>
<For each={activeLineComments()}>
{(comment) => (
<tr class={comment.author === 'claude' ? 'claude-comment-row' : 'comment-row'}>
<td colspan="3">
Expand All @@ -200,6 +204,14 @@ export default function DiffLine(props: Props) {
)}
</For>

<Show when={resolvedLineComments().length > 0}>
<tr class="comment-row">
<td colspan="3">
<ResolvedSection comments={resolvedLineComments()} />
</td>
</tr>
</Show>

<Show when={showNewComment()}>
{(() => {
// Click-outside to dismiss empty comment
Expand Down
17 changes: 15 additions & 2 deletions frontend/src/components/document/DocumentView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { comments, activeItemId, mdMeta, addLocalComment } from '../../state';
import { renderMd } from '../../utils';
import { createComment as apiCreateComment } from '../../comment-api';
import CommentRow from '../comments/CommentRow';
import ResolvedSection from '../comments/ResolvedSection';
import CommentTextarea from '../comments/CommentTextarea';

export default function DocumentView() {
Expand Down Expand Up @@ -37,7 +38,10 @@ export default function DocumentView() {
});

const totalComments = createMemo(
() => comments.list.filter((c) => c.item === activeItemId() && !c.parentId && c.status !== 'dismissed').length,
() =>
comments.list.filter(
(c) => c.item === activeItemId() && !c.parentId && c.status !== 'dismissed' && c.status !== 'resolved',
).length,
);

return (
Expand All @@ -62,6 +66,8 @@ function DocumentBlock(props: { html: string; blockIdx: number }) {
(c) => c.item === activeItemId() && c.block === props.blockIdx && !c.parentId && c.status !== 'dismissed',
),
);
const activeBlockComments = createMemo(() => blockComments().filter((c) => c.status !== 'resolved'));
const resolvedBlockComments = createMemo(() => blockComments().filter((c) => c.status === 'resolved'));

function handleBlockClick(e: MouseEvent) {
if ((e.target as HTMLElement).closest('.comment-box') || (e.target as HTMLElement).closest('.reply-textarea-wrap'))
Expand Down Expand Up @@ -96,7 +102,7 @@ function DocumentBlock(props: { html: string; blockIdx: number }) {
onClick={handleBlockClick}
innerHTML={props.html}
/>
<For each={blockComments()}>
<For each={activeBlockComments()}>
{(comment) => (
<div class="md-comment" style="margin:4px 0">
<div class="comment-box" style="max-width:100%">
Expand All @@ -105,6 +111,13 @@ function DocumentBlock(props: { html: string; blockIdx: number }) {
</div>
)}
</For>
<Show when={resolvedBlockComments().length > 0}>
<div class="md-comment" style="margin:4px 0">
<div class="comment-box" style="max-width:100%">
<ResolvedSection comments={resolvedBlockComments()} />
</div>
</div>
</Show>
<Show when={showNewComment()}>
<div class="md-comment">
<CommentTextarea onSave={handleSave} onCancel={() => setShowNewComment(false)} />
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,10 @@ export const commentsByFile = createMemo(() => {
});

export const userCommentCount = createMemo(
() => comments.list.filter((c) => c?.author === 'user' && !c.parentId && c.status !== 'dismissed').length,
() =>
comments.list.filter(
(c) => c?.author === 'user' && !c.parentId && c.status !== 'dismissed' && c.status !== 'resolved',
).length,
);

/** Resolves a file path to its current tree row id, then sets active row. */
Expand Down
Loading
Loading