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 ? ( +
+ +