diff --git a/README.md b/README.md index df250fd..fb74ad9 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Common variants: curl -fsSL https://github.com/bmarimuthu-nv/Maglev/releases/latest/download/install.sh | MAGLEV_INSTALL_DIR="$HOME/bin" sh # Install a specific release tag -curl -fsSL https://github.com/bmarimuthu-nv/Maglev/releases/latest/download/install.sh | MAGLEV_VERSION="v0.16.2" sh +curl -fsSL https://github.com/bmarimuthu-nv/Maglev/releases/latest/download/install.sh | MAGLEV_VERSION="v0.16.3" sh ``` If `maglev` is not found after install: diff --git a/bun.lock b/bun.lock index edf85d1..a1c4c3e 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,7 @@ }, "cli": { "name": "maglev", - "version": "0.16.2", + "version": "0.16.3", "bin": { "maglev": "bin/maglev.cjs", }, @@ -43,11 +43,11 @@ "vitest": "^4.0.16", }, "optionalDependencies": { - "maglev-darwin-arm64": "0.16.2", - "maglev-darwin-x64": "0.16.2", - "maglev-linux-arm64": "0.16.2", - "maglev-linux-x64": "0.16.2", - "maglev-win32-x64": "0.16.2", + "maglev-darwin-arm64": "0.16.3", + "maglev-darwin-x64": "0.16.3", + "maglev-linux-arm64": "0.16.3", + "maglev-linux-x64": "0.16.3", + "maglev-win32-x64": "0.16.3", }, }, "docs": { diff --git a/cli/package.json b/cli/package.json index 1422278..fa4cb27 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "maglev", - "version": "0.16.2", + "version": "0.16.3", "description": "App for agentic coding - access coding agent anywhere", "author": "Kirill Dubovitskiy & weishu", "license": "AGPL-3.0-only", @@ -18,11 +18,11 @@ "NOTICE" ], "optionalDependencies": { - "maglev-darwin-arm64": "0.16.2", - "maglev-darwin-x64": "0.16.2", - "maglev-linux-arm64": "0.16.2", - "maglev-linux-x64": "0.16.2", - "maglev-win32-x64": "0.16.2" + "maglev-darwin-arm64": "0.16.3", + "maglev-darwin-x64": "0.16.3", + "maglev-linux-arm64": "0.16.3", + "maglev-linux-x64": "0.16.3", + "maglev-win32-x64": "0.16.3" }, "scripts": { "postinstall": "node -e \"try{require('fs').chmodSync(require('path').join(__dirname,'bin','maglev.cjs'),0o755)}catch(e){}\"", diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 688888b..c9d8d22 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -62,7 +62,7 @@ curl -fsSL https://github.com/bmarimuthu-nv/Maglev/releases/latest/download/inst To install a specific release tag: ```bash -curl -fsSL https://github.com/bmarimuthu-nv/Maglev/releases/latest/download/install.sh | MAGLEV_VERSION="v0.16.2" sh +curl -fsSL https://github.com/bmarimuthu-nv/Maglev/releases/latest/download/install.sh | MAGLEV_VERSION="v0.16.3" sh ``` If `maglev` is not found after install: diff --git a/scripts/migrate-file-threads-to-review-json.mjs b/scripts/migrate-file-threads-to-review-json.mjs new file mode 100755 index 0000000..38c5e98 --- /dev/null +++ b/scripts/migrate-file-threads-to-review-json.mjs @@ -0,0 +1,322 @@ +#!/usr/bin/env node + +import { copyFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' + +const LEGACY_FILE_NAME = 'file-threads.json' +const LEGACY_GIT_FOLDER = 'maglev-review' +const LEGACY_WORKSPACE_FOLDER = '.maglev-review' +const REVIEW_FILE_PATH = join('.maglev-review', 'review.json') +const VALID_REVIEW_MODES = new Set(['branch', 'working']) + +function usage() { + console.log(`Usage: + node scripts/migrate-file-threads-to-review-json.mjs [workspace] [options] + +Migrates old open-file review comments from file-threads.json into +.maglev-review/review.json. Existing review.json threads are preserved. + +Options: + --legacy Read a specific file-threads.json + --review Write a specific review.json path + --mode + Diff mode to assign to migrated threads. Default: branch + --dry-run Print what would happen without writing + --no-backup Do not back up an existing review.json before writing + -h, --help Show this help +`) +} + +function fail(message) { + console.error(`Error: ${message}`) + process.exit(1) +} + +function parseArgs(argv) { + const options = { + workspacePath: null, + legacyPath: null, + reviewPath: null, + mode: 'branch', + dryRun: false, + backup: true + } + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + if (arg === '-h' || arg === '--help') { + usage() + process.exit(0) + } + if (arg === '--dry-run') { + options.dryRun = true + continue + } + if (arg === '--no-backup') { + options.backup = false + continue + } + if (arg === '--legacy' || arg === '--review' || arg === '--mode') { + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + fail(`${arg} requires a value`) + } + index += 1 + if (arg === '--legacy') options.legacyPath = resolve(value) + if (arg === '--review') options.reviewPath = resolve(value) + if (arg === '--mode') options.mode = value + continue + } + if (arg.startsWith('--')) { + fail(`Unknown option: ${arg}`) + } + if (options.workspacePath) { + fail(`Unexpected extra positional argument: ${arg}`) + } + options.workspacePath = resolve(arg) + } + + options.workspacePath = options.workspacePath ?? process.cwd() + options.reviewPath = options.reviewPath ?? join(options.workspacePath, REVIEW_FILE_PATH) + + if (!VALID_REVIEW_MODES.has(options.mode)) { + fail(`--mode must be one of: ${Array.from(VALID_REVIEW_MODES).join(', ')}`) + } + + return options +} + +async function pathExists(path) { + try { + await stat(path) + return true + } catch (error) { + if (error?.code === 'ENOENT') { + return false + } + throw error + } +} + +async function readIfExists(path) { + try { + return await readFile(path, 'utf8') + } catch (error) { + if (error?.code === 'ENOENT') { + return null + } + throw error + } +} + +async function findGitLegacyPath(workspacePath) { + let current = resolve(workspacePath) + + while (true) { + const gitMarkerPath = join(current, '.git') + try { + const gitMarkerStats = await stat(gitMarkerPath) + if (gitMarkerStats.isDirectory()) { + return join(gitMarkerPath, LEGACY_GIT_FOLDER, LEGACY_FILE_NAME) + } + if (gitMarkerStats.isFile()) { + const gitPointer = await readFile(gitMarkerPath, 'utf8') + const match = gitPointer.match(/^gitdir:\s*(.+)\s*$/m) + if (match?.[1]) { + return join(resolve(current, match[1].trim()), LEGACY_GIT_FOLDER, LEGACY_FILE_NAME) + } + } + } catch (error) { + if (error?.code !== 'ENOENT') { + throw error + } + } + + const parent = dirname(current) + if (parent === current) { + return null + } + current = parent + } +} + +async function resolveLegacyPath(workspacePath, explicitLegacyPath) { + if (explicitLegacyPath) { + return { legacyPath: explicitLegacyPath, checkedPaths: [explicitLegacyPath] } + } + + const gitPath = await findGitLegacyPath(workspacePath) + const workspacePathFallback = join(workspacePath, LEGACY_WORKSPACE_FOLDER, LEGACY_FILE_NAME) + const checkedPaths = [...new Set([gitPath, workspacePathFallback].filter(Boolean))] + + for (const candidate of checkedPaths) { + if (await pathExists(candidate)) { + return { legacyPath: candidate, checkedPaths } + } + } + + return { legacyPath: checkedPaths[0] ?? workspacePathFallback, checkedPaths } +} + +function parseJson(raw, path) { + try { + return JSON.parse(raw) + } catch { + fail(`Invalid JSON in ${path}`) + } +} + +function isObject(value) { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function validateLegacyStore(value, path) { + if (!isObject(value) || value.version !== 1 || !Array.isArray(value.threads)) { + fail(`Invalid legacy file review store format: ${path}`) + } + + for (const [index, thread] of value.threads.entries()) { + if (!isObject(thread) + || typeof thread.id !== 'string' + || typeof thread.filePath !== 'string' + || (thread.status !== 'open' && thread.status !== 'resolved') + || !isObject(thread.anchor) + || typeof thread.anchor.line !== 'number' + || typeof thread.anchor.preview !== 'string' + || !Array.isArray(thread.comments)) { + fail(`Invalid legacy thread at index ${index}: ${path}`) + } + + for (const [commentIndex, comment] of thread.comments.entries()) { + if (!isObject(comment) + || typeof comment.id !== 'string' + || typeof comment.author !== 'string' + || typeof comment.createdAt !== 'number' + || typeof comment.body !== 'string') { + fail(`Invalid legacy comment at thread ${index}, comment ${commentIndex}: ${path}`) + } + } + } + + return value +} + +function createEmptyReviewFile(workspacePath) { + return { + version: 1, + workspacePath, + currentBranch: null, + defaultBranch: null, + mergeBase: null, + reviewContext: null, + updatedAt: Date.now(), + threads: [] + } +} + +function validateReviewFile(value, path, workspacePath) { + if (!isObject(value) || value.version !== 1 || !Array.isArray(value.threads)) { + fail(`Invalid review file format: ${path}`) + } + + return { + ...value, + workspacePath: typeof value.workspacePath === 'string' ? value.workspacePath : workspacePath, + currentBranch: value.currentBranch ?? null, + defaultBranch: value.defaultBranch ?? null, + mergeBase: value.mergeBase ?? null, + reviewContext: value.reviewContext ?? null + } +} + +async function loadReviewFile(reviewPath, workspacePath) { + const raw = await readIfExists(reviewPath) + if (!raw || !raw.trim()) { + return createEmptyReviewFile(workspacePath) + } + return validateReviewFile(parseJson(raw, reviewPath), reviewPath, workspacePath) +} + +function migrateThread(thread, mode) { + return { + id: thread.id, + diffMode: mode, + filePath: thread.filePath, + anchor: { + side: 'right', + line: thread.anchor.line, + preview: thread.anchor.preview + }, + status: thread.status, + comments: thread.comments.map((comment) => ({ + id: comment.id, + author: comment.author, + createdAt: comment.createdAt, + body: comment.body + })) + } +} + +function timestampForFile() { + return new Date().toISOString().replace(/[:.]/g, '-') +} + +async function main() { + const options = parseArgs(process.argv.slice(2)) + const { legacyPath, checkedPaths } = await resolveLegacyPath(options.workspacePath, options.legacyPath) + + if (!await pathExists(legacyPath)) { + fail(`Legacy ${LEGACY_FILE_NAME} not found. Checked:\n ${checkedPaths.join('\n ')}`) + } + + const legacyRaw = await readFile(legacyPath, 'utf8') + const legacyStore = validateLegacyStore(parseJson(legacyRaw, legacyPath), legacyPath) + const reviewFile = await loadReviewFile(options.reviewPath, options.workspacePath) + const existingThreadIds = new Set(reviewFile.threads.map((thread) => thread.id)) + const migratedThreads = [] + let skippedDuplicateIds = 0 + + for (const thread of legacyStore.threads) { + if (existingThreadIds.has(thread.id)) { + skippedDuplicateIds += 1 + continue + } + migratedThreads.push(migrateThread(thread, options.mode)) + } + + const nextReviewFile = { + ...reviewFile, + workspacePath: reviewFile.workspacePath || options.workspacePath, + updatedAt: Date.now(), + threads: [...reviewFile.threads, ...migratedThreads] + } + + console.log(`Legacy store: ${legacyPath}`) + console.log(`Review file: ${options.reviewPath}`) + console.log(`Diff mode: ${options.mode}`) + console.log(`Existing review threads: ${reviewFile.threads.length}`) + console.log(`Legacy threads: ${legacyStore.threads.length}`) + console.log(`Threads to migrate: ${migratedThreads.length}`) + console.log(`Skipped duplicate IDs: ${skippedDuplicateIds}`) + + if (options.dryRun) { + console.log('Dry run only. No files written.') + return + } + + await mkdir(dirname(options.reviewPath), { recursive: true }) + + if (options.backup && await pathExists(options.reviewPath)) { + const backupPath = `${options.reviewPath}.pre-file-threads-migration-${timestampForFile()}` + await copyFile(options.reviewPath, backupPath) + console.log(`Backup written: ${backupPath}`) + } + + await writeFile(options.reviewPath, `${JSON.stringify(nextReviewFile, null, 2)}\n`, 'utf8') + console.log(`Migration complete. Wrote ${options.reviewPath}`) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/web/src/components/FilePreviewPanel.test.tsx b/web/src/components/FilePreviewPanel.test.tsx index d6494c0..64931b2 100644 --- a/web/src/components/FilePreviewPanel.test.tsx +++ b/web/src/components/FilePreviewPanel.test.tsx @@ -2,8 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react' import { AppContextProvider } from '@/lib/app-context' -import type { FileReviewThread } from '@/types/api' -import { encodeBase64 } from '@/lib/utils' +import { decodeBase64, encodeBase64 } from '@/lib/utils' import { FilePreviewPanel } from './FilePreviewPanel' vi.mock('@/lib/shiki', () => ({ @@ -17,36 +16,6 @@ vi.mock('@/components/MarkdownRenderer', () => ({ ) })) -function makeThread(overrides: Partial & { id: string }): FileReviewThread { - const now = Date.now() - const { id, ...rest } = overrides - return { - id, - filePath: '/repo/src/example.ts', - absolutePath: '/repo/src/example.ts', - createdAt: now, - updatedAt: now, - status: 'open', - anchor: { - line: overrides.resolvedLine ?? 1, - preview: 'const value = 1', - contextBefore: [], - contextAfter: [] - }, - comments: [ - { - id: `${overrides.id}-comment-1`, - author: 'user', - createdAt: now, - body: 'Review note' - } - ], - resolvedLine: 1, - orphaned: false, - ...rest - } -} - function createApi(overrides?: Partial<{ readSessionFile: ReturnType getSessionFileReviewThreads: ReturnType @@ -96,6 +65,7 @@ function renderPreview( sessionId="session-1" filePath={filePath} api={api as never} + workspacePath="/repo" onClose={vi.fn()} presentation={presentation} /> @@ -133,27 +103,50 @@ describe('FilePreviewPanel', () => { }) it('loads a code file and switches into review mode with inline thread content', async () => { - const thread = makeThread({ - id: 'thread-1', - resolvedLine: 2, - comments: [ - { - id: 'thread-1-root', - author: 'user', - createdAt: Date.now(), - body: 'Need a null guard here.' - } - ] - }) + const now = Date.now() const api = createApi({ - readSessionFile: vi.fn().mockResolvedValue({ - success: true, - content: encodeBase64(['const a = 1', 'const b = a + 1'].join('\n')), - hash: 'file-hash' + readSessionFile: vi.fn().mockImplementation(async (_sessionId: string, path: string) => { + if (path === '.maglev-review/review.json') { + return { + success: true, + content: encodeBase64(JSON.stringify({ + version: 1, + workspacePath: '/repo', + currentBranch: null, + defaultBranch: null, + mergeBase: null, + reviewContext: null, + updatedAt: now, + threads: [{ + id: 'thread-1', + diffMode: 'branch', + filePath: '/repo/src/example.ts', + anchor: { + side: 'right', + line: 2, + preview: 'const b = a + 1' + }, + status: 'open', + comments: [{ + id: 'thread-1-root', + author: 'user', + createdAt: now, + body: 'Need a null guard here.' + }] + }] + })), + hash: 'review-hash' + } + } + return { + success: true, + content: encodeBase64(['const a = 1', 'const b = a + 1'].join('\n')), + hash: 'file-hash' + } }), getSessionFileReviewThreads: vi.fn().mockResolvedValue({ success: true, - threads: [thread] + threads: [] }) }) @@ -166,7 +159,7 @@ describe('FilePreviewPanel', () => { fireEvent.click(screen.getByRole('button', { name: 'Review' })) expect(await screen.findByText('Review annotations')).toBeInTheDocument() - expect(screen.getByText('1 total threads')).toBeInTheDocument() + expect(await screen.findByText('1 total threads')).toBeInTheDocument() expect(screen.getAllByText('1 unresolved').length).toBeGreaterThanOrEqual(1) expect(screen.getByText('Need a null guard here.')).toBeInTheDocument() expect(screen.getByPlaceholderText('Search in file')).toBeInTheDocument() @@ -195,30 +188,116 @@ describe('FilePreviewPanel', () => { }) }) - it('uses rendered markdown in code mode and falls back to the code canvas in review mode', async () => { - const thread = makeThread({ - id: 'thread-md', - filePath: '/repo/README.md', - absolutePath: '/repo/README.md', - resolvedLine: 1, - comments: [ - { - id: 'thread-md-root', - author: 'agent', - createdAt: Date.now(), - body: 'Consider tightening this heading.' + it('creates open-file review comments in the shared review JSON file', async () => { + const writeSessionFile = vi.fn().mockResolvedValue({ + success: true, + hash: 'review-hash' + }) + const api = createApi({ + readSessionFile: vi.fn().mockImplementation(async (_sessionId: string, path: string) => { + if (path === '.maglev-review/review.json') { + return { + success: false, + error: 'ENOENT: no such file or directory' + } + } + return { + success: true, + content: encodeBase64(['const a = 1', 'const b = a + 1'].join('\n')), + hash: 'file-hash' } - ] + }), + writeSessionFile + }) + + renderPreview(api, '/repo/src/example.ts') + + expect(await screen.findByText('/repo/src/example.ts')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Review' })) + expect(await screen.findByText('Review annotations')).toBeInTheDocument() + + const addCommentButton = await screen.findByTitle('Add comment on line 2') + await waitFor(() => { + expect(addCommentButton).not.toBeDisabled() + }) + fireEvent.click(addCommentButton) + fireEvent.change(screen.getByPlaceholderText('Add comment for line 2'), { + target: { value: 'Use the shared review file.' } + }) + fireEvent.click(screen.getByRole('button', { name: 'Save comment' })) + + await waitFor(() => { + expect(writeSessionFile).toHaveBeenCalled() + }) + const [, reviewPath, encodedContent, expectedHash] = writeSessionFile.mock.calls[0] + const decoded = decodeBase64(encodedContent) + expect(reviewPath).toBe('.maglev-review/review.json') + expect(expectedHash).toBeNull() + expect(decoded.ok).toBe(true) + const payload = JSON.parse(decoded.text) + expect(payload.workspacePath).toBe('/repo') + expect(payload.threads).toHaveLength(1) + expect(payload.threads[0]).toMatchObject({ + diffMode: 'branch', + filePath: '/repo/src/example.ts', + anchor: { + side: 'right', + line: 2, + preview: 'const b = a + 1' + }, + status: 'open' + }) + expect(payload.threads[0].comments[0]).toMatchObject({ + author: 'user', + body: 'Use the shared review file.' }) + }) + + it('uses rendered markdown in code mode and falls back to the code canvas in review mode', async () => { + const now = Date.now() const api = createApi({ - readSessionFile: vi.fn().mockResolvedValue({ - success: true, - content: encodeBase64('# Hello Maglev\n\nPreview text'), - hash: 'md-hash' + readSessionFile: vi.fn().mockImplementation(async (_sessionId: string, path: string) => { + if (path === '.maglev-review/review.json') { + return { + success: true, + content: encodeBase64(JSON.stringify({ + version: 1, + workspacePath: '/repo', + currentBranch: null, + defaultBranch: null, + mergeBase: null, + reviewContext: null, + updatedAt: now, + threads: [{ + id: 'thread-md', + diffMode: 'branch', + filePath: '/repo/README.md', + anchor: { + side: 'right', + line: 1, + preview: '# Hello Maglev' + }, + status: 'open', + comments: [{ + id: 'thread-md-root', + author: 'agent', + createdAt: now, + body: 'Consider tightening this heading.' + }] + }] + })), + hash: 'review-hash' + } + } + return { + success: true, + content: encodeBase64('# Hello Maglev\n\nPreview text'), + hash: 'md-hash' + } }), getSessionFileReviewThreads: vi.fn().mockResolvedValue({ success: true, - threads: [thread] + threads: [] }) }) @@ -232,7 +311,7 @@ describe('FilePreviewPanel', () => { expect(await screen.findByText('Review annotations')).toBeInTheDocument() expect(screen.getByPlaceholderText('Search in file')).toBeInTheDocument() - expect(screen.getByText('Consider tightening this heading.')).toBeInTheDocument() + expect(await screen.findByText('Consider tightening this heading.')).toBeInTheDocument() await waitFor(() => { expect(screen.getAllByTestId('markdown-renderer').map((element) => element.textContent)).toEqual([ diff --git a/web/src/components/FilePreviewPanel.tsx b/web/src/components/FilePreviewPanel.tsx index e944f96..5224710 100644 --- a/web/src/components/FilePreviewPanel.tsx +++ b/web/src/components/FilePreviewPanel.tsx @@ -12,7 +12,7 @@ import { MarkdownRenderer } from '@/components/MarkdownRenderer' import { SourceReviewFileCard } from '@/components/review/SourceReviewFileCard' import { CheckIcon, CopyIcon } from '@/components/icons' import { useCopyToClipboard } from '@/hooks/useCopyToClipboard' -import { REVIEW_FILE_PATH } from '@/lib/review-file' +import { createEmptyReviewFile, parseReviewFile, REVIEW_FILE_PATH, type ReviewFile, type ReviewThread } from '@/lib/review-file' function CloseIcon() { return ( @@ -142,15 +142,88 @@ function getConflictMessage(conflict: WriteFileConflict): string { } } +function isMissingReviewFileError(message: string): boolean { + const normalized = message.toLowerCase() + return normalized.includes('enoent') + || normalized.includes('enotdir') + || normalized.includes('no such file') + || normalized.includes('not a directory') +} + +function makeId(prefix: string): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return `${prefix}-${crypto.randomUUID()}` + } + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` +} + +function getThreadTimestamp(thread: ReviewThread, fallback: number): number { + return thread.comments[0]?.createdAt ?? fallback +} + +function getThreadUpdatedAt(thread: ReviewThread, fallback: number): number { + return thread.comments.at(-1)?.createdAt ?? fallback +} + +function resolveReviewThreadLine(thread: ReviewThread, sourceLines: string[]): { resolvedLine: number | null; orphaned: boolean } { + if (thread.anchor.side !== 'right') { + return { resolvedLine: null, orphaned: true } + } + + const originalLine = thread.anchor.line + const preview = thread.anchor.preview + if (originalLine >= 1 && originalLine <= sourceLines.length) { + if (!preview || sourceLines[originalLine - 1] === preview) { + return { resolvedLine: originalLine, orphaned: false } + } + } + + if (preview) { + const relocatedIndex = sourceLines.findIndex((line) => line === preview) + if (relocatedIndex >= 0) { + return { resolvedLine: relocatedIndex + 1, orphaned: false } + } + } + + return { resolvedLine: null, orphaned: true } +} + +function toFileReviewThread(thread: ReviewThread, sourceLines: string[]): FileReviewThread { + const now = Date.now() + const resolved = resolveReviewThreadLine(thread, sourceLines) + return { + id: thread.id, + filePath: thread.filePath, + absolutePath: thread.filePath, + createdAt: getThreadTimestamp(thread, now), + updatedAt: getThreadUpdatedAt(thread, now), + status: thread.status, + anchor: { + line: thread.anchor.line, + preview: thread.anchor.preview ?? sourceLines[Math.max(0, thread.anchor.line - 1)] ?? '', + contextBefore: [], + contextAfter: [] + }, + comments: thread.comments, + resolvedLine: resolved.resolvedLine, + orphaned: resolved.orphaned + } +} + +type ReviewFileLoadResult = + | { success: true; reviewFile: ReviewFile; hash: string | null } + | { success: false; error: string } + export function FilePreviewPanel(props: { sessionId: string filePath: string api: ApiClient | null onClose: () => void presentation?: 'sidebar' | 'overlay' + workspacePath?: string | null }) { const { scopeKey } = useAppContext() - const { sessionId, filePath, api, onClose, presentation = 'sidebar' } = props + const { sessionId, filePath, api, onClose, presentation = 'sidebar', workspacePath = null } = props const queryClient = useQueryClient() const { copied: reviewPathCopied, copy: copyReviewPath } = useCopyToClipboard() const isOverlay = presentation === 'overlay' @@ -165,16 +238,6 @@ export function FilePreviewPanel(props: { retry: false, }) - const reviewThreadsQuery = useQuery({ - queryKey: queryKeys.sessionFileReviewThreads(scopeKey, sessionId, filePath), - queryFn: async () => { - if (!api) throw new Error('API unavailable') - return await api.getSessionFileReviewThreads(sessionId, filePath) - }, - enabled: Boolean(api && filePath), - retry: false - }) - const decoded = fileQuery.data?.success && fileQuery.data.content ? decodeBase64(fileQuery.data.content) : { text: '', ok: true } @@ -210,6 +273,43 @@ export function FilePreviewPanel(props: { const restoredDraftKeyRef = useRef(null) const isEditing = panelMode === 'edit' + const reviewFileQuery = useQuery({ + queryKey: queryKeys.sessionFile(scopeKey, sessionId, REVIEW_FILE_PATH), + queryFn: async () => { + if (!api) throw new Error('API unavailable') + const result = await api.readSessionFile(sessionId, REVIEW_FILE_PATH) + if (!result.success) { + const errorMessage = result.error ?? 'Failed to load review file' + if (isMissingReviewFileError(errorMessage)) { + return { + success: true, + reviewFile: createEmptyReviewFile(workspacePath ?? ''), + hash: null + } + } + return { success: false, error: errorMessage } + } + + const decodedReview = result.content ? decodeBase64(result.content) : { ok: true, text: '' } + if (!decodedReview.ok) { + return { success: false, error: 'Failed to decode review file' } + } + + const parsed = parseReviewFile(decodedReview.text, workspacePath ?? '') + if (!parsed.ok) { + return { success: false, error: parsed.error } + } + + return { + success: true, + reviewFile: parsed.value, + hash: result.hash ?? null + } + }, + enabled: Boolean(api && filePath && panelMode === 'review' && !binary), + retry: false + }) + useEffect(() => { setViewMode('rendered') setPanelMode('read') @@ -311,13 +411,13 @@ export function FilePreviewPanel(props: { setPanelMode('read') setDraft('') await fileQuery.refetch() - await reviewThreadsQuery.refetch() + await reviewFileQuery.refetch() } catch (error) { setSaveError(error instanceof Error ? error.message : 'Failed to save') } finally { setIsSaving(false) } - }, [api, draft, draftStorageKey, fileHash, filePath, fileQuery, isSaving, reviewThreadsQuery, sessionId]) + }, [api, draft, draftStorageKey, fileHash, filePath, fileQuery, isSaving, reviewFileQuery, sessionId]) const discardDraft = useCallback(async () => { clearDraftSnapshot(draftStorageKey) @@ -327,29 +427,54 @@ export function FilePreviewPanel(props: { setDraftRecovered(false) setPanelMode('read') await fileQuery.refetch() - await reviewThreadsQuery.refetch() - }, [draftStorageKey, fileQuery, reviewThreadsQuery]) + await reviewFileQuery.refetch() + }, [draftStorageKey, fileQuery, reviewFileQuery]) const invalidateReviewThreads = useCallback(async () => { - await queryClient.invalidateQueries({ queryKey: queryKeys.sessionFileReviewThreads(scopeKey, sessionId, filePath) }) - await reviewThreadsQuery.refetch() - }, [filePath, queryClient, reviewThreadsQuery, scopeKey, sessionId]) + await queryClient.invalidateQueries({ queryKey: queryKeys.sessionFile(scopeKey, sessionId, REVIEW_FILE_PATH) }) + await reviewFileQuery.refetch() + }, [queryClient, reviewFileQuery, scopeKey, sessionId]) - const runReviewMutation = useCallback(async (mutate: () => Promise<{ success: boolean; error?: string }>) => { + const runReviewMutation = useCallback(async (mutate: (current: ReviewFile) => ReviewFile) => { setReviewSaving(true) setReviewError(null) try { - const result = await mutate() + if (!api) { + throw new Error('API unavailable') + } + if (reviewFileQuery.isLoading) { + throw new Error('Review file is still loading') + } + const loaded = reviewFileQuery.data + if (loaded && !loaded.success) { + throw new Error(loaded.error) + } + + const current = loaded?.success + ? loaded.reviewFile + : createEmptyReviewFile(workspacePath ?? '') + const next = mutate(current) + const payload: ReviewFile = { + ...next, + workspacePath: next.workspacePath || workspacePath || '', + updatedAt: Date.now() + } + const result = await api.writeSessionFile( + sessionId, + REVIEW_FILE_PATH, + encodeBase64(`${JSON.stringify(payload, null, 2)}\n`), + loaded?.success ? loaded.hash : null + ) if (!result.success) { - throw new Error(result.error ?? 'Failed to update review threads') + throw new Error(result.error ?? 'Failed to update review file') } await invalidateReviewThreads() } catch (error) { - setReviewError(error instanceof Error ? error.message : 'Failed to update review threads') + setReviewError(error instanceof Error ? error.message : 'Failed to update review file') } finally { setReviewSaving(false) } - }, [invalidateReviewThreads]) + }, [api, invalidateReviewThreads, reviewFileQuery.data, reviewFileQuery.isLoading, sessionId, workspacePath]) const handleRefresh = useCallback(async () => { if (!api || isEditing || isSaving || reviewSaving) { @@ -361,13 +486,20 @@ export function FilePreviewPanel(props: { setReviewError(null) await Promise.all([ fileQuery.refetch(), - reviewThreadsQuery.refetch(), + reviewFileQuery.refetch(), ]) - }, [api, fileQuery, isEditing, isSaving, reviewSaving, reviewThreadsQuery]) + }, [api, fileQuery, isEditing, isSaving, reviewFileQuery, reviewSaving]) const isDirty = isEditing && draft !== content - const isRefreshing = (fileQuery.isFetching && !fileQuery.isLoading) || (reviewThreadsQuery.isFetching && !reviewThreadsQuery.isLoading) - const reviewThreads = reviewThreadsQuery.data?.success ? (reviewThreadsQuery.data.threads ?? []) : [] + const isRefreshing = (fileQuery.isFetching && !fileQuery.isLoading) || (reviewFileQuery.isFetching && !reviewFileQuery.isLoading) + const reviewThreads = useMemo( + () => reviewFileQuery.data?.success + ? reviewFileQuery.data.reviewFile.threads + .filter((thread) => thread.filePath === filePath) + .map((thread) => toFileReviewThread(thread, sourceLines)) + : [], + [filePath, reviewFileQuery.data, sourceLines] + ) const lineThreads = useMemo(() => { const map = new Map() for (const thread of reviewThreads) { @@ -406,15 +538,36 @@ export function FilePreviewPanel(props: { if (!api || !body) { return } - await runReviewMutation(() => api.createSessionFileReviewThread(sessionId, { - path: filePath, - line, - body, - author: 'user' - })) + await runReviewMutation((current) => { + const now = Date.now() + const threadId = makeId('thread') + return { + ...current, + threads: [ + ...current.threads, + { + id: threadId, + diffMode: current.reviewContext?.mode ?? 'branch', + filePath, + anchor: { + side: 'right', + line, + preview: sourceLines[Math.max(0, line - 1)] ?? '' + }, + status: 'open', + comments: [{ + id: makeId('comment'), + author: 'user', + createdAt: now, + body + }] + } + ] + } + }) setComposerLine(null) setComposerText('') - }, [api, composerText, filePath, runReviewMutation, sessionId]) + }, [api, composerText, filePath, runReviewMutation, sourceLines]) const toggleCollapsedThread = useCallback((threadId: string) => { setCollapsedResolvedThreadIds((current) => ({ @@ -424,27 +577,43 @@ export function FilePreviewPanel(props: { }, []) const handleResolveThread = useCallback((thread: FileReviewThread) => { - void runReviewMutation(() => api?.setSessionFileReviewThreadStatus( - sessionId, - thread.id, - thread.status === 'resolved' ? 'open' : 'resolved' - ) ?? Promise.resolve({ success: false, error: 'API unavailable' })) - }, [api, runReviewMutation, sessionId]) + void runReviewMutation((current) => ({ + ...current, + threads: current.threads.map((candidate) => candidate.id === thread.id + ? { ...candidate, status: candidate.status === 'resolved' ? 'open' : 'resolved' } + : candidate) + })) + }, [runReviewMutation]) const handleDeleteThread = useCallback((thread: FileReviewThread) => { if (!window.confirm('Delete this review thread permanently?')) { return } - void runReviewMutation(() => api?.deleteSessionFileReviewThread(sessionId, thread.id) - ?? Promise.resolve({ success: false, error: 'API unavailable' })) - }, [api, runReviewMutation, sessionId]) + void runReviewMutation((current) => ({ + ...current, + threads: current.threads.filter((candidate) => candidate.id !== thread.id) + })) + }, [runReviewMutation]) const handleReplyToThread = useCallback((thread: FileReviewThread, body: string) => { - void runReviewMutation(() => api?.replyToSessionFileReviewThread(sessionId, thread.id, { - body, - author: 'user' - }) ?? Promise.resolve({ success: false, error: 'API unavailable' })) - }, [api, runReviewMutation, sessionId]) + void runReviewMutation((current) => ({ + ...current, + threads: current.threads.map((candidate) => candidate.id === thread.id + ? { + ...candidate, + comments: [ + ...candidate.comments, + { + id: makeId('comment'), + author: 'user', + createdAt: Date.now(), + body + } + ] + } + : candidate) + })) + }, [runReviewMutation]) const handleCopyReviewPath = useCallback(() => { void copyReviewPath(REVIEW_FILE_PATH) @@ -602,10 +771,10 @@ export function FilePreviewPanel(props: { {reviewThreads.length} total threads {unresolvedCount} unresolved {reviewSaving ? Saving… : null} - {reviewThreadsQuery.isLoading ? Loading threads… : null} + {reviewFileQuery.isLoading ? Loading threads… : null} {reviewError ? {reviewError} : null} - {reviewThreadsQuery.data && !reviewThreadsQuery.data.success ? ( - {reviewThreadsQuery.data.error ?? 'Failed to load review threads'} + {reviewFileQuery.data && !reviewFileQuery.data.success ? ( + {reviewFileQuery.data.error ?? 'Failed to load review file'} ) : null} ) : null} @@ -675,7 +844,7 @@ export function FilePreviewPanel(props: { codeViewRef={reviewViewRef} filePath={filePath} sourceLines={sourceLines} - reviewSaving={reviewSaving} + reviewSaving={reviewSaving || reviewFileQuery.isLoading} reviewThreads={reviewThreads} lineThreads={lineThreads} orphanedThreads={orphanedThreads} diff --git a/web/src/routes/sessions/terminal.tsx b/web/src/routes/sessions/terminal.tsx index 6a261f4..8d26db5 100644 --- a/web/src/routes/sessions/terminal.tsx +++ b/web/src/routes/sessions/terminal.tsx @@ -2413,6 +2413,7 @@ export default function TerminalPage() { sessionId={loadedSessionId} filePath={previewFilePath} api={api} + workspacePath={session?.metadata?.path ?? null} presentation="sidebar" onClose={() => setPreviewFilePath(null)} /> @@ -2439,6 +2440,7 @@ export default function TerminalPage() { sessionId={loadedSessionId} filePath={previewFilePath} api={api} + workspacePath={session?.metadata?.path ?? null} presentation="overlay" onClose={() => setPreviewFilePath(null)} /> diff --git a/web/src/types/api.ts b/web/src/types/api.ts index f8cdf47..f868465 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -242,7 +242,7 @@ export type FileReadResponse = { export type FileReviewComment = { id: string - author: 'user' | 'agent' + author: string createdAt: number body: string }