From 64752a4163f09e8a498d52df1a159c58441d053b Mon Sep 17 00:00:00 2001 From: Jack <2075649045@qq.com> Date: Mon, 21 Sep 2026 19:13:39 +0800 Subject: [PATCH] feat(review): add inline feedback and expanded change actions Keep review comments attached to their historical code while letting users reach review and file preview from an expanded transcript change. Preserve session ownership, send recovery and existing permission guards. Cover navigation and comment lifecycle with interaction tests and synchronize the localized interface and product documentation. --- apps/desktop/src/components/Composer.tsx | 5 +- .../src/components/ReviewChangeActions.tsx | 42 +++ .../src/components/ReviewChangeCard.tsx | 241 +++++++++++------- .../src/components/ReviewFeedbackDiff.tsx | 238 +++++++++++++++++ .../workpanel/ReviewPendingFeedback.tsx | 45 ++++ .../src/components/workpanel/ReviewTab.tsx | 42 ++- .../chat/composer/hooks/useComposerDraft.ts | 17 ++ .../chat/composer/hooks/useComposerSubmit.ts | 15 +- .../chat/composer/review-feedback-drafts.ts | 39 +++ .../src/hooks/useReviewLineSelection.ts | 139 ++++++++++ apps/desktop/src/lib/composer-smart-stop.ts | 3 + apps/desktop/src/lib/review-feedback.ts | 132 ++++++++++ apps/desktop/src/lib/review-navigation.ts | 26 ++ apps/desktop/src/stores/slices/queue-slice.ts | 3 + apps/desktop/src/styles/globals.css | 1 + apps/desktop/src/styles/review-feedback.css | 128 ++++++++++ apps/desktop/test/chat-review-entry.test.mjs | 2 +- .../desktop/test/composer-send-state.test.mjs | 4 +- .../test/fixtures/review-feedback-runner.cjs | 152 +++++++++++ .../desktop/test/fixtures/review-feedback.jsx | 61 +++++ apps/desktop/test/review-feedback.test.mjs | 64 +++++ docs/guide/index.md | 32 +++ docs/spec/04-ux/08-component-spec.md | 51 ++++ docs/spec/06-delivery/04-e2e-test-plan.md | 39 +++ docs/zh-CN/guide/index.md | 19 ++ docs/zh-CN/spec/04-ux/08-component-spec.md | 22 ++ .../spec/06-delivery/04-e2e-test-plan.md | 24 ++ packages/i18n/src/locales/de/index.ts | 17 ++ packages/i18n/src/locales/en/index.ts | 17 ++ packages/i18n/src/locales/es/index.ts | 17 ++ packages/i18n/src/locales/fr/index.ts | 17 ++ packages/i18n/src/locales/ko/index.ts | 17 ++ packages/i18n/src/locales/tr/index.ts | 17 ++ packages/i18n/src/locales/zh-CN/index.ts | 17 ++ packages/i18n/src/locales/zh-TW/index.ts | 17 ++ packages/shared/src/changelog-de.ts | 1 + packages/shared/src/changelog-es.ts | 1 + packages/shared/src/changelog-fr.ts | 1 + packages/shared/src/changelog-ko.ts | 1 + packages/shared/src/changelog-tr.ts | 1 + packages/shared/src/changelog.ts | 3 + scripts/test-review-feedback.mjs | 52 ++++ 42 files changed, 1665 insertions(+), 117 deletions(-) create mode 100644 apps/desktop/src/components/ReviewChangeActions.tsx create mode 100644 apps/desktop/src/components/ReviewFeedbackDiff.tsx create mode 100644 apps/desktop/src/components/workpanel/ReviewPendingFeedback.tsx create mode 100644 apps/desktop/src/features/chat/composer/review-feedback-drafts.ts create mode 100644 apps/desktop/src/hooks/useReviewLineSelection.ts create mode 100644 apps/desktop/src/lib/review-feedback.ts create mode 100644 apps/desktop/src/lib/review-navigation.ts create mode 100644 apps/desktop/src/styles/review-feedback.css create mode 100644 apps/desktop/test/fixtures/review-feedback-runner.cjs create mode 100644 apps/desktop/test/fixtures/review-feedback.jsx create mode 100644 apps/desktop/test/review-feedback.test.mjs create mode 100644 scripts/test-review-feedback.mjs diff --git a/apps/desktop/src/components/Composer.tsx b/apps/desktop/src/components/Composer.tsx index 58631f85c2..b8b2b7f9d6 100644 --- a/apps/desktop/src/components/Composer.tsx +++ b/apps/desktop/src/components/Composer.tsx @@ -63,6 +63,7 @@ import { useComposerModelMenu } from "../features/chat/composer/hooks/useCompose import { ComposerToolbar } from "../features/chat/composer/ComposerToolbar"; import { ComposerStatus } from "../features/chat/composer/ComposerStatus"; + const EMPTY_QUEUED_PROMPTS: QueuedPrompt[] = []; export { @@ -234,7 +235,7 @@ export function Composer({ // captured draft becomes the input, so the input must be empty first: the // live read is the only current source (the draft cache is not per keystroke). const handleEditQueuedPrompt = (id: string) => { - if (readLiveDraft().trim() || activeFileReferences.length) { + if (readLiveDraft().trim() || activeFileReferences.length || draft.reviewFeedback) { showToast(t("chat.editQueuedPromptBusy"), { variant: "info" }); return; } @@ -395,7 +396,7 @@ export function Composer({ !isImageGenerationModel(settings?.imageGeneration, provider.id, modelId) && (provider.hasSecret || provider.authKind === "none"); const enterToSend = settings?.enterToSend ?? true; - const hasDraftContent = Boolean(value.trim() || activeFileReferences.length); + const hasDraftContent = Boolean(value.trim() || activeFileReferences.length || draft.reviewFeedback); useEffect(() => { if (!controlsBlocked) return; diff --git a/apps/desktop/src/components/ReviewChangeActions.tsx b/apps/desktop/src/components/ReviewChangeActions.tsx new file mode 100644 index 0000000000..f1012b803c --- /dev/null +++ b/apps/desktop/src/components/ReviewChangeActions.tsx @@ -0,0 +1,42 @@ +import { useTranslation } from "react-i18next"; +import type { ReviewChange } from "@pi-desktop/shared"; +import { useAppStore } from "../stores/app-store"; +import { toolWorkPanelTab } from "../lib/work-panel-tabs"; +import { focusReviewChange } from "../lib/review-navigation"; +import { useOpenPreviewTarget } from "../hooks/use-preview-target"; +import { IconDiff, IconExternal } from "./icons"; + +export function ReviewChangeActions({ change }: { change: ReviewChange }) { + const { t } = useTranslation(); + const openTarget = useOpenPreviewTarget(); + const review = () => { + const state = useAppStore.getState(); + if (!state.activeSessionId || !state.workspace?.path) return; + focusReviewChange( + state.activeSessionId, + state.workspace.path, + change.snapshotId, + ); + state.openWorkPanelTab(toolWorkPanelTab("review")); + }; + return ( + <> + + + + ); +} diff --git a/apps/desktop/src/components/ReviewChangeCard.tsx b/apps/desktop/src/components/ReviewChangeCard.tsx index 68432e26bd..e5f502b326 100644 --- a/apps/desktop/src/components/ReviewChangeCard.tsx +++ b/apps/desktop/src/components/ReviewChangeCard.tsx @@ -1,4 +1,4 @@ -import { memo, useId, useState } from "react"; +import { memo, useEffect, useId, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import type { ReviewChangeStatus, @@ -8,6 +8,9 @@ import type { import { reviewChangeFromMessage } from "../lib/workspace-review"; import { useAppStore } from "../stores/app-store"; import { cx } from "./ui"; +import type { ReviewFeedback } from "../lib/review-feedback"; +import { ReviewChangeActions } from "./ReviewChangeActions"; +import { ReviewFeedbackDiff } from "./ReviewFeedbackDiff"; import { IconCheck, IconChevronRight, IconSnapshot } from "./icons"; /* Git-status letters carry the status without relying on color alone; the @@ -18,19 +21,29 @@ const STATUS_MARKS: Record = { deleted: "D", }; -function DiffBody({ message, compact }: { message: UiMessage; compact: boolean }) { +function DiffBody({ + message, + compact, + enableFeedback, + feedback, +}: { + message: UiMessage; + compact: boolean; + enableFeedback: boolean; + feedback?: ReviewFeedback; +}) { const { t } = useTranslation(); const change = reviewChangeFromMessage(message); const rollback = useAppStore((state) => state.rollbackWorkspaceChange); const [rollingBack, setRollingBack] = useState(false); - const [rollbackStatus, setRollbackStatus] = useState( - null, - ); + const [rollbackStatus, setRollbackStatus] = + useState(null); if (!change) return null; const runRollback = async () => { - if (!change.reversible || change.state === "rolledBack" || rollingBack) return; + if (!change.reversible || change.state === "rolledBack" || rollingBack) + return; setRollingBack(true); setRollbackStatus(null); const result = await rollback(message.id, change.snapshotId); @@ -38,6 +51,42 @@ function DiffBody({ message, compact }: { message: UiMessage; compact: boolean } if (result) setRollbackStatus(result.status); }; + const actions = ( +
+ {rollbackStatus === "conflict" ? ( +
+ {t("panel.review.rollbackConflict")} +
+ ) : rollbackStatus === "unavailable" ? ( +
+ {t("panel.review.rollbackUnavailable")} +
+ ) : null} + {!compact ? : null} + {change.state === "rolledBack" ? ( + + + {t("panel.review.rolledBack")} + + ) : change.reversible ? ( + + ) : ( + + {t("panel.review.rollbackUnavailable")} + + )} +
+ ); return (
{change.binary ? ( @@ -45,60 +94,19 @@ function DiffBody({ message, compact }: { message: UiMessage; compact: boolean } ) : change.truncated ? (
{t("panel.review.tooLarge")}
) : change.hunks.length > 0 ? ( -
- {change.hunks.map((hunk, hunkIndex) => ( -
-
- {hunk.header} -
- {hunk.lines.map((line, lineIndex) => ( -
- - {line.type === "add" ? "+" : line.type === "del" ? "−" : " "} - - {line.text} -
- ))} -
- ))} -
+ ) : ( -
{t("panel.review.noLineDetails")}
+
+ {t("panel.review.noLineDetails")} +
)} -
- {rollbackStatus === "conflict" ? ( -
- {t("panel.review.rollbackConflict")} -
- ) : rollbackStatus === "unavailable" ? ( -
- {t("panel.review.rollbackUnavailable")} -
- ) : null} - {change.state === "rolledBack" ? ( - - - {t("panel.review.rolledBack")} - - ) : change.reversible ? ( - - ) : ( - - {t("panel.review.rollbackUnavailable")} - - )} -
+ {actions}
); } @@ -106,24 +114,44 @@ function DiffBody({ message, compact }: { message: UiMessage; compact: boolean } export const ReviewChangeCard = memo(function ReviewChangeCard({ message, compact = false, + enableFeedback = false, + feedback, + revealRequest, }: { message: UiMessage; compact?: boolean; + enableFeedback?: boolean; + feedback?: ReviewFeedback; + revealRequest?: number; }) { const { t } = useTranslation(); const change = reviewChangeFromMessage(message); const detailsId = useId(); - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(Boolean(feedback || revealRequest)); + const cardRef = useRef(null); + useEffect(() => { + if (revealRequest) setOpen(true); + }, [revealRequest]); + useEffect(() => { + if (!revealRequest || !open) return; + const frame = requestAnimationFrame(() => + cardRef.current?.scrollIntoView({ block: "nearest" }), + ); + return () => cancelAnimationFrame(frame); + }, [revealRequest, open]); if (!change) return null; const statusLabel = t(`panel.review.status.${change.status}`); - const baseLabel = t(open ? "chat.reviewChangeHide" : "chat.reviewChangeShow", { - status: statusLabel, - path: change.path, - additions: change.additions, - deletions: change.deletions, - }); + const baseLabel = t( + open ? "chat.reviewChangeHide" : "chat.reviewChangeShow", + { + status: statusLabel, + path: change.path, + additions: change.additions, + deletions: change.deletions, + }, + ); // The collapsed row shows a rolled-back change struck through, so the state // has to reach the accessible name too. const accessibleLabel = @@ -133,46 +161,63 @@ export const ReviewChangeCard = memo(function ReviewChangeCard({ return (
- + + + + + {STATUS_MARKS[change.status]} + + + {change.path} + + + {change.additions > 0 && ( + +{change.additions} + )} + {change.deletions > 0 && ( + −{change.deletions} + )} + + {feedback ? ( + + {t("panel.review.feedback.attachment")} + + ) : null} + + {open ? (
- +
) : null}
diff --git a/apps/desktop/src/components/ReviewFeedbackDiff.tsx b/apps/desktop/src/components/ReviewFeedbackDiff.tsx new file mode 100644 index 0000000000..131065a2b1 --- /dev/null +++ b/apps/desktop/src/components/ReviewFeedbackDiff.tsx @@ -0,0 +1,238 @@ +import { Fragment, useEffect, useId, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { ReviewChange, UiMessage } from "@pi-desktop/shared"; +import { useAppStore } from "../stores/app-store"; +import { + createReviewFeedback, + reviewFeedbackAnchor, + type ReviewFeedback, + MAX_REVIEW_COMMENT, + MAX_REVIEW_SELECTION, + reviewFeedbackLines, +} from "../lib/review-feedback"; +import { ReviewPendingFeedback } from "./workpanel/ReviewPendingFeedback"; +import { stageReviewFeedback } from "../features/chat/composer/review-feedback-drafts"; + +import { useReviewLineSelection } from "../hooks/useReviewLineSelection"; +import { IconPlus } from "./icons"; + +/** Selection and comment editing stay local; only an explicit Add stages a draft. */ +export function ReviewFeedbackDiff({ + change, + message, + enabled, + feedback, +}: { + change: ReviewChange; + message: UiMessage; + enabled: boolean; + feedback?: ReviewFeedback; +}) { + const { t } = useTranslation(); + const sessionId = useAppStore((state) => state.activeSessionId); + const workspacePath = useAppStore((state) => state.workspace?.path ?? ""); + // Remount the editor on ownership changes, even when a tool id is reused. + return ( + + ); +} + +function FeedbackEditor({ + change, + message, + sessionId, + workspacePath, + t, + enabled, + feedback, +}: { + change: ReviewChange; + message: UiMessage; + sessionId: string | null | undefined; + workspacePath: string; + enabled: boolean; + feedback?: ReviewFeedback; + t: ReturnType["t"]; +}) { + const lineSelection = useReviewLineSelection(); + const { selection, dragging } = lineSelection; + const textareaRef = useRef(null); + useEffect(() => { + if (selection && !dragging) + textareaRef.current?.focus({ preventScroll: true }); + }, [selection, dragging]); + const [comment, setComment] = useState(""); + const [error, setError] = useState(""); + const inputId = useId(); + const anchor = enabled ? reviewFeedbackAnchor(change, feedback) : null; + const selectedLines = selection + ? reviewFeedbackLines(change.hunks[selection.hunk]).slice( + Math.min(selection.anchor, selection.end), + Math.max(selection.anchor, selection.end) + 1, + ) + : []; + const oversized = + selectedLines.map((line) => line.text).join("\n").length > + MAX_REVIEW_SELECTION; + const add = () => { + const state = useAppStore.getState(); + if ( + !selection || + state.activeSessionId !== sessionId || + state.workspace?.path !== workspacePath || + !state.messages.some((row) => row.id === message.id) + ) + return; + const feedback = createReviewFeedback( + change, + sessionId ?? "", + workspacePath, + selection.hunk, + selection.anchor, + selection.end, + comment, + ); + if (!feedback) return; + if (!stageReviewFeedback(feedback)) { + setError(t("panel.review.feedback.pending")); + return; + } + lineSelection.clear(); + setComment(""); + state.showToast(t("panel.review.feedback.added"), { variant: "info" }); + }; + const form = + selection && !dragging ? ( +
+ +