Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/desktop/src/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
42 changes: 42 additions & 0 deletions apps/desktop/src/components/ReviewChangeActions.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<button
type="button"
className="review-change-rollback review-navigate"
onClick={review}
>
<IconDiff size={13} />
{t("panel.review.feedback.review")}
</button>
<button
type="button"
className="review-change-rollback review-open-file"
onClick={() => openTarget({ kind: "file", path: change.path })}
>
<IconExternal size={13} />
{t("panel.review.feedback.open")}
</button>
</>
);
}
241 changes: 143 additions & 98 deletions apps/desktop/src/components/ReviewChangeCard.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand All @@ -18,112 +21,137 @@ const STATUS_MARKS: Record<ReviewChangeStatus, string> = {
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<ReviewRollbackStatus | null>(
null,
);
const [rollbackStatus, setRollbackStatus] =
useState<ReviewRollbackStatus | null>(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);
setRollingBack(false);
if (result) setRollbackStatus(result.status);
};

const actions = (
<div className={cx("review-change-card-actions", compact && "is-compact")}>
{rollbackStatus === "conflict" ? (
<div className="review-change-rollback-note is-warning">
{t("panel.review.rollbackConflict")}
</div>
) : rollbackStatus === "unavailable" ? (
<div className="review-change-rollback-note">
{t("panel.review.rollbackUnavailable")}
</div>
) : null}
{!compact ? <ReviewChangeActions change={change} /> : null}
{change.state === "rolledBack" ? (
<span className="review-change-state is-rolled-back">
<IconCheck size={13} />
{t("panel.review.rolledBack")}
</span>
) : change.reversible ? (
<button
type="button"
className="review-change-rollback"
onClick={() => void runRollback()}
disabled={rollingBack}
>
<IconSnapshot size={13} />
{rollingBack
? t("panel.review.rollingBack")
: t("panel.review.rollback")}
</button>
) : (
<span className="review-change-rollback-note">
{t("panel.review.rollbackUnavailable")}
</span>
)}
</div>
);
return (
<div className="review-change-card-body-content">
{change.binary ? (
<div className="review-change-note">{t("panel.review.binary")}</div>
) : change.truncated ? (
<div className="review-change-note">{t("panel.review.tooLarge")}</div>
) : change.hunks.length > 0 ? (
<div className="review-change-diff">
{change.hunks.map((hunk, hunkIndex) => (
<div className="diff-hunk" key={`${hunk.header}-${hunkIndex}`}>
<div className="diff-line hunk">
<span className="diff-line-text">{hunk.header}</span>
</div>
{hunk.lines.map((line, lineIndex) => (
<div className={cx("diff-line", line.type)} key={lineIndex}>
<span className="diff-line-sign" aria-hidden>
{line.type === "add" ? "+" : line.type === "del" ? "−" : " "}
</span>
<span className="diff-line-text">{line.text}</span>
</div>
))}
</div>
))}
</div>
<ReviewFeedbackDiff
change={change}
message={message}
enabled={enableFeedback}
feedback={feedback}
/>
) : (
<div className="review-change-note">{t("panel.review.noLineDetails")}</div>
<div className="review-change-note">
{t("panel.review.noLineDetails")}
</div>
)}

<div className={cx("review-change-card-actions", compact && "is-compact")}>
{rollbackStatus === "conflict" ? (
<div className="review-change-rollback-note is-warning">
{t("panel.review.rollbackConflict")}
</div>
) : rollbackStatus === "unavailable" ? (
<div className="review-change-rollback-note">
{t("panel.review.rollbackUnavailable")}
</div>
) : null}
{change.state === "rolledBack" ? (
<span className="review-change-state is-rolled-back">
<IconCheck size={13} />
{t("panel.review.rolledBack")}
</span>
) : change.reversible ? (
<button
type="button"
className="review-change-rollback"
onClick={() => void runRollback()}
disabled={rollingBack}
>
<IconSnapshot size={13} />
{rollingBack
? t("panel.review.rollingBack")
: t("panel.review.rollback")}
</button>
) : (
<span className="review-change-rollback-note">
{t("panel.review.rollbackUnavailable")}
</span>
)}
</div>
{actions}
</div>
);
}

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<HTMLElement>(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 =
Expand All @@ -133,46 +161,63 @@ export const ReviewChangeCard = memo(function ReviewChangeCard({

return (
<section
className={cx("review-change-card", compact && "is-compact", open && "open")}
ref={cardRef}
className={cx(
"review-change-card",
compact && "is-compact",
open && "open",
)}
data-state={change.state}
data-status={change.status}
>
<button
type="button"
className="review-change-card-header"
aria-expanded={open}
aria-controls={detailsId}
aria-label={accessibleLabel}
title={accessibleLabel}
onClick={() => setOpen((value) => !value)}
>
<span className="review-change-card-caret" aria-hidden>
<IconChevronRight size={11} />
</span>
<span
className={cx("review-change-card-mark", `is-${change.status}`)}
aria-hidden
>
{STATUS_MARKS[change.status]}
</span>
<span className="review-change-card-path" title={change.path}>
{change.path}
</span>
<span
className="review-change-card-counts diff-counts"
aria-label={t("chat.reviewChangeCounts", change)}
<div className="review-change-heading">
<button
type="button"
className="review-change-card-header"
aria-expanded={open}
aria-controls={detailsId}
aria-label={accessibleLabel}
title={accessibleLabel}
onClick={() => setOpen((value) => !value)}
>
{change.additions > 0 && (
<span className="diff-count-add">+{change.additions}</span>
)}
{change.deletions > 0 && (
<span className="diff-count-del">−{change.deletions}</span>
)}
</span>
</button>
<span className="review-change-card-caret" aria-hidden>
<IconChevronRight size={11} />
</span>
<span
className={cx("review-change-card-mark", `is-${change.status}`)}
aria-hidden
>
{STATUS_MARKS[change.status]}
</span>
<span className="review-change-card-path" title={change.path}>
{change.path}
</span>
<span
className="review-change-card-counts diff-counts"
aria-label={t("chat.reviewChangeCounts", change)}
>
{change.additions > 0 && (
<span className="diff-count-add">+{change.additions}</span>
)}
{change.deletions > 0 && (
<span className="diff-count-del">−{change.deletions}</span>
)}
</span>
{feedback ? (
<span className="review-comment-marker">
{t("panel.review.feedback.attachment")}
</span>
) : null}
</button>
</div>
{open ? (
<div className="review-change-card-body" id={detailsId}>
<DiffBody message={message} compact={compact} />
<DiffBody
message={message}
compact={compact}
enableFeedback={enableFeedback}
feedback={feedback}
/>
</div>
) : null}
</section>
Expand Down
Loading
Loading