From 611928e1e595f9177d18fd1d83fcc5954c387289 Mon Sep 17 00:00:00 2001 From: "Fredrik Liljegren (Claude Code Claude Opus 5)" Date: Tue, 15 Sep 2026 15:03:31 +0200 Subject: [PATCH 1/3] fix: an outdated comment can be replied to and resolved again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A thread whose lines have left the diff renders in the outdated list, which built its card without reply, resolve or reopen — the moment a conversation about it is most useful is the moment it was stranded. The list now takes the same `commentActions` its in-diff sibling uses, so an outdated thread carries the controls every other thread has. Closes #81 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Bwp5QefjsjMFeq6CK6cT6w --- package-lock.json | 12 +-- packages/api/package.json | 2 +- packages/cli/package.json | 2 +- packages/git/package.json | 2 +- packages/github/package.json | 2 +- packages/parser/package.json | 2 +- packages/ui/package.json | 2 +- .../components/comments/orphaned-threads.tsx | 18 +++-- packages/ui/src/components/diff/diff-view.tsx | 4 +- .../ui/src/components/diff/file-block.tsx | 4 +- .../tests/outdated-thread-controls.test.tsx | 80 +++++++++++++++++++ 11 files changed, 104 insertions(+), 26 deletions(-) create mode 100644 packages/ui/tests/outdated-thread-controls.test.tsx diff --git a/package-lock.json b/package-lock.json index b957c353..d3cfa0bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8457,7 +8457,7 @@ }, "packages/api": { "name": "@diffity/api", - "version": "0.10.36", + "version": "0.10.37", "dependencies": { "@diffity/parser": "*" }, @@ -8468,7 +8468,7 @@ }, "packages/cli": { "name": "@naturalcycles/diffity", - "version": "0.10.36", + "version": "0.10.37", "license": "MIT", "dependencies": { "commander": "^14.0.3", @@ -8492,7 +8492,7 @@ }, "packages/git": { "name": "@diffity/git", - "version": "0.10.36", + "version": "0.10.37", "devDependencies": { "@types/node": "^25.5.0", "typescript": "^5.9.3", @@ -8501,7 +8501,7 @@ }, "packages/github": { "name": "@diffity/github", - "version": "0.10.36", + "version": "0.10.37", "dependencies": { "@diffity/api": "*", "@diffity/parser": "*" @@ -8514,7 +8514,7 @@ }, "packages/parser": { "name": "@diffity/parser", - "version": "0.10.36", + "version": "0.10.37", "devDependencies": { "typescript": "^5.9.3", "vitest": "^4.1.0" @@ -8522,7 +8522,7 @@ }, "packages/ui": { "name": "@diffity/ui", - "version": "0.10.36", + "version": "0.10.37", "dependencies": { "@diffity/api": "*", "@diffity/parser": "*", diff --git a/packages/api/package.json b/packages/api/package.json index 5cf91d2d..3699cd30 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/api", - "version": "0.10.36", + "version": "0.10.37", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index 60743d29..e351a659 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@naturalcycles/diffity", - "version": "0.10.36", + "version": "0.10.37", "description": "Agent-agnostic, GitHub-style diff viewer and code review tool with a live agent loop", "type": "module", "bin": { diff --git a/packages/git/package.json b/packages/git/package.json index 9ae8cea5..9fa81a2f 100644 --- a/packages/git/package.json +++ b/packages/git/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/git", - "version": "0.10.36", + "version": "0.10.37", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/packages/github/package.json b/packages/github/package.json index 1645889b..3bc64045 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/github", - "version": "0.10.36", + "version": "0.10.37", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/packages/parser/package.json b/packages/parser/package.json index 261d7f40..af78d40c 100644 --- a/packages/parser/package.json +++ b/packages/parser/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/parser", - "version": "0.10.36", + "version": "0.10.37", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/packages/ui/package.json b/packages/ui/package.json index 94234805..6fc1ec7b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/ui", - "version": "0.10.36", + "version": "0.10.37", "type": "module", "private": true, "scripts": { diff --git a/packages/ui/src/components/comments/orphaned-threads.tsx b/packages/ui/src/components/comments/orphaned-threads.tsx index ba47f045..cb1e14a4 100644 --- a/packages/ui/src/components/comments/orphaned-threads.tsx +++ b/packages/ui/src/components/comments/orphaned-threads.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import type { CommentThread as CommentThreadType } from './types'; -import { isThreadResolved } from './types'; +import { DEFAULT_AUTHOR, isThreadResolved } from './types'; +import type { CommentActions } from '../../hooks/use-comment-actions'; import { CommentIcon } from '../icons/comment-icon'; import { ChevronIcon } from '../icons/chevron-icon'; import { ThreadBadge } from '../ui/thread-badge'; @@ -8,13 +9,11 @@ import { ThreadCard } from './thread-card'; interface OrphanedThreadsProps { threads: CommentThreadType[]; - onEditComment: (commentId: string, body: string) => void; - onDeleteComment: (threadId: string, commentId: string) => void; - onDeleteThread: (threadId: string) => void; + commentActions: CommentActions; } export function OrphanedThreads(props: OrphanedThreadsProps) { - const { threads, onEditComment, onDeleteComment, onDeleteThread } = props; + const { threads, commentActions } = props; const [isExpanded, setIsExpanded] = useState(() => threads.some(thread => !isThreadResolved(thread))); useEffect(() => { @@ -51,9 +50,12 @@ export function OrphanedThreads(props: OrphanedThreadsProps) { onEditComment(commentId, body)} - onDeleteComment={(commentId) => onDeleteComment(thread.id, commentId)} - onDeleteThread={() => onDeleteThread(thread.id)} + onReply={(body) => commentActions.addReply(thread.id, body, DEFAULT_AUTHOR)} + onResolve={() => commentActions.resolveThread(thread.id)} + onUnresolve={() => commentActions.unresolveThread(thread.id)} + onEditComment={(commentId, body) => commentActions.editComment(commentId, body)} + onDeleteComment={(commentId) => commentActions.deleteComment(thread.id, commentId)} + onDeleteThread={() => commentActions.deleteThread(thread.id)} className="border border-border max-w-[700px]" headerLeft={ <> diff --git a/packages/ui/src/components/diff/diff-view.tsx b/packages/ui/src/components/diff/diff-view.tsx index 6a73aa57..d738ea19 100644 --- a/packages/ui/src/components/diff/diff-view.tsx +++ b/packages/ui/src/components/diff/diff-view.tsx @@ -351,9 +351,7 @@ export function DiffView(props: DiffViewProps) {
)} diff --git a/packages/ui/src/components/diff/file-block.tsx b/packages/ui/src/components/diff/file-block.tsx index a55101d6..f503a623 100644 --- a/packages/ui/src/components/diff/file-block.tsx +++ b/packages/ui/src/components/diff/file-block.tsx @@ -546,9 +546,7 @@ export function FileBlock(props: FileBlockProps) { <>
diff --git a/packages/ui/tests/outdated-thread-controls.test.tsx b/packages/ui/tests/outdated-thread-controls.test.tsx new file mode 100644 index 00000000..6aff6411 --- /dev/null +++ b/packages/ui/tests/outdated-thread-controls.test.tsx @@ -0,0 +1,80 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, cleanup, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { OrphanedThreads } from '../src/components/comments/orphaned-threads'; +import type { CommentActions } from '../src/hooks/use-comment-actions'; +import type { CommentThread } from '../src/components/comments/types'; +import { makeComment, makeThread } from './helpers/wire'; + +function actions(): CommentActions { + return { + addThread: vi.fn(), + addReply: vi.fn(), + resolveThread: vi.fn(), + unresolveThread: vi.fn(), + dismissThread: vi.fn(), + editComment: vi.fn(), + deleteComment: vi.fn(), + deleteThread: vi.fn(), + deleteAllThreads: vi.fn(), + }; +} + +function outdated(over: Partial = {}): CommentThread { + return makeThread({ + id: 'gone', + anchorContent: 'const removed = true;', + comments: [makeComment({ id: 'c1', body: 'P2: this reads oddly' })], + ...over, + }); +} + +function renderThreads(commentActions: CommentActions, thread = outdated()) { + return render(); +} + +afterEach(cleanup); + +describe('an outdated thread', () => { + it('can be replied to', async () => { + const commentActions = actions(); + renderThreads(commentActions); + + await userEvent.click(screen.getByText('Reply')); + await userEvent.type(screen.getByPlaceholderText('Reply...'), 'still applies, see line 40'); + await userEvent.click(screen.getByRole('button', { name: 'Reply' })); + + expect(commentActions.addReply).toHaveBeenCalledWith( + 'gone', + 'still applies, see line 40', + expect.anything(), + ); + }); + + it('can be resolved', async () => { + const commentActions = actions(); + renderThreads(commentActions); + + await userEvent.click(screen.getByText('Resolve')); + + expect(commentActions.resolveThread).toHaveBeenCalledWith('gone'); + }); + + it('offers reopen once resolved, not resolve again', async () => { + const commentActions = actions(); + renderThreads(commentActions, outdated({ status: 'resolved' })); + // Nothing here is open, so the list starts collapsed. + await userEvent.click(screen.getByText('1 outdated comment')); + + expect(screen.queryByText('Resolve')).toBeNull(); + await userEvent.click(screen.getByText('Reopen')); + + expect(commentActions.unresolveThread).toHaveBeenCalledWith('gone'); + }); + + it('keeps the stale anchor on show', () => { + renderThreads(actions()); + + expect(screen.getByText('const removed = true;')).toBeTruthy(); + }); +}); From 7c8ed62f429831c12f1d1098165525c61058c86c Mon Sep 17 00:00:00 2001 From: "Fredrik Liljegren (Claude Code Claude Opus 5)" Date: Tue, 15 Sep 2026 15:11:01 +0200 Subject: [PATCH 2/3] fix: say why an outdated thread gets no Ask, and pin the author in the test Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Bwp5QefjsjMFeq6CK6cT6w --- packages/ui/src/components/comments/orphaned-threads.tsx | 1 + packages/ui/tests/outdated-thread-controls.test.tsx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/comments/orphaned-threads.tsx b/packages/ui/src/components/comments/orphaned-threads.tsx index cb1e14a4..ad0c6d3f 100644 --- a/packages/ui/src/components/comments/orphaned-threads.tsx +++ b/packages/ui/src/components/comments/orphaned-threads.tsx @@ -46,6 +46,7 @@ export function OrphanedThreads(props: OrphanedThreadsProps) { ? `Line ${thread.startLine}` : `Lines ${thread.startLine}–${thread.endLine}`; + // Ask and Act stay off: the agent would be pointed at an anchor the diff no longer has. return ( { expect(commentActions.addReply).toHaveBeenCalledWith( 'gone', 'still applies, see line 40', - expect.anything(), + DEFAULT_AUTHOR, ); }); From 6a171546cb17a747c658226eed0018fbc91f5c9e Mon Sep 17 00:00:00 2001 From: "Fredrik Liljegren (Claude Code Claude Opus 5)" Date: Tue, 15 Sep 2026 15:14:26 +0200 Subject: [PATCH 3/3] fix: the reply test no longer types at human speed userEvent's per-keystroke delay put the reply case over the 5s timeout on the node 22.13 runner. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Bwp5QefjsjMFeq6CK6cT6w --- .../tests/outdated-thread-controls.test.tsx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/ui/tests/outdated-thread-controls.test.tsx b/packages/ui/tests/outdated-thread-controls.test.tsx index 6ddf2c32..64b82ad5 100644 --- a/packages/ui/tests/outdated-thread-controls.test.tsx +++ b/packages/ui/tests/outdated-thread-controls.test.tsx @@ -38,37 +38,37 @@ afterEach(cleanup); describe('an outdated thread', () => { it('can be replied to', async () => { + // Per-keystroke delays are the default and make this the slowest test in the file. + const user = userEvent.setup({ delay: null }); const commentActions = actions(); renderThreads(commentActions); - await userEvent.click(screen.getByText('Reply')); - await userEvent.type(screen.getByPlaceholderText('Reply...'), 'still applies, see line 40'); - await userEvent.click(screen.getByRole('button', { name: 'Reply' })); + await user.click(screen.getByText('Reply')); + await user.type(screen.getByPlaceholderText('Reply...'), 'still applies'); + await user.click(screen.getByRole('button', { name: 'Reply' })); - expect(commentActions.addReply).toHaveBeenCalledWith( - 'gone', - 'still applies, see line 40', - DEFAULT_AUTHOR, - ); + expect(commentActions.addReply).toHaveBeenCalledWith('gone', 'still applies', DEFAULT_AUTHOR); }); it('can be resolved', async () => { + const user = userEvent.setup({ delay: null }); const commentActions = actions(); renderThreads(commentActions); - await userEvent.click(screen.getByText('Resolve')); + await user.click(screen.getByText('Resolve')); expect(commentActions.resolveThread).toHaveBeenCalledWith('gone'); }); it('offers reopen once resolved, not resolve again', async () => { + const user = userEvent.setup({ delay: null }); const commentActions = actions(); renderThreads(commentActions, outdated({ status: 'resolved' })); // Nothing here is open, so the list starts collapsed. - await userEvent.click(screen.getByText('1 outdated comment')); + await user.click(screen.getByText('1 outdated comment')); expect(screen.queryByText('Resolve')).toBeNull(); - await userEvent.click(screen.getByText('Reopen')); + await user.click(screen.getByText('Reopen')); expect(commentActions.unresolveThread).toHaveBeenCalledWith('gone'); });