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..ad0c6d3f 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(() => { @@ -47,13 +46,17 @@ 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 ( 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..64b82ad5 --- /dev/null +++ b/packages/ui/tests/outdated-thread-controls.test.tsx @@ -0,0 +1,81 @@ +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 { DEFAULT_AUTHOR } from '../src/components/comments/types'; +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 () => { + // 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 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', DEFAULT_AUTHOR); + }); + + it('can be resolved', async () => { + const user = userEvent.setup({ delay: null }); + const commentActions = actions(); + renderThreads(commentActions); + + 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 user.click(screen.getByText('1 outdated comment')); + + expect(screen.queryByText('Resolve')).toBeNull(); + await user.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(); + }); +});